8b8e5214f9
Put the compact board trigger in the chat header, keep the sheet above the composer on iOS, and align onboarding messages with the intake card on the session column. Co-authored-by: Cursor <cursoragent@cursor.com>
4025 lines
178 KiB
TypeScript
4025 lines
178 KiB
TypeScript
"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 { 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: () => <p className="birth-time-assistant-intent" role="status">正在加载出生时间评估...</p>,
|
||
},
|
||
);
|
||
|
||
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<ChatReplyPhase, "completed" | "stopped" | "failed">;
|
||
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<string>,
|
||
): StoredPendingConsultation | null {
|
||
if (!raw) return null;
|
||
try {
|
||
const parsedPending = JSON.parse(raw) as Record<string, unknown>;
|
||
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<AccountDialog, string>;
|
||
|
||
const accountDialogClasses = {
|
||
profile: "profile-modal",
|
||
logout: "logout-modal",
|
||
} as const satisfies Record<AccountDialog, string>;
|
||
|
||
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<DailyStarlanguageState> {
|
||
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<Profile> & {
|
||
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<ChatSession> & {
|
||
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 (
|
||
<fieldset className="location-fieldset">
|
||
<legend>出生地点</legend>
|
||
<BirthPlacePicker
|
||
value={{
|
||
provinceCode: value.provinceCode,
|
||
cityCode: value.cityCode,
|
||
districtCode: value.districtCode,
|
||
timezoneId: value.timezoneId,
|
||
timezoneOffset: value.timezoneOffset,
|
||
}}
|
||
birthDate={value.date}
|
||
birthTime={value.reportedTime || value.time}
|
||
onChange={(location) => onChange({
|
||
...value,
|
||
countryCode: "CN",
|
||
provinceCode: location?.provinceCode || "",
|
||
cityCode: location?.cityCode || "",
|
||
districtCode: location?.districtCode || "",
|
||
birthPlaceLabel: location?.label || "",
|
||
birthPlaceType: location?.placeType || "",
|
||
birthPlaceProvider: location ? "china_locations" : "",
|
||
birthPlaceProviderId: location?.providerPlaceId || "",
|
||
timezoneId: location?.timezoneId || "",
|
||
timezoneSource: location ? "iana_historical" : "",
|
||
latitude: location?.latitude ?? null,
|
||
longitude: location?.longitude ?? null,
|
||
timezoneOffset: location?.timezoneOffset ?? null,
|
||
})}
|
||
/>
|
||
</fieldset>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<>
|
||
<label>
|
||
<span>如何称呼你</span>
|
||
<input id={nameInputId} required autoComplete="name" maxLength={80} placeholder="例如:林遥" value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} />
|
||
</label>
|
||
<BirthTimeIntakeFields value={value} onPatch={(patch) => onChange(applyBirthTimeDraftPatch(value, patch))} />
|
||
<BirthLocationFields
|
||
value={value}
|
||
onChange={(next) => 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 (
|
||
<article className={`message message-${role} onboarding-message${phraseSafe ? " is-phrase-safe" : ""}`} aria-label={role === "assistant" ? "Jyotisha" : "你"}>
|
||
{role === "assistant" && <AgentAvatar />}
|
||
<div className="message-content">
|
||
<div className="message-bubble">
|
||
{role === "assistant" ? (
|
||
streaming ? (
|
||
<>
|
||
<div className={`onboarding-stream ${length >= text.length ? "is-complete" : ""}`} aria-hidden="true"><ChatMessageContent text={protectedVisibleText} /></div>
|
||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{length >= text.length ? text : ""}</span>
|
||
</>
|
||
) : <ChatMessageContent text={protectedVisibleText} />
|
||
) : <p>{protectedVisibleText}</p>}
|
||
</div>
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
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<string, unknown>;
|
||
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<void>((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<Account> {
|
||
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<unknown> {
|
||
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<ConsultationStatus>;
|
||
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<ConsultationStatus> {
|
||
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<ConsultationStatus | null> {
|
||
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<Profile>(emptyProfile);
|
||
const [profileDraft, setProfileDraft] = useState<Profile>(emptyProfile);
|
||
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
||
const [activeAccountDialog, setActiveAccountDialog] = useState<AccountDialog | null>(null);
|
||
const [chartLibrary, setChartLibrary] = useState<ChartLibraryRecord[]>([]);
|
||
const [chartLibraryOpen, setChartLibraryOpen] = useState(false);
|
||
const [synastryRelationshipType, setSynastryRelationshipType] = useState<SynastryRelationshipType>("romance");
|
||
const [synastryPendingId, setSynastryPendingId] = useState<string | null>(null);
|
||
const [otherProfileDraft, setOtherProfileDraft] = useState<Profile>(emptyProfile);
|
||
const [synastryReportCard, setSynastryReportCard] = useState<SynastryReportCard | null>(null);
|
||
const [synastryHistory, setSynastryHistory] = useState<SynastryReportCard[]>([]);
|
||
const [dailyStarlanguage, setDailyStarlanguage] = useState<DailyStarlanguageState>({ kind: "pending" });
|
||
const [profileNotice, setProfileNotice] = useState("");
|
||
const [avatarNotice, setAvatarNotice] = useState("");
|
||
const [avatarSaving, setAvatarSaving] = useState(false);
|
||
const [account, setAccount] = useState<Account | null>(null);
|
||
const [accountError, setAccountError] = useState("");
|
||
const [signingOut, setSigningOut] = useState(false);
|
||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||
const [pinnedSessionIds, setPinnedSessionIds] = useState<string[]>([]);
|
||
const [archivedSessionIds, setArchivedSessionIds] = useState<string[]>([]);
|
||
const [showArchivedSessions, setShowArchivedSessions] = useState(false);
|
||
const [sessionMenuId, setSessionMenuId] = useState<string | null>(null);
|
||
const [pendingSessionDeletion, setPendingSessionDeletion] = useState<ChatSession | null>(null);
|
||
const [modelCatalog, setModelCatalog] = useState<PublicLanguageModelCatalog | null>(null);
|
||
const [activeSessionId, setActiveSessionId] = useState("");
|
||
const draftTheme = useRef<Theme | null>(null);
|
||
const draftEntrypoint = useRef<ConsultationEntrypoint | null>(null);
|
||
const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | "recovering" | null>(null);
|
||
const [cancellationPending, setCancellationPending] = useState(false);
|
||
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
|
||
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
|
||
const [streamingReply, setStreamingReply] = useState<StreamingReply | null>(null);
|
||
const [messageFeedback, setMessageFeedback] = useState<Record<string, ChatMessageFeedback>>({});
|
||
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
|
||
const [replyOutcome, setReplyOutcome] = useState<ReplyOutcome | null>(null);
|
||
const [requestError, setRequestError] = useState<RequestError | null>(null);
|
||
const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState<BirthTimeConsultationConsentState>(
|
||
createBirthTimeConsultationConsentState,
|
||
);
|
||
const [rectificationSessionId, setRectificationSessionId] = useState<string | null>(null);
|
||
const [rectificationCaseId, setRectificationCaseId] = useState<string | null>(null);
|
||
const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState<string | null>(null);
|
||
const [rectificationLoading, setRectificationLoading] = useState(false);
|
||
const [rectificationMutationPending, setRectificationMutationPending] = useState(false);
|
||
const [rectificationError, setRectificationError] = useState("");
|
||
const [rectificationReadonly, setRectificationReadonly] = useState(false);
|
||
const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false);
|
||
const [rectificationTurns, setRectificationTurns] = useState<PersistedRectificationTurn[]>([]);
|
||
const [rectificationEntrySummary, setRectificationEntrySummary] = useState<RectificationEntrySummary | null>(null);
|
||
const [hydrated, setHydrated] = useState(false);
|
||
const [profileSaving, setProfileSaving] = useState(false);
|
||
const [creatingSession, setCreatingSession] = useState(false);
|
||
const [onboarding, setOnboarding] = useState<OnboardingContent | null>(null);
|
||
const [onboardingError, setOnboardingError] = useState("");
|
||
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep>("name");
|
||
const [onboardingJustCompleted, setOnboardingJustCompleted] = useState(false);
|
||
const [onboardingPaywallOpen, setOnboardingPaywallOpen] = useState(false);
|
||
const [birthTimeJourney, setBirthTimeJourney] = useState<JourneyClientResponse | null>(null);
|
||
const [birthTimeError, setBirthTimeError] = useState("");
|
||
const [birthTimeAssessmentPhase, setBirthTimeAssessmentPhase] = useState<BirthTimeAssessmentPhase | null>(null);
|
||
const [startGreeting, setStartGreeting] = useState("");
|
||
const [starterGreetingSelection, setStarterGreetingSelection] = useState(() => Math.random());
|
||
const [presetMessageLength, setPresetMessageLength] = useState(0);
|
||
const conversation = useRef<HTMLDivElement>(null);
|
||
const accountTrigger = useRef<HTMLButtonElement>(null);
|
||
const accountDialog = useRef<HTMLElement>(null);
|
||
const dialogReturnTarget = useRef<HTMLButtonElement | null>(null);
|
||
const closeButton = useRef<HTMLButtonElement>(null);
|
||
const onboardingPaywallShown = useRef(false);
|
||
const starterGreetingUnseen = useRef(true);
|
||
const composerInput = useRef<HTMLTextAreaElement>(null);
|
||
const pendingConsultation = useRef<PendingConsultation | null>(null);
|
||
const cancellationRequests = useRef(new Map<string, Promise<void>>());
|
||
const cancellationFeedbackRequest = useRef<string | null>(null);
|
||
const cancellationInFlight = useRef(false);
|
||
const stoppedRequestAwaitingSettlement = useRef<string | null>(null);
|
||
const stoppedSessionPersistence = useRef(new Map<string, Promise<void>>());
|
||
const consultationRecoveryWakeup = useRef<() => void>(() => undefined);
|
||
const consultationRecoveryCheck = useRef<() => void>(() => undefined);
|
||
const consultationReplay = useRef<() => void>(() => undefined);
|
||
const consultationStatusMissingCount = useRef(0);
|
||
const consultationReplayStarted = useRef<string | null>(null);
|
||
const modelPersistence = useRef(new SessionModelPersistenceQueue());
|
||
const modelSyncFailures = useRef(new Set<string>());
|
||
const modelSelectionVersions = useRef(new Map<string, number>());
|
||
const activeSessionIdRef = useRef("");
|
||
const 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<string | null>(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() {
|
||
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;
|
||
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) return;
|
||
if ((caught as Error).name !== "AbortError" && !controller.signal.aborted) {
|
||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "暂时无法读取云端数据"));
|
||
}
|
||
} finally {
|
||
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<typeof setTimeout> | 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<Profile> {
|
||
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<HTMLFormElement>) {
|
||
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<HTMLFormElement>) {
|
||
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<HTMLFormElement>) {
|
||
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<HTMLFormElement>) {
|
||
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<boolean> {
|
||
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([
|
||
"这是本地交互预览。正式对话会结合你的星盘证据继续分析。",
|
||
"<!--AYANAM_TITLE:事业方向与时间选择-->",
|
||
].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<ConsultationAgentPublicEvent, { type: "run.failed" }> | 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<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!profileComplete) {
|
||
if (onboardingStep === "name" && presetMessageFinished) void saveOnboardingName();
|
||
return;
|
||
}
|
||
void send(composerDraftSnapshot(), draftTheme.current ?? undefined, draftEntrypoint.current);
|
||
}
|
||
|
||
function handleComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||
if (event.nativeEvent.isComposing) return;
|
||
if (event.key === "Enter" && !event.shiftKey) {
|
||
event.preventDefault();
|
||
event.currentTarget.form?.requestSubmit();
|
||
}
|
||
}
|
||
|
||
if (!hydrated || (!account && !accountError)) {
|
||
return (
|
||
<main className="app-loading" aria-busy="true" aria-live="polite">
|
||
<AppLoadingIndicator title="正在载入账户" detail="同步个人资料与对话记录" />
|
||
</main>
|
||
);
|
||
}
|
||
|
||
if (!account) {
|
||
return (
|
||
<main className="app-loading app-loading-error">
|
||
<div className="app-loading-content" role="alert">
|
||
<strong>暂时无法进入 Jyotisha</strong>
|
||
<span>{accountError}</span>
|
||
<div className="app-loading-actions">
|
||
<button className="button-primary" type="button" onClick={() => window.location.reload()}>重试</button>
|
||
<Link className="button-secondary" href="/login">返回登录</Link>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
const sidebarAccount = {
|
||
name: profile.name.trim() || account.user.email || "账户",
|
||
email: account.user.email || "尚未读取邮箱",
|
||
credits: account.credits,
|
||
avatar: account.avatar,
|
||
initial: profile.name.trim().slice(0, 1)
|
||
|| account.user.email?.slice(0, 1).toUpperCase()
|
||
|| "你",
|
||
};
|
||
|
||
const sidebarSessions = visibleSessions.map((session) => ({
|
||
id: session.id,
|
||
title: session.title,
|
||
messageCount: session.messages.length,
|
||
pinned: pinnedSessionIds.includes(session.id),
|
||
archived: archivedSessionIds.includes(session.id),
|
||
}));
|
||
const modalOpen = activeAccountDialog !== null || onboardingPaywallOpen;
|
||
|
||
return (
|
||
<SidebarProvider escapeBlocked={accountMenuOpen || modalOpen}>
|
||
<main className="chat-app">
|
||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{replyAnnouncement}</span>
|
||
<AppSidebar
|
||
sessions={sidebarSessions}
|
||
activeSessionId={activeSession?.id ?? null}
|
||
account={sidebarAccount}
|
||
accountMenuOpen={accountMenuOpen}
|
||
accountTriggerRef={accountTrigger}
|
||
newChatDisabled={!hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending}
|
||
creatingSession={creatingSession}
|
||
sessionControls={{
|
||
archivedCount: archivedSessionIds.length,
|
||
showingArchived: showArchivedSessions,
|
||
menuSessionId: sessionMenuId,
|
||
disabled: Boolean(pendingSessionId) || cancellationPending,
|
||
onToggleArchivedView: () => {
|
||
setShowArchivedSessions((current) => !current);
|
||
setSessionMenuId(null);
|
||
},
|
||
onMenuSessionChange: setSessionMenuId,
|
||
onTogglePinned: togglePinnedSession,
|
||
onRename: (sessionId) => {
|
||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||
if (session) void renameSession(session);
|
||
},
|
||
onShare: (sessionId) => {
|
||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||
if (session) void shareSession(session);
|
||
},
|
||
onToggleArchived: toggleArchivedSession,
|
||
onDelete: (sessionId) => {
|
||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||
if (session) setPendingSessionDeletion(session);
|
||
},
|
||
}}
|
||
onAccountMenuOpenChange={setAccountMenuOpen}
|
||
onNewChat={() => void startNewChat()}
|
||
onOpenReports={() => router.push("/reports")}
|
||
onSelectSession={selectSession}
|
||
onOpenProfile={() => openAccountDialog("profile")}
|
||
onOpenRedeem={() => router.push(membershipHref("account-menu"))}
|
||
onOpenLogout={() => openAccountDialog("logout")}
|
||
/>
|
||
{pendingSessionDeletion ? (
|
||
<div className="account-modal-overlay session-delete-overlay" role="presentation" onMouseDown={() => setPendingSessionDeletion(null)}>
|
||
<section className="account-modal logout-modal session-delete-confirmation" role="alertdialog" aria-modal="true" aria-label="确认删除聊天记录" onMouseDown={(event) => event.stopPropagation()}>
|
||
<h2>删除聊天记录?</h2>
|
||
<p>“{pendingSessionDeletion.title}”将被永久删除,无法恢复。</p>
|
||
<div className="dialog-actions">
|
||
<button type="button" onClick={() => setPendingSessionDeletion(null)}>取消</button>
|
||
<button className="danger-button" type="button" onClick={() => {
|
||
const session = pendingSessionDeletion;
|
||
setPendingSessionDeletion(null);
|
||
void deleteSession(session);
|
||
}}>确认删除</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
) : null}
|
||
<SidebarInset className={`chat-panel${rectificationSurfaceOpen ? " is-rectification" : ""}`} inert={modalOpen}>
|
||
<header className="chat-header">
|
||
<SidebarTrigger placement="inset" />
|
||
<div>
|
||
<strong>{activeSession?.title || "新对话"}</strong>
|
||
</div>
|
||
<div className="chat-header-actions">
|
||
<div className="chat-header-rectification" data-rectification-header-slot="" />
|
||
<button className="credit-button" type="button" onClick={() => router.push(membershipHref("credits"))} aria-label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}>
|
||
<Sparkles className="credit-icon" aria-hidden="true" />
|
||
<span>{account ? account.credits : "—"}</span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{!rectificationSurfaceOpen && (
|
||
<div ref={conversation} className={`conversation${!activeSession?.messages.length && !onboardingFormActive ? " is-empty" : ""}${!profileComplete ? " is-onboarding" : ""}${onboardingFormActive ? " is-onboarding-form" : ""}`}>
|
||
{!activeSession?.messages.length ? (
|
||
<div className="welcome">
|
||
{!profileComplete ? (
|
||
<>
|
||
<OnboardingChatMessage role="assistant" text={presetOnboardingMessage} streaming={onboardingStep === "name" && !profileComplete} length={presetMessageLength} />
|
||
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="user" text={profileDraft.name.trim()} />}
|
||
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="assistant" text={birthQuestion(profileDraft.name.trim())} streaming={onboardingStep === "birth" && !profileComplete} length={presetMessageLength} />}
|
||
{(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && <OnboardingChatMessage role="user" text={formatBirthMoment(profileDraft)} />}
|
||
{(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && <OnboardingChatMessage role="assistant" text={placeQuestion(profileDraft)} streaming={onboardingStep === "place" && !profileComplete} length={presetMessageLength} />}
|
||
{!profileComplete && onboardingStep === "rectification" && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
|
||
{!profileComplete && onboardingStep === "rectification" && birthTimeJourney && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} phraseSafe />}
|
||
{profileComplete && onboardingJustCompleted && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
|
||
{profileComplete && onboardingJustCompleted && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} />}
|
||
</>
|
||
) : null}
|
||
|
||
{!profileComplete && onboardingStep === "birth" && onboardingCardReady && (
|
||
<div className="onboarding-card-reveal">
|
||
<div className="onboarding-card-reveal-inner">
|
||
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingBirth} aria-busy={birthTimeAssessmentPhase !== null}>
|
||
<div className="onboarding-card-heading">
|
||
<div className="onboarding-card-heading-copy">
|
||
<b>出生日期与时间</b>
|
||
</div>
|
||
</div>
|
||
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
|
||
<BirthTimeIntakeFields value={profileDraft} onPatch={(patch) => setProfileDraft((current) => applyBirthTimeDraftPatch(current, patch))} />
|
||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||
<div className="onboarding-card-actions">
|
||
{birthTimeContinueHint ? <p className="onboarding-card-action-hint">{birthTimeContinueHint}</p> : <span />}
|
||
<button className="button-primary" type="submit" disabled={profileSaving || !isBirthTimeDraftReady(profileDraft)}>{profileSaving ? "保存中" : "继续"}</button>
|
||
</div>
|
||
</fieldset>
|
||
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!profileComplete && onboardingStep === "place" && onboardingCardReady && (
|
||
<div className="onboarding-card-reveal">
|
||
<div className="onboarding-card-reveal-inner">
|
||
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingPlace} aria-busy={birthTimeAssessmentPhase !== null}>
|
||
<div className="onboarding-card-heading"><b>出生地点</b><small>搜索全球城市或区县,系统会匹配出生当日的时区。</small></div>
|
||
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
|
||
<BirthLocationFields value={profileDraft} onChange={setProfileDraft} />
|
||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||
<div className="onboarding-card-actions"><button className="button-primary" type="submit" disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}</button></div>
|
||
</fieldset>
|
||
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!profileComplete && onboardingStep === "rectification" && presetMessageFinished && birthTimeJourney && (
|
||
<div className="onboarding-card-reveal">
|
||
<div className="onboarding-card-reveal-inner">
|
||
<BirthTimeRectification
|
||
journey={birthTimeJourney}
|
||
controller={birthTimeGuided}
|
||
externalError={birthTimeError}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!profileComplete && onboardingStep === "rectification" && presetMessageFinished && !birthTimeJourney && (
|
||
<div className="onboarding-card birth-time-retry-card" role="status">
|
||
<b>出生时间尚未完成评估</b>
|
||
<p>{birthTimeError || "资料已经保留,但暂时无法恢复校正进度。系统不会应用未经验证的具体时间。"}</p>
|
||
<button className="button-primary" type="button" disabled={profileSaving} onClick={() => void retryBirthTimeAssessment()}>{profileSaving ? "评估中" : "重新评估"}</button>
|
||
</div>
|
||
)}
|
||
|
||
{!profileComplete && onboardingStep === "name" && accountError && <p className="form-error onboarding-inline-error" role="alert">{accountError}</p>}
|
||
|
||
{profileComplete && presetMessageFinished && !rectificationSurfaceOpen && (onboardingPending ? (
|
||
<div className="starter-loading" role="status">
|
||
<AppLoadingIndicator title="正在准备问题" detail="根据你的资料整理今天的起点。" />
|
||
</div>
|
||
) : (
|
||
<div className="starter-list starter-workbench" aria-label="Jyotisha 推荐的初始问题">
|
||
<section className="starter-hero" aria-labelledby="starter-heading">
|
||
<div className="starter-hero-copy">
|
||
<p className="starter-greeting">{starterGreeting.salutation}</p>
|
||
<h1 id="starter-heading">{starterGreeting.question}</h1>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="product-entrypoints" aria-label="常用占星入口">
|
||
<article className="daily-starlanguage-card product-entrypoint-card" aria-labelledby="daily-starlanguage-title">
|
||
<button
|
||
className="product-entrypoint-hitarea"
|
||
type="button"
|
||
aria-label={personalChartAvailable ? dailyStarlanguageQuestion : "查看今日运势"}
|
||
disabled={productEntrypointsDisabled}
|
||
onClick={draftDailyStarlanguageQuestion}
|
||
/>
|
||
<div className="product-entrypoint-copy">
|
||
<h2 id="daily-starlanguage-title">{personalChartAvailable ? "今日星语" : "每日运势"}</h2>
|
||
<p
|
||
aria-busy={dailyStarlanguageBusy}
|
||
role={personalChartAvailable && dailyStarlanguage.kind !== "ready" ? "status" : undefined}
|
||
>{personalChartAvailable ? dailyStarlanguageTrend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}</p>
|
||
</div>
|
||
<div className="product-entrypoint-footer">
|
||
<small>{personalChartAvailable ? dailyStarlanguageAction : "不支持的个人判断会明确说明,不会补造出生时间。"}</small>
|
||
<span className="product-entrypoint-action" aria-hidden="true">{personalChartAvailable ? dailyStarlanguageQuestion : "查看今日运势"} <ArrowUpRight className="starter-arrow" /></span>
|
||
</div>
|
||
</article>
|
||
<article className="birth-rectification-card product-entrypoint-card" aria-labelledby="birth-rectification-title">
|
||
<button
|
||
className="product-entrypoint-hitarea"
|
||
type="button"
|
||
aria-label={rectificationCardLabel}
|
||
disabled={productEntrypointsDisabled || rectificationLoading || rectificationMutationPending}
|
||
onClick={() => void openRectificationFromHomepage()}
|
||
/>
|
||
<div className="product-entrypoint-copy">
|
||
<h2 id="birth-rectification-title">生时校正</h2>
|
||
<p>{rectificationEntrySummary?.hasResumableCase
|
||
? "新建一段独立校正;未完成的记录仍可从左侧历史会话继续。"
|
||
: rectificationCardAction === "restart"
|
||
? "上一次校正已经完成,可以基于最新资料再次校正。"
|
||
: "不确定准确出生时间时,可通过已经发生的人生事件逐步缩小范围。"}</p>
|
||
</div>
|
||
<div className="product-entrypoint-footer">
|
||
<span className="product-entrypoint-action" aria-hidden="true">{rectificationCardLabel} <ArrowUpRight className="starter-arrow" /></span>
|
||
</div>
|
||
</article>
|
||
</section>
|
||
{rectificationError && !rectificationSurfaceOpen && (
|
||
<p className="form-error rectification-entry-error" role="alert">
|
||
{rectificationErrorMessage}
|
||
</p>
|
||
)}
|
||
|
||
<section className="starter-themes" aria-labelledby="starter-themes-heading">
|
||
<div className="starter-section-heading">
|
||
<h2 id="starter-themes-heading">从一个主题开始</h2>
|
||
<p>{personalChartAvailable
|
||
? "选择一个你现在想解决的问题。"
|
||
: "选择一个你现在想解决的问题;出生时间不足以支持的部分,我会明确说明,不会补造具体分钟。"}</p>
|
||
</div>
|
||
<div className="starter-theme-accordion">
|
||
{starterSuggestions.map((item) => {
|
||
const theme = starterThemes.find((candidate) => candidate.id === item.theme);
|
||
return (
|
||
<button
|
||
className="starter-theme-card"
|
||
key={`${item.theme}-${item.text}`}
|
||
type="button"
|
||
aria-label={`${theme?.label || "开始"}:${item.text}`}
|
||
disabled={!hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog}
|
||
onClick={() => chooseSuggestedQuestion(item.text, item.theme)}
|
||
>
|
||
<span className="starter-content">
|
||
<b>{theme?.label || "开始"}</b>
|
||
<span>{item.text}</span>
|
||
</span>
|
||
<ArrowUpRight className="starter-arrow" aria-hidden="true" />
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
{onboardingError && <p className="starter-note">个性化问题暂时不可用,已显示安全的默认问题。</p>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="message-list" aria-busy={isLoading}>
|
||
{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 (
|
||
<div className="message-entry" key={message.renderKey}>
|
||
<ChatMessageRow message={message} />
|
||
{showActions && (
|
||
<ChatMessageActions
|
||
feedback={messageFeedback[feedbackKey]}
|
||
copied={copiedMessageKey === feedbackKey}
|
||
canRegenerate={message.renderKey === latestRegeneratableKey}
|
||
onFeedback={(requested) => 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)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{activeError && <p className="error-message" role="alert">{activeError}</p>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{rectificationSurfaceOpen && rectificationCaseId && (
|
||
<ConversationalBirthTimeRectification
|
||
key={`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.at(-1)?.id ?? "loading"}`}
|
||
caseId={rectificationCaseId}
|
||
sessionId={rectificationSessionId ?? ""}
|
||
readonly={rectificationReadonly}
|
||
shouldStartOpening={rectificationShouldStartOpening}
|
||
initialTurns={rectificationTurns}
|
||
models={modelCatalog?.models ?? []}
|
||
selectedModelId={activeSession?.modelId ?? ""}
|
||
onSelectModel={(modelId) => void selectSessionModel(modelId)}
|
||
onMessagesChange={handleRectificationMessagesChange}
|
||
onCompleted={() => void refreshAccount()}
|
||
onPendingChange={setRectificationMutationPending}
|
||
onProfileIncomplete={handleRectificationProfileIncomplete}
|
||
onSaved={() => void refreshAccount()}
|
||
onStartConsultation={() => void startConsultationAfterRectification()}
|
||
pendingConsultationQuestion={rectificationPendingQuestion}
|
||
onRestart={() => void startNewRectification()}
|
||
/>
|
||
)}
|
||
|
||
{!rectificationSurfaceOpen && !onboardingFormActive && <div className={`composer-wrap ${starterHomeVisible ? "composer-wrap-starter" : ""}`}>
|
||
{jumpToLatestVisible && (
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-full flex justify-center pb-3">
|
||
<button
|
||
className="pointer-events-auto inline-flex min-h-11 min-w-11 items-center gap-1.5 rounded-full border border-border bg-canvas px-4 text-sm text-ink shadow-md transition-colors outline-none hover:bg-canvas-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||
type="button"
|
||
aria-label="跳到最新"
|
||
onClick={conversationAnchor.anchorToLatest}
|
||
>
|
||
<ArrowDown aria-hidden="true" className="size-4" />
|
||
跳到最新
|
||
</button>
|
||
</div>
|
||
)}
|
||
<ChatComposer
|
||
inputRef={composerInput}
|
||
inputLabel={!profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
|
||
placeholder={!account
|
||
? "正在读取账户…"
|
||
: !profileComplete
|
||
? onboardingStep === "name"
|
||
? presetMessageFinished ? "输入你的称呼" : "Jyotisha 正在输入…"
|
||
: "请先完成上方资料"
|
||
: account.credits === 0 && !account.activeSubscription
|
||
? "余额不足,可前往会员页充值点数或开通会员"
|
||
: "例如:未来半年是否适合换工作?"}
|
||
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
|
||
inputDisabled={isLoading || cancellationPending || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
|
||
submitLabel={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"}
|
||
submitBlocked={Boolean(pendingSessionId) || cancellationPending || rectificationSurfaceOpen || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
|
||
stopVisible={isLoading}
|
||
stopLabel={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"}
|
||
stopTitle={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
|
||
onSubmit={submit}
|
||
onChange={(event) => {
|
||
setDraft(event.target.value);
|
||
setDraftTheme(null);
|
||
setDraftEntrypoint(null);
|
||
setComposerNotice("");
|
||
}}
|
||
onKeyDown={handleComposerKeyDown}
|
||
onStop={() => void stopResponse()}
|
||
/>
|
||
<div className="composer-footer">
|
||
<ModelSelector
|
||
models={modelCatalog?.models ?? []}
|
||
selectedModelId={activeSession?.modelId ?? ""}
|
||
disabled={!activeSession || isLoading || cancellationPending || creatingSession}
|
||
onSelect={(modelId) => void selectSessionModel(modelId)}
|
||
/>
|
||
</div>
|
||
</div>}
|
||
</SidebarInset>
|
||
|
||
{activeAccountDialog !== null && (
|
||
<div className="account-modal-overlay" onMouseDown={closeAccountDialog}>
|
||
<section className={`account-modal ${accountDialogClasses[activeAccountDialog]}`} ref={accountDialog} role="dialog" aria-modal="true" aria-labelledby="account-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
|
||
<header className="account-modal-header">
|
||
<h2 id="account-dialog-title">{accountDialogTitles[activeAccountDialog]}</h2>
|
||
<button className="dialog-close" ref={closeButton} aria-label="关闭" type="button" onClick={closeAccountDialog} disabled={signingOut}><X aria-hidden="true" /></button>
|
||
</header>
|
||
|
||
{activeAccountDialog === "profile" && (
|
||
<>
|
||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||
{account.avatar && (
|
||
<section className="sheet-section avatar-section" aria-labelledby="avatar-section-title">
|
||
<div className="section-heading">
|
||
<b id="avatar-section-title">头像</b>
|
||
<small>Beam 形象由随机种子生成,刷新和换设备后保持一致</small>
|
||
</div>
|
||
<div className="avatar-editor">
|
||
<UserAvatar avatar={account.avatar} size={96} label="当前头像预览" />
|
||
<div className="avatar-editor-controls">
|
||
<div className="avatar-palette-list" role="radiogroup" aria-label="头像配色">
|
||
{beamAvatarPalettes.map((palette, index) => (
|
||
<button
|
||
key={palette.name}
|
||
className="avatar-palette"
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={account.avatar?.palette === index}
|
||
aria-label={palette.name}
|
||
title={palette.name}
|
||
disabled={avatarSaving}
|
||
onClick={() => void persistAvatar({ action: "set_palette", palette: index })}
|
||
>
|
||
{palette.colors.map((color) => <span key={color} style={{ backgroundColor: color }} />)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
className="button-secondary avatar-randomize"
|
||
type="button"
|
||
disabled={avatarSaving}
|
||
onClick={() => void persistAvatar({ action: "randomize" })}
|
||
>
|
||
{avatarSaving ? "保存中" : "换一个形象"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{avatarNotice && <p className="form-success avatar-notice" role="status">{avatarNotice}</p>}
|
||
</section>
|
||
)}
|
||
<section className="sheet-section birth-section">
|
||
<div className="section-heading"><b>出生资料</b><small>加密传输并保存到云端,用于此账号的所有对话</small></div>
|
||
<div className="default-chart-card" aria-label="当前默认星盘">
|
||
<div>
|
||
<span>当前默认星盘</span>
|
||
<strong>{profileDraft.name.trim() || "未命名"}</strong>
|
||
<small>角色:本人</small>
|
||
</div>
|
||
<button className="button-secondary" type="button" onClick={() => setChartLibraryOpen((current) => !current)}>管理星盘库</button>
|
||
</div>
|
||
{chartLibraryOpen && (
|
||
<div className="chart-library-panel" aria-label="星盘库">
|
||
<div className="chart-library-group">
|
||
<b>本人</b>
|
||
{chartLibrary.filter((record) => record.role === "self").map((record) => (
|
||
<article className="chart-library-item" key={record.id}>
|
||
<div>
|
||
<strong>{record.profile.name || "未命名"}</strong>
|
||
<small>{record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}</small>
|
||
</div>
|
||
<span>当前默认</span>
|
||
</article>
|
||
))}
|
||
</div>
|
||
<div className="chart-library-group">
|
||
<b>其他</b>
|
||
{chartLibrary.filter((record) => record.role === "other").length === 0 && <p className="empty-library-copy">还没有其他星盘。</p>}
|
||
{chartLibrary.filter((record) => record.role === "other").map((record) => (
|
||
<article className="chart-library-item" key={record.id}>
|
||
<div>
|
||
<strong>{record.profile.name || "未命名"}</strong>
|
||
<small>{record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}</small>
|
||
</div>
|
||
<div className="chart-library-actions">
|
||
<select aria-label="关系类型" value={synastryRelationshipType} onChange={(event) => setSynastryRelationshipType(event.target.value as SynastryRelationshipType)} disabled={synastryPendingId !== null}>
|
||
<option value="romance">婚恋</option>
|
||
<option value="business">商业合作</option>
|
||
<option value="family">亲友/家庭</option>
|
||
<option value="general">其他关系</option>
|
||
</select>
|
||
<button className="button-secondary" type="button" onClick={() => void draftSynastryQuestionFromChart(record, synastryRelationshipType)} disabled={synastryPendingId !== null}>{synastryPendingId === record.id ? "正在计算合盘..." : "用于合盘"}</button>
|
||
<button className="button-secondary" type="button" onClick={() => void makeDefaultChart(record)} disabled={profileSaving}>设为默认</button>
|
||
<button className="button-secondary danger-button" type="button" onClick={() => deleteOtherChart(record.id)}>删除</button>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
{synastryReportCard && (
|
||
<article className="synastry-report-card" aria-label="合盘结果摘要">
|
||
<div>
|
||
<span>合盘结果摘要</span>
|
||
<strong>{synastryReportCard.partnerName}</strong>
|
||
<small>Ashtakoot {synastryReportCard.score ?? "?"}/{synastryReportCard.maxScore ?? "?"} · {synastryReportCard.assessment || synastryReportCard.scoreBand || "待解释"}</small>
|
||
</div>
|
||
{synastryReportCard.headline && <p>{synastryReportCard.headline}</p>}
|
||
<details>
|
||
<summary>查看证据</summary>
|
||
<ul>
|
||
{(synastryReportCard.strengths || []).map((item) => <li key={item}>{item}</li>)}
|
||
{(synastryReportCard.risks || []).map((item) => <li key={item}>{item}</li>)}
|
||
</ul>
|
||
<small>下一步证据:{(synastryReportCard.nextEvidence || []).join(" / ") || "双方 Dasha / UL-DK / D9 7宫"}</small>
|
||
</details>
|
||
</article>
|
||
)}
|
||
{synastryHistory.length > 0 && (
|
||
<div className="synastry-history-list" aria-label="合盘历史">
|
||
<b>合盘历史</b>
|
||
{synastryHistory.slice(0, 5).map((item) => (
|
||
<button key={item.id} type="button" className="synastry-history-item" onClick={() => setSynastryReportCard(item)}>
|
||
<span>{item.partnerName}</span>
|
||
<small>Ashtakoot {item.score ?? "?"}/{item.maxScore ?? "?"} · {item.assessment || item.scoreBand || "待解释"}</small>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
<form className="profile-form chart-library-form" onSubmit={saveOtherChart}>
|
||
<div className="section-heading"><b>添加其他星盘</b><small>用于合盘、亲友盘或客户盘。</small></div>
|
||
<ProfileFields value={otherProfileDraft} onChange={setOtherProfileDraft} nameInputId="other-profile-name" />
|
||
<button className="button-primary save-profile" type="submit" disabled={!account}>添加到星盘库</button>
|
||
</form>
|
||
</div>
|
||
)}
|
||
</section>
|
||
{profileNotice && <p className="form-success" role="status">{profileNotice}</p>}
|
||
<form className="profile-form" onSubmit={saveProfile}>
|
||
<ProfileFields value={profileDraft} onChange={setProfileDraft} nameInputId="profile-name" />
|
||
<button className="button-primary save-profile" type="submit" disabled={profileSaving || !account}>{profileSaving ? "保存中" : "保存出生资料"}</button>
|
||
</form>
|
||
</>
|
||
)}
|
||
|
||
{activeAccountDialog === "logout" && (
|
||
<>
|
||
<p className="logout-copy">退出后,需要重新登录才能继续查看对话。</p>
|
||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||
<div className="dialog-actions">
|
||
<button className="button-secondary" type="button" onClick={closeAccountDialog} disabled={signingOut}>取消</button>
|
||
<button className="button-primary danger-primary" type="button" onClick={() => void signOut()} disabled={signingOut}>{signingOut ? "正在退出" : "确认退出"}</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</section>
|
||
</div>
|
||
)}
|
||
{onboardingPaywallOpen && account && (
|
||
<OnboardingRedeemPaywall
|
||
credits={account.credits}
|
||
onClose={() => setOnboardingPaywallOpen(false)}
|
||
onCreditsChanged={(credits) => setAccount((current) => current ? { ...current, credits } : current)}
|
||
/>
|
||
)}
|
||
</main>
|
||
</SidebarProvider>
|
||
);
|
||
}
|