Files
Jyotisha/frontend/src/lib/home-bootstrap-run.ts
T
Jesse_ChenandClaude Opus 5.5 638b6a60c0 fix(home): warm return to / without replaying the loading ring (BUG-1040)
Coming back to / from /chart, /ephemeris, /reports or /people by client
navigation remounted Home from hydrated=false and replayed the whole
bootstrap: model catalog, consult status, entry summary and daily card,
3-5 round trips plus up to 4 s of reveal budget, every time.

The reveal now counts per document load, not per mount:
- lib/home-warm-snapshot.ts: memory-only, per-account snapshot of the
  model catalog, rectification entry summary, session cursor and today's
  card (keyed by person + birth fingerprint + day). Account, profile and
  the session list already survive in SessionListProvider. Cleared on
  account change, sign-out, provider 401 and redirectToLogin.
- lib/home-warm-start.ts: Home's useState initializers take one warm
  decision per mount. Complete snapshot + settled list + complete profile
  + a landing computable in memory -> start ready, landing resolved with
  the cold-path functions (?new=1, ?c=, login-return stash and its person
  scope from BUG-1038). Anything missing or needing a lookup -> the
  unchanged cold path (no half-reveal, BUG-1021).
- runHomeWarmRefresh commits the landing, then refreshes catalog, account
  and background-consultation recovery in the background (recovery shares
  resolveReservedConsultation with the cold path). Summary and daily card
  refresh through their existing effects.
- Next renders the new page before it writes the address bar, so AppLink /
  navigateAppPath note the target href (lib/client-navigation-target.ts);
  unknown target -> cold path.

Home() useState 33 / useRef 37 unchanged; page.tsx 1329 -> 1373 lines.

