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
2020 lines
92 KiB
TypeScript
2020 lines
92 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, 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>
|
||
);
|
||
}
|