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

Slash: attempt to fix.

Browse files
package.json CHANGED
@@ -1,6 +1,6 @@
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",
 
1
  {
2
  "name": "localm",
3
+ "version": "1.1.29",
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/init-milkdown.js CHANGED
@@ -8,9 +8,9 @@ import {
8
  rootCtx
9
  } from '@milkdown/core';
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 { outputMessage } from './output-message';
15
 
16
  import "@milkdown/crepe/theme/common/style.css";
@@ -69,15 +69,15 @@ export async function initMilkdown({
69
  },
70
  featureConfigs: {
71
  [Crepe.Feature.Placeholder]: {
72
- text: 'Prompt (or /slash for model list)...',
73
  mode: 'block'
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);
@@ -94,27 +94,21 @@ export async function initMilkdown({
94
  id: e.id || e.modelId || '',
95
  name: e.name || (e.id || e.modelId || '').split('/').pop(),
96
  size: '',
97
- slashCommand: (e.id || e.modelId || '').split('/').pop(),
98
- pipeline_tag: e.pipeline_tag || null,
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
 
 
8
  rootCtx
9
  } from '@milkdown/core';
10
  import { Crepe } from '@milkdown/crepe';
 
11
  import { commonmark } from '@milkdown/kit/preset/commonmark';
12
 
13
+ import { addModelSlashToCrepe, modelSlash } from './model-slash';
14
  import { outputMessage } from './output-message';
15
 
16
  import "@milkdown/crepe/theme/common/style.css";
 
69
  },
70
  featureConfigs: {
71
  [Crepe.Feature.Placeholder]: {
72
+ text: 'Prompt or /model...',
73
  mode: 'block'
74
  }
75
  }
76
  });
77
  // Create input editor immediately so the UI is responsive.
78
+ const chatInputEditor = (await crepeInput.create()).use(modelSlash);
79
 
80
+ // Fetch models in background and add model slash plugin when ready
81
  (async () => {
82
  try {
83
  const { id, promise, cancel } = await worker.listChatModels({}, undefined);
 
94
  id: e.id || e.modelId || '',
95
  name: e.name || (e.id || e.modelId || '').split('/').pop(),
96
  size: '',
 
 
97
  requiresAuth: e.classification === 'auth-protected'
98
  }));
99
 
100
  outputMessage('Models discovered: **' + availableModels.length + '**');
101
 
102
+ // Add model slash plugin to the editor
103
+ await addModelSlashToCrepe(crepeInput, availableModels, {
104
+ onSlashCommand: (modelId) => {
105
+ if (onSlashCommand) {
106
+ return onSlashCommand(modelId);
107
+ }
 
 
 
 
108
  }
109
  });
110
  } catch (e) {
111
+ console.warn('Failed to load models for slash plugin via worker:', e);
112
  }
113
  })();
114
 
