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
1851 lines
82 KiB
TypeScript
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>
|
|
);
|
|
}
|