refactor(home): split bootstrap, recovery polling, and shells

Move the home startup sequence and consultation recovery poll into
plain functions, and the error and onboarding screens into components.
Landing rules, the 20s fatal screen, retry, and the chart settings
deep link stay the same.
This commit is contained in:
jesse-ux
2026-09-25 02:59:11 +08:00
parent 9167d75e74
commit da35b3e4af
19 changed files with 1972 additions and 632 deletions
+91 -524
View File
@@ -1,21 +1,24 @@
"use client";
import Link from "next/link";
import dynamic from "next/dynamic";
import { loadBillingPanel } from "@/lib/billing-panel-loader";
import { Sparkles } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import type { FormEvent, KeyboardEvent } from "react";
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay"; import { ProfilePanel } from "@/components/profile-panel"; import { ThemePreferencePanel } from "@/components/theme-preference-menu";
import { BirthTimeAssessmentOverlay, type BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay";
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { useEffect, useMemo, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
import { ProfilePanel } from "@/components/profile-panel";
import { ThemePreferencePanel } from "@/components/theme-preference-menu";
import type { BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay";
import { AppLoadingIndicator } from "@/components/app-loading-indicator";
import { HomeBootstrapError } from "@/components/home-bootstrap-error";
import { HomeOnboardingShell } from "@/components/home-onboarding-shell";
import {
entrySummaryFromResponse,
rectificationEntryLabels,
resolveRectificationEntryAction,
} from "@/lib/rectification-entry";
import {
composerLocksAsRectification, dropRectificationStoredPending,
composerLocksAsRectification,
ordinaryComposerPlaceholder,
rectificationOrdinaryComposerReopen,
} from "@/lib/rectification-session-composer-guard";
@@ -39,9 +42,6 @@ import { SidebarTrigger } from "@/components/ui/sidebar";
import { useHomeShellRegistration } from "@/hooks/use-home-shell-registration";
import { useSessionList } from "@/hooks/use-session-list";
import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
import { clearStaleClientReload } from "@/lib/stale-client-recovery";
import { BirthLocationFields } from "@/components/birth-location-fields";
import { OnboardingChatMessage } from "@/components/onboarding-chat-message";
import { ChartLibraryPanel } from "@/components/chart-library-panel";
import { StarterEntries, StarterGreeting } from "@/components/starter-home";
import {
@@ -50,13 +50,7 @@ import {
isRectificationHandoffQuestion,
type ConsultationEntrypoint,
} from "@/lib/consultation-entrypoint";
import {
applyBirthTimeDraftPatch,
assistantIntentCopy,
hydrateDeclaredWindowDraft,
isBirthTimeDraftReady,
birthTimeDraftReadyHint,
} from "@/lib/birth-time-intake-model";
import { assistantIntentCopy } from "@/lib/birth-time-intake-model";
import {
clearBirthTimeConsultationConsent,
createLatestAccountRequestGuard,
@@ -82,12 +76,6 @@ import { chatReplyAnnouncement, type ChatReplyPhase } from "@/lib/chat-reply-ann
import {
type JourneyClientResponse,
} from "@/lib/birth-time-journey-client";
import {
guidedBirthTimePreview,
isGuidedBirthTimePreview,
previewRectificationJourney,
} from "@/lib/birth-time-guided-preview";
import { keepFocusWithin } from "@/lib/focus-trap";
import {
BALANCE_SYNC_KEY,
@@ -123,16 +111,8 @@ import {
CONSULTATION_EVIDENCE_VALIDATION_LABEL,
CONSULTATION_LOADING_METHOD_LABEL,
} from "@/lib/consultation-activity-labels";
import {
SESSION_MISSING_NOTICE,
clearLoginSessionReturn,
writeSessionUrl,
} from "@/lib/chat-session-url";
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
import {
createStartGreeting,
createStartGreetingParts,
} from "@/lib/starter-greeting";
import { createStartGreetingParts } from "@/lib/starter-greeting";
import {
calendarDateInTimeZone,
dailyStarlanguageProfileKey,
@@ -149,18 +129,11 @@ import {
accountDialogClasses,
accountDialogTitles,
dailyStarlanguageRetryDelayMs,
emptyProfile,
pendingConsultationStorageKey,
presetOnboardingMessage,
previewModelCatalog,
timestamp,
type Account,
type AccountDialog,
type ChartLibraryRecord,
type ChatSession,
type ConsultationStatus,
type DailyStarlanguageState,
type Message,
type OnboardingStep,
type OpenAccountDialogOptions,
type PendingConsultation,
@@ -174,67 +147,39 @@ import { chatSubjectSendBlocked } from "@/lib/chat-profile-picker-model";
import { useChatProfileCatalog } from "@/hooks/use-chat-profile-catalog";
import {
birthQuestion,
chartSnapshotForSession,
completedOnboardingMessage,
completedOnboardingTranscript,
formatBirthMoment,
isProfileComplete,
missingProfileStep,
placeQuestion,
readProfile,
selectedBirthPlace,
sessionChartLabel, upsertSelfChart,
} from "@/lib/home-profile";
import {
activeChartStorageKey,
applyLegacySessionControls,
CancellationResponseError,
ConsultationResponseError,
ConsultationStatusError,
createSession,
discardLegacyCloudMirrorKeys,
fetchActiveConsultationStatus,
fetchCloudChartLibrary,
fetchCloudSynastryHistory,
fetchConsultationStatus,
fetchDailyStarlanguage,
fetchModelCatalog,
fetchSessionDetail,
friendlyError,
LoginRedirectError,
mergeHydratedSession,
patchSessionModel,
resolveLookupBootstrap,
payloadCode,
payloadMessage,
readSessions,
readStoredDailyStarlanguage,
readStoredPendingConsultation,
redirectToLogin,
waitForUndoWindow,
writeStoredDailyStarlanguage,
} from "@/lib/home-cloud-sync";
import { runHomeBootstrap } from "@/lib/home-bootstrap-run";
import { runConsultationRecoveryPoll } from "@/lib/consultation-recovery-poll";
import {
bootstrapLoadingCopy,
bootstrapPrepareSettled,
bootstrapRevealDelayMs,
resolveStarterHomeLandingSessionId,
retryHomeBootstrap,
sessionIdsToPrefetch,
shouldAutoOpenRectificationSession,
starterHomeLandingNeedsConsultation,
HOME_BOOTSTRAP_SLOW_MS,
type BootstrapPhase,
} from "@/lib/home-bootstrap";
import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter";
const BirthTimeRectification = dynamic(
() => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification),
{
ssr: false,
loading: () => <p className="birth-time-assistant-intent" role="status">正在加载出生时间评估...</p>,
},
);
const BillingPanel = dynamic(loadBillingPanel, {
ssr: false,
loading: () => <p className="secondary-page-waiting">账户信息还没拿到。</p>,
@@ -760,7 +705,6 @@ export default function Home() {
&& pendingSessionId !== activeSession.id,
);
const onboardingFormActive = !profileComplete && onboardingStep !== "name";
const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : "";
const starterHasPriorSessions = sessions.some((session) => session.id !== activeSession?.id);
const starterGreeting = createStartGreetingParts(profile.name, new Date(), starterGreetingSelection, starterHasPriorSessions);
const conversationAnchor = useConversationScrollAnchor(
@@ -842,287 +786,36 @@ export default function Home() {
useEffect(() => {
const controller = new AbortController();
const bootstrapTimeout = window.setTimeout(() => {
if (controller.signal.aborted) return;
controller.abort();
setAccountError("连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。");
setHydrated(true);
}, HOME_BOOTSTRAP_SLOW_MS);
async function loadCloudData() {
let redirectedToLogin = false;
let bootstrapFailed = false;
try {
const previewMode = process.env.NODE_ENV === "development"
? new URLSearchParams(window.location.search).get("preview")
: null;
if (previewMode) {
uiPreview.current = true;
uiPreviewMode.current = previewMode;
setGuidedJourneyPreview(true);
if (previewMode === "error") {
setAccountError("连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。");
setHydrated(true);
return;
}
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: Message[] = previewMode === "conversation" || previewMode === "streaming" || previewMode === "partial"
? [
{ role: "user", text: "未来半年是否适合换工作?" },
{ role: "assistant", text: "可以先看职业方向、关键时间。\n同时评估现实风险。\n此处只展示本地预览,\n不调用真实星盘。" },
]
: [];
const previewSession: ChatSession = {
id: "preview-session",
title: previewMessages.length > 0 ? "未来半年是否适合换工作" : "新对话",
theme: "career",
modelId: previewModelCatalog.defaultModelId,
messages: previewMessages,
updatedAt: timestamp(),
sessionType: "consultation",
rectificationCaseId: null,
chartProfileId: "self",
chartProfileName: previewProfile.name.trim() || "我",
chartProfileRole: "self",
pinned: false,
archivedAt: null,
messagesHydrated: true,
};
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,
});
setModelCatalog(previewModelCatalog);
setProfile(previewProfile);
setProfileDraft(previewProfile);
if (isRectificationPreview) {
setBirthTimeJourney(previewJourney);
}
if (isAssessmentLoadingPreview) {
setBirthTimeAssessmentPhase("assessing");
}
setOnboardingStep(isAssessmentLoadingPreview ? "birth" : missingProfileStep(previewProfile) ?? "name");
setSessions([previewSession]);
setActiveSessionId(previewSession.id);
const previewGreeting = previewProfile.name.trim() ? createStartGreeting(previewProfile.name) : "";
setStartGreeting(previewGreeting);
setHydrated(true);
return;
}
// 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 = fetchModelCatalog(controller.signal)
.then((catalog) => ({ catalog, error: null as unknown }))
.catch((error: unknown) => ({ catalog: null, error }));
const activeStatusPromise = fetchActiveConsultationStatus(controller.signal).catch((caught: unknown) => {
if (caught instanceof Error && caught.name === "AbortError") throw caught;
return null;
});
await sessionListReady;
if (controller.signal.aborted) return;
const listBoot = sessionListBoot();
if (listBoot?.signedOut || !listBoot?.account) {
redirectToLogin();
return;
}
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("self", [], nextProfile),
)];
}
nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions);
let reservedConsultation: ConsultationStatus | null = await activeStatusPromise;
const storedPending = dropRectificationStoredPending(
readStoredPendingConsultation(sessionStorage.getItem(pendingConsultationStorageKey), nextSessions.map((session) => session.id)),
nextSessions,
);
if (!storedPending) sessionStorage.removeItem(pendingConsultationStorageKey);
if (storedPending) {
try {
const status = await fetchConsultationStatus(
storedPending.sessionId,
storedPending.requestId,
controller.signal,
);
if (status.status === "reserved") {
consultationStatusMissingCount.current = 0;
reservedConsultation = status;
} else {
sessionStorage.removeItem(pendingConsultationStorageKey);
reservedConsultation = null;
}
} catch (caught) {
if (caught instanceof Error && caught.name === "AbortError") throw caught;
consultationStatusMissingCount.current = caught instanceof ConsultationStatusError && caught.status === 404 ? 1 : 0;
reservedConsultation = {
sessionId: storedPending.sessionId,
requestId: storedPending.requestId,
status: "reserved",
};
}
}
if (controller.signal.aborted) return;
const lookedUp = await resolveLookupBootstrap({
sessions: nextSessions, catalog: nextModelCatalog,
defaultSessionId: nextSessions[0].id, signal: controller.signal,
});
nextSessions = lookedUp.sessions;
const bootstrapSelection = lookedUp.selection;
let landingSessionId = resolveStarterHomeLandingSessionId(
nextSessions,
bootstrapSelection.sessionId,
bootstrapSelection.urlAction,
);
if (starterHomeLandingNeedsConsultation(nextSessions, landingSessionId, bootstrapSelection.urlAction)) {
const homeSession = createSession(
nextModelCatalog?.defaultModelId ?? "",
"consultation",
chartSnapshotForSession("self", [], 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 fetchSessionDetail(activeListed.id, nextModelCatalog, controller.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 (controller.signal.aborted) return;
clearStaleClientReload(sessionStorage);
setSessionsCursor(listBoot.cursor);
setAccount(nextAccount);
setModelCatalog(nextModelCatalog);
setProfile(nextProfile);
setProfileDraft(nextProfile);
setStartGreeting(nextProfile.name.trim() ? createStartGreeting(nextProfile.name) : "");
setOnboardingStep(missingProfileStep(nextProfile) ?? "name");
setSessions(nextSessions);
if (bootstrapSelection.urlAction === "new-chat") writeSessionUrl(null, "replace");
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");
}
}
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") {
setActiveSessionId(landingSessionId);
setComposerNotice("");
}
}
if (parsedSessions.fallbackSessionIds.length > 0) {
setComposerNotice("此前选择的模型已下线,已切换为默认模型。");
} else if (lookedUp.notice) {
setComposerNotice(lookedUp.notice);
} else if (bootstrapSelection.missing) {
setComposerNotice(SESSION_MISSING_NOTICE);
}
setAccountError("");
if (nextModelCatalog && parsedSessions.fallbackSessionIds.length > 0) {
try {
await Promise.all(parsedSessions.fallbackSessionIds.map((sessionId) =>
patchSessionModel(sessionId, nextModelCatalog.defaultModelId, controller.signal),
));
} catch {
if (!controller.signal.aborted) {
setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。");
}
}
}
} catch (caught) {
if (caught instanceof LoginRedirectError) {
redirectedToLogin = true;
return;
}
if ((caught as Error).name !== "AbortError" && !controller.signal.aborted) {
bootstrapFailed = true;
setAccountError(friendlyError(caught instanceof Error ? caught.message : "暂时无法读取云端数据"));
}
} finally {
if (redirectedToLogin) return;
window.clearTimeout(bootstrapTimeout);
if (!controller.signal.aborted) {
if (bootstrapFailed) setHydrated(true);
else setBootstrapPhase("prepare");
}
}
}
void loadCloudData();
return () => {
window.clearTimeout(bootstrapTimeout);
controller.abort();
};
void runHomeBootstrap({
sessionListReady,
sessionListBoot,
uiPreview,
uiPreviewMode,
consultationStatusMissingCount,
isDevelopment: process.env.NODE_ENV === "development",
readSearch: () => window.location.search,
storage: sessionStorage,
restoreConsultationRecovery,
setComposerNotice,
commit: {
setAccount,
setAccountError,
setActiveSessionId,
setBirthTimeAssessmentPhase,
setBirthTimeJourney,
setBootstrapPhase,
setGuidedJourneyPreview,
setHydrated,
setModelCatalog,
setOnboardingStep,
setProfile,
setProfileDraft,
setSessions,
setSessionsCursor,
setStartGreeting,
},
}, controller.signal);
return () => controller.abort();
}, [sessionListReady]);
useEffect(() => {
@@ -1144,98 +837,26 @@ export default function Home() {
useEffect(() => {
if (consultationPhase !== "recovering" || !pendingSessionId || !pendingRequestId || uiPreview.current) return;
const controller = new AbortController();
let timer = 0;
let polling = false;
const poll = async () => {
if (polling || controller.signal.aborted) return;
polling = true;
try {
if (!navigator.onLine) {
setComposerNotice("网络已断开,回答仍在后台生成;联网后会自动恢复。");
return;
}
const status = await fetchConsultationStatus(pendingSessionId, pendingRequestId, controller.signal);
if (status.status === "reserved") {
consultationStatusMissingCount.current = 0;
setComposerNotice("回答仍在后台生成,正在自动恢复。");
return;
}
if (status.status === "completed") {
const detailed = await fetchSessionDetail(pendingSessionId, modelCatalog, controller.signal);
if (detailed) {
setSessions((current) => mergeHydratedSession(current, detailed));
setActiveSessionId((current) => current || detailed.id);
}
pendingConsultation.current = null;
setPendingSessionId(null);
setPendingRequestId(null);
setConsultationPhase(null);
setStreamingReply(null);
setRequestError(null);
setComposerNotice("回答已恢复,已显示在对话区末尾。");
void refreshAccount();
return;
}
pendingConsultation.current = null;
setPendingSessionId(null);
setPendingRequestId(null);
setConsultationPhase(null);
setStreamingReply(null);
setRequestError(null);
setComposerNotice("回答已取消;问题仍保留在聊天记录中,可重新发送。");
} catch (caught) {
if (controller.signal.aborted) return;
if (caught instanceof ConsultationStatusError && caught.status === 404) {
consultationStatusMissingCount.current += 1;
if (consultationStatusMissingCount.current === 1) {
setComposerNotice("正在确认本次咨询请求是否已开始…");
return;
}
if (consultationReplayStarted.current !== pendingRequestId) {
consultationReplay.current();
return;
}
if (consultationStatusMissingCount.current >= 4) {
pendingConsultation.current = null;
setPendingSessionId(null);
setPendingRequestId(null);
setConsultationPhase(null);
setStreamingReply(null);
setRequestError({
sessionId: pendingSessionId,
message: "后台未找到本次咨询请求,请重新发送。",
});
setComposerNotice("后台未找到本次咨询请求,已停止恢复,请重新发送。");
return;
}
setComposerNotice("正在重新发起本次咨询…");
return;
}
consultationStatusMissingCount.current = 0;
setComposerNotice(navigator.onLine
? "回答仍在后台生成,正在自动恢复。"
: "网络已断开,回答仍在后台生成;联网后会自动恢复。");
} finally {
polling = false;
if (!controller.signal.aborted && pendingConsultation.current?.phase === "recovering") {
timer = window.setTimeout(() => void poll(), 1_750);
}
}
};
consultationRecoveryWakeup.current = () => {
window.clearTimeout(timer);
void poll();
};
if (consultationStatusMissingCount.current > 0) {
timer = window.setTimeout(() => void poll(), 1_750);
} else {
void poll();
}
consultationRecoveryWakeup.current = runConsultationRecoveryPoll({
pendingSessionId,
pendingRequestId,
modelCatalog,
consultationStatusMissingCount,
consultationReplayStarted,
consultationReplay,
pendingConsultation,
setComposerNotice,
setSessions,
setActiveSessionId,
setPendingSessionId,
setPendingRequestId,
setConsultationPhase,
setStreamingReply,
setRequestError,
refreshAccount,
}, controller.signal);
return () => {
consultationRecoveryWakeup.current = () => undefined;
window.clearTimeout(timer);
controller.abort();
};
}, [consultationPhase, modelCatalog, pendingRequestId, pendingSessionId]);
@@ -1534,18 +1155,16 @@ export default function Home() {
// after the bootstrap controller was aborted; never reveal without a catalog.
if (!account || !modelCatalog) {
return (
<main className="app-loading app-loading-error">
<div className="app-loading-content" role="alert">
<strong>暂时无法进入 Jyotisha</strong>
<span>{accountError}</span>
<div className="app-loading-actions">
<button className="button-primary" type="button" onClick={() => retryHomeBootstrap(
setAccountError, setHydrated, setBootstrapPhase, prepareStartedAt, reloadSessionList,
)}>重试</button>
<Link className="button-secondary" href="/login">返回登录</Link>
</div>
</div>
</main>
<HomeBootstrapError
accountError={accountError}
onRetry={() => retryHomeBootstrap(
setAccountError,
setHydrated,
setBootstrapPhase,
prepareStartedAt,
reloadSessionList,
)}
/>
);
}
@@ -1646,80 +1265,28 @@ export default function Home() {
{!activeSession?.messages.length ? (
<div className="welcome">
{!profileComplete ? (
<>
<OnboardingChatMessage role="assistant" text={presetOnboardingMessage} streaming={onboardingStep === "name" && !profileComplete} length={presetMessageLength} />
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="user" text={profileDraft.name.trim()} />}
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="assistant" text={birthQuestion(profileDraft.name.trim())} streaming={onboardingStep === "birth" && !profileComplete} length={presetMessageLength} />}
{(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && <OnboardingChatMessage role="user" text={formatBirthMoment(profileDraft)} />}
{(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && <OnboardingChatMessage role="assistant" text={placeQuestion(profileDraft)} streaming={onboardingStep === "place" && !profileComplete} length={presetMessageLength} />}
{!profileComplete && onboardingStep === "rectification" && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
{!profileComplete && onboardingStep === "rectification" && birthTimeJourney && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} phraseSafe />}
{profileComplete && onboardingJustCompleted && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
{profileComplete && onboardingJustCompleted && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} />}
</>
<HomeOnboardingShell
profileComplete={profileComplete}
profileDraft={profileDraft}
setProfileDraft={setProfileDraft}
onboardingStep={onboardingStep}
presetMessageLength={presetMessageLength}
currentOnboardingMessage={currentOnboardingMessage}
onboardingJustCompleted={onboardingJustCompleted}
onboardingCardReady={onboardingCardReady}
presetMessageFinished={presetMessageFinished}
birthTimeJourney={birthTimeJourney}
birthTimeAssessmentPhase={birthTimeAssessmentPhase}
birthTimeError={birthTimeError}
birthTimeGuided={birthTimeGuided}
accountError={accountError}
profileSaving={profileSaving}
saveOnboardingBirth={saveOnboardingBirth}
saveOnboardingPlace={saveOnboardingPlace}
retryBirthTimeAssessment={() => { void retryBirthTimeAssessment(); }}
/>
) : null}
{!profileComplete && onboardingStep === "birth" && onboardingCardReady && (
<div className="onboarding-card-reveal">
<div className="onboarding-card-reveal-inner">
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingBirth} aria-busy={birthTimeAssessmentPhase !== null}>
<div className="onboarding-card-heading">
<div className="onboarding-card-heading-copy">
<b>出生日期与时间</b>
</div>
</div>
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
<BirthTimeIntakeFields value={profileDraft} onPatch={(patch) => setProfileDraft((current) => applyBirthTimeDraftPatch(current, patch))} />
{accountError && <p className="form-error" role="alert">{accountError}</p>}
<div className="onboarding-card-actions">
{birthTimeContinueHint ? <p className="onboarding-card-action-hint">{birthTimeContinueHint}</p> : <span />}
<button className="button-primary" type="submit" disabled={profileSaving || !isBirthTimeDraftReady(profileDraft)}>{profileSaving ? "保存中" : "继续"}</button>
</div>
</fieldset>
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
</form>
</div>
</div>
)}
{!profileComplete && onboardingStep === "place" && onboardingCardReady && (
<div className="onboarding-card-reveal">
<div className="onboarding-card-reveal-inner">
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingPlace} aria-busy={birthTimeAssessmentPhase !== null}>
<div className="onboarding-card-heading"><b>出生地点</b><small>搜索全球城市或区县,系统会匹配出生当日的时区。</small></div>
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
<BirthLocationFields value={profileDraft} onChange={setProfileDraft} />
{accountError && <p className="form-error" role="alert">{accountError}</p>}
<div className="onboarding-card-actions"><button className="button-primary" type="submit" disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}</button></div>
</fieldset>
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
</form>
</div>
</div>
)}
{!profileComplete && onboardingStep === "rectification" && presetMessageFinished && birthTimeJourney && (
<div className="onboarding-card-reveal">
<div className="onboarding-card-reveal-inner">
<BirthTimeRectification
journey={birthTimeJourney}
controller={birthTimeGuided}
externalError={birthTimeError}
/>
</div>
</div>
)}
{!profileComplete && onboardingStep === "rectification" && presetMessageFinished && !birthTimeJourney && (
<div className="onboarding-card birth-time-retry-card" role="status">
<b>出生时间尚未完成评估</b>
<p>{birthTimeError || "资料已经保留,但暂时无法恢复校正进度。系统不会应用未经验证的具体时间。"}</p>
<button className="button-primary" type="button" disabled={profileSaving} onClick={() => void retryBirthTimeAssessment()}>{profileSaving ? "评估中" : "重新评估"}</button>
</div>
)}
{!profileComplete && onboardingStep === "name" && accountError && <p className="form-error onboarding-inline-error" role="alert">{accountError}</p>}
{profileComplete && presetMessageFinished && !rectificationSurfaceOpen && (
<StarterGreeting starterGreeting={starterGreeting} />
)}
@@ -0,0 +1,24 @@
"use client";
import Link from "next/link";
export function HomeBootstrapError({
accountError,
onRetry,
}: {
readonly accountError: string;
readonly onRetry: () => void;
}) {
return (
<main className="app-loading app-loading-error">
<div className="app-loading-content" role="alert">
<strong>暂时无法进入 Jyotisha</strong>
<span>{accountError}</span>
<div className="app-loading-actions">
<button className="button-primary" type="button" onClick={onRetry}>重试</button>
<Link className="button-secondary" href="/login">返回登录</Link>
</div>
</div>
</main>
);
}
@@ -0,0 +1,148 @@
"use client";
import dynamic from "next/dynamic";
import type { FormEvent } from "react";
import { BirthTimeAssessmentOverlay, type BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay";
import { BirthLocationFields } from "@/components/birth-location-fields";
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { OnboardingChatMessage } from "@/components/onboarding-chat-message";
import type { BirthTimeGuidedController } from "@/hooks/use-birth-time-guided-journey";
import {
applyBirthTimeDraftPatch,
birthTimeDraftReadyHint,
isBirthTimeDraftReady,
} from "@/lib/birth-time-intake-model";
import type { JourneyClientResponse } from "@/lib/birth-time-journey-client";
import {
birthQuestion,
formatBirthMoment,
placeQuestion,
selectedBirthPlace,
} from "@/lib/home-profile";
import { presetOnboardingMessage, type OnboardingStep, type Profile } from "@/lib/home-types";
const BirthTimeRectification = dynamic(
() => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification),
{
ssr: false,
loading: () => <p className="birth-time-assistant-intent" role="status">正在加载出生时间评估...</p>,
},
);
export type HomeOnboardingShellProps = {
readonly profileComplete: boolean;
readonly profileDraft: Profile;
readonly setProfileDraft: (update: Profile | ((current: Profile) => Profile)) => void;
readonly onboardingStep: OnboardingStep;
readonly presetMessageLength: number;
readonly currentOnboardingMessage: string;
readonly onboardingJustCompleted: boolean;
readonly onboardingCardReady: boolean;
readonly presetMessageFinished: boolean;
readonly birthTimeJourney: JourneyClientResponse | null;
readonly birthTimeAssessmentPhase: BirthTimeAssessmentPhase | null;
readonly birthTimeError: string;
readonly birthTimeGuided: BirthTimeGuidedController;
readonly accountError: string;
readonly profileSaving: boolean;
readonly saveOnboardingBirth: (event: FormEvent<HTMLFormElement>) => void;
readonly saveOnboardingPlace: (event: FormEvent<HTMLFormElement>) => void;
readonly retryBirthTimeAssessment: () => void;
};
export function HomeOnboardingShell({
profileComplete,
profileDraft,
setProfileDraft,
onboardingStep,
presetMessageLength,
currentOnboardingMessage,
onboardingJustCompleted,
onboardingCardReady,
presetMessageFinished,
birthTimeJourney,
birthTimeAssessmentPhase,
birthTimeError,
birthTimeGuided,
accountError,
profileSaving,
saveOnboardingBirth,
saveOnboardingPlace,
retryBirthTimeAssessment,
}: HomeOnboardingShellProps) {
const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : "";
return (
<>
<OnboardingChatMessage role="assistant" text={presetOnboardingMessage} streaming={onboardingStep === "name" && !profileComplete} length={presetMessageLength} />
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="user" text={profileDraft.name.trim()} />}
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="assistant" text={birthQuestion(profileDraft.name.trim())} streaming={onboardingStep === "birth" && !profileComplete} length={presetMessageLength} />}
{(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && <OnboardingChatMessage role="user" text={formatBirthMoment(profileDraft)} />}
{(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && <OnboardingChatMessage role="assistant" text={placeQuestion(profileDraft)} streaming={onboardingStep === "place" && !profileComplete} length={presetMessageLength} />}
{!profileComplete && onboardingStep === "rectification" && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
{!profileComplete && onboardingStep === "rectification" && birthTimeJourney && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} phraseSafe />}
{profileComplete && onboardingJustCompleted && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
{profileComplete && onboardingJustCompleted && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} />}
{!profileComplete && onboardingStep === "birth" && onboardingCardReady && (
<div className="onboarding-card-reveal">
<div className="onboarding-card-reveal-inner">
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingBirth} aria-busy={birthTimeAssessmentPhase !== null}>
<div className="onboarding-card-heading">
<div className="onboarding-card-heading-copy">
<b>出生日期与时间</b>
</div>
</div>
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
<BirthTimeIntakeFields value={profileDraft} onPatch={(patch) => setProfileDraft((current) => applyBirthTimeDraftPatch(current, patch))} />
{accountError && <p className="form-error" role="alert">{accountError}</p>}
<div className="onboarding-card-actions">
{birthTimeContinueHint ? <p className="onboarding-card-action-hint">{birthTimeContinueHint}</p> : <span />}
<button className="button-primary" type="submit" disabled={profileSaving || !isBirthTimeDraftReady(profileDraft)}>{profileSaving ? "保存中" : "继续"}</button>
</div>
</fieldset>
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
</form>
</div>
</div>
)}
{!profileComplete && onboardingStep === "place" && onboardingCardReady && (
<div className="onboarding-card-reveal">
<div className="onboarding-card-reveal-inner">
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingPlace} aria-busy={birthTimeAssessmentPhase !== null}>
<div className="onboarding-card-heading"><b>出生地点</b><small>搜索全球城市或区县,系统会匹配出生当日的时区。</small></div>
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
<BirthLocationFields value={profileDraft} onChange={setProfileDraft} />
{accountError && <p className="form-error" role="alert">{accountError}</p>}
<div className="onboarding-card-actions"><button className="button-primary" type="submit" disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}</button></div>
</fieldset>
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
</form>
</div>
</div>
)}
{!profileComplete && onboardingStep === "rectification" && presetMessageFinished && birthTimeJourney && (
<div className="onboarding-card-reveal">
<div className="onboarding-card-reveal-inner">
<BirthTimeRectification
journey={birthTimeJourney}
controller={birthTimeGuided}
externalError={birthTimeError}
/>
</div>
</div>
)}
{!profileComplete && onboardingStep === "rectification" && presetMessageFinished && !birthTimeJourney && (
<div className="onboarding-card birth-time-retry-card" role="status">
<b>出生时间尚未完成评估</b>
<p>{birthTimeError || "资料已经保留,但暂时无法恢复校正进度。系统不会应用未经验证的具体时间。"}</p>
<button className="button-primary" type="button" disabled={profileSaving} onClick={() => void retryBirthTimeAssessment()}>{profileSaving ? "评估中" : "重新评估"}</button>
</div>
)}
{!profileComplete && onboardingStep === "name" && accountError && <p className="form-error onboarding-inline-error" role="alert">{accountError}</p>}
</>
);
}
@@ -0,0 +1,141 @@
/**
* One recovering consultation's status loop. No React hooks: Home keeps the
* effect, the abort signal, and the wakeup ref.
*/
import {
ConsultationStatusError,
fetchConsultationStatus,
fetchSessionDetail,
mergeHydratedSession,
} from "@/lib/home-cloud-sync";
import type { ChatSession, RequestError, StreamingReply } from "@/lib/home-types";
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
export const CONSULTATION_RECOVERY_POLL_MS = 1_750;
export type ConsultationRecoveryPollDeps = {
pendingSessionId: string;
pendingRequestId: string;
modelCatalog: PublicLanguageModelCatalog | null;
consultationStatusMissingCount: { current: number };
consultationReplayStarted: { current: string | null };
consultationReplay: { current: () => void };
pendingConsultation: { current: { phase: string } | null };
setComposerNotice: (notice: string) => void;
setSessions: (update: ChatSession[] | ((current: ChatSession[]) => ChatSession[])) => void;
setActiveSessionId: (update: string | ((current: string) => string)) => void;
setPendingSessionId: (id: string | null) => void;
setPendingRequestId: (id: string | null) => void;
setConsultationPhase: (phase: "undo" | "streaming" | "recovering" | null) => void;
setStreamingReply: (value: StreamingReply | null) => void;
setRequestError: (value: RequestError | null) => void;
refreshAccount: () => void | Promise<void>;
online?: () => boolean;
fetchConsultationStatus?: typeof fetchConsultationStatus;
fetchSessionDetail?: typeof fetchSessionDetail;
};
export function runConsultationRecoveryPoll(
deps: ConsultationRecoveryPollDeps,
signal: AbortSignal,
): () => void {
const online = deps.online ?? (() => navigator.onLine);
const fetchStatus = deps.fetchConsultationStatus ?? fetchConsultationStatus;
const fetchDetail = deps.fetchSessionDetail ?? fetchSessionDetail;
let timer = 0;
let polling = false;
const poll = async () => {
if (polling || signal.aborted) return;
polling = true;
try {
if (!online()) {
deps.setComposerNotice("网络已断开,回答仍在后台生成;联网后会自动恢复。");
return;
}
const status = await fetchStatus(deps.pendingSessionId, deps.pendingRequestId, signal);
if (status.status === "reserved") {
deps.consultationStatusMissingCount.current = 0;
deps.setComposerNotice("回答仍在后台生成,正在自动恢复。");
return;
}
if (status.status === "completed") {
const detailed = await fetchDetail(deps.pendingSessionId, deps.modelCatalog, signal);
if (detailed) {
deps.setSessions((current) => mergeHydratedSession(current, detailed));
deps.setActiveSessionId((current) => current || detailed.id);
}
deps.pendingConsultation.current = null;
deps.setPendingSessionId(null);
deps.setPendingRequestId(null);
deps.setConsultationPhase(null);
deps.setStreamingReply(null);
deps.setRequestError(null);
deps.setComposerNotice("回答已恢复,已显示在对话区末尾。");
void deps.refreshAccount();
return;
}
deps.pendingConsultation.current = null;
deps.setPendingSessionId(null);
deps.setPendingRequestId(null);
deps.setConsultationPhase(null);
deps.setStreamingReply(null);
deps.setRequestError(null);
deps.setComposerNotice("回答已取消;问题仍保留在聊天记录中,可重新发送。");
} catch (caught) {
if (signal.aborted) return;
if (caught instanceof ConsultationStatusError && caught.status === 404) {
deps.consultationStatusMissingCount.current += 1;
if (deps.consultationStatusMissingCount.current === 1) {
deps.setComposerNotice("正在确认本次咨询请求是否已开始…");
return;
}
if (deps.consultationReplayStarted.current !== deps.pendingRequestId) {
deps.consultationReplay.current();
return;
}
if (deps.consultationStatusMissingCount.current >= 4) {
deps.pendingConsultation.current = null;
deps.setPendingSessionId(null);
deps.setPendingRequestId(null);
deps.setConsultationPhase(null);
deps.setStreamingReply(null);
deps.setRequestError({
sessionId: deps.pendingSessionId,
message: "后台未找到本次咨询请求,请重新发送。",
});
deps.setComposerNotice("后台未找到本次咨询请求,已停止恢复,请重新发送。");
return;
}
deps.setComposerNotice("正在重新发起本次咨询…");
return;
}
deps.consultationStatusMissingCount.current = 0;
deps.setComposerNotice(online()
? "回答仍在后台生成,正在自动恢复。"
: "网络已断开,回答仍在后台生成;联网后会自动恢复。");
} finally {
polling = false;
if (!signal.aborted && deps.pendingConsultation.current?.phase === "recovering") {
timer = window.setTimeout(() => void poll(), 1_750);
}
}
};
const wakeup = () => {
window.clearTimeout(timer);
void poll();
};
const clearOnAbort = () => {
window.clearTimeout(timer);
};
if (signal.aborted) clearOnAbort();
else signal.addEventListener("abort", clearOnAbort);
if (deps.consultationStatusMissingCount.current > 0) {
timer = window.setTimeout(() => void poll(), 1_750);
} else {
void poll();
}
return wakeup;
}
+475
View File
@@ -0,0 +1,475 @@
/**
* 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 {
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<Storage, "getItem" | "removeItem">;
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<void>;
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<HomeBootstrapIo>;
};
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<HomeBootstrapIo, "restoreConsultationRecovery"> = {
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<AccountPhaseResult> {
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,
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.sessionListReady;
if (signal.aborted) return { kind: "stop" };
const listBoot = deps.sessionListBoot();
if (listBoot?.signedOut || !listBoot?.account) {
io.redirectToLogin();
return { kind: "stop" };
}
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("self", [], 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<void> {
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,
});
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("self", [], 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<void> {
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");
}
}
}