mihailik commited on
Commit
4dc14dc
·
1 Parent(s): 99fd943

Worker loading model list tied into the UI.

Browse files
package.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "name": "localm",
3
- "version": "1.1.24",
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.25",
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
@@ -27,11 +27,12 @@ export async function bootApp() {
27
  //outputMessage('Available models: ' + models.join(', '));
28
  });
29
 
30
- const {
31
- chatLogEditor: chatLogEditorInstance,
32
  chatInputEditor: chatInputEditorInstance,
33
- crepeInput
34
  } = await initMilkdown({
 
35
  chatLog,
36
  chatInput,
37
  inputPlugins: makeEnterPlugins({ workerConnection: worker }),
@@ -45,10 +46,10 @@ export async function bootApp() {
45
  }
46
  }
47
  });
48
-
49
  chatLogEditor = chatLogEditorInstance;
50
  chatInputEditor = chatInputEditorInstance;
51
-
52
  // Flush any outputs that were buffered before the editor was ready
53
  flushBufferedOutputs();
54
 
 
27
  //outputMessage('Available models: ' + models.join(', '));
28
  });
29
 
30
+ const {
31
+ chatLogEditor: chatLogEditorInstance,
32
  chatInputEditor: chatInputEditorInstance,
33
+ crepeInput
34
  } = await initMilkdown({
35
+ worker,
36
  chatLog,
37
  chatInput,
38
  inputPlugins: makeEnterPlugins({ workerConnection: worker }),
 
46
  }
47
  }
48
  });
49
+
50
  chatLogEditor = chatLogEditorInstance;
51
  chatInputEditor = chatInputEditorInstance;
52
+
53
  // Flush any outputs that were buffered before the editor was ready
54
  flushBufferedOutputs();
55
 
src/app/init-milkdown.js CHANGED
@@ -10,8 +10,6 @@ import {
10
  import { Crepe } from '@milkdown/crepe';
11
  import { blockEdit } from '@milkdown/crepe/feature/block-edit';
12
  import { commonmark } from '@milkdown/kit/preset/commonmark';
13
- import { slashFactory } from "@milkdown/plugin-slash";
14
- import { fetchBrowserModels } from './model-list.js';
15
 
16
  import "@milkdown/crepe/theme/common/style.css";
17
  import "@milkdown/crepe/theme/frame.css";
@@ -21,7 +19,8 @@ import "@milkdown/crepe/theme/frame.css";
21
  * chatLog: HTMLElement,
22
  * chatInput: HTMLElement,
23
  * inputPlugins?: any[],
24
- * onSlashCommand?: (command: string) => void | boolean | Promise<void | boolean>
 
25
  * }} InitMilkdownOptions
26
  */
27
 
@@ -32,18 +31,14 @@ export async function initMilkdown({
32
  chatLog,
33
  chatInput,
34
  inputPlugins = [], // Keep for backward compatibility but not used for Crepe
35
- onSlashCommand
 
36
  }) {
37
  if (chatLog) chatLog.textContent = 'Loading Milkdown...';
38
 
39
  if (chatLog) chatLog.innerHTML = '';
40
  if (chatInput) chatInput.innerHTML = '';
41
 
42
- // Fetch available models for slash menu
43
- console.log('Starting to fetch browser models...');
44
- const availableModels = await fetchBrowserModels();
45
- console.log(`Loaded ${availableModels.length} models for slash menu`);
46
- console.log('Available models:', availableModels);
47
 
48
  // Create read-only editor in .chat-log
49
  const chatLogEditor = await Editor.make()
@@ -61,12 +56,11 @@ export async function initMilkdown({
61
  defaultValue: '',
62
  features: {
63
  // Do NOT enable BlockEdit here; we'll add it later after models load
 
64
  [Crepe.Feature.Placeholder]: true,
65
  [Crepe.Feature.Cursor]: true,
66
- [Crepe.Feature.BlockEdit]: false,
67
  [Crepe.Feature.ListItem]: true,
68
  [Crepe.Feature.CodeMirror]: true,
69
- // Disable features not needed for chat input
70
  [Crepe.Feature.ImageBlock]: true,
71
  [Crepe.Feature.Table]: true,
72
  [Crepe.Feature.Latex]: true,
@@ -80,25 +74,28 @@ export async function initMilkdown({
80
  }
81
  }
82
  });
 
 
 
 
 
 
 
 
 
 
83
 
84
- // Dynamically add BlockEdit feature now that models are available.
85
- crepeInput.addFeature(blockEdit, {
86
- // Provide only a single 'models' group populated from availableModels
87
- buildMenu: (groupBuilder) => {
88
- const modelsGroup = groupBuilder.addGroup('models', 'Models');
89
- availableModels.forEach((model) => {
90
- modelsGroup.addItem(model.slashCommand, {
91
- label: `${model.name} (${model.size})`,
92
- icon: '🤖',
93
- onRun: () => {
94
- if (onSlashCommand) onSlashCommand(model.id);
95
- }
96
- });
97
  });
 
 
98
  }
99
- });
100
-
101
- const chatInputEditor = await crepeInput.create();
102
 
103
  // Auto-focus the Crepe input editor when ready
104
  try {
 
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";
 
19
  * chatLog: HTMLElement,
20
  * chatInput: HTMLElement,
21
  * inputPlugins?: any[],
22
+ * onSlashCommand?: (command: string) => void | boolean | Promise<void | boolean>,
23
+ * worker?: any
24
  * }} InitMilkdownOptions
25
  */
26
 
 
31
  chatLog,
32
  chatInput,
33
  inputPlugins = [], // Keep for backward compatibility but not used for Crepe
34
+ onSlashCommand,
35
+ worker
36
  }) {
37
  if (chatLog) chatLog.textContent = 'Loading Milkdown...';
38
 
39
  if (chatLog) chatLog.innerHTML = '';
40
  if (chatInput) chatInput.innerHTML = '';
41
 
 
 
 
 
 
42
 
43
  // Create read-only editor in .chat-log
44
  const chatLogEditor = await Editor.make()
 
56
  defaultValue: '',
57
  features: {
58
  // Do NOT enable BlockEdit here; we'll add it later after models load
59
+ [Crepe.Feature.BlockEdit]: false,
60
  [Crepe.Feature.Placeholder]: true,
61
  [Crepe.Feature.Cursor]: true,
 
62
  [Crepe.Feature.ListItem]: true,
63
  [Crepe.Feature.CodeMirror]: true,
 
64
  [Crepe.Feature.ImageBlock]: true,
65
  [Crepe.Feature.Table]: true,
66
  [Crepe.Feature.Latex]: true,
 
74
  }
75
  }
