import { writeChatSession } from "@/lib/chat-session-write-contract"; import { persistLoginSessionReturn } from "@/lib/chat-session-url"; import { parsePublicThinkingSections } from "@/lib/consultation-thinking-plan"; import { parsePublicModelCatalog, resolveSessionModelId, type PublicLanguageModelCatalog, } from "@/lib/public-models"; import { normalizeConsultationDomain } from "@/lib/consultation-domain-registry"; import { timestamp, undoWindowMs, uuidPattern, type Account, type ChartLibraryApiRecord, type ChartLibraryRecord, type ChatProfileBinding, type ChatSession, type ChatSessionType, type ConsultationStatus, type DailyStarlanguageApiResponse, type DailyStarlanguageState, type Message, type SessionReadResult, type StoredDailyStarlanguage, type StoredPendingConsultation, type SynastryReportApiRecord, type SynastryReportCard, } from "@/lib/home-types"; export function readStoredPendingConsultation( raw: string | null, sessionIds: Iterable, ): StoredPendingConsultation | null { if (!raw) return null; try { const parsedPending = JSON.parse(raw) as Record; if (typeof parsedPending.sessionId !== "string" || typeof parsedPending.requestId !== "string" || !uuidPattern.test(parsedPending.sessionId) || !uuidPattern.test(parsedPending.requestId)) { return null; } let sessionKnown = false; for (const sessionId of sessionIds) { if (sessionId === parsedPending.sessionId) { sessionKnown = true; break; } } if (!sessionKnown) return null; return { sessionId: parsedPending.sessionId, requestId: parsedPending.requestId, question: typeof parsedPending.question === "string" ? parsedPending.question : "", theme: normalizeConsultationDomain(parsedPending.theme), entrypoint: parsedPending.entrypoint === "daily_starlanguage" ? "daily_starlanguage" : null, }; } catch { return null; } } export function createSession( modelId: string, sessionType: ChatSessionType = "consultation", chartBinding: ChatProfileBinding = { chartProfileId: null, chartProfileName: null, chartProfileRole: null }, ): ChatSession { return { id: globalThis.crypto.randomUUID(), title: sessionType === "birth_time_rectification" ? "生时校正" : "新对话", theme: "general", modelId, messages: [], updatedAt: timestamp(), sessionType, rectificationCaseId: null, pinned: false, archivedAt: null, messagesHydrated: true, ...chartBinding, }; } export function activeChartStorageKey(accountId: string) { return `jyotisha_active_chart:${accountId}`; } export function dailyStarlanguageStorageKey(accountId: string) { return `jyotisha_daily_starlanguage:${accountId}`; } export function sessionControlsStorageKey(accountId: string, kind: "pinned" | "archived") { return `jyotisha-session-controls:${accountId}:${kind}`; } export function discardLegacyCloudMirrorKeys(accountId: string) { localStorage.removeItem(`jyotisha_chart_library:${accountId}`); localStorage.removeItem(`jyotisha_synastry_history:${accountId}`); } export function readLegacySessionControlIds(accountId: string, kind: "pinned" | "archived"): string[] { try { const parsed = JSON.parse(localStorage.getItem(sessionControlsStorageKey(accountId, kind)) || "null") as unknown; return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : []; } catch { return []; } } export function clearLegacySessionControlKeys(accountId: string) { localStorage.removeItem(sessionControlsStorageKey(accountId, "pinned")); localStorage.removeItem(sessionControlsStorageKey(accountId, "archived")); } export function applyLegacySessionControls(accountId: string, sessions: ChatSession[]): ChatSession[] { const pinnedKey = sessionControlsStorageKey(accountId, "pinned"); const archivedKey = sessionControlsStorageKey(accountId, "archived"); if (localStorage.getItem(pinnedKey) === null && localStorage.getItem(archivedKey) === null) { return sessions; } const pinnedIds = new Set(readLegacySessionControlIds(accountId, "pinned")); const archivedIds = new Set(readLegacySessionControlIds(accountId, "archived")); const next = sessions.map((session) => ({ ...session, pinned: session.pinned || pinnedIds.has(session.id), archivedAt: session.archivedAt || (archivedIds.has(session.id) ? new Date().toISOString() : null), })); void Promise.allSettled(next.flatMap((session, index) => { const previous = sessions[index]; if (!previous) return []; const patch: { pinned?: boolean; archived_at?: string | null } = {}; if (session.pinned !== previous.pinned) patch.pinned = session.pinned; if (session.archivedAt !== previous.archivedAt) patch.archived_at = session.archivedAt; if (patch.pinned === undefined && patch.archived_at === undefined) return []; return [writeChatSession(session.id, patch, "update")]; })); clearLegacySessionControlKeys(accountId); return next; } export 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; } } export function writeStoredDailyStarlanguage(accountId: string, stored: StoredDailyStarlanguage) { localStorage.setItem(dailyStarlanguageStorageKey(accountId), JSON.stringify(stored)); } export 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(), }; } export 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[]; } export 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; } export function normalizeChartLibraryApiRecord(record: ChartLibraryApiRecord): ChartLibraryRecord { const relationship = record.role === "self" ? "self" : record.profile.chartRelationship || "other"; return { id: record.role === "self" ? "self" : record.id, role: record.role, profile: { ...record.profile, chartRelationship: relationship }, relationship, updatedAt: Date.parse(record.updated_at || "") || timestamp(), }; } export 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); } export 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; } export async function updateCloudChartProfile(record: ChartLibraryRecord) { const response = await fetch(`/api/chart-profiles/${encodeURIComponent(record.id)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ 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_update_failed"); return payload?.profile ? normalizeChartLibraryApiRecord(payload.profile) : record; } export 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"); } export async function fetchDailyStarlanguage(signal: AbortSignal): Promise { const response = await fetch("/api/daily-starlanguage", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), signal, }); if (!response.ok) return { kind: "unavailable" }; const payload = await response.json().catch(() => null) as DailyStarlanguageApiResponse | null; if (payload?.status !== "ok" || !payload.card) return { kind: "unavailable" }; return { kind: "ready", card: payload.card }; } export type SessionListPage = { readonly sessions: unknown; readonly nextCursor: string | null; }; export function readSessionListPage(value: unknown): SessionListPage { if (Array.isArray(value)) return { sessions: value, nextCursor: null }; if (!value || typeof value !== "object") return { sessions: [], nextCursor: null }; const page = value as { sessions?: unknown; nextCursor?: unknown }; return { sessions: page.sessions, nextCursor: typeof page.nextCursor === "string" && page.nextCursor ? page.nextCursor : null, }; } export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null): SessionReadResult { if (!Array.isArray(value)) return { sessions: [], fallbackSessionIds: [] }; const fallbackSessionIds: string[] = []; const sessions = value.flatMap((item): ChatSession[] => { if (!item || typeof item !== "object") return []; const session = item as Partial & { model_id?: unknown; rectification_case_id?: unknown; chart_profile_id?: unknown; chart_profile_name?: unknown; chart_profile_role?: unknown; session_type?: unknown; updated_at?: unknown; archived_at?: unknown; }; const messagesPresent = Object.prototype.hasOwnProperty.call(session, "messages"); const messages: Message[] = Array.isArray(session.messages) ? session.messages.flatMap((message) => { if (!message || typeof message !== "object") return []; const stored = message as Message; if ((stored.role !== "user" && stored.role !== "assistant") || typeof stored.text !== "string") { return []; } const thinkingText = typeof stored.thinkingText === "string" && stored.thinkingText.trim() ? stored.thinkingText.slice(0, 4000) : undefined; const thinkingSections = parsePublicThinkingSections(stored.thinkingSections); return [{ role: stored.role, text: stored.text.slice(0, 12000), ...(thinkingText ? { thinkingText } : {}), ...(thinkingSections.length ? { thinkingSections } : {}), ...(typeof stored.techniqueTruth === "string" ? { techniqueTruth: stored.techniqueTruth } : {}), ...(stored.agentExecutionReceipt ? { agentExecutionReceipt: stored.agentExecutionReceipt } : {}), ...(stored.workflowReceipt ? { workflowReceipt: stored.workflowReceipt } : {}), }]; }) : []; 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, 48) : "新对话", 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, chartProfileId: typeof session.chart_profile_id === "string" ? session.chart_profile_id : null, chartProfileName: typeof session.chart_profile_name === "string" ? session.chart_profile_name : null, chartProfileRole: session.chart_profile_role === "self" || session.chart_profile_role === "other" ? session.chart_profile_role : null, pinned: session.pinned === true, archivedAt: typeof session.archived_at === "string" && session.archived_at ? session.archived_at : typeof session.archivedAt === "string" && session.archivedAt ? session.archivedAt : null, updatedAt: typeof session.updatedAt === "number" ? session.updatedAt : typeof session.updated_at === "string" ? Date.parse(session.updated_at) : timestamp(), messagesHydrated: messagesPresent, }]; }); return { sessions, fallbackSessionIds }; } export function mergeHydratedSession(current: ChatSession[], detailed: ChatSession): ChatSession[] { const next = { ...detailed, messagesHydrated: true }; if (current.some((session) => session.id === next.id)) { return current.map((session) => (session.id === next.id ? { ...session, ...next } : session)); } return [next, ...current]; } export function friendlyError(message: string) { return ( message.includes("数据库配置缺失") || (message.includes("Supabase") && (message.includes("配置") || message.includes("environment") || message.includes("URL"))) ) ? "数据库尚未配置" : message; } export function payloadMessage(payload: unknown, fallback: string) { if (!payload || typeof payload !== "object") return fallback; const data = payload as Record; const message = [data.recovery, data.message, data.error].find((value) => typeof value === "string") as string | undefined; return friendlyError(message || fallback); } export function payloadCode(payload: unknown): string | undefined { if (!payload || typeof payload !== "object") return undefined; const code = (payload as { code?: unknown }).code; return typeof code === "string" ? code : undefined; } export class CancellationResponseError extends Error { readonly status: number; constructor(status: number, message: string) { super(message); this.name = "CancellationResponseError"; this.status = status; } } export class ConsultationResponseError extends Error { readonly status: number; readonly code?: string; constructor(status: number, message: string, code?: string) { super(message); this.name = "ConsultationResponseError"; this.status = status; this.code = code; } } export class ConsultationStatusError extends Error { readonly status: number; constructor(status: number, message: string) { super(message); this.name = "ConsultationStatusError"; this.status = status; } } export class LoginRedirectError extends Error { constructor() { super("Redirecting to login"); this.name = "LoginRedirectError"; } } export function redirectToLogin(): never { persistLoginSessionReturn(); window.location.replace("/login"); throw new LoginRedirectError(); } export function waitForUndoWindow(signal: AbortSignal) { return new Promise((resolve) => { const finish = () => { window.clearTimeout(timer); signal.removeEventListener("abort", finish); resolve(); }; const timer = window.setTimeout(finish, undoWindowMs); signal.addEventListener("abort", finish, { once: true }); }); } export async function fetchAccount(signal?: AbortSignal): Promise { const response = await fetch("/api/account", { signal, cache: "no-store" }); if (response.status === 401) redirectToLogin(); const payload = await response.json().catch(() => null); if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取账户信息")); return payload as Account; } export 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); } export async function fetchSessions( signal?: AbortSignal, options?: { before?: string | null; archived?: boolean }, ): Promise { const params = new URLSearchParams(); if (options?.before) params.set("before", options.before); if (options?.archived) params.set("archived", "1"); const query = params.toString(); const response = await fetch("/api/sessions" + (query ? `?${query}` : ""), { 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 readSessionListPage(payload); } export async function fetchSessionDetail( sessionId: string, catalog: PublicLanguageModelCatalog | null, signal?: AbortSignal, ): Promise { const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { signal, cache: "no-store", }); if (response.status === 401) redirectToLogin(); const payload = await response.json().catch(() => null); if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录")); const sessionValue = payload && typeof payload === "object" ? (payload as { session?: unknown }).session : null; return readSessions(sessionValue ? [sessionValue] : [], catalog).sessions[0] ?? null; } export function parseConsultationStatus(payload: unknown, requestId?: string): ConsultationStatus { if (!payload || typeof payload !== "object") throw new Error("后台回答状态无效"); const status = payload as Partial; if (typeof status.requestId !== "string" || typeof status.sessionId !== "string" || (requestId && status.requestId !== requestId) || (status.status !== "reserved" && status.status !== "completed" && status.status !== "cancelled")) { throw new Error("后台回答状态无效"); } return status as ConsultationStatus; } export async function fetchConsultationStatus(sessionId: string, requestId: string, signal?: AbortSignal): Promise { const response = await fetch(`/api/consult/status?sessionId=${encodeURIComponent(sessionId)}&requestId=${encodeURIComponent(requestId)}`, { signal, cache: "no-store", }); const payload: unknown = await response.json().catch(() => null); if (!response.ok) { throw new ConsultationStatusError( response.status, payloadMessage(payload, "暂时无法恢复后台回答"), ); } const status = parseConsultationStatus(payload, requestId); if (status.sessionId !== sessionId) throw new Error("后台回答状态无效"); return status; } export async function fetchActiveConsultationStatus(signal?: AbortSignal): Promise { const response = await fetch("/api/consult/status", { signal, cache: "no-store" }); const payload: unknown = await response.json().catch(() => null); if (response.status === 404) return null; if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法恢复后台回答")); const status = parseConsultationStatus(payload); if (status.status !== "reserved") throw new Error("后台回答状态无效"); return status; } export 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, "模型选择暂时无法同步到云端。")); }