File size: 6,481 Bytes
99c7c9f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
184
185
186
187
188
189
190
191
192
193
194
195
196
197
/**
 * Pulpie WebUI - Model Cache Manager
 *
 * Handles persistent browser caching of sizzlebop/pulpie-orange-small-onnx (model_fp16.onnx)
 * using the CacheStorage API with real-time download progress tracking.
 */

export const LOCAL_MODEL_URL = '/models/sizzlebop/pulpie-orange-small-onnx/model.onnx';
export const REMOTE_MODEL_URL =
  'https://huggingface.co/sizzlebop/pulpie-orange-small-onnx/resolve/main/model.onnx';
export const DEFAULT_MODEL_URL = REMOTE_MODEL_URL;

export const CACHE_NAME = 'pulpie-onnx-v2';
export const MODEL_SIZE_ESTIMATE_BYTES = 847240360; // ~808 MB

/**
 * Determine the best available model URL (local asset vs remote CDN).
 * @returns {Promise<string>}
 */
export async function resolveModelUrl() {
  const isLocal =
    typeof window !== 'undefined' &&
    (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');

  if (isLocal) {
    try {
      const resp = await fetch(LOCAL_MODEL_URL, { method: 'HEAD' });
      const cType = resp.headers.get('content-type') || '';
      const cLen = parseInt(resp.headers.get('content-length') || '0', 10);
      // Ensure it's not an SPA HTML fallback (which is small text/html)
      if (resp.ok && cLen > 10_000_000 && !cType.includes('text/html')) {
        return LOCAL_MODEL_URL;
      }
    } catch {
      // ignore
    }
  }
  return REMOTE_MODEL_URL;
}

/**
 * Check if the model is currently cached in the browser CacheStorage.
 * @param {string} [modelUrl]
 * @returns {Promise<boolean>}
 */
export async function isModelCached(modelUrl = null) {
  if (!('caches' in window)) return false;
  try {
    const cache = await caches.open(CACHE_NAME);
    const targetUrl = modelUrl || (await resolveModelUrl());
    const match = await cache.match(targetUrl);
    return Boolean(match);
  } catch (err) {
    console.warn('[ModelCache] Failed to check cache status:', err);
    return false;
  }
}

/**
 * Retrieve cache metadata (size in MB, cached status).
 * @param {string} [modelUrl]
 * @returns {Promise<{ isCached: boolean, sizeMb: number | null }>}
 */
export async function getCacheInfo(modelUrl = null) {
  if (!('caches' in window)) return { isCached: false, sizeMb: null };
  try {
    const cache = await caches.open(CACHE_NAME);
    const targetUrl = modelUrl || (await resolveModelUrl());
    const match = await cache.match(targetUrl);
    if (!match) return { isCached: false, sizeMb: null };

    const blob = await match.blob();
    const sizeMb = Number((blob.size / (1024 * 1024)).toFixed(1));
    return { isCached: true, sizeMb };
  } catch (err) {
    console.warn('[ModelCache] Failed to get cache info:', err);
    return { isCached: false, sizeMb: null };
  }
}

/**
 * Remove the model from browser CacheStorage.
 * @param {string} [modelUrl]
 * @returns {Promise<boolean>}
 */
export async function clearModelCache(modelUrl = null) {
  if (!('caches' in window)) return false;
  try {
    const cache = await caches.open(CACHE_NAME);
    const targetUrl = modelUrl || (await resolveModelUrl());
    return await cache.delete(targetUrl);
  } catch (err) {
    console.error('[ModelCache] Failed to delete model cache:', err);
    return false;
  }
}

/**
 * Download model with streaming progress reporting and store in CacheStorage.
 *
 * @param {string} [customUrl]
 * @param {function({ loaded: number, total: number, percent: number, speedMbps: number, fromCache: boolean }): void} [onProgress]
 * @returns {Promise<ArrayBuffer>}
 */
export async function loadModelBuffer(customUrl = null, onProgress = null) {
  const modelUrl = customUrl || (await resolveModelUrl());

  let cache = null;
  if ('caches' in window) {
    try {
      cache = await caches.open(CACHE_NAME);
      const cachedResponse = await cache.match(modelUrl);
      if (cachedResponse) {
        if (onProgress) onProgress({ loaded: 1, total: 1, percent: 100, speedMbps: 0, fromCache: true });
        return await cachedResponse.arrayBuffer();
      }
    } catch (cacheErr) {
      console.warn('[ModelCache] Cache read error, falling back to fetch:', cacheErr);
    }
  }

  const startTime = performance.now();
  const response = await fetch(modelUrl, { mode: 'cors' });

  if (!response.ok) {
    throw new Error(`Failed to download model (${response.status} ${response.statusText})`);
  }

  const contentType = response.headers.get('content-type') || '';
  if (contentType.includes('text/html')) {
    throw new Error('Received HTML response instead of model binary file. Please verify model URL.');
  }

  const contentLengthHeader = response.headers.get('content-length');
  const total = contentLengthHeader ? parseInt(contentLengthHeader, 10) : MODEL_SIZE_ESTIMATE_BYTES;

  if (!response.body) {
    const buffer = await response.arrayBuffer();
    if (cache) {
      const responseClone = new Response(buffer.slice(0), {
        headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(buffer.byteLength) },
      });
      await cache.put(modelUrl, responseClone).catch(() => {});
    }
    return buffer;
  }

  const reader = response.body.getReader();
  const chunks = [];
  let loaded = 0;
  let lastReportTime = startTime;

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    chunks.push(value);
    loaded += value.length;

    const now = performance.now();
    if (onProgress && now - lastReportTime > 80) {
      const elapsedSec = (now - startTime) / 1000;
      const speedMbps = elapsedSec > 0 ? Number(((loaded * 8) / (elapsedSec * 1024 * 1024)).toFixed(2)) : 0;
      const percent = Math.min(100, Number(((loaded / total) * 100).toFixed(1)));
      onProgress({ loaded, total, percent, speedMbps, fromCache: false });
      lastReportTime = now;
    }
  }

  const modelBuffer = new Uint8Array(loaded);
  let offset = 0;
  for (const chunk of chunks) {
    modelBuffer.set(chunk, offset);
    offset += chunk.length;
  }

  if (onProgress) {
    onProgress({ loaded, total: loaded, percent: 100, speedMbps: 0, fromCache: false });
  }

  if (cache) {
    try {
      const cacheResponse = new Response(modelBuffer.buffer.slice(0), {
        headers: {
          'Content-Type': 'application/octet-stream',
          'Content-Length': String(loaded),
        },
      });
      await cache.put(modelUrl, cacheResponse);
    } catch (putErr) {
      console.warn('[ModelCache] Could not persist model to CacheStorage:', putErr);
    }
  }

  return modelBuffer.buffer;
}