76
  });
77
+ // Create input editor immediately so the UI is responsive.
78
+ const chatInputEditor = await crepeInput.create();
79
+
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
+ const entries = Array.isArray(out.models ? out.models : out) ? (out.models || out) : [];
86
+ const availableModels = entries.map(e => ({ id: e.id, name: e.name || (e.id || '').split('/').pop(), size: '', slashCommand: (e.id || '').split('/').pop(), pipeline_tag: e.pipeline_tag || null, requiresAuth: e.classification === 'auth-protected' }));
87
 
88
+ // Add BlockEdit feature now that models are available
89
+ crepeInput.addFeature(blockEdit, {
90
+ buildMenu: (groupBuilder) => {
91
+ const modelsGroup = groupBuilder.addGroup('models', 'Models');
92
+ (availableModels || []).forEach((model) => modelsGroup.addItem(model.slashCommand, { label: `${model.name} ${model.size ? `(${model.size})` : ''}`, icon: '🤖', onRun: () => { if (onSlashCommand) onSlashCommand(model.id); } }));
93
+ }
 
 
 
 
 
 
 
94
  });
95
+ } catch (e) {
96
+ console.warn('Failed to load models for BlockEdit via worker:', e);
97
  }
98
+ })();
 
 
99
 
100
  // Auto-focus the Crepe input editor when ready
