lemonade-sdk 8.1.4__py3-none-any.whl → 8.2.2__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 (53) hide show
  1. lemonade/cache.py +6 -1
  2. lemonade/cli.py +47 -5
  3. lemonade/common/inference_engines.py +13 -4
  4. lemonade/common/status.py +4 -4
  5. lemonade/common/system_info.py +544 -1
  6. lemonade/profilers/agt_power.py +437 -0
  7. lemonade/profilers/hwinfo_power.py +429 -0
  8. lemonade/tools/accuracy.py +143 -48
  9. lemonade/tools/adapter.py +6 -1
  10. lemonade/tools/bench.py +26 -8
  11. lemonade/tools/flm/__init__.py +1 -0
  12. lemonade/tools/flm/utils.py +303 -0
  13. lemonade/tools/huggingface/bench.py +6 -1
  14. lemonade/tools/llamacpp/bench.py +146 -27
  15. lemonade/tools/llamacpp/load.py +30 -2
  16. lemonade/tools/llamacpp/utils.py +393 -33
  17. lemonade/tools/oga/bench.py +5 -26
  18. lemonade/tools/oga/load.py +60 -121
  19. lemonade/tools/oga/migration.py +403 -0
  20. lemonade/tools/report/table.py +76 -8
  21. lemonade/tools/server/flm.py +133 -0
  22. lemonade/tools/server/llamacpp.py +220 -553
  23. lemonade/tools/server/serve.py +684 -168
  24. lemonade/tools/server/static/js/chat.js +666 -342
  25. lemonade/tools/server/static/js/model-settings.js +24 -3
  26. lemonade/tools/server/static/js/models.js +597 -73
  27. lemonade/tools/server/static/js/shared.js +79 -14
  28. lemonade/tools/server/static/logs.html +191 -0
  29. lemonade/tools/server/static/styles.css +491 -66
  30. lemonade/tools/server/static/webapp.html +83 -31
  31. lemonade/tools/server/tray.py +158 -38
  32. lemonade/tools/server/utils/macos_tray.py +226 -0
  33. lemonade/tools/server/utils/{system_tray.py → windows_tray.py} +13 -0
  34. lemonade/tools/server/webapp.py +4 -1
  35. lemonade/tools/server/wrapped_server.py +559 -0
  36. lemonade/version.py +1 -1
  37. lemonade_install/install.py +54 -611
  38. {lemonade_sdk-8.1.4.dist-info → lemonade_sdk-8.2.2.dist-info}/METADATA +29 -72
  39. lemonade_sdk-8.2.2.dist-info/RECORD +83 -0
  40. lemonade_server/cli.py +145 -37
  41. lemonade_server/model_manager.py +521 -37
  42. lemonade_server/pydantic_models.py +28 -1
  43. lemonade_server/server_models.json +246 -92
  44. lemonade_server/settings.py +39 -39
  45. lemonade/tools/quark/__init__.py +0 -0
  46. lemonade/tools/quark/quark_load.py +0 -173
  47. lemonade/tools/quark/quark_quantize.py +0 -439
  48. lemonade_sdk-8.1.4.dist-info/RECORD +0 -77
  49. {lemonade_sdk-8.1.4.dist-info → lemonade_sdk-8.2.2.dist-info}/WHEEL +0 -0
  50. {lemonade_sdk-8.1.4.dist-info → lemonade_sdk-8.2.2.dist-info}/entry_points.txt +0 -0
  51. {lemonade_sdk-8.1.4.dist-info → lemonade_sdk-8.2.2.dist-info}/licenses/LICENSE +0 -0
  52. {lemonade_sdk-8.1.4.dist-info → lemonade_sdk-8.2.2.dist-info}/licenses/NOTICE.md +0 -0
  53. {lemonade_sdk-8.1.4.dist-info → lemonade_sdk-8.2.2.dist-info}/top_level.txt +0 -0
@@ -3,9 +3,11 @@
3
3
  // State variables for model management
4
4
  let currentLoadedModel = null;
5
5
  let installedModels = new Set(); // Track which models are actually installed
6
+ let activeOperations = new Set(); // Track models currently being downloaded or loaded
6
7
 
7
- // Make installedModels accessible globally for the chat dropdown
8
+ // Make installedModels and activeOperations accessible globally
8
9
  window.installedModels = installedModels;
10
+ window.activeOperations = activeOperations;
9
11
  let currentCategory = 'hot';
10
12
  let currentFilter = null;
11
13
 
@@ -42,48 +44,95 @@ async function checkModelHealth() {
42
44
  }
43
45
  }
44
46
 
47
+ // Populate the model dropdown with all installed models
48
+ function populateModelDropdown() {
49
+ const indicator = document.getElementById('model-status-indicator');
50
+ const select = document.getElementById('model-select');
51
+ select.innerHTML = '';
52
+
53
+ // Add the default option
54
+ const defaultOption = document.createElement('option');
55
+ defaultOption.value = '';
56
+ defaultOption.textContent = 'Click to select a model ▼';
57
+ select.appendChild(defaultOption);
58
+
59
+ // Add the hidden 'Server Offline' option
60
+ const hiddenOption = document.createElement('option');
61
+ hiddenOption.value = 'server-offline';
62
+ hiddenOption.textContent = 'Server Offline';
63
+ hiddenOption.hidden = true;
64
+ select.appendChild(hiddenOption);
65
+
66
+ // Get all installed models from the global set
67
+ const sortedModels = Array.from(installedModels).sort();
68
+
69
+ // Add options for each installed model
70
+ sortedModels.forEach(modelId => {
71
+ const option = document.createElement('option');
72
+ option.value = modelId;
73
+ option.textContent = modelId;
74
+ select.appendChild(option);
75
+ });
76
+ }
77
+
45
78
  // Update model status indicator
