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
@@ -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");
}
}
}