101
  try {
src/app/model-list.js CHANGED
@@ -1,6 +1,6 @@
1
  // @ts-check
2
 
3
- import fallbackModels from '../model-cache-filtered.json';
4
 
5
  /**
6
  * @typedef {{
@@ -38,125 +38,41 @@ const MOBILE_SIZE_THRESHOLD = 15; // Models under 15B are considered mobile-capa
38
  * Fetch models from Hugging Face Hub with transformers.js compatibility
39
  * @returns {Promise<ModelInfo[]>}
40
  */
41
- export async function fetchBrowserModels() {
42
- // Check cache first
43
- const now = Date.now();
44
- if (modelCache && (now - cacheTimestamp) < CACHE_DURATION) {
45
- return modelCache;
46
- }
47
-
48
  try {
49
- console.log('Fetching transformers.js compatible models from Hugging Face Hub in batches...');
50
-
51
- const batchSize = 1000;
52
- const batchCount = 5; // 5 consecutive batches of 1000
53
- let allRaw = [];
54
-
55
- for (let i = 0; i < batchCount; i++) {
56
- const skip = i * batchSize;
57
- const url = `https://huggingface.co/api/models?library=transformers.js&sort=downloads&direction=-1&limit=${batchSize}&skip=${skip}&full=true`;
58
- try {
59
- // fetch sequentially to avoid surprises with HF rate limits
60
- // eslint-disable-next-line no-await-in-loop
61
- const res = await fetch(url);
62
- if (!res.ok) {
63
- console.warn(`HF batch ${i + 1} returned ${res.status}; stopping further batches`);
64
- break;
65
- }
66
- // eslint-disable-next-line no-await-in-loop
67
- const batch = await res.json();
68
- if (!Array.isArray(batch) || batch.length === 0) {
69
- console.log(`HF batch ${i + 1} returned 0 models; stopping`);
70
- break;
71
- }
72
- console.log(`batch ${i + 1} -> ${batch.length} models`);
73
- allRaw = allRaw.concat(batch);
74
- if (batch.length < batchSize) break; // last page
75
- } catch (err) {
76
- console.warn(`Error fetching HF batch ${i + 1}:`, err);
77
- break;
78
- }
79
- }
80
-
81
- // dedupe by id
82
- const seen = new Set();
83
- const dedup = allRaw.filter(m => m && m.id && (!seen.has(m.id) ? (seen.add(m.id) || true) : false));
84
- console.log(`fetched unique ${dedup.length} models`);
85
-
86
- // Process models: detect required files (ONNX + tokenizer), determine gating
87
- const processed = dedup.map(m => {
88
- try {
89
- const { hasOnnx, hasTokenizer, missingFiles, missingReason } = detectRequiredFiles(m);
90
- const requiresAuth = Boolean(m.gated || m.private || (m.cardData && (m.cardData.gated || m.cardData.private)));
91
- const base = processModelData(m);
92
- if (!base) return null;
93
- return Object.assign({}, base, {
94
- requiresAuth: !!requiresAuth,
95
- hasOnnx: !!hasOnnx,
96
- hasTokenizer: !!hasTokenizer,
97
- missingFiles: !!missingFiles,
98
- missingReason: missingReason || '',
99
- downloads: m.downloads || 0,
100
- tags: Array.isArray(m.tags) ? m.tags.slice() : []
101
- });
102
- } catch (e) {
103
- return null;
104
- }
105
- }).filter(m => m !== null);
106
-
107
- // Keep only models that have both ONNX and tokenizer files AND support chat
108
- const withFiles = processed.filter(p => p && p.hasOnnx && p.hasTokenizer && isModelChatCapable(p));
109
-
110
- // Sort by downloads desc
111
- withFiles.sort((a, b) => ((b && b.downloads) || 0) - ((a && a.downloads) || 0));
112
-
113
- const auth = withFiles.filter(m => m && m.requiresAuth).slice(0, 10).map(x => x);
114
- const pub = withFiles.filter(m => m && !m.requiresAuth).slice(0, 10).map(x => x);
115
-
116
- const final = [...auth, ...pub];
117
-
118
- modelCache = final;
119
- cacheTimestamp = now;
120
- // Persist filtered list to localStorage as a fallback for offline or HF failures
121
- try {
122
- if (typeof localStorage !== 'undefined') {
123
- const payload = JSON.stringify({ ts: now, models: final });
124
- localStorage.setItem(STORAGE_KEY, payload);
125
- }
126
- } catch (e) {
127
- // ignore storage errors
128
- }
129
-
130
- console.log(`Selected ${auth.length} auth + ${pub.length} public models (total ${final.length})`);
131
- if (final.length) return final;
132
- } catch (error) {
133
- console.error('Failed to fetch models from Hugging Face Hub:', error);
134
- // Try to restore from persisted cache before returning static fallback
135
- }
136
- try {
137
- if (typeof localStorage !== 'undefined') {
138
- const raw = localStorage.getItem(STORAGE_KEY);
139
- if (raw) {
140
- const parsed = JSON.parse(raw);
141
- if (parsed && Array.isArray(parsed.models)) {
142
- const age = Date.now() - (parsed.ts || 0);
143
- if (age < STORAGE_TTL) {
144
- console.warn('Restoring models from localStorage cache (age ' + Math.round(age / 1000) + 's)');
145
- modelCache = parsed.models;
146
- cacheTimestamp = Date.now();
147
- return modelCache;
148
- }
149
- }
150
- }
151
- }
152
- } catch (e) {
153
- // ignore parse/storage errors
154
  }
155
-
156
- // Return fallback models if API fails and no persisted cache
157
- return fallbackModels;
158
  }
159
 
 
 
 
 
 
 
 
160
  /**
161
  * Check if a model is suitable for mobile/browser use
162
  * @param {any} model - Raw model data from HF API
 
1
  // @ts-check
2
 
3
+ import { workerConnection } from './worker-connection.js';
4
 
5
  /**
6
  * @typedef {{
 
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
src/app/worker-connection.js CHANGED
@@ -8,9 +8,9 @@ export function workerConnection() {
8
  const connection = {
9
  loaded: workerLoaded.then(worker => ({ env: worker.env })),
10
  listModels,
11
- loadModel,
12
- runPrompt,
13
- listChatModels
14
  };
15
 
16
  return connection;
@@ -88,18 +88,27 @@ export function workerConnection() {
88
  */
89
  async function listChatModels(params = {}, onProgress) {
90
  await workerLoaded;
91
- const { send, pending, worker } = await workerLoaded;
92
- return new Promise((resolve, reject) => {
93
- const id = String(Math.random()).slice(2);
94
- pending.set(id, { resolve, reject, onProgress });
95
- const msg = Object.assign({}, params, { type: 'listChatModels', id });
96
  try {
97
- worker.postMessage(msg);
98
  } catch (err) {
99
  pending.delete(id);
100
- return reject(err);
101
  }
102
  });
 
 
 
 
 
 
 
 
 
103
  }
104
 
105
  /** @param {string} modelName */
 
8
  const connection = {
9
  loaded: workerLoaded.then(worker => ({ env: worker.env })),
10
  listModels,
11
+ loadModel,
12
+ runPrompt,
13
+ listChatModels
14
  };
15
 
16
  return connection;
 
88
  */
89
  async function listChatModels(params = {}, onProgress) {
90
  await workerLoaded;
91
+ const { worker, pending } = await workerLoaded;
92
+ const id = String(Math.random()).slice(2);
93
+ let resolved = false;
94
+ const promise = new Promise((resolve, reject) => {
95
+ pending.set(id, { resolve: (res) => { resolved = true; resolve(res); }, reject, onProgress });
96
  try {
97
+ worker.postMessage(Object.assign({}, params, { type: 'listChatModels', id }));
98
  } catch (err) {
99
  pending.delete(id);
100
+ reject(err);
101
  }
102
  });
103
+
104
+ const cancel = () => {
105
+ try {
106
+ if (!resolved) worker.postMessage({ type: 'cancelListChatModels', id });
107
+ } catch (e) {}
108
+ pending.delete(id);
109
+ };
110
+
111
+ return { id, promise, cancel };
112
  }
113
 
114
  /** @param {string} modelName */
src/worker/boot-worker.js CHANGED
@@ -101,15 +101,16 @@ export function bootWorker() {
101
  activeTasks.set(id, { abort: () => { try { iterator.return(); } catch (e) {} } });
102
  try {
103
  for await (const delta of iterator) {
 
104
  try { enqueueProgress(delta); } catch (e) {}
105
  if (delta && delta.status === 'done') {
106
  sawDone = true;
107
  // flush any remaining progress messages synchronously
108
  try { flushBatch(); } catch (e) {}
109
  try { self.postMessage({ id, type: 'response', result: { models: delta.models, meta: delta.meta } }); } catch (e) {}
110
- break;
111
  }
112
  }
 
113
  if (!sawDone) {
114
  // iterator exited early (likely cancelled)
115
  try { flushBatch(); } catch (e) {}
 
101
  activeTasks.set(id, { abort: () => { try { iterator.return(); } catch (e) {} } });
102
  try {
103
  for await (const delta of iterator) {
104
+ console.info('loading ', delta);
105
  try { enqueueProgress(delta); } catch (e) {}
106
  if (delta && delta.status === 'done') {
107
  sawDone = true;
108
  // flush any remaining progress messages synchronously
109
  try { flushBatch(); } catch (e) {}
110
  try { self.postMessage({ id, type: 'response', result: { models: delta.models, meta: delta.meta } }); } catch (e) {}
 
111
  }
112
  }
113
+
114
  if (!sawDone) {
115
  // iterator exited early (likely cancelled)
116
  try { flushBatch(); } catch (e) {}
src/worker/list-chat-models.js CHANGED
@@ -6,7 +6,7 @@ export async function* listChatModelsIterator(params = {}) {
6
  const opts = Object.assign({ maxCandidates: 250, concurrency: 12, hfToken: null, timeoutMs: 10000, maxListing: 5000 }, params || {});
7
  const { maxCandidates, concurrency, hfToken, timeoutMs, maxListing } = opts;
8
  const MAX_TOTAL_TO_FETCH = Math.min(maxListing, 5000);
9
- const PAGE_SIZE = 100;
10
  const RETRIES = 3;
11
  const BACKOFF_BASE_MS = 200;
12
 
 
6
  const opts = Object.assign({ maxCandidates: 250, concurrency: 12, hfToken: null, timeoutMs: 10000, maxListing: 5000 }, params || {});
7
  const { maxCandidates, concurrency, hfToken, timeoutMs, maxListing } = opts;
8
  const MAX_TOTAL_TO_FETCH = Math.min(maxListing, 5000);
9
+ const PAGE_SIZE = 1000;
10
  const RETRIES = 3;
11
  const BACKOFF_BASE_MS = 200;
12