File size: 5,892 Bytes
6daa87a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | "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;
}
|