lemonade-sdk 8.1.10__py3-none-any.whl → 8.1.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of lemonade-sdk might be problematic. Click here for more details.

Files changed (30) hide show
  1. lemonade/tools/flm/__init__.py +1 -0
  2. lemonade/tools/flm/utils.py +255 -0
  3. lemonade/tools/llamacpp/utils.py +58 -10
  4. lemonade/tools/server/flm.py +137 -0
  5. lemonade/tools/server/llamacpp.py +23 -5
  6. lemonade/tools/server/serve.py +260 -135
  7. lemonade/tools/server/static/js/chat.js +165 -82
  8. lemonade/tools/server/static/js/models.js +87 -54
  9. lemonade/tools/server/static/js/shared.js +5 -3
  10. lemonade/tools/server/static/logs.html +47 -0
  11. lemonade/tools/server/static/styles.css +159 -8
  12. lemonade/tools/server/static/webapp.html +28 -10
  13. lemonade/tools/server/tray.py +94 -38
  14. lemonade/tools/server/utils/macos_tray.py +226 -0
  15. lemonade/tools/server/utils/{system_tray.py → windows_tray.py} +13 -0
  16. lemonade/tools/server/webapp.py +4 -1
  17. lemonade/tools/server/wrapped_server.py +91 -25
  18. lemonade/version.py +1 -1
  19. lemonade_install/install.py +25 -2
  20. {lemonade_sdk-8.1.10.dist-info → lemonade_sdk-8.1.11.dist-info}/METADATA +9 -6
  21. {lemonade_sdk-8.1.10.dist-info → lemonade_sdk-8.1.11.dist-info}/RECORD +30 -25
  22. lemonade_server/cli.py +103 -14
  23. lemonade_server/model_manager.py +186 -45
  24. lemonade_server/pydantic_models.py +25 -1
  25. lemonade_server/server_models.json +162 -62
  26. {lemonade_sdk-8.1.10.dist-info → lemonade_sdk-8.1.11.dist-info}/WHEEL +0 -0
  27. {lemonade_sdk-8.1.10.dist-info → lemonade_sdk-8.1.11.dist-info}/entry_points.txt +0 -0
  28. {lemonade_sdk-8.1.10.dist-info → lemonade_sdk-8.1.11.dist-info}/licenses/LICENSE +0 -0
  29. {lemonade_sdk-8.1.10.dist-info → lemonade_sdk-8.1.11.dist-info}/licenses/NOTICE.md +0 -0
  30. {lemonade_sdk-8.1.10.dist-info → lemonade_sdk-8.1.11.dist-info}/top_level.txt +0 -0
@@ -2,6 +2,7 @@
2
2
  let messages = [];
3
3
  let attachedFiles = [];
4
4
  let systemMessageElement = null;
5
+ let abortController = null;
5
6
 
6
7
  // Default model configuration
7
8
  const DEFAULT_MODEL = 'Qwen2.5-0.5B-Instruct-CPU';
@@ -14,13 +15,18 @@ const THINKING_FRAMES = THINKING_USE_LEMON
14
15
  : ['Thinking.','Thinking..','Thinking...'];
15
16
 
16
17
  // Get DOM elements
17
- let chatHistory, chatInput, sendBtn, attachmentBtn, fileAttachment, attachmentsPreviewContainer, attachmentsPreviewRow, modelSelect;
18
+ let chatHistory, chatInput, attachmentBtn, fileAttachment, attachmentsPreviewContainer, attachmentsPreviewRow, modelSelect, toggleBtn;
19
+ // Track if a stream is currently active (separate from abortController existing briefly before validation)
20
+ let isStreaming = false;
21
+ // When the user scrolls up in the chat history, disable automatic scrolling until they scroll back to the bottom.
22
+ let autoscrollEnabled = true;
23
+ const AUTOSCROLL_TOLERANCE_PX = 10;
18
24
 
19
25
  // Initialize chat functionality when DOM is loaded
