"use client"; import localforage from "localforage"; import { fetchSkillRuntimeConditional, type SkillRuntime } from "@/lib/api"; /** * A Skill runtime is a pure function of the files inside its directory, so the * previous result stays valid until those files change. The catalog already * carries each Skill's `guide_version`, which lets a cached entry be checked * against the installed files without an extra round trip. * * Opens are served in this order: * 1. memory hit that still matches the catalog version -> no request at all * 2. IndexedDB hit -> paint immediately, then revalidate with If-None-Match * 3. network fetch -> store for the next open */ const MEMORY_LIMIT = 24; const STORAGE_LIMIT = 24; const FRESH_MS = 60_000; const STORAGE_NAME = "chatgpt2api"; const STORE_NAME = "skill-runtime"; const INDEX_KEY = "__index__"; export type SkillRuntimeCacheEntry = { version: string; etag: string; savedAt: number; runtime: SkillRuntime; }; const memory = new Map(); const inflight = new Map>(); const storage = localforage.createInstance({ name: STORAGE_NAME, storeName: STORE_NAME }); let storageReady: Promise | null = null; let storageDisabled = false; function ensureStorage() { if (storageDisabled) return Promise.resolve(); if (!storageReady) { storageReady = storage.ready().then(() => undefined).catch(() => { // Private browsing or a blocked storage backend: keep working in memory. storageDisabled = true; }); } return storageReady; } async function readIndex(): Promise { try { const value = await storage.getItem(INDEX_KEY); if (!Array.isArray(value)) return []; return value.filter((item): item is string => typeof item === "string"); } catch { return []; } } async function writeIndex(ids: string[]) { try { await storage.setItem(INDEX_KEY, ids); } catch { // A full quota must never break the detail dialog. } } function rememberInMemory(skillId: string, entry: SkillRuntimeCacheEntry) { memory.delete(skillId); memory.set(skillId, entry); while (memory.size > MEMORY_LIMIT) { const oldest = memory.keys().next().value; if (oldest === undefined) break; memory.delete(oldest); } } async function readEntry(skillId: string): Promise { const inMemory = memory.get(skillId); if (inMemory) return inMemory; await ensureStorage(); if (storageDisabled) return null; try { const value = await storage.getItem(skillId); if (value && value.runtime) { rememberInMemory(skillId, value); return value; } } catch { return null; } return null; } async function writeEntry(skillId: string, entry: SkillRuntimeCacheEntry) { rememberInMemory(skillId, entry); await ensureStorage(); if (storageDisabled) return; try { await storage.setItem(skillId, entry); const ids = await readIndex(); const next = [skillId, ...ids.filter((item) => item !== skillId)]; const dropped = next.slice(STORAGE_LIMIT); await writeIndex(next.slice(0, STORAGE_LIMIT)); await Promise.all(dropped.map((id) => storage.removeItem(id))); } catch { // Ignore quota errors: the in-memory copy still serves this session. } } /** Cached runtime for instant paint, without touching the network. */ export function peekSkillRuntime(skillId: string): Promise { return readEntry(skillId); } /** * True when a cached entry matches the version the catalog reports and was * written recently enough that no revalidation is expected at all. */ export function isSkillRuntimeFresh(entry: SkillRuntimeCacheEntry | null, expectedVersion = "") { if (!entry || !expectedVersion || entry.version !== expectedVersion) return false; return Date.now() - entry.savedAt < FRESH_MS; } export function forgetSkillRuntime(skillId: string) { memory.delete(skillId); void (async () => { await ensureStorage(); if (storageDisabled) return; try { await storage.removeItem(skillId); const ids = await readIndex(); await writeIndex(ids.filter((item) => item !== skillId)); } catch { // Nothing to clean up. } })(); } async function revalidateSkillRuntime(skillId: string, expectedVersion: string): Promise { const cached = await readEntry(skillId); const versionMatches = Boolean(cached && expectedVersion && cached.version === expectedVersion); if (cached && versionMatches && Date.now() - cached.savedAt < FRESH_MS) { return cached.runtime; } const result = await fetchSkillRuntimeConditional(skillId, cached?.etag || ""); if (result.notModified) { if (!cached) throw new Error("Skill 数据读取失败"); // The server confirmed the files are unchanged; refresh recency and adopt // the catalog version so the next open can skip the request entirely. await writeEntry(skillId, { ...cached, version: expectedVersion || cached.version, etag: result.etag || cached.etag, savedAt: Date.now(), }); return cached.runtime; } const runtime = result.runtime; if (!runtime) throw new Error("Skill 数据读取失败"); await writeEntry(skillId, { version: expectedVersion, etag: result.etag, savedAt: Date.now(), runtime, }); return runtime; } /** Load a Skill runtime, reusing the cache and de-duplicating concurrent opens. */ export function loadSkillRuntime(skillId: string, expectedVersion = ""): Promise { const pending = inflight.get(skillId); if (pending) return pending; const task = revalidateSkillRuntime(skillId, expectedVersion).finally(() => { if (inflight.get(skillId) === task) inflight.delete(skillId); }); inflight.set(skillId, task); return task; }