Files
Jyotisha/frontend/src/app/page.tsx
T
Jesse_ChenandClaude Fable 5.1 482796fc52 fix(chat): reveal the home page once after a two-phase bootstrap
The loading screen now owns every wait: starter questions, today's
starlanguage and the rectification entry summary are fetched in a prepare
phase before reveal (4s budget, static fallbacks), the rectification chunk is
warmed, recent sessions are prefetched, and no component spinner remains
after the page appears. BUG-479.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-02 04:39:04 +00:00

2020 lines
92 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import "@/app/site-styles";
import Link from "next/link";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { Sparkles } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
import { ThemePreferencePanel } from "@/components/theme-preference-menu";
import { AppSidebar } from "@/components/app-sidebar";
import { UserAvatar } from "@/components/user-avatar";
import {
BirthTimeAssessmentOverlay,
type BirthTimeAssessmentPhase,
} from "@/components/birth-time-assessment-overlay";
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { AppLoadingIndicator } from "@/components/app-loading-indicator";
import {
entrySummaryFromResponse,
rectificationEntryLabels,
resolveRectificationEntryAction,
type RectificationEntrySummary,
} from "@/lib/rectification-entry";
import { ConversationalBirthTimeRectification, type PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
import {
toggleChatMessageFeedback,
type ChatMessageFeedback,
} from "@/components/chat-message-actions";
import { ChatTranscript, type ChatTranscriptActions } from "@/components/chat-transcript";
import { ModelSelector } from "@/components/model-selector";
import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall";
import { ChatComposer } from "@/components/chat-composer";
import { JumpToLatestButton } from "@/components/jump-to-latest-button";
import { ComposerCharacterRemaining } from "@/components/composer-character-remaining";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
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 { StarterHome } from "@/components/starter-home";
import {
beamAvatarPalettes,
} from "@/lib/beam-avatar";
import {
GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
isGeneralDailyFortuneQuestion,
isRectificationHandoffQuestion,
type ConsultationEntrypoint,
} from "@/lib/consultation-entrypoint";
import {
applyBirthTimeDraftPatch,
assistantIntentCopy,
hydrateDeclaredWindowDraft,
isBirthTimeDraftReady,
birthTimeDraftReadyHint,
} from "@/lib/birth-time-intake-model";
import {
clearBirthTimeConsultationConsent,
createLatestAccountRequestGuard,
createBirthTimeConsultationConsentState,
grantBirthTimeConsultationConsent,
resolveBirthTimeConsultationRoute,
type BirthTimeConsultationConsentState,
} from "@/lib/birth-time-consultation-consent";
import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode";
import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey";
import { useConsultationRun } from "@/hooks/use-consultation-run";
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
import { useProfileOnboarding } from "@/hooks/use-profile-onboarding";
import { useRectificationSurface } from "@/hooks/use-rectification-surface";
import { useSessionManagement } from "@/hooks/use-session-management";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
import { chatReplyAnnouncement, type ChatReplyPhase } from "@/lib/chat-reply-announcement";
import {
type JourneyClientResponse,
} from "@/lib/birth-time-journey-client";
import {
guidedBirthTimePreview,
isGuidedBirthTimePreview,
previewRectificationJourney,
} from "@/lib/birth-time-guided-preview";
import { generalGuidedJyotishTopics } from "@/lib/guided-jyotish-topics";
import { keepFocusWithin } from "@/lib/focus-trap";
import {
BALANCE_SYNC_KEY,
BALANCE_CHANGED_EVENT,
membershipHref,
} from "@/lib/membership";
import { nextActivityView, activityCompletedTrail, type AgentActivityView } from "@/lib/chat-message-view";
import {
chartLibraryFromCloudOthers,
chartLibraryOnCloudFailure,
chartLibrarySessionBranch,
} from "@/lib/chart-library-session";
import {
emptyConsultationTimeline,
reduceConsultationTimeline,
type ConsultationTimelineRow,
} from "@/lib/consultation-run-timeline";
import {
createNdjsonParser,
type AgentExecutionReceipt,
type ConsultationAgentPublicEvent,
} from "@/lib/consultation-agent-events";
import {
applyThinkingSectionProgress,
upsertThinkingSection,
type PublicThinkingSection,
} from "@/lib/consultation-thinking-plan";
import {
CONSULTATION_CHART_CALCULATION_LABEL,
CONSULTATION_COMPOSING_LABEL,
CONSULTATION_DONE_CHART_LABEL,
CONSULTATION_DONE_SKILL_LABEL,
CONSULTATION_EVIDENCE_VALIDATION_LABEL,
CONSULTATION_LOADING_METHOD_LABEL,
} from "@/lib/consultation-activity-labels";
import { writeChatSession } from "@/lib/chat-session-write-contract";
import {
SESSION_MISSING_NOTICE,
clearLoginSessionReturn,
parseSessionUrlQuery,
readLoginSessionReturn,
resolveBootstrapSessionSelection,
writeSessionUrl,
} from "@/lib/chat-session-url";
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
import {
OnboardingAuthenticationError,
type OnboardingContent,
createStartGreeting,
createStartGreetingParts,
isCurrentOnboardingRequest,
onboardingProfileFingerprint,
onboardingRequestIdentity,
requestOnboardingWithRecovery,
} from "@/lib/onboarding-client";
import {
calendarDateInTimeZone,
dailyStarlanguageProfileKey,
} from "@/lib/daily-starlanguage";
import { preserveShallowEqual } from "@/lib/preserve-shallow-equal";
import {
SessionModelPersistenceQueue,
persistSessionModelSelection,
} from "@/lib/session-model-persistence";
import {
type PublicLanguageModelCatalog,
} from "@/lib/public-models";
import {
accountDialogClasses,
accountDialogTitles,
dailyStarlanguageRetryDelayMs,
emptyProfile,
pendingConsultationStorageKey,
presetOnboardingMessage,
previewModelCatalog,
themes,
timestamp,
type Account,
type AccountDialog,
type ChartLibraryRecord,
type ChartRelationship,
type ChatSession,
type ConsultationStatus,
type DailyStarlanguageState,
type Message,
type OnboardingStep,
type PendingConsultation,
type Profile,
type ReplyOutcome,
type RequestError,
type StoredPendingConsultation,
type StreamingReply,
type SynastryRelationshipType,
type SynastryReportCard,
type Theme,
} from "@/lib/home-types";
import {
birthQuestion,
buildSynastryQuestion,
chartSnapshotForSession,
completedOnboardingMessage,
completedOnboardingTranscript,
formatBirthMoment,
isProfileComplete,
missingProfileStep,
placeQuestion,
readProfile,
selectedBirthPlace,
sessionChartLabel,
sessionSidebarTitle,
upsertSelfChart,
} from "@/lib/home-profile";
import {
activeChartStorageKey,
applyLegacySessionControls,
CancellationResponseError,
ConsultationResponseError,
ConsultationStatusError,
createSession,
discardLegacyCloudMirrorKeys,
fetchAccount,
fetchActiveConsultationStatus,
fetchCloudChartLibrary,
fetchCloudSynastryHistory,
fetchConsultationStatus,
fetchDailyStarlanguage,
fetchModelCatalog,
fetchSessionDetail,
fetchSessions,
friendlyError,
LoginRedirectError,
mergeHydratedSession,
patchSessionModel,
payloadCode,
payloadMessage,
readSessions,
readStoredDailyStarlanguage,
readStoredPendingConsultation,
redirectToLogin,
saveCloudSynastryReport,
waitForUndoWindow,
writeStoredDailyStarlanguage,
} from "@/lib/home-cloud-sync";
import {
bootstrapLoadingCopy,
bootstrapPrepareSettled,
bootstrapRevealDelayMs,
sessionIdsToPrefetch,
type BootstrapPhase,
} from "@/lib/home-bootstrap";
const BirthTimeRectification = dynamic(
() => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification),
{
ssr: false,
loading: () => <p className="birth-time-assistant-intent" role="status">正在加载出生时间评估...</p>,
},
);
export default function Home() {
const router = useRouter();
const [profile, setProfile] = useState<Profile>(emptyProfile);
const [profileDraft, setProfileDraft] = useState<Profile>(emptyProfile);
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [activeAccountDialog, setActiveAccountDialog] = useState<AccountDialog | null>(null);
const [chartLibrary, setChartLibrary] = useState<ChartLibraryRecord[]>([]);
const [activeChartId, setActiveChartId] = useState("self");
const [synastryRelationshipType, setSynastryRelationshipType] = useState<SynastryRelationshipType>("romance");
const [synastryPendingId, setSynastryPendingId] = useState<string | null>(null);
const [otherProfileDraft, setOtherProfileDraft] = useState<Profile>(emptyProfile);
const [otherChartRelationship, setOtherChartRelationship] = useState<Exclude<ChartRelationship, "self">>("other");
const [editingChartId, setEditingChartId] = useState<string | null>(null);
const [editingSelfChart, setEditingSelfChart] = useState(false);
const [synastryReportCard, setSynastryReportCard] = useState<SynastryReportCard | null>(null);
const [synastryHistory, setSynastryHistory] = useState<SynastryReportCard[]>([]);
const [dailyStarlanguage, setDailyStarlanguage] = useState<DailyStarlanguageState>({ kind: "pending" });
const [profileNotice, setProfileNotice] = useState("");
const [avatarNotice, setAvatarNotice] = useState("");
const [avatarSaving, setAvatarSaving] = useState(false);
const [account, setAccount] = useState<Account | null>(null);
const [accountError, setAccountError] = useState("");
const [signingOut, setSigningOut] = useState(false);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [showArchivedSessions, setShowArchivedSessions] = useState(false);
const [sessionMenuId, setSessionMenuId] = useState<string | null>(null);
const [pendingSessionDeletion, setPendingSessionDeletion] = useState<ChatSession | null>(null);
const [modelCatalog, setModelCatalog] = useState<PublicLanguageModelCatalog | null>(null);
const [activeSessionId, setActiveSessionId] = useState("");
const draftTheme = useRef<Theme | null>(null);
const draftEntrypoint = useRef<ConsultationEntrypoint | null>(null);
const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | "recovering" | null>(null);
const [cancellationPending, setCancellationPending] = useState(false);
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
const [streamingReply, setStreamingReply] = useState<StreamingReply | null>(null);
const [messageFeedback, setMessageFeedback] = useState<Record<string, ChatMessageFeedback>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [replyOutcome, setReplyOutcome] = useState<ReplyOutcome | null>(null);
const [requestError, setRequestError] = useState<RequestError | null>(null);
const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState<BirthTimeConsultationConsentState>(
createBirthTimeConsultationConsentState,
);
const [rectificationSessionId, setRectificationSessionId] = useState<string | null>(null);
const [rectificationCaseId, setRectificationCaseId] = useState<string | null>(null);
const [rectificationHeaderSlot, setRectificationHeaderSlot] = useState<HTMLElement | null>(null);
const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState<string | null>(null);
const [rectificationLoading, setRectificationLoading] = useState(false);
const [rectificationMutationPending, setRectificationMutationPending] = useState(false);
const [rectificationError, setRectificationError] = useState("");
const [rectificationReadonly, setRectificationReadonly] = useState(false);
const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false);
const [rectificationTurns, setRectificationTurns] = useState<PersistedRectificationTurn[]>([]);
const [rectificationEntrySummary, setRectificationEntrySummary] = useState<RectificationEntrySummary | null>(null);
const [hydrated, setHydrated] = useState(false);
const [bootstrapPhase, setBootstrapPhase] = useState<BootstrapPhase>("account");
const [rectificationEntrySummarySettled, setRectificationEntrySummarySettled] = useState(false);
const prepareStartedAt = useRef<number | null>(null);
const [guidedJourneyPreview, setGuidedJourneyPreview] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
const [creatingSession, setCreatingSession] = useState(false);
const [sessionDetailLoadingId, setSessionDetailLoadingId] = useState<string | null>(null);
const [sessionFullPrompt, setSessionFullPrompt] = useState<{ question: string; theme: Theme } | null>(null);
const [onboarding, setOnboarding] = useState<OnboardingContent | null>(null);
const [onboardingError, setOnboardingError] = useState("");
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep>("name");
const [onboardingJustCompleted, setOnboardingJustCompleted] = useState(false);
const [onboardingPaywallOpen, setOnboardingPaywallOpen] = useState(false);
const [birthTimeJourney, setBirthTimeJourney] = useState<JourneyClientResponse | null>(null);
const [birthTimeError, setBirthTimeError] = useState("");
const [birthTimeAssessmentPhase, setBirthTimeAssessmentPhase] = useState<BirthTimeAssessmentPhase | null>(null);
const [startGreeting, setStartGreeting] = useState("");
const [starterGreetingSelection, setStarterGreetingSelection] = useState(() => Math.random());
const [presetMessageLength, setPresetMessageLength] = useState(0);
const conversation = useRef<HTMLDivElement>(null);
const accountTrigger = useRef<HTMLButtonElement>(null);
const accountDialog = useRef<HTMLElement>(null);
const dialogReturnTarget = useRef<HTMLButtonElement | null>(null);
const closeButton = useRef<HTMLButtonElement>(null);
const onboardingPaywallShown = useRef(false);
const starterGreetingUnseen = useRef(true);
const composerInput = useRef<HTMLTextAreaElement>(null);
const pendingConsultation = useRef<PendingConsultation | null>(null);
const cancellationRequests = useRef(new Map<string, Promise<void>>());
const cancellationFeedbackRequest = useRef<string | null>(null);
const cancellationInFlight = useRef(false);
const stoppedRequestAwaitingSettlement = useRef<string | null>(null);
const stoppedSessionPersistence = useRef(new Map<string, Promise<void>>());
const consultationRecoveryWakeup = useRef<() => void>(() => undefined);
const consultationRecoveryCheck = useRef<() => void>(() => undefined);
const consultationReplay = useRef<() => void>(() => undefined);
const consultationStatusMissingCount = useRef(0);
const consultationReplayStarted = useRef<string | null>(null);
const modelPersistence = useRef(new SessionModelPersistenceQueue());
const modelSyncFailures = useRef(new Set<string>());
const modelSelectionVersions = useRef(new Map<string, number>());
const activeSessionIdRef = useRef("");
const sessionSelectionSource = useRef<"user" | "history">("user");
const applySessionPopStateRef = useRef<(search: string) => void>(() => undefined);
const chartLibraryLoadedAccount = useRef("");
const chatActionsRef = useRef<ChatTranscriptActions>({
onFeedback() {},
onCopy() {},
onRegenerate() {},
onFollowUp() {},
});
const activeOnboardingRequestIdentity = useRef("");
const accountRefreshGuard = useRef(createLatestAccountRequestGuard());
const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined);
const rectificationOpenInFlight = useRef(false);
const sessionDetailInFlight = useRef(new Set<string>());
const sessionsRef = useRef(sessions);
const rectificationSessionOpenerRef = useRef(async (_exactSessionId: string) => {});
const guidedBirthTimeReadyRef = useRef((_result: JourneyClientResponse) => {});
const editDeclaredBirthTimeDetailsRef = useRef(() => {});
const uiPreview = useRef(false);
const uiPreviewMode = useRef<string | null>(null);
const birthTimeRevisionPending = useRef(false);
const birthTimeGuided = useBirthTimeGuidedJourney({
journey: birthTimeJourney,
preview: guidedJourneyPreview,
onJourney: setBirthTimeJourney,
onReady: (result) => guidedBirthTimeReadyRef.current(result),
onEditBirthTimeDetails: () => editDeclaredBirthTimeDetailsRef.current(),
});
const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0];
const activeRectificationSession = activeSession?.sessionType === "birth_time_rectification";
const rectificationSurfaceOpen = activeRectificationSession
&& activeSession.id === rectificationSessionId;
const visibleSessions = sessions
.filter((session) => showArchivedSessions ? Boolean(session.archivedAt) : !session.archivedAt)
.filter((session) => session.sessionType === "birth_time_rectification"
|| session.messages.length > 0
|| !session.messagesHydrated
|| session.id === activeSessionId)
.sort((left, right) => Number(right.pinned) - Number(left.pinned));
const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : "";
const isLoading = pendingSessionId === activeSession?.id;
const productEntrypointsDisabled = !hydrated
|| Boolean(pendingSessionId)
|| cancellationPending
|| creatingSession
|| rectificationMutationPending
|| !account
|| !modelCatalog;
const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : "";
const activeStreamingActivity = streamingReply && streamingReply.sessionId === activeSession?.id
? streamingReply.activity
: undefined;
const activeStreamingThinking = streamingReply && streamingReply.sessionId === activeSession?.id
? streamingReply.thinkingText
: undefined;
const activeStreamingSections = streamingReply && streamingReply.sessionId === activeSession?.id
? streamingReply.thinkingSections
: undefined;
const activeStreamingTimeline = streamingReply && streamingReply.sessionId === activeSession?.id
? streamingReply.timeline
: undefined;
const activeReplyOutcome = replyOutcome && replyOutcome.sessionId === activeSession?.id ? replyOutcome : null;
const replyPhase: ChatReplyPhase = isLoading
? consultationPhase === "recovering" ? "recovering" : "generating"
: activeReplyOutcome?.phase ?? "idle";
const replyAnnouncement = chatReplyAnnouncement(replyPhase, activeReplyOutcome?.replyOrdinal ?? 0);
const accountId = account?.user.id;
const rectificationCardAction = resolveRectificationEntryAction(
rectificationEntrySummary ?? {
hasResumableCase: false,
hasTerminalCaseWithTime: false,
latestResumable: null,
latestTerminal: null,
},
);
const rectificationCardLabel = rectificationEntryLabels[rectificationCardAction];
const rectificationErrorMessage = rectificationError === "profile_incomplete"
? "服务端未能读取完整出生资料,请重新确认并保存后再开始生时校正。"
: rectificationError;
const onboardingFingerprint = onboardingProfileFingerprint(profile);
const {
updateSession,
persistSession,
ensureSessionMessages,
continueInNewChat,
renameSession,
deleteSession,
togglePinnedSession,
toggleArchivedSession,
shareSession,
startNewChat,
startConsultationAfterRectification,
selectSession,
selectSessionModel,
} = useSessionManagement({
account, accountId, activeChartId, activeSession, activeSessionId, activeSessionIdRef,
applySessionPopStateRef, cancellationPending, chartLibrary, creatingSession, modelCatalog,
modelPersistence, modelSelectionVersions, modelSyncFailures, pendingSessionId, profile,
rectificationSessionId, sessionDetailInFlight, sessionSelectionSource, sessions, sessionsRef,
setActiveChartId, setActiveSessionId, setBirthTimeConsultationConsent, setCreatingSession,
setDraft, setDraftEntrypoint, setDraftTheme, setRectificationError, setRequestError,
setSessionDetailLoadingId, setSessionFullPrompt, setSessions, uiPreview, visibleSessions,
openRectificationSession: (exactSessionId) => rectificationSessionOpenerRef.current(exactSessionId),
});
const {
refreshAccount,
openAccountDialog,
closeAccountDialog,
persistAvatar,
saveProfile,
saveOnboardingName,
saveOnboardingBirth,
saveOnboardingPlace,
retryBirthTimeAssessment,
signOut,
} = useProfileOnboarding({
account, accountRefreshGuard, accountTrigger, activeChartId, avatarSaving,
birthTimeRevisionPending, chartLibrary, dialogReturnTarget, profile, profileDraft,
profileSaving, setAccount, setAccountError, setAccountMenuOpen, setActiveAccountDialog,
setActiveChartId, setAvatarNotice, setAvatarSaving, setBirthTimeAssessmentPhase,
setBirthTimeConsultationConsent, setBirthTimeError, setBirthTimeJourney, setDraft,
setEditingSelfChart, setOnboardingJustCompleted, setOnboardingStep, setPresetMessageLength,
setProfile, setProfileDraft, setProfileNotice, setProfileSaving, setRectificationError,
setSigningOut, setStartGreeting, signingOut, uiPreview,
guidedBirthTimeReadyRef, editDeclaredBirthTimeDetailsRef,
});
const {
refreshRectificationCase,
openRectificationFromHomepage,
startNewRectification,
handleRectificationProfileIncomplete,
handleRectificationMessagesChange,
} = useRectificationSurface({
account, activeChartId, activeSessionIdRef, chartLibrary, creatingSession, modelCatalog,
persistSession, profile, rectificationLoading, rectificationMutationPending,
rectificationOpenInFlight, rectificationSessionId, resumeRectificationSession,
sessionSelectionSource, sessions, setActiveSessionId, setDraft, setDraftEntrypoint,
setDraftTheme, setOnboardingStep, setProfileNotice, setRectificationCaseId,
setRectificationEntrySummary, setRectificationError, setRectificationLoading,
setRectificationPendingQuestion, setRectificationReadonly, setRectificationSessionId,
setRectificationShouldStartOpening, setRectificationTurns, setSessions, uiPreview,
updateSession, openAccountDialog, refreshAccount, rectificationSessionOpenerRef,
});
useEffect(() => {
activeSessionIdRef.current = activeSessionId;
}, [activeSessionId]);
useEffect(() => {
if (!hydrated || uiPreview.current || !activeSessionId) return;
void ensureSessionMessages(activeSessionId);
}, [hydrated, activeSessionId, modelCatalog, pendingSessionId]);
useEffect(() => {
if (!hydrated || uiPreview.current) return;
const onPopState = () => applySessionPopStateRef.current(window.location.search);
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, [hydrated]);
useEffect(() => {
if (!hydrated
|| !account
|| !modelCatalog
|| activeSession?.sessionType !== "birth_time_rectification"
|| activeSession.id === rectificationSessionId
|| rectificationLoading
|| rectificationMutationPending
|| creatingSession
|| rectificationError) return;
resumeRectificationSession.current(activeSession);
}, [
account,
activeSession,
creatingSession,
hydrated,
modelCatalog,
rectificationError,
rectificationLoading,
rectificationMutationPending,
rectificationSessionId,
]);
useEffect(() => {
if (bootstrapPhase === "account" || !accountId) return;
void (async () => {
try {
const response = await fetch("/api/rectification/cases/entry-summary", { cache: "no-store" });
if (!response.ok) return;
const payload = await response.json().catch(() => null);
setRectificationEntrySummary(entrySummaryFromResponse(payload));
} catch {
// The CTA falls back to the server-agnostic default labels.
} finally {
setRectificationEntrySummarySettled(true);
}
})();
}, [accountId, bootstrapPhase]);
useEffect(() => {
if (!hydrated || !accountId) return;
const storedChartId = localStorage.getItem(activeChartStorageKey(accountId)) || "self";
queueMicrotask(() => setActiveChartId(storedChartId));
}, [accountId, hydrated]);
useEffect(() => {
const branch = chartLibrarySessionBranch(accountId, chartLibraryLoadedAccount.current);
if (branch === "clear" || !accountId) {
chartLibraryLoadedAccount.current = "";
queueMicrotask(() => {
setChartLibrary([]);
setSynastryHistory([]);
setActiveChartId("self");
});
return;
}
const profileForLibrary: Profile = activeChartId === "self" ? profile : account ? readProfile(account.profile) : profile;
if (branch === "hydrate-then-persist") {
chartLibraryLoadedAccount.current = accountId;
discardLegacyCloudMirrorKeys(accountId);
setChartLibrary(chartLibraryOnCloudFailure(profileForLibrary, upsertSelfChart));
setSynastryHistory([]);
void fetchCloudChartLibrary()
.then((cloudLibrary) => {
setChartLibrary(chartLibraryFromCloudOthers(cloudLibrary, profileForLibrary, upsertSelfChart));
})
.catch(() => {
setComposerNotice("星盘库暂时无法读取,请重试。", {
label: "重试",
onClick: () => {
void fetchCloudChartLibrary()
.then((cloudLibrary) => {
setChartLibrary(chartLibraryFromCloudOthers(cloudLibrary, profileForLibrary, upsertSelfChart));
})
.catch(() => {
setComposerNotice("星盘库暂时无法读取,请重试。");
});
},
});
});
void fetchCloudSynastryHistory()
.then((cloudHistory) => {
setSynastryHistory([...cloudHistory].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10));
})
.catch(() => {
setComposerNotice("合盘历史暂时无法读取,请重试。", {
label: "重试",
onClick: () => {
void fetchCloudSynastryHistory()
.then((cloudHistory) => {
setSynastryHistory([...cloudHistory].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10));
})
.catch(() => {
setComposerNotice("合盘历史暂时无法读取,请重试。");
});
},
});
});
}
setChartLibrary((current) => upsertSelfChart(current, profileForLibrary));
}, [account, accountId, activeChartId, profile]);
useEffect(() => {
if (!hydrated || !accountId || chartLibrary.length === 0) return;
const record = chartLibrary.find((item) => item.id === activeChartId)
|| chartLibrary.find((item) => item.role === "self");
if (!record) return;
if (record.id !== activeChartId) {
localStorage.setItem(activeChartStorageKey(accountId), record.id);
}
const nextChartId = record.id !== activeChartId ? record.id : null;
const nextProfile = record.profile;
const syncSelfDraft = record.role === "self";
queueMicrotask(() => {
if (nextChartId) setActiveChartId(nextChartId);
setProfile((current) => preserveShallowEqual(current, nextProfile));
if (syncSelfDraft) setProfileDraft((current) => preserveShallowEqual(current, nextProfile));
});
}, [accountId, activeChartId, chartLibrary, hydrated]);
const profileComplete = isProfileComplete(profile);
const birthTimeRoute = resolveBirthTimeConsultationRoute(profile, birthTimeConsultationConsent, activeSessionId);
const natalMinuteAvailable = birthTimeRoute.kind === "consult"
&& (birthTimeRoute.mode === "verified_chart" || birthTimeRoute.mode === "unverified_birth_time");
const personalConsultationAvailable = birthTimeRoute.kind === "consult"
&& birthTimeRoute.mode !== "general_no_birth_time";
const dailyStarlanguageFingerprint = dailyStarlanguageProfileKey(profile);
const starterThemes = personalConsultationAvailable ? themes : generalGuidedJyotishTopics;
const dailyStarlanguageQuestion = !natalMinuteAvailable
? GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION
: dailyStarlanguage.kind === "unavailable"
? "从今日问起"
: "深入看今日";
const dailyStarlanguageTrend = dailyStarlanguage.kind === "ready"
? dailyStarlanguage.card.trend
: "今天的星语还没写出来。";
const dailyStarlanguageAction = dailyStarlanguage.kind === "ready" ? dailyStarlanguage.card.action : "";
const onboardingPending = profileComplete && !onboarding && !onboardingError;
const bootstrapPrepareReady = bootstrapPrepareSettled({
profileComplete,
onboardingSettled: onboarding !== null || onboardingError !== "",
dailyStarlanguageApplicable: Boolean(accountId) && profileComplete && natalMinuteAvailable,
dailyStarlanguageSettled: dailyStarlanguage.kind !== "pending",
entrySummarySettled: rectificationEntrySummarySettled,
});
useEffect(() => {
if (hydrated || bootstrapPhase !== "prepare") return;
prepareStartedAt.current ??= Date.now();
const delay = bootstrapRevealDelayMs(Date.now(), prepareStartedAt.current, bootstrapPrepareReady);
const timer = window.setTimeout(() => setHydrated(true), delay);
return () => window.clearTimeout(timer);
}, [bootstrapPhase, bootstrapPrepareReady, hydrated]);
useEffect(() => {
if (bootstrapPhase !== "prepare" || uiPreview.current) return;
void import("@/components/birth-time-rectification");
}, [bootstrapPhase]);
useEffect(() => {
if (!hydrated || uiPreview.current) return;
let cancelled = false;
void (async () => {
for (const sessionId of sessionIdsToPrefetch(sessionsRef.current, activeSessionIdRef.current)) {
if (cancelled) return;
await ensureSessionMessages(sessionId);
}
})();
return () => {
cancelled = true;
};
}, [hydrated]);
const currentOnboardingMessage = onboardingJustCompleted
? startGreeting || completedOnboardingMessage(profileDraft.name.trim())
: onboardingStep === "birth"
? birthQuestion(profileDraft.name.trim())
: onboardingStep === "place"
? placeQuestion(profileDraft)
: onboardingStep === "rectification" && birthTimeJourney
? assistantIntentCopy(birthTimeJourney.snapshot.assistantIntent)
: presetOnboardingMessage;
const shouldStreamOnboarding = !profileComplete;
const presetMessageFinished = !shouldStreamOnboarding || presetMessageLength >= currentOnboardingMessage.length;
const onboardingCardReady = presetMessageFinished || birthTimeAssessmentPhase !== null;
const starterSuggestions = starterThemes.map((theme) => personalConsultationAvailable
? onboarding?.suggestions.find((item) => item.theme === theme.id) ?? { theme: theme.id, text: theme.prompt }
: { theme: theme.id, text: theme.prompt });
const starterHomeVisible = profileComplete
&& presetMessageFinished
&& !onboardingPending
&& !rectificationSurfaceOpen
&& Boolean(activeSession?.messagesHydrated)
&& !activeSession.messages.length;
const sessionMessagesLoading = Boolean(
activeSession
&& activeSession.sessionType === "consultation"
&& !activeSession.messagesHydrated
&& pendingSessionId !== activeSession.id,
);
const onboardingFormActive = !profileComplete && onboardingStep !== "name";
const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : "";
const starterGreeting = createStartGreetingParts(profile.name, new Date(), starterGreetingSelection);
const conversationAnchor = useConversationScrollAnchor(
conversation,
!rectificationSurfaceOpen && !starterHomeVisible,
activeSessionId,
);
const {
restoreConsultationRecovery,
stopResponse,
send,
regenerateLatestAnswer,
} = useConsultationRun({
account,
activeSession,
activeSessionIdRef,
birthTimeConsultationConsent,
cancellationFeedbackRequest,
cancellationInFlight,
cancellationPending,
cancellationRequests,
composerInput,
consultationRecoveryCheck,
consultationRecoveryWakeup,
consultationReplay,
consultationReplayStarted,
consultationStatusMissingCount,
conversationAnchor,
isLoading,
modelCatalog,
onboardingJustCompleted,
pendingConsultation,
pendingSessionId,
profile,
router,
sessions,
setAccount,
setActiveSessionId,
setCancellationPending,
setConsultationPhase,
setDraft,
setDraftEntrypoint,
setDraftTheme,
setMessageFeedback,
setOnboardingJustCompleted,
setPendingRequestId,
setPendingSessionId,
setProfileNotice,
setReplyOutcome,
setRequestError,
setSessionFullPrompt,
setSessions,
setStreamingReply,
startGreeting,
stoppedRequestAwaitingSettlement,
stoppedSessionPersistence,
uiPreview,
uiPreviewMode,
persistSession,
updateSession,
startNewChat,
continueInNewChat,
refreshAccount,
openAccountDialog,
openRectificationFromHomepage,
});
const jumpToLatestVisible = !rectificationSurfaceOpen
&& !starterHomeVisible
&& !conversationAnchor.anchored
&& Boolean(activeSession?.messages.length);
function setDraft(value: string) {
setComposerDraft(value);
}
function setDraftTheme(theme: Theme | null) {
draftTheme.current = theme;
}
function setDraftEntrypoint(entrypoint: ConsultationEntrypoint | null) {
draftEntrypoint.current = entrypoint;
}
useEffect(() => {
const controller = new AbortController();
const bootstrapTimeout = window.setTimeout(() => {
if (controller.signal.aborted) return;
controller.abort();
setAccountError("连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。");
setHydrated(true);
}, 8000);
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);
setOnboarding(previewMode === "onboarding"
? null
: { suggestions: themes.map(({ id, prompt }) => ({ theme: id, text: prompt })) });
setHydrated(true);
return;
}
const [nextAccount, modelCatalogResult, sessionsPayload] = await Promise.all([
fetchAccount(controller.signal),
fetchModelCatalog(controller.signal)
.then((catalog) => ({ catalog, unavailable: false }))
.catch((caught: unknown) => {
if (caught instanceof Error && caught.name === "AbortError") throw caught;
return { catalog: null, unavailable: true };
}),
fetchSessions(controller.signal),
]);
const nextModelCatalog = modelCatalogResult.catalog;
const nextProfile = readProfile(nextAccount.profile);
const parsedSessions = readSessions(sessionsPayload, nextModelCatalog);
let nextSessions = parsedSessions.sessions;
if (nextSessions.length === 0) {
if (controller.signal.aborted) return;
const initialSession = createSession(
nextModelCatalog?.defaultModelId ?? "",
"consultation",
chartSnapshotForSession("self", [], nextProfile),
);
if (nextModelCatalog) {
await writeChatSession(initialSession.id, {
title: initialSession.title,
theme: initialSession.theme,
model_id: initialSession.modelId,
messages: [],
session_type: initialSession.sessionType,
rectification_case_id: initialSession.rectificationCaseId,
chart_profile_id: initialSession.chartProfileId,
chart_profile_name: initialSession.chartProfileName,
chart_profile_role: initialSession.chartProfileRole,
}, "create");
}
nextSessions = [initialSession];
}
nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions);
let reservedConsultation: ConsultationStatus | null = null;
const storedPending: StoredPendingConsultation | null = readStoredPendingConsultation(
sessionStorage.getItem(pendingConsultationStorageKey),
nextSessions.map((session) => session.id),
);
if (!storedPending && sessionStorage.getItem(pendingConsultationStorageKey)) {
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);
}
} 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",
};
}
} else {
try {
reservedConsultation = await fetchActiveConsultationStatus(controller.signal);
consultationStatusMissingCount.current = 0;
} catch (caught) {
if (caught instanceof Error && caught.name === "AbortError") throw caught;
}
}
if (controller.signal.aborted) return;
const bootstrapSelection = resolveBootstrapSessionSelection({
listedIds: nextSessions.map((session) => session.id),
defaultSessionId: nextSessions[0].id,
search: window.location.search,
storedReturnId: readLoginSessionReturn(),
});
const activeListed = nextSessions.find((session) => session.id === bootstrapSelection.sessionId)
?? 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);
setAccount(nextAccount);
setModelCatalog(nextModelCatalog);
setProfile(nextProfile);
setProfileDraft(nextProfile);
setStartGreeting(nextProfile.name.trim() ? createStartGreeting(nextProfile.name) : "");
setOnboardingStep(missingProfileStep(nextProfile) ?? "name");
setSessions(nextSessions);
setActiveSessionId(bootstrapSelection.sessionId);
if (bootstrapSelection.clearStoredReturn) clearLoginSessionReturn();
if (bootstrapSelection.urlAction === "replace-clear") writeSessionUrl(null, "replace");
if (bootstrapSelection.urlAction === "replace-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 (modelCatalogResult.unavailable) {
setComposerNotice("模型服务暂时不可用,当前无法发送问题。");
} else if (parsedSessions.fallbackSessionIds.length > 0) {
setComposerNotice("此前选择的模型已下线,已切换为默认模型。");
} 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();
};
}, []);
useEffect(() => {
if (!hydrated || uiPreview.current) return;
if (pendingSessionId && pendingRequestId) {
sessionStorage.setItem(pendingConsultationStorageKey, JSON.stringify({
sessionId: pendingSessionId,
requestId: pendingRequestId,
question: pendingConsultation.current?.question ?? "",
theme: pendingConsultation.current?.theme ?? null,
entrypoint: pendingConsultation.current?.entrypoint ?? null,
}));
} else {
consultationStatusMissingCount.current = 0;
sessionStorage.removeItem(pendingConsultationStorageKey);
}
}, [hydrated, pendingRequestId, pendingSessionId]);
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();
}
return () => {
consultationRecoveryWakeup.current = () => undefined;
window.clearTimeout(timer);
controller.abort();
};
}, [consultationPhase, modelCatalog, pendingRequestId, pendingSessionId]);
useEffect(() => {
if (!hydrated || !shouldStreamOnboarding) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
const frame = window.requestAnimationFrame(() => setPresetMessageLength(currentOnboardingMessage.length));
return () => window.cancelAnimationFrame(frame);
}
const timer = window.setInterval(() => {
setPresetMessageLength((current) => {
const next = Math.min(current + 1, currentOnboardingMessage.length);
if (next === currentOnboardingMessage.length) window.clearInterval(timer);
return next;
});
}, 26);
return () => window.clearInterval(timer);
}, [currentOnboardingMessage, hydrated, shouldStreamOnboarding]);
useEffect(() => {
if (bootstrapPhase === "account" || !accountId || !profileComplete || uiPreview.current) return;
const requestIdentity = onboardingRequestIdentity(accountId, onboardingFingerprint);
if (isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return;
activeOnboardingRequestIdentity.current = requestIdentity;
const controller = new AbortController();
setOnboarding(null);
setOnboardingError("");
void requestOnboardingWithRecovery(controller.signal, () => {
if (!controller.signal.aborted
&& isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) {
setOnboardingError("个性化入门问题准备超时");
}
})
.then((content) => {
if (controller.signal.aborted
|| !isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return;
setOnboarding(content);
setOnboardingError("");
})
.catch((caught: unknown) => {
if (controller.signal.aborted
|| !isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return;
if (caught instanceof OnboardingAuthenticationError) {
window.location.assign("/login");
return;
}
setOnboardingError(caught instanceof Error ? caught.message : "暂时无法准备初始问题");
});
return () => {
if (isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) {
activeOnboardingRequestIdentity.current = "";
}
controller.abort();
};
}, [accountId, bootstrapPhase, onboardingFingerprint, profile.name, profileComplete]);
useEffect(() => {
if (bootstrapPhase === "account" || !accountId || !profileComplete || !natalMinuteAvailable) return;
const today = calendarDateInTimeZone(new Date(), profile.timezoneId);
const fingerprint = dailyStarlanguageProfileKey(profile);
const stored = readStoredDailyStarlanguage(accountId);
const controller = new AbortController();
let retryTimer: ReturnType<typeof setTimeout> | undefined;
const cachedCard = stored && stored.day === today && stored.fingerprint === fingerprint
? stored.card
: null;
queueMicrotask(() => {
if (controller.signal.aborted) return;
if (cachedCard) setDailyStarlanguage({ kind: "ready", card: cachedCard });
else setDailyStarlanguage({ kind: "pending" });
});
const attempt = (remainingRetries: number) => {
void fetchDailyStarlanguage(controller.signal)
.then((next) => {
if (controller.signal.aborted) return;
if (next.kind === "unavailable" && remainingRetries > 0) {
retryTimer = setTimeout(() => attempt(remainingRetries - 1), dailyStarlanguageRetryDelayMs);
return;
}
if (next.kind === "ready") {
writeStoredDailyStarlanguage(accountId, { day: today, fingerprint, card: next.card });
setDailyStarlanguage(next);
return;
}
if (stored && stored.day === today && stored.fingerprint === fingerprint) return;
setDailyStarlanguage(next);
})
.catch(() => {
if (controller.signal.aborted) return;
if (stored && stored.day === today && stored.fingerprint === fingerprint) return;
setDailyStarlanguage({ kind: "unavailable" });
});
};
attempt(1);
return () => {
controller.abort();
if (retryTimer !== undefined) clearTimeout(retryTimer);
};
}, [accountId, bootstrapPhase, dailyStarlanguageFingerprint, natalMinuteAvailable, profileComplete]);
useEffect(() => {
if (starterHomeVisible) {
starterGreetingUnseen.current = false;
return;
}
if (starterGreetingUnseen.current) return;
starterGreetingUnseen.current = true;
setStarterGreetingSelection(Math.random());
}, [starterHomeVisible]);
useEffect(() => {
if (hydrated && accountId && !profileComplete && onboardingStep === "name" && presetMessageFinished && activeAccountDialog === null) {
composerInput.current?.focus();
}
}, [accountId, activeAccountDialog, hydrated, onboardingStep, presetMessageFinished, profileComplete]);
useEffect(() => {
if (uiPreview.current
|| onboardingPaywallShown.current
|| !starterHomeVisible
|| !account
|| account.credits > 0
|| account.activeSubscription?.status === "active"
|| activeAccountDialog !== null) return;
onboardingPaywallShown.current = true;
setOnboardingPaywallOpen(true);
}, [account, activeAccountDialog, starterHomeVisible]);
useEffect(() => {
if (activeAccountDialog === null) return;
window.requestAnimationFrame(() => {
if (signingOut) return;
closeButton.current?.focus();
});
const closeOnEscape = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
if (signingOut) return;
setActiveAccountDialog(null);
const returnTarget = dialogReturnTarget.current;
window.requestAnimationFrame(() => returnTarget?.focus());
return;
}
const container = accountDialog.current;
if (container) keepFocusWithin(event, container);
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [activeAccountDialog, signingOut]);
useEffect(() => {
if (!accountId || !hydrated) return;
const onBalanceStorage = (event: StorageEvent) => {
if (event.key === BALANCE_SYNC_KEY) void refreshAccount();
};
const onBalanceChanged = () => void refreshAccount();
const onPageShow = () => {
void refreshAccount();
consultationRecoveryCheck.current();
};
const onOnline = () => consultationRecoveryCheck.current();
const onOffline = () => {
if (pendingConsultation.current?.phase === "recovering") {
setComposerNotice("网络已断开,回答仍在后台生成;联网后会自动恢复。");
}
};
window.addEventListener("storage", onBalanceStorage);
window.addEventListener(BALANCE_CHANGED_EVENT, onBalanceChanged);
window.addEventListener("pageshow", onPageShow);
window.addEventListener("online", onOnline);
window.addEventListener("offline", onOffline);
return () => {
window.removeEventListener("storage", onBalanceStorage);
window.removeEventListener(BALANCE_CHANGED_EVENT, onBalanceChanged);
window.removeEventListener("pageshow", onPageShow);
window.removeEventListener("online", onOnline);
window.removeEventListener("offline", onOffline);
};
}, [accountId, hydrated]);
function chooseSuggestedQuestion(
question: string,
theme?: Theme,
entrypoint: ConsultationEntrypoint | null = null,
) {
if (pendingSessionId || cancellationInFlight.current) return;
setDraft(question);
setDraftTheme(theme ?? null);
setDraftEntrypoint(entrypoint);
setComposerNotice("");
window.requestAnimationFrame(() => composerInput.current?.focus());
}
async function startSuggestedConsultation(
question: string,
theme?: Theme,
entrypoint: ConsultationEntrypoint | null = null,
) {
if (
pendingSessionId
|| cancellationInFlight.current
|| pendingConsultation.current
|| creatingSession
) return;
const trimmed = question.trim();
if (!trimmed || !account || !modelCatalog) return;
let targetSession = activeSession
&& activeSession.sessionType === "consultation"
&& activeSession.messagesHydrated
&& activeSession.messages.length === 0
? activeSession
: null;
if (!targetSession) {
targetSession = await startNewChat();
if (!targetSession) return;
}
await send(trimmed, theme, entrypoint, null, targetSession.id, {
sessionOverride: targetSession,
});
}
function startDailyStarlanguageConsultation() {
void startSuggestedConsultation(
dailyStarlanguageQuestion,
"timing",
"daily_starlanguage",
);
}
async function draftSynastryQuestionFromChart(record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) {
if (record.role !== "other") return;
if (synastryPendingId) return;
const baseQuestion = buildSynastryQuestion(profile, record.profile, relationshipType);
setSynastryPendingId(record.id);
setComposerNotice("正在计算基础合盘证据,请稍候。");
try {
const response = await fetch("/api/synastry", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ partnerChartProfileId: record.id, relationshipType }),
});
const payload = await response.json().catch(() => null) as { status?: string; claimStatus?: string; blockedLayers?: string[]; evidenceLayers?: string[]; synastry?: { total_score?: number; max_score?: number; assessment?: string }; relationshipReport?: { headline?: string; scoreBand?: string; strengths?: string[]; risks?: string[]; nextEvidence?: string[] } } | null;
if (response.ok && payload?.status === "ok") {
const score = payload.synastry?.total_score;
const max = payload.synastry?.max_score;
const assessment = payload.synastry?.assessment;
const layers = (payload.evidenceLayers || []).join(" / ") || "Ashtakoot / Moon / D9";
const evidenceSummary = relationshipType === "business"
? `已完成基础商业合作证据筛查:${layers};声明状态:${payload.claimStatus || "partial"};未用层:${(payload.blockedLayers || []).join(" / ") || "A10 / 双方 Dasha-Narayana / 功能吉凶"}。请勿将其表述为合作保证或精确时点。`
: `已计算基础合盘证据:${layers};Ashtakoot ${score ?? "?"}/${max ?? "?"},初步评级:${assessment || "待解释"}。请基于这个证据包继续分析。`;
const reportCard: SynastryReportCard = {
id: `${record.id}-${timestamp()}`,
partnerName: record.profile.name || "对方",
score,
maxScore: max,
assessment,
headline: payload.relationshipReport?.headline,
scoreBand: payload.relationshipReport?.scoreBand,
strengths: payload.relationshipReport?.strengths,
risks: payload.relationshipReport?.risks,
nextEvidence: payload.relationshipReport?.nextEvidence,
createdAt: timestamp(),
};
let savedReportCard = reportCard;
let historyPersisted = !accountId;
if (accountId) {
try {
savedReportCard = await saveCloudSynastryReport(reportCard);
historyPersisted = true;
} catch {
historyPersisted = false;
}
}
setSynastryReportCard(savedReportCard);
if (accountId && historyPersisted) {
setSynastryHistory((current) => (
[savedReportCard, ...current.filter((item) => item.id !== savedReportCard.id)].slice(0, 10)
));
}
chooseSuggestedQuestion([
baseQuestion,
"",
evidenceSummary,
payload.relationshipReport?.headline ? `结构化摘要:${payload.relationshipReport.headline}` : "",
].join("\n"), relationshipType === "business" ? "career" : "marriage");
if (accountId && !historyPersisted) {
setComposerNotice("未能存入历史");
}
} else {
chooseSuggestedQuestion(baseQuestion, relationshipType === "business" ? "career" : "marriage");
setComposerNotice(response.status === 404
? "请先把对方星盘保存到云端,再用于合盘。"
: response.status === 429
? "合盘请求过于频繁,请稍后再试。"
: payload?.status === "blocked" ? "合盘计算暂时不可用,已先生成问题草稿。" : "已生成合盘问题草稿。");
}
} catch {
chooseSuggestedQuestion(baseQuestion, relationshipType === "business" ? "career" : "marriage");
setComposerNotice("合盘计算暂时不可用,已先生成问题草稿。");
} finally {
setSynastryPendingId(null);
}
closeAccountDialog();
}
async function copyAssistantMessage(renderKey: string, text: string) {
try {
await navigator.clipboard.writeText(text);
setCopiedMessageKey(renderKey);
window.setTimeout(() => setCopiedMessageKey((current) => (
current === renderKey ? null : current
)), 1_500);
} catch {
// Clipboard permission failures must not interrupt the conversation.
}
}
useEffect(() => {
chatActionsRef.current = {
onFeedback(feedbackKey, requested) {
setMessageFeedback((current) => {
const next = { ...current };
const value = toggleChatMessageFeedback(current[feedbackKey], requested);
if (value) next[feedbackKey] = value;
else delete next[feedbackKey];
return next;
});
},
onCopy(feedbackKey, text) {
void copyAssistantMessage(feedbackKey, text);
},
onRegenerate(renderKey) {
regenerateLatestAnswer(renderKey);
},
onFollowUp(question) {
void send(question, activeSession?.theme);
},
};
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!profileComplete) {
if (onboardingStep === "name" && presetMessageFinished) void saveOnboardingName();
return;
}
void send(composerDraftSnapshot(), draftTheme.current ?? undefined, draftEntrypoint.current);
}
function handleComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.nativeEvent.isComposing) return;
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}
if (!hydrated || (!account && !accountError)) {
const loadingCopy = bootstrapLoadingCopy(bootstrapPhase);
return (
<main className="app-loading" aria-busy="true" aria-live="polite">
<AppLoadingIndicator title={loadingCopy.title} detail={loadingCopy.detail} />
</main>
);
}
if (!account) {
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={() => window.location.reload()}>重试</button>
<Link className="button-secondary" href="/login">返回登录</Link>
</div>
</div>
</main>
);
}
const sidebarAccount = {
name: profile.name.trim() || account.user.email || "账户",
email: account.user.email || "尚未读取邮箱",
credits: account.credits,
avatar: account.avatar,
initial: profile.name.trim().slice(0, 1)
|| account.user.email?.slice(0, 1).toUpperCase()
|| "你",
};
const sidebarSessions = visibleSessions.map((session) => ({
id: session.id,
title: sessionSidebarTitle(session, chartLibrary),
pinned: session.pinned,
archived: Boolean(session.archivedAt),
}));
const sidebarCharts = (chartLibrary.length > 0
? chartLibrary
: profile.name.trim()
? [{ id: "self", role: "self" as const, profile, updatedAt: timestamp() }]
: []
).map((record) => ({
id: record.id,
name: record.profile.name.trim() || (record.role === "self" ? "我" : "对方"),
role: record.role,
}));
const modalOpen = activeAccountDialog !== null || onboardingPaywallOpen;
const accountOverlayModel: AccountOverlayModel | null = activeAccountDialog === null ? null : {
title: activeAccountDialog ? accountDialogTitles[activeAccountDialog] : "",
dialogClass: activeAccountDialog ? accountDialogClasses[activeAccountDialog] : "",
signingOut,
close: closeAccountDialog,
navigate: (dialog) => openAccountDialog(dialog, dialogReturnTarget.current),
openRedeem: () => router.push(membershipHref("account-menu")),
overlayRef: accountDialog,
closeButtonRef: closeButton,
renderProfile() {
return (
<>
{accountError && <p className="form-error" role="alert">{accountError}</p>}
{account.avatar && (
<section className="sheet-section avatar-section" aria-labelledby="avatar-section-title">
<div className="section-heading">
<b id="avatar-section-title">头像</b>
<small>Beam 形象由随机种子生成,刷新和换设备后保持一致</small>
</div>
<div className="avatar-editor">
<UserAvatar avatar={account.avatar} size={96} label="当前头像预览" />
<div className="avatar-editor-controls">
<div className="avatar-palette-list" role="radiogroup" aria-label="头像配色">
{beamAvatarPalettes.map((palette, index) => (
<button
key={palette.name}
className="avatar-palette"
type="button"
role="radio"
aria-checked={account.avatar?.palette === index}
aria-label={palette.name}
title={palette.name}
disabled={avatarSaving}
onClick={() => void persistAvatar({ action: "set_palette", palette: index })}
>
{palette.colors.map((color) => <span key={color} style={{ backgroundColor: color }} />)}
</button>
))}
</div>
<button
className="button-secondary avatar-randomize"
type="button"
disabled={avatarSaving}
onClick={() => void persistAvatar({ action: "randomize" })}
>
{avatarSaving ? "保存中" : "换一个形象"}
</button>
</div>
</div>
{avatarNotice && <p className="form-success avatar-notice" role="status">{avatarNotice}</p>}
</section>
)}
<section className="sheet-section" aria-labelledby="account-info-title">
<div className="section-heading"><b id="account-info-title">账户信息</b><small>登录与账户识别信息</small></div>
<dl className="account-info-list">
<div><dt>昵称</dt><dd>{profile.name.trim() || "尚未设置"}<small>与“我的星盘”名称同步</small></dd></div>
<div><dt>登录邮箱</dt><dd>{account.user.email || "尚未读取邮箱"}</dd></div>
</dl>
</section>
<div className="dialog-actions">
<button className="button-secondary" type="button" onClick={() => openAccountDialog("chart-library")}>管理星盘资料</button>
</div>
</>
);
},
renderChartLibrary() {
return (
<>
<p className="settings-dialog-copy">管理本人和其他人的出生资料。本人资料用于默认解盘,其他人资料可用于合盘或单独查看。</p>
<ChartLibraryPanel
account={account}
accountId={accountId}
chartLibrary={chartLibrary}
setChartLibrary={setChartLibrary}
profile={profile}
profileDraft={profileDraft}
setProfileDraft={setProfileDraft}
setProfile={setProfile}
profileSaving={profileSaving}
profileNotice={profileNotice}
setProfileNotice={setProfileNotice}
setAccountError={setAccountError}
editingSelfChart={editingSelfChart}
setEditingSelfChart={setEditingSelfChart}
otherProfileDraft={otherProfileDraft}
setOtherProfileDraft={setOtherProfileDraft}
otherChartRelationship={otherChartRelationship}
setOtherChartRelationship={setOtherChartRelationship}
editingChartId={editingChartId}
setEditingChartId={setEditingChartId}
synastryRelationshipType={synastryRelationshipType}
setSynastryRelationshipType={setSynastryRelationshipType}
synastryPendingId={synastryPendingId}
synastryReportCard={synastryReportCard}
setSynastryReportCard={setSynastryReportCard}
synastryHistory={synastryHistory}
activeChartId={activeChartId}
setActiveChartId={setActiveChartId}
saveProfile={saveProfile}
draftSynastryQuestionFromChart={draftSynastryQuestionFromChart}
/>
</>
);
},
renderGeneral() {
return <ThemePreferencePanel />;
},
renderLogout() {
return (
<>
<p className="logout-copy">退出后,需要重新登录才能继续查看对话。</p>
{accountError && <p className="form-error" role="alert">{accountError}</p>}
<div className="dialog-actions">
<button className="button-secondary" type="button" onClick={closeAccountDialog} disabled={signingOut}>取消</button>
<button className="button-primary danger-primary" type="button" onClick={() => void signOut()} disabled={signingOut}>{signingOut ? "正在退出" : "确认退出"}</button>
</div>
</>
);
},
};
return (
<SidebarProvider escapeBlocked={accountMenuOpen || modalOpen}>
<main className="chat-app">
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{replyAnnouncement}</span>
<AppSidebar
sessions={sidebarSessions}
charts={sidebarCharts}
activeSessionId={activeSession?.id ?? null}
account={sidebarAccount}
accountMenuOpen={accountMenuOpen}
accountTriggerRef={accountTrigger}
newChatDisabled={!hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending}
creatingSession={creatingSession}
sessionControls={{
archivedCount: sessions.filter((session) => session.archivedAt).length,
showingArchived: showArchivedSessions,
menuSessionId: sessionMenuId,
disabled: Boolean(pendingSessionId) || cancellationPending,
onToggleArchivedView: () => {
setShowArchivedSessions((current) => !current);
setSessionMenuId(null);
},
onMenuSessionChange: setSessionMenuId,
onTogglePinned: togglePinnedSession,
onRename: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) void renameSession(session);
},
onShare: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) void shareSession(session);
},
onToggleArchived: toggleArchivedSession,
onDelete: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) setPendingSessionDeletion(session);
},
}}
onAccountMenuOpenChange={setAccountMenuOpen}
onNewChat={() => void startNewChat()}
onOpenReports={() => router.push("/reports")}
onSelectSession={selectSession}
onSelectChart={() => openAccountDialog("chart-library")}
onAddChart={() => openAccountDialog("chart-library")}
onOpenProfile={() => openAccountDialog("profile")}
onOpenChartLibrary={() => openAccountDialog("chart-library")}
onOpenGeneral={() => openAccountDialog("general")}
onOpenRedeem={() => router.push(membershipHref("account-menu"))}
onOpenLogout={() => openAccountDialog("logout")}
/>
{pendingSessionDeletion ? (
<div className="account-modal-overlay session-delete-overlay" role="presentation" onMouseDown={() => setPendingSessionDeletion(null)}>
<section className="account-modal logout-modal session-delete-confirmation" role="alertdialog" aria-modal="true" aria-label="确认删除聊天记录" onMouseDown={(event) => event.stopPropagation()}>
<h2>删除聊天记录?</h2>
<p>“{pendingSessionDeletion.title}”将被永久删除,无法恢复。</p>
<div className="dialog-actions">
<button className="button-secondary" type="button" onClick={() => setPendingSessionDeletion(null)}>取消</button>
<button className="button-primary danger-primary" type="button" onClick={() => {
const session = pendingSessionDeletion;
setPendingSessionDeletion(null);
void deleteSession(session);
}}>确认删除</button>
</div>
</section>
</div>
) : null}
<SidebarInset className={`chat-panel${rectificationSurfaceOpen ? " is-rectification" : ""}`} inert={modalOpen}>
<header className="chat-header">
<SidebarTrigger placement="inset" />
<div>
<strong>{activeSession?.title || "新对话"}</strong>
<span className="chat-header-subtitle">分析对象:{activeSession ? sessionChartLabel(activeSession, chartLibrary) : "未关联资料"}</span>
</div>
<div className="chat-header-actions">
<div className="chat-header-rectification" data-rectification-header-slot="" ref={setRectificationHeaderSlot} />
<button className="credit-button" type="button" onClick={() => router.push(membershipHref("credits"))} aria-label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}>
<Sparkles className="credit-icon" aria-hidden="true" />
<span>{account ? account.credits : "—"}</span>
</button>
</div>
</header>
{!rectificationSurfaceOpen && (
<div ref={conversation} className={`conversation${!activeSession?.messages.length && Boolean(activeSession?.messagesHydrated) && !onboardingFormActive ? " is-empty" : ""}${!profileComplete ? " is-onboarding" : ""}${onboardingFormActive ? " is-onboarding-form" : ""}`}>
{!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} />}
</>
) : 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 && (
<StarterHome
starterGreeting={starterGreeting}
natalMinuteAvailable={natalMinuteAvailable}
dailyStarlanguage={dailyStarlanguage}
dailyStarlanguageQuestion={dailyStarlanguageQuestion}
dailyStarlanguageTrend={dailyStarlanguageTrend}
dailyStarlanguageAction={dailyStarlanguageAction}
productEntrypointsDisabled={productEntrypointsDisabled}
startDailyStarlanguageConsultation={startDailyStarlanguageConsultation}
rectificationCardLabel={rectificationCardLabel}
rectificationCardAction={rectificationCardAction}
rectificationLoading={rectificationLoading}
rectificationMutationPending={rectificationMutationPending}
openRectificationFromHomepage={openRectificationFromHomepage}
rectificationEntrySummary={rectificationEntrySummary}
rectificationError={rectificationError}
rectificationSurfaceOpen={rectificationSurfaceOpen}
rectificationErrorMessage={rectificationErrorMessage}
starterThemes={starterThemes}
starterSuggestions={starterSuggestions}
startSuggestedConsultation={startSuggestedConsultation}
onboardingError={onboardingError}
/>
)}
</div>
) : sessionMessagesLoading ? (
<div className="message-list session-messages-loading" role="status" aria-busy="true" aria-live="polite">
<span className="sr-only">正在加载聊天记录</span>
</div>
) : (
<div className="message-list" aria-busy={isLoading}>
<ChatTranscript
messages={activeSession.messages}
loading={isLoading}
streamingText={activeStreamingText}
streamingActivity={activeStreamingActivity}
streamingThinking={activeStreamingThinking}
streamingSections={activeStreamingSections}
streamingTimeline={activeStreamingTimeline}
sessionId={activeSession.id}
sessionType={activeSession.sessionType}
theme={activeSession.theme}
messageFeedback={messageFeedback}
copiedMessageKey={copiedMessageKey}
cancellationPending={cancellationPending}
productEntrypointsDisabled={productEntrypointsDisabled}
actionsRef={chatActionsRef}
/>
{activeError && <p className="error-message" role="alert">{activeError}</p>}
</div>
)}
</div>
)}
{rectificationSurfaceOpen && rectificationCaseId && (
<ConversationalBirthTimeRectification
key={`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.length > 0 ? "ready" : "loading"}`}
caseId={rectificationCaseId}
sessionId={rectificationSessionId ?? ""}
readonly={rectificationReadonly}
shouldStartOpening={rectificationShouldStartOpening}
initialTurns={rectificationTurns}
models={modelCatalog?.models ?? []}
selectedModelId={activeSession?.modelId ?? ""}
onSelectModel={(modelId) => void selectSessionModel(modelId)}
onMessagesChange={handleRectificationMessagesChange}
onOpeningConsumed={() => setRectificationShouldStartOpening(false)}
onCompleted={() => {
void refreshAccount();
if (rectificationCaseId && rectificationSessionId) {
void refreshRectificationCase(rectificationCaseId, rectificationSessionId);
}
}}
onPendingChange={setRectificationMutationPending}
onProfileIncomplete={handleRectificationProfileIncomplete}
onSaved={() => void refreshAccount()}
onStartConsultation={() => void startConsultationAfterRectification()}
pendingConsultationQuestion={rectificationPendingQuestion}
onRestart={() => void startNewRectification()}
headerSlot={rectificationHeaderSlot}
/>
)}
{!rectificationSurfaceOpen && !onboardingFormActive && <div className={`composer-wrap ${starterHomeVisible ? "composer-wrap-starter" : ""}`}>
{jumpToLatestVisible && (
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
)}
<ChatComposer
inputRef={composerInput}
inputLabel={!profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
placeholder={!account
? "正在读取账户…"
: !profileComplete
? onboardingStep === "name"
? presetMessageFinished ? "输入你的称呼" : "Jyotisha 正在输入…"
: "请先完成上方资料"
: account.credits === 0 && !account.activeSubscription
? "余额不足,可前往会员页充值点数或开通会员"
: "例如:未来半年是否适合换工作?"}
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
inputDisabled={isLoading || sessionMessagesLoading || cancellationPending || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
submitLabel={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"}
submitBlocked={Boolean(pendingSessionId) || sessionMessagesLoading || cancellationPending || rectificationSurfaceOpen || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
stopVisible={isLoading}
stopLabel={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"}
stopTitle={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
onSubmit={submit}
onChange={(event) => {
setDraft(event.target.value);
setDraftTheme(null);
setDraftEntrypoint(null);
setComposerNotice("");
}}
onKeyDown={handleComposerKeyDown}
onStop={() => void stopResponse()}
/>
<div className="composer-footer">
<ModelSelector
models={modelCatalog?.models ?? []}
selectedModelId={activeSession?.modelId ?? ""}
disabled={!activeSession || isLoading || cancellationPending || creatingSession}
onSelect={(modelId) => void selectSessionModel(modelId)}
/>
<ComposerCharacterRemaining maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500} />
</div>
</div>}
</SidebarInset>
<AccountDialogOverlay
open={activeAccountDialog !== null}
dialog={activeAccountDialog}
model={accountOverlayModel}
/>
{onboardingPaywallOpen && account && (
<OnboardingRedeemPaywall
credits={account.credits}
onClose={() => setOnboardingPaywallOpen(false)}
onCreditsChanged={(credits) => setAccount((current) => current ? { ...current, credits } : current)}
/>
)}
</main>
</SidebarProvider>
);
}