"use client"; import Link from "next/link"; import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; import { ArrowDown, ArrowUpRight, Sparkles, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { FormEvent, KeyboardEvent } from "react"; 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, isTerminalRectificationStatus, openRectificationRequestBody, openResponseFromPayload, rectificationEntryLabels, resolveRectificationEntryAction, type RectificationEntrySummary, } from "@/lib/rectification-entry"; import { ConversationalBirthTimeRectification, type PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification"; import { ChatMessageContent } from "@/components/chat-message-content"; import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row"; import { ChatMessageActions, toggleChatMessageFeedback, type ChatMessageFeedback, } from "@/components/chat-message-actions"; import { ModelSelector } from "@/components/model-selector"; import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall"; import { BirthPlacePicker } from "@/components/birth-place-picker"; import { ChatComposer } from "@/components/chat-composer"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft"; import { clearStaleClientReload } from "@/lib/stale-client-recovery"; import { chinaLocations, type ProvinceNode } from "@/data/china-locations"; import { parseAgentReply, resolveSessionTitle, type ReplyTheme } from "@/lib/agent-reply"; import { beamAvatarPalettes, beamAvatarSchema, type BeamAvatar, type BeamAvatarPatch, } from "@/lib/beam-avatar"; import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint"; import { applyBirthTimeDraftPatch, applyPersistedBirthTime, assistantIntentCopy, birthTimePersistenceValues, declaredBirthInputChanged, describeBirthTimeDraft, isDeclaredBirthProfileComplete, isBirthTimeDraftReady, birthTimeDraftReadyHint, normalizePersistedBirthDate, type BirthTimeDraft, type BirthTimeSource, } from "@/lib/birth-time-intake-model"; import { birthTimeConsultationOptionsCopy, 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 { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor"; import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; import { chatReplyAnnouncement, type ChatReplyPhase } from "@/lib/chat-reply-announcement"; import { requestBirthTimeAssessment, type JourneyClientResponse, } from "@/lib/birth-time-journey-client"; import { guidedBirthTimePreview, isGuidedBirthTimePreview, previewRectificationJourney, } from "@/lib/birth-time-guided-preview"; import { defaultGuidedJyotishTopics, generalGuidedJyotishTopics } from "@/lib/guided-jyotish-topics"; import { normalizeConsultationDomain } from "@/lib/consultation-domain-registry"; import { keepFocusWithin } from "@/lib/focus-trap"; import { BALANCE_SYNC_KEY, BALANCE_CHANGED_EVENT, membershipHref, } from "@/lib/membership"; import { chatMessageViews, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view"; import { createNdjsonParser, type AgentExecutionReceipt, type ConsultationAgentPublicEvent, } from "@/lib/consultation-agent-events"; import { writeChatSession } from "@/lib/chat-session-write-contract"; import { consultationReportMarkdown } from "@/lib/consultation-report-export"; import { OnboardingAuthenticationError, type OnboardingContent, createStartGreeting, createStartGreetingParts, isCurrentOnboardingRequest, onboardingProfileFingerprint, onboardingRequestIdentity, requestOnboardingWithRecovery, } from "@/lib/onboarding-client"; import { protectOnboardingPhrases } from "@/lib/onboarding-copy"; import { calendarDateInTimeZone, dailyStarlanguageProfileKey, } from "@/lib/daily-starlanguage"; import { preserveShallowEqual } from "@/lib/preserve-shallow-equal"; import { SessionModelPersistenceQueue, persistSessionModelSelection, } from "@/lib/session-model-persistence"; import { parsePublicModelCatalog, resolveSessionModelId, type PublicLanguageModelCatalog, } from "@/lib/public-models"; import { selfHostedOtpActions } from "@/modules/identity/client"; const BirthTimeRectification = dynamic( () => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification), { ssr: false, loading: () =>

正在加载出生时间评估...

, }, ); type Theme = ReplyTheme; type Message = ChatMessage; type Profile = BirthTimeDraft & { name: string; countryCode: string; provinceCode: string; cityCode: string; districtCode: string; birthPlaceLabel: string; birthPlaceType: string; birthPlaceProvider: string; birthPlaceProviderId: string; timezoneId: string; timezoneSource: string; latitude: number | null; longitude: number | null; timezoneOffset: number | null; rectificationCaseId: string; }; type ChartLibraryRecord = { id: string; role: "self" | "other"; profile: Profile; updatedAt: number; }; type SynastryRelationshipType = "romance" | "business" | "family" | "general"; type ChartLibraryApiRecord = { id: string; role: "self" | "other"; profile: Profile; updated_at?: string; }; type SynastryReportCard = { id: string; partnerName: string; score?: number; maxScore?: number; assessment?: string; headline?: string; scoreBand?: string; strengths?: string[]; risks?: string[]; nextEvidence?: string[]; createdAt: number; }; type SynastryReportApiRecord = { id: string; partner_name?: string; report?: SynastryReportCard; created_at?: string; }; type ChatSessionType = "consultation" | "birth_time_rectification"; type ChatSession = { id: string; title: string; theme: Theme; modelId: string; messages: Message[]; updatedAt: number; sessionType: ChatSessionType; rectificationCaseId: string | null; }; type RequestError = { sessionId: string; message: string }; type ReplyOutcome = { readonly sessionId: string; readonly phase: Extract; readonly replyOrdinal: number; }; type StreamingReply = { sessionId: string; text: string; activity?: AgentActivityView }; type BirthPlace = { label: string; lat: number; lon: number; tz: number | null; timezoneId: string; }; type Account = { user: { id: string; email: string | null }; avatar: BeamAvatar | null; credits: number; isAdmin: boolean; adminUrl: string | null; rectificationPriceCredits: number; activeSubscription: { id: string; status: string; startsAt: string; endsAt: string; productCode: string; productVersion: number; product: { name?: string; productType?: string } | null; entitlements: unknown; } | null; hasConfirmedBirthTime: boolean; hasUsableBirthTime: boolean; profile: unknown; }; type OnboardingStep = "name" | "birth" | "place" | "rectification"; type AccountDialog = "profile" | "logout"; type DailyStarlanguageCard = { trend: string; action: string; caution: string }; type DailyStarlanguageApiResponse = { status?: "ok" | "unavailable" | "unauthenticated"; card?: DailyStarlanguageCard; source?: "engine_evidence" | "engine_evidence_cache" | "agent" | "agent_cache"; claim_status?: "exploratory_unvalidated"; boundary?: "not_deterministic_prediction"; }; type DailyStarlanguageState = | { kind: "pending" } | { kind: "ready"; card: DailyStarlanguageCard } | { kind: "unavailable" }; type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] }; type ConsultationStatus = { readonly requestId: string; readonly sessionId: string; readonly status: "reserved" | "completed" | "cancelled"; readonly responseMessage?: unknown; readonly updatedAt?: string; }; type PendingConsultation = { readonly requestId: string; readonly sessionId: string; readonly question: string; readonly entrypoint: ConsultationEntrypoint | null; readonly theme: Theme; readonly previousSession: ChatSession; readonly optimisticSession: ChatSession; readonly previousOnboardingState: boolean; readonly controller: AbortController; readonly cancelled: boolean; readonly phase: "undo" | "streaming" | "recovering"; readonly partialReply: string; }; const undoWindowMs = 2_500; const pendingConsultationStorageKey = "jyotisha.pending-consultation"; const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; type StoredPendingConsultation = { readonly sessionId: string; readonly requestId: string; readonly question: string; readonly theme: Theme | null; readonly entrypoint: ConsultationEntrypoint | null; }; function readStoredPendingConsultation( raw: string | null, sessionIds: Iterable, ): StoredPendingConsultation | null { if (!raw) return null; try { const parsedPending = JSON.parse(raw) as Record; if (typeof parsedPending.sessionId !== "string" || typeof parsedPending.requestId !== "string" || !uuidPattern.test(parsedPending.sessionId) || !uuidPattern.test(parsedPending.requestId)) { return null; } let sessionKnown = false; for (const sessionId of sessionIds) { if (sessionId === parsedPending.sessionId) { sessionKnown = true; break; } } if (!sessionKnown) return null; return { sessionId: parsedPending.sessionId, requestId: parsedPending.requestId, question: typeof parsedPending.question === "string" ? parsedPending.question : "", theme: normalizeConsultationDomain(parsedPending.theme), entrypoint: parsedPending.entrypoint === "daily_starlanguage" ? "daily_starlanguage" : null, }; } catch { return null; } } const china = chinaLocations.country; const themes = defaultGuidedJyotishTopics; const accountDialogTitles = { profile: "个人资料", logout: "退出登录?", } as const satisfies Record; const accountDialogClasses = { profile: "profile-modal", logout: "logout-modal", } as const satisfies Record; const previewModelCatalog = parsePublicModelCatalog({ defaultModelId: "deepseek-pro", models: [ { id: "deepseek-pro", label: "DeepSeek V4 Pro", description: "更适合复杂分析", creditCost: 1, isDefault: true }, { id: "gpt-5-mini", label: "ChatGPT 5 Mini", description: "响应稳定、速度均衡", creditCost: 1, isDefault: false }, ], }); const presetOnboardingMessage = "你好,我是 Jyotisha。\n开始前,我想先认识你。\n请问我该怎么称呼你?"; const emptyProfile: Profile = { name: "", date: "", time: "", reportedTime: "", birthTimeSource: "", birthTimePeriod: "", birthTimeClue: "", uncertaintyBeforeMinutes: null, uncertaintyAfterMinutes: null, birthTimeStatus: "", rectificationCaseId: "", countryCode: "CN", provinceCode: "", cityCode: "", districtCode: "", birthPlaceLabel: "", birthPlaceType: "", birthPlaceProvider: "", birthPlaceProviderId: "", timezoneId: "", timezoneSource: "", latitude: null, longitude: null, timezoneOffset: null, }; function timestamp() { return Date.now(); } function createSession( modelId: string, sessionType: ChatSessionType = "consultation", ): ChatSession { return { id: globalThis.crypto.randomUUID(), title: sessionType === "birth_time_rectification" ? "生时校正" : "新对话", theme: "general", modelId, messages: [], updatedAt: timestamp(), sessionType, rectificationCaseId: null, }; } function findProvince(code: string) { return china.provinces.find((province) => province.code === code); } function findCity(province: ProvinceNode | undefined, code: string) { return province?.cities.find((city) => city.code === code); } function selectedBirthPlace(profile: Profile): BirthPlace | null { if (profile.birthPlaceLabel && Number.isFinite(profile.latitude) && Number.isFinite(profile.longitude) && profile.timezoneId.trim() && (profile.timezoneOffset === null || Number.isFinite(profile.timezoneOffset))) { return { label: profile.birthPlaceLabel, lat: profile.latitude as number, lon: profile.longitude as number, tz: profile.timezoneOffset, timezoneId: profile.timezoneId, }; } const province = findProvince(profile.provinceCode); const city = findCity(province, profile.cityCode); if (!province || !city) return null; const district = city.districts.find((item) => item.code === profile.districtCode); if (city.districts.length > 0 && !district) return null; const location = district ?? city; const label = [china.name, province.name, city.name, district?.name] .filter((name, index, names) => Boolean(name) && names.indexOf(name) === index) .join(" · "); return { label, lat: location.center[1], lon: location.center[0], tz: china.timezone, timezoneId: "Asia/Shanghai", }; } function chartLibraryStorageKey(accountId: string) { return `jyotisha_chart_library:${accountId}`; } function synastryHistoryStorageKey(accountId: string) { return `jyotisha_synastry_history:${accountId}`; } function dailyStarlanguageStorageKey(accountId: string) { return `jyotisha_daily_starlanguage:${accountId}`; } type StoredDailyStarlanguage = { readonly day: string; readonly fingerprint: string; readonly card: DailyStarlanguageCard; }; function readStoredDailyStarlanguage(accountId: string): StoredDailyStarlanguage | null { try { const parsed = JSON.parse(localStorage.getItem(dailyStarlanguageStorageKey(accountId)) || "null") as StoredDailyStarlanguage | null; if (!parsed?.day || !parsed.fingerprint || !parsed.card?.trend || !parsed.card?.action) return null; return parsed; } catch { return null; } } function writeStoredDailyStarlanguage(accountId: string, stored: StoredDailyStarlanguage) { localStorage.setItem(dailyStarlanguageStorageKey(accountId), JSON.stringify(stored)); } function profileReadyForLibrary(profile: Profile) { return !missingProfileStep(profile); } function buildSelfChartRecord(profile: Profile): ChartLibraryRecord { return { id: "self", role: "self", profile, updatedAt: timestamp() }; } function upsertSelfChart(library: ChartLibraryRecord[], profile: Profile) { if (!profileReadyForLibrary(profile)) return library.filter((record) => record.role !== "self"); const others = library.filter((record) => record.role !== "self"); return [buildSelfChartRecord(profile), ...others]; } function readChartLibrary(accountId: string): ChartLibraryRecord[] { try { const parsed = JSON.parse(localStorage.getItem(chartLibraryStorageKey(accountId)) || "[]") as ChartLibraryRecord[]; return Array.isArray(parsed) ? parsed.filter((record) => record?.id && record?.profile) : []; } catch { return []; } } function readSynastryHistory(accountId: string): SynastryReportCard[] { try { const parsed = JSON.parse(localStorage.getItem(synastryHistoryStorageKey(accountId)) || "[]") as SynastryReportCard[]; return Array.isArray(parsed) ? parsed.filter((record) => record?.id && record?.partnerName).slice(0, 10) : []; } catch { return []; } } function writeSynastryHistory(accountId: string, history: SynastryReportCard[]) { localStorage.setItem(synastryHistoryStorageKey(accountId), JSON.stringify(history.slice(0, 10))); } function normalizeSynastryReportApiRecord(record: SynastryReportApiRecord): SynastryReportCard | null { if (!record.report || typeof record.report !== "object") return null; return { ...record.report, id: record.id, partnerName: record.partner_name || record.report.partnerName || "对方", createdAt: Date.parse(record.created_at || "") || record.report.createdAt || timestamp(), }; } async function fetchCloudSynastryHistory() { const response = await fetch("/api/synastry-reports", { cache: "no-store" }); if (!response.ok) throw new Error("cloud_synastry_history_unavailable"); const payload = await response.json().catch(() => null) as { reports?: SynastryReportApiRecord[] } | null; return (payload?.reports || []).map(normalizeSynastryReportApiRecord).filter(Boolean) as SynastryReportCard[]; } async function saveCloudSynastryReport(report: SynastryReportCard) { const response = await fetch("/api/synastry-reports", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ partnerName: report.partnerName, report }), }); if (!response.ok) throw new Error("cloud_synastry_report_save_failed"); const payload = await response.json().catch(() => null) as { report?: SynastryReportApiRecord } | null; return payload?.report ? normalizeSynastryReportApiRecord(payload.report) || report : report; } function normalizeChartLibraryApiRecord(record: ChartLibraryApiRecord): ChartLibraryRecord { return { id: record.role === "self" ? "self" : record.id, role: record.role, profile: record.profile, updatedAt: Date.parse(record.updated_at || "") || timestamp(), }; } async function fetchCloudChartLibrary() { const response = await fetch("/api/chart-profiles", { cache: "no-store" }); if (!response.ok) throw new Error("cloud_chart_library_unavailable"); const payload = await response.json().catch(() => null) as { profiles?: ChartLibraryApiRecord[] } | null; return (payload?.profiles || []).map(normalizeChartLibraryApiRecord); } async function saveCloudChartProfile(record: ChartLibraryRecord) { const response = await fetch("/api/chart-profiles", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ role: record.role, profile: record.profile, }), }); const payload = await response.json().catch(() => null) as { profile?: ChartLibraryApiRecord; error?: string } | null; if (!response.ok) throw new Error(payload?.error || "cloud_chart_profile_save_failed"); return payload?.profile ? normalizeChartLibraryApiRecord(payload.profile) : record; } async function deleteCloudChartProfile(recordId: string) { const response = await fetch(`/api/chart-profiles/${encodeURIComponent(recordId)}`, { method: "DELETE" }); if (!response.ok) throw new Error("cloud_chart_profile_delete_failed"); } function profilePlaceLabel(profile: Profile) { return selectedBirthPlace(profile)?.label || "地点未完整"; } function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Profile, relationshipType: SynastryRelationshipType) { const relationshipLabel = relationshipType === "business" ? "商业合作" : relationshipType === "family" ? "亲友/家庭" : relationshipType === "general" ? "其他关系" : "婚恋"; const evidenceRequest = relationshipType === "business" ? "请先说明 D2/D10/D11 已用层与 A10、双方 Dasha/Narayana、功能吉凶等缺失层;不得给出合作成败、收益保证或精确时点。" : relationshipType === "romance" ? "请先说明会使用哪些证据层,再分析关系模式、冲突点、适合发展的方式和需要谨慎的时间窗口。" : "请先说明当前缺少专用合盘计算合同,只基于可验证资料提出需要补充的现实关系信息,不作确定性判断。"; return [ `请用印度占星分析我和${partnerProfile.name || "对方"}的${relationshipLabel}关系。`, `我的资料:${selfProfile.name || "本人"},${selfProfile.date} ${selfProfile.time},${profilePlaceLabel(selfProfile)}。`, `对方资料:${partnerProfile.name || "对方"},${partnerProfile.date} ${partnerProfile.time},${profilePlaceLabel(partnerProfile)}。`, evidenceRequest, ].join("\n"); } const dailyStarlanguageRetryDelayMs = 5_000; async function fetchDailyStarlanguage(signal: AbortSignal): Promise { const response = await fetch("/api/daily-starlanguage", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), signal, }); if (!response.ok) return { kind: "unavailable" }; const payload = await response.json().catch(() => null) as DailyStarlanguageApiResponse | null; if (payload?.status !== "ok" || !payload.card) return { kind: "unavailable" }; return { kind: "ready", card: payload.card }; } function missingProfileStep(profile: Profile): OnboardingStep | null { if (!profile.name.trim()) return "name"; if (!isDeclaredBirthProfileComplete(profile)) return "birth"; if (!isDeclaredBirthProfileComplete(profile, selectedBirthPlace(profile))) return "place"; return null; } function missingOtherProfileStep(profile: Profile): "name" | "birth" | "place" | null { if (!profile.name.trim()) return "name"; if (!isBirthTimeDraftReady(profile)) return "birth"; if (!selectedBirthPlace(profile)) return "place"; return null; } function birthQuestion(name: string) { return `${name},你好。接下来请告诉我出生日期,以及你对出生时间知道到什么程度。不确定也没关系,我不会要求你猜一个具体时间。`; } function formatBirthMoment(profile: Profile) { return describeBirthTimeDraft(profile); } function placeQuestion(profile: Profile) { return `记下了:${formatBirthMoment(profile)}。最后一个问题,你出生在哪里?`; } function completedOnboardingMessage(name: string) { return `${name},我们可以开始了。你可以从下面三个方向选择,也可以直接告诉我现在最想问的事。`; } function completedOnboardingTranscript(profile: Profile, greeting: string): Message[] { const name = profile.name.trim(); const birthPlace = selectedBirthPlace(profile); if (!name || !isDeclaredBirthProfileComplete(profile) || !birthPlace) return []; return [ { role: "assistant", text: presetOnboardingMessage }, { role: "user", text: name }, { role: "assistant", text: birthQuestion(name) }, { role: "user", text: formatBirthMoment(profile) }, { role: "assistant", text: placeQuestion(profile) }, { role: "user", text: birthPlace.label }, { role: "assistant", text: greeting || completedOnboardingMessage(name) }, ]; } function readProfile(value: unknown): Profile { if (!value || typeof value !== "object") return emptyProfile; const profile = value as Partial & { birth_date?: unknown; birth_time?: unknown; reported_birth_time?: unknown; active_birth_time?: unknown; birth_time_source?: unknown; birth_time_period?: unknown; birth_time_clue?: unknown; uncertainty_before_minutes?: unknown; uncertainty_after_minutes?: unknown; birth_time_status?: unknown; rectification_case_id?: unknown; country_code?: unknown; province_code?: unknown; city_code?: unknown; district_code?: unknown; birth_place_label?: unknown; birth_place_type?: unknown; birth_place_provider?: unknown; birth_place_provider_id?: unknown; timezone_id?: unknown; timezone_source?: unknown; latitude?: unknown; longitude?: unknown; timezone_offset?: unknown; }; const date = normalizePersistedBirthDate( typeof profile.birth_date === "string" ? profile.birth_date : profile.date, ); const legacyTime = typeof profile.birth_time === "string" ? profile.birth_time.slice(0, 5) : profile.time; const time = typeof profile.active_birth_time === "string" ? profile.active_birth_time.slice(0, 5) : legacyTime; const persistedReportedTime = typeof profile.reported_birth_time === "string" ? profile.reported_birth_time.slice(0, 5) : ""; const knownSources: readonly BirthTimeSource[] = [ "hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import", ]; const source = knownSources.find((item) => item === profile.birth_time_source) ?? (time ? "legacy_import" : ""); const reportedTime = persistedReportedTime || (source === "legacy_import" ? time : ""); const knownPeriods = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const; const period = knownPeriods.find((item) => item === profile.birth_time_period) ?? ""; const knownStatuses = ["reported", "assessing", "rectifying", "candidate", "accepted", "confirmed"] as const; const status = knownStatuses.find((item) => item === profile.birth_time_status) ?? (time ? "confirmed" : ""); const provinceCode = typeof profile.province_code === "string" ? profile.province_code : profile.provinceCode; const cityCode = typeof profile.city_code === "string" ? profile.city_code : profile.cityCode; const districtCode = typeof profile.district_code === "string" ? profile.district_code : profile.districtCode; const countryCode = typeof profile.country_code === "string" ? profile.country_code : profile.countryCode; const birthPlaceLabel = typeof profile.birth_place_label === "string" ? profile.birth_place_label : profile.birthPlaceLabel; const birthPlaceType = typeof profile.birth_place_type === "string" ? profile.birth_place_type : profile.birthPlaceType; const birthPlaceProvider = typeof profile.birth_place_provider === "string" ? profile.birth_place_provider : profile.birthPlaceProvider; const birthPlaceProviderId = typeof profile.birth_place_provider_id === "string" ? profile.birth_place_provider_id : profile.birthPlaceProviderId; const timezoneId = typeof profile.timezone_id === "string" ? profile.timezone_id : profile.timezoneId; const timezoneSource = typeof profile.timezone_source === "string" ? profile.timezone_source : profile.timezoneSource; const latitude = typeof profile.latitude === "number" && Number.isFinite(profile.latitude) ? profile.latitude : null; const longitude = typeof profile.longitude === "number" && Number.isFinite(profile.longitude) ? profile.longitude : null; const timezoneOffset = typeof profile.timezone_offset === "number" && Number.isFinite(profile.timezone_offset) ? profile.timezone_offset : typeof profile.timezoneOffset === "number" && Number.isFinite(profile.timezoneOffset) ? profile.timezoneOffset : null; return { name: typeof profile.name === "string" ? profile.name.slice(0, 80) : "", date: typeof date === "string" ? date : "", time: typeof time === "string" ? time : "", reportedTime: typeof reportedTime === "string" ? reportedTime : "", birthTimeSource: source, birthTimePeriod: period, birthTimeClue: typeof profile.birth_time_clue === "string" ? profile.birth_time_clue.slice(0, 240) : "", uncertaintyBeforeMinutes: typeof profile.uncertainty_before_minutes === "number" ? profile.uncertainty_before_minutes : null, uncertaintyAfterMinutes: typeof profile.uncertainty_after_minutes === "number" ? profile.uncertainty_after_minutes : null, birthTimeStatus: status, rectificationCaseId: typeof profile.rectification_case_id === "string" ? profile.rectification_case_id : "", countryCode: typeof countryCode === "string" && countryCode ? countryCode : "CN", provinceCode: typeof provinceCode === "string" ? provinceCode : "", cityCode: typeof cityCode === "string" ? cityCode : "", districtCode: typeof districtCode === "string" ? districtCode : "", birthPlaceLabel: typeof birthPlaceLabel === "string" ? birthPlaceLabel : "", birthPlaceType: typeof birthPlaceType === "string" ? birthPlaceType : "", birthPlaceProvider: typeof birthPlaceProvider === "string" ? birthPlaceProvider : "", birthPlaceProviderId: typeof birthPlaceProviderId === "string" ? birthPlaceProviderId : "", timezoneId: typeof timezoneId === "string" ? timezoneId : "", timezoneSource: typeof timezoneSource === "string" ? timezoneSource : "", latitude, longitude, timezoneOffset, }; } function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null): SessionReadResult { if (!Array.isArray(value)) return { sessions: [], fallbackSessionIds: [] }; const fallbackSessionIds: string[] = []; const sessions = value.flatMap((item): ChatSession[] => { if (!item || typeof item !== "object") return []; const session = item as Partial & { model_id?: unknown; rectification_case_id?: unknown; session_type?: unknown; updated_at?: unknown; }; const messages: Message[] = Array.isArray(session.messages) ? session.messages.flatMap((message) => ( message && typeof message === "object" && ((message as Message).role === "user" || (message as Message).role === "assistant") && typeof (message as Message).text === "string" ? [{ role: (message as Message).role, text: (message as Message).text.slice(0, 12000), }] : [] )) : []; if (typeof session.id !== "string") return []; const savedModelId = session.model_id ?? session.modelId; const selection = catalog ? resolveSessionModelId(savedModelId, catalog) : { modelId: typeof savedModelId === "string" ? savedModelId : "", fellBack: false }; if (catalog && selection.fellBack) fallbackSessionIds.push(session.id); return [{ id: session.id, title: typeof session.title === "string" ? session.title.slice(0, 36) : "新对话", theme: normalizeConsultationDomain(session.theme) ?? "general", modelId: selection.modelId, messages, sessionType: session.session_type === "birth_time_rectification" ? "birth_time_rectification" : "consultation", rectificationCaseId: typeof session.rectification_case_id === "string" ? session.rectification_case_id : null, updatedAt: typeof session.updatedAt === "number" ? session.updatedAt : typeof session.updated_at === "string" ? Date.parse(session.updated_at) : timestamp(), }]; }); return { sessions, fallbackSessionIds }; } function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) { return (
出生地点 onChange({ ...value, countryCode: "CN", provinceCode: location?.provinceCode || "", cityCode: location?.cityCode || "", districtCode: location?.districtCode || "", birthPlaceLabel: location?.label || "", birthPlaceType: location?.placeType || "", birthPlaceProvider: location ? "china_locations" : "", birthPlaceProviderId: location?.providerPlaceId || "", timezoneId: location?.timezoneId || "", timezoneSource: location ? "iana_historical" : "", latitude: location?.latitude ?? null, longitude: location?.longitude ?? null, timezoneOffset: location?.timezoneOffset ?? null, })} />
); } const birthLocationKeys = [ "countryCode", "provinceCode", "cityCode", "districtCode", "birthPlaceLabel", "birthPlaceProviderId", "timezoneId", "latitude", "longitude", "timezoneOffset", ] as const; function birthProfileDeclarationChanged(current: Profile, next: Profile) { return declaredBirthInputChanged(current, next) || birthLocationKeys.some((key) => current[key] !== next[key]); } function invalidateCandidateAfterLocationChange(current: Profile, next: Profile): Profile { const locationChanged = birthLocationKeys.some((key) => current[key] !== next[key]); if (!locationChanged || current.birthTimeStatus === "confirmed" || (current.birthTimeStatus !== "candidate" && !current.time)) return next; return { ...next, time: "", birthTimeStatus: "reported" }; } function ProfileFields({ value, onChange, nameInputId }: { value: Profile; onChange: (profile: Profile) => void; nameInputId?: string }) { return ( <> onChange(applyBirthTimeDraftPatch(value, patch))} /> onChange(invalidateCandidateAfterLocationChange(value, next))} /> ); } function OnboardingChatMessage({ role, text, streaming = false, length = text.length, phraseSafe = false }: { role: Message["role"]; text: string; streaming?: boolean; length?: number; phraseSafe?: boolean }) { const visibleText = streaming ? text.slice(0, length) : text; const protectedVisibleText = protectOnboardingPhrases(visibleText); return (
{role === "assistant" && }
{role === "assistant" ? ( streaming ? ( <>
= text.length ? "is-complete" : ""}`} aria-hidden="true">
{length >= text.length ? text : ""} ) : ) :

{protectedVisibleText}

}
); } function isProfileComplete(profile: Profile) { return missingProfileStep(profile) === null; } function friendlyError(message: string) { return message.includes("Supabase") && (message.includes("配置") || message.includes("environment") || message.includes("URL")) ? "Supabase 尚未配置" : message; } function payloadMessage(payload: unknown, fallback: string) { if (!payload || typeof payload !== "object") return fallback; const data = payload as Record; const message = [data.recovery, data.message, data.error].find((value) => typeof value === "string") as string | undefined; return friendlyError(message || fallback); } class CancellationResponseError extends Error { readonly status: number; constructor(status: number, message: string) { super(message); this.name = "CancellationResponseError"; this.status = status; } } class ConsultationResponseError extends Error { readonly status: number; constructor(status: number, message: string) { super(message); this.name = "ConsultationResponseError"; this.status = status; } } class ConsultationStatusError extends Error { readonly status: number; constructor(status: number, message: string) { super(message); this.name = "ConsultationStatusError"; this.status = status; } } class LoginRedirectError extends Error { constructor() { super("Redirecting to login"); this.name = "LoginRedirectError"; } } function redirectToLogin(): never { window.location.replace("/login"); throw new LoginRedirectError(); } function waitForUndoWindow(signal: AbortSignal) { return new Promise((resolve) => { const finish = () => { window.clearTimeout(timer); signal.removeEventListener("abort", finish); resolve(); }; const timer = window.setTimeout(finish, undoWindowMs); signal.addEventListener("abort", finish, { once: true }); }); } async function fetchAccount(signal?: AbortSignal): Promise { const response = await fetch("/api/account", { signal, cache: "no-store" }); if (response.status === 401) redirectToLogin(); const payload = await response.json().catch(() => null); if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取账户信息")); return payload as Account; } async function fetchModelCatalog(signal?: AbortSignal) { const response = await fetch("/api/models", { signal, cache: "no-store" }); const payload = await response.json().catch(() => null); if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取可用模型")); return parsePublicModelCatalog(payload); } async function fetchSessions(signal?: AbortSignal): Promise { const response = await fetch("/api/sessions", { signal, cache: "no-store" }); if (response.status === 401) redirectToLogin(); const payload = await response.json().catch(() => null); if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录")); return payload && typeof payload === "object" ? (payload as { sessions?: unknown }).sessions : null; } function parseConsultationStatus(payload: unknown, requestId?: string): ConsultationStatus { if (!payload || typeof payload !== "object") throw new Error("后台回答状态无效"); const status = payload as Partial; if (typeof status.requestId !== "string" || typeof status.sessionId !== "string" || (requestId && status.requestId !== requestId) || (status.status !== "reserved" && status.status !== "completed" && status.status !== "cancelled")) { throw new Error("后台回答状态无效"); } return status as ConsultationStatus; } async function fetchConsultationStatus(sessionId: string, requestId: string, signal?: AbortSignal): Promise { const response = await fetch(`/api/consult/status?sessionId=${encodeURIComponent(sessionId)}&requestId=${encodeURIComponent(requestId)}`, { signal, cache: "no-store", }); const payload: unknown = await response.json().catch(() => null); if (!response.ok) { throw new ConsultationStatusError( response.status, payloadMessage(payload, "暂时无法恢复后台回答"), ); } const status = parseConsultationStatus(payload, requestId); if (status.sessionId !== sessionId) throw new Error("后台回答状态无效"); return status; } async function fetchActiveConsultationStatus(signal?: AbortSignal): Promise { const response = await fetch("/api/consult/status", { signal, cache: "no-store" }); const payload: unknown = await response.json().catch(() => null); if (response.status === 404) return null; if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法恢复后台回答")); const status = parseConsultationStatus(payload); if (status.status !== "reserved") throw new Error("后台回答状态无效"); return status; } async function patchSessionModel(sessionId: string, modelId: string, signal?: AbortSignal) { const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { method: "PATCH", headers: { "content-type": "application/json" }, credentials: "same-origin", body: JSON.stringify({ model_id: modelId }), signal, }); if (response.status === 401) { window.location.assign("/login"); throw new Error("请先登录"); } const payload = await response.json().catch(() => null); if (!response.ok) throw new Error(payloadMessage(payload, "模型选择暂时无法同步到云端。")); } export default function Home() { const router = useRouter(); const [profile, setProfile] = useState(emptyProfile); const [profileDraft, setProfileDraft] = useState(emptyProfile); const [accountMenuOpen, setAccountMenuOpen] = useState(false); const [activeAccountDialog, setActiveAccountDialog] = useState(null); const [chartLibrary, setChartLibrary] = useState([]); const [chartLibraryOpen, setChartLibraryOpen] = useState(false); const [synastryRelationshipType, setSynastryRelationshipType] = useState("romance"); const [synastryPendingId, setSynastryPendingId] = useState(null); const [otherProfileDraft, setOtherProfileDraft] = useState(emptyProfile); const [synastryReportCard, setSynastryReportCard] = useState(null); const [synastryHistory, setSynastryHistory] = useState([]); const [dailyStarlanguage, setDailyStarlanguage] = useState({ kind: "pending" }); const [profileNotice, setProfileNotice] = useState(""); const [avatarNotice, setAvatarNotice] = useState(""); const [avatarSaving, setAvatarSaving] = useState(false); const [account, setAccount] = useState(null); const [accountError, setAccountError] = useState(""); const [signingOut, setSigningOut] = useState(false); const [sessions, setSessions] = useState([]); const [pinnedSessionIds, setPinnedSessionIds] = useState([]); const [archivedSessionIds, setArchivedSessionIds] = useState([]); const [showArchivedSessions, setShowArchivedSessions] = useState(false); const [sessionMenuId, setSessionMenuId] = useState(null); const [pendingSessionDeletion, setPendingSessionDeletion] = useState(null); const [modelCatalog, setModelCatalog] = useState(null); const [activeSessionId, setActiveSessionId] = useState(""); const draftTheme = useRef(null); const draftEntrypoint = useRef(null); const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | "recovering" | null>(null); const [cancellationPending, setCancellationPending] = useState(false); const [pendingSessionId, setPendingSessionId] = useState(null); const [pendingRequestId, setPendingRequestId] = useState(null); const [streamingReply, setStreamingReply] = useState(null); const [messageFeedback, setMessageFeedback] = useState>({}); const [copiedMessageKey, setCopiedMessageKey] = useState(null); const [replyOutcome, setReplyOutcome] = useState(null); const [requestError, setRequestError] = useState(null); const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState( createBirthTimeConsultationConsentState, ); const [rectificationSessionId, setRectificationSessionId] = useState(null); const [rectificationCaseId, setRectificationCaseId] = useState(null); const [rectificationHeaderSlot, setRectificationHeaderSlot] = useState(null); const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState(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([]); const [rectificationEntrySummary, setRectificationEntrySummary] = useState(null); const [hydrated, setHydrated] = useState(false); const [profileSaving, setProfileSaving] = useState(false); const [creatingSession, setCreatingSession] = useState(false); const [onboarding, setOnboarding] = useState(null); const [onboardingError, setOnboardingError] = useState(""); const [onboardingStep, setOnboardingStep] = useState("name"); const [onboardingJustCompleted, setOnboardingJustCompleted] = useState(false); const [onboardingPaywallOpen, setOnboardingPaywallOpen] = useState(false); const [birthTimeJourney, setBirthTimeJourney] = useState(null); const [birthTimeError, setBirthTimeError] = useState(""); const [birthTimeAssessmentPhase, setBirthTimeAssessmentPhase] = useState(null); const [startGreeting, setStartGreeting] = useState(""); const [starterGreetingSelection, setStarterGreetingSelection] = useState(() => Math.random()); const [presetMessageLength, setPresetMessageLength] = useState(0); const conversation = useRef(null); const accountTrigger = useRef(null); const accountDialog = useRef(null); const dialogReturnTarget = useRef(null); const closeButton = useRef(null); const onboardingPaywallShown = useRef(false); const starterGreetingUnseen = useRef(true); const composerInput = useRef(null); const pendingConsultation = useRef(null); const cancellationRequests = useRef(new Map>()); const cancellationFeedbackRequest = useRef(null); const cancellationInFlight = useRef(false); const stoppedRequestAwaitingSettlement = useRef(null); const stoppedSessionPersistence = useRef(new Map>()); const consultationRecoveryWakeup = useRef<() => void>(() => undefined); const consultationRecoveryCheck = useRef<() => void>(() => undefined); const consultationReplay = useRef<() => void>(() => undefined); const consultationStatusMissingCount = useRef(0); const consultationReplayStarted = useRef(null); const modelPersistence = useRef(new SessionModelPersistenceQueue()); const modelSyncFailures = useRef(new Set()); const modelSelectionVersions = useRef(new Map()); const activeSessionIdRef = useRef(""); const chartLibraryLoadedAccount = useRef(""); const activeOnboardingRequestIdentity = useRef(""); const accountRefreshGuard = useRef(createLatestAccountRequestGuard()); const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined); const rectificationOpenInFlight = useRef(false); const uiPreview = useRef(false); const uiPreviewMode = useRef(null); const birthTimeRevisionPending = useRef(false); const birthTimeGuided = useBirthTimeGuidedJourney({ journey: birthTimeJourney, preview: process.env.NODE_ENV === "development" && uiPreview.current, onJourney: setBirthTimeJourney, onReady: completeGuidedBirthTime, 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 = sessions .filter((session) => showArchivedSessions ? archivedSessionIds.includes(session.id) : !archivedSessionIds.includes(session.id)) .sort((left, right) => Number(pinnedSessionIds.includes(right.id)) - Number(pinnedSessionIds.includes(left.id))); 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 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); useEffect(() => { activeSessionIdRef.current = activeSessionId; }, [activeSessionId]); 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 (!hydrated || !accountId) return; const prefix = `jyotisha-session-controls:${accountId}:`; setPinnedSessionIds(JSON.parse(localStorage.getItem(`${prefix}pinned`) || "[]")); setArchivedSessionIds(JSON.parse(localStorage.getItem(`${prefix}archived`) || "[]")); }, [accountId, hydrated]); useEffect(() => { if (!hydrated || !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. } })(); }, [accountId, hydrated]); useEffect(() => { if (!hydrated || !accountId) return; const prefix = `jyotisha-session-controls:${accountId}:`; localStorage.setItem(`${prefix}pinned`, JSON.stringify(pinnedSessionIds)); localStorage.setItem(`${prefix}archived`, JSON.stringify(archivedSessionIds)); }, [accountId, archivedSessionIds, hydrated, pinnedSessionIds]); useEffect(() => { if (!accountId) { setChartLibrary([]); setSynastryHistory([]); chartLibraryLoadedAccount.current = ""; return; } if (chartLibraryLoadedAccount.current === accountId) return; chartLibraryLoadedAccount.current = accountId; setChartLibrary(upsertSelfChart(readChartLibrary(accountId), profile)); setSynastryHistory(readSynastryHistory(accountId)); void fetchCloudChartLibrary() .then((cloudLibrary) => { setChartLibrary(() => { const next = upsertSelfChart(cloudLibrary.filter((record) => record.role !== "self"), profile); localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); return next; }); }) .catch(() => { // Cloud chart library is best-effort; local library remains usable. }); void fetchCloudSynastryHistory() .then((cloudHistory) => { setSynastryHistory((current) => { const byId = new Map([...current, ...cloudHistory].map((record) => [record.id, record] as const)); const next = [...byId.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10); writeSynastryHistory(accountId, next); return next; }); }) .catch(() => { // Cloud synastry history is best-effort; local history remains usable. }); }, [accountId, profile]); useEffect(() => { if (!accountId) return; setChartLibrary((current) => { const next = upsertSelfChart(current, profile); localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); return next; }); }, [accountId, profile]); const profileComplete = isProfileComplete(profile); const birthTimeRoute = resolveBirthTimeConsultationRoute(profile, birthTimeConsultationConsent, activeSessionId); const personalChartAvailable = birthTimeRoute.kind === "consult" && birthTimeRoute.mode !== "general_no_birth_time"; const dailyStarlanguageFingerprint = dailyStarlanguageProfileKey(profile); const starterThemes = personalChartAvailable ? themes : generalGuidedJyotishTopics; const dailyStarlanguageQuestion = !personalChartAvailable ? "请帮我看一下今天的运势,重点告诉我适合推进什么、需要注意什么。" : dailyStarlanguage.kind === "unavailable" ? "从今日问起" : "深入看今日"; const dailyStarlanguageTrend = dailyStarlanguage.kind === "ready" ? dailyStarlanguage.card.trend : dailyStarlanguage.kind === "pending" ? "正在写下今天的星语。" : "今天的星语还没写出来。"; const dailyStarlanguageAction = dailyStarlanguage.kind === "ready" ? dailyStarlanguage.card.action : ""; const dailyStarlanguageBusy = personalChartAvailable && dailyStarlanguage.kind === "pending"; const onboardingPending = profileComplete && !onboarding && !onboardingError; 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) => personalChartAvailable ? 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 && !activeSession?.messages.length; 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 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; } function restoreConsultationRecovery( session: ChatSession, requestId: string, stored?: StoredPendingConsultation | null, ) { if (pendingConsultation.current) return; const lastMessage = session.messages.at(-1); const storedQuestion = stored?.question?.trim() ?? ""; const lastIsQuestion = lastMessage?.role === "user" && (!storedQuestion || lastMessage.text === storedQuestion); const question = lastIsQuestion && lastMessage ? lastMessage.text : storedQuestion; const optimisticSession = lastIsQuestion || !question ? session : { ...session, title: session.messages.length === 0 && session.title === "新对话" ? resolveSessionTitle(question) : session.title, theme: stored?.theme ?? session.theme, messages: [...session.messages, { role: "user" as const, text: question }], updatedAt: timestamp(), }; const previousSession = optimisticSession.messages.at(-1)?.role === "user" ? { ...optimisticSession, messages: optimisticSession.messages.slice(0, -1) } : optimisticSession; if (optimisticSession !== session) updateSession(session.id, () => optimisticSession); pendingConsultation.current = { requestId, sessionId: session.id, question, entrypoint: stored?.entrypoint ?? null, theme: stored?.theme ?? session.theme, previousSession, optimisticSession, previousOnboardingState: false, controller: new AbortController(), cancelled: false, phase: "recovering", partialReply: "", }; setPendingSessionId(session.id); setPendingRequestId(requestId); setActiveSessionId(session.id); setConsultationPhase("recovering"); setStreamingReply({ sessionId: session.id, text: "" }); setComposerNotice(navigator.onLine ? "回答仍在后台生成,正在自动恢复。" : "网络已断开,回答仍在后台生成;联网后会自动恢复。"); } consultationRecoveryCheck.current = () => { consultationRecoveryWakeup.current(); if (pendingConsultation.current || uiPreview.current) return; void fetchActiveConsultationStatus() .then((status) => { if (status?.status !== "reserved") return; const session = sessions.find((item) => item.id === status.sessionId); if (session) restoreConsultationRecovery(session, status.requestId); }) .catch(() => undefined); }; 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; try { const previewMode = process.env.NODE_ENV === "development" ? new URLSearchParams(window.location.search).get("preview") : null; if (previewMode) { uiPreview.current = true; uiPreviewMode.current = previewMode; 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", birthTimePeriod: 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, }; 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 parsedSessions = readSessions(sessionsPayload, nextModelCatalog); let nextSessions = parsedSessions.sessions; if (nextSessions.length === 0) { if (controller.signal.aborted) return; const initialSession = createSession(nextModelCatalog?.defaultModelId ?? ""); if (nextModelCatalog) { await writeChatSession(initialSession.id, { title: initialSession.title, theme: initialSession.theme, model_id: initialSession.modelId, messages: initialSession.messages, session_type: initialSession.sessionType, rectification_case_id: initialSession.rectificationCaseId, updated_at: new Date(initialSession.updatedAt).toISOString(), }, "create"); } nextSessions = [initialSession]; } 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; clearStaleClientReload(sessionStorage); const nextProfile = readProfile(nextAccount.profile); setAccount(nextAccount); setModelCatalog(nextModelCatalog); setProfile(nextProfile); setProfileDraft(nextProfile); setStartGreeting(nextProfile.name.trim() ? createStartGreeting(nextProfile.name) : ""); setOnboardingStep(missingProfileStep(nextProfile) ?? "name"); setSessions(nextSessions); setActiveSessionId(nextSessions[0].id); 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("此前选择的模型已下线,已切换为默认模型。"); } 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) { setAccountError(friendlyError(caught instanceof Error ? caught.message : "暂时无法读取云端数据")); } } finally { if (redirectedToLogin) return; window.clearTimeout(bootstrapTimeout); if (!controller.signal.aborted) setHydrated(true); } } 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 payload = await fetchSessions(controller.signal); const parsed = readSessions(payload, modelCatalog); setSessions(parsed.sessions); setActiveSessionId((current) => parsed.sessions.some((session) => session.id === current) ? current : parsed.sessions[0]?.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 (!hydrated || !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, hydrated, onboardingFingerprint, profile.name, profileComplete]); useEffect(() => { if (!hydrated || !accountId || !profileComplete || !personalChartAvailable) return; const today = calendarDateInTimeZone(new Date(), profile.timezoneId); const fingerprint = dailyStarlanguageProfileKey(profile); const stored = readStoredDailyStarlanguage(accountId); const controller = new AbortController(); let retryTimer: ReturnType | undefined; if (stored && stored.day === today && stored.fingerprint === fingerprint) { setDailyStarlanguage({ kind: "ready", card: stored.card }); } 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, dailyStarlanguageFingerprint, hydrated, personalChartAvailable, profileComplete]); useEffect(() => { if (starterHomeVisible) { starterGreetingUnseen.current = false; return; } if (starterGreetingUnseen.current) return; starterGreetingUnseen.current = true; setStarterGreetingSelection(Math.random()); }, [starterHomeVisible]); useEffect(() => { if (starterHomeVisible) return; const container = conversation.current; if (!container) return; if (!conversationAnchor.anchored) return; const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; container.scrollTo({ top: container.scrollHeight, behavior: isLoading || reduceMotion ? "auto" : "smooth" }); }, [activeSessionId, activeSession?.messages.length, activeStreamingText, conversationAnchor.anchored, isLoading, onboardingPending, onboardingStep, presetMessageFinished, profileComplete, 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]); async function refreshAccount() { const requestIdentity = accountRefreshGuard.current.begin(); try { const latest = await fetchAccount(); if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return; const nextProfile = readProfile(latest.profile); setProfile((current) => preserveShallowEqual(current, nextProfile)); setAccount(latest); setAccountError(""); } catch (caught) { if (caught instanceof LoginRedirectError) return; if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return; setAccountError(caught instanceof Error ? caught.message : "暂时无法读取账户信息"); } } function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) { setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session))); } async function persistSession(session: ChatSession, mode: "create" | "update" = "update") { if (!account) throw new Error("账户尚未加载完成"); if (process.env.NODE_ENV === "development" && uiPreview.current) return; const values = { title: session.title, theme: session.theme, model_id: session.modelId, messages: session.messages, session_type: session.sessionType, rectification_case_id: session.rectificationCaseId, updated_at: new Date(session.updatedAt).toISOString(), }; await writeChatSession(session.id, values, mode); } async function renameSession(session: ChatSession) { const title = window.prompt("重命名聊天记录", session.title)?.trim(); if (!title || title === session.title) return; const nextSession = { ...session, title, updatedAt: timestamp() }; updateSession(session.id, () => nextSession); try { await persistSession(nextSession); } catch (caught) { setComposerNotice(caught instanceof Error ? caught.message : "重命名同步失败"); } } async function deleteSession(session: ChatSession) { if (!account) return; const previousSessions = sessions; const nextSessions = sessions.filter((item) => item.id !== session.id); setSessions(nextSessions); setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent(current, session.id)); setPinnedSessionIds((current) => current.filter((id) => id !== session.id)); setArchivedSessionIds((current) => current.filter((id) => id !== session.id)); if (activeSessionId === session.id) setActiveSessionId(nextSessions[0]?.id ?? ""); try { const response = await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" }); const payload = await response.json().catch(() => null) as { error?: string } | null; if (!response.ok) throw new Error(payload?.error || "删除聊天记录失败"); } catch (caught) { setSessions(previousSessions); setComposerNotice(caught instanceof Error ? `删除失败:${caught.message}` : "删除失败"); } } function togglePinnedSession(sessionId: string) { setPinnedSessionIds((current) => current.includes(sessionId) ? current.filter((id) => id !== sessionId) : [sessionId, ...current]); } function toggleArchivedSession(sessionId: string) { const restoring = archivedSessionIds.includes(sessionId); setArchivedSessionIds((current) => restoring ? current.filter((id) => id !== sessionId) : [sessionId, ...current]); if (!restoring && activeSessionId === sessionId) { setActiveSessionId(visibleSessions.find((session) => session.id !== sessionId)?.id ?? ""); } setComposerNotice(restoring ? "已恢复到聊天记录。" : "已归档,可在左侧归档中恢复。"); } async function shareSession(session: ChatSession) { const sharePayload = { share_payload_version: 1, exported_at: new Date().toISOString(), title: session.title, theme: session.theme, message_count: session.messages.length, messages: session.messages.map((message) => ({ role: message.role, text: message.text })), }; const reportMarkdown = consultationReportMarkdown({ title: session.title, messages: session.messages }); const transcript = [ `Jyotisha 对话:${session.title}`, "", ...session.messages.map((message) => `${message.role === "user" ? "我" : "Jyotisha"}:${message.text}`), "", "---- Markdown 报告 ----", reportMarkdown, "", "---- JSON 分享包 ----", JSON.stringify(sharePayload, null, 2), ].join("\n"); try { await navigator.clipboard.writeText(transcript); setComposerNotice("已复制当前聊天,可粘贴转发。"); } catch { setComposerNotice("无法访问剪贴板,请手动复制聊天内容。"); } } async function startNewChat() { if (!account || !modelCatalog || creatingSession) return; const nextSession = createSession(modelCatalog.defaultModelId); const previousSessionId = activeSession?.id ?? ""; setCreatingSession(true); setSessions((current) => [nextSession, ...current]); setActiveSessionId(nextSession.id); setDraft(""); setDraftTheme(null); setDraftEntrypoint(null); setComposerNotice(""); setRequestError(null); try { await persistSession(nextSession, "create"); } catch (caught) { setSessions((current) => current.filter((session) => session.id !== nextSession.id)); setActiveSessionId(previousSessionId); setRequestError({ sessionId: previousSessionId, message: caught instanceof Error ? caught.message : "新对话未能保存到云端。", }); } finally { setCreatingSession(false); } } async function startConsultationAfterRectification() { await startNewChat(); setDraft("请用刚才采用的代表性出生时间看盘。"); setComposerNotice("已用刚才采用的时间作为当前排盘。这还不是唯一分钟确认。"); } function selectSession(sessionId: string) { const nextSession = sessions.find((session) => session.id === sessionId); setActiveSessionId(sessionId); setDraft(""); setDraftEntrypoint(null); setComposerNotice(""); if (nextSession?.sessionType === "birth_time_rectification") { setRectificationError(""); if (nextSession.id !== rectificationSessionId) { // The exact sessionId is passed to the server; the server resolves // the exact Case and never switches to another rectification record. void openRectificationSession(nextSession.id); } } } async function selectSessionModel(modelId: string) { const userId = account?.user.id; if (!activeSession || !modelCatalog || !userId || pendingSessionId || cancellationPending || creatingSession) return; const selectedModel = modelCatalog.models.find((model) => model.id === modelId); const retryingFailedSync = activeSession.modelId === modelId && modelSyncFailures.current.has(activeSession.id); if (!selectedModel || (activeSession.modelId === modelId && !retryingFailedSync)) return; const nextSession: ChatSession = retryingFailedSync ? activeSession : { ...activeSession, modelId, updatedAt: timestamp() }; const selectionVersion = (modelSelectionVersions.current.get(nextSession.id) ?? 0) + 1; modelSelectionVersions.current.set(nextSession.id, selectionVersion); if (!retryingFailedSync) updateSession(activeSession.id, () => nextSession); setRequestError(null); setComposerNotice(""); try { await modelPersistence.current.enqueue(nextSession.id, () => persistSessionModelSelection( async ({ values, sessionId }) => { if (process.env.NODE_ENV === "development" && uiPreview.current) { return { found: true, error: null }; } try { await patchSessionModel(sessionId, values.model_id); return { found: true, error: null }; } catch (error) { return { found: false, error: error instanceof Error ? error.message : "模型选择暂时无法同步到云端。", }; } }, userId, nextSession.id, modelId, )); if (modelSelectionVersions.current.get(nextSession.id) !== selectionVersion) return; modelSelectionVersions.current.delete(nextSession.id); modelSyncFailures.current.delete(nextSession.id); } catch (caught) { if (modelSelectionVersions.current.get(nextSession.id) !== selectionVersion) return; modelSelectionVersions.current.delete(nextSession.id); modelSyncFailures.current.add(nextSession.id); if (activeSessionIdRef.current === nextSession.id) { setComposerNotice(`已在当前页面选择 ${selectedModel.label},但云端同步失败;再次选择当前模型即可重试。`); } setRequestError({ sessionId: nextSession.id, message: caught instanceof Error ? caught.message : "模型选择暂时无法同步到云端。", }); } } function openAccountDialog(dialog: AccountDialog, returnTarget: HTMLButtonElement | null = accountTrigger.current) { dialogReturnTarget.current = returnTarget ?? accountTrigger.current; setAccountMenuOpen(false); setAccountError(""); if (dialog === "profile") { setProfileDraft(profile); setProfileNotice(""); setAvatarNotice(""); } setActiveAccountDialog(dialog); } function closeAccountDialog() { if (signingOut) return; setActiveAccountDialog(null); const returnTarget = dialogReturnTarget.current; window.requestAnimationFrame(() => returnTarget?.focus()); } async function persistAvatar(patch: BeamAvatarPatch) { if (!account?.avatar || avatarSaving) return; setAvatarSaving(true); setAvatarNotice(""); setAccountError(""); try { const response = await fetch("/api/account/avatar", { method: "PATCH", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify(patch), }); const payload = await response.json().catch(() => null) as { avatar?: unknown; error?: string } | null; if (!response.ok) throw new Error(payload?.error || "头像暂时无法保存。"); const avatar = beamAvatarSchema.parse(payload?.avatar); setAccount((current) => current ? { ...current, avatar } : current); setAvatarNotice(patch.action === "randomize" ? "已生成并保存新头像。" : "头像配色已保存。"); } catch (caught) { setAccountError(friendlyError(caught instanceof Error ? caught.message : "头像保存失败")); } finally { setAvatarSaving(false); } } async function persistProfile(nextProfile: Profile): Promise { if (!account) throw new Error("账户尚未加载完成"); if (process.env.NODE_ENV === "development" && uiPreview.current) return nextProfile; const birthPlace = selectedBirthPlace(nextProfile); const response = await fetch("/api/account", { method: "PATCH", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: nextProfile.name.trim() || null, birth_date: nextProfile.date || null, ...birthTimePersistenceValues(nextProfile), country_code: nextProfile.countryCode, province_code: nextProfile.provinceCode || null, city_code: nextProfile.cityCode || null, district_code: nextProfile.districtCode || null, birth_place_label: nextProfile.birthPlaceLabel || null, birth_place_type: nextProfile.birthPlaceType || null, birth_place_provider: nextProfile.birthPlaceProvider || null, birth_place_provider_id: nextProfile.birthPlaceProviderId || null, latitude: birthPlace?.lat ?? null, longitude: birthPlace?.lon ?? null, timezone_id: nextProfile.timezoneId || null, timezone_offset: birthPlace?.tz ?? null, timezone_source: nextProfile.timezoneSource || null, }), }); const payload = await response.json().catch(() => null) as { error?: string; birthTime?: unknown; } | null; if (!response.ok) { throw new Error(payload?.error || "账户资料暂时无法保存。"); } const savedProfile = applyPersistedBirthTime(nextProfile, payload?.birthTime); await saveCloudChartProfile({ ...buildSelfChartRecord(savedProfile), updatedAt: timestamp() }).catch(() => null); return savedProfile; } async function saveOtherChart(event: FormEvent) { event.preventDefault(); const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim() }; if (missingOtherProfileStep(nextProfile)) { setAccountError("请补全其他星盘的称呼、出生时间和出生地点。"); return; } if (!accountId) return; let record: ChartLibraryRecord = { id: globalThis.crypto.randomUUID(), role: "other", profile: nextProfile, updatedAt: timestamp(), }; let cloudSaved = false; try { record = await saveCloudChartProfile(record); cloudSaved = true; } catch { setProfileNotice("已保存到本地星盘库;云端同步失败,稍后会继续使用本地记录。"); setAccountError(""); } setChartLibrary((current) => { const next = [...upsertSelfChart(current, profile), record]; localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); return next; }); setOtherProfileDraft(emptyProfile); if (cloudSaved) { setAccountError(""); setProfileNotice("已保存到云端星盘库。请选择关系类型后点击“用于合盘”。"); } } async function deleteOtherChart(recordId: string) { if (!accountId) return; let cloudDeleted = false; try { await deleteCloudChartProfile(recordId); cloudDeleted = true; } catch { setAccountError(""); } setChartLibrary((current) => { const next = current.filter((record) => record.id !== recordId || record.role === "self"); localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); return next; }); setAccountError(""); setProfileNotice(cloudDeleted ? "已从云端星盘库删除。" : "已从本地星盘库删除;云端同步失败,稍后云端可能仍显示旧记录。"); } async function makeDefaultChart(record: ChartLibraryRecord) { if (record.role !== "other" || profileSaving) return; setProfileSaving(true); setAccountError(""); try { const savedProfile = await persistProfile(record.profile); setProfile(savedProfile); setProfileDraft(savedProfile); setProfileNotice("已设为当前默认星盘。"); } catch (caught) { setAccountError(friendlyError(caught instanceof Error ? caught.message : "默认星盘保存失败")); } finally { setProfileSaving(false); } } async function assessSavedBirthTime(nextProfile: Profile) { const result = process.env.NODE_ENV === "development" && uiPreview.current ? previewRectificationJourney : await requestBirthTimeAssessment(); const nextStatus = result.snapshot.state === "ready" ? "confirmed" : result.snapshot.state === "candidate" ? "candidate" : "rectifying"; const assessedProfile: Profile = { ...nextProfile, time: result.snapshot.activeTime ?? "", birthTimeStatus: nextStatus, rectificationCaseId: result.caseId, }; setBirthTimeJourney(result); setBirthTimeError(""); setProfile(assessedProfile); setProfileDraft(assessedProfile); return assessedProfile; } async function saveProfile(event: FormEvent) { event.preventDefault(); if (!profileDraft.name.trim() || !isBirthTimeDraftReady(profileDraft) || !selectedBirthPlace(profileDraft) || !account || profileSaving) return; setProfileSaving(true); setProfileNotice(""); setAccountError(""); try { const declarationChanged = birthProfileDeclarationChanged(profile, profileDraft); const savedProfile = await persistProfile(profileDraft); setProfile(savedProfile); setProfileDraft(savedProfile); setRectificationError(""); if (declarationChanged) { setBirthTimeConsultationConsent(createBirthTimeConsultationConsentState()); void refreshAccount(); } setProfileNotice(savedProfile.birthTimeStatus === "confirmed" ? "出生资料已保存到云端,可在同一账号的其他设备使用。" : `出生资料已保存。${birthTimeConsultationOptionsCopy(savedProfile)}`); } catch (caught) { setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生资料保存失败")); } finally { setProfileSaving(false); } } async function saveOnboardingName() { const name = composerDraftSnapshot().replace(/\s+/g, " ").trim().slice(0, 80); if (!name || !account || profileSaving) return; const nextProfile = { ...profileDraft, name }; setProfileSaving(true); setAccountError(""); try { const savedProfile = await persistProfile(nextProfile); setProfile(savedProfile); setProfileDraft(savedProfile); setStartGreeting(createStartGreeting(savedProfile.name)); setDraft(""); setPresetMessageLength(0); const nextStep = missingProfileStep(savedProfile); if (nextStep) setOnboardingStep(nextStep); else setOnboardingJustCompleted(false); } catch (caught) { setAccountError(friendlyError(caught instanceof Error ? caught.message : "称呼保存失败")); } finally { setProfileSaving(false); } } async function saveOnboardingBirth(event: FormEvent) { event.preventDefault(); if (!isBirthTimeDraftReady(profileDraft) || !account || profileSaving) return; setProfileSaving(true); setBirthTimeAssessmentPhase("saving_profile"); setAccountError(""); try { const savedProfile = await persistProfile(profileDraft); birthTimeRevisionPending.current = false; setProfile(savedProfile); setProfileDraft(savedProfile); setPresetMessageLength(0); const nextStep = missingProfileStep(savedProfile); if (nextStep) setOnboardingStep(nextStep); else setOnboardingJustCompleted(false); } catch (caught) { setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生时间保存失败")); } finally { setBirthTimeAssessmentPhase(null); setProfileSaving(false); } } function editDeclaredBirthTimeDetails() { birthTimeRevisionPending.current = true; setBirthTimeError(""); setPresetMessageLength(0); setOnboardingStep("birth"); } async function saveOnboardingPlace(event: FormEvent) { event.preventDefault(); if (!selectedBirthPlace(profileDraft) || !account || profileSaving) return; setProfileSaving(true); setBirthTimeAssessmentPhase("entering_home"); setAccountError(""); try { const savedProfile = await persistProfile(profileDraft); setProfile(savedProfile); setProfileDraft(savedProfile); setStartGreeting(createStartGreeting(savedProfile.name)); setPresetMessageLength(0); setOnboardingJustCompleted(false); } catch (caught) { setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生地点保存失败")); } finally { setBirthTimeAssessmentPhase(null); setProfileSaving(false); } } function completeGuidedBirthTime(result: JourneyClientResponse) { if (result.nextAction.kind !== "ready") return; const confirmedProfile: Profile = { ...profileDraft, time: result.nextAction.activeTime, birthTimeStatus: "confirmed", rectificationCaseId: result.caseId, }; setProfile(confirmedProfile); setProfileDraft(confirmedProfile); setPresetMessageLength(0); setOnboardingJustCompleted(false); } async function retryBirthTimeAssessment() { if (!account || profileSaving) return; setProfileSaving(true); setBirthTimeError(""); try { const assessedProfile = await assessSavedBirthTime(profileDraft); setPresetMessageLength(0); if (assessedProfile.birthTimeStatus === "confirmed") setOnboardingJustCompleted(false); } catch (caught) { setBirthTimeError(caught instanceof Error ? caught.message : "生时评估暂时不可用,请稍后重试。"); } finally { setProfileSaving(false); } } async function signOut() { if (signingOut) return; setSigningOut(true); setAccountError(""); try { await selfHostedOtpActions.signOut(); window.location.assign("/login"); } catch (caught) { const message = caught instanceof Error ? caught.message : "退出失败"; setAccountError(friendlyError(message)); setSigningOut(false); } } 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()); } function draftDailyStarlanguageQuestion() { chooseSuggestedQuestion( dailyStarlanguageQuestion, "timing", personalChartAvailable ? "daily_starlanguage" : null, ); } async function refreshRectificationEntrySummary() { if (!account) return; 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. } } async function refreshRectificationCase(caseId: string, sessionId: string) { try { const response = await fetch( `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, { cache: "no-store" }, ); if (!response.ok) return; const payload = await response.json().catch(() => null); const turns = Array.isArray(payload?.turns) ? payload.turns : []; setRectificationTurns(turns.map((turn: { id?: unknown; role?: unknown; text?: unknown; status?: unknown; receipt?: unknown }) => ({ id: String(turn?.id ?? ""), role: turn?.role === "user" ? "user" as const : "assistant" as const, text: typeof turn?.text === "string" ? turn.text : null, status: String(turn?.status ?? "completed"), receipt: turn?.receipt && typeof turn.receipt === "object" ? { status: String((turn.receipt as { status?: unknown }).status ?? ""), phases: Array.isArray((turn.receipt as { phases?: unknown }).phases) ? (turn.receipt as { phases: unknown[] }).phases.map(String) : [], tools: Array.isArray((turn.receipt as { tools?: unknown }).tools) ? (turn.receipt as { tools: unknown[] }).tools.map(String) : [], methods: Array.isArray((turn.receipt as { methods?: unknown }).methods) ? (turn.receipt as { methods: unknown[] }).methods.map(String) : [], skill_name: typeof (turn.receipt as { skill_name?: unknown }).skill_name === "string" ? (turn.receipt as { skill_name: string }).skill_name : undefined, skill_version: typeof (turn.receipt as { skill_version?: unknown }).skill_version === "string" ? (turn.receipt as { skill_version: string }).skill_version : undefined, } : null, }))); } catch { // History refresh is best-effort; the stream restores live turns. } } async function openRectificationCase( intent: "homepage" | "session" | "new", exactSessionId: string | null, pendingConsultationQuestion: string | null, ) { if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current || rectificationMutationPending) return null; const missingStep = missingProfileStep(profile); if (missingStep) { setRectificationSessionId(null); setRectificationCaseId(null); setRectificationPendingQuestion(null); setOnboardingStep(missingStep); setComposerNotice("请先完成出生资料,再开始生时校正。"); return null; } rectificationOpenInFlight.current = true; setRectificationLoading(true); setRectificationError(""); try { const response = await fetch("/api/rectification/cases/open", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(openRectificationRequestBody(intent, exactSessionId)), }); const payload = await response.json().catch(() => null); if (!response.ok) { const code = payload?.code; if (code === "profile_incomplete") { handleRectificationProfileIncomplete(); } else if (code === "invalid_open_request") { setRectificationError(payload?.error || "生时校正打开请求不正确,请刷新后重试。"); } else if (code === "active_case_conflict") { setRectificationError(payload?.error || "仍有未完成的校正,请先回到当前校正。"); } else if (code === "case_session_not_found") { setRectificationError("该校正会话不存在或已结束。"); } else { setRectificationError(payload?.error || "暂时无法打开生时校正。"); } return null; } const opened = openResponseFromPayload(payload); if (!opened) { setRectificationError("生时校正服务响应不正确,请稍后重试。"); return null; } // Merge the server-created session into the local list. The browser // never generates a Case id; it only mirrors the returned binding. const merged: ChatSession = { id: opened.sessionId, title: "生时校正", theme: "general", modelId: modelCatalog.defaultModelId ?? "", messages: [], updatedAt: timestamp(), sessionType: "birth_time_rectification", rectificationCaseId: opened.caseId, }; setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]); setRectificationPendingQuestion(pendingConsultationQuestion); setDraft(""); setDraftTheme(null); setDraftEntrypoint(null); setRectificationSessionId(opened.sessionId); setRectificationCaseId(opened.caseId); setRectificationShouldStartOpening(opened.shouldStartOpening); setRectificationReadonly( opened.disposition === "readonly" || isTerminalRectificationStatus(opened.status), ); setRectificationTurns([]); activeSessionIdRef.current = opened.sessionId; setActiveSessionId(opened.sessionId); void refreshRectificationCase(opened.caseId, opened.sessionId); void refreshRectificationEntrySummary(); return opened; } catch { setRectificationError("生时校正会话暂时无法打开,请稍后重试。"); return null; } finally { rectificationOpenInFlight.current = false; setRectificationLoading(false); } } async function openRectificationFromHomepage(pendingConsultationQuestion: string | null = null) { await openRectificationCase("homepage", null, pendingConsultationQuestion); } async function openRectificationSession(exactSessionId: string) { await openRectificationCase("session", exactSessionId, null); } async function startNewRectification() { await openRectificationCase("new", null, null); } resumeRectificationSession.current = (session) => { void openRectificationSession(session.id); }; function handleRectificationProfileIncomplete() { setRectificationError("profile_incomplete"); setRectificationSessionId(null); setRectificationCaseId(null); setRectificationPendingQuestion(null); const missingStep = missingProfileStep(profile); if (missingStep) { setOnboardingStep(missingStep); setComposerNotice("请先完成出生资料,再开始生时校正。"); return; } openAccountDialog("profile"); setProfileNotice("服务端未能读取完整出生资料,请重新确认并保存。"); void refreshAccount(); } function handleRectificationMessagesChange(messages: Message[]) { if (!rectificationSessionId) return; updateSession(rectificationSessionId, (session) => ({ ...session, messages, updatedAt: timestamp(), })); } 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}-${Date.now()}`, 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: Date.now(), }; let savedReportCard = reportCard; if (accountId) { try { savedReportCard = await saveCloudSynastryReport(reportCard); } catch { // Local history remains the fallback when cloud persistence is unavailable. } } setSynastryReportCard(savedReportCard); if (accountId) { setSynastryHistory((current) => { const next = [savedReportCard, ...current.filter((item) => item.id !== savedReportCard.id)].slice(0, 10); writeSynastryHistory(accountId, next); return next; }); } chooseSuggestedQuestion([ baseQuestion, "", evidenceSummary, payload.relationshipReport?.headline ? `结构化摘要:${payload.relationshipReport.headline}` : "", ].join("\n"), relationshipType === "business" ? "career" : "marriage"); } 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 requestCancellation(requestId: string) { const existing = cancellationRequests.current.get(requestId); if (existing) return existing; const cancellation = (async () => { const response = await fetch("/api/consult/cancel", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ requestId }), keepalive: true, }); const payload: unknown = await response.json().catch(() => null); if (!response.ok) { throw new CancellationResponseError( response.status, payloadMessage(payload, "暂时无法确认点数已退回"), ); } if (!payload || typeof payload !== "object") return; const credits = "credits" in payload ? payload.credits : null; if (typeof credits === "number") { setAccount((current) => current ? { ...current, credits } : current); } })(); cancellationRequests.current.set(requestId, cancellation); return cancellation; } async function confirmCancellation(requestId: string, sessionId: string, confirmedNotice: string) { try { await requestCancellation(requestId); if (cancellationFeedbackRequest.current === requestId && activeSessionIdRef.current === sessionId) { setComposerNotice(confirmedNotice); } } catch (error) { if (cancellationFeedbackRequest.current === requestId && activeSessionIdRef.current === sessionId) { setComposerNotice(error instanceof CancellationResponseError && error.status === 409 ? "回答已完成结算,本次已计费;问题仍保留在输入框。" : "问题已放回输入框;暂时无法确认点数状态,请稍后在账户中核对。"); setRequestError((current) => current?.sessionId === sessionId ? current : { sessionId, message: error instanceof Error ? error.message : "暂时无法确认点数状态。", }); } void refreshAccount(); } } async function stopResponse() { const pending = pendingConsultation.current; if (!pending || pending.cancelled) return; setReplyOutcome({ sessionId: pending.sessionId, phase: "stopped", replyOrdinal: 0 }); const isPreview = process.env.NODE_ENV === "development" && uiPreview.current; if (pending.phase !== "undo" && !isPreview) { stoppedRequestAwaitingSettlement.current = pending.requestId; cancellationInFlight.current = true; setCancellationPending(true); } pendingConsultation.current = { ...pending, cancelled: true }; pending.controller.abort(); if (pending.partialReply) { const stoppedSession: ChatSession = { ...pending.optimisticSession, messages: [...pending.optimisticSession.messages, { role: "assistant", text: pending.partialReply }], updatedAt: timestamp(), }; if (isPreview) { updateSession(pending.sessionId, () => stoppedSession); setStreamingReply(null); setPendingSessionId(null); setConsultationPhase(null); setRequestError(null); setComposerNotice("已停止回答,现有内容已保留。"); if (pendingConsultation.current?.requestId === pending.requestId) { pendingConsultation.current = null; } return; } setComposerNotice("正在停止回答并申请退回本次点数…"); try { await requestCancellation(pending.requestId); } catch (error) { cancellationRequests.current.delete(pending.requestId); stoppedRequestAwaitingSettlement.current = null; cancellationInFlight.current = false; setCancellationPending(false); pendingConsultation.current = { ...pending, controller: new AbortController(), cancelled: false, phase: "recovering", }; setPendingSessionId(pending.sessionId); setConsultationPhase("recovering"); setStreamingReply({ sessionId: pending.sessionId, text: pending.partialReply }); setRequestError(null); if (error instanceof CancellationResponseError && error.status === 409) { setComposerNotice("回答已完成,正在恢复服务端完整内容。"); try { const status = await fetchConsultationStatus(pending.sessionId, pending.requestId); if (status.status === "completed") { const payload = await fetchSessions(); const parsed = readSessions(payload, modelCatalog); setSessions(parsed.sessions); setActiveSessionId((current) => parsed.sessions.some((session) => session.id === current) ? current : parsed.sessions[0]?.id ?? ""); pendingConsultation.current = null; setPendingSessionId(null); setConsultationPhase(null); setStreamingReply(null); setComposerNotice("回答已恢复,已显示在对话区末尾。"); void refreshAccount(); return; } } catch { // The recovery poll retries status and session reload. } } else { setComposerNotice("停止请求尚未确认,正在自动恢复后台回答。"); } window.setTimeout(() => consultationRecoveryWakeup.current(), 0); return; } updateSession(pending.sessionId, () => stoppedSession); setStreamingReply(null); setPendingSessionId(null); setConsultationPhase(null); setRequestError(null); try { await persistSession(stoppedSession); setComposerNotice("已停止回答,现有内容已保留,本次点数已退回。"); } catch (error) { setComposerNotice("本次点数已退回;现有内容暂时无法同步。"); setRequestError({ sessionId: pending.sessionId, message: error instanceof Error ? error.message : "已停止的回答暂时无法同步。", }); } cancellationRequests.current.delete(pending.requestId); stoppedRequestAwaitingSettlement.current = null; cancellationInFlight.current = false; setCancellationPending(false); if (pendingConsultation.current?.requestId === pending.requestId) { pendingConsultation.current = null; } void refreshAccount(); return; } updateSession(pending.sessionId, () => pending.previousSession); setOnboardingJustCompleted(pending.previousOnboardingState); setDraft(pending.question); setDraftTheme(pending.theme); setDraftEntrypoint(pending.entrypoint); setStreamingReply(null); setPendingSessionId(null); setConsultationPhase(null); setRequestError(null); cancellationFeedbackRequest.current = pending.requestId; setComposerNotice("已停止,问题已放回输入框,正在确认点数…"); window.requestAnimationFrame(() => composerInput.current?.focus()); if (pending.phase === "undo" || isPreview) { if (pendingConsultation.current?.requestId === pending.requestId) { pendingConsultation.current = null; } setComposerNotice("已停止,问题已放回输入框,本次未扣点。"); return; } await confirmCancellation( pending.requestId, pending.sessionId, "已停止,问题已放回输入框,本次未扣点。", ); cancellationRequests.current.delete(pending.requestId); stoppedRequestAwaitingSettlement.current = null; cancellationInFlight.current = false; setCancellationPending(false); if (pendingConsultation.current?.requestId === pending.requestId) pendingConsultation.current = null; } function completeConsultationInterface(requestId: string) { if (pendingConsultation.current?.requestId !== requestId) return; pendingConsultation.current = null; if (consultationReplayStarted.current === requestId) consultationReplayStarted.current = null; setStreamingReply(null); setPendingSessionId(null); setPendingRequestId(null); setConsultationPhase(null); } async function send( text: string, requestedTheme?: Theme, entrypoint: ConsultationEntrypoint | null = null, consentGrantedForRequest: ConsultationBirthTimeMode | null = null, targetSessionId: string | null = null, options: { resumeRequestId?: string; sessionOverride?: ChatSession; restoreOnFailure?: ChatSession; } = {}, ): Promise { const originalQuestion = text; const question = text.trim(); const resumeRequestId = options.resumeRequestId; const resuming = Boolean(resumeRequestId); const liveSession = targetSessionId ? sessions.find((session) => session.id === targetSessionId) : activeSession; const currentSession = options.sessionOverride ?? liveSession; if (!question || !currentSession || !modelCatalog || !account) return false; const rollbackSession = options.restoreOnFailure ?? liveSession ?? currentSession; if (!resuming && (pendingSessionId || cancellationInFlight.current || pendingConsultation.current)) return false; if (resuming) { const pending = pendingConsultation.current; if (!pending || pending.requestId !== resumeRequestId || pending.sessionId !== currentSession.id || pending.cancelled) return false; } if (!isProfileComplete(profile)) { openAccountDialog("profile"); setProfileNotice("请先补充出生资料,才能进行星盘计算。"); return false; } if (entrypoint === "birth_time_rectification") { await openRectificationFromHomepage(); return false; } const birthPlace = selectedBirthPlace(profile); if (!birthPlace) return false; const theme = requestedTheme ?? currentSession.theme; const sessionId = currentSession.id; const consentForDecision = consentGrantedForRequest === "unverified_birth_time" ? grantBirthTimeConsultationConsent( birthTimeConsultationConsent, sessionId, "unverified_birth_time", ) : birthTimeConsultationConsent; const initialConsultationRoute = resolveBirthTimeConsultationRoute( profile, consentForDecision, sessionId, ); const consultationRoute = initialConsultationRoute.kind === "choice" ? { kind: "consult" as const, mode: "general_no_birth_time" as const, time: null } : initialConsultationRoute; if (account.credits <= 0 && !account.activeSubscription) { router.push(membershipHref("insufficient-credits")); return false; } const [year, month, day] = profile.date.split("-").map(Number); const [hour, minute] = consultationRoute.time?.split(":").map(Number) ?? []; const lastMessage = currentSession.messages.at(-1); const questionAlreadyPresent = lastMessage?.role === "user" && lastMessage.text === question; const preservedMessages = questionAlreadyPresent ? currentSession.messages : (onboardingJustCompleted && currentSession.messages.length === 0 ? completedOnboardingTranscript(profile, startGreeting) : currentSession.messages); const userSession: ChatSession = { ...currentSession, title: currentSession.messages.length === 0 && currentSession.title === "新对话" ? resolveSessionTitle(question) : currentSession.title, theme, messages: questionAlreadyPresent ? preservedMessages : [...preservedMessages, { role: "user", text: question }], updatedAt: questionAlreadyPresent ? currentSession.updatedAt : timestamp(), }; const requestId = resumeRequestId ?? globalThis.crypto.randomUUID(); const controller = resuming && pendingConsultation.current ? pendingConsultation.current.controller : new AbortController(); const previousOnboardingState = onboardingJustCompleted; cancellationFeedbackRequest.current = null; setRequestError(null); setReplyOutcome(null); if (!resuming) { setComposerNotice(""); consultationStatusMissingCount.current = 0; consultationReplayStarted.current = null; setPendingSessionId(sessionId); setPendingRequestId(requestId); setConsultationPhase("undo"); pendingConsultation.current = { requestId, sessionId, question: originalQuestion, entrypoint, theme, previousSession: rollbackSession, optimisticSession: userSession, previousOnboardingState, controller, cancelled: false, phase: "undo", partialReply: "", }; try { sessionStorage.setItem(pendingConsultationStorageKey, JSON.stringify({ sessionId, requestId, question: originalQuestion, theme, entrypoint, })); } catch { // Private-mode storage must not block send. } setOnboardingJustCompleted(false); updateSession(sessionId, () => userSession); conversationAnchor.anchorToLatest(); setDraft(""); setDraftTheme(null); setDraftEntrypoint(null); } if (!resuming && process.env.NODE_ENV === "development" && uiPreview.current) { setStreamingReply({ sessionId, text: "" }); if (uiPreviewMode.current === "partial") { const partialReply = "已开始查看事业方向与关键时间,先给你一个阶段性的判断。"; if (pendingConsultation.current?.requestId === requestId) { pendingConsultation.current = { ...pendingConsultation.current, phase: "streaming", partialReply, }; } setConsultationPhase("streaming"); setStreamingReply({ sessionId, text: partialReply }); } await new Promise((resolve) => window.setTimeout(resolve, uiPreviewMode.current === "streaming" || uiPreviewMode.current === "partial" ? 15_000 : 800)); if (controller.signal.aborted) { if (pendingConsultation.current?.requestId === requestId) pendingConsultation.current = null; return false; } const previewReply = parseAgentReply([ "这是本地交互预览。正式对话会结合你的星盘证据继续分析。", "", ].join("\n")); const previewSession: ChatSession = { ...userSession, title: userSession.title, messages: [...userSession.messages, { role: "assistant", text: previewReply.text, }], updatedAt: timestamp(), }; updateSession(sessionId, () => previewSession); completeConsultationInterface(requestId); return true; } if (!resuming) { await waitForUndoWindow(controller.signal); if (controller.signal.aborted) return false; } if (!resuming || !questionAlreadyPresent) { if (resuming && !questionAlreadyPresent) updateSession(sessionId, () => userSession); try { await persistSession(userSession); } catch (caught) { if (controller.signal.aborted) return false; updateSession(sessionId, () => rollbackSession); setOnboardingJustCompleted(previousOnboardingState); if (!options.restoreOnFailure && activeSessionIdRef.current === sessionId) { setDraft(originalQuestion); setDraftTheme(theme); setDraftEntrypoint(entrypoint); } setRequestError({ sessionId, message: `${caught instanceof Error ? caught.message : "问题保存失败,请稍后重试。"} 问题已放回输入框。`, }); setComposerNotice(options.restoreOnFailure ? "问题保存失败,未开始生成;已恢复原来的回答。" : "问题保存失败,未开始生成;问题已放回输入框。"); completeConsultationInterface(requestId); window.requestAnimationFrame(() => composerInput.current?.focus()); return false; } } if (pendingConsultation.current?.requestId === requestId) { pendingConsultation.current = { ...pendingConsultation.current, question: originalQuestion, entrypoint, theme, optimisticSession: userSession, phase: "streaming", }; setConsultationPhase("streaming"); } setStreamingReply({ sessionId, text: "" }); let latestPartialReply = ""; try { const response = await fetch("/api/consult", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ requestId, sessionId: currentSession.id, modelId: currentSession.modelId, name: profile.name, consultationMode: consultationRoute.mode, entrypoint: entrypoint ?? undefined, ...(consultationRoute.mode === "general_no_birth_time" ? {} : { year, month, day, hour, minute, city: birthPlace.label, lat: birthPlace.lat, lon: birthPlace.lon, tz: birthPlace.tz, entryMode: "direct_chart" as const, }), theme, question, history: currentSession.messages.slice(-12).map((message) => ({ role: message.role, text: message.text.slice(0, 4000), })), }), signal: controller.signal, }); if (!response.ok) { const contentType = response.headers.get("content-type") ?? ""; const errorPayload = contentType.includes("application/json") ? await response.json() : { message: await response.text() }; if (response.status === 401) window.location.assign("/login"); if (response.status === 402) router.push(membershipHref("insufficient-credits")); throw new ConsultationResponseError( response.status, payloadMessage(errorPayload, "服务暂时不可用"), ); } if (!response.body) { throw new ConsultationResponseError(502, "浏览器未收到可读取的回答流"); } let techniqueTruth = response.headers.get("x-jyotish-technique-truth") ?? "unknown"; let workflowReceipt: AgentExecutionReceipt["workflow"] = { route: response.headers.get("x-jyotish-workflow-route") ?? "unknown", status: response.headers.get("x-jyotish-workflow-status") ?? "unknown", preciseTiming: response.headers.get("x-jyotish-precise-timing") ?? "unknown", missingLayers: (response.headers.get("x-jyotish-missing-layers") ?? "none") .split(",") .map((item) => item.trim()) .filter((item) => item && item !== "none"), }; let agentExecutionReceipt: AgentExecutionReceipt | undefined; let runCompleted = false; let truncatedFailure: Extract | undefined; const reader = response.body.getReader(); const decoder = new TextDecoder(); let answer = ""; const updateStreamingAnswer = (activity?: AgentActivityView) => { const partialReply = parseAgentReply(answer).text; latestPartialReply = partialReply; setStreamingReply({ sessionId, text: partialReply, activity }); if (partialReply && pendingConsultation.current?.requestId === requestId) { pendingConsultation.current = { ...pendingConsultation.current, partialReply }; } }; const updateActivity = (event: ConsultationAgentPublicEvent) => { let activity: AgentActivityView | undefined; if (event.type === "skill.started") { activity = { phase: "loading-method", label: "正在读取印度占星分析规则…" }; } else if (event.type === "tool.started") { activity = { phase: "chart-calculation", label: "正在计算本命盘…" }; } else if (event.type === "activity") { activity = { phase: event.phase, label: event.label }; } else if (event.type === "tool.completed") { activity = { phase: "evidence-validation", label: "正在核对可用证据…" }; } else if (event.type === "answer.delta") { activity = { phase: "answer-composition", label: "正在组织回答…" }; } if (activity) updateStreamingAnswer(activity); }; if ((response.headers.get("content-type") ?? "").includes("application/x-ndjson")) { const parser = createNdjsonParser((event) => { if (event.type === "answer.delta") answer += event.text; if (event.type === "run.completed") { runCompleted = true; agentExecutionReceipt = event.receipt; workflowReceipt = event.receipt.workflow; techniqueTruth = event.receipt.techniqueTruth ?? "unknown"; } if (event.type === "run.failed") { if (event.code === "answer_truncated") { truncatedFailure = event; if (event.receipt) { agentExecutionReceipt = event.receipt; workflowReceipt = event.receipt.workflow; techniqueTruth = event.receipt.techniqueTruth ?? techniqueTruth; } return; } throw new ConsultationResponseError(502, event.message); } updateActivity(event); }); while (true) { const { done, value } = await reader.read(); if (done) break; parser.push(decoder.decode(value, { stream: true })); } parser.finish(decoder.decode()); if (truncatedFailure) { const reply = parseAgentReply(answer); if (!reply.text) throw new ConsultationResponseError(502, truncatedFailure.message); const truncatedSession: ChatSession = { ...userSession, title: userSession.title, messages: [...userSession.messages, { role: "assistant", text: reply.text, techniqueTruth, workflowReceipt, agentExecutionReceipt, }], updatedAt: timestamp(), }; updateSession(sessionId, () => truncatedSession); setStreamingReply(null); setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 }); setComposerNotice(truncatedFailure.message); try { await persistSession(truncatedSession); } catch (error) { setRequestError({ sessionId, message: error instanceof Error ? error.message : "未完成的回答暂时无法同步。", }); } completeConsultationInterface(requestId); void refreshAccount(); return true; } if (!runCompleted && !truncatedFailure) throw new ConsultationResponseError(502, "Agent 回答未完成,本次不会保存为成功咨询。"); } else { while (true) { const { done, value } = await reader.read(); if (done) break; answer += decoder.decode(value, { stream: true }); updateStreamingAnswer(); } answer += decoder.decode(); } if (controller.signal.aborted) return Boolean(latestPartialReply); if (!answer.trim()) throw new Error("Agent 没有返回内容,请重试。"); const reply = parseAgentReply(answer); if (!reply.text) throw new Error("Agent 没有返回可显示的回答,请重试。"); const completedSession: ChatSession = { ...userSession, title: userSession.title, messages: [...userSession.messages, { role: "assistant", text: reply.text, techniqueTruth, workflowReceipt, agentExecutionReceipt, }], updatedAt: timestamp(), }; updateSession(sessionId, () => completedSession); setReplyOutcome({ sessionId, phase: "completed", replyOrdinal: completedSession.messages.filter((message) => message.role === "assistant").length, }); completeConsultationInterface(requestId); void refreshAccount(); return true; } catch (caught) { const cancelled = controller.signal.aborted; const ownsInterface = pendingConsultation.current?.requestId === requestId; const partialReply = latestPartialReply; if (!cancelled && ownsInterface && pendingConsultation.current && caught instanceof ConsultationResponseError) { if (caught.message === "request_conflict") { pendingConsultation.current = { ...pendingConsultation.current, phase: "recovering", partialReply, }; setConsultationPhase("recovering"); setRequestError(null); setComposerNotice("回答仍在后台生成,正在自动恢复。"); return Boolean(partialReply); } setRequestError({ sessionId, message: caught.message }); setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 }); setComposerNotice(caught.message); const restore = options.restoreOnFailure; if (restore) { updateSession(sessionId, () => restore); void persistSession(restore).catch(() => {}); } completeConsultationInterface(requestId); return false; } if (!cancelled && ownsInterface && pendingConsultation.current) { pendingConsultation.current = { ...pendingConsultation.current, phase: "recovering", partialReply, }; setConsultationPhase("recovering"); setRequestError(null); setComposerNotice(navigator.onLine ? "连接中断,回答仍在后台生成,正在自动恢复。" : "网络已断开,回答仍在后台生成;联网后会自动恢复。"); } return Boolean(partialReply); } finally { cancellationRequests.current.delete(requestId); const pending = pendingConsultation.current; if (pending?.requestId !== requestId || pending.phase !== "recovering") { completeConsultationInterface(requestId); } if (stoppedRequestAwaitingSettlement.current === requestId) { const persistence = stoppedSessionPersistence.current.get(requestId); if (persistence) { await persistence; stoppedSessionPersistence.current.delete(requestId); } stoppedRequestAwaitingSettlement.current = null; cancellationInFlight.current = false; setCancellationPending(false); } } } 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. } } function regenerateLatestAnswer(renderKey: string) { const session = activeSession; if (!session || isLoading || cancellationPending || pendingConsultation.current) return; const last = session.messages.at(-1); if (last?.role !== "assistant" || last.text.trim() === "") return; const previous = session.messages.at(-2); if (previous?.role !== "user" || previous.text.trim() === "") return; if (`message-${session.messages.length - 1}` !== renderKey) return; const sessionOverride: ChatSession = { ...session, messages: session.messages.slice(0, -1), updatedAt: timestamp(), }; setMessageFeedback((current) => { const next = { ...current }; delete next[`${session.id}:${renderKey}`]; return next; }); void send(previous.text, session.theme, null, null, session.id, { sessionOverride, restoreOnFailure: session, }); } consultationReplay.current = () => { const pending = pendingConsultation.current; if (!pending || pending.cancelled || pending.phase !== "recovering" || !pending.question.trim()) return; if (consultationReplayStarted.current === pending.requestId) return; consultationReplayStarted.current = pending.requestId; setComposerNotice("后台尚未开始本次咨询,正在重新发起…"); void send( pending.question, pending.theme, pending.entrypoint, null, pending.sessionId, { resumeRequestId: pending.requestId }, ).then((started) => { if (started || pendingConsultation.current?.requestId !== pending.requestId) return; pendingConsultation.current = null; setPendingSessionId(null); setPendingRequestId(null); setConsultationPhase(null); setStreamingReply(null); setRequestError({ sessionId: pending.sessionId, message: "后台未找到本次咨询请求,请重新发送。", }); setComposerNotice("后台未找到本次咨询请求,已停止恢复,请重新发送。"); }); }; function submit(event: FormEvent) { event.preventDefault(); if (!profileComplete) { if (onboardingStep === "name" && presetMessageFinished) void saveOnboardingName(); return; } void send(composerDraftSnapshot(), draftTheme.current ?? undefined, draftEntrypoint.current); } function handleComposerKeyDown(event: KeyboardEvent) { if (event.nativeEvent.isComposing) return; if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); event.currentTarget.form?.requestSubmit(); } } if (!hydrated || (!account && !accountError)) { return (
); } if (!account) { return (
暂时无法进入 Jyotisha {accountError}
返回登录
); } 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: session.title, messageCount: session.messages.length, pinned: pinnedSessionIds.includes(session.id), archived: archivedSessionIds.includes(session.id), })); const modalOpen = activeAccountDialog !== null || onboardingPaywallOpen; return (
{replyAnnouncement} { 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} onOpenProfile={() => openAccountDialog("profile")} onOpenRedeem={() => router.push(membershipHref("account-menu"))} onOpenLogout={() => openAccountDialog("logout")} /> {pendingSessionDeletion ? (
setPendingSessionDeletion(null)}>
event.stopPropagation()}>

删除聊天记录?

“{pendingSessionDeletion.title}”将被永久删除,无法恢复。

) : null}
{activeSession?.title || "新对话"}
{!rectificationSurfaceOpen && (
{!activeSession?.messages.length ? (
{!profileComplete ? ( <> {(onboardingStep !== "name" || profileComplete) && } {(onboardingStep !== "name" || profileComplete) && } {(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && } {(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && } {!profileComplete && onboardingStep === "rectification" && selectedBirthPlace(profileDraft) && } {!profileComplete && onboardingStep === "rectification" && birthTimeJourney && } {profileComplete && onboardingJustCompleted && selectedBirthPlace(profileDraft) && } {profileComplete && onboardingJustCompleted && } ) : null} {!profileComplete && onboardingStep === "birth" && onboardingCardReady && (
出生日期与时间
setProfileDraft((current) => applyBirthTimeDraftPatch(current, patch))} /> {accountError &&

{accountError}

}
{birthTimeContinueHint ?

{birthTimeContinueHint}

: }
)} {!profileComplete && onboardingStep === "place" && onboardingCardReady && (
出生地点搜索全球城市或区县,系统会匹配出生当日的时区。
{accountError &&

{accountError}

}
)} {!profileComplete && onboardingStep === "rectification" && presetMessageFinished && birthTimeJourney && (
)} {!profileComplete && onboardingStep === "rectification" && presetMessageFinished && !birthTimeJourney && (
出生时间尚未完成评估

{birthTimeError || "资料已经保留,但暂时无法恢复校正进度。系统不会应用未经验证的具体时间。"}

)} {!profileComplete && onboardingStep === "name" && accountError &&

{accountError}

} {profileComplete && presetMessageFinished && !rectificationSurfaceOpen && (onboardingPending ? (
) : (

{starterGreeting.salutation}

{starterGreeting.question}

{rectificationError && !rectificationSurfaceOpen && (

{rectificationErrorMessage}

)}

从一个主题开始

{personalChartAvailable ? "选择一个你现在想解决的问题。" : "选择一个你现在想解决的问题;出生时间不足以支持的部分,我会明确说明,不会补造具体分钟。"}

{starterSuggestions.map((item) => { const theme = starterThemes.find((candidate) => candidate.id === item.theme); return ( ); })}
{onboardingError &&

个性化问题暂时不可用,已显示安全的默认问题。

}
))}
) : (
{chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity).map((message, _, views) => { const showActions = message.role === "assistant" && message.state === "settled" && Boolean(message.text); const feedbackKey = `${activeSession.id}:${message.renderKey}`; const latestRegeneratableKey = !isLoading && !cancellationPending ? [...views].reverse().find((item) => ( item.role === "assistant" && item.state === "settled" && Boolean(item.text) ))?.renderKey : undefined; return (
{showActions && ( setMessageFeedback((current) => { const next = { ...current }; const value = toggleChatMessageFeedback(current[feedbackKey], requested); if (value) next[feedbackKey] = value; else delete next[feedbackKey]; return next; })} onCopy={() => void copyAssistantMessage(feedbackKey, message.text)} onRegenerate={() => regenerateLatestAnswer(message.renderKey)} /> )}
); })} {activeError &&

{activeError}

}
)}
)} {rectificationSurfaceOpen && rectificationCaseId && ( void selectSessionModel(modelId)} onMessagesChange={handleRectificationMessagesChange} onCompleted={() => void refreshAccount()} onPendingChange={setRectificationMutationPending} onProfileIncomplete={handleRectificationProfileIncomplete} onSaved={() => void refreshAccount()} onStartConsultation={() => void startConsultationAfterRectification()} pendingConsultationQuestion={rectificationPendingQuestion} onRestart={() => void startNewRectification()} headerSlot={rectificationHeaderSlot} /> )} {!rectificationSurfaceOpen && !onboardingFormActive &&
{jumpToLatestVisible && (
)} { setDraft(event.target.value); setDraftTheme(null); setDraftEntrypoint(null); setComposerNotice(""); }} onKeyDown={handleComposerKeyDown} onStop={() => void stopResponse()} />
void selectSessionModel(modelId)} />
}
{activeAccountDialog !== null && (
event.stopPropagation()}>

{accountDialogTitles[activeAccountDialog]}

{activeAccountDialog === "profile" && ( <> {accountError &&

{accountError}

} {account.avatar && (
头像 Beam 形象由随机种子生成,刷新和换设备后保持一致
{beamAvatarPalettes.map((palette, index) => ( ))}
{avatarNotice &&

{avatarNotice}

}
)}
出生资料加密传输并保存到云端,用于此账号的所有对话
当前默认星盘 {profileDraft.name.trim() || "未命名"} 角色:本人
{chartLibraryOpen && (
本人 {chartLibrary.filter((record) => record.role === "self").map((record) => (
{record.profile.name || "未命名"} {record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}
当前默认
))}
其他 {chartLibrary.filter((record) => record.role === "other").length === 0 &&

还没有其他星盘。

} {chartLibrary.filter((record) => record.role === "other").map((record) => (
{record.profile.name || "未命名"} {record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}
))}
{synastryReportCard && (
合盘结果摘要 {synastryReportCard.partnerName} Ashtakoot {synastryReportCard.score ?? "?"}/{synastryReportCard.maxScore ?? "?"} · {synastryReportCard.assessment || synastryReportCard.scoreBand || "待解释"}
{synastryReportCard.headline &&

{synastryReportCard.headline}

}
查看证据
    {(synastryReportCard.strengths || []).map((item) =>
  • {item}
  • )} {(synastryReportCard.risks || []).map((item) =>
  • {item}
  • )}
下一步证据:{(synastryReportCard.nextEvidence || []).join(" / ") || "双方 Dasha / UL-DK / D9 7宫"}
)} {synastryHistory.length > 0 && (
合盘历史 {synastryHistory.slice(0, 5).map((item) => ( ))}
)}
添加其他星盘用于合盘、亲友盘或客户盘。
)}
{profileNotice &&

{profileNotice}

}
)} {activeAccountDialog === "logout" && ( <>

退出后,需要重新登录才能继续查看对话。

{accountError &&

{accountError}

}
)}
)} {onboardingPaywallOpen && account && ( setOnboardingPaywallOpen(false)} onCreditsChanged={(credits) => setAccount((current) => current ? { ...current, credits } : current)} /> )}
); }