/** * Home cloud bootstrap. No React hooks: Home owns one effect and passes setters. * * The 20s timeout aborts this run and reveals the fatal screen. A login redirect * leaves that timer running so a failed navigation can still escape the spinner. */ import type { BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay"; import { guidedBirthTimePreview, isGuidedBirthTimePreview, previewRectificationJourney, } from "@/lib/birth-time-guided-preview"; import { hydrateDeclaredWindowDraft } from "@/lib/birth-time-intake-model"; import type { JourneyClientResponse } from "@/lib/birth-time-journey-client"; import { SESSION_MISSING_NOTICE, clearLoginSessionReturn, writeSessionUrl, type BootstrapSessionSelection, } from "@/lib/chat-session-url"; import { createStartGreeting } from "@/lib/starter-greeting"; import { HOME_BOOTSTRAP_SLOW_MS, resolveStarterHomeLandingSessionId, starterHomeLandingNeedsConsultation, type BootstrapPhase, } from "@/lib/home-bootstrap"; import { ConsultationStatusError, LoginRedirectError, applyLegacySessionControls, createSession, fetchActiveConsultationStatus, fetchConsultationStatus, fetchModelCatalog, fetchSessionDetail, friendlyError, patchSessionModel, readSessions, readStoredPendingConsultation, redirectToLogin, resolveLookupBootstrap, } from "@/lib/home-cloud-sync"; import { chartSnapshotForSession, missingProfileStep, readProfile } from "@/lib/home-profile"; import { peekSubjectCatalog, readCurrentSubjectId } from "@/lib/current-subject"; import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model"; import { emptyProfile, pendingConsultationStorageKey, previewModelCatalog, timestamp, type Account, type ChatSession, type ConsultationStatus, type OnboardingStep, type Profile, type SessionReadResult, type StoredPendingConsultation, } from "@/lib/home-types"; import type { PublicLanguageModelCatalog } from "@/lib/public-models"; import { dropRectificationStoredPending } from "@/lib/rectification-session-composer-guard"; import type { SessionListBoot } from "@/lib/session-list-context"; import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter"; import { clearStaleClientReload } from "@/lib/stale-client-recovery"; export const HOME_BOOTSTRAP_TIMEOUT_MESSAGE = "连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。"; export type HomeBootstrapStorage = Pick; export type HomeBootstrapCommit = { setAccount: (account: Account) => void; setAccountError: (value: string) => void; setActiveSessionId: (id: string) => void; setBirthTimeAssessmentPhase: (phase: BirthTimeAssessmentPhase | null) => void; setBirthTimeJourney: (journey: JourneyClientResponse | null) => void; setBootstrapPhase: (phase: BootstrapPhase) => void; setGuidedJourneyPreview: (value: boolean) => void; setHydrated: (value: boolean) => void; setModelCatalog: (catalog: PublicLanguageModelCatalog | null) => void; setOnboardingStep: (step: OnboardingStep) => void; setProfile: (profile: Profile) => void; setProfileDraft: (profile: Profile) => void; setSessions: (sessions: ChatSession[]) => void; setSessionsCursor: (cursor: string | null) => void; setStartGreeting: (value: string) => void; }; export type HomeBootstrapIo = { fetchModelCatalog: typeof fetchModelCatalog; fetchActiveConsultationStatus: typeof fetchActiveConsultationStatus; fetchConsultationStatus: typeof fetchConsultationStatus; fetchSessionDetail: typeof fetchSessionDetail; resolveLookupBootstrap: typeof resolveLookupBootstrap; patchSessionModel: typeof patchSessionModel; redirectToLogin: typeof redirectToLogin; clearStaleClientReload: typeof clearStaleClientReload; writeSessionUrl: typeof writeSessionUrl; clearLoginSessionReturn: typeof clearLoginSessionReturn; restoreConsultationRecovery: ( session: ChatSession, requestId: string, stored?: StoredPendingConsultation | null, ) => void; }; export type HomeBootstrapDeps = { sessionListReady: Promise; waitForSessionList?: () => Promise; sessionListBoot: () => SessionListBoot | null; uiPreview: { current: boolean }; uiPreviewMode: { current: string | null }; consultationStatusMissingCount: { current: number }; isDevelopment: boolean; readSearch: () => string; storage: HomeBootstrapStorage; restoreConsultationRecovery: HomeBootstrapIo["restoreConsultationRecovery"]; setComposerNotice: (notice: string) => void; commit: HomeBootstrapCommit; io?: Partial; }; type AccountPhaseReady = { kind: "ready"; nextAccount: Account; nextModelCatalog: PublicLanguageModelCatalog | null; nextProfile: Profile; nextSessions: ChatSession[]; parsedSessions: SessionReadResult; reservedConsultation: ConsultationStatus | null; storedPending: StoredPendingConsultation | null; cursor: string | null; }; type AccountPhaseResult = AccountPhaseReady | { kind: "stop" }; const defaultIo: Omit = { fetchModelCatalog, fetchActiveConsultationStatus, fetchConsultationStatus, fetchSessionDetail, resolveLookupBootstrap, patchSessionModel, redirectToLogin, clearStaleClientReload, writeSessionUrl, clearLoginSessionReturn, }; function resolveIo(deps: HomeBootstrapDeps): HomeBootstrapIo { return { ...defaultIo, ...deps.io, restoreConsultationRecovery: deps.io?.restoreConsultationRecovery ?? deps.restoreConsultationRecovery, }; } export async function runAccountPhase( deps: HomeBootstrapDeps, signal: AbortSignal, ): Promise { const io = resolveIo(deps); const previewMode = deps.isDevelopment ? new URLSearchParams(deps.readSearch()).get("preview") : null; if (previewMode) { deps.uiPreview.current = true; deps.uiPreviewMode.current = previewMode; deps.commit.setGuidedJourneyPreview(true); if (previewMode === "error") { deps.commit.setAccountError(HOME_BOOTSTRAP_TIMEOUT_MESSAGE); deps.commit.setHydrated(true); return { kind: "stop" }; } const isAssessmentLoadingPreview = previewMode === "birth-time-assessment-loading"; const isCompletedCandidatePreview = previewMode === "birth-time-candidate-complete"; const isRectificationPreview = isGuidedBirthTimePreview(previewMode); const previewJourney = isRectificationPreview ? guidedBirthTimePreview(previewMode) : previewRectificationJourney; const previewProfile: Profile = previewMode === "onboarding" ? emptyProfile : { ...emptyProfile, name: "林遥", date: "1990-06-15", time: isCompletedCandidatePreview ? "04:53" : isRectificationPreview || isAssessmentLoadingPreview ? "" : "12:30", reportedTime: isRectificationPreview ? "14:30" : isAssessmentLoadingPreview || isCompletedCandidatePreview ? "" : "12:30", birthTimeSource: isRectificationPreview ? "approximate" : isAssessmentLoadingPreview || isCompletedCandidatePreview ? "period_only" : "legacy_import", ...hydrateDeclaredWindowDraft({ period: isAssessmentLoadingPreview || isCompletedCandidatePreview ? "early_morning" : "", }), birthTimeClue: "", uncertaintyBeforeMinutes: isRectificationPreview ? 30 : null, uncertaintyAfterMinutes: isRectificationPreview ? 30 : null, birthTimeStatus: isCompletedCandidatePreview ? "candidate" : isAssessmentLoadingPreview ? "rectifying" : previewJourney.snapshot.state === "candidate" || previewJourney.snapshot.state === "confirming" || previewJourney.snapshot.state === "ready" ? "candidate" : isRectificationPreview ? "rectifying" : "confirmed", rectificationCaseId: isRectificationPreview ? previewJourney.caseId : "", countryCode: "CN", provinceCode: "110000", cityCode: "110000-city", districtCode: "110101", }; const previewMessages = previewMode === "conversation" || previewMode === "streaming" || previewMode === "partial" ? [ { role: "user" as const, text: "未来半年是否适合换工作?" }, { role: "assistant" as const, text: "可以先看职业方向、关键时间。\n同时评估现实风险。\n此处只展示本地预览,\n不调用真实星盘。" }, ] : []; const previewSession: ChatSession = { id: "preview-session", title: previewMessages.length > 0 ? "未来半年是否适合换工作" : "新对话", theme: "career", modelId: previewModelCatalog.defaultModelId, messages: previewMessages, // session-clock-exempt: development preview fixture, not a stored session updatedAt: timestamp(), sessionType: "consultation", rectificationCaseId: null, chartProfileId: "self", chartProfileName: previewProfile.name.trim() || "我", chartProfileRole: "self", pinned: false, archivedAt: null, messagesHydrated: true, }; deps.commit.setAccount({ user: { id: "preview-user", email: "preview@local.test" }, avatar: null, credits: 8, isAdmin: false, adminUrl: null, rectificationPriceCredits: 1, hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed", hasUsableBirthTime: previewProfile.birthTimeStatus === "accepted" || previewProfile.birthTimeStatus === "confirmed", activeSubscription: null, profile: previewProfile, }); deps.commit.setModelCatalog(previewModelCatalog); deps.commit.setProfile(previewProfile); deps.commit.setProfileDraft(previewProfile); if (isRectificationPreview) { deps.commit.setBirthTimeJourney(previewJourney); } if (isAssessmentLoadingPreview) { deps.commit.setBirthTimeAssessmentPhase("assessing"); } deps.commit.setOnboardingStep(isAssessmentLoadingPreview ? "birth" : missingProfileStep(previewProfile) ?? "name"); deps.commit.setSessions([previewSession]); deps.commit.setActiveSessionId(previewSession.id); const previewGreeting = previewProfile.name.trim() ? createStartGreeting(previewProfile.name) : ""; deps.commit.setStartGreeting(previewGreeting); deps.commit.setHydrated(true); return { kind: "stop" }; } // Start independent reads before waiting for the provider so slow-network // latency is spent in parallel, not as a serial account -> model chain. const modelCatalogPromise = io.fetchModelCatalog(signal) .then((catalog) => ({ catalog, error: null as unknown })) .catch((error: unknown) => ({ catalog: null, error })); const activeStatusPromise = io.fetchActiveConsultationStatus(signal).catch((caught: unknown) => { if (caught instanceof Error && caught.name === "AbortError") throw caught; return null; }); await (deps.waitForSessionList?.() ?? deps.sessionListReady); if (signal.aborted) return { kind: "stop" }; const listBoot = deps.sessionListBoot(); if (listBoot?.signedOut) { io.redirectToLogin(); return { kind: "stop" }; } if (!listBoot?.account || listBoot.error) throw new Error("session_list_unavailable"); const nextAccount = listBoot.account; const modelCatalogResult = await modelCatalogPromise; if (modelCatalogResult.error) throw modelCatalogResult.error; const nextModelCatalog = modelCatalogResult.catalog; const nextProfile = readProfile(nextAccount.profile); const parsedSessions = readSessions(listBoot.rawRows, nextModelCatalog); let nextSessions = parsedSessions.sessions.length > 0 ? parsedSessions.sessions : listBoot.sessions; if (nextSessions.length === 0) { nextSessions = [createSession( nextModelCatalog?.defaultModelId ?? "", "consultation", chartSnapshotForSession(readCurrentSubjectId(), [...(peekSubjectCatalog() ?? [])], nextProfile), )]; } nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions); let reservedConsultation: ConsultationStatus | null = await activeStatusPromise; const storedPending = dropRectificationStoredPending( readStoredPendingConsultation(deps.storage.getItem(pendingConsultationStorageKey), nextSessions.map((session) => session.id)), nextSessions, ); if (!storedPending) deps.storage.removeItem(pendingConsultationStorageKey); if (storedPending) { try { const status = await io.fetchConsultationStatus( storedPending.sessionId, storedPending.requestId, signal, ); if (status.status === "reserved") { deps.consultationStatusMissingCount.current = 0; reservedConsultation = status; } else { deps.storage.removeItem(pendingConsultationStorageKey); reservedConsultation = null; } } catch (caught) { if (caught instanceof Error && caught.name === "AbortError") throw caught; deps.consultationStatusMissingCount.current = caught instanceof ConsultationStatusError && caught.status === 404 ? 1 : 0; reservedConsultation = { sessionId: storedPending.sessionId, requestId: storedPending.requestId, status: "reserved", }; } } if (signal.aborted) return { kind: "stop" }; return { kind: "ready", nextAccount, nextModelCatalog, nextProfile, nextSessions, parsedSessions, reservedConsultation, storedPending, cursor: listBoot.cursor, }; } export async function resolveLanding( deps: HomeBootstrapDeps, signal: AbortSignal, ready: AccountPhaseReady, ): Promise { const io = resolveIo(deps); const { writeSessionUrl, clearLoginSessionReturn, restoreConsultationRecovery } = io; const lookedUp = await io.resolveLookupBootstrap({ sessions: ready.nextSessions, catalog: ready.nextModelCatalog, defaultSessionId: ready.nextSessions[0].id, signal, inSubjectScope: (session) => sessionMatchesSubject(session, readCurrentSubjectId()), }); let nextSessions = lookedUp.sessions; const bootstrapSelection: BootstrapSessionSelection = lookedUp.selection; let landingSessionId = resolveStarterHomeLandingSessionId( nextSessions, bootstrapSelection.sessionId, bootstrapSelection.urlAction, ); if (starterHomeLandingNeedsConsultation(nextSessions, landingSessionId, bootstrapSelection.urlAction)) { const homeSession = createSession( ready.nextModelCatalog?.defaultModelId ?? "", "consultation", chartSnapshotForSession(readCurrentSubjectId(), [...(peekSubjectCatalog() ?? [])], ready.nextProfile), ); nextSessions = [homeSession, ...nextSessions]; landingSessionId = homeSession.id; } const activeListed = nextSessions.find((session) => session.id === landingSessionId) ?? nextSessions[0]; if (activeListed && !activeListed.messagesHydrated && activeListed.sessionType === "consultation") { try { const detailed = await io.fetchSessionDetail(activeListed.id, ready.nextModelCatalog, signal); if (detailed) { nextSessions = nextSessions.map((session) => session.id === detailed.id ? detailed : session); } } catch (caught) { if (caught instanceof Error && caught.name === "AbortError") throw caught; if (caught instanceof LoginRedirectError) return; } } if (signal.aborted) return; io.clearStaleClientReload(deps.storage); deps.commit.setSessionsCursor(ready.cursor); deps.commit.setAccount(ready.nextAccount); deps.commit.setModelCatalog(ready.nextModelCatalog); deps.commit.setProfile(ready.nextProfile); deps.commit.setProfileDraft(ready.nextProfile); deps.commit.setStartGreeting(ready.nextProfile.name.trim() ? createStartGreeting(ready.nextProfile.name) : ""); deps.commit.setOnboardingStep(missingProfileStep(ready.nextProfile) ?? "name"); deps.commit.setSessions(nextSessions); if (bootstrapSelection.urlAction === "new-chat") writeSessionUrl(null, "replace"); deps.commit.setActiveSessionId(landingSessionId); if (bootstrapSelection.clearStoredReturn) clearLoginSessionReturn(); if (bootstrapSelection.urlAction === "replace-clear") writeSessionUrl(null, "replace"); if (bootstrapSelection.urlAction === "replace-selected") { const selected = nextSessions.find((session) => session.id === bootstrapSelection.sessionId); if (!selected || !isUnsavedEmptyConsultation(selected)) { writeSessionUrl(bootstrapSelection.sessionId, "replace"); } } const reservedConsultation = ready.reservedConsultation; const storedPending = ready.storedPending; if (reservedConsultation?.status === "reserved") { const recoverySession = nextSessions.find((session) => session.id === reservedConsultation.sessionId); if (recoverySession) restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending); if (bootstrapSelection.urlAction === "new-chat") { deps.commit.setActiveSessionId(landingSessionId); deps.setComposerNotice(""); } } if (ready.parsedSessions.fallbackSessionIds.length > 0) { deps.setComposerNotice("此前选择的模型已下线,已切换为默认模型。"); } else if (lookedUp.notice) { deps.setComposerNotice(lookedUp.notice); } else if (bootstrapSelection.missing) { deps.setComposerNotice(SESSION_MISSING_NOTICE); } deps.commit.setAccountError(""); const fallbackCatalog = ready.nextModelCatalog; if (fallbackCatalog && ready.parsedSessions.fallbackSessionIds.length > 0) { try { await Promise.all(ready.parsedSessions.fallbackSessionIds.map((sessionId) => io.patchSessionModel(sessionId, fallbackCatalog.defaultModelId, signal), )); } catch { if (!signal.aborted) { deps.setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。"); } } } } export async function runHomeBootstrap(deps: HomeBootstrapDeps, signal: AbortSignal): Promise { const controller = new AbortController(); const onParentAbort = () => controller.abort(); if (signal.aborted) onParentAbort(); else signal.addEventListener("abort", onParentAbort); const bootstrapTimeout = window.setTimeout(() => { if (controller.signal.aborted) return; controller.abort(); deps.commit.setAccountError(HOME_BOOTSTRAP_TIMEOUT_MESSAGE); deps.commit.setHydrated(true); }, HOME_BOOTSTRAP_SLOW_MS); const clearTimeoutOnParentAbort = () => { window.clearTimeout(bootstrapTimeout); }; if (signal.aborted) clearTimeoutOnParentAbort(); else signal.addEventListener("abort", clearTimeoutOnParentAbort); let redirectedToLogin = false; let bootstrapFailed = false; try { const ready = await runAccountPhase(deps, controller.signal); if (ready.kind === "ready") await resolveLanding(deps, controller.signal, ready); } catch (caught) { if (caught instanceof LoginRedirectError) { redirectedToLogin = true; return; } if ((caught as Error).name !== "AbortError" && !controller.signal.aborted) { bootstrapFailed = true; deps.commit.setAccountError(friendlyError(caught instanceof Error ? caught.message : "暂时无法读取云端数据")); } } finally { if (redirectedToLogin) return; signal.removeEventListener("abort", onParentAbort); signal.removeEventListener("abort", clearTimeoutOnParentAbort); window.clearTimeout(bootstrapTimeout); if (!controller.signal.aborted) { if (bootstrapFailed) deps.commit.setHydrated(true); else deps.commit.setBootstrapPhase("prepare"); } } }