lemonade-sdk 8.1.10__py3-none-any.whl → 8.1.12__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.
- lemonade/cache.py +6 -1
- lemonade/common/status.py +4 -4
- lemonade/tools/bench.py +22 -1
- lemonade/tools/flm/__init__.py +1 -0
- lemonade/tools/flm/utils.py +255 -0
- lemonade/tools/llamacpp/bench.py +111 -23
- lemonade/tools/llamacpp/load.py +20 -1
- lemonade/tools/llamacpp/utils.py +210 -17
- lemonade/tools/oga/bench.py +0 -26
- lemonade/tools/report/table.py +6 -0
- lemonade/tools/server/flm.py +133 -0
- lemonade/tools/server/llamacpp.py +23 -5
- lemonade/tools/server/serve.py +260 -135
- lemonade/tools/server/static/js/chat.js +165 -82
- lemonade/tools/server/static/js/models.js +87 -54
- lemonade/tools/server/static/js/shared.js +9 -6
- lemonade/tools/server/static/logs.html +57 -0
- lemonade/tools/server/static/styles.css +159 -8
- lemonade/tools/server/static/webapp.html +28 -10
- lemonade/tools/server/tray.py +94 -38
- lemonade/tools/server/utils/macos_tray.py +226 -0
- lemonade/tools/server/utils/{system_tray.py → windows_tray.py} +13 -0
- lemonade/tools/server/webapp.py +4 -1
- lemonade/tools/server/wrapped_server.py +91 -25
- lemonade/version.py +1 -1
- lemonade_install/install.py +25 -2
- {lemonade_sdk-8.1.10.dist-info → lemonade_sdk-8.1.12.dist-info}/METADATA +10 -6
- {lemonade_sdk-8.1.10.dist-info → lemonade_sdk-8.1.12.dist-info}/RECORD +37 -32
- lemonade_server/cli.py +103 -14
- lemonade_server/model_manager.py +186 -45
- lemonade_server/pydantic_models.py +25 -1
- lemonade_server/server_models.json +175 -62
- {lemonade_sdk-8.1.10.dist-info → lemonade_sdk-8.1.12.dist-info}/WHEEL +0 -0
- {lemonade_sdk-8.1.10.dist-info → lemonade_sdk-8.1.12.dist-info}/entry_points.txt +0 -0
- {lemonade_sdk-8.1.10.dist-info → lemonade_sdk-8.1.12.dist-info}/licenses/LICENSE +0 -0
- {lemonade_sdk-8.1.10.dist-info → lemonade_sdk-8.1.12.dist-info}/licenses/NOTICE.md +0 -0
- {lemonade_sdk-8.1.10.dist-info → lemonade_sdk-8.1.12.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,
|
|
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
|
-
|
|
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
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
//
|
|
69
|
-
|
|
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
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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: () => {
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
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
|
-
//
|
|
345
|
-
if (
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
669
|
+
scrollChatToBottom();
|
|
608
670
|
|
|
609
671
|
systemMessageElement = div;
|
|
610
672
|
}
|
|
611
673
|
}
|
|
612
674
|
|
|
613
|
-
function
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1033
|
-
|
|
1110
|
+
if (e && e.name !== 'AbortError') {
|
|
1111
|
+
llmBubble.textContent = '[Error: ' + detail + ']';
|
|
1112
|
+
showErrorBanner(`Chat error: ${detail}`);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1034
1115
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
-
|
|
382
|
-
|
|
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.
|
|
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 = '
|
|
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
|
-
|
|
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
|
|
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.
|
|
469
|
-
|
|
492
|
+
if (nameElement && nameElement.getAttribute('data-model-id') === modelId) {
|
|
493
|
+
deleteBtn = item.querySelector('.model-item-btn.delete');
|
|
470
494
|
}
|
|
471
495
|
});
|
|
472
|
-
|
|
473
|
-
if (
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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
|
|
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 = '
|
|
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 = '
|
|
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;
|