From bf6989eccfd22f1d2e2aa1c738fa91f28a38092b Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 2 Sep 2026 05:56:47 +0800 Subject: [PATCH] refactor(chat): extract profile onboarding and rectification surface hooks Close the home-page split by moving account/onboarding and rectification glue out of page.tsx without relocating React state, keeping the route static and hook order intact. Co-authored-by: Cursor --- PROGRESS-home-split-batch3-20260901.md | 92 +++ frontend/src/app/page.tsx | 584 ++---------------- frontend/src/hooks/use-profile-onboarding.ts | 424 +++++++++++++ .../src/hooks/use-rectification-surface.ts | 306 +++++++++ .../birth-time-assessment-loading.test.ts | 4 +- .../birth-time-guided-review-fixes.test.ts | 3 +- .../chat-navigation-a11y-contract.test.ts | 2 +- frontend/tests/home-surface.ts | 2 + frontend/tests/profile-persistence.test.ts | 6 +- frontend/tests/profile-state.test.ts | 4 +- .../tests/rectification-agentic-entry.test.ts | 2 +- tests/test_birth_time_journey_contract.py | 2 + ...est_daily_and_rectification_entrypoints.py | 12 +- tests/test_session_management_entrypoints.py | 2 + tests/test_supabase_user_data_contract.py | 2 + 15 files changed, 920 insertions(+), 527 deletions(-) create mode 100644 PROGRESS-home-split-batch3-20260901.md create mode 100644 frontend/src/hooks/use-profile-onboarding.ts create mode 100644 frontend/src/hooks/use-rectification-surface.ts diff --git a/PROGRESS-home-split-batch3-20260901.md b/PROGRESS-home-split-batch3-20260901.md new file mode 100644 index 00000000..76d4b643 --- /dev/null +++ b/PROGRESS-home-split-batch3-20260901.md @@ -0,0 +1,92 @@ +# PROGRESS · 拆分首页巨石组件·第三批:onboarding/profile 与校正胶水(2026-09-01) + +工作树:`.worktrees/home-split-batch3-20260901` +分支:`codex/home-split-batch3-20260901` +基线:`origin/staging` @ `80e77361`(含报告语气 PR #48 与出生精度 PR #49)。`551d6317` 之后 **没有** 未验收的 `page.tsx` 改动:`git log 551d6317..origin/staging -- frontend/src/app/page.tsx` 为空。 + +不要写成 `PROGRESS.md`。本轮未提交、未推送。 + +React Compiler 仍关。未引入 context/store。无手写 `useCallback`/`useMemo`。自定义 hook 内部 **0** 个 `useState` / `useRef` / `useEffect`。`site-styles` import 仍在 `page.tsx` 顶。`const BirthTimeRectification = dynamic(...)` 未动。 + +脏树未碰:`yinduzhanxing` @ `codex/minute-rectification-p0`、`.worktrees/fix-general-daily-20260815` 仍有本地 `page.tsx` 残留。 + +## 度量 + +| 项 | 拆前(第二批后 2445 / staging 同文件) | 拆后 | 说明 | +| --- | ---: | ---: | --- | +| `frontend/src/app/page.tsx` 行数 | 2445 | 1997 | 目标 ≤2000,达标 | +| `useState(` Home / profile hook / rectification hook | 26 / — / — | 26 / 0 / 0 | 独占 state 未下移 | +| `useRef(` Home / profile / rectification | 19 / — / — | 19 / 0 / 0 | 同上 | +| `useEffect(` Home / profile / rectification | 20 / — / — | 20 / 0 / 0 | 拉取/恢复 effect 仍在 Home | +| `/` 路由 | `○ Static` | `○ Static` | webpack `next build --webpack` | +| 首屏 JS gzip -9 | 第二批 501104 B | 503980 B = 492.2 KB | +2876 B / **+0.57%**(±2% 内) | +| 前端测试 | staging 高于 2428 | **2460** | fail=0 skipped=0 | + +gzip 相对第二批的 +0.57% 含 `80e77361` 已合入的语气/精度前端,不单是本次搬家。同 SHA 干净树未再打一份对照包。 + +本批按任务书收官:starter 三函数、合盘起草、composer 小函数仍留在 `Home`。 + +## Hook 调用顺序(红线 1) + +`useRouter` → `useState`×26 → `useRef`×19 → `useBirthTimeGuidedJourney` → `useSessionManagement` → `useProfileOnboarding` → `useRectificationSurface` → `useEffect`×8 → `useConversationScrollAnchor` → `useConsultationRun` → 其余 `useEffect` + +新增的两个 hook **内部零 React hook**。`useState`/`useRef`/`useEffect` 在 Home 里的相对次序与搬前相同。 + +| 调用点 | 位置 | 原因 | +| --- | --- | --- | +| `useSessionManagement` | `useBirthTimeGuidedJourney` 之后 | 第二批已定;`openRectificationSession` 经模块级 holder 转发给尚未声明的校正 hook | +| `useProfileOnboarding` | session hook 之后、第一条 `useEffect` 之前 | 账户/引导函数要给校正 hook 与咨询 hook | +| `useRectificationSurface` | profile hook 之后、第一条 `useEffect` 之前 | `persistSession` / `updateSession` / `refreshAccount` / `openAccountDialog` 已齐 | + +依赖方向:session → Home → profile / rectification / consult。四个 hook 文件 **不得、也没有** 互相 import。 + +跨声明顺序的回调用 **模块级 `{ current }` 对象**,不是新的 `useRef`(避免改变 Home hook 数量): + +- `rectificationSessionOpener`:session hook 的 `openRectificationSession` 转发到校正 hook +- `guidedBirthTimeReady` / `editDeclaredBirthTimeDetailsHolder`:`useBirthTimeGuidedJourney` 早于 profile hook,原函数声明提升不再可用 + +`setDraft` 等仍是 `Home` 内 function declaration(JS 提升),可出现在 hook 参数里。 + +## 任务 1–2 · 搬家 + +函数体相对 `80e77361:frontend/src/app/page.tsx` 去缩进后逐行 **IDENTICAL**(22/22): + +| 文件 | 行 | 内容 | +| --- | ---: | ---: | +| `frontend/src/hooks/use-profile-onboarding.ts` | 424 | `refreshAccount` … `signOut` | +| `frontend/src/hooks/use-rectification-surface.ts` | 306 | `refreshRectificationEntrySummary` … `handleRectificationMessagesChange`(含 `resumeRectificationSession.current =`) | + +公证抽样(全部 22 个,不少于 4):`refreshAccount` 19、`persistProfile` 41、`saveOnboardingName` 22、`openRectificationCase` 105、以及其余 18 个均为 IDENTICAL。改名清单:**零**。 + +仍留在 Home:`chooseSuggestedQuestion` / `startSuggestedConsultation` / `startDailyStarlanguageConsultation`、`draftSynastryQuestionFromChart` 及之后、`copyAssistantMessage` / `handleComposerKeyDown`。`send`/`submit` 已在第二批进咨询 hook。 + +## 合同测试路径(同一 token,只换读取面) + +拼接件扩容:可选 `use-profile-onboarding.ts` / `use-rectification-surface.ts`。 + +| 测试 | 原路径 | 新路径 | +| --- | --- | --- | +| `profile-state.test.ts` `refreshAccount` 切片 | 已是 `homeSurface` | 仍 `homeSurface`(函数已进 profile hook;去掉未用的 `readFileSync`) | +| `profile-persistence.test.ts` `persistProfile` | 已是 `homeSurface` | 仍 `homeSurface` | +| `birth-time-assessment-loading.test.ts` `saveOnboardingBirth`/`Place` | `page.tsx` + overlay | `homeSurface` + overlay | +| `birth-time-guided-review-fixes.test.ts` 终端 CJK | `homeSurface` | 仍 `homeSurface` | +| `chat-navigation-a11y` sign-out 切片 | `homeSurface`,终点 `chooseSuggestedQuestion`(仍在 page,拼在 hook **之前**) | 终点改为 profile hook 的 `return { refreshAccount`(断言内容未改) | +| `rectification-agentic-entry` `handleRectificationProfileIncomplete` | 终点 `draftSynastryQuestionFromChart`(仍在 page) | 终点改为仍相邻的 `handleRectificationMessagesChange` | +| `tests/test_daily_and_rectification_entrypoints.py` `openRectificationFromHomepage` | `PAGE`+starter+cloud | 若存在则拼接 rectification hook | +| 三个 Python `_home_surface()` | 可选 session/consult hook | 再列入两个新 hook | + +## 验证 + +- `./node_modules/.bin/tsc --noEmit`:通过。 +- 全量 `npm test`:**2460 pass / 0 fail / 0 skipped**。 +- Python:`test_session_management_entrypoints.py`、`test_supabase_user_data_contract.py`、`test_birth_time_journey_contract.py`、`test_daily_and_rectification_entrypoints.py` 全绿。 +- `./node_modules/.bin/next build --webpack`:`┌ ○ /` Static。首屏 JS gzip-9 503980 B vs 第二批 501104 B(+0.57%)。 +- 行为抽查:无登录态浏览器点选会话 / 发送咨询。搬家由源码合同锁住。 + +未改 `.gitea/workflows/**`。未动数据库。未提升 `main`。未重开 React Compiler。未提交。 + +## 明确未做 + +- 合盘起草与 composer 小函数(任务书本批不动)。 +- 未把 onboarding/profile/rectification **state** 下移进 hook。 +- 引擎运行时治理在独立 worktree `engine-runtime-hygiene-20260901`,不碰 `page.tsx`。 diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 1b0271d0..ce1de7a5 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -20,9 +20,6 @@ import { BirthTimeIntakeFields } from "@/components/birth-time-intake"; import { AppLoadingIndicator } from "@/components/app-loading-indicator"; import { entrySummaryFromResponse, - isTerminalRectificationStatus, - openRectificationRequestBody, - openResponseFromPayload, rectificationEntryLabels, resolveRectificationEntryAction, type RectificationEntrySummary, @@ -40,15 +37,12 @@ import { ComposerCharacterRemaining } from "@/components/composer-character-rema import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft"; import { clearStaleClientReload } from "@/lib/stale-client-recovery"; -import { parseAgentReply, isGenericSessionTitle, resolveSessionTitle } from "@/lib/agent-reply"; import { BirthLocationFields } from "@/components/birth-location-fields"; import { OnboardingChatMessage } from "@/components/onboarding-chat-message"; import { ChartLibraryPanel } from "@/components/chart-library-panel"; import { StarterHome } from "@/components/starter-home"; import { beamAvatarPalettes, - beamAvatarSchema, - type BeamAvatarPatch, } from "@/lib/beam-avatar"; import { GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION, @@ -58,15 +52,12 @@ import { } from "@/lib/consultation-entrypoint"; import { applyBirthTimeDraftPatch, - applyPersistedBirthTime, assistantIntentCopy, - birthTimePersistenceValues, hydrateDeclaredWindowDraft, isBirthTimeDraftReady, birthTimeDraftReadyHint, } from "@/lib/birth-time-intake-model"; import { - birthTimeConsultationOptionsCopy, clearBirthTimeConsultationConsent, createLatestAccountRequestGuard, createBirthTimeConsultationConsentState, @@ -78,11 +69,12 @@ import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mo 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 { useProfileOnboarding } from "@/hooks/use-profile-onboarding"; +import { useRectificationSurface } from "@/hooks/use-rectification-surface"; 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 { - requestBirthTimeAssessment, type JourneyClientResponse, } from "@/lib/birth-time-journey-client"; import { @@ -158,7 +150,6 @@ import { import { type PublicLanguageModelCatalog, } from "@/lib/public-models"; -import { selfHostedOtpActions } from "@/modules/identity/client"; import { accountDialogClasses, accountDialogTitles, @@ -189,9 +180,7 @@ import { type Theme, } from "@/lib/home-types"; import { - birthProfileDeclarationChanged, birthQuestion, - buildSelfChartRecord, buildSynastryQuestion, chartSnapshotForSession, completedOnboardingMessage, @@ -233,7 +222,6 @@ import { readStoredDailyStarlanguage, readStoredPendingConsultation, redirectToLogin, - saveCloudChartProfile, saveCloudSynastryReport, waitForUndoWindow, writeStoredDailyStarlanguage, @@ -247,6 +235,16 @@ const BirthTimeRectification = dynamic( }, ); +const rectificationSessionOpener = { + current: async (_exactSessionId: string) => {}, +}; +const guidedBirthTimeReady = { + current: (_result: JourneyClientResponse) => {}, +}; +const editDeclaredBirthTimeDetailsHolder = { + current: () => {}, +}; + export default function Home() { const router = useRouter(); const [profile, setProfile] = useState(emptyProfile); @@ -365,8 +363,8 @@ export default function Home() { journey: birthTimeJourney, preview: process.env.NODE_ENV === "development" && uiPreview.current, onJourney: setBirthTimeJourney, - onReady: completeGuidedBirthTime, - onEditBirthTimeDetails: editDeclaredBirthTimeDetails, + onReady: (result) => guidedBirthTimeReady.current(result), + onEditBirthTimeDetails: () => editDeclaredBirthTimeDetailsHolder.current(), }); const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0]; @@ -437,44 +435,62 @@ export default function Home() { 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, + 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: (exactSessionId) => rectificationSessionOpener.current(exactSessionId), }); + const { + refreshAccount, + openAccountDialog, + closeAccountDialog, + persistAvatar, + saveProfile, + saveOnboardingName, + saveOnboardingBirth, + editDeclaredBirthTimeDetails, + saveOnboardingPlace, + completeGuidedBirthTime, + retryBirthTimeAssessment, + signOut, + } = useProfileOnboarding({ + account, accountRefreshGuard, accountTrigger, activeChartId, avatarSaving, + birthTimeRevisionPending, chartLibrary, dialogReturnTarget, profile, profileDraft, + profileSaving, setAccount, setAccountError, setAccountMenuOpen, setActiveAccountDialog, + setActiveChartId, setAvatarNotice, setAvatarSaving, setBirthTimeAssessmentPhase, + setBirthTimeConsultationConsent, setBirthTimeError, setBirthTimeJourney, setDraft, + setEditingSelfChart, setOnboardingJustCompleted, setOnboardingStep, setPresetMessageLength, + setProfile, setProfileDraft, setProfileNotice, setProfileSaving, setRectificationError, + setSigningOut, setStartGreeting, signingOut, uiPreview, + }); + guidedBirthTimeReady.current = completeGuidedBirthTime; + editDeclaredBirthTimeDetailsHolder.current = editDeclaredBirthTimeDetails; + + const { + refreshRectificationCase, + openRectificationFromHomepage, + openRectificationSession, + startNewRectification, + handleRectificationProfileIncomplete, + handleRectificationMessagesChange, + } = useRectificationSurface({ + account, activeChartId, activeSessionIdRef, chartLibrary, creatingSession, modelCatalog, + persistSession, profile, rectificationLoading, rectificationMutationPending, + rectificationOpenInFlight, rectificationSessionId, resumeRectificationSession, + sessionSelectionSource, sessions, setActiveSessionId, setDraft, setDraftEntrypoint, + setDraftTheme, setOnboardingStep, setProfileNotice, setRectificationCaseId, + setRectificationEntrySummary, setRectificationError, setRectificationLoading, + setRectificationPendingQuestion, setRectificationReadonly, setRectificationSessionId, + setRectificationShouldStartOpening, setRectificationTurns, setSessions, uiPreview, + updateSession, openAccountDialog, refreshAccount, + }); + rectificationSessionOpener.current = openRectificationSession; + useEffect(() => { activeSessionIdRef.current = activeSessionId; }, [activeSessionId]); @@ -1304,282 +1320,6 @@ export default function Home() { }; }, [accountId, hydrated]); - async function refreshAccount() { - const requestIdentity = accountRefreshGuard.current.begin(); - try { - const latest = await fetchAccount(); - if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return; - const nextProfile = readProfile(latest.profile); - const activeOther = activeChartId !== "self" - && chartLibrary.find((record) => record.id === activeChartId && record.role === "other"); - if (!activeOther) { - setProfile((current) => preserveShallowEqual(current, nextProfile)); - } - setAccount(latest); - setAccountError(""); - } catch (caught) { - if (caught instanceof LoginRedirectError) return; - if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return; - setAccountError(caught instanceof Error ? caught.message : "暂时无法读取账户信息"); - } - } - - - function openAccountDialog(dialog: AccountDialog, returnTarget: HTMLButtonElement | null = accountTrigger.current) { - dialogReturnTarget.current = returnTarget ?? accountTrigger.current; - setAccountMenuOpen(false); - setAccountError(""); - if (dialog === "profile") { - setProfileNotice(""); - setAvatarNotice(""); - } - if (dialog === "chart-library") { - setProfileDraft(profile); - setProfileNotice(""); - setEditingSelfChart(false); - } - setActiveAccountDialog(dialog); - } - - function closeAccountDialog() { - if (signingOut) return; - setActiveAccountDialog(null); - const returnTarget = dialogReturnTarget.current; - window.requestAnimationFrame(() => returnTarget?.focus()); - } - - async function persistAvatar(patch: BeamAvatarPatch) { - if (!account?.avatar || avatarSaving) return; - setAvatarSaving(true); - setAvatarNotice(""); - setAccountError(""); - try { - const response = await fetch("/api/account/avatar", { - method: "PATCH", - credentials: "same-origin", - headers: { "content-type": "application/json" }, - body: JSON.stringify(patch), - }); - const payload = await response.json().catch(() => null) as { avatar?: unknown; error?: string } | null; - if (!response.ok) throw new Error(payload?.error || "头像暂时无法保存。"); - const avatar = beamAvatarSchema.parse(payload?.avatar); - setAccount((current) => current ? { ...current, avatar } : current); - setAvatarNotice(patch.action === "randomize" ? "已生成并保存新头像。" : "头像配色已保存。"); - } catch (caught) { - setAccountError(friendlyError(caught instanceof Error ? caught.message : "头像保存失败")); - } finally { - setAvatarSaving(false); - } - } - - async function persistProfile(nextProfile: Profile): Promise { - if (!account) throw new Error("账户尚未加载完成"); - if (process.env.NODE_ENV === "development" && uiPreview.current) return nextProfile; - const birthPlace = selectedBirthPlace(nextProfile); - const response = await fetch("/api/account", { - method: "PATCH", - credentials: "same-origin", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: nextProfile.name.trim() || null, - birth_date: nextProfile.date || null, - ...birthTimePersistenceValues(nextProfile), - country_code: nextProfile.countryCode, - province_code: nextProfile.provinceCode || null, - city_code: nextProfile.cityCode || null, - district_code: nextProfile.districtCode || null, - birth_place_label: nextProfile.birthPlaceLabel || null, - birth_place_type: nextProfile.birthPlaceType || null, - birth_place_provider: nextProfile.birthPlaceProvider || null, - birth_place_provider_id: nextProfile.birthPlaceProviderId || null, - latitude: birthPlace?.lat ?? null, - longitude: birthPlace?.lon ?? null, - timezone_id: nextProfile.timezoneId || null, - timezone_offset: birthPlace?.tz ?? null, - timezone_source: nextProfile.timezoneSource || null, - }), - }); - const payload = await response.json().catch(() => null) as { - error?: string; - birthTime?: unknown; - } | null; - if (!response.ok) { - throw new Error(payload?.error || "账户资料暂时无法保存。"); - } - const savedProfile = applyPersistedBirthTime(nextProfile, payload?.birthTime); - setAccount((current) => current ? { ...current, profile: savedProfile } : current); - setActiveChartId("self"); - localStorage.setItem(activeChartStorageKey(account.user.id), "self"); - await saveCloudChartProfile({ ...buildSelfChartRecord(savedProfile), updatedAt: timestamp() }).catch(() => null); - return savedProfile; - } - - - async function assessSavedBirthTime(nextProfile: Profile) { - const result = process.env.NODE_ENV === "development" && uiPreview.current - ? previewRectificationJourney - : await requestBirthTimeAssessment(); - const nextStatus = result.snapshot.state === "ready" - ? "confirmed" - : result.snapshot.state === "candidate" - ? "candidate" - : "rectifying"; - const assessedProfile: Profile = { - ...nextProfile, - time: result.snapshot.activeTime ?? "", - birthTimeStatus: nextStatus, - rectificationCaseId: result.caseId, - }; - setBirthTimeJourney(result); - setBirthTimeError(""); - setProfile(assessedProfile); - setProfileDraft(assessedProfile); - return assessedProfile; - } - - async function saveProfile(event: FormEvent) { - event.preventDefault(); - if (!profileDraft.name.trim() || !isBirthTimeDraftReady(profileDraft) || !selectedBirthPlace(profileDraft) || !account || profileSaving) return; - setProfileSaving(true); - setProfileNotice(""); - setAccountError(""); - try { - const declarationChanged = birthProfileDeclarationChanged(profile, profileDraft); - const savedProfile = await persistProfile(profileDraft); - setProfile(savedProfile); - setProfileDraft(savedProfile); - setRectificationError(""); - if (declarationChanged) { - setBirthTimeConsultationConsent(createBirthTimeConsultationConsentState()); - void refreshAccount(); - } - setProfileNotice(savedProfile.birthTimeStatus === "confirmed" - ? "出生资料已保存到云端,可在同一账号的其他设备使用。" - : `出生资料已保存。${birthTimeConsultationOptionsCopy(savedProfile)}`); - } catch (caught) { - setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生资料保存失败")); - } finally { - setProfileSaving(false); - } - } - - async function saveOnboardingName() { - const name = composerDraftSnapshot().replace(/\s+/g, " ").trim().slice(0, 80); - if (!name || !account || profileSaving) return; - const nextProfile = { ...profileDraft, name }; - setProfileSaving(true); - setAccountError(""); - try { - const savedProfile = await persistProfile(nextProfile); - setProfile(savedProfile); - setProfileDraft(savedProfile); - setStartGreeting(createStartGreeting(savedProfile.name)); - setDraft(""); - setPresetMessageLength(0); - const nextStep = missingProfileStep(savedProfile); - if (nextStep) setOnboardingStep(nextStep); - else setOnboardingJustCompleted(false); - } catch (caught) { - setAccountError(friendlyError(caught instanceof Error ? caught.message : "称呼保存失败")); - } finally { - setProfileSaving(false); - } - } - - async function saveOnboardingBirth(event: FormEvent) { - event.preventDefault(); - if (!isBirthTimeDraftReady(profileDraft) || !account || profileSaving) return; - setProfileSaving(true); - setBirthTimeAssessmentPhase("saving_profile"); - setAccountError(""); - try { - const savedProfile = await persistProfile(profileDraft); - birthTimeRevisionPending.current = false; - setProfile(savedProfile); - setProfileDraft(savedProfile); - setPresetMessageLength(0); - const nextStep = missingProfileStep(savedProfile); - if (nextStep) setOnboardingStep(nextStep); - else setOnboardingJustCompleted(false); - } catch (caught) { - setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生时间保存失败")); - } finally { - setBirthTimeAssessmentPhase(null); - setProfileSaving(false); - } - } - - function editDeclaredBirthTimeDetails() { - birthTimeRevisionPending.current = true; - setBirthTimeError(""); - setPresetMessageLength(0); - setOnboardingStep("birth"); - } - - async function saveOnboardingPlace(event: FormEvent) { - event.preventDefault(); - if (!selectedBirthPlace(profileDraft) || !account || profileSaving) return; - setProfileSaving(true); - setBirthTimeAssessmentPhase("entering_home"); - setAccountError(""); - try { - const savedProfile = await persistProfile(profileDraft); - setProfile(savedProfile); - setProfileDraft(savedProfile); - setStartGreeting(createStartGreeting(savedProfile.name)); - setPresetMessageLength(0); - setOnboardingJustCompleted(false); - } catch (caught) { - setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生地点保存失败")); - } finally { - setBirthTimeAssessmentPhase(null); - setProfileSaving(false); - } - } - - function completeGuidedBirthTime(result: JourneyClientResponse) { - if (result.nextAction.kind !== "ready") return; - const confirmedProfile: Profile = { - ...profileDraft, - time: result.nextAction.activeTime, - birthTimeStatus: "confirmed", - rectificationCaseId: result.caseId, - }; - setProfile(confirmedProfile); - setProfileDraft(confirmedProfile); - setPresetMessageLength(0); - setOnboardingJustCompleted(false); - } - - async function retryBirthTimeAssessment() { - if (!account || profileSaving) return; - setProfileSaving(true); - setBirthTimeError(""); - try { - const assessedProfile = await assessSavedBirthTime(profileDraft); - setPresetMessageLength(0); - if (assessedProfile.birthTimeStatus === "confirmed") setOnboardingJustCompleted(false); - } catch (caught) { - setBirthTimeError(caught instanceof Error ? caught.message : "生时评估暂时不可用,请稍后重试。"); - } finally { - setProfileSaving(false); - } - } - - async function signOut() { - if (signingOut) return; - setSigningOut(true); - setAccountError(""); - try { - await selfHostedOtpActions.signOut(); - window.location.assign("/login"); - } catch (caught) { - const message = caught instanceof Error ? caught.message : "退出失败"; - setAccountError(friendlyError(message)); - setSigningOut(false); - } - } - function chooseSuggestedQuestion( question: string, theme?: Theme, @@ -1631,194 +1371,6 @@ export default function Home() { ); } - async function refreshRectificationEntrySummary() { - if (!account) return; - try { - const response = await fetch("/api/rectification/cases/entry-summary", { cache: "no-store" }); - if (!response.ok) return; - const payload = await response.json().catch(() => null); - setRectificationEntrySummary(entrySummaryFromResponse(payload)); - } catch { - // The CTA falls back to the server-agnostic default labels. - } - } - - async function refreshRectificationCase(caseId: string, sessionId: string) { - try { - const response = await fetch( - `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, - { cache: "no-store" }, - ); - if (!response.ok) return; - const payload = await response.json().catch(() => null); - const turns = Array.isArray(payload?.turns) ? payload.turns : []; - setRectificationTurns(turns.map((turn: { id?: unknown; role?: unknown; text?: unknown; status?: unknown; receipt?: unknown }) => ({ - id: String(turn?.id ?? ""), - role: turn?.role === "user" ? "user" as const : "assistant" as const, - text: typeof turn?.text === "string" ? turn.text : null, - status: String(turn?.status ?? "completed"), - receipt: turn?.receipt && typeof turn.receipt === "object" ? { - status: String((turn.receipt as { status?: unknown }).status ?? ""), - phases: Array.isArray((turn.receipt as { phases?: unknown }).phases) ? (turn.receipt as { phases: unknown[] }).phases.map(String) : [], - tools: Array.isArray((turn.receipt as { tools?: unknown }).tools) ? (turn.receipt as { tools: unknown[] }).tools.map(String) : [], - methods: Array.isArray((turn.receipt as { methods?: unknown }).methods) ? (turn.receipt as { methods: unknown[] }).methods.map(String) : [], - skill_name: typeof (turn.receipt as { skill_name?: unknown }).skill_name === "string" ? (turn.receipt as { skill_name: string }).skill_name : undefined, - skill_version: typeof (turn.receipt as { skill_version?: unknown }).skill_version === "string" ? (turn.receipt as { skill_version: string }).skill_version : undefined, - } : null, - }))); - } catch { - // History refresh is best-effort; the stream restores live turns. - } - } - - async function openRectificationCase( - intent: "homepage" | "session" | "new", - exactSessionId: string | null, - pendingConsultationQuestion: string | null, - ) { - if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current - || rectificationMutationPending) { - sessionSelectionSource.current = "user"; - return null; - } - const missingStep = missingProfileStep(profile); - if (missingStep) { - sessionSelectionSource.current = "user"; - setRectificationSessionId(null); - setRectificationCaseId(null); - setRectificationPendingQuestion(null); - setOnboardingStep(missingStep); - setComposerNotice("请先完成出生资料,再开始生时校正。"); - return null; - } - - rectificationOpenInFlight.current = true; - setRectificationLoading(true); - setRectificationError(""); - - try { - const response = await fetch("/api/rectification/cases/open", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(openRectificationRequestBody(intent, exactSessionId)), - }); - const payload = await response.json().catch(() => null); - if (!response.ok) { - const code = payload?.code; - if (code === "profile_incomplete") { - handleRectificationProfileIncomplete(); - } else if (code === "invalid_open_request") { - setRectificationError(payload?.error || "生时校正打开请求不正确,请刷新后重试。"); - } else if (code === "active_case_conflict") { - setRectificationError(payload?.error || "仍有未完成的校正,请先回到当前校正。"); - } else if (code === "case_session_not_found") { - setRectificationError("该校正会话不存在或已结束。"); - } else { - setRectificationError(payload?.error || "暂时无法打开生时校正。"); - } - return null; - } - - const opened = openResponseFromPayload(payload); - if (!opened) { - setRectificationError("生时校正服务响应不正确,请稍后重试。"); - return null; - } - - // Merge the server-created session into the local list. The browser - // never generates a Case id; it only mirrors the returned binding. - const existing = sessions.find((session) => session.id === opened.sessionId); - const merged: ChatSession = { - id: opened.sessionId, - title: resolveSessionTitle("生时校正", undefined, { - entrypoint: "birth_time_rectification", - existingTitles: sessions.map((session) => session.title), - }), - theme: "general", - modelId: modelCatalog.defaultModelId ?? "", - messages: [], - updatedAt: timestamp(), - sessionType: "birth_time_rectification", - rectificationCaseId: opened.caseId, - pinned: existing?.pinned ?? false, - archivedAt: existing?.archivedAt ?? null, - messagesHydrated: true, - ...chartSnapshotForSession(activeChartId, chartLibrary, profile), - }; - setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]); - void persistSession(merged).catch(() => {}); - - setRectificationPendingQuestion(pendingConsultationQuestion); - setDraft(""); - setDraftTheme(null); - setDraftEntrypoint(null); - setRectificationSessionId(opened.sessionId); - setRectificationCaseId(opened.caseId); - setRectificationShouldStartOpening(opened.shouldStartOpening); - setRectificationReadonly( - opened.disposition === "readonly" || isTerminalRectificationStatus(opened.status), - ); - setRectificationTurns([]); - activeSessionIdRef.current = opened.sessionId; - setActiveSessionId(opened.sessionId); - if (!uiPreview.current && sessionSelectionSource.current === "user") { - writeSessionUrl(opened.sessionId, "push"); - } - void refreshRectificationCase(opened.caseId, opened.sessionId); - void refreshRectificationEntrySummary(); - return opened; - } catch { - setRectificationError("生时校正会话暂时无法打开,请稍后重试。"); - return null; - } finally { - sessionSelectionSource.current = "user"; - rectificationOpenInFlight.current = false; - setRectificationLoading(false); - } - } - - async function openRectificationFromHomepage(pendingConsultationQuestion: string | null = null) { - await openRectificationCase("homepage", null, pendingConsultationQuestion); - } - - async function openRectificationSession(exactSessionId: string) { - await openRectificationCase("session", exactSessionId, null); - } - - async function startNewRectification() { - await openRectificationCase("new", null, null); - } - - resumeRectificationSession.current = (session) => { - sessionSelectionSource.current = "history"; - void openRectificationSession(session.id); - }; - - function handleRectificationProfileIncomplete() { - setRectificationError("profile_incomplete"); - setRectificationSessionId(null); - setRectificationCaseId(null); - setRectificationPendingQuestion(null); - const missingStep = missingProfileStep(profile); - if (missingStep) { - setOnboardingStep(missingStep); - setComposerNotice("请先完成出生资料,再开始生时校正。"); - return; - } - openAccountDialog("profile"); - setProfileNotice("服务端未能读取完整出生资料,请重新确认并保存。"); - void refreshAccount(); - } - - function handleRectificationMessagesChange(messages: Message[]) { - if (!rectificationSessionId) return; - updateSession(rectificationSessionId, (session) => ({ - ...session, - messages, - updatedAt: timestamp(), - })); - } - async function draftSynastryQuestionFromChart(record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) { if (record.role !== "other") return; if (synastryPendingId) return; diff --git a/frontend/src/hooks/use-profile-onboarding.ts b/frontend/src/hooks/use-profile-onboarding.ts new file mode 100644 index 00000000..b63edaba --- /dev/null +++ b/frontend/src/hooks/use-profile-onboarding.ts @@ -0,0 +1,424 @@ +"use client"; + +import type { Dispatch, FormEvent, MutableRefObject, SetStateAction } from "react"; + +import { + beamAvatarSchema, + type BeamAvatarPatch, +} from "@/lib/beam-avatar"; +import { + applyPersistedBirthTime, + birthTimePersistenceValues, + isBirthTimeDraftReady, +} from "@/lib/birth-time-intake-model"; +import { + birthTimeConsultationOptionsCopy, + createBirthTimeConsultationConsentState, + type BirthTimeConsultationConsentState, + type LatestAccountRequestGuard, +} from "@/lib/birth-time-consultation-consent"; +import { composerDraftSnapshot } from "@/lib/composer-draft"; +import { + activeChartStorageKey, + fetchAccount, + friendlyError, + LoginRedirectError, + saveCloudChartProfile, +} from "@/lib/home-cloud-sync"; +import { + birthProfileDeclarationChanged, + buildSelfChartRecord, + missingProfileStep, + readProfile, + selectedBirthPlace, +} from "@/lib/home-profile"; +import { createStartGreeting } from "@/lib/onboarding-client"; +import { + timestamp, + type Account, + type AccountDialog, + type ChartLibraryRecord, + type OnboardingStep, + type Profile, +} from "@/lib/home-types"; +import { preserveShallowEqual } from "@/lib/preserve-shallow-equal"; +import { + requestBirthTimeAssessment, + type JourneyClientResponse, +} from "@/lib/birth-time-journey-client"; +import { previewRectificationJourney } from "@/lib/birth-time-guided-preview"; +import { selfHostedOtpActions } from "@/modules/identity/client"; +import type { BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay"; + +export type ProfileOnboardingParams = { + account: Account | null; + accountRefreshGuard: MutableRefObject; + accountTrigger: MutableRefObject; + activeChartId: string; + avatarSaving: boolean; + birthTimeRevisionPending: MutableRefObject; + chartLibrary: ChartLibraryRecord[]; + dialogReturnTarget: MutableRefObject; + profile: Profile; + profileDraft: Profile; + profileSaving: boolean; + setAccount: Dispatch>; + setAccountError: Dispatch>; + setAccountMenuOpen: Dispatch>; + setActiveAccountDialog: Dispatch>; + setActiveChartId: Dispatch>; + setAvatarNotice: Dispatch>; + setAvatarSaving: Dispatch>; + setBirthTimeAssessmentPhase: Dispatch>; + setBirthTimeConsultationConsent: Dispatch>; + setBirthTimeError: Dispatch>; + setBirthTimeJourney: Dispatch>; + setDraft: (value: string) => void; + setEditingSelfChart: Dispatch>; + setOnboardingJustCompleted: Dispatch>; + setOnboardingStep: Dispatch>; + setPresetMessageLength: Dispatch>; + setProfile: Dispatch>; + setProfileDraft: Dispatch>; + setProfileNotice: Dispatch>; + setProfileSaving: Dispatch>; + setRectificationError: Dispatch>; + setSigningOut: Dispatch>; + setStartGreeting: Dispatch>; + signingOut: boolean; + uiPreview: MutableRefObject; +}; + +export function useProfileOnboarding(params: ProfileOnboardingParams) { + const { + account, + accountRefreshGuard, + accountTrigger, + activeChartId, + avatarSaving, + birthTimeRevisionPending, + chartLibrary, + dialogReturnTarget, + profile, + profileDraft, + profileSaving, + setAccount, + setAccountError, + setAccountMenuOpen, + setActiveAccountDialog, + setActiveChartId, + setAvatarNotice, + setAvatarSaving, + setBirthTimeAssessmentPhase, + setBirthTimeConsultationConsent, + setBirthTimeError, + setBirthTimeJourney, + setDraft, + setEditingSelfChart, + setOnboardingJustCompleted, + setOnboardingStep, + setPresetMessageLength, + setProfile, + setProfileDraft, + setProfileNotice, + setProfileSaving, + setRectificationError, + setSigningOut, + setStartGreeting, + signingOut, + uiPreview, + } = params; + + async function refreshAccount() { + const requestIdentity = accountRefreshGuard.current.begin(); + try { + const latest = await fetchAccount(); + if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return; + const nextProfile = readProfile(latest.profile); + const activeOther = activeChartId !== "self" + && chartLibrary.find((record) => record.id === activeChartId && record.role === "other"); + if (!activeOther) { + setProfile((current) => preserveShallowEqual(current, nextProfile)); + } + setAccount(latest); + setAccountError(""); + } catch (caught) { + if (caught instanceof LoginRedirectError) return; + if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return; + setAccountError(caught instanceof Error ? caught.message : "暂时无法读取账户信息"); + } + } + + + function openAccountDialog(dialog: AccountDialog, returnTarget: HTMLButtonElement | null = accountTrigger.current) { + dialogReturnTarget.current = returnTarget ?? accountTrigger.current; + setAccountMenuOpen(false); + setAccountError(""); + if (dialog === "profile") { + setProfileNotice(""); + setAvatarNotice(""); + } + if (dialog === "chart-library") { + setProfileDraft(profile); + setProfileNotice(""); + setEditingSelfChart(false); + } + setActiveAccountDialog(dialog); + } + + function closeAccountDialog() { + if (signingOut) return; + setActiveAccountDialog(null); + const returnTarget = dialogReturnTarget.current; + window.requestAnimationFrame(() => returnTarget?.focus()); + } + + async function persistAvatar(patch: BeamAvatarPatch) { + if (!account?.avatar || avatarSaving) return; + setAvatarSaving(true); + setAvatarNotice(""); + setAccountError(""); + try { + const response = await fetch("/api/account/avatar", { + method: "PATCH", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify(patch), + }); + const payload = await response.json().catch(() => null) as { avatar?: unknown; error?: string } | null; + if (!response.ok) throw new Error(payload?.error || "头像暂时无法保存。"); + const avatar = beamAvatarSchema.parse(payload?.avatar); + setAccount((current) => current ? { ...current, avatar } : current); + setAvatarNotice(patch.action === "randomize" ? "已生成并保存新头像。" : "头像配色已保存。"); + } catch (caught) { + setAccountError(friendlyError(caught instanceof Error ? caught.message : "头像保存失败")); + } finally { + setAvatarSaving(false); + } + } + + async function persistProfile(nextProfile: Profile): Promise { + if (!account) throw new Error("账户尚未加载完成"); + if (process.env.NODE_ENV === "development" && uiPreview.current) return nextProfile; + const birthPlace = selectedBirthPlace(nextProfile); + const response = await fetch("/api/account", { + method: "PATCH", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: nextProfile.name.trim() || null, + birth_date: nextProfile.date || null, + ...birthTimePersistenceValues(nextProfile), + country_code: nextProfile.countryCode, + province_code: nextProfile.provinceCode || null, + city_code: nextProfile.cityCode || null, + district_code: nextProfile.districtCode || null, + birth_place_label: nextProfile.birthPlaceLabel || null, + birth_place_type: nextProfile.birthPlaceType || null, + birth_place_provider: nextProfile.birthPlaceProvider || null, + birth_place_provider_id: nextProfile.birthPlaceProviderId || null, + latitude: birthPlace?.lat ?? null, + longitude: birthPlace?.lon ?? null, + timezone_id: nextProfile.timezoneId || null, + timezone_offset: birthPlace?.tz ?? null, + timezone_source: nextProfile.timezoneSource || null, + }), + }); + const payload = await response.json().catch(() => null) as { + error?: string; + birthTime?: unknown; + } | null; + if (!response.ok) { + throw new Error(payload?.error || "账户资料暂时无法保存。"); + } + const savedProfile = applyPersistedBirthTime(nextProfile, payload?.birthTime); + setAccount((current) => current ? { ...current, profile: savedProfile } : current); + setActiveChartId("self"); + localStorage.setItem(activeChartStorageKey(account.user.id), "self"); + await saveCloudChartProfile({ ...buildSelfChartRecord(savedProfile), updatedAt: timestamp() }).catch(() => null); + return savedProfile; + } + + + async function assessSavedBirthTime(nextProfile: Profile) { + const result = process.env.NODE_ENV === "development" && uiPreview.current + ? previewRectificationJourney + : await requestBirthTimeAssessment(); + const nextStatus = result.snapshot.state === "ready" + ? "confirmed" + : result.snapshot.state === "candidate" + ? "candidate" + : "rectifying"; + const assessedProfile: Profile = { + ...nextProfile, + time: result.snapshot.activeTime ?? "", + birthTimeStatus: nextStatus, + rectificationCaseId: result.caseId, + }; + setBirthTimeJourney(result); + setBirthTimeError(""); + setProfile(assessedProfile); + setProfileDraft(assessedProfile); + return assessedProfile; + } + + async function saveProfile(event: FormEvent) { + event.preventDefault(); + if (!profileDraft.name.trim() || !isBirthTimeDraftReady(profileDraft) || !selectedBirthPlace(profileDraft) || !account || profileSaving) return; + setProfileSaving(true); + setProfileNotice(""); + setAccountError(""); + try { + const declarationChanged = birthProfileDeclarationChanged(profile, profileDraft); + const savedProfile = await persistProfile(profileDraft); + setProfile(savedProfile); + setProfileDraft(savedProfile); + setRectificationError(""); + if (declarationChanged) { + setBirthTimeConsultationConsent(createBirthTimeConsultationConsentState()); + void refreshAccount(); + } + setProfileNotice(savedProfile.birthTimeStatus === "confirmed" + ? "出生资料已保存到云端,可在同一账号的其他设备使用。" + : `出生资料已保存。${birthTimeConsultationOptionsCopy(savedProfile)}`); + } catch (caught) { + setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生资料保存失败")); + } finally { + setProfileSaving(false); + } + } + + async function saveOnboardingName() { + const name = composerDraftSnapshot().replace(/\s+/g, " ").trim().slice(0, 80); + if (!name || !account || profileSaving) return; + const nextProfile = { ...profileDraft, name }; + setProfileSaving(true); + setAccountError(""); + try { + const savedProfile = await persistProfile(nextProfile); + setProfile(savedProfile); + setProfileDraft(savedProfile); + setStartGreeting(createStartGreeting(savedProfile.name)); + setDraft(""); + setPresetMessageLength(0); + const nextStep = missingProfileStep(savedProfile); + if (nextStep) setOnboardingStep(nextStep); + else setOnboardingJustCompleted(false); + } catch (caught) { + setAccountError(friendlyError(caught instanceof Error ? caught.message : "称呼保存失败")); + } finally { + setProfileSaving(false); + } + } + + async function saveOnboardingBirth(event: FormEvent) { + event.preventDefault(); + if (!isBirthTimeDraftReady(profileDraft) || !account || profileSaving) return; + setProfileSaving(true); + setBirthTimeAssessmentPhase("saving_profile"); + setAccountError(""); + try { + const savedProfile = await persistProfile(profileDraft); + birthTimeRevisionPending.current = false; + setProfile(savedProfile); + setProfileDraft(savedProfile); + setPresetMessageLength(0); + const nextStep = missingProfileStep(savedProfile); + if (nextStep) setOnboardingStep(nextStep); + else setOnboardingJustCompleted(false); + } catch (caught) { + setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生时间保存失败")); + } finally { + setBirthTimeAssessmentPhase(null); + setProfileSaving(false); + } + } + + function editDeclaredBirthTimeDetails() { + birthTimeRevisionPending.current = true; + setBirthTimeError(""); + setPresetMessageLength(0); + setOnboardingStep("birth"); + } + + async function saveOnboardingPlace(event: FormEvent) { + event.preventDefault(); + if (!selectedBirthPlace(profileDraft) || !account || profileSaving) return; + setProfileSaving(true); + setBirthTimeAssessmentPhase("entering_home"); + setAccountError(""); + try { + const savedProfile = await persistProfile(profileDraft); + setProfile(savedProfile); + setProfileDraft(savedProfile); + setStartGreeting(createStartGreeting(savedProfile.name)); + setPresetMessageLength(0); + setOnboardingJustCompleted(false); + } catch (caught) { + setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生地点保存失败")); + } finally { + setBirthTimeAssessmentPhase(null); + setProfileSaving(false); + } + } + + function completeGuidedBirthTime(result: JourneyClientResponse) { + if (result.nextAction.kind !== "ready") return; + const confirmedProfile: Profile = { + ...profileDraft, + time: result.nextAction.activeTime, + birthTimeStatus: "confirmed", + rectificationCaseId: result.caseId, + }; + setProfile(confirmedProfile); + setProfileDraft(confirmedProfile); + setPresetMessageLength(0); + setOnboardingJustCompleted(false); + } + + async function retryBirthTimeAssessment() { + if (!account || profileSaving) return; + setProfileSaving(true); + setBirthTimeError(""); + try { + const assessedProfile = await assessSavedBirthTime(profileDraft); + setPresetMessageLength(0); + if (assessedProfile.birthTimeStatus === "confirmed") setOnboardingJustCompleted(false); + } catch (caught) { + setBirthTimeError(caught instanceof Error ? caught.message : "生时评估暂时不可用,请稍后重试。"); + } finally { + setProfileSaving(false); + } + } + + async function signOut() { + if (signingOut) return; + setSigningOut(true); + setAccountError(""); + try { + await selfHostedOtpActions.signOut(); + window.location.assign("/login"); + } catch (caught) { + const message = caught instanceof Error ? caught.message : "退出失败"; + setAccountError(friendlyError(message)); + setSigningOut(false); + } + } + + return { + refreshAccount, + openAccountDialog, + closeAccountDialog, + persistAvatar, + persistProfile, + assessSavedBirthTime, + saveProfile, + saveOnboardingName, + saveOnboardingBirth, + editDeclaredBirthTimeDetails, + saveOnboardingPlace, + completeGuidedBirthTime, + retryBirthTimeAssessment, + signOut, + }; +} diff --git a/frontend/src/hooks/use-rectification-surface.ts b/frontend/src/hooks/use-rectification-surface.ts new file mode 100644 index 00000000..ad90be02 --- /dev/null +++ b/frontend/src/hooks/use-rectification-surface.ts @@ -0,0 +1,306 @@ +"use client"; + +import type { Dispatch, MutableRefObject, SetStateAction } from "react"; + +import { resolveSessionTitle } from "@/lib/agent-reply"; +import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; +import { writeSessionUrl } from "@/lib/chat-session-url"; +import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint"; +import { chartSnapshotForSession, missingProfileStep } from "@/lib/home-profile"; +import { + timestamp, + type Account, + type AccountDialog, + type ChartLibraryRecord, + type ChatSession, + type Message, + type OnboardingStep, + type Profile, + type Theme, +} from "@/lib/home-types"; +import { + entrySummaryFromResponse, + isTerminalRectificationStatus, + openRectificationRequestBody, + openResponseFromPayload, + type RectificationEntrySummary, +} from "@/lib/rectification-entry"; +import type { PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification"; +import type { PublicLanguageModelCatalog } from "@/lib/public-models"; + +export type RectificationSurfaceParams = { + account: Account | null; + activeChartId: string; + activeSessionIdRef: MutableRefObject; + chartLibrary: ChartLibraryRecord[]; + creatingSession: boolean; + modelCatalog: PublicLanguageModelCatalog | null; + persistSession: (session: ChatSession, mode?: "create" | "update") => Promise; + profile: Profile; + rectificationLoading: boolean; + rectificationMutationPending: boolean; + rectificationOpenInFlight: MutableRefObject; + rectificationSessionId: string | null; + resumeRectificationSession: MutableRefObject<(session: ChatSession) => void>; + sessionSelectionSource: MutableRefObject<"user" | "history">; + sessions: ChatSession[]; + setActiveSessionId: Dispatch>; + setDraft: (value: string) => void; + setDraftEntrypoint: (entrypoint: ConsultationEntrypoint | null) => void; + setDraftTheme: (theme: Theme | null) => void; + setOnboardingStep: Dispatch>; + setProfileNotice: Dispatch>; + setRectificationCaseId: Dispatch>; + setRectificationEntrySummary: Dispatch>; + setRectificationError: Dispatch>; + setRectificationLoading: Dispatch>; + setRectificationPendingQuestion: Dispatch>; + setRectificationReadonly: Dispatch>; + setRectificationSessionId: Dispatch>; + setRectificationShouldStartOpening: Dispatch>; + setRectificationTurns: Dispatch>; + setSessions: Dispatch>; + uiPreview: MutableRefObject; + updateSession: (sessionId: string, change: (session: ChatSession) => ChatSession) => void; + openAccountDialog: (dialog: AccountDialog, returnTarget?: HTMLButtonElement | null) => void; + refreshAccount: () => Promise; +}; + +export function useRectificationSurface(params: RectificationSurfaceParams) { + const { + account, + activeChartId, + activeSessionIdRef, + chartLibrary, + creatingSession, + modelCatalog, + persistSession, + profile, + rectificationLoading, + rectificationMutationPending, + rectificationOpenInFlight, + rectificationSessionId, + resumeRectificationSession, + sessionSelectionSource, + sessions, + setActiveSessionId, + setDraft, + setDraftEntrypoint, + setDraftTheme, + setOnboardingStep, + setProfileNotice, + setRectificationCaseId, + setRectificationEntrySummary, + setRectificationError, + setRectificationLoading, + setRectificationPendingQuestion, + setRectificationReadonly, + setRectificationSessionId, + setRectificationShouldStartOpening, + setRectificationTurns, + setSessions, + uiPreview, + updateSession, + openAccountDialog, + refreshAccount, + } = params; + + async function refreshRectificationEntrySummary() { + if (!account) return; + try { + const response = await fetch("/api/rectification/cases/entry-summary", { cache: "no-store" }); + if (!response.ok) return; + const payload = await response.json().catch(() => null); + setRectificationEntrySummary(entrySummaryFromResponse(payload)); + } catch { + // The CTA falls back to the server-agnostic default labels. + } + } + + async function refreshRectificationCase(caseId: string, sessionId: string) { + try { + const response = await fetch( + `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, + { cache: "no-store" }, + ); + if (!response.ok) return; + const payload = await response.json().catch(() => null); + const turns = Array.isArray(payload?.turns) ? payload.turns : []; + setRectificationTurns(turns.map((turn: { id?: unknown; role?: unknown; text?: unknown; status?: unknown; receipt?: unknown }) => ({ + id: String(turn?.id ?? ""), + role: turn?.role === "user" ? "user" as const : "assistant" as const, + text: typeof turn?.text === "string" ? turn.text : null, + status: String(turn?.status ?? "completed"), + receipt: turn?.receipt && typeof turn.receipt === "object" ? { + status: String((turn.receipt as { status?: unknown }).status ?? ""), + phases: Array.isArray((turn.receipt as { phases?: unknown }).phases) ? (turn.receipt as { phases: unknown[] }).phases.map(String) : [], + tools: Array.isArray((turn.receipt as { tools?: unknown }).tools) ? (turn.receipt as { tools: unknown[] }).tools.map(String) : [], + methods: Array.isArray((turn.receipt as { methods?: unknown }).methods) ? (turn.receipt as { methods: unknown[] }).methods.map(String) : [], + skill_name: typeof (turn.receipt as { skill_name?: unknown }).skill_name === "string" ? (turn.receipt as { skill_name: string }).skill_name : undefined, + skill_version: typeof (turn.receipt as { skill_version?: unknown }).skill_version === "string" ? (turn.receipt as { skill_version: string }).skill_version : undefined, + } : null, + }))); + } catch { + // History refresh is best-effort; the stream restores live turns. + } + } + + async function openRectificationCase( + intent: "homepage" | "session" | "new", + exactSessionId: string | null, + pendingConsultationQuestion: string | null, + ) { + if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current + || rectificationMutationPending) { + sessionSelectionSource.current = "user"; + return null; + } + const missingStep = missingProfileStep(profile); + if (missingStep) { + sessionSelectionSource.current = "user"; + setRectificationSessionId(null); + setRectificationCaseId(null); + setRectificationPendingQuestion(null); + setOnboardingStep(missingStep); + setComposerNotice("请先完成出生资料,再开始生时校正。"); + return null; + } + + rectificationOpenInFlight.current = true; + setRectificationLoading(true); + setRectificationError(""); + + try { + const response = await fetch("/api/rectification/cases/open", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(openRectificationRequestBody(intent, exactSessionId)), + }); + const payload = await response.json().catch(() => null); + if (!response.ok) { + const code = payload?.code; + if (code === "profile_incomplete") { + handleRectificationProfileIncomplete(); + } else if (code === "invalid_open_request") { + setRectificationError(payload?.error || "生时校正打开请求不正确,请刷新后重试。"); + } else if (code === "active_case_conflict") { + setRectificationError(payload?.error || "仍有未完成的校正,请先回到当前校正。"); + } else if (code === "case_session_not_found") { + setRectificationError("该校正会话不存在或已结束。"); + } else { + setRectificationError(payload?.error || "暂时无法打开生时校正。"); + } + return null; + } + + const opened = openResponseFromPayload(payload); + if (!opened) { + setRectificationError("生时校正服务响应不正确,请稍后重试。"); + return null; + } + + // Merge the server-created session into the local list. The browser + // never generates a Case id; it only mirrors the returned binding. + const existing = sessions.find((session) => session.id === opened.sessionId); + const merged: ChatSession = { + id: opened.sessionId, + title: resolveSessionTitle("生时校正", undefined, { + entrypoint: "birth_time_rectification", + existingTitles: sessions.map((session) => session.title), + }), + theme: "general", + modelId: modelCatalog.defaultModelId ?? "", + messages: [], + updatedAt: timestamp(), + sessionType: "birth_time_rectification", + rectificationCaseId: opened.caseId, + pinned: existing?.pinned ?? false, + archivedAt: existing?.archivedAt ?? null, + messagesHydrated: true, + ...chartSnapshotForSession(activeChartId, chartLibrary, profile), + }; + setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]); + void persistSession(merged).catch(() => {}); + + setRectificationPendingQuestion(pendingConsultationQuestion); + setDraft(""); + setDraftTheme(null); + setDraftEntrypoint(null); + setRectificationSessionId(opened.sessionId); + setRectificationCaseId(opened.caseId); + setRectificationShouldStartOpening(opened.shouldStartOpening); + setRectificationReadonly( + opened.disposition === "readonly" || isTerminalRectificationStatus(opened.status), + ); + setRectificationTurns([]); + activeSessionIdRef.current = opened.sessionId; + setActiveSessionId(opened.sessionId); + if (!uiPreview.current && sessionSelectionSource.current === "user") { + writeSessionUrl(opened.sessionId, "push"); + } + void refreshRectificationCase(opened.caseId, opened.sessionId); + void refreshRectificationEntrySummary(); + return opened; + } catch { + setRectificationError("生时校正会话暂时无法打开,请稍后重试。"); + return null; + } finally { + sessionSelectionSource.current = "user"; + rectificationOpenInFlight.current = false; + setRectificationLoading(false); + } + } + + async function openRectificationFromHomepage(pendingConsultationQuestion: string | null = null) { + await openRectificationCase("homepage", null, pendingConsultationQuestion); + } + + async function openRectificationSession(exactSessionId: string) { + await openRectificationCase("session", exactSessionId, null); + } + + async function startNewRectification() { + await openRectificationCase("new", null, null); + } + + resumeRectificationSession.current = (session) => { + sessionSelectionSource.current = "history"; + void openRectificationSession(session.id); + }; + + function handleRectificationProfileIncomplete() { + setRectificationError("profile_incomplete"); + setRectificationSessionId(null); + setRectificationCaseId(null); + setRectificationPendingQuestion(null); + const missingStep = missingProfileStep(profile); + if (missingStep) { + setOnboardingStep(missingStep); + setComposerNotice("请先完成出生资料,再开始生时校正。"); + return; + } + openAccountDialog("profile"); + setProfileNotice("服务端未能读取完整出生资料,请重新确认并保存。"); + void refreshAccount(); + } + + function handleRectificationMessagesChange(messages: Message[]) { + if (!rectificationSessionId) return; + updateSession(rectificationSessionId, (session) => ({ + ...session, + messages, + updatedAt: timestamp(), + })); + } + + return { + refreshRectificationEntrySummary, + refreshRectificationCase, + openRectificationCase, + openRectificationFromHomepage, + openRectificationSession, + startNewRectification, + handleRectificationProfileIncomplete, + handleRectificationMessagesChange, + }; +} diff --git a/frontend/tests/birth-time-assessment-loading.test.ts b/frontend/tests/birth-time-assessment-loading.test.ts index c10d9966..5280cfd8 100644 --- a/frontend/tests/birth-time-assessment-loading.test.ts +++ b/frontend/tests/birth-time-assessment-loading.test.ts @@ -2,10 +2,12 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { homeSurface } from "./home-surface.ts"; + test("onboarding keeps the current card visible while birth-time assessment is pending", () => { // Given: saving the selected time can span profile persistence and assessment requests. const source = [ - readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"), + homeSurface, readFileSync(new URL("../src/components/birth-time-assessment-overlay.tsx", import.meta.url), "utf8"), ].join("\n"); diff --git a/frontend/tests/birth-time-guided-review-fixes.test.ts b/frontend/tests/birth-time-guided-review-fixes.test.ts index d253bd1e..cfbb7423 100644 --- a/frontend/tests/birth-time-guided-review-fixes.test.ts +++ b/frontend/tests/birth-time-guided-review-fixes.test.ts @@ -11,6 +11,7 @@ import { guidedTerminalPath } from "../src/lib/birth-time-guided-terminal.ts"; import { parseJourneyResponse } from "../src/lib/birth-time-journey-client.ts"; import { dynamicBirthTimePreview } from "../src/lib/birth-time-dynamic-preview.ts"; import { guidedBirthTimePreview } from "../src/lib/birth-time-guided-preview.ts"; +import { homeSurface } from "./home-surface.ts"; test("draft revision publishes its new version before confirmation can fail", async () => { const original = guidedBirthTimePreview("birth-time-rectification-draft"); @@ -147,7 +148,7 @@ test("unconfirmed terminal candidates preserve the range without offering direct test("terminal CJK copy stays intact while homepage candidates remain unconfirmed in v3", () => { const candidateResultSource = readFileSync(new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), "utf8"); - const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const pageSource = homeSurface; assert.match(candidateResultSource, /候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。补充经历后可重新评估。/); assert.match(pageSource, /`出生资料已保存。\$\{birthTimeConsultationOptionsCopy\(savedProfile\)\}`/); diff --git a/frontend/tests/chat-navigation-a11y-contract.test.ts b/frontend/tests/chat-navigation-a11y-contract.test.ts index b20a5bf1..59f930d8 100644 --- a/frontend/tests/chat-navigation-a11y-contract.test.ts +++ b/frontend/tests/chat-navigation-a11y-contract.test.ts @@ -69,7 +69,7 @@ test("auth redirects stay hard document loads so stale session state cannot surv assert.doesNotMatch(pageSource, /router\.replace\("\/login"\)/); // And: the login redirect after sign-out drops the whole React tree with the session. - const signOut = sourceBetween(pageSource, "async function signOut()", "function chooseSuggestedQuestion"); + const signOut = sourceBetween(pageSource, "async function signOut()", "return {\n refreshAccount,"); assert.match(signOut, /await selfHostedOtpActions\.signOut\(\);\n\s*window\.location\.assign\("\/login"\)/); // And: the sibling membership surface keeps the same precedent. diff --git a/frontend/tests/home-surface.ts b/frontend/tests/home-surface.ts index d7b08166..35f7dca6 100644 --- a/frontend/tests/home-surface.ts +++ b/frontend/tests/home-surface.ts @@ -15,6 +15,8 @@ const homeSurfaceFiles = [ const optionalHomeHookFiles = [ "../src/hooks/use-session-management.ts", "../src/hooks/use-consultation-run.ts", + "../src/hooks/use-profile-onboarding.ts", + "../src/hooks/use-rectification-surface.ts", ] as const; export const homeSurface = [ diff --git a/frontend/tests/profile-persistence.test.ts b/frontend/tests/profile-persistence.test.ts index 0a45e321..dca0bea5 100644 --- a/frontend/tests/profile-persistence.test.ts +++ b/frontend/tests/profile-persistence.test.ts @@ -2,14 +2,16 @@ import assert from "node:assert/strict"; import { existsSync, readFileSync } from "node:fs"; import test from "node:test"; +import { homeSurface } from "./home-surface.ts"; + test("upserts a missing profile when saving account details", () => { - const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const source = homeSurface; assert.match(source, /fetch\("\/api\/account",[\s\S]*?\{[\s\S]*?method:\s*"PATCH"/); assert.match(source, /credentials:\s*"same-origin"/); }); test("every profile save adopts the birth-time truth the account write returned", () => { - const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const source = homeSurface; // Given: the account write derives birth-time status and active minute. assert.match(source, /const savedProfile = applyPersistedBirthTime\(nextProfile, payload\?\.birthTime\)/); diff --git a/frontend/tests/profile-state.test.ts b/frontend/tests/profile-state.test.ts index cde3e4f3..ca2246e2 100644 --- a/frontend/tests/profile-state.test.ts +++ b/frontend/tests/profile-state.test.ts @@ -1,9 +1,7 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; import test from "node:test"; import { preserveShallowEqual } from "../src/lib/preserve-shallow-equal.ts"; - -const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +import { homeSurface as pageSource } from "./home-surface.ts"; test("preserves the current profile reference when refreshed values are unchanged", () => { const current = { diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 667fa878..9ee673bf 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -128,7 +128,7 @@ test("server profile failures pause automatic rectification resume", () => { ); const incompleteHandler = page.slice( page.indexOf("function handleRectificationProfileIncomplete"), - page.indexOf("async function draftSynastryQuestionFromChart"), + page.indexOf("function handleRectificationMessagesChange"), ); assert.match(resumeEffect, /\|\| rectificationError\) return/); assert.match(incompleteHandler, /setRectificationError\("profile_incomplete"\)/); diff --git a/tests/test_birth_time_journey_contract.py b/tests/test_birth_time_journey_contract.py index 5dc01b7b..494f1f7c 100644 --- a/tests/test_birth_time_journey_contract.py +++ b/tests/test_birth_time_journey_contract.py @@ -139,6 +139,8 @@ def _home_surface() -> str: FRONTEND / "src" / "components" / "starter-home.tsx", FRONTEND / "src" / "hooks" / "use-session-management.ts", FRONTEND / "src" / "hooks" / "use-consultation-run.ts", + FRONTEND / "src" / "hooks" / "use-profile-onboarding.ts", + FRONTEND / "src" / "hooks" / "use-rectification-surface.ts", ] return "".join(path.read_text(encoding="utf-8") for path in files if path.exists()) diff --git a/tests/test_daily_and_rectification_entrypoints.py b/tests/test_daily_and_rectification_entrypoints.py index 73226bd3..0428d6ef 100644 --- a/tests/test_daily_and_rectification_entrypoints.py +++ b/tests/test_daily_and_rectification_entrypoints.py @@ -4,12 +4,20 @@ from pathlib import Path PAGE = Path("frontend/src/app/page.tsx") STARTER_HOME = Path("frontend/src/components/starter-home.tsx") HOME_CLOUD = Path("frontend/src/lib/home-cloud-sync.ts") +RECTIFICATION_HOOK = Path("frontend/src/hooks/use-rectification-surface.ts") DAILY_ROUTE = Path("frontend/src/app/api/daily-starlanguage/route.ts") RECTIFICATION_ROUTE = Path("frontend/src/app/api/birth-rectification/route.ts") +def _home_entrypoint_surface() -> str: + parts = [PAGE, STARTER_HOME, HOME_CLOUD] + if RECTIFICATION_HOOK.exists(): + parts.append(RECTIFICATION_HOOK) + return "".join(path.read_text(encoding="utf-8") for path in parts) + + def test_daily_starlanguage_entrypoint_is_productized() -> None: - source = PAGE.read_text(encoding="utf-8") + STARTER_HOME.read_text(encoding="utf-8") + HOME_CLOUD.read_text(encoding="utf-8") + source = _home_entrypoint_surface() assert "今日星语" in source assert "fetchDailyStarlanguage" in source assert "daily-starlanguage-card" in source @@ -32,7 +40,7 @@ def test_daily_starlanguage_api_declares_honest_source_boundary() -> None: def test_birth_time_rectification_entrypoint_is_productized() -> None: - source = PAGE.read_text(encoding="utf-8") + STARTER_HOME.read_text(encoding="utf-8") + HOME_CLOUD.read_text(encoding="utf-8") + source = _home_entrypoint_surface() assert "生时校正" in source assert "birth-rectification-card" in source assert "openRectificationFromHomepage" in source diff --git a/tests/test_session_management_entrypoints.py b/tests/test_session_management_entrypoints.py index 47e422fa..bb1ad329 100644 --- a/tests/test_session_management_entrypoints.py +++ b/tests/test_session_management_entrypoints.py @@ -17,6 +17,8 @@ HOME_SURFACE_FILES = ( OPTIONAL_HOME_HOOKS = ( _FRONTEND_SRC / "hooks" / "use-session-management.ts", _FRONTEND_SRC / "hooks" / "use-consultation-run.ts", + _FRONTEND_SRC / "hooks" / "use-profile-onboarding.ts", + _FRONTEND_SRC / "hooks" / "use-rectification-surface.ts", ) STYLES = Path("frontend/src/app/globals.css") SESSION_ROW = Path("frontend/src/components/sidebar-session-row.tsx") diff --git a/tests/test_supabase_user_data_contract.py b/tests/test_supabase_user_data_contract.py index adb12d0a..f0b45890 100644 --- a/tests/test_supabase_user_data_contract.py +++ b/tests/test_supabase_user_data_contract.py @@ -55,6 +55,8 @@ HOME_SURFACE_FILES = ( OPTIONAL_HOME_HOOKS = ( _FRONTEND_SRC / "hooks" / "use-session-management.ts", _FRONTEND_SRC / "hooks" / "use-consultation-run.ts", + _FRONTEND_SRC / "hooks" / "use-profile-onboarding.ts", + _FRONTEND_SRC / "hooks" / "use-rectification-surface.ts", )