mihailik commited on
Commit
2a0250a
·
1 Parent(s): 5df2a9b

Faster fetching of model list (with caching).

Browse files
package.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "name": "localm",
3
- "version": "1.1.27",
4
  "description": "Chat application",
5
  "scripts": {
6
  "build": "esbuild src/index.js --target=es6 --bundle --sourcemap --outfile=./index.js --format=iife --external:fs --external:path --external:child_process --external:ws --external:katex/dist/katex.min.css",
 
1
  {
2
  "name": "localm",
3
+ "version": "1.1.28",
4
  "description": "Chat application",
5
  "scripts": {
6
  "build": "esbuild src/index.js --target=es6 --bundle --sourcemap --outfile=./index.js --format=iife --external:fs --external:path --external:child_process --external:ws --external:katex/dist/katex.min.css",
src/app/boot-app.js CHANGED
@@ -22,7 +22,7 @@ export async function bootApp() {
22
  worker.loaded.then(async ({ env }) => {
23
  document.title = name + ' v' + version + ' t/' + env.version;
24
  outputMessage(
25
- 'transformers.js v' + env.version);
26
  });
27
 
28
  const {
@@ -54,5 +54,5 @@ export async function bootApp() {
54
  // Setup Enter key handling for the Crepe input editor
55
  setupCrepeEnterKey(crepeInput, worker);
56
  document.title = name + ' v' + version;
57
- outputMessage(description + ' v' + version);
58
  }
 
22
  worker.loaded.then(async ({ env }) => {
23
  document.title = name + ' v' + version + ' t/' + env.version;
24
  outputMessage(
25
+ 'transformers.js **v' + env.version + '**');
26
  });
27
 
28
  const {
 
54
  // Setup Enter key handling for the Crepe input editor
55
  setupCrepeEnterKey(crepeInput, worker);
56
  document.title = name + ' v' + version;
57
+ outputMessage(description + ' **v' + version + '**');
58
  }
src/app/init-milkdown.js CHANGED
@@ -11,9 +11,10 @@ import { Crepe } from '@milkdown/crepe';
11
  import { blockEdit } from '@milkdown/crepe/feature/block-edit';
12
  import { commonmark } from '@milkdown/kit/preset/commonmark';
13
 
 
 
14
  import "@milkdown/crepe/theme/common/style.css";
15
  import "@milkdown/crepe/theme/frame.css";
16
- import { outputMessage } from './output-message';
17
 
18
  /**
19
  * @typedef {{
@@ -45,7 +46,6 @@ export async function initMilkdown({
45
  const chatLogEditor = await Editor.make()
46
  .config((ctx) => {
47
  ctx.set(rootCtx, chatLog);
48
- ctx.set(defaultValueCtx, 'Loaded.');
49
  ctx.set(editorViewOptionsCtx, { editable: () => false });
50
  })
51
  .use(commonmark)
@@ -56,7 +56,6 @@ export async function initMilkdown({
56
  root: chatInput,
57
  defaultValue: '',
58
  features: {
59
- // Do NOT enable BlockEdit here; we'll add it later after models load
60
  [Crepe.Feature.BlockEdit]: false,
61
  [Crepe.Feature.Placeholder]: true,
62
  [Crepe.Feature.Cursor]: true,
@@ -70,7 +69,7 @@ export async function initMilkdown({
70
  },
71
  featureConfigs: {
72
  [Crepe.Feature.Placeholder]: {
73
- text: 'Start typing...',
74
  mode: 'block'
75
  }
76
  }
@@ -81,14 +80,8 @@ export async function initMilkdown({
81
  // Fetch models in background and add BlockEdit when ready
82
  (async () => {
83
  try {
84
- if (!worker || typeof worker.listChatModels !== 'function') {
85
- console.warn('[initMilkdown] worker.listChatModels not available; skipping BlockEdit setup');
86
- return;
87
- }
88
- console.log('[initMilkdown] requesting models from worker');
89
  const { id, promise, cancel } = await worker.listChatModels({}, undefined);
90
  const out = await promise;
91
- console.log('[initMilkdown] worker.listChatModels resolved', out && out.meta ? out.meta : out);
92
 
93
  // Normalize possible response shapes
94
  let entries = [];
@@ -106,76 +99,22 @@ export async function initMilkdown({
106
  requiresAuth: e.classification === 'auth-protected'
107
  }));
108
 
109
- console.log('[initMilkdown] extracted models', { count: availableModels.length });
110
-
111
  outputMessage('Models discovered: **' + availableModels.length + '**');
112
 
113
- // Add BlockEdit feature now that models are available
114
- const _addFeatureResult = crepeInput.addFeature(blockEdit, {
115
  buildMenu: (groupBuilder) => {
116
  const modelsGroup = groupBuilder.addGroup('models', 'Models');
117
  (availableModels || []).forEach((model) => modelsGroup.addItem(model.slashCommand, {
118
  label: `${model.name} ${model.size ? `(${model.size})` : ''}`,
119
  icon: '🤖',
120
- onRun: () => { if (onSlashCommand) onSlashCommand(model.id); }
 
 
121
  }));
122
  }
123
  });
124
- // await in case addFeature returns a promise (some implementations do async init)
125
- try {
126
- await Promise.resolve(_addFeatureResult);
127
- } catch (e) {
128
- console.warn('[initMilkdown] addFeature promise rejected', e);
129
- }
130
- console.log('[initMilkdown] BlockEdit feature added');
131
- // Non-destructive smoke-test: insert a '/' then remove it to trigger the slash provider
132
- // This helps verify the menu actually shows when the feature is registered.
133
- try {
134
- crepeInput.editor.action((ctx) => {
135
- const view = ctx.get(editorViewCtx);
136
- if (!view) return;
137
- const pos = view.state.selection.from;
138
- try {
139
- view.dispatch(view.state.tr.insertText('/', pos));
140
- console.log('[initMilkdown] probe: inserted slash at', pos);
141
- } catch (e) {
142
- console.warn('[initMilkdown] probe insert failed', e);
143
- }
144
- // Remove the inserted slash shortly after to avoid mutating user content
145
- setTimeout(() => {
146
- try {
147
- crepeInput.editor.action((ctx2) => {
148
- const view2 = ctx2.get(editorViewCtx);
149
- if (!view2) return;
150
- const selFrom = view2.state.selection.from;
151
- // delete the single character if still present at the original position
152
- const delTr = view2.state.tr.delete(pos, pos + 1);
153
- view2.dispatch(delTr);
154
- console.log('[initMilkdown] probe: removed slash at', pos);
155
- });
156
- } catch (e) {
157
- console.warn('[initMilkdown] probe cleanup failed', e);
158
- }
159
- }, 300);
160
- });
161
- } catch (e) {
162
- console.warn('[initMilkdown] probe failed', e);
163
- }
164
- // Trigger a small editor action to ensure the UI acknowledges the new feature
165
- try {
166
- crepeInput.editor.action((ctx) => {
167
- const view = ctx.get(editorViewCtx);
168
- if (view && typeof view.update === 'function') try { view.update(view.state); } catch (e) {}
169
- });
170
- } catch (e) {
171
- // if action fails, ignore
172
- }
173
  } catch (e) {
174
  console.warn('Failed to load models for BlockEdit via worker:', e);
175
- try {
176
- const marker = document.getElementById('models-loaded-indicator');
177
- if (marker && marker.parentNode) marker.parentNode.removeChild(marker);
178
- } catch (ee) {}
179
  }
180
  })();
181
 
 
11
  import { blockEdit } from '@milkdown/crepe/feature/block-edit';
12
  import { commonmark } from '@milkdown/kit/preset/commonmark';
13
 
14
+ import { outputMessage } from './output-message';
15
+
16
  import "@milkdown/crepe/theme/common/style.css";
17
  import "@milkdown/crepe/theme/frame.css";
 
18
 
19
  /**
20
  * @typedef {{
 
46
  const chatLogEditor = await Editor.make()
47
  .config((ctx) => {
48
  ctx.set(rootCtx, chatLog);
 
49
  ctx.set(editorViewOptionsCtx, { editable: () => false });
50
  })
51
  .use(commonmark)
 
56
  root: chatInput,
57
  defaultValue: '',
58
  features: {
 
59
  [Crepe.Feature.BlockEdit]: false,
60
  [Crepe.Feature.Placeholder]: true,
61
  [Crepe.Feature.Cursor]: true,
 
69
  },
70
  featureConfigs: {
71
  [Crepe.Feature.Placeholder]: {
72
+ text: 'Prompt (or /slash for model list)...',
73
  mode: 'block'
74
  }
75
  }
 
80
  // Fetch models in background and add BlockEdit when ready
81
  (async () => {
82
  try {
 
 
 
 
 
83
  const { id, promise, cancel } = await worker.listChatModels({}, undefined);
84
  const out = await promise;
 
85
 
86
  // Normalize possible response shapes
87
  let entries = [];
 
99
  requiresAuth: e.classification === 'auth-protected'
100
  }));
101
 
 
 
102
  outputMessage('Models discovered: **' + availableModels.length + '**');
103
 
104
+ crepeInput.addFeature(blockEdit, {
 
105
  buildMenu: (groupBuilder) => {
106
  const modelsGroup = groupBuilder.addGroup('models', 'Models');
107
  (availableModels || []).forEach((model) => modelsGroup.addItem(model.slashCommand, {
108
  label: `${model.name} ${model.size ? `(${model.size})` : ''}`,
109
  icon: '🤖',
110
+ onRun: () => {
111
+ if (onSlashCommand) onSlashCommand(model.id);
112
+ }
113
  }));
114
  }
115
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  } catch (e) {
117
  console.warn('Failed to load models for BlockEdit via worker:', e);
 
 
 
 
118
  }
119
  })();
120
 
src/app/model-list.js DELETED
@@ -1,395 +0,0 @@
1
- // @ts-check
2
-
3
- import { workerConnection } from './worker-connection.js';
4
-
5
- /**
6
- * @typedef {{
7
- * id: string,
8
- * name: string,
9
- * vendor: string,
10
- * size: string,
11
- * slashCommand: string,
12
- * description: string,
13
- * downloads?: number,
14
- * pipeline_tag?: string,
15
- * requiresAuth?: boolean,
16
- * hasOnnx?: boolean,
17
- * hasTokenizer?: boolean,
18
- * missingFiles?: boolean,
19
- * missingReason?: string
20
- * }} ModelInfo
21
- */
22
-
23
- /**
24
- * Cache for fetched models to avoid repeated API calls
25
- */
26
- let modelCache = null;
27
- let cacheTimestamp = 0;
28
- const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
29
- const STORAGE_KEY = 'localm_models_cache_v1';
30
- const STORAGE_TTL = 24 * 60 * 60 * 1000; // 24 hours for persisted cache
31
-
32
- /**
33
- * Size thresholds for mobile capability (in billions of parameters)
34
- */
35
- const MOBILE_SIZE_THRESHOLD = 15; // Models under 15B are considered mobile-capable
36
-
37
- /**
38
- * Fetch models from Hugging Face Hub with transformers.js compatibility
39
- * @returns {Promise<ModelInfo[]>}
40
- */
41
- export async function fetchBrowserModels(params = {}) {
42
- // Worker-backed implementation: call worker.listChatModels and return final models.
43
- try {
44
- const wc = workerConnection();
45
- const { id, promise, cancel } = await wc.listChatModels(params, /* onProgress */ undefined);
46
- // wait for final result (no caching, no localStorage)
47
- const res = await promise;
48
- // Map worker ModelEntry -> UI ModelInfo minimal shape
49
- const mapped = Array.isArray(res.models ? res.models : res)
50
- ? (res.models || res).map(e => ({
51
- id: e.id,
52
- name: e.name || (e.id || '').split('/').pop(),
53
- vendor: extractVendor(e.id || ''),
54
- size: '',
55
- slashCommand: generateSlashCommand(e.id || ''),
56
- description: '',
57
- pipeline_tag: e.pipeline_tag || null,
58
- requiresAuth: e.classification === 'auth-protected'
59
- }))
60
- : [];
61
- return mapped.length ? mapped : FALLBACK_MODELS;
62
- } catch (err) {
63
- // on error, return small fallback list
64
- console.warn('fetchBrowserModels: worker error, returning fallback', err && err.message ? err.message : err);
65
- return FALLBACK_MODELS;
66
- }
67
- }
68
-
69
- // Small fallback list used when worker fails or times out
70
- const FALLBACK_MODELS = [
71
- { id: 'microsoft/Phi-3-mini-4k-instruct', name: 'Phi-3 Mini', vendor: 'Microsoft', size: '3.8B', slashCommand: 'phi3', description: 'Fallback Phi-3 Mini' },
72
- { id: 'mistralai/Mistral-7B-v0.1', name: 'Mistral 7B', vendor: 'Mistral AI', size: '7.3B', slashCommand: 'mistral', description: 'Fallback Mistral' },
73
- { id: 'Xenova/distilgpt2', name: 'DistilGPT-2', vendor: 'Xenova', size: '82M', slashCommand: 'distilgpt2', description: 'Fallback DistilGPT2' }
74
- ];
75
-
76
- /**
77
- * Check if a model is suitable for mobile/browser use
78
- * @param {any} model - Raw model data from HF API
79
- * @returns {boolean}
80
- */
81
- function isModelMobileCapable(model) {
82
- // Skip if no model ID
83
- if (!model.id) return false;
84
-
85
- // Estimate model size from various indicators
86
- const sizeEstimate = estimateModelSize(model);
87
-
88
- // Skip models that are too large
89
- if (sizeEstimate > MOBILE_SIZE_THRESHOLD) {
90
- return false;
91
- }
92
-
93
- // Prefer models with certain pipeline tags that work well in browsers
94
- const preferredTags = [
95
- 'text-generation',
96
- 'text2text-generation',
97
- 'feature-extraction',
98
- 'sentence-similarity',
99
- 'fill-mask'
100
- ];
101
-
102
- const hasPreferredTag = !model.pipeline_tag || preferredTags.includes(model.pipeline_tag);
103
-
104
- // Skip certain model types that are less suitable for general text generation
105
- const excludePatterns = [
106
- /whisper/i,
107
- /vision/i,
108
- /image/i,
109
- /audio/i,
110
- /translation/i,
111
- /classification/i,
112
- /embedding/i
113
- ];
114
-
115
- const isExcluded = excludePatterns.some(pattern => pattern.test(model.id));
116
-
117
- return hasPreferredTag && !isExcluded;
118
- }
119
-
120
- /**
121
- * Estimate model size in billions of parameters from various indicators
122
- * @param {any} model - Raw model data from HF API
123
- * @returns {number}
124
- */
125
- function estimateModelSize(model) {
126
- const modelId = model.id.toLowerCase();
127
-
128
- // Extract size from model name patterns
129
- const sizePatterns = [
130
- /(\d+\.?\d*)b\b/i, // "7b", "3.8b", etc.
131
- /(\d+)m\b/i, // "125m" -> convert to billions
132
- /(\d+)k\b/i // "125k" -> very small
133
- ];
134
-
135
- for (const pattern of sizePatterns) {
136
- const match = modelId.match(pattern);
137
- if (match) {
138
- const size = parseFloat(match[1]);
139
- if (pattern.source.includes('m\\b')) {
140
- return size / 1000; // Convert millions to billions
141
- } else if (pattern.source.includes('k\\b')) {
142
- return size / 1000000; // Convert thousands to billions
143
- } else {
144
- return size; // Already in billions
145
- }
146
- }
147
- }
148
-
149
- // If no size found in name, make conservative estimates based on model family
150
- if (modelId.includes('gpt2') || modelId.includes('distil')) return 0.2;
151
- if (modelId.includes('phi-1') || modelId.includes('phi1')) return 1.3;
152
- if (modelId.includes('phi-3') || modelId.includes('phi3')) return 3.8;
153
- if (modelId.includes('mistral')) return 7;
154
- if (modelId.includes('qwen') && modelId.includes('3b')) return 3;
155
- if (modelId.includes('qwen') && modelId.includes('7b')) return 7;
156
- if (modelId.includes('llama') && modelId.includes('7b')) return 7;
157
- if (modelId.includes('llama') && modelId.includes('13b')) return 13;
158
-
159
- // Default conservative estimate for unknown models
160
- return 5;
161
- }
162
-
163
- /**
164
- * Process raw model data into our ModelInfo format
165
- * @param {any} model - Raw model data from HF API
166
- * @returns {ModelInfo | null}
167
- */
168
- function processModelData(model) {
169
- try {
170
- const size = estimateModelSize(model);
171
- const vendor = extractVendor(model.id);
172
- const name = extractModelName(model.id);
173
- const slashCommand = generateSlashCommand(model.id);
174
-
175
- return {
176
- id: model.id,
177
- name,
178
- vendor,
179
- size: formatSize(size),
180
- slashCommand,
181
- description: `${formatSize(size)} parameter model from ${vendor}`,
182
- downloads: model.downloads || 0,
183
- pipeline_tag: model.pipeline_tag
184
- };
185
- } catch (error) {
186
- console.warn(`Failed to process model ${model.id}:`, error);
187
- return null;
188
- }
189
- }
190
-
191
- /**
192
- * Extract vendor/organization from model ID
193
- * @param {string} modelId
194
- * @returns {string}
195
- */
196
- function extractVendor(modelId) {
197
- const parts = modelId.split('/');
198
- if (parts.length > 1) {
199
- const org = parts[0];
200
- // Map known organizations to friendly names
201
- const orgMap = {
202
- 'microsoft': 'Microsoft',
203
- 'mistralai': 'Mistral AI',
204
- 'Qwen': 'Alibaba',
205
- 'google': 'Google',
206
- 'openai-community': 'OpenAI',
207
- 'Xenova': 'Xenova',
208
- 'meta-llama': 'Meta',
209
- 'onnx-community': 'ONNX Community'
210
- };
211
- return orgMap[org] || org;
212
- }
213
- return 'Unknown';
214
- }
215
-
216
- /**
217
- * Extract clean model name from full ID
218
- * @param {string} modelId
219
- * @returns {string}
220
- */
221
- function extractModelName(modelId) {
222
- const parts = modelId.split('/');
223
- const name = parts[parts.length - 1];
224
-
225
- // Clean up common patterns
226
- return name
227
- .replace(/-ONNX$/, '')
228
- .replace(/-onnx$/, '')
229
- .replace(/-instruct$/, '')
230
- .replace(/-chat$/, '')
231
- .replace(/^Xenova-/, '')
232
- .replace(/-/g, ' ')
233
- .replace(/\b\w/g, l => l.toUpperCase()); // Title case
234
- }
235
-
236
- /**
237
- * Generate a slash command from model ID
238
- * @param {string} modelId
239
- * @returns {string}
240
- */
241
- function generateSlashCommand(modelId) {
242
- const name = (modelId.split('/').pop() || modelId).toLowerCase();
243
-
244
- // Create short, memorable commands
245
- if (name.includes('phi-3') || name.includes('phi3')) return 'phi3';
246
- if (name.includes('phi-1') || name.includes('phi1')) return 'phi1';
247
- if (name.includes('mistral')) return 'mistral';
248
- if (name.includes('qwen') && name.includes('3b')) return 'qwen3b';
249
- if (name.includes('qwen') && name.includes('7b')) return 'qwen7b';
250
- if (name.includes('qwen')) return 'qwen';
251
- if (name.includes('gpt2')) return 'gpt2';
252
- if (name.includes('distilgpt2')) return 'distilgpt2';
253
- if (name.includes('llama')) return 'llama';
254
- if (name.includes('gemma')) return 'gemma';
255
- if (name.includes('flan')) return 'flant5';
256
-
257
- // Generate from first few characters of model name
258
- const clean = name.replace(/[^a-z0-9]/g, '');
259
- return clean.substring(0, 8);
260
- }
261
-
262
- /**
263
- * Format size number for display
264
- * @param {number} size
265
- * @returns {string}
266
- */
267
- function formatSize(size) {
268
- if (size < 1) {
269
- return `${Math.round(size * 1000)}M`;
270
- } else {
271
- return `${size.toFixed(1)}B`;
272
- }
273
- }
274
-
275
- /**
276
- * Detect if the model repository includes necessary runtime files.
277
- * Uses 'siblings' list available when calling Hugging Face API with full=true.
278
- * @param {any} model
279
- * @returns {{hasOnnx:boolean, hasTokenizer:boolean, missingFiles:boolean, missingReason:string}}
280
- */
281
- function detectRequiredFiles(model) {
282
- const siblings = Array.isArray(model.siblings) ? model.siblings : [];
283
- const names = siblings.map(s => s.rfilename || s.filename || '');
284
- const hasOnnx = names.some(n => /\.onnx$/i.test(n));
285
- const hasTokenizer = names.some(n => /tokenizer\.json$/i.test(n) || /tokenizer_config\.json$/i.test(n));
286
- const missing = !(hasOnnx && hasTokenizer);
287
- let reason = '';
288
- if (missing) {
289
- if (!hasOnnx && !hasTokenizer) reason = 'Missing ONNX and tokenizer files';
290
- else if (!hasOnnx) reason = 'Missing ONNX files';
291
- else if (!hasTokenizer) reason = 'Missing tokenizer files';
292
- }
293
- return { hasOnnx, hasTokenizer, missingFiles: missing, missingReason: reason };
294
- }
295
-
296
- /**
297
- * Determine if a model supports chat-style inputs/outputs.
298
- * Uses pipeline_tag, tags, and name heuristics as fallback.
299
- * @param {any} model
300
- */
301
- function isModelChatCapable(model) {
302
- if (!model) return false;
303
- const allowedPipelines = new Set([
304
- 'text-generation', 'conversational', 'text2text-generation', 'chat',
305
- 'sentence'
306
- ]);
307
- if (model.pipeline_tag && allowedPipelines.has(model.pipeline_tag)) return true;
308
- // tags array may contain 'conversational' or 'chat'
309
- if (Array.isArray(model.tags)) {
310
- for (const t of model.tags) {
311
- if (typeof t === 'string' && allowedPipelines.has(t)) return true;
312
- }
313
- }
314
- // fallback heuristics in id/name: look for chat, conversational, dialog, instruct
315
- const id = (model.id || '').toLowerCase();
316
- const name = (model.name || '').toLowerCase();
317
- const heuristics = ['chat', 'conversational', 'dialog', 'instruct', 'instruction', 'sentence'];
318
- for (const h of heuristics) {
319
- if (id.includes(h) || name.includes(h)) return true;
320
- }
321
- return false;
322
- }
323
-
324
- /**
325
- * Get fallback models if API fetch fails
326
- * @returns {ModelInfo[]}
327
- */
328
- function getFallbackModels() {
329
- return [
330
- {
331
- id: 'microsoft/Phi-3-mini-4k-instruct',
332
- name: 'Phi-3 Mini',
333
- vendor: 'Microsoft',
334
- size: '3.8B',
335
- slashCommand: 'phi3',
336
- description: 'Exceptional performance-to-size ratio, strong in reasoning and math'
337
- },
338
- {
339
- id: 'mistralai/Mistral-7B-v0.1',
340
- name: 'Mistral 7B',
341
- vendor: 'Mistral AI',
342
- size: '7.3B',
343
- slashCommand: 'mistral',
344
- description: 'Highly efficient, outperforms larger models with innovative architecture'
345
- },
346
- {
347
- id: 'Xenova/distilgpt2',
348
- name: 'DistilGPT-2',
349
- vendor: 'Xenova',
350
- size: '82M',
351
- slashCommand: 'distilgpt2',
352
- description: 'Extremely fast and lightweight for quick prototyping'
353
- },
354
- {
355
- id: 'openai-community/gpt2',
356
- name: 'GPT-2',
357
- vendor: 'OpenAI',
358
- size: '124M',
359
- slashCommand: 'gpt2',
360
- description: 'Foundational model for reliable lightweight text generation'
361
- }
362
- ];
363
- }
364
-
365
- /**
366
- * Get model info by slash command
367
- * @param {string} command - The slash command (e.g., 'phi3')
368
- * @param {ModelInfo[]} [models] - Optional pre-fetched models list
369
- * @returns {Promise<ModelInfo | undefined>}
370
- */
371
- export async function getModelByCommand(command, models) {
372
- const modelList = models || await fetchBrowserModels();
373
- return modelList.find(model => model.slashCommand === command);
374
- }
375
-
376
- /**
377
- * Get model info by ID
378
- * @param {string} id - The model ID
379
- * @param {ModelInfo[]} [models] - Optional pre-fetched models list
380
- * @returns {Promise<ModelInfo | undefined>}
381
- */
382
- export async function getModelById(id, models) {
383
- const modelList = models || await fetchBrowserModels();
384
- return modelList.find(model => model.id === id);
385
- }
386
-
387
- /**
388
- * Get all available slash commands
389
- * @param {ModelInfo[]} [models] - Optional pre-fetched models list
390
- * @returns {Promise<string[]>}
391
- */
392
- export async function getAllSlashCommands(models) {
393
- const modelList = models || await fetchBrowserModels();
394
- return modelList.map(model => model.slashCommand);
395
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/worker/list-chat-models.js CHANGED
@@ -81,18 +81,6 @@ export async function* listChatModelsIterator(params = {}) {
81
  }
82
  }
83
 
84
- async function fetchWithController(url, init = {}) {
85
- const c = new AbortController();
86
- inFlight.add(c);
87
- try {
88
- const merged = Object.assign({}, init, { signal: c.signal });
89
- const resp = await fetch(url, merged);
90
- return resp;
91
- } finally {
92
- inFlight.delete(c);
93
- }
94
- }
95
-
96
  // helper: fetchConfigForModel (tries multiple paths, per-request timeouts & retries)
97
  async function fetchConfigForModel(modelId) {
98
  const urls = [
@@ -106,7 +94,13 @@ export async function* listChatModelsIterator(params = {}) {
106
  const controller = new AbortController();
107
  inFlight.add(controller);
108
  try {
109
- const resp = await fetch(url, { signal: controller.signal, headers: hfToken ? { Authorization: `Bearer ${hfToken}` } : {} });
 
 
 
 
 
 
110
  if (resp.status === 200) {
111
  const json = await resp.json();
112
  counters.configFetch200++;
@@ -192,7 +186,13 @@ export async function* listChatModelsIterator(params = {}) {
192
  let ok = false;
193
  for (let attempt = 0; attempt <= RETRIES && !ok; attempt++) {
194
  try {
195
- const resp = await fetch(url, { headers: hfToken ? { Authorization: `Bearer ${hfToken}` } : {} });
 
 
 
 
 
 
196
  if (resp.status === 429) {
197
  const backoff = BACKOFF_BASE_MS * Math.pow(2, attempt);
198
  await new Promise(r => setTimeout(r, backoff));
 
81
  }
82
  }
83
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  // helper: fetchConfigForModel (tries multiple paths, per-request timeouts & retries)
85
  async function fetchConfigForModel(modelId) {
86
  const urls = [
 
94
  const controller = new AbortController();
95
  inFlight.add(controller);
96
  try {
97
+ const resp = await fetch(
98
+ url,
99
+ {
100
+ signal: controller.signal,
101
+ headers: hfToken ? { Authorization: `Bearer ${hfToken}` } : {},
102
+ cache: 'force-cache'
103
+ });
104
  if (resp.status === 200) {
105
  const json = await resp.json();
106
  counters.configFetch200++;
 
186
  let ok = false;
187
  for (let attempt = 0; attempt <= RETRIES && !ok; attempt++) {
188
  try {
189
+ const resp = await fetch(
190
+ url,
191
+ {
192
+ headers: hfToken ? { Authorization: `Bearer ${hfToken}` } : {},
193
+ cache: 'force-cache'
194
+ }
195
+ );
196
  if (resp.status === 429) {
197
  const backoff = BACKOFF_BASE_MS * Math.pow(2, attempt);
198
  await new Promise(r => setTimeout(r, backoff));