"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 (
);
}
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}
{!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 && (
)}
{!profileComplete && onboardingStep === "place" && onboardingCardReady && (
)}
{!profileComplete && onboardingStep === "rectification" && presetMessageFinished && birthTimeJourney && (
)}
{!profileComplete && onboardingStep === "rectification" && presetMessageFinished && !birthTimeJourney && (
出生时间尚未完成评估
{birthTimeError || "资料已经保留,但暂时无法恢复校正进度。系统不会应用未经验证的具体时间。"}
)}
{!profileComplete && onboardingStep === "name" && accountError &&
{accountError}
}
{profileComplete && presetMessageFinished && !rectificationSurfaceOpen && (onboardingPending ? (
) : (
{starterGreeting.salutation}
{starterGreeting.question}
{personalChartAvailable ? "今日星语" : "每日运势"}
{personalChartAvailable ? dailyStarlanguageTrend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}
{personalChartAvailable ? dailyStarlanguageAction : "不支持的个人判断会明确说明,不会补造出生时间。"}
{personalChartAvailable ? dailyStarlanguageQuestion : "查看今日运势"}
{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)}
/>
)}
);
}