Product decision 2026-09-27 (D1-D4), overriding the BUG-1038 login-return stash and the "latest session" default landing: - A full load of / without ?c= (login, typed address, bookmark, refresh of bare /) lands on the current person's blank starter home; an existing empty draft of that person is reused, otherwise one is created locally. - ?c= and ?new=1 are unchanged; a refresh inside a conversation keeps its ?c=. - The login-return stash is removed: redirectToLogin and sidebar links no longer write it, bootstrap no longer reads it and clears a leftover value once. replace-selected, the lookup origin/other-subject branch and the warm resumeRectification dependency go with it. - A background answer being recovered no longer takes over the blank home (same rule BUG-1015 set for ?new=1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
621 lines
26 KiB
TypeScript
621 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,
|
|
landingYieldsToRecovery,
|
|
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, replaceUnsavedEmptyConsultations } 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,
|
|
});
|
|
let nextSessions = lookedUp.sessions;
|
|
const bootstrapSelection: BootstrapSessionSelection = lookedUp.selection;
|
|
// Bare `/` lands on the blank starter home of the current person (BUG-1052):
|
|
// an empty draft of that person is reused, otherwise one is created locally.
|
|
const inSubjectScope = nextSessions.filter((session) => sessionMatchesSubject(session, readCurrentSubjectId()));
|
|
let landingSessionId = resolveStarterHomeLandingSessionId(
|
|
inSubjectScope,
|
|
bootstrapSelection.sessionId,
|
|
bootstrapSelection.urlAction,
|
|
);
|
|
if (starterHomeLandingNeedsConsultation(inSubjectScope, landingSessionId, bootstrapSelection.urlAction)) {
|
|
const homeSession = createSession(
|
|
ready.nextModelCatalog?.defaultModelId ?? "",
|
|
"consultation",
|
|
chartSnapshotForSession(readCurrentSubjectId(), [...(peekSubjectCatalog() ?? [])], ready.nextProfile),
|
|
);
|
|
nextSessions = replaceUnsavedEmptyConsultations(nextSessions, homeSession);
|
|
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);
|
|
// An older build stashed the last `?c=` here; drop it so it never matters.
|
|
clearLoginSessionReturn();
|
|
if (bootstrapSelection.urlAction === "replace-clear") writeSessionUrl(null, "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);
|
|
// The blank home and an explicit new chat are not taken over; the older
|
|
// answer keeps recovering in the background (BUG-1015/1052).
|
|
if (!landingYieldsToRecovery(bootstrapSelection.urlAction)) {
|
|
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"];
|
|
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" | "patchSessionModel">,
|
|
signal: AbortSignal,
|
|
) {
|
|
const { landing, modelCatalog, sessionsCursor } = deps.start;
|
|
const selection = landing.selection;
|
|
deps.commit.setSessions((current) => applyWarmLanding(current, landing));
|
|
deps.commit.setSessionsCursor(sessionsCursor);
|
|
if (selection.urlAction === "new-chat") io.writeSessionUrl(null, "replace");
|
|
if (selection.urlAction === "replace-clear") io.writeSessionUrl(null, "replace");
|
|
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 (!landingYieldsToRecovery(start.landing.selection.urlAction)) {
|
|
// Same rule as the cold path: recovery continues in the background and
|
|
// does not take over the blank home or an explicit new chat.
|
|
deps.commit.setActiveSessionId(start.landing.activeSessionId);
|
|
deps.setComposerNotice("");
|
|
}
|
|
})().catch(() => undefined);
|
|
|
|
const accountTask = deps.refreshAccount().catch(() => undefined);
|
|
await Promise.all([catalogTask, recoveryTask, accountTask]);
|
|
}
|