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:
+91
-524
@@ -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} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user