Faster fetching of model list (with caching).
Browse files- package.json +1 -1
- src/app/boot-app.js +2 -2
- src/app/init-milkdown.js +7 -68
- src/app/model-list.js +0 -395
- src/worker/list-chat-models.js +14 -14
package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"name": "localm",
|
| 3 |
-
"version": "1.1.
|
| 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: '
|
| 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 |
-
|
| 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: () => {
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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));
|