// localStorage layer for the /ask SAVED CHATS library: a set of named chats + // an active-name pointer, mirroring the search "profiles" store // (common/components/exportFilterStorage.ts) — strict parse-or-reset, quota-safe // writes, and a deep-equal dirty helper. A SavedChat is a snapshot of the working // conversation (messages + report + grounding flags) PLUS an optional sweep // checkpoint, so a paused sweep is just a saved chat in a paused state and can be // resumed after a reload or a model switch. // // The single live "working" conversation still lives under its own K_CONVO key in // useAskChat; a saved chat is a named copy, and loading one replaces the working // conversation. import type { UiMessage } from "../lib/askConversation"; import type { RetrievedVideo } from "../lib/askRetrieval"; const KEY = "ytdlp-tb:ai:chats"; const VERSION = 1; // A sweep that paused mid-run (rate-limit, model warning, or a manual Pause). Holds // everything needed to RESUME from the next batch under any provider/model: the // remaining result slugs, the directive, and the running report is carried by the // SavedChat.report field. `reason` is a short human string for the paused banner. export type SweepCheckpoint = { // The report directive the sweep was folding toward. directive: string; // Result slugs not yet folded into the report (the remaining batch queue, // flattened — the resumer re-chunks them by the current batch size). remainingSlugs: string[]; // Batches already completed / total, for the "resumed at i of n" copy. done: number; total: number; // Why it paused (e.g. "usage limit", "safety block", "paused"), for the banner. reason: string; }; // One saved chat = the persisted working conversation shape, plus a name, the // last-used model as INFO (never binding — resume under any model), and an // optional paused-sweep checkpoint. export type SavedChat = { messages: UiMessage[]; contextOverride: string | null; strictGrounding?: boolean; detached?: boolean; enrichments?: Record; report?: string; // The report's source registry (what its `[n]` citations index into), trimmed of // snippet text. Absent on chats saved before this existed. reportSources?: RetrievedVideo[]; reportMode?: boolean; // Sweep checkpoint (present only for a paused sweep). checkpoint?: SweepCheckpoint | null; // Info only — which provider/model produced this (NOT applied on load). model?: string; provider?: string; // Epoch ms of the last save, passed in by the caller (scripts can't call // Date.now()); used only for display ordering. savedAt?: number; }; export type SavedChatsState = { v: 1; items: Record; activeName: string | null; }; export function emptySavedChats(): SavedChatsState { return { v: VERSION, items: {}, activeName: null }; } function isStringArray(v: unknown): v is string[] { return Array.isArray(v) && v.every((x) => typeof x === "string"); } function parseCheckpoint(raw: unknown): SweepCheckpoint | null { if (!raw || typeof raw !== "object") return null; const r = raw as Record; if (typeof r.directive !== "string") return null; if (!isStringArray(r.remainingSlugs)) return null; const done = typeof r.done === "number" ? r.done : 0; const total = typeof r.total === "number" ? r.total : 0; const reason = typeof r.reason === "string" ? r.reason : "paused"; return { directive: r.directive, remainingSlugs: r.remainingSlugs.slice(), done, total, reason }; } function parseChat(raw: unknown): SavedChat | null { if (!raw || typeof raw !== "object") return null; const r = raw as Record; // messages is the one required field; the rest are optional flags. if (!Array.isArray(r.messages)) return null; const chat: SavedChat = { messages: r.messages as UiMessage[], contextOverride: typeof r.contextOverride === "string" ? r.contextOverride : null, }; if (typeof r.strictGrounding === "boolean") chat.strictGrounding = r.strictGrounding; if (typeof r.detached === "boolean") chat.detached = r.detached; if (r.enrichments && typeof r.enrichments === "object" && !Array.isArray(r.enrichments)) { chat.enrichments = r.enrichments as SavedChat["enrichments"]; } if (typeof r.report === "string") chat.report = r.report; if (Array.isArray(r.reportSources)) { chat.reportSources = r.reportSources as RetrievedVideo[]; } if (typeof r.reportMode === "boolean") chat.reportMode = r.reportMode; if (r.checkpoint !== undefined) chat.checkpoint = parseCheckpoint(r.checkpoint); if (typeof r.model === "string") chat.model = r.model; if (typeof r.provider === "string") chat.provider = r.provider; if (typeof r.savedAt === "number") chat.savedAt = r.savedAt; return chat; } function parseState(raw: string): SavedChatsState | null { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return null; } if (!parsed || typeof parsed !== "object") return null; const r = parsed as Record; if (r.v !== VERSION) return null; const itemsRaw = r.items; if (!itemsRaw || typeof itemsRaw !== "object" || Array.isArray(itemsRaw)) { return null; } const items: Record = {}; for (const [name, chatRaw] of Object.entries(itemsRaw as Record)) { if (!name) continue; const chat = parseChat(chatRaw); if (!chat) return null; items[name] = chat; } const active = typeof r.activeName === "string" && r.activeName in items ? r.activeName : null; return { v: VERSION, items, activeName: active }; } export function loadSavedChats(): SavedChatsState { if (typeof window === "undefined") return emptySavedChats(); let raw: string | null = null; try { raw = window.localStorage.getItem(KEY); } catch { return emptySavedChats(); } if (raw === null) return emptySavedChats(); const parsed = parseState(raw); if (!parsed) { // Reset on a malformed payload rather than trapping the UI in a broken state. try { window.localStorage.removeItem(KEY); } catch { /* ignore */ } return emptySavedChats(); } return parsed; } export function saveSavedChats(next: SavedChatsState): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(KEY, JSON.stringify(next)); } catch { /* quota / serialization errors are non-fatal */ } } // Deep-equal-ish dirty check between a saved chat and the current working state. // Compares the fields that a save round-trips (messages + report + grounding // flags + checkpoint) via canonical JSON, ignoring info-only fields (model, // savedAt) that don't change what a load restores. export function savedChatsEqual(a: SavedChat, b: SavedChat): boolean { const norm = (c: SavedChat) => JSON.stringify({ messages: c.messages, contextOverride: c.contextOverride ?? null, strictGrounding: c.strictGrounding ?? true, detached: c.detached ?? false, enrichments: c.enrichments ?? {}, report: c.report ?? "", reportSources: c.reportSources ?? [], reportMode: c.reportMode ?? false, checkpoint: c.checkpoint ?? null, }); return norm(a) === norm(b); }