Slash: attempt to fix.
Browse files- package.json +1 -1
- src/app/init-milkdown.js +11 -17
- src/app/model-slash.js +252 -0
- src/worker/boot-worker.js +1 -1
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.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
|
| 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
|
| 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 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
onRun: () => {
|
| 111 |
-
if (onSlashCommand) onSlashCommand(model.id);
|
| 112 |
-
}
|
| 113 |
-
}));
|
| 114 |
}
|
| 115 |
});
|
| 116 |
} catch (e) {
|
| 117 |
-
console.warn('Failed to load models for
|
| 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;
|