Tests: home-warm-return-lifecycle (13, real Home + sidebar + provider,
navigating in Next's render-then-write-URL order; 11 fail with warm start
disabled) and home-warm-snapshot (12). Full suite 3953 / 61 failing,
failure names identical to the 3928 / 61 baseline. Build: / stays Static,
rootMainFiles gzip 130933 B unchanged. Local Chrome with every /api/*
held 1.5 s: /chart -> 新建对话 interactive in 18-23 ms with no loading
ring (baseline 5530 ms with ring).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 08:21:35 +08:00

637 lines
26 KiB
TypeScript

/**
* 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";
import {
applyWarmLanding,
claimWarmLandingCommit,
reconcileSessionModels,
sameModelCatalog,
type HomeWarmStart,
} from "@/lib/home-warm-start";
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>;
waitForSessionList?: () => 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,
};
}
/**
* Which consultation is still generating in the background: the account-wide
* active status, overridden by this tab's stored pending request. Shared by
* the cold bootstrap and the warm return (BUG-1040), so recovery is the same.
*/
async function resolveReservedConsultation(input: {
io: Pick<HomeBootstrapIo, "fetchConsultationStatus">;
storage: HomeBootstrapStorage;
storedRaw: string | null;
sessions: readonly ChatSession[];
activeStatus: ConsultationStatus | null;
consultationStatusMissingCount: { current: number };
signal: AbortSignal;
}): Promise<{ reservedConsultation: ConsultationStatus | null; storedPending: StoredPendingConsultation | null }> {
let reservedConsultation = input.activeStatus;
const storedPending = dropRectificationStoredPending(
readStoredPendingConsultation(input.storedRaw, input.sessions.map((session) => session.id)),
[...input.sessions],
);
if (!storedPending) input.storage.removeItem(pendingConsultationStorageKey);
if (storedPending) {
try {
const status = await input.io.fetchConsultationStatus(
storedPending.sessionId,
storedPending.requestId,
input.signal,
);
if (status.status === "reserved") {
input.consultationStatusMissingCount.current = 0;
reservedConsultation = status;
} else {
input.storage.removeItem(pendingConsultationStorageKey);
reservedConsultation = null;
}
} catch (caught) {
if (caught instanceof Error && caught.name === "AbortError") throw caught;
input.consultationStatusMissingCount.current = caught instanceof ConsultationStatusError && caught.status === 404 ? 1 : 0;
reservedConsultation = {
sessionId: storedPending.sessionId,
requestId: storedPending.requestId,
status: "reserved",
};
}
}
return { reservedConsultation, storedPending };
}
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,
// 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);
const activeStatus = await activeStatusPromise;
const { reservedConsultation, storedPending } = await resolveReservedConsultation({
io,
storage: deps.storage,
storedRaw: deps.storage.getItem(pendingConsultationStorageKey),
sessions: nextSessions,
activeStatus,
consultationStatusMissingCount: deps.consultationStatusMissingCount,
signal,
});
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,
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<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");
}
}
}
export type HomeWarmRefreshDeps = {
start: HomeWarmStart;
storage: HomeBootstrapStorage;
consultationStatusMissingCount: { current: number };
/** The list as Home holds it now (sessionsRef). */
currentSessions: () => ChatSession[];
restoreConsultationRecovery: HomeBootstrapIo["restoreConsultationRecovery"];
/** Home's rectification auto-open, for a stashed return whose `?c=` is written only now. */
resumeRectification: (session: ChatSession) => void;
refreshAccount: () => Promise<void>;
setComposerNotice: (notice: string) => void;
commit: {
setActiveSessionId: (id: string) => void;
setModelCatalog: (catalog: PublicLanguageModelCatalog) => void;
setSessions: (update: (current: ChatSession[]) => ChatSession[]) => void;
setSessionsCursor: (cursor: string | null) => void;
};
io?: Partial<HomeBootstrapIo>;
};
function applyWarmCatalog(
deps: HomeWarmRefreshDeps,
io: Pick<HomeBootstrapIo, "patchSessionModel">,
catalog: PublicLanguageModelCatalog,
signal: AbortSignal,
) {
const fallbackSessionIds = reconcileSessionModels(deps.currentSessions(), catalog).fallbackSessionIds;
deps.commit.setSessions((current) => reconcileSessionModels(current, catalog).sessions);
// A local chat that was never saved has nothing to patch or to announce.
const known = new Set(deps.currentSessions().filter((session) => !isUnsavedEmptyConsultation(session)).map((session) => session.id));
const saved = fallbackSessionIds.filter((id) => known.has(id));
if (saved.length === 0) return false;
deps.setComposerNotice("此前选择的模型已下线,已切换为默认模型。");
void Promise.all(saved.map((sessionId) => io.patchSessionModel(sessionId, catalog.defaultModelId, signal))).catch(() => {
if (!signal.aborted) deps.setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。");
});
return true;
}
/** Synchronous part of a warm return: the same landing side effects as `resolveLanding`. */
function commitWarmLanding(
deps: HomeWarmRefreshDeps,
io: Pick<HomeBootstrapIo, "writeSessionUrl" | "clearLoginSessionReturn" | "patchSessionModel">,
signal: AbortSignal,
) {
const { landing, modelCatalog, sessionsCursor } = deps.start;
const selection = landing.selection;
const landed = applyWarmLanding(deps.currentSessions(), landing);
deps.commit.setSessions((current) => applyWarmLanding(current, landing));
deps.commit.setSessionsCursor(sessionsCursor);
if (selection.urlAction === "new-chat") io.writeSessionUrl(null, "replace");
if (selection.clearStoredReturn) io.clearLoginSessionReturn();
if (selection.urlAction === "replace-clear") io.writeSessionUrl(null, "replace");
if (selection.urlAction === "replace-selected") {
const selected = landed.find((session) => session.id === selection.sessionId);
if (!selected || !isUnsavedEmptyConsultation(selected)) {
io.writeSessionUrl(selection.sessionId, "replace");
// Home's auto-open effect already ran on this mount, before the URL
// named the session; without this a stashed rectification session
// would sit locked, as in BUG-1038.
if (selected?.sessionType === "birth_time_rectification" && selected.id === landing.activeSessionId) {
deps.resumeRectification(selected);
}
}
}
const fellBack = applyWarmCatalog(deps, io, modelCatalog, signal);
if (!fellBack && selection.missing) deps.setComposerNotice(SESSION_MISSING_NOTICE);
}
/**
* Warm return to `/` (BUG-1040). Home is already painted from the snapshot;
* this commits the landing and then refreshes in the background: model
* catalog, account, and background-consultation recovery. Results replace
* values silently. Nothing here is awaited before the page is usable, and
* nothing here returns Home to the loading state. The rectification entry
* summary and today's card refresh through their own effects.
*/
export async function runHomeWarmRefresh(deps: HomeWarmRefreshDeps, signal: AbortSignal): Promise<void> {
const io = { ...defaultIo, ...deps.io };
const { start } = deps;
if (claimWarmLandingCommit(start)) commitWarmLanding(deps, io, signal);
if (signal.aborted) return;
const catalogTask = io.fetchModelCatalog(signal)
.then((next) => {
if (signal.aborted || sameModelCatalog(next, start.modelCatalog)) return;
deps.commit.setModelCatalog(next);
applyWarmCatalog(deps, io, next, signal);
})
// The snapshot catalog stays; the next document load reads it again.
.catch(() => undefined);
const recoveryTask = (async () => {
const activeStatus = await io.fetchActiveConsultationStatus(signal).catch((caught: unknown) => {
if (caught instanceof Error && caught.name === "AbortError") throw caught;
return null;
});
const { reservedConsultation, storedPending } = await resolveReservedConsultation({
io,
storage: deps.storage,
storedRaw: start.storedPendingRaw,
sessions: deps.currentSessions(),
activeStatus,
consultationStatusMissingCount: deps.consultationStatusMissingCount,
signal,
});
if (signal.aborted || reservedConsultation?.status !== "reserved") return;
const recoverySession = deps.currentSessions().find((session) => session.id === reservedConsultation.sessionId);
if (recoverySession) deps.restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending);
if (start.landing.selection.urlAction === "new-chat") {
// Same rule as the cold path: recovery continues in the background and
// does not take over an explicit new-chat landing.
deps.commit.setActiveSessionId(start.landing.activeSessionId);
deps.setComposerNotice("");
}
})().catch(() => undefined);
const accountTask = deps.refreshAccount().catch(() => undefined);
await Promise.all([catalogTask, recoveryTask, accountTask]);
}