46
79
  async function updateModelStatusIndicator() {
47
80
  const indicator = document.getElementById('model-status-indicator');
48
- const statusText = document.getElementById('model-status-text');
49
- const unloadBtn = document.getElementById('model-unload-btn');
50
-
81
+ const select = document.getElementById('model-select');
82
+ const buttonIcons = document.querySelectorAll('button');
83
+
51
84
  // Fetch both health and installed models
52
85
  const [health] = await Promise.all([
53
86
  checkModelHealth(),
54
87
  fetchInstalledModels()
55
88
  ]);
56
-
57
- // Refresh model dropdown in chat after fetching installed models
58
- if (window.initializeModelDropdown) {
59
- window.initializeModelDropdown();
60
- }
61
-
89
+
90
+ // Populate the dropdown with the newly fetched installed models
91
+ populateModelDropdown();
92
+
62
93
  // Refresh model management UI if we're on the models tab
63
94
  const modelsTab = document.getElementById('content-models');
64
95
  if (modelsTab && modelsTab.classList.contains('active')) {
65
96
  // Use the display-only version to avoid re-fetching data we just fetched
66
97
  refreshModelMgmtUIDisplay();
98
+
99
+ // Also refresh the model browser to show updated button states
100
+ if (currentCategory === 'hot') displayHotModels();
101
+ else if (currentCategory === 'recipes') displayModelsByRecipe(currentFilter);
102
+ else if (currentCategory === 'labels') displayModelsByLabel(currentFilter);
67
103
  }
68
-
69
- // Remove any click handlers
70
- indicator.onclick = null;
71
-
104
+
72
105
  if (health && health.model_loaded) {
73
106
  // Model is loaded - show model name with online status
107
+ indicator.classList.remove('online', 'offline', 'loading');
74
108
  currentLoadedModel = health.model_loaded;
75
- updateStatusIndicator(health.model_loaded, 'loaded');
76
- unloadBtn.style.display = 'block';
77
- } else if (health) {
109
+ indicator.classList.add('loaded');
110
+ select.value = currentLoadedModel;
111
+ select.disabled = false;
112
+ buttonIcons.forEach(btn => btn.disabled = false);
113
+ } else if (health !== null) {
78
114
  // Server is online but no model loaded
115
+ indicator.classList.remove('loaded', 'offline', 'loading');
79
116
  currentLoadedModel = null;
80
- updateStatusIndicator('Server Online', 'online');
81
- unloadBtn.style.display = 'none';
117
+ indicator.classList.add('online');
118
+ select.value = ''; // Set to the "Click to select a model ▼" option
119
+ select.disabled = false;
120
+ buttonIcons.forEach(btn => btn.disabled = false);
82
121
  } else {
83
122
  // Server is offline
123
+ indicator.classList.remove('loaded', 'online', 'loading');
84
124
  currentLoadedModel = null;
85
- updateStatusIndicator('Server Offline', 'offline');
86
- unloadBtn.style.display = 'none';
125
+ // Add the hidden 'Server Offline' option
126
+ const hiddenOption = document.createElement('option');
127
+ hiddenOption.value = 'server-offline';
128
+ hiddenOption.textContent = 'Server Offline';
129
+ hiddenOption.hidden = true;
130
+ select.appendChild(hiddenOption);
131
+ indicator.classList.add('offline');
132
+ select.value = 'server-offline';
133
+ select.disabled = true;
134
+ buttonIcons.forEach(btn => btn.disabled = true);
135
+ return;
87
136
  }
88
137
  }
89
138
 
