li / web /src /lib /skill-runtime-cache.ts
xiaolongmr
1
6daa87a
Raw History Blame Contribute Delete
5.89 kB
"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<string, SkillRuntimeCacheEntry>();
const inflight = new Map<string, Promise<SkillRuntime>>();
const storage = localforage.createInstance({ name: STORAGE_NAME, storeName: STORE_NAME });
let storageReady: Promise<void> | 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<string[]> {
try {
const value = await storage.getItem<string[]>(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<SkillRuntimeCacheEntry | null> {
const inMemory = memory.get(skillId);
if (inMemory) return inMemory;
await ensureStorage();
if (storageDisabled) return null;
try {
const value = await storage.getItem<SkillRuntimeCacheEntry>(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<SkillRuntimeCacheEntry | null> {
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<SkillRuntime> {
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<SkillRuntime> {
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;
}