Files
Jyotisha/frontend/src/app/page.tsx
T
Jesse_ChenandClaude Opus 5 e6c6839742 test(home): follow the profile hook's return through the signOut marker
The mount-once bootstrap effect keeps its literal `}, []);` dependency array:
two existing contract tests use that exact text as a slicing landmark, so the
effect stays as it was and its existing exhaustive-deps warning simply names two
more stable setters. Only the signOut slice marker in
chat-navigation-a11y-contract moves, with the three-column note; the assertion's
subject is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-16 02:02:22 +00:00

1851 lines
82 KiB
TypeScript

"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, 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 { AppSidebar } from "@/components/app-sidebar";
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,
} from "@/lib/rectification-entry";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
import {
declaredBirthTime,
RECTIFICATION_OPENING_LABEL,
} from "@/lib/rectification-surface-state";
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 {
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 {
createRectificationShellSetters,
EMPTY_RECTIFICATION_SHELL,
useRectificationSurface,
} from "@/hooks/use-rectification-surface";
import { useSynastry } from "@/hooks/use-synastry";
import { useSessionManagement } from "@/hooks/use-session-management";
import { sortSessions } from "@/lib/session-groups";
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 { keepFocusWithin } from "@/lib/focus-trap";
import {
BALANCE_SYNC_KEY,
BALANCE_CHANGED_EVENT,
} from "@/lib/membership";
import { parseSettingsQuery, stripSettingsQuery } from "@/lib/settings-url";
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,
writeSessionUrl,
} from "@/lib/chat-session-url";
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
import {
createStartGreeting,
createStartGreetingParts,
} from "@/lib/starter-greeting";
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,
timestamp,
type Account,
type AccountDialog,
type ChartLibraryRecord,
type ChatSession,
type ConsultationStatus,
type DailyStarlanguageState,
type Message,
type OnboardingStep,
type OpenAccountDialogOptions,
type PendingConsultation,
type Profile,
type ReplyOutcome,
type RequestError,
type StoredPendingConsultation,
type StreamingReply,
type Theme,
} from "@/lib/home-types";
import {
birthQuestion,
chartSnapshotForSession,
completedOnboardingMessage,
completedOnboardingTranscript,
formatBirthMoment,
isProfileComplete,
missingProfileStep,
placeQuestion,
readProfile,
selectedBirthPlace,
sessionChartLabel, sessionSidebarSubtitle, 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,
resolveLookupBootstrap,
payloadCode,
payloadMessage,
readSessions,
readStoredDailyStarlanguage,
readStoredPendingConsultation,
redirectToLogin,
waitForUndoWindow,
writeStoredDailyStarlanguage,
} from "@/lib/home-cloud-sync";
import {
bootstrapLoadingCopy,
bootstrapPrepareSettled,
bootstrapRevealDelayMs,
resolveStarterHomeLandingSessionId,
sessionIdsToPrefetch,
shouldAutoOpenRectificationSession,
starterHomeLandingNeedsConsultation,
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>,
},
);
const BillingPanel = dynamic(
() => import("@/components/billing-panel").then((module) => module.BillingPanel),
{ ssr: false },
);
export default function Home() {
const router = useRouter();
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [activeAccountDialog, setActiveAccountDialog] = useState<AccountDialog | null>(null);
const [chartLibrary, setChartLibrary] = useState<ChartLibraryRecord[]>([]);
const [activeChartId, setActiveChartId] = useState("self");
const [billingPane, setBillingPane] = useState<OpenAccountDialogOptions>({});
const [dailyStarlanguage, setDailyStarlanguage] = useState<DailyStarlanguageState>({ kind: "pending" });
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 [sessionsCursor, setSessionsCursor] = useState<string | null>(null);
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 [rectification, setRectification] = useState(EMPTY_RECTIFICATION_SHELL);
const {
sessionId: rectificationSessionId,
caseId: rectificationCaseId,
openingSessionId: rectificationOpeningSessionId,
loading: rectificationLoading,
mutationPending: rectificationMutationPending,
entrySummary: rectificationEntrySummary,
entrySummarySettled: rectificationEntrySummarySettled,
error: rectificationError,
errorSessionId: rectificationErrorSessionId,
} = rectification;
const {
setRectificationError,
setRectificationErrorSessionId,
setRectificationEntrySummary,
setRectificationEntrySummarySettled,
} = useMemo(() => createRectificationShellSetters(setRectification), [setRectification]);
const [hydrated, setHydrated] = useState(false);
const [bootstrapPhase, setBootstrapPhase] = useState<BootstrapPhase>("account");
const prepareStartedAt = useRef<number | null>(null);
const [guidedJourneyPreview, setGuidedJourneyPreview] = 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 [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 dialogReturnTargetRef = 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 accountRefreshGuard = useRef(createLatestAccountRequestGuard());
const sessionDetailInFlight = useRef(new Set<string>());
const sessionsRef = useRef(sessions);
const rectificationSessionOpenerRef = useRef(async (_exactSessionId: string) => {});
const uiPreview = useRef(false);
const uiPreviewMode = useRef<string | null>(null);
const birthTimeRevisionPendingRef = useRef(false);
const {
profile,
setProfile,
profileDraft,
setProfileDraft,
profileNotice,
setProfileNotice,
avatarNotice,
avatarSaving,
profileSaving,
refreshAccount,
completeGuidedBirthTime,
editDeclaredBirthTimeDetails,
openAccountDialog,
closeAccountDialog,
persistAvatar,
saveProfile,
saveOnboardingName,
saveOnboardingBirth,
saveOnboardingPlace,
retryBirthTimeAssessment,
signOut,
} = useProfileOnboarding({
account, accountRefreshGuard, accountTrigger, activeChartId,
birthTimeRevisionPendingRef, chartLibrary, dialogReturnTargetRef,
setAccount, setAccountError, setAccountMenuOpen, setActiveAccountDialog,
setActiveChartId, setBirthTimeAssessmentPhase,
setBirthTimeConsultationConsent, setBirthTimeError, setBirthTimeJourney, setBillingPane, setDraft,
setOnboardingJustCompleted, setOnboardingStep, setPresetMessageLength,
setRectificationError,
setSigningOut, setStartGreeting, signingOut, uiPreview,
});
const birthTimeGuided = useBirthTimeGuidedJourney({
journey: birthTimeJourney,
preview: guidedJourneyPreview,
onJourney: setBirthTimeJourney,
onReady: (result) => completeGuidedBirthTime(result),
onEditBirthTimeDetails: () => editDeclaredBirthTimeDetails(),
});
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 = sortSessions(sessions.filter((session) => (showArchivedSessions ? session.archivedAt : !session.archivedAt)
&& (session.sessionType === "birth_time_rectification"
|| session.messages.length > 0
|| !session.messagesHydrated
|| session.id === activeSessionId)));
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 = rectificationLoading
? RECTIFICATION_OPENING_LABEL
: rectificationEntryLabels[rectificationCardAction];
const rectificationDeclaredTime = declaredBirthTime(profile);
const rectificationErrorMessage = rectificationError === "profile_incomplete"
? "服务端未能读取完整出生资料,请重新确认并保存后再开始生时校正。"
: rectificationError;
const {
updateSession,
persistSession,
ensureSessionMessages,
continueInNewChat,
renameSession,
deleteSession,
togglePinnedSession,
toggleArchivedSession,
shareSession,
startNewChat,
selectSession, selectSessionModel, loadMoreSessions, toggleArchivedView,
} = 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, setRectificationErrorSessionId, setRequestError,
setSessionDetailLoadingId, setSessionFullPrompt, setSessions, sessionsCursor, setSessionsCursor,
showArchivedSessions, setShowArchivedSessions, uiPreview, visibleSessions,
openRectificationSession: (exactSessionId) => rectificationSessionOpenerRef.current(exactSessionId),
});
const { synastryPanel, clearSynastryHistory, applyCloudSynastryHistory } = useSynastry({
accountId, profile, chooseSuggestedQuestion, closeAccountDialog,
});
const {
setRectificationHeaderSlot,
rectificationPanel,
resumeRectificationSession,
openRectificationFromHomepage,
} = useRectificationSurface({
account, activeChartId, activeSessionIdRef, chartLibrary, creatingSession, modelCatalog,
persistSession, profile, rectification, setRectification,
sessionSelectionSource, sessions, setActiveSessionId, setDraft, setDraftEntrypoint,
setDraftTheme, setOnboardingStep, setProfileNotice, 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 applySettingsFromSearch = (search: string) => {
const query = parseSettingsQuery(search);
if (!query) return;
openAccountDialog("billing", { source: query.source ?? undefined, plan: query.plan ?? undefined, tab: query.tab ?? undefined });
const next = stripSettingsQuery(search, window.location.pathname);
if (`${window.location.pathname}${window.location.search}` !== next) window.history.replaceState(null, "", next);
};
applySettingsFromSearch(window.location.search);
const onPopState = () => {
applySettingsFromSearch(window.location.search);
applySessionPopStateRef.current(window.location.search);
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, [hydrated]);
// A rectification session named in `?c=` (refresh, deep link, login return)
// is opened during prepare so the reveal shows the surface. Bare `/` after
// login must not open the latest history item in the background.
useEffect(() => {
if ((!hydrated && bootstrapPhase === "account")
|| !account
|| !modelCatalog
|| !activeSession
|| !shouldAutoOpenRectificationSession({
sessionType: activeSession.sessionType,
sessionId: activeSession.id,
search: window.location.search,
})
|| activeSession.id === rectificationSessionId
|| rectificationLoading
|| rectificationMutationPending
|| creatingSession
|| rectificationError) return;
resumeRectificationSession.current(activeSession);
}, [
account,
activeSession,
bootstrapPhase,
creatingSession,
hydrated,
modelCatalog,
rectificationError,
rectificationLoading,
rectificationMutationPending,
rectificationSessionId,
resumeRectificationSession,
]);
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, setRectificationEntrySummary, setRectificationEntrySummarySettled]);
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([]);
clearSynastryHistory();
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));
clearSynastryHistory();
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) => {
applyCloudSynastryHistory(cloudHistory);
})
.catch(() => {
setComposerNotice("合盘历史暂时无法读取,请重试。", {
label: "重试",
onClick: () => {
void fetchCloudSynastryHistory()
.then((cloudHistory) => {
applyCloudSynastryHistory(cloudHistory);
})
.catch(() => {
setComposerNotice("合盘历史暂时无法读取,请重试。");
});
},
});
});
}
setChartLibrary((current) => upsertSelfChart(current, profileForLibrary));
}, [account, accountId, activeChartId, applyCloudSynastryHistory, clearSynastryHistory, 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, setProfile, setProfileDraft]);
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 dailyStarlanguageFingerprint = dailyStarlanguageProfileKey(profile);
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 bootstrapPrepareReady = bootstrapPrepareSettled({
dailyStarlanguageApplicable: Boolean(accountId) && profileComplete && natalMinuteAvailable,
dailyStarlanguageSettled: dailyStarlanguage.kind !== "pending",
entrySummarySettled: rectificationEntrySummarySettled,
rectificationApplicable: shouldAutoOpenRectificationSession({
sessionType: activeSession?.sessionType,
sessionId: activeSession?.id,
search: typeof window === "undefined" ? "" : window.location.search,
}),
rectificationSettled: activeSession?.id === rectificationSessionId
|| rectificationError !== ""
|| !profileComplete,
});
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 starterHomeVisible = profileComplete
&& presetMessageFinished
&& !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, queuedDraft, enqueueQueuedDraft, recallQueuedDraft } = useConsultationRun({
account,
activeSession,
activeSessionIdRef,
birthTimeConsultationConsent,
cancellationFeedbackRequest,
cancellationInFlight,
cancellationPending,
cancellationRequests,
composerInput,
consultationRecoveryCheck,
consultationRecoveryWakeup,
consultationReplay,
consultationReplayStarted,
consultationStatusMissingCount,
conversationAnchor,
consultationPhase,
isLoading,
modelCatalog,
onboardingJustCompleted,
pendingConsultation,
pendingSessionId,
profile,
router,
sessions,
setAccount,
setActiveSessionId,
setCancellationPending,
setConsultationPhase,
setDraft,
setDraftEntrypoint,
setDraftTheme,
setMessageFeedback,
setOnboardingJustCompleted,
setPendingRequestId,
setPendingSessionId,
setProfileNotice,
replyOutcome,
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);
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.sessions, nextModelCatalog);
setSessionsCursor(sessionsPayload.nextCursor);
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 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)) {
if (controller.signal.aborted) return;
const homeSession = createSession(
nextModelCatalog?.defaultModelId ?? "",
"consultation",
chartSnapshotForSession("self", [], nextProfile),
);
if (nextModelCatalog) {
await writeChatSession(homeSession.id, {
title: homeSession.title,
theme: homeSession.theme,
model_id: homeSession.modelId,
messages: [],
session_type: homeSession.sessionType,
rectification_case_id: homeSession.rectificationCaseId,
chart_profile_id: homeSession.chartProfileId,
chart_profile_name: homeSession.chartProfileName,
chart_profile_role: homeSession.chartProfileRole,
}, "create");
}
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);
setAccount(nextAccount);
setModelCatalog(nextModelCatalog);
setProfile(nextProfile);
setProfileDraft(nextProfile);
setStartGreeting(nextProfile.name.trim() ? createStartGreeting(nextProfile.name) : "");
setOnboardingStep(missingProfileStep(nextProfile) ?? "name");
setSessions(nextSessions);
setActiveSessionId(landingSessionId);
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 (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();
};
}, []);
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 || !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 = dialogReturnTargetRef.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 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;
}
if (isLoading || cancellationPending) return void enqueueQueuedDraft(composerDraftSnapshot());
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),
subtitle: sessionSidebarSubtitle(session, chartLibrary),
pinned: session.pinned, archived: Boolean(session.archivedAt), updatedAt: session.updatedAt,
}));
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, dialogReturnTargetRef.current),
overlayRef: accountDialog,
closeButtonRef: closeButton,
renderProfile: () => (
<ProfilePanel account={account} profile={profile} avatarSaving={avatarSaving} avatarNotice={avatarNotice} accountError={accountError} persistAvatar={persistAvatar} />
),
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}
{...synastryPanel} activeChartId={activeChartId} setActiveChartId={setActiveChartId}
saveProfile={saveProfile}
/>
</>
);
},
renderBilling: () => (
<BillingPanel account={account} highlightedPlan={billingPane.plan ?? null} initialTab={billingPane.tab ?? "membership"} />
),
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}
openingSessionId={rectificationOpeningSessionId}
openErrorSessionId={rectificationErrorSessionId}
openErrorMessage={rectificationErrorSessionId ? rectificationErrorMessage : ""}
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,
hasMore: Boolean(sessionsCursor), onLoadMore: loadMoreSessions,
menuSessionId: sessionMenuId,
disabled: Boolean(pendingSessionId) || cancellationPending,
onToggleArchivedView: () => { void toggleArchivedView(); 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")}
onOpenBilling={() => openAccountDialog("billing", { source: "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={(event) => openAccountDialog("billing", { returnTarget: event.currentTarget, source: "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${!onboardingFormActive && !activeSession?.messages.length && (Boolean(activeSession?.messagesHydrated) || activeSession?.sessionType !== "consultation") ? " 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={rectificationErrorSessionId ? "" : rectificationError}
rectificationSurfaceOpen={rectificationSurfaceOpen}
rectificationErrorMessage={rectificationErrorSessionId ? "" : rectificationErrorMessage}
/>
)}
</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}`}
declaredTime={rectificationDeclaredTime}
models={modelCatalog?.models ?? []}
selectedModelId={activeSession?.modelId ?? ""}
onSelectModel={(modelId) => void selectSessionModel(modelId)}
onOpenBilling={(options) => openAccountDialog("billing", { source: options?.source ?? "rectification" })}
onSaved={() => void refreshAccount()}
panel={rectificationPanel}
/>
)}
{!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={sessionMessagesLoading || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
submitLabel={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"}
submitBlocked={Boolean(pendingSessionId) || Boolean(queuedDraft) || sessionMessagesLoading || 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}
queued={queuedDraft ? { text: queuedDraft, onRecall: recallQueuedDraft } : undefined}
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)}
onOpenBilling={() => {
setOnboardingPaywallOpen(false);
openAccountDialog("billing", { source: "onboarding-paywall" });
}}
/>
)}
</main>
</SidebarProvider>
);
}