@@ -92,9 +141,18 @@ async function unloadModel() {
92
141
  if (!currentLoadedModel) return;
93
142
 
94
143
  try {
144
+ // Set loading state
145
+ const indicator = document.getElementById('model-status-indicator');
146
+ const select = document.getElementById('model-select');
147
+ indicator.classList.remove('loaded', 'online', 'offline');
148
+ indicator.classList.add('loading');
149
+ select.disabled = true;
150
+ select.value = currentLoadedModel; // Keep the selected model visible during unload
151
+
95
152
  await httpRequest(getServerBaseUrl() + '/api/v1/unload', {
96
153
  method: 'POST'
97
154
  });
155
+
98
156
  await updateModelStatusIndicator();
99
157
 
100
158
  // Refresh model list to show updated button states
@@ -104,11 +162,35 @@ async function unloadModel() {
104
162
  } catch (error) {
105
163
  console.error('Error unloading model:', error);
106
164
  showErrorBanner('Failed to unload model: ' + error.message);
165
+ await updateModelStatusIndicator(); // Revert state on error
107
166
  }
108
167
  }
109
168
 
110
169
  // === Model Browser Management ===
111
170
 
171
+ // Update visibility of categories/subcategories based on available models
172
+ function updateCategoryVisibility() {
173
+ const allModels = window.SERVER_MODELS || {};
174
+
175
+ // Count models for each recipe
176
+ const recipeCounts = {};
177
+ const recipes = ['llamacpp', 'oga-hybrid', 'oga-npu', 'oga-cpu', 'flm'];
178
+ recipes.forEach(recipe => {
179
+ recipeCounts[recipe] = 0;
180
+ Object.entries(allModels).forEach(([modelId, modelData]) => {
181
+ if (modelData.recipe === recipe && (modelData.suggested || installedModels.has(modelId))) {
182
+ recipeCounts[recipe]++;
183
+ }
184
+ });
185
+
186
+ // Show/hide recipe subcategory
187
+ const subcategory = document.querySelector(`[data-recipe="${recipe}"]`);
188
+ if (subcategory) {
189
+ subcategory.style.display = recipeCounts[recipe] > 0 ? 'block' : 'none';
190
+ }
191
+ });
192
+ }
193
+
112
194
  // Toggle category in model browser (only for Hot Models now)
113
195
  function toggleCategory(categoryName) {
114
196
  const header = document.querySelector(`[data-category="${categoryName}"] .category-header`);
@@ -226,7 +308,7 @@ function displayHotModels() {
226
308
  modelList.innerHTML = '';
227
309
 
228
310
  Object.entries(allModels).forEach(([modelId, modelData]) => {
229
- if (modelData.labels && modelData.labels.includes('hot')) {
311
+ if (modelData.labels && modelData.labels.includes('hot') && (modelData.suggested || installedModels.has(modelId))) {
230
312
  createModelItem(modelId, modelData, modelList);
231
313
  }
232
314
  });
@@ -244,8 +326,23 @@ function displayModelsByRecipe(recipe) {
244
326
 
245
327
  modelList.innerHTML = '';
246
328
 
329
+ // Add FastFlowLM notice if this is the FLM recipe
330
+ if (recipe === 'flm') {
331
+ const notice = document.createElement('div');
332
+ notice.className = 'flm-notice';
333
+ notice.innerHTML = `
334
+ <div class="flm-notice-content">
335
+ <div class="flm-notice-icon">⚠️</div>
336
+ <div class="flm-notice-text">
337
+ <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.
338
+ </div>
339
+ </div>
340
+ `;
341
+ modelList.appendChild(notice);
342
+ }
343
+
247
344
  Object.entries(allModels).forEach(([modelId, modelData]) => {
248
- if (modelData.recipe === recipe) {
345
+ if (modelData.recipe === recipe && (modelData.suggested || installedModels.has(modelId))) {
249
346
  createModelItem(modelId, modelData, modelList);
250
347
  }
251
348
  });
@@ -269,7 +366,7 @@ function displayModelsByLabel(label) {
269
366
  if (modelId.startsWith('user.')) {
270
367
  createModelItem(modelId, modelData, modelList);
271
368
  }
272
- } else if (modelData.labels && modelData.labels.includes(label)) {
369
+ } else if (modelData.labels && modelData.labels.includes(label) && (modelData.suggested || installedModels.has(modelId))) {
273
370
  createModelItem(modelId, modelData, modelList);
274
371
  }
275
372
  });
@@ -321,10 +418,22 @@ function createModelItem(modelId, modelData, container) {
321
418
  actions.appendChild(unloadBtn);
322
419
  } else {
323
420
  const loadBtn = document.createElement('button');
421
+ const modelSelect = document.getElementById('model-select');
324
422
  loadBtn.className = 'model-item-btn load';
325
423
  loadBtn.textContent = '🚀';
326
424
  loadBtn.title = 'Load';
327
- loadBtn.onclick = () => loadModel(modelId);
425
+ loadBtn.onclick = () => {
426
+ loadModelStandardized(modelId, {
427
+ loadButton: loadBtn,
428
+ onSuccess: (loadedModelId) => {
429
+ console.log(`Model ${loadedModelId} loaded successfully`);
430
+ },
431
+ onError: (error, failedModelId) => {
432
+ console.error(`Failed to load model ${failedModelId}:`, error);
433
+ showErrorBanner('Failed to load model: ' + error.message);
434
+ }
435
+ });
436
+ };
328
437
  actions.appendChild(loadBtn);
329
438
  }
330
439
 
@@ -349,7 +458,7 @@ async function installModel(modelId) {
349
458
 
350
459
  modelItems.forEach(item => {
351
460
  const nameElement = item.querySelector('.model-item-name .model-labels-container span');
352
- if (nameElement && nameElement.textContent === modelId) {
461
+ if (nameElement && nameElement.getAttribute('data-model-id') === modelId) {
353
462
  installBtn = item.querySelector('.model-item-btn.install');
354
463
  }
355
464
  });
@@ -359,6 +468,9 @@ async function installModel(modelId) {
359
468
  installBtn.textContent = '⏳';
360
469
  }
361
470
 
471
+ // Track this download as active
472
+ activeOperations.add(modelId);
473
+
362
474
  try {
363
475
  const modelData = window.SERVER_MODELS[modelId];
364
476
  await httpRequest(getServerBaseUrl() + '/api/v1/pull', {
@@ -367,6 +479,9 @@ async function installModel(modelId) {
367
479
  body: JSON.stringify({ model_name: modelId, ...modelData })
368
480
  });
369
481
 
482
+ // Download complete - remove from active operations
483
+ activeOperations.delete(modelId);
484
+
370
485
  // Refresh installed models and model status
371
486
  await fetchInstalledModels();
372
487
  await updateModelStatusIndicator();
@@ -384,47 +499,38 @@ async function installModel(modelId) {
384
499
  console.error('Error installing model:', error);
385
500
  showErrorBanner('Failed to install model: ' + error.message);
386
501
 
502
+ // Remove from active operations on error too
503
+ activeOperations.delete(modelId);
504
+
387
505
  // Reset button state on error
388
506
  if (installBtn) {
389
507
  installBtn.disabled = false;
390
- installBtn.textContent = 'Install';
508
+ installBtn.textContent = '📥';
391
509
  }
392
510
  }
393
511
  }
394
512
 
395
- // Load model
396
- async function loadModel(modelId) {
397
- // Find the load button and show loading state
513
+
514
+ // Delete model
515
+ async function deleteModel(modelId) {
516
+ if (!confirm(`Are you sure you want to delete the model "${modelId}"?`)) {
517
+ return;
518
+ }
519
+
520
+ // Find the delete button and show loading state
398
521
  const modelItems = document.querySelectorAll('.model-item');
399
- let loadBtn = null;
522
+ let deleteBtn = null;
400
523
 
401
524
  modelItems.forEach(item => {
402
525
  const nameElement = item.querySelector('.model-item-name .model-labels-container span');
403
- if (nameElement && nameElement.textContent === modelId) {
404
- loadBtn = item.querySelector('.model-item-btn.load');
526
+ if (nameElement && nameElement.getAttribute('data-model-id') === modelId) {
527
+ deleteBtn = item.querySelector('.model-item-btn.delete');
405
528
  }
406
529
  });
407
530
 
408
- // Use the standardized load function
409
- const success = await loadModelStandardized(modelId, {
410
- loadButton: loadBtn,
411
- onSuccess: (loadedModelId) => {
412
- console.log(`Model ${loadedModelId} loaded successfully`);
413
- // Refresh model list after successful load
414
- if (currentCategory === 'hot') displayHotModels();
415
- else if (currentCategory === 'recipes') displayModelsByRecipe(currentFilter);
416
- else if (currentCategory === 'labels') displayModelsByLabel(currentFilter);
417
- },
418
- onError: (error, failedModelId) => {
419
- console.error(`Failed to load model ${failedModelId}:`, error);
420
- }
421
- });
422
- }
423
-
424
- // Delete model
425
- async function deleteModel(modelId) {
426
- if (!confirm(`Are you sure you want to delete the model "${modelId}"?`)) {
427
- return;
531
+ if (deleteBtn) {
532
+ deleteBtn.disabled = true;
533
+ deleteBtn.textContent = '⏳';
428
534
  }
429
535
 
430
536
  try {
@@ -433,7 +539,11 @@ async function deleteModel(modelId) {
433
539
  headers: { 'Content-Type': 'application/json' },
434
540
  body: JSON.stringify({ model_name: modelId })
435
541
  });
436
-
542
+ installedModels.delete(modelId);
543
+ // Remove custom models from SERVER_MODELS to prevent them from reappearing without having to do a manual refresh
544
+ if (modelId.startsWith('user.')) {
545
+ delete window.SERVER_MODELS[modelId];
546
+ }
437
547
  // Refresh installed models and model status
438
548
  await fetchInstalledModels();
439
549
  await updateModelStatusIndicator();
@@ -450,6 +560,12 @@ async function deleteModel(modelId) {
450
560
  } catch (error) {
451
561
  console.error('Error deleting model:', error);
452
562
  showErrorBanner('Failed to delete model: ' + error.message);
563
+
564
+ // Reset button state on error
565
+ if (deleteBtn) {
566
+ deleteBtn.disabled = false;
567
+ deleteBtn.textContent = '🗑️';
568
+ }
453
569
  }
454
570
  }
455
571
 
@@ -457,16 +573,25 @@ async function deleteModel(modelId) {
457
573
 
458
574
  // Create model name with labels
459
575
  function createModelNameWithLabels(modelId, serverModels) {
576
+ const modelData = serverModels[modelId];
460
577
  const container = document.createElement('div');
461
578
  container.className = 'model-labels-container';
462
579
 
463
580
  // Model name
464
581
  const nameSpan = document.createElement('span');
465
- nameSpan.textContent = modelId;
582
+
583
+ // Store the original modelId as a data attribute for button finding
584
+ nameSpan.setAttribute('data-model-id', modelId);
585
+
586
+ // Append size if available
587
+ let displayName = modelId;
588
+ if (modelData && typeof modelData.size === 'number') {
589
+ displayName += ` (${modelData.size} GB)`;
590
+ }
591
+ nameSpan.textContent = displayName;
466
592
  container.appendChild(nameSpan);
467
593
 
468
594
  // Labels
469
- const modelData = serverModels[modelId];
470
595
  if (modelData && modelData.labels && Array.isArray(modelData.labels)) {
471
596
  modelData.labels.forEach(label => {
472
597
  const labelLower = label.toLowerCase();
@@ -488,6 +613,8 @@ function createModelNameWithLabels(modelId, serverModels) {
488
613
  labelClass = 'reranking';
489
614
  } else if (labelLower === 'coding') {
490
615
  labelClass = 'coding';
616
+ } else if (labelLower === 'tool-calling') {
617
+ labelClass = 'tool-calling';
491
618
  }
492
619
  labelSpan.className = `model-label ${labelClass}`;
493
620
  labelSpan.textContent = label;
@@ -511,8 +638,8 @@ document.addEventListener('DOMContentLoaded', async function() {
511
638
  // Initial fetch of model data - this will populate installedModels
512
639
  await updateModelStatusIndicator();
513
640
 
514
- // Set up periodic refresh of model status
515
- setInterval(updateModelStatusIndicator, 5000); // Check every 5 seconds
641
+ // Update category visibility on initial load
642
+ updateCategoryVisibility();
516
643
 
517
644
  // Initialize model browser with hot models
518
645
  displayHotModels();
@@ -528,6 +655,46 @@ document.addEventListener('DOMContentLoaded', async function() {
528
655
 
529
656
  // Set up register model form
530
657
  setupRegisterModelForm();
658
+ setupFolderSelection();
659
+
660
+ // Set up smart periodic refresh to detect external model changes
661
+ // Poll every 15 seconds (much less aggressive than 1 second)
662
+ // Only poll when page is visible to save resources
663
+ let pollInterval = null;
664
+
665
+ function startPolling() {
666
+ if (!pollInterval) {
667
+ pollInterval = setInterval(async () => {
668
+ // Only update if page is visible AND no active operations
669
+ // Skip polling during downloads/loads to prevent false positives
670
+ if (document.visibilityState === 'visible' && activeOperations.size === 0) {
671
+ await updateModelStatusIndicator();
672
+ }
673
+ }, 15000); // Check every 15 seconds
674
+ }
675
+ }
676
+
677
+ function stopPolling() {
678
+ if (pollInterval) {
679
+ clearInterval(pollInterval);
680
+ pollInterval = null;
681
+ }
682
+ }
683
+
684
+ // Start polling when page is visible, stop when hidden
685
+ document.addEventListener('visibilitychange', () => {
686
+ if (document.visibilityState === 'visible') {
687
+ // Page became visible - update immediately and resume polling
688
+ updateModelStatusIndicator();
689
+ startPolling();
690
+ } else {
691
+ // Page hidden - stop polling to save resources
692
+ stopPolling();
693
+ }
694
+ });
695
+
696
+ // Start polling initially
697
+ startPolling();
531
698
  });
532
699
 
533
700
  // Toggle Add Model form
@@ -566,14 +733,22 @@ function renderModelTable(tbody, models, allModels, emptyMessage) {
566
733
  btn.title = 'Install model';
567
734
  btn.onclick = async function() {
568
735
  btn.disabled = true;
569
- btn.textContent = 'Installing...';
736
+ btn.textContent = '';
570
737
  btn.classList.add('installing-btn');
738
+
739
+ // Track this download as active
740
+ activeOperations.add(mid);
741
+
571
742
  try {
572
743
  await httpRequest(getServerBaseUrl() + '/api/v1/pull', {
573
744
  method: 'POST',
574
745
  headers: { 'Content-Type': 'application/json' },
575
746
  body: JSON.stringify({ model_name: mid })
576
747
  });
748
+
749
+ // Download complete - remove from active operations
750
+ activeOperations.delete(mid);
751
+
577
752
  await refreshModelMgmtUI();
578
753
  // Update chat dropdown too if loadModels function exists
579
754
  if (typeof loadModels === 'function') {
@@ -581,7 +756,11 @@ function renderModelTable(tbody, models, allModels, emptyMessage) {
581
756
  }
582
757
  } catch (e) {
583
758
  btn.textContent = 'Error';
759
+ btn.disabled = false;
584
760
  showErrorBanner(`Failed to install model: ${e.message}`);
761
+
762
+ // Remove from active operations on error too
763
+ activeOperations.delete(mid);
585
764
  }
586
765
  };
587
766
  tdBtn.appendChild(btn);
@@ -657,7 +836,7 @@ async function refreshModelMgmtUI() {
657
836
  return;
658
837
  }
659
838
  btn.disabled = true;
660
- btn.textContent = 'Deleting...';
839
+ btn.textContent = '';
661
840
  btn.style.backgroundColor = '#888';
662
841
  try {
663
842
  await httpRequest(getServerBaseUrl() + '/api/v1/delete', {
@@ -673,11 +852,11 @@ async function refreshModelMgmtUI() {
673
852
  } catch (e) {
674
853
  btn.textContent = 'Error';
675
854
  btn.disabled = false;
855
+ btn.style.backgroundColor = '';
676
856
  showErrorBanner(`Failed to delete model: ${e.message}`);
677
857
  }
678
858
  };
679
859
  tdBtn.appendChild(btn);
680
-
681
860
  tr.appendChild(tdName);
682
861
  tr.appendChild(tdBtn);
683
862
  installedTbody.appendChild(tr);
@@ -699,6 +878,11 @@ async function refreshModelMgmtUI() {
699
878
  if (window.initializeModelDropdown) {
700
879
  window.initializeModelDropdown();
701
880
  }
881
+
882
+ // Update system message when installed models change
883
+ if (window.displaySystemMessage) {
884
+ window.displaySystemMessage();
885
+ }
702
886
  }
703
887
 
704
888
  // Make refreshModelMgmtUI globally accessible
@@ -755,6 +939,10 @@ function refreshModelMgmtUIDisplay() {
755
939
  btn.title = 'Remove this model';
756
940
  btn.onclick = async function() {
757
941
  if (confirm(`Are you sure you want to remove the model "${mid}"?`)) {
942
+ btn.disabled = true;
943
+ btn.textContent = '⏳';
944
+ const originalBgColor = btn.style.backgroundColor;
945
+ btn.style.backgroundColor = '#888';
758
946
  try {
759
947
  await httpRequest(getServerBaseUrl() + '/api/v1/delete', {
760
948
  method: 'POST',
@@ -765,6 +953,10 @@ function refreshModelMgmtUIDisplay() {
765
953
  } catch (error) {
766
954
  console.error('Error removing model:', error);
767
955
  showErrorBanner('Failed to remove model: ' + error.message);
956
+ // Reset button state on error
957
+ btn.disabled = false;
958
+ btn.textContent = '−';
959
+ btn.style.backgroundColor = originalBgColor;
768
960
  }
769
961
  }
770
962
  };
@@ -807,34 +999,91 @@ function setupRegisterModelForm() {
807
999
  if (!name.startsWith('user.')) {
808
1000
  name = 'user.' + name;
809
1001
  }
810
-
1002
+
1003
+ // Check if model name already exists
1004
+ const allModels = window.SERVER_MODELS || {};
1005
+ if (allModels[name] || installedModels.has(name)) {
1006
+ showErrorBanner('Model name already exists. Please enter a different name.');
1007
+ registerStatus.textContent = 'Model name already exists';
1008
+ registerStatus.style.color = '#b10819ff';
1009
+ registerStatus.className = 'register-status error';
1010
+ return;
1011
+ }
1012
+
811
1013
  const checkpoint = document.getElementById('register-checkpoint').value.trim();
812
1014
  const recipe = document.getElementById('register-recipe').value;
813
1015
  const reasoning = document.getElementById('register-reasoning').checked;
1016
+ const vision = document.getElementById('register-vision').checked;
814
1017
  const mmproj = document.getElementById('register-mmproj').value.trim();
815
1018
 
816
1019
  if (!name || !recipe) {
817
1020
  return;
818
1021
  }
819
1022
 
820
- const payload = { model_name: name, recipe, reasoning };
821
- if (checkpoint) payload.checkpoint = checkpoint;
822
- if (mmproj) payload.mmproj = mmproj;
823
-
824
1023
  const btn = document.getElementById('register-submit');
825
1024
  btn.disabled = true;
826
1025
  btn.textContent = 'Installing...';
827
1026
 
828
1027
  try {
829
- await httpRequest(getServerBaseUrl() + '/api/v1/pull', {
830
- method: 'POST',
831
- headers: { 'Content-Type': 'application/json' },
832
- body: JSON.stringify(payload)
833
- });
1028
+ if (isLocalModel && selectedModelFiles) {
1029
+ if (recipe === 'llamacpp' && !Array.from(selectedModelFiles).some(file => file.name.toLowerCase().endsWith('.gguf'))) {
1030
+ throw new Error('No .gguf files found in the selected folder for llamacpp');
1031
+ }
1032
+
1033
+ const formData = new FormData();
1034
+ formData.append('model_name', name);
1035
+ formData.append('checkpoint', checkpoint);
1036
+ formData.append('recipe', recipe);
1037
+ formData.append('reasoning', reasoning);
1038
+ formData.append('vision', vision);
1039
+ if (mmproj) formData.append('mmproj', mmproj);
1040
+ Array.from(selectedModelFiles).forEach(file => {
1041
+ formData.append('model_files', file, file.webkitRelativePath);
1042
+ });
1043
+
1044
+ await httpRequest(getServerBaseUrl() + '/api/v1/add-local-model', {
1045
+ method: 'POST',
1046
+ body: formData
1047
+ });
1048
+ }
1049
+ else {
1050
+ if (!checkpoint) {
1051
+ throw new Error('Checkpoint is required for remote models');
1052
+ }
1053
+ const payload = { model_name: name, recipe, reasoning, vision };
1054
+ if (checkpoint) payload.checkpoint = checkpoint;
1055
+ if (mmproj) payload.mmproj = mmproj;
1056
+
1057
+ await httpRequest(getServerBaseUrl() + '/api/v1/pull', {
1058
+ method: 'POST',
1059
+ headers: { 'Content-Type': 'application/json' },
1060
+ body: JSON.stringify(payload)
1061
+ });
1062
+ }
1063
+
834
1064
  registerStatus.textContent = 'Model installed!';
835
- registerStatus.style.color = '#27ae60';
1065
+ registerStatus.style.color = '#0eaf51ff';
836
1066
  registerStatus.className = 'register-status success';
1067
+
1068
+ // Add custom model to SERVER_MODELS so it appears in the UI without having to do a manual refresh
1069
+ if (name.startsWith('user.')) {
1070
+ const labels = ['custom'];
1071
+ if (vision) labels.push('vision');
1072
+ if (reasoning) labels.push('reasoning');
1073
+
1074
+ window.SERVER_MODELS[name] = {
1075
+ recipe: recipe,
1076
+ labels: labels
1077
+ };
1078
+ if (checkpoint) window.SERVER_MODELS[name].checkpoint = checkpoint;
1079
+ if (mmproj) window.SERVER_MODELS[name].mmproj = mmproj;
1080
+ }
1081
+
837
1082
  registerForm.reset();
1083
+ isLocalModel = false;
1084
+ selectedModelFiles = null;
1085
+ document.getElementById('folder-input').value = '';
1086
+
838
1087
  await refreshModelMgmtUI();
839
1088
  // Update chat dropdown too if loadModels function exists
840
1089
  if (typeof loadModels === 'function') {
@@ -853,6 +1102,278 @@ function setupRegisterModelForm() {
853
1102
  };
854
1103
  }
855
1104
  }
1105
+ let isLocalModel = false;
1106
+ let selectedModelFiles = null;
1107
+ // Helper function to find mmproj file in selected folder
1108
+ function findMmprojFile(files) {
1109
+ for (let i = 0; i < files.length; i++) {
1110
+ const file = files[i];
1111
+ const fileName = file.name.toLowerCase();
1112
+ const relativePath = file.webkitRelativePath;
1113
+
1114
+ // Check if file contains 'mmproj' and has .gguf extension
1115
+ if (fileName.includes('mmproj') && fileName.endsWith('.gguf')) {
1116
+ // Return just the filename (last part of the path)
1117
+ return relativePath.split('/').pop();
1118
+ }
1119
+ }
1120
+ return null;
1121
+ }
1122
+
1123
+ // Helper function to find all non-mmproj GGUF files in selected folder
1124
+ function findGgufFiles(files) {
1125
+ const ggufFiles = [];
1126
+ for (let i = 0; i < files.length; i++) {
1127
+ const file = files[i];
1128
+ const fileName = file.name.toLowerCase();
1129
+ const relativePath = file.webkitRelativePath;
1130
+
1131
+ // Check if file has .gguf extension but is NOT an mmproj file
1132
+ if (fileName.endsWith('.gguf') && !fileName.includes('mmproj')) {
1133
+ // Store just the filename (last part of the path)
1134
+ ggufFiles.push(relativePath.split('/').pop());
1135
+ }
1136
+ }
1137
+ return ggufFiles;
1138
+ }
1139
+
1140
+ // Helper function to check GGUF files and show appropriate banners
1141
+ function checkGgufFilesAndShowBanner(files) {
1142
+ const recipeSelect = document.getElementById('register-recipe');
1143
+
1144
+ // Only check if llamacpp is selected
1145
+ if (!recipeSelect || recipeSelect.value !== 'llamacpp') {
1146
+ return;
1147
+ }
1148
+
1149
+ const mmprojFile = findMmprojFile(files);
1150
+ const ggufFiles = findGgufFiles(files);
1151
+
1152
+ // Hide any existing banners first
1153
+ hideErrorBanner();
1154
+
1155
+ if (ggufFiles.length > 1) {
1156
+ // Multiple GGUF files detected
1157
+ const folderPath = files[0].webkitRelativePath.split('/')[0];
1158
+ let bannerMsg = `More than one variant detected. Please clarify them at the end of the checkpoint name like:\n<folder_name>:<variant>\nExample: ${folderPath}:${ggufFiles[0]}`;
1159
+
1160
+ if (mmprojFile) {
1161
+ bannerMsg += `\n\nDon't forget to enter the mmproj file name and check the 'vision' checkbox if it is a vision model.`;
1162
+ }
1163
+
1164
+ showBanner(bannerMsg, 'warning');
1165
+ } else if (mmprojFile) {
1166
+ // MMproj detected
1167
+ showBanner("MMproj detected and populated. Please validate the file name and check the 'vision' checkbox if it is a vision model.", 'success');
1168
+ }
1169
+ }
1170
+ // Helper function to auto-fill mmproj field if llamacpp is selected
1171
+ function autoFillMmproj() {
1172
+ const recipeSelect = document.getElementById('register-recipe');
1173
+ const mmprojInput = document.getElementById('register-mmproj');
1174
+
1175
+ if (recipeSelect && mmprojInput && isLocalModel && selectedModelFiles) {
1176
+ const selectedRecipe = recipeSelect.value;
1177
+
1178
+ if (selectedRecipe === 'llamacpp') {
1179
+ const mmprojFile = findMmprojFile(selectedModelFiles);
1180
+ if (mmprojFile) {
1181
+ mmprojInput.value = mmprojFile;
1182
+ }
1183
+
1184
+ // Check GGUF files and show appropriate banner
1185
+ checkGgufFilesAndShowBanner(selectedModelFiles);
1186
+ } else {
1187
+ // Hide banners if not llamacpp
1188
+ hideErrorBanner();
1189
+ }
1190
+ }
1191
+ }
1192
+ function setupFolderSelection() {
1193
+ const selectFolderBtn = document.getElementById('select-folder-btn');
1194
+ const folderInput = document.getElementById('folder-input');
1195
+ const checkpointInput = document.getElementById('register-checkpoint');
1196
+ const recipeSelect = document.getElementById('register-recipe');
1197
+
1198
+ if (selectFolderBtn && folderInput && checkpointInput) {
1199
+ selectFolderBtn.addEventListener('click', () => {
1200
+ folderInput.click();
1201
+ });
1202
+
1203
+ folderInput.addEventListener('change', (event) => {
1204
+ const files = event.target.files;
1205
+ if (files.length > 0) {
1206
+ const firstFile = files[0];
1207
+ const folderPath = firstFile.webkitRelativePath.split('/')[0];
1208
+ checkpointInput.value = folderPath;
1209
+ isLocalModel = true;
1210
+ selectedModelFiles = files;
1211
+
1212
+ // Auto-fill mmproj if llamacpp is already selected
1213
+ autoFillMmproj();
1214
+ }
1215
+ else {
1216
+ isLocalModel = false;
1217
+ selectedModelFiles = null;
1218
+ checkpointInput.value = '';
1219
+ hideErrorBanner();
1220
+ }
1221
+ });
1222
+
1223
+ // Add listener to recipe dropdown to auto-fill mmproj when changed to llamacpp
1224
+ if (recipeSelect) {
1225
+ recipeSelect.addEventListener('change', () => {
1226
+ autoFillMmproj();
1227
+ });
1228
+ }
1229
+ }
1230
+ }
1231
+ // === Migration/Cleanup Functions ===
1232
+
1233
+ // Store incompatible models data globally
1234
+ let incompatibleModelsData = null;
1235
+
1236
+ // Check for incompatible models on page load
1237
+ async function checkIncompatibleModels() {
1238
+ try {
1239
+ const response = await httpJson(getServerBaseUrl() + '/api/v1/migration/incompatible-models');
1240
+ incompatibleModelsData = response;
1241
+
1242
+ if (response.count > 0) {
1243
+ showMigrationBanner(response.count, response.total_size);
1244
+ }
1245
+ } catch (error) {
1246
+ console.error('Error checking for incompatible models:', error);
1247
+ }
1248
+ }
1249
+
1250
+ // Show migration banner
1251
+ function showMigrationBanner(count, totalSize) {
1252
+ const banner = document.getElementById('migration-banner');
1253
+ const msg = document.getElementById('migration-banner-msg');
1254
+
1255
+ const sizeGB = (totalSize / (1024 * 1024 * 1024)).toFixed(1);
1256
+ msg.textContent = `Found ${count} incompatible RyzenAI model${count > 1 ? 's' : ''} (${sizeGB} GB). Clean up to free disk space.`;
1257
+ banner.style.display = 'flex';
1258
+ }
1259
+
1260
+ // Hide migration banner
1261
+ function hideMigrationBanner() {
1262
+ const banner = document.getElementById('migration-banner');
1263
+ banner.style.display = 'none';
1264
+ }
1265
+
1266
+ // Show migration modal with model list
1267
+ function showMigrationModal() {
1268
+ if (!incompatibleModelsData || incompatibleModelsData.count === 0) {
1269
+ return;
1270
+ }
1271
+
1272
+ const modal = document.getElementById('migration-modal');
1273
+ const modelList = document.getElementById('migration-model-list');
1274
+ const totalSize = document.getElementById('migration-total-size');
1275
+
1276
+ // Populate model list
1277
+ modelList.innerHTML = '';
1278
+ incompatibleModelsData.models.forEach(model => {
1279
+ const item = document.createElement('div');
1280
+ item.className = 'migration-model-item';
1281
+
1282
+ const nameSpan = document.createElement('span');
1283
+ nameSpan.className = 'migration-model-name';
1284
+ nameSpan.textContent = model.name;
1285
+
1286
+ const sizeSpan = document.createElement('span');
1287
+ sizeSpan.className = 'migration-model-size';
1288
+ sizeSpan.textContent = model.size_formatted;
1289
+
1290
+ item.appendChild(nameSpan);
1291
+ item.appendChild(sizeSpan);
1292
+ modelList.appendChild(item);
1293
+ });
1294
+
1295
+ // Set total size
1296
+ const sizeGB = (incompatibleModelsData.total_size / (1024 * 1024 * 1024)).toFixed(1);
1297
+ totalSize.textContent = `${sizeGB} GB`;
1298
+
1299
+ modal.style.display = 'flex';
1300
+ }
1301
+
1302
+ // Hide migration modal
1303
+ function hideMigrationModal() {
1304
+ const modal = document.getElementById('migration-modal');
1305
+ modal.style.display = 'none';
1306
+ }
1307
+
1308
+ // Delete incompatible models
1309
+ async function deleteIncompatibleModels() {
1310
+ if (!incompatibleModelsData || incompatibleModelsData.count === 0) {
1311
+ return;
1312
+ }
1313
+
1314
+ const modelPaths = incompatibleModelsData.models.map(m => m.path);
1315
+
1316
+ try {
1317
+ // Disable buttons during deletion
1318
+ const deleteBtn = document.querySelector('.delete-btn');
1319
+ const cancelBtn = document.querySelector('.cancel-btn');
1320
+ deleteBtn.disabled = true;
1321
+ cancelBtn.disabled = true;
1322
+ deleteBtn.textContent = 'Deleting...';
1323
+
1324
+ const response = await httpRequest(getServerBaseUrl() + '/api/v1/migration/cleanup', {
1325
+ method: 'POST',
1326
+ headers: { 'Content-Type': 'application/json' },
1327
+ body: JSON.stringify({ model_paths: modelPaths })
1328
+ });
1329
+
1330
+ const result = await response.json();
1331
+
1332
+ // Close modal
1333
+ hideMigrationModal();
1334
+
1335
+ // Hide banner
1336
+ hideMigrationBanner();
1337
+
1338
+ // Show success message
1339
+ showSuccessMessage(`Successfully deleted ${result.success_count} model${result.success_count > 1 ? 's' : ''}, freed ${result.freed_size_formatted}`);
1340
+
1341
+ // Clear cached data
1342
+ incompatibleModelsData = null;
1343
+
1344
+ } catch (error) {
1345
+ console.error('Error deleting incompatible models:', error);
1346
+ showErrorBanner('Failed to delete models: ' + error.message);
1347
+
1348
+ // Re-enable buttons
1349
+ const deleteBtn = document.querySelector('.delete-btn');
1350
+ const cancelBtn = document.querySelector('.cancel-btn');
1351
+ deleteBtn.disabled = false;
1352
+ cancelBtn.disabled = false;
1353
+ deleteBtn.textContent = 'Delete All';
1354
+ }
1355
+ }
1356
+
1357
+ // Show success message (reuse error banner with green color)
1358
+ function showSuccessMessage(message) {
1359
+ const banner = document.getElementById('error-banner');
1360
+ const msg = document.getElementById('error-banner-msg');
1361
+ msg.textContent = message;
1362
+ banner.style.backgroundColor = '#2d7f47';
1363
+ banner.style.display = 'flex';
1364
+
1365
+ // Auto-hide after 5 seconds
1366
+ setTimeout(() => {
1367
+ banner.style.display = 'none';
1368
+ banner.style.backgroundColor = ''; // Reset to default
1369
+ }, 5000);
1370
+ }
1371
+
1372
+ // Check for incompatible models when page loads
1373
+ document.addEventListener('DOMContentLoaded', function() {
1374
+ // Run check after a short delay to let the page load
1375
+ setTimeout(checkIncompatibleModels, 1000);
1376
+ });
856
1377
 
857
1378
  // Make functions globally available for HTML onclick handlers and other components
858
1379
  window.toggleCategory = toggleCategory;
@@ -861,5 +1382,8 @@ window.selectLabel = selectLabel;
861
1382
  window.showAddModelForm = showAddModelForm;
862
1383
  window.unloadModel = unloadModel;
863
1384
  window.installModel = installModel;
864
- window.loadModel = loadModel;
865
1385
  window.deleteModel = deleteModel;
1386
+ window.showMigrationModal = showMigrationModal;
1387
+ window.hideMigrationModal = hideMigrationModal;
1388
+ window.hideMigrationBanner = hideMigrationBanner;
1389
+ window.deleteIncompatibleModels = deleteIncompatibleModels;