20
26
  document.addEventListener('DOMContentLoaded', function() {
21
27
  chatHistory = document.getElementById('chat-history');
22
28
  chatInput = document.getElementById('chat-input');
23
- sendBtn = document.getElementById('send-btn');
29
+ toggleBtn = document.getElementById('toggle-btn');
24
30
  attachmentBtn = document.getElementById('attachment-btn');
25
31
  fileAttachment = document.getElementById('file-attachment');
26
32
  attachmentsPreviewContainer = document.getElementById('attachments-preview-container');
@@ -30,6 +36,26 @@ document.addEventListener('DOMContentLoaded', function() {
30
36
  // Set up event listeners
31
37
  setupChatEventListeners();
32
38
 
39
+ // Pause autoscroll when user scrolls up in the chat history. If they scroll back to bottom, resume.
40
+ if (chatHistory) {
41
+ chatHistory.addEventListener('scroll', function () {
42
+ try {
43
+ const atBottom = chatHistory.scrollTop + chatHistory.clientHeight >= chatHistory.scrollHeight - AUTOSCROLL_TOLERANCE_PX;
44
+ if (atBottom) {
45
+ if (!autoscrollEnabled) {
46
+ autoscrollEnabled = true;
47
+ chatHistory.classList.remove('autoscroll-paused');
48
+ }
49
+ } else {
50
+ if (autoscrollEnabled) {
51
+ autoscrollEnabled = false;
52
+ chatHistory.classList.add('autoscroll-paused');
53
+ }
54
+ }
55
+ } catch (_) {}
56
+ });
57
+ }
58
+
33
59
  // Initialize model dropdown (will be populated when models.js calls updateModelStatusIndicator)
34
60
  initializeModelDropdown();
35
61
 
@@ -42,42 +68,34 @@ document.addEventListener('DOMContentLoaded', function() {
42
68
  });
43
69
 
44
70
  function setupChatEventListeners() {
45
- // Send button click
46
- sendBtn.onclick = sendMessage;
47
-
48
- // Attachment button click
49
- attachmentBtn.onclick = () => {
50
- if (!currentLoadedModel) {
51
- alert('Please load a model first before attaching images.');
52
- return;
53
- }
54
- if (!isVisionModel(currentLoadedModel)) {
55
- alert(`The current model "${currentLoadedModel}" does not support image inputs. Please load a model with "Vision" capabilities to attach images.`);
56
- return;
71
+ // Toggle button click – send or stop streaming
72
+ toggleBtn.onclick = function () {
73
+ if (abortController) {
74
+ abortCurrentRequest();
75
+ } else {
76
+ sendMessage();
57
77
  }
58
- fileAttachment.click();
59
78
  };
60
79
 
61
- // File input change
62
- fileAttachment.addEventListener('change', handleFileSelection);
63
-
64
- // Chat input events
65
- chatInput.addEventListener('keydown', handleChatInputKeydown);
66
- chatInput.addEventListener('paste', handleChatInputPaste);
80
+ // Send on Enter, clear attachments on Escape
81
+ if (chatInput) {
82
+ chatInput.addEventListener('keydown', handleChatInputKeydown);
83
+ chatInput.addEventListener('paste', handleChatInputPaste);
84
+ }
67
85
 
68
- // Model select change
69
- modelSelect.addEventListener('change', handleModelSelectChange);
86
+ // Open file picker and handle image selection
87
+ if (attachmentBtn && fileAttachment) {
88
+ attachmentBtn.addEventListener('click', function () {
89
+ // Let the selection handler validate vision capability, etc.
90
+ fileAttachment.click();
91
+ });
92
+ fileAttachment.addEventListener('change', handleFileSelection);
93
+ }
70
94
 
71
- // Send button click
72
- sendBtn.addEventListener('click', function() {
73
- // Check if we have a loaded model
74
- if (currentLoadedModel && modelSelect.value !== '' && !modelSelect.disabled) {
75
- sendMessage();
76
- } else if (!currentLoadedModel) {
77
- // Auto-load default model and send
78
- autoLoadDefaultModelAndSend();
79
- }
80
- });
95
+ // React to model selection changes
96
+ if (modelSelect) {
97
+ modelSelect.addEventListener('change', handleModelSelectChange);
98
+ }
81
99
  }
82
100
 
83
101
  // Initialize model dropdown with available models
@@ -168,12 +186,15 @@ function updateAttachmentButtonState() {
168
186
  updateModelSelectValue();
169
187
 
170
188
  // Update send button state based on model loading
171
- if (modelSelect.disabled) {
172
- sendBtn.disabled = true;
173
- sendBtn.textContent = 'Loading...';
174
- } else {
175
- sendBtn.disabled = false;
176
- sendBtn.textContent = 'Send';
189
+ if (toggleBtn) {
190
+ const loading = !!(modelSelect && modelSelect.disabled);
191
+ if (isStreaming) {
192
+ toggleBtn.disabled = false;
193
+ toggleBtn.textContent = 'Stop';
194
+ } else {
195
+ toggleBtn.disabled = loading;
196
+ toggleBtn.textContent = 'Send';
197
+ }
177
198
  }
178
199
 
179
200
  if (!currentLoadedModel) {
@@ -224,9 +245,14 @@ async function autoLoadDefaultModelAndSend() {
224
245
  // Use the standardized load function
225
246
  const success = await loadModelStandardized(DEFAULT_MODEL, {
226
247
  // Custom UI updates for auto-loading
227
- onLoadingStart: () => { sendBtn.textContent = 'Loading model...'; },
228
- // Reset send button text
229
- onLoadingEnd: () => { sendBtn.textContent = 'Send'; },
248
+ onLoadingStart: () => {
249
+ if (toggleBtn) {
250
+ toggleBtn.disabled = true;
251
+ toggleBtn.textContent = 'Send';
252
+ }
253
+ },
254
+ // Reset send button state
255
+ onLoadingEnd: () => { updateAttachmentButtonState(); },
230
256
  // Send the message after successful load
231
257
  onSuccess: () => { sendMessage(messageToSend); },
232
258
  onError: (error) => {
@@ -306,8 +332,6 @@ function handleChatInputKeydown(e) {
306
332
 
307
333
  // Handle paste events for images
308
334
  async function handleChatInputPaste(e) {
309
- e.preventDefault();
310
-
311
335
  const clipboardData = e.clipboardData || window.clipboardData;
312
336
  const items = clipboardData.items;
313
337
  let hasImage = false;
@@ -330,7 +354,7 @@ async function handleChatInputPaste(e) {
330
354
  const currentModel = modelSelect.value;
331
355
  if (!isVisionModel(currentModel)) {
332
356
  alert(`The selected model "${currentModel}" does not support image inputs. Please select a model with "Vision" capabilities to paste images.`);
333
- if (pastedText) chatInput.value = pastedText;
357
+ // Don't prevent default if we're not handling the paste
334
358
  return;
335
359
  }
336
360
  // Add to attachedFiles array only if it's an image and model supports vision
@@ -341,13 +365,39 @@ async function handleChatInputPaste(e) {
341
365
  }
342
366
  }
343
367
 
344
- // Update input box content - only show text, images will be indicated separately
345
- if (pastedText) chatInput.value = pastedText;
368
+ // If we have images, prevent default and handle specially
369
+ if (hasImage && attachedFiles.length > 0) {
370
+ e.preventDefault();
371
+
372
+ // If there's also text, insert it at cursor position
373
+ if (pastedText) {
374
+ insertTextAtCursor(chatInput, pastedText);
375
+ }
346
376
 
347
- // Update placeholder to show attached images
348
- updateInputPlaceholder();
349
- updateAttachmentPreviewVisibility();
350
- updateAttachmentPreviews();
377
+ // Update placeholder to show attached images
378
+ updateInputPlaceholder();
379
+ updateAttachmentPreviewVisibility();
380
+ updateAttachmentPreviews();
381
+ }
382
+ // If no images, let the browser handle the paste normally (preserves cursor position and undo)
383
+ }
384
+
385
+ // Helper function to insert text at cursor position
386
+ function insertTextAtCursor(textElement, text) {
387
+ const start = textElement.selectionStart;
388
+ const end = textElement.selectionEnd;
389
+ const currentValue = textElement.value;
390
+
391
+ // Insert the text at the cursor position
392
+ const newValue = currentValue.substring(0, start) + text + currentValue.substring(end);
393
+ textElement.value = newValue;
394
+
395
+ // Move cursor to end of inserted text
396
+ const newCursorPos = start + text.length;
397
+ textElement.setSelectionRange(newCursorPos, newCursorPos);
398
+
399
+ // Focus the element to ensure cursor is visible
400
+ textElement.focus();
351
401
  }
352
402
 
353
403
  function clearAttachments() {
@@ -544,6 +594,18 @@ function updateMessageContent(bubbleElement, text, isMarkdown = false) {
544
594
  bubbleElement.dataset.thinkExpanded = expanded ? 'true' : 'false';
545
595
  }
546
596
 
597
+ // Scroll helper that respects user's scroll interaction. If autoscroll is disabled
598
+ // because the user scrolled up, this will not force the view to the bottom.
599
+ function scrollChatToBottom(force = false) {
600
+ if (!chatHistory) return;
601
+ if (force || autoscrollEnabled) {
602
+ // Small timeout to allow DOM insertion/layout to finish in streaming cases
603
+ setTimeout(() => {
604
+ try { chatHistory.scrollTop = chatHistory.scrollHeight; } catch (_) {}
605
+ }, 0);
606
+ }
607
+ }
608
+
547
609
  function appendMessage(role, text, isMarkdown = false) {
548
610
  const div = document.createElement('div');
549
611
  div.className = 'chat-message ' + role;
@@ -561,7 +623,7 @@ function appendMessage(role, text, isMarkdown = false) {
561
623
 
562
624
  div.appendChild(bubble);
563
625
  chatHistory.appendChild(div);
564
- chatHistory.scrollTop = chatHistory.scrollHeight;
626
+ scrollChatToBottom();
565
627
  return bubble;
566
628
  }
567
629
 
@@ -604,34 +666,30 @@ function displaySystemMessage() {
604
666
 
605
667
  div.appendChild(bubble);
606
668
  chatHistory.appendChild(div);
607
- chatHistory.scrollTop = chatHistory.scrollHeight;
669
+ scrollChatToBottom();
608
670
 
609
671
  systemMessageElement = div;
610
672
  }
611
673
  }
612
674
 
613
- function toggleThinkTokens(header) {
614
- const container = header.parentElement;
615
- const content = container.querySelector('.think-tokens-content');
616
- const chevron = header.querySelector('.think-tokens-chevron');
617
- const bubble = header.closest('.chat-bubble');
675
+ function abortCurrentRequest() {
676
+ if (abortController) {
677
+ // Abort the in-flight fetch stream immediately
678
+ abortController.abort();
618
679
 
619
- const nowCollapsed = !container.classList.contains('collapsed'); // current (before toggle) expanded?
620
- if (nowCollapsed) {
621
- // Collapse
622
- content.style.display = 'none';
623
- chevron.textContent = '▶';
624
- container.classList.add('collapsed');
625
- if (bubble) bubble.dataset.thinkExpanded = 'false';
626
- } else {
627
- // Expand
628
- content.style.display = 'block';
629
- chevron.textContent = '▼';
630
- container.classList.remove('collapsed');
631
- if (bubble) bubble.dataset.thinkExpanded = 'true';
680
+ // Also signal the server to halt generation promptly (helps slow CPU backends)
681
+ try {
682
+ // Fire-and-forget; no await to avoid blocking UI
683
+ fetch(getServerBaseUrl() + '/api/v1/halt', { method: 'GET', keepalive: true }).catch(() => {});
684
+ } catch (_) {}
685
+ abortController = null;
686
+ isStreaming = false;
687
+ updateAttachmentButtonState();
688
+ console.log('Streaming request aborted by user.');
632
689
  }
633
690
  }
634
691
 
692
+
635
693
  // ---------- Reasoning Parsing (Harmony + <think>) ----------
636
694
 
637
695
  function parseReasoningBlocks(raw) {
@@ -779,7 +837,22 @@ function stopThinkingAnimation(container, finalLabel = 'Thought Process') {
779
837
 
780
838
  async function sendMessage(existingTextIfAny) {
781
839
  const text = (existingTextIfAny !== undefined ? existingTextIfAny : chatInput.value.trim());
782
- if (!text && attachedFiles.length === 0) return;
840
+
841
+ // Prepare abort controller for this request
842
+ abortController = new AbortController();
843
+ // UI state: set button to Stop
844
+ if (toggleBtn) {
845
+ toggleBtn.disabled = false;
846
+ toggleBtn.textContent = 'Stop';
847
+ }
848
+ if (!text && attachedFiles.length === 0) {
849
+ // Nothing to send; revert button state and clear abort handle
850
+ abortController = null;
851
+ updateAttachmentButtonState();
852
+ return;
853
+ }
854
+
855
+ isStreaming = true;
783
856
 
784
857
  // Remove system message when user starts chatting
785
858
  if (systemMessageElement) {
@@ -878,7 +951,7 @@ async function sendMessage(existingTextIfAny) {
878
951
  updateInputPlaceholder(); // Reset placeholder
879
952
  updateAttachmentPreviewVisibility(); // Hide preview container
880
953
  updateAttachmentPreviews(); // Clear previews
881
- sendBtn.disabled = true;
954
+ // Keep the Send/Stop button enabled during streaming so user can abort.
882
955
 
883
956
  // Streaming OpenAI completions (placeholder, adapt as needed)
884
957
  let llmText = '';
@@ -898,7 +971,8 @@ async function sendMessage(existingTextIfAny) {
898
971
  const resp = await httpRequest(getServerBaseUrl() + '/api/v1/chat/completions', {
899
972
  method: 'POST',
900
973
  headers: { 'Content-Type': 'application/json' },
901
- body: JSON.stringify(payload)
974
+ body: JSON.stringify(payload),
975
+ signal: abortController ? abortController.signal : undefined
902
976
  });
903
977
  if (!resp.body) throw new Error('No stream');
904
978
  const reader = resp.body.getReader();
@@ -998,7 +1072,7 @@ async function sendMessage(existingTextIfAny) {
998
1072
  llmText += '<think>' + parts.slice(1).join('<think>');
999
1073
  receivedAnyReasoning = true;
1000
1074
  updateMessageContent(llmBubble, llmText, true);
1001
- chatHistory.scrollTop = chatHistory.scrollHeight;
1075
+ scrollChatToBottom();
1002
1076
  continue;
1003
1077
  }
1004
1078
 
@@ -1006,7 +1080,7 @@ async function sendMessage(existingTextIfAny) {
1006
1080
  }
1007
1081
 
1008
1082
  updateMessageContent(llmBubble, llmText, true);
1009
- chatHistory.scrollTop = chatHistory.scrollHeight;
1083
+ scrollChatToBottom();
1010
1084
  }
1011
1085
  }
1012
1086
 
@@ -1019,7 +1093,11 @@ async function sendMessage(existingTextIfAny) {
1019
1093
  messages.push({ role: 'assistant', ...assistantMsg });
1020
1094
 
1021
1095
  } catch (e) {
1022
- let detail = e.message;
1096
+ // If the request was aborted by the user, just clean up UI without error banner
1097
+ if (e.name === 'AbortError') {
1098
+ console.log('Chat request aborted by user.');
1099
+ } else {
1100
+ let detail = e.message;
1023
1101
  try {
1024
1102
  const errPayload = { model: currentLoadedModel, messages: messages, stream: false };
1025
1103
  const errResp = await httpJson(getServerBaseUrl() + '/api/v1/chat/completions', {
@@ -1029,10 +1107,15 @@ async function sendMessage(existingTextIfAny) {
1029
1107
  });
1030
1108
  if (errResp && errResp.detail) detail = errResp.detail;
1031
1109
  } catch (_) {}
1032
- llmBubble.textContent = '[Error: ' + detail + ']';
1033
- showErrorBanner(`Chat error: ${detail}`);
1110
+ if (e && e.name !== 'AbortError') {
1111
+ llmBubble.textContent = '[Error: ' + detail + ']';
1112
+ showErrorBanner(`Chat error: ${detail}`);
1113
+ }
1114
+ }
1034
1115
  }
1035
- sendBtn.disabled = false;
1036
- // Force a final render to trigger stop animation if needed
1116
+ // Reset UI state after streaming finishes
1117
+ abortController = null;
1118
+ isStreaming = false;
1119
+ updateAttachmentButtonState();
1037
1120
  updateMessageContent(llmBubble, llmText, true);
1038
- }
1121
+ }
@@ -93,6 +93,11 @@ async function updateModelStatusIndicator() {
93
93
  if (modelsTab && modelsTab.classList.contains('active')) {
94
94
  // Use the display-only version to avoid re-fetching data we just fetched
95
95
  refreshModelMgmtUIDisplay();
96
+
97
+ // Also refresh the model browser to show updated button states
98
+ if (currentCategory === 'hot') displayHotModels();
99
+ else if (currentCategory === 'recipes') displayModelsByRecipe(currentFilter);
100
+ else if (currentCategory === 'labels') displayModelsByLabel(currentFilter);
96
101
  }
97
102
 
98
103
  if (health && health.model_loaded) {
@@ -296,6 +301,21 @@ function displayModelsByRecipe(recipe) {
296
301
 
297
302
  modelList.innerHTML = '';
298
303
 
304
+ // Add FastFlowLM notice if this is the FLM recipe
305
+ if (recipe === 'flm') {
306
+ const notice = document.createElement('div');
307
+ notice.className = 'flm-notice';
308
+ notice.innerHTML = `
309
+ <div class="flm-notice-content">
310
+ <div class="flm-notice-icon">⚠️</div>
311
+ <div class="flm-notice-text">
312
+ <strong><a href="https://github.com/FastFlowLM/FastFlowLM">FastFlowLM (FLM)</a> support in Lemonade is in Early Access.</strong> FLM is free for non-commercial use, however note that commercial licensing terms apply. Installing an FLM model will automatically launch the FLM installer, which will require you to accept the FLM license terms to continue. Contact <a href="mailto:lemonade@amd.com">lemonade@amd.com</a> for inquiries.
313
+ </div>
314
+ </div>
315
+ `;
316
+ modelList.appendChild(notice);
317
+ }
318
+
299
319
  Object.entries(allModels).forEach(([modelId, modelData]) => {
300
320
  if (modelData.recipe === recipe) {
301
321
  createModelItem(modelId, modelData, modelList);
@@ -378,9 +398,17 @@ function createModelItem(modelId, modelData, container) {
378
398
  loadBtn.textContent = '🚀';
379
399
  loadBtn.title = 'Load';
380
400
  loadBtn.onclick = () => {
381
- modelSelect.value = modelId;
382
- modelSelect.dispatchEvent(new Event('change', { bubbles: true }));
383
- };
401
+ loadModelStandardized(modelId, {
402
+ loadButton: loadBtn,
403
+ onSuccess: (loadedModelId) => {
404
+ console.log(`Model ${loadedModelId} loaded successfully`);
405
+ },
406
+ onError: (error, failedModelId) => {
407
+ console.error(`Failed to load model ${failedModelId}:`, error);
408
+ showErrorBanner('Failed to load model: ' + error.message);
409
+ }
410
+ });
411
+ };
384
412
  actions.appendChild(loadBtn);
385
413
  }
386
414
 
@@ -405,7 +433,7 @@ async function installModel(modelId) {
405
433
 
406
434
  modelItems.forEach(item => {
407
435
  const nameElement = item.querySelector('.model-item-name .model-labels-container span');
408
- if (nameElement && nameElement.textContent === modelId) {
436
+ if (nameElement && nameElement.getAttribute('data-model-id') === modelId) {
409
437
  installBtn = item.querySelector('.model-item-btn.install');
410
438
  }
411
439
  });
@@ -443,60 +471,32 @@ async function installModel(modelId) {
443
471
  // Reset button state on error
444
472
  if (installBtn) {
445
473
  installBtn.disabled = false;
446
- installBtn.textContent = 'Install';
474
+ installBtn.textContent = '📥';
447
475
  }
448
476
  }
449
477
  }
450
478
 
451
- // Load model
452
- async function loadModel(modelId) {
453
- const indicator = document.getElementById('model-status-indicator');
454
- const select = document.getElementById('model-select');
455
-
456
- // Set loading state for indicator
457
- modelSelect.value = 'loading-model';
458
- indicator.classList.remove('loaded', 'online', 'offline');
459
- indicator.classList.add('loading');
460
- select.disabled = true;
461
479
 
462
- // Find the load button and show loading state
480
+ // Delete model
481
+ async function deleteModel(modelId) {
482
+ if (!confirm(`Are you sure you want to delete the model "${modelId}"?`)) {
483
+ return;
484
+ }
485
+
486
+ // Find the delete button and show loading state
463
487
  const modelItems = document.querySelectorAll('.model-item');
464
- let loadBtn = null;
488
+ let deleteBtn = null;
465
489
 
466
490
  modelItems.forEach(item => {
467
491
  const nameElement = item.querySelector('.model-item-name .model-labels-container span');
468
- if (nameElement && nameElement.textContent === modelId) {
469
- loadBtn = item.querySelector('.model-item-btn.load');
492
+ if (nameElement && nameElement.getAttribute('data-model-id') === modelId) {
493
+ deleteBtn = item.querySelector('.model-item-btn.delete');
470
494
  }
471
495
  });
472
-
473
- if (loadBtn) {
474
- loadBtn.disabled = true;
475
- loadBtn.textContent = '⏳';
476
- loadBtn.classList.add('loading');
477
- }
478
-
479
- // Use the standardized load function
480
- const success = await loadModelStandardized(modelId, {
481
- loadButton: loadBtn,
482
- onSuccess: (loadedModelId) => {
483
- console.log(`Model ${loadedModelId} loaded successfully`);
484
- // Refresh model list after successful load
485
- if (currentCategory === 'hot') displayHotModels();
486
- else if (currentCategory === 'recipes') displayModelsByRecipe(currentFilter);
487
- else if (currentCategory === 'labels') displayModelsByLabel(currentFilter);
488
- },
489
- onError: (error, failedModelId) => {
490
- console.error(`Failed to load model ${failedModelId}:`, error);
491
- showErrorBanner('Failed to load model: ' + error.message);
492
- }
493
- });
494
- }
495
-
496
- // Delete model
497
- async function deleteModel(modelId) {
498
- if (!confirm(`Are you sure you want to delete the model "${modelId}"?`)) {
499
- return;
496
+
497
+ if (deleteBtn) {
498
+ deleteBtn.disabled = true;
499
+ deleteBtn.textContent = '⏳';
500
500
  }
501
501
 
502
502
  try {
@@ -522,6 +522,12 @@ async function deleteModel(modelId) {
522
522
  } catch (error) {
523
523
  console.error('Error deleting model:', error);
524
524
  showErrorBanner('Failed to delete model: ' + error.message);
525
+
526
+ // Reset button state on error
527
+ if (deleteBtn) {
528
+ deleteBtn.disabled = false;
529
+ deleteBtn.textContent = '🗑️';
530
+ }
525
531
  }
526
532
  }
527
533
 
@@ -529,16 +535,25 @@ async function deleteModel(modelId) {
529
535
 
530
536
  // Create model name with labels
531
537
  function createModelNameWithLabels(modelId, serverModels) {
538
+ const modelData = serverModels[modelId];
532
539
  const container = document.createElement('div');
533
540
  container.className = 'model-labels-container';
534
541
 
535
542
  // Model name
536
543
  const nameSpan = document.createElement('span');
537
- nameSpan.textContent = modelId;
544
+
545
+ // Store the original modelId as a data attribute for button finding
546
+ nameSpan.setAttribute('data-model-id', modelId);
547
+
548
+ // Append size if available
549
+ let displayName = modelId;
550
+ if (modelData && typeof modelData.size === 'number') {
551
+ displayName += ` (${modelData.size} GB)`;
552
+ }
553
+ nameSpan.textContent = displayName;
538
554
  container.appendChild(nameSpan);
539
555
 
540
556
  // Labels
541
- const modelData = serverModels[modelId];
542
557
  if (modelData && modelData.labels && Array.isArray(modelData.labels)) {
543
558
  modelData.labels.forEach(label => {
544
559
  const labelLower = label.toLowerCase();
@@ -587,7 +602,15 @@ document.addEventListener('DOMContentLoaded', async function() {
587
602
  modelSelect.addEventListener('change', async function() {
588
603
  const modelId = this.value;
589
604
  if (modelId) {
590
- await loadModel(modelId);
605
+ await loadModelStandardized(modelId, {
606
+ onSuccess: (loadedModelId) => {
607
+ console.log(`Model ${loadedModelId} loaded successfully`);
608
+ },
609
+ onError: (error, failedModelId) => {
610
+ console.error(`Failed to load model ${failedModelId}:`, error);
611
+ showErrorBanner('Failed to load model: ' + error.message);
612
+ }
613
+ });
591
614
  }
592
615
  });
593
616
  }
@@ -650,7 +673,7 @@ function renderModelTable(tbody, models, allModels, emptyMessage) {
650
673
  btn.title = 'Install model';
651
674
  btn.onclick = async function() {
652
675
  btn.disabled = true;
653
- btn.textContent = 'Installing...';
676
+ btn.textContent = '';
654
677
  btn.classList.add('installing-btn');
655
678
  try {
656
679
  await httpRequest(getServerBaseUrl() + '/api/v1/pull', {
@@ -665,6 +688,7 @@ function renderModelTable(tbody, models, allModels, emptyMessage) {
665
688
  }
666
689
  } catch (e) {
667
690
  btn.textContent = 'Error';
691
+ btn.disabled = false;
668
692
  showErrorBanner(`Failed to install model: ${e.message}`);
669
693
  }
670
694
  };
@@ -741,7 +765,7 @@ async function refreshModelMgmtUI() {
741
765
  return;
742
766
  }
743
767
  btn.disabled = true;
744
- btn.textContent = 'Deleting...';
768
+ btn.textContent = '';
745
769
  btn.style.backgroundColor = '#888';
746
770
  try {
747
771
  await httpRequest(getServerBaseUrl() + '/api/v1/delete', {
@@ -757,6 +781,7 @@ async function refreshModelMgmtUI() {
757
781
  } catch (e) {
758
782
  btn.textContent = 'Error';
759
783
  btn.disabled = false;
784
+ btn.style.backgroundColor = '';
760
785
  showErrorBanner(`Failed to delete model: ${e.message}`);
761
786
  }
762
787
  };
@@ -843,6 +868,10 @@ function refreshModelMgmtUIDisplay() {
843
868
  btn.title = 'Remove this model';
844
869
  btn.onclick = async function() {
845
870
  if (confirm(`Are you sure you want to remove the model "${mid}"?`)) {
871
+ btn.disabled = true;
872
+ btn.textContent = '⏳';
873
+ const originalBgColor = btn.style.backgroundColor;
874
+ btn.style.backgroundColor = '#888';
846
875
  try {
847
876
  await httpRequest(getServerBaseUrl() + '/api/v1/delete', {
848
877
  method: 'POST',
@@ -853,6 +882,10 @@ function refreshModelMgmtUIDisplay() {
853
882
  } catch (error) {
854
883
  console.error('Error removing model:', error);
855
884
  showErrorBanner('Failed to remove model: ' + error.message);
885
+ // Reset button state on error
886
+ btn.disabled = false;
887
+ btn.textContent = '−';
888
+ btn.style.backgroundColor = originalBgColor;
856
889
  }
857
890
  }
858
891
  };
@@ -899,13 +932,14 @@ function setupRegisterModelForm() {
899
932
  const checkpoint = document.getElementById('register-checkpoint').value.trim();
900
933
  const recipe = document.getElementById('register-recipe').value;
901
934
  const reasoning = document.getElementById('register-reasoning').checked;
935
+ const vision = document.getElementById('register-vision').checked;
902
936
  const mmproj = document.getElementById('register-mmproj').value.trim();
903
937
 
904
938
  if (!name || !recipe) {
905
939
  return;
906
940
  }
907
941
 
908
- const payload = { model_name: name, recipe, reasoning };
942
+ const payload = { model_name: name, recipe, reasoning, vision };
909
943
  if (checkpoint) payload.checkpoint = checkpoint;
910
944
  if (mmproj) payload.mmproj = mmproj;
911
945
 
@@ -949,5 +983,4 @@ window.selectLabel = selectLabel;
949
983
  window.showAddModelForm = showAddModelForm;
950
984
  window.unloadModel = unloadModel;
951
985
  window.installModel = installModel;
952
- window.loadModel = loadModel;
953
986
  window.deleteModel = deleteModel;