522 lines
21 KiB
TypeScript
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, "模型选择暂时无法同步到云端。"));
|
|
}
|