File size: 6,246 Bytes
c7592d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { useCallback, useEffect, useState } from "react";
import { MLC_REPO, mlcAppConfig, mlcUrls, mlcVariant } from "../runtime/mlc";
import { MODEL_IDS, type ModelId, type ContextPreset } from "../types";
import {
  clearLoadedModel,
  markLoadedModel,
  readLoadedModels,
  type LoadedModels,
} from "../lib/modelCacheRecord";

export interface StorageUsage {
  usage: number | null;
  quota: number | null;
}

export interface ModelCacheState {
  /** Per quant, from WebLLM's own cache check; null while still unknown. */
  cached: Record<ModelId, boolean | null>;
  /** Per quant, those that have completed a load on this device. */
  loadedHere: LoadedModels;
  storage: StorageUsage;
  /** The quant currently being cleared, if any. */
  clearing: ModelId | null;
  /** True while a full (all-quants) cache purge is in flight. */
  clearingAll: boolean;
  refresh(): Promise<void>;
  clear(id: ModelId): Promise<void>;
  /** Wipe every WebLLM weight/config entry for this app, including orphans. */
  clearAll(): Promise<void>;
  /** Record a successful load, then re-check the cache. */
  markLoaded(id: ModelId): void;
}

const UNKNOWN = Object.fromEntries(MODEL_IDS.map((id) => [id, null])) as Record<
  ModelId,
  boolean | null
>;

async function readStorage(): Promise<StorageUsage> {
  try {
    const est = await navigator.storage?.estimate?.();
    return { usage: est?.usage ?? null, quota: est?.quota ?? null };
  } catch {
    return { usage: null, quota: null };
  }
}

/**
 * Delete every cache entry across all of this origin's caches whose URL
 * matches. WebLLM's `deleteModelAllInfoInCache` is index-driven: it only
 * removes the shards listed in `tensor-cache.json`. Shards left behind by an
 * interrupted download - or by a browser-evicted index - become orphans it can
 * never reach, and they are what keep `storage.estimate().usage` high long
 * after the UI reports "not downloaded". Matching on the URL is the only way
 * to reach those bytes.
 */
async function purgeCaches(match: (url: string) => boolean): Promise<void> {
  if (typeof caches === "undefined") return;
  const names = await caches.keys();
  await Promise.all(
    names.map(async (name) => {
      const cache = await caches.open(name);
      const hits = (await cache.keys()).filter((req) => match(req.url));
      await Promise.all(hits.map((req) => cache.delete(req)));
    }),
  );
}

/** Purge everything cached for one model's subdir: weights, wasm, tokenizer, index. */
function purgeModelCache(id: ModelId): Promise<void> {
  const base = mlcUrls(id).model; // "…/resolve/main/<subdir>", no trailing slash
  return purgeCaches((u) => u === base || u.startsWith(base + "/"));
}

/** Purge every WebLLM artifact for this app, across all quants at once. */
function purgeAllModelCaches(): Promise<void> {
  const marker = `${MLC_REPO}/resolve/main/`;
  return purgeCaches((u) => u.includes(marker));
}

/**
 * Reports, for every quant rather than only the selected one, whether its
 * weights are still in the browser cache - plus how much storage the origin is
 * actually using, which is the figure that explains a cache that went away.
 *
 * Two independent signals are kept, because WebLLM's alone is not trustworthy
 * on its own: `hasModelInCache` needs *every* param shard present, so a browser
 * that evicted Cache Storage under pressure reports "not downloaded" for a
 * quant this device has loaded many times. Our own record tells those apart.
 */
export function useModelCache(context: ContextPreset): ModelCacheState {
  const [cached, setCached] = useState<Record<ModelId, boolean | null>>(UNKNOWN);
  const [loadedHere, setLoadedHere] = useState<LoadedModels>(() => readLoadedModels());
  const [storage, setStorage] = useState<StorageUsage>({ usage: null, quota: null });
  const [clearing, setClearing] = useState<ModelId | null>(null);
  const [clearingAll, setClearingAll] = useState(false);

  const refresh = useCallback(async () => {
    try {
      const { hasModelInCache } = await import("@mlc-ai/web-llm");
      const results = await Promise.all(
        MODEL_IDS.map(async (id) => {
          try {
            const hit = await hasModelInCache(
              mlcVariant(id).cacheLabel,
              mlcAppConfig(id, context)
            );
            return [id, hit] as const;
          } catch {
            return [id, null] as const;
          }
        })
      );
      setCached(Object.fromEntries(results) as Record<ModelId, boolean | null>);
    } catch {
      setCached({ ...UNKNOWN });
    }
    setLoadedHere(readLoadedModels());
    setStorage(await readStorage());
  }, [context]);

  const clear = useCallback(
    async (id: ModelId) => {
      setClearing(id);
      try {
        const { deleteModelAllInfoInCache } = await import("@mlc-ai/web-llm");
        await deleteModelAllInfoInCache(mlcVariant(id).cacheLabel, mlcAppConfig(id, context));
      } catch {
        /* nothing cached to remove */
      }
      // WebLLM's delete only removes what its index lists; also purge any shards
      // still sitting under this model's subdir so usage actually drops.
      await purgeModelCache(id).catch(() => {
        /* best-effort */
      });
      // Forget our own record too: after an explicit clear the honest status is
      // "not downloaded", not "evicted".
      setLoadedHere(clearLoadedModel(id));
      await refresh();
      setClearing(null);
    },
    [context, refresh]
  );

  const clearAll = useCallback(async () => {
    setClearingAll(true);
    try {
      await purgeAllModelCaches();
      // Forget our own load records so every quant reads "not downloaded".
      let rec = readLoadedModels();
      for (const id of MODEL_IDS) rec = clearLoadedModel(id);
      setLoadedHere(rec);
    } catch {
      /* storage unavailable; the usage re-read below reflects reality */
    }
    await refresh();
    setClearingAll(false);
  }, [refresh]);

  const markLoaded = useCallback(
    (id: ModelId) => {
      setLoadedHere(markLoadedModel(id));
      void refresh();
    },
    [refresh]
  );

  useEffect(() => {
    void refresh();
  }, [refresh]);

  return { cached, loadedHere, storage, clearing, clearingAll, refresh, clear, clearAll, markLoaded };
}