Files
Jyotisha/frontend/src/lib/home-cloud-sync.ts
T

522 lines
21 KiB
TypeScript

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<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;
}
}
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<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 };
}
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<ChatSession> & {
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<string, unknown>;
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<void>((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<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;
}
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<SessionListPage> {
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<ChatSession | null> {
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<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;
}
export 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;
}
export 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;
}
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, "模型选择暂时无法同步到云端。"));
}