src/app/model-slash.js ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ /**
4
+ * Model Slash Plugin for Milkdown Crepe
5
+ *
6
+ * This module implements a custom slash command interface for model selection
7
+ * using Milkdown's slash plugin instead of the built-in block edit feature.
8
+ *
9
+ * Features:
10
+ * - Custom slash menu UI with model icons and metadata
11
+ * - Support for auth-required models with visual indicators
12
+ * - Keyboard and mouse navigation
13
+ * - Async command execution with error handling
14
+ * - Dynamic model list updates
15
+ */
16
+
17
+ import { slashFactory, SlashProvider } from '@milkdown/plugin-slash';
18
+ import { editorViewCtx, prosePluginsCtx } from '@milkdown/core';
19
+
20
+ /**
21
+ * @typedef {{
22
+ * id: string,
23
+ * name: string,
24
+ * size?: string,
25
+ * requiresAuth?: boolean
26
+ * }} ModelInfo
27
+ */
28
+
29
+ /**
30
+ * @typedef {{
31
+ * onSlashCommand?: (modelId: string) => void | boolean | Promise<void | boolean>
32
+ * }} ModelSlashOptions
33
+ */
34
+
35
+ // Create the slash plugin factory
36
+ export const modelSlash = slashFactory('ModelCommands');
37
+
38
+ /**
39
+ * Creates a slash provider and DOM content for model selection
40
+ * @param {ModelInfo[]} availableModels
41
+ * @param {ModelSlashOptions} options
42
+ * @returns {{provider: SlashProvider, commands: Array}}
43
+ */
44
+ export function createModelSlashProvider(availableModels, options = {}) {
45
+ const { onSlashCommand } = options;
46
+
47
+ // Create the DOM content for the menu
48
+ const content = document.createElement('div');
49
+ content.className = "slash-menu";
50
+ content.style.cssText = `
51
+ position: absolute;
52
+ padding: 4px 0;
53
+ background: white;
54
+ border: 1px solid #eee;
55
+ box-shadow: 0 2px 8px rgba(0,0,0,.15);
56
+ border-radius: 6px;
57
+ font-size: 14px;
58
+ max-height: 256px;
59
+ overflow-y: auto;
60
+ min-width: 256px;
61
+ z-index: 50;
62
+ `;
63
+
64
+ // Create header if there are models
65
+ if (availableModels.length > 0) {
66
+ const header = document.createElement('div');
67
+ header.style.cssText = "padding: 8px 12px; font-weight: 600; color: #666; border-bottom: 1px solid #eee; background: #f9f9f9; font-size: 12px;";
68
+ header.textContent = `Models (${availableModels.length})`;
69
+ content.appendChild(header);
70
+ }
71
+
72
+ // Create command list from available models
73
+ const commands = availableModels.map(model => ({
74
+ id: model.id,
75
+ text: model.name,
76
+ subtitle: model.size ? `(${model.size})` : '',
77
+ icon: model.requiresAuth ? '🔒' : '🤖',
78
+ model: model,
79
+ onSelect: async (view) => {
80
+ // Remove the slash character and any typed text
81
+ const { dispatch, state } = view;
82
+ const { tr, selection } = state;
83
+ const { from } = selection;
84
+
85
+ // Find the start of the slash command
86
+ const textBefore = state.doc.textBetween(Math.max(0, from - 20), from);
87
+ const slashIndex = textBefore.lastIndexOf('/');
88
+ const deleteFrom = from - (textBefore.length - slashIndex);
89
+
90
+ dispatch(tr.deleteRange(deleteFrom, from));
91
+ view.focus();
92
+
93
+ // Execute the model selection
94
+ if (onSlashCommand) {
95
+ try {
96
+ await onSlashCommand(model.id);
97
+ } catch (error) {
98
+ console.error('Error executing slash command:', error);
99
+ }
100
+ }
101
+ }
102
+ }));
103
+
104
+ // Create the list element
105
+ const list = document.createElement('ul');
106
+ list.style.cssText = "margin: 0; padding: 0; list-style: none;";
107
+
108
+ // Add fallback message if no models
109
+ if (commands.length === 0) {
110
+ const noModels = document.createElement('li');
111
+ noModels.style.cssText = "padding: 16px 12px; color: #666; text-align: center; font-style: italic;";
112
+ noModels.textContent = "No models available";
113
+ list.appendChild(noModels);
114
+ } else {
115
+ // Create menu items
116
+ commands.forEach((command, index) => {
117
+ const item = document.createElement('li');
118
+ item.style.cssText = "padding: 8px 12px; cursor: pointer; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid #f0f0f0;";
119
+ item.dataset.modelId = command.id;
120
+
121
+ // Create icon span
122
+ const iconSpan = document.createElement('span');
123
+ iconSpan.textContent = command.icon;
124
+ iconSpan.style.fontSize = "16px";
125
+
126
+ // Create text container
127
+ const textContainer = document.createElement('div');
128
+ textContainer.style.cssText = "flex: 1; min-width: 0;";
129
+
130
+ const nameDiv = document.createElement('div');
131
+ nameDiv.textContent = command.text;
132
+ nameDiv.style.cssText = "font-weight: 500; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;";
133
+
134
+ textContainer.appendChild(nameDiv);
135
+
136
+ if (command.subtitle) {
137
+ const subtitleDiv = document.createElement('div');
138
+ subtitleDiv.textContent = command.subtitle;
139
+ subtitleDiv.style.cssText = "font-size: 12px; color: #666;";
140
+ textContainer.appendChild(subtitleDiv);
141
+ }
142
+
143
+ item.appendChild(iconSpan);
144
+ item.appendChild(textContainer);
145
+
146
+ // Add auth indicator if needed
147
+ if (command.model.requiresAuth) {
148
+ const authSpan = document.createElement('span');
149
+ authSpan.textContent = "Auth Required";
150
+ authSpan.style.cssText = "font-size: 10px; color: #d97706; background: #fef3c7; padding: 2px 6px; border-radius: 4px;";
151
+ item.appendChild(authSpan);
152
+ }
153
+
154
+ // Add hover effects
155
+ item.addEventListener('mouseenter', () => {
156
+ item.style.background = '#f0f9ff';
157
+ });
158
+
159
+ item.addEventListener('mouseleave', () => {
160
+ item.style.background = '';
161
+ });
162
+
163
+ list.appendChild(item);
164
+ });
165
+ }
166
+
167
+ content.appendChild(list);
168
+
169
+ // Store current view reference for click handlers
170
+ let currentView = null;
171
+
172
+ // Create click handler for the menu
173
+ content.addEventListener('click', (e) => {
174
+ const target = /** @type {HTMLElement} */ (e.target);
175
+ if (!target) return;
176
+
177
+ const item = target.closest('li[data-model-id]');
178
+ if (item && item instanceof HTMLElement) {
179
+ const modelId = item.dataset.modelId;
180
+ const command = commands.find(c => c.id === modelId);
181
+ if (command && command.onSelect && currentView) {
182
+ command.onSelect(currentView);
183
+ }
184
+ }
185
+ });
186
+
187
+ // Create the slash provider
188
+ const provider = new SlashProvider({
189
+ content,
190
+ shouldShow(view) {
191
+ currentView = view; // Store current view for click handlers
192
+ const content = provider.getContent(view);
193
+ return content?.endsWith('/') ?? false;
194
+ },
195
+ offset: 8,
196
+ });
197
+
198
+ return { provider, commands };
199
+ }
200
+
201
+ /**
202
+ * Adds the model slash plugin to a Crepe editor
203
+ * @param {any} crepeEditor - The Crepe editor instance
204
+ * @param {ModelInfo[]} availableModels
205
+ * @param {ModelSlashOptions} options
206
+ */
207
+ export async function addModelSlashToCrepe(crepeEditor, availableModels, options = {}) {
208
+ try {
209
+ const { provider } = createModelSlashProvider(availableModels, options);
210
+
211
+ await crepeEditor.editor.action((ctx) => {
212
+ // Configure slash plugin with the provider
213
+ ctx.set(modelSlash.key, {
214
+ view: () => ({
215
+ update: provider.update.bind(provider),
216
+ destroy: provider.destroy.bind(provider),
217
+ }),
218
+ });
219
+
220
+ // Add the slash plugin to prose plugins
221
+ ctx.update(prosePluginsCtx, (plugins) => [...plugins, modelSlash]);
222
+ });
223
+ } catch (error) {
224
+ console.error('Failed to add model slash plugin:', error);
225
+ throw error;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Updates the available models in an existing slash plugin
231
+ * @param {any} crepeEditor - The Crepe editor instance
232
+ * @param {ModelInfo[]} availableModels
233
+ * @param {ModelSlashOptions} options
234
+ */
235
+ export async function updateModelSlash(crepeEditor, availableModels, options = {}) {
236
+ try {
237
+ const { provider } = createModelSlashProvider(availableModels, options);
238
+
239
+ await crepeEditor.editor.action((ctx) => {
240
+ // Update the slash plugin configuration with new provider
241
+ ctx.set(modelSlash.key, {
242
+ view: () => ({
243
+ update: provider.update.bind(provider),
244
+ destroy: provider.destroy.bind(provider),
245
+ }),
246
+ });
247
+ });
248
+ } catch (error) {
249
+ console.error('Failed to update model slash plugin:', error);
250
+ throw error;
251
+ }
252
+ }
src/worker/boot-worker.js CHANGED
@@ -85,6 +85,7 @@ export function bootWorker() {
85
  function flushBatch() {
86
  if (!batchBuffer || batchBuffer.length === 0) return;
87
  try {
 
88
  self.postMessage({ id, type: 'progress', batch: true, items: batchBuffer.splice(0) });
89
  } catch (e) {}
90
  if (batchTimer) { clearTimeout(batchTimer); batchTimer = null; }
@@ -101,7 +102,6 @@ export function bootWorker() {
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;
 
85
  function flushBatch() {
86
  if (!batchBuffer || batchBuffer.length === 0) return;
87
  try {
88
+ console.log('Loading: ', batchBuffer[batchBuffer.length - 1]);
89
  self.postMessage({ id, type: 'progress', batch: true, items: batchBuffer.splice(0) });
90
  } catch (e) {}
91
  if (batchTimer) { clearTimeout(batchTimer); batchTimer = null; }
 
102
  activeTasks.set(id, { abort: () => { try { iterator.return(); } catch (e) {} } });
103
  try {
104
  for await (const delta of iterator) {
 
105
  try { enqueueProgress(delta); } catch (e) {}
106
  if (delta && delta.status === 'done') {
107
  sawDone = true;