lemonade-sdk 8.1.0__py3-none-any.whl → 8.1.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.
- lemonade/common/inference_engines.py +63 -78
- lemonade/common/system_info.py +61 -44
- lemonade/tools/llamacpp/load.py +13 -4
- lemonade/tools/llamacpp/utils.py +322 -54
- lemonade/tools/management_tools.py +1 -1
- lemonade/tools/oga/load.py +3 -3
- lemonade/tools/server/llamacpp.py +30 -53
- lemonade/tools/server/serve.py +58 -104
- lemonade/tools/server/static/styles.css +203 -0
- lemonade/tools/server/static/webapp.html +509 -72
- lemonade/tools/server/tray.py +4 -2
- lemonade/tools/server/utils/port.py +2 -2
- lemonade/tools/server/utils/thread.py +2 -4
- lemonade/version.py +1 -1
- lemonade_install/install.py +25 -2
- {lemonade_sdk-8.1.0.dist-info → lemonade_sdk-8.1.2.dist-info}/METADATA +45 -6
- {lemonade_sdk-8.1.0.dist-info → lemonade_sdk-8.1.2.dist-info}/RECORD +25 -25
- {lemonade_sdk-8.1.0.dist-info → lemonade_sdk-8.1.2.dist-info}/entry_points.txt +1 -0
- lemonade_server/cli.py +115 -27
- lemonade_server/model_manager.py +1 -1
- lemonade_server/server_models.json +71 -1
- {lemonade_sdk-8.1.0.dist-info → lemonade_sdk-8.1.2.dist-info}/WHEEL +0 -0
- {lemonade_sdk-8.1.0.dist-info → lemonade_sdk-8.1.2.dist-info}/licenses/LICENSE +0 -0
- {lemonade_sdk-8.1.0.dist-info → lemonade_sdk-8.1.2.dist-info}/licenses/NOTICE.md +0 -0
- {lemonade_sdk-8.1.0.dist-info → lemonade_sdk-8.1.2.dist-info}/top_level.txt +0 -0
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
<a href="https://lemonade-server.ai/news/" target="_blank">News</a>
|
|
25
25
|
</div>
|
|
26
26
|
</nav>
|
|
27
|
+
<div id="error-banner" class="error-banner" style="display:none;">
|
|
28
|
+
<span id="error-banner-msg"></span>
|
|
29
|
+
<button class="close-btn" onclick="hideErrorBanner()">×</button>
|
|
30
|
+
</div>
|
|
27
31
|
<main class="main">
|
|
28
32
|
<div class="tab-container">
|
|
29
33
|
<div class="tabs">
|
|
@@ -35,8 +39,15 @@
|
|
|
35
39
|
<div class="chat-history" id="chat-history"></div>
|
|
36
40
|
<div class="chat-input-row">
|
|
37
41
|
<select id="model-select"></select>
|
|
38
|
-
<
|
|
42
|
+
<div class="input-with-indicator">
|
|
43
|
+
<input type="text" id="chat-input" placeholder="Type your message..." />
|
|
44
|
+
</div>
|
|
45
|
+
<input type="file" id="file-attachment" style="display: none;" multiple accept="image/*">
|
|
46
|
+
<button id="attachment-btn" title="Attach files">📎</button>
|
|
39
47
|
<button id="send-btn">Send</button>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="attachments-preview-container" id="attachments-preview-container">
|
|
50
|
+
<div class="attachments-preview-row" id="attachments-preview-row"></div>
|
|
40
51
|
</div>
|
|
41
52
|
</div>
|
|
42
53
|
<!-- App Suggestions Section -->
|
|
@@ -141,7 +152,12 @@
|
|
|
141
152
|
</table>
|
|
142
153
|
</div>
|
|
143
154
|
<div class="model-mgmt-pane">
|
|
144
|
-
<h3
|
|
155
|
+
<h3>🔥 Hot Models</h3>
|
|
156
|
+
<table class="model-table" id="hot-models-table">
|
|
157
|
+
<tbody id="hot-models-tbody"></tbody>
|
|
158
|
+
</table>
|
|
159
|
+
|
|
160
|
+
<h3 style="margin-top: 2em;">Suggested Models</h3>
|
|
145
161
|
<table class="model-table" id="suggested-models-table">
|
|
146
162
|
<tbody id="suggested-models-tbody"></tbody>
|
|
147
163
|
</table>
|
|
@@ -214,6 +230,50 @@
|
|
|
214
230
|
}
|
|
215
231
|
}
|
|
216
232
|
|
|
233
|
+
// Display an error message in the banner
|
|
234
|
+
function showErrorBanner(msg) {
|
|
235
|
+
const banner = document.getElementById('error-banner');
|
|
236
|
+
if (!banner) return;
|
|
237
|
+
const msgEl = document.getElementById('error-banner-msg');
|
|
238
|
+
const fullMsg = msg + '\nCheck the Lemonade Server logs via the system tray app for more information.';
|
|
239
|
+
if (msgEl) {
|
|
240
|
+
msgEl.textContent = fullMsg;
|
|
241
|
+
} else {
|
|
242
|
+
banner.textContent = fullMsg;
|
|
243
|
+
}
|
|
244
|
+
banner.style.display = 'flex';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function hideErrorBanner() {
|
|
248
|
+
const banner = document.getElementById('error-banner');
|
|
249
|
+
if (banner) banner.style.display = 'none';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Helper fetch wrappers that surface server error details
|
|
253
|
+
async function httpRequest(url, options = {}) {
|
|
254
|
+
const resp = await fetch(url, options);
|
|
255
|
+
if (!resp.ok) {
|
|
256
|
+
let detail = resp.statusText || 'Request failed';
|
|
257
|
+
try {
|
|
258
|
+
const contentType = resp.headers.get('content-type') || '';
|
|
259
|
+
if (contentType.includes('application/json')) {
|
|
260
|
+
const data = await resp.json();
|
|
261
|
+
if (data && data.detail) detail = data.detail;
|
|
262
|
+
} else {
|
|
263
|
+
const text = await resp.text();
|
|
264
|
+
if (text) detail = text.trim();
|
|
265
|
+
}
|
|
266
|
+
} catch (_) {}
|
|
267
|
+
throw new Error(detail);
|
|
268
|
+
}
|
|
269
|
+
return resp;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function httpJson(url, options = {}) {
|
|
273
|
+
const resp = await httpRequest(url, options);
|
|
274
|
+
return await resp.json();
|
|
275
|
+
}
|
|
276
|
+
|
|
217
277
|
// Tab switching logic
|
|
218
278
|
function showTab(tab, updateHash = true) {
|
|
219
279
|
document.getElementById('tab-chat').classList.remove('active');
|
|
@@ -309,14 +369,40 @@
|
|
|
309
369
|
// Helper to get server base URL
|
|
310
370
|
function getServerBaseUrl() {
|
|
311
371
|
const port = window.SERVER_PORT || 8000;
|
|
312
|
-
|
|
372
|
+
const host = window.location.hostname || 'localhost';
|
|
373
|
+
return `http://${host}:${port}`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Check if current model supports vision
|
|
377
|
+
function isVisionModel(modelId) {
|
|
378
|
+
const allModels = window.SERVER_MODELS || {};
|
|
379
|
+
const modelData = allModels[modelId];
|
|
380
|
+
if (modelData && modelData.labels && Array.isArray(modelData.labels)) {
|
|
381
|
+
return modelData.labels.some(label => label.toLowerCase() === 'vision');
|
|
382
|
+
}
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Update attachment button state based on current model
|
|
387
|
+
function updateAttachmentButtonState() {
|
|
388
|
+
const currentModel = modelSelect.value;
|
|
389
|
+
const isVision = isVisionModel(currentModel);
|
|
390
|
+
|
|
391
|
+
if (isVision) {
|
|
392
|
+
attachmentBtn.style.opacity = '1';
|
|
393
|
+
attachmentBtn.style.cursor = 'pointer';
|
|
394
|
+
attachmentBtn.title = 'Attach images';
|
|
395
|
+
} else {
|
|
396
|
+
attachmentBtn.style.opacity = '0.5';
|
|
397
|
+
attachmentBtn.style.cursor = 'not-allowed';
|
|
398
|
+
attachmentBtn.title = 'Image attachments not supported by this model';
|
|
399
|
+
}
|
|
313
400
|
}
|
|
314
401
|
|
|
315
402
|
// Populate model dropdown from /api/v1/models endpoint
|
|
316
403
|
async function loadModels() {
|
|
317
404
|
try {
|
|
318
|
-
const
|
|
319
|
-
const data = await resp.json();
|
|
405
|
+
const data = await httpJson(getServerBaseUrl() + '/api/v1/models');
|
|
320
406
|
const select = document.getElementById('model-select');
|
|
321
407
|
select.innerHTML = '';
|
|
322
408
|
if (!data.data || !Array.isArray(data.data)) {
|
|
@@ -380,14 +466,50 @@
|
|
|
380
466
|
console.warn(`Model '${urlModel}' specified in URL not found in available models`);
|
|
381
467
|
}
|
|
382
468
|
}
|
|
469
|
+
|
|
470
|
+
// Update attachment button state after model is loaded
|
|
471
|
+
updateAttachmentButtonState();
|
|
383
472
|
} catch (e) {
|
|
384
473
|
const select = document.getElementById('model-select');
|
|
385
474
|
select.innerHTML = `<option>Error loading models: ${e.message}</option>`;
|
|
386
475
|
console.error('Error loading models:', e);
|
|
476
|
+
showErrorBanner(`Error loading models: ${e.message}`);
|
|
387
477
|
}
|
|
388
478
|
}
|
|
389
479
|
loadModels();
|
|
390
480
|
|
|
481
|
+
// Add model change handler to clear attachments if switching to non-vision model
|
|
482
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
483
|
+
const modelSelect = document.getElementById('model-select');
|
|
484
|
+
if (modelSelect) {
|
|
485
|
+
modelSelect.addEventListener('change', function() {
|
|
486
|
+
const currentModel = this.value;
|
|
487
|
+
updateAttachmentButtonState(); // Update button visual state
|
|
488
|
+
|
|
489
|
+
if (attachedFiles.length > 0 && !isVisionModel(currentModel)) {
|
|
490
|
+
if (confirm(`The selected model "${currentModel}" does not support images. Would you like to remove the attached images?`)) {
|
|
491
|
+
clearAttachments();
|
|
492
|
+
} else {
|
|
493
|
+
// Find a vision model to switch back to
|
|
494
|
+
const allModels = window.SERVER_MODELS || {};
|
|
495
|
+
const visionModels = Array.from(this.options).filter(option =>
|
|
496
|
+
isVisionModel(option.value)
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
if (visionModels.length > 0) {
|
|
500
|
+
this.value = visionModels[0].value;
|
|
501
|
+
updateAttachmentButtonState(); // Update button state again
|
|
502
|
+
alert(`Switched back to "${visionModels[0].value}" which supports images.`);
|
|
503
|
+
} else {
|
|
504
|
+
alert('No vision models available. Images will be cleared.');
|
|
505
|
+
clearAttachments();
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
391
513
|
// Helper function to create model name with labels
|
|
392
514
|
function createModelNameWithLabels(modelId, allModels) {
|
|
393
515
|
// Create container for model name and labels
|
|
@@ -403,8 +525,14 @@
|
|
|
403
525
|
const modelData = allModels[modelId];
|
|
404
526
|
if (modelData && modelData.labels && Array.isArray(modelData.labels)) {
|
|
405
527
|
modelData.labels.forEach(label => {
|
|
406
|
-
const labelSpan = document.createElement('span');
|
|
407
528
|
const labelLower = label.toLowerCase();
|
|
529
|
+
|
|
530
|
+
// Skip "hot" labels since they have their own section
|
|
531
|
+
if (labelLower === 'hot') {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const labelSpan = document.createElement('span');
|
|
408
536
|
let labelClass = 'other';
|
|
409
537
|
if (labelLower === 'vision') {
|
|
410
538
|
labelClass = 'vision';
|
|
@@ -426,23 +554,92 @@
|
|
|
426
554
|
return container;
|
|
427
555
|
}
|
|
428
556
|
|
|
557
|
+
// Helper function to render a model table section
|
|
558
|
+
function renderModelTable(tbody, models, allModels, emptyMessage) {
|
|
559
|
+
tbody.innerHTML = '';
|
|
560
|
+
if (models.length === 0) {
|
|
561
|
+
const tr = document.createElement('tr');
|
|
562
|
+
const td = document.createElement('td');
|
|
563
|
+
td.colSpan = 2;
|
|
564
|
+
td.textContent = emptyMessage;
|
|
565
|
+
td.style.textAlign = 'center';
|
|
566
|
+
td.style.fontStyle = 'italic';
|
|
567
|
+
td.style.color = '#666';
|
|
568
|
+
td.style.padding = '1em';
|
|
569
|
+
tr.appendChild(td);
|
|
570
|
+
tbody.appendChild(tr);
|
|
571
|
+
} else {
|
|
572
|
+
models.forEach(mid => {
|
|
573
|
+
const tr = document.createElement('tr');
|
|
574
|
+
const tdName = document.createElement('td');
|
|
575
|
+
|
|
576
|
+
tdName.appendChild(createModelNameWithLabels(mid, allModels));
|
|
577
|
+
tdName.style.paddingRight = '1em';
|
|
578
|
+
tdName.style.verticalAlign = 'middle';
|
|
579
|
+
const tdBtn = document.createElement('td');
|
|
580
|
+
tdBtn.style.width = '1%';
|
|
581
|
+
tdBtn.style.verticalAlign = 'middle';
|
|
582
|
+
const btn = document.createElement('button');
|
|
583
|
+
btn.textContent = '+';
|
|
584
|
+
btn.title = 'Install model';
|
|
585
|
+
btn.onclick = async function() {
|
|
586
|
+
btn.disabled = true;
|
|
587
|
+
btn.textContent = 'Installing...';
|
|
588
|
+
btn.classList.add('installing-btn');
|
|
589
|
+
try {
|
|
590
|
+
await httpRequest(getServerBaseUrl() + '/api/v1/pull', {
|
|
591
|
+
method: 'POST',
|
|
592
|
+
headers: { 'Content-Type': 'application/json' },
|
|
593
|
+
body: JSON.stringify({ model_name: mid })
|
|
594
|
+
});
|
|
595
|
+
await refreshModelMgmtUI();
|
|
596
|
+
await loadModels(); // update chat dropdown too
|
|
597
|
+
} catch (e) {
|
|
598
|
+
btn.textContent = 'Error';
|
|
599
|
+
showErrorBanner(`Failed to install model: ${e.message}`);
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
tdBtn.appendChild(btn);
|
|
603
|
+
tr.appendChild(tdName);
|
|
604
|
+
tr.appendChild(tdBtn);
|
|
605
|
+
tbody.appendChild(tr);
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
429
610
|
// Model Management Tab Logic
|
|
430
611
|
async function refreshModelMgmtUI() {
|
|
431
612
|
// Get installed models from /api/v1/models
|
|
432
613
|
let installed = [];
|
|
433
614
|
try {
|
|
434
|
-
const
|
|
435
|
-
const data = await resp.json();
|
|
615
|
+
const data = await httpJson(getServerBaseUrl() + '/api/v1/models');
|
|
436
616
|
if (data.data && Array.isArray(data.data)) {
|
|
437
617
|
installed = data.data.map(m => m.id || m.name || m);
|
|
438
618
|
}
|
|
439
|
-
} catch (e) {
|
|
619
|
+
} catch (e) {
|
|
620
|
+
showErrorBanner(`Error loading models: ${e.message}`);
|
|
621
|
+
}
|
|
440
622
|
// All models from server_models.json (window.SERVER_MODELS)
|
|
441
623
|
const allModels = window.SERVER_MODELS || {};
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
624
|
+
|
|
625
|
+
// Separate hot models and regular suggested models not installed
|
|
626
|
+
const hotModels = [];
|
|
627
|
+
const regularSuggested = [];
|
|
628
|
+
|
|
629
|
+
Object.keys(allModels).forEach(k => {
|
|
630
|
+
if (allModels[k].suggested && !installed.includes(k)) {
|
|
631
|
+
const modelData = allModels[k];
|
|
632
|
+
const hasHotLabel = modelData.labels && modelData.labels.some(label =>
|
|
633
|
+
label.toLowerCase() === 'hot'
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
if (hasHotLabel) {
|
|
637
|
+
hotModels.push(k);
|
|
638
|
+
} else {
|
|
639
|
+
regularSuggested.push(k);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
});
|
|
446
643
|
// Render installed models as a table (two columns, second is invisible)
|
|
447
644
|
const installedTbody = document.getElementById('installed-models-tbody');
|
|
448
645
|
installedTbody.innerHTML = '';
|
|
@@ -469,21 +666,17 @@
|
|
|
469
666
|
btn.textContent = 'Deleting...';
|
|
470
667
|
btn.style.backgroundColor = '#888';
|
|
471
668
|
try {
|
|
472
|
-
|
|
669
|
+
await httpRequest(getServerBaseUrl() + '/api/v1/delete', {
|
|
473
670
|
method: 'POST',
|
|
474
671
|
headers: { 'Content-Type': 'application/json' },
|
|
475
672
|
body: JSON.stringify({ model_name: mid })
|
|
476
673
|
});
|
|
477
|
-
if (!response.ok) {
|
|
478
|
-
const errorData = await response.json();
|
|
479
|
-
throw new Error(errorData.detail || 'Failed to delete model');
|
|
480
|
-
}
|
|
481
674
|
await refreshModelMgmtUI();
|
|
482
675
|
await loadModels(); // update chat dropdown too
|
|
483
676
|
} catch (e) {
|
|
484
677
|
btn.textContent = 'Error';
|
|
485
678
|
btn.disabled = false;
|
|
486
|
-
|
|
679
|
+
showErrorBanner(`Failed to delete model: ${e.message}`);
|
|
487
680
|
}
|
|
488
681
|
};
|
|
489
682
|
tdBtn.appendChild(btn);
|
|
@@ -492,43 +685,13 @@
|
|
|
492
685
|
tr.appendChild(tdBtn);
|
|
493
686
|
installedTbody.appendChild(tr);
|
|
494
687
|
});
|
|
495
|
-
|
|
688
|
+
|
|
689
|
+
// Render hot models and suggested models using the helper function
|
|
690
|
+
const hotTbody = document.getElementById('hot-models-tbody');
|
|
496
691
|
const suggestedTbody = document.getElementById('suggested-models-tbody');
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
const tdName = document.createElement('td');
|
|
501
|
-
|
|
502
|
-
tdName.appendChild(createModelNameWithLabels(mid, allModels));
|
|
503
|
-
tdName.style.paddingRight = '1em';
|
|
504
|
-
tdName.style.verticalAlign = 'middle';
|
|
505
|
-
const tdBtn = document.createElement('td');
|
|
506
|
-
tdBtn.style.width = '1%';
|
|
507
|
-
tdBtn.style.verticalAlign = 'middle';
|
|
508
|
-
const btn = document.createElement('button');
|
|
509
|
-
btn.textContent = '+';
|
|
510
|
-
btn.title = 'Install model';
|
|
511
|
-
btn.onclick = async function() {
|
|
512
|
-
btn.disabled = true;
|
|
513
|
-
btn.textContent = 'Installing...';
|
|
514
|
-
btn.classList.add('installing-btn');
|
|
515
|
-
try {
|
|
516
|
-
await fetch(getServerBaseUrl() + '/api/v1/pull', {
|
|
517
|
-
method: 'POST',
|
|
518
|
-
headers: { 'Content-Type': 'application/json' },
|
|
519
|
-
body: JSON.stringify({ model_name: mid })
|
|
520
|
-
});
|
|
521
|
-
await refreshModelMgmtUI();
|
|
522
|
-
await loadModels(); // update chat dropdown too
|
|
523
|
-
} catch (e) {
|
|
524
|
-
btn.textContent = 'Error';
|
|
525
|
-
}
|
|
526
|
-
};
|
|
527
|
-
tdBtn.appendChild(btn);
|
|
528
|
-
tr.appendChild(tdName);
|
|
529
|
-
tr.appendChild(tdBtn);
|
|
530
|
-
suggestedTbody.appendChild(tr);
|
|
531
|
-
});
|
|
692
|
+
|
|
693
|
+
renderModelTable(hotTbody, hotModels, allModels, "Nice, you've already installed all these models!");
|
|
694
|
+
renderModelTable(suggestedTbody, regularSuggested, allModels, "Nice, you've already installed all these models!");
|
|
532
695
|
}
|
|
533
696
|
// Initial load
|
|
534
697
|
refreshModelMgmtUI();
|
|
@@ -539,8 +702,211 @@
|
|
|
539
702
|
const chatHistory = document.getElementById('chat-history');
|
|
540
703
|
const chatInput = document.getElementById('chat-input');
|
|
541
704
|
const sendBtn = document.getElementById('send-btn');
|
|
705
|
+
const attachmentBtn = document.getElementById('attachment-btn');
|
|
706
|
+
const fileAttachment = document.getElementById('file-attachment');
|
|
707
|
+
const attachmentsPreviewContainer = document.getElementById('attachments-preview-container');
|
|
708
|
+
const attachmentsPreviewRow = document.getElementById('attachments-preview-row');
|
|
542
709
|
const modelSelect = document.getElementById('model-select');
|
|
543
710
|
let messages = [];
|
|
711
|
+
let attachedFiles = [];
|
|
712
|
+
|
|
713
|
+
attachmentBtn.onclick = () => {
|
|
714
|
+
const currentModel = modelSelect.value;
|
|
715
|
+
if (!isVisionModel(currentModel)) {
|
|
716
|
+
alert(`The selected model "${currentModel}" does not support image inputs. Please select a model with "Vision" capabilities to attach images.`);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
fileAttachment.click();
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
function clearAttachments() {
|
|
723
|
+
attachedFiles = [];
|
|
724
|
+
fileAttachment.value = '';
|
|
725
|
+
updateInputPlaceholder();
|
|
726
|
+
updateAttachmentPreviewVisibility();
|
|
727
|
+
updateAttachmentPreviews();
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function updateAttachmentPreviewVisibility() {
|
|
731
|
+
if (attachedFiles.length > 0) {
|
|
732
|
+
attachmentsPreviewContainer.classList.add('has-attachments');
|
|
733
|
+
} else {
|
|
734
|
+
attachmentsPreviewContainer.classList.remove('has-attachments');
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function updateAttachmentPreviews() {
|
|
739
|
+
// Clear existing previews
|
|
740
|
+
attachmentsPreviewRow.innerHTML = '';
|
|
741
|
+
|
|
742
|
+
if (attachedFiles.length === 0) {
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
attachedFiles.forEach((file, index) => {
|
|
747
|
+
// Skip non-image files (extra safety check)
|
|
748
|
+
if (!file.type.startsWith('image/')) {
|
|
749
|
+
console.warn(`Skipping non-image file in preview: ${file.name} (${file.type})`);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const previewDiv = document.createElement('div');
|
|
754
|
+
previewDiv.className = 'attachment-preview';
|
|
755
|
+
|
|
756
|
+
// Create thumbnail
|
|
757
|
+
const thumbnail = document.createElement('img');
|
|
758
|
+
thumbnail.className = 'attachment-thumbnail';
|
|
759
|
+
thumbnail.alt = file.name;
|
|
760
|
+
|
|
761
|
+
// Create filename display
|
|
762
|
+
const filename = document.createElement('div');
|
|
763
|
+
filename.className = 'attachment-filename';
|
|
764
|
+
filename.textContent = file.name || `pasted-image-${index + 1}`;
|
|
765
|
+
filename.title = file.name || `pasted-image-${index + 1}`;
|
|
766
|
+
|
|
767
|
+
// Create remove button
|
|
768
|
+
const removeBtn = document.createElement('button');
|
|
769
|
+
removeBtn.className = 'attachment-remove-btn';
|
|
770
|
+
removeBtn.innerHTML = '✕';
|
|
771
|
+
removeBtn.title = 'Remove this image';
|
|
772
|
+
removeBtn.onclick = () => removeAttachment(index);
|
|
773
|
+
|
|
774
|
+
// Generate thumbnail for image
|
|
775
|
+
const reader = new FileReader();
|
|
776
|
+
reader.onload = (e) => {
|
|
777
|
+
thumbnail.src = e.target.result;
|
|
778
|
+
};
|
|
779
|
+
reader.readAsDataURL(file);
|
|
780
|
+
|
|
781
|
+
previewDiv.appendChild(thumbnail);
|
|
782
|
+
previewDiv.appendChild(filename);
|
|
783
|
+
previewDiv.appendChild(removeBtn);
|
|
784
|
+
attachmentsPreviewRow.appendChild(previewDiv);
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function removeAttachment(index) {
|
|
789
|
+
attachedFiles.splice(index, 1);
|
|
790
|
+
updateInputPlaceholder();
|
|
791
|
+
updateAttachmentPreviewVisibility();
|
|
792
|
+
updateAttachmentPreviews();
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
fileAttachment.addEventListener('change', () => {
|
|
796
|
+
if (fileAttachment.files.length > 0) {
|
|
797
|
+
// Check if current model supports vision
|
|
798
|
+
const currentModel = modelSelect.value;
|
|
799
|
+
if (!isVisionModel(currentModel)) {
|
|
800
|
+
alert(`The selected model "${currentModel}" does not support image inputs. Please select a model with "Vision" capabilities or choose a different model.`);
|
|
801
|
+
fileAttachment.value = ''; // Clear the input
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Filter only image files
|
|
806
|
+
const imageFiles = Array.from(fileAttachment.files).filter(file => {
|
|
807
|
+
if (!file.type.startsWith('image/')) {
|
|
808
|
+
console.warn(`Skipping non-image file: ${file.name} (${file.type})`);
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
811
|
+
return true;
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
if (imageFiles.length === 0) {
|
|
815
|
+
alert('Please select only image files (PNG, JPG, GIF, etc.)');
|
|
816
|
+
fileAttachment.value = ''; // Clear the input
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (imageFiles.length !== fileAttachment.files.length) {
|
|
821
|
+
alert(`${fileAttachment.files.length - imageFiles.length} non-image file(s) were skipped. Only image files are supported.`);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
attachedFiles = imageFiles;
|
|
825
|
+
updateInputPlaceholder();
|
|
826
|
+
updateAttachmentPreviewVisibility();
|
|
827
|
+
updateAttachmentPreviews();
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
// Handle paste events for images
|
|
832
|
+
chatInput.addEventListener('paste', async (e) => {
|
|
833
|
+
e.preventDefault();
|
|
834
|
+
|
|
835
|
+
const clipboardData = e.clipboardData || window.clipboardData;
|
|
836
|
+
const items = clipboardData.items;
|
|
837
|
+
let hasImage = false;
|
|
838
|
+
let pastedText = '';
|
|
839
|
+
|
|
840
|
+
// Check for text content first
|
|
841
|
+
for (let item of items) {
|
|
842
|
+
if (item.type === 'text/plain') {
|
|
843
|
+
pastedText = clipboardData.getData('text/plain');
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Check for images
|
|
848
|
+
for (let item of items) {
|
|
849
|
+
if (item.type.indexOf('image') !== -1) {
|
|
850
|
+
hasImage = true;
|
|
851
|
+
const file = item.getAsFile();
|
|
852
|
+
if (file && file.type.startsWith('image/')) {
|
|
853
|
+
// Check if current model supports vision before adding image
|
|
854
|
+
const currentModel = modelSelect.value;
|
|
855
|
+
if (!isVisionModel(currentModel)) {
|
|
856
|
+
alert(`The selected model "${currentModel}" does not support image inputs. Please select a model with "Vision" capabilities to paste images.`);
|
|
857
|
+
// Only paste text, skip the image
|
|
858
|
+
if (pastedText) {
|
|
859
|
+
chatInput.value = pastedText;
|
|
860
|
+
}
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
// Add to attachedFiles array only if it's an image and model supports vision
|
|
864
|
+
attachedFiles.push(file);
|
|
865
|
+
} else if (file) {
|
|
866
|
+
console.warn(`Skipping non-image pasted file: ${file.name || 'unknown'} (${file.type})`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Update input box content - only show text, images will be indicated separately
|
|
872
|
+
if (pastedText) {
|
|
873
|
+
chatInput.value = pastedText;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Update placeholder to show attached images
|
|
877
|
+
updateInputPlaceholder();
|
|
878
|
+
updateAttachmentPreviewVisibility();
|
|
879
|
+
updateAttachmentPreviews();
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
// Function to update input placeholder to show attached files
|
|
883
|
+
function updateInputPlaceholder() {
|
|
884
|
+
if (attachedFiles.length > 0) {
|
|
885
|
+
chatInput.placeholder = `Type your message... (${attachedFiles.length} image${attachedFiles.length > 1 ? 's' : ''} attached)`;
|
|
886
|
+
} else {
|
|
887
|
+
chatInput.placeholder = 'Type your message...';
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Add keyboard shortcut to clear attachments
|
|
892
|
+
chatInput.addEventListener('keydown', function(e) {
|
|
893
|
+
if (e.key === 'Escape' && attachedFiles.length > 0) {
|
|
894
|
+
e.preventDefault();
|
|
895
|
+
clearAttachments();
|
|
896
|
+
} else if (e.key === 'Enter') {
|
|
897
|
+
sendMessage();
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
// Function to convert file to base64
|
|
902
|
+
function fileToBase64(file) {
|
|
903
|
+
return new Promise((resolve, reject) => {
|
|
904
|
+
const reader = new FileReader();
|
|
905
|
+
reader.readAsDataURL(file);
|
|
906
|
+
reader.onload = () => resolve(reader.result.split(',')[1]); // Remove data:image/...;base64, prefix
|
|
907
|
+
reader.onerror = error => reject(error);
|
|
908
|
+
});
|
|
909
|
+
}
|
|
544
910
|
|
|
545
911
|
function appendMessage(role, text, isMarkdown = false) {
|
|
546
912
|
const div = document.createElement('div');
|
|
@@ -650,24 +1016,88 @@
|
|
|
650
1016
|
|
|
651
1017
|
async function sendMessage() {
|
|
652
1018
|
const text = chatInput.value.trim();
|
|
653
|
-
if (!text) return;
|
|
654
|
-
|
|
655
|
-
|
|
1019
|
+
if (!text && attachedFiles.length === 0) return;
|
|
1020
|
+
|
|
1021
|
+
// Check if trying to send images to non-vision model
|
|
1022
|
+
if (attachedFiles.length > 0) {
|
|
1023
|
+
const currentModel = modelSelect.value;
|
|
1024
|
+
if (!isVisionModel(currentModel)) {
|
|
1025
|
+
alert(`Cannot send images to model "${currentModel}" as it does not support vision. Please select a model with "Vision" capabilities or remove the attached images.`);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Create message content
|
|
1031
|
+
let messageContent = [];
|
|
1032
|
+
|
|
1033
|
+
// Add text if present
|
|
1034
|
+
if (text) {
|
|
1035
|
+
messageContent.push({
|
|
1036
|
+
type: "text",
|
|
1037
|
+
text: text
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Add images if present
|
|
1042
|
+
if (attachedFiles.length > 0) {
|
|
1043
|
+
for (const file of attachedFiles) {
|
|
1044
|
+
if (file.type.startsWith('image/')) {
|
|
1045
|
+
try {
|
|
1046
|
+
const base64 = await fileToBase64(file);
|
|
1047
|
+
messageContent.push({
|
|
1048
|
+
type: "image_url",
|
|
1049
|
+
image_url: {
|
|
1050
|
+
url: `data:${file.type};base64,${base64}`
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
console.error('Error converting image to base64:', error);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Display user message (show text and file names)
|
|
1061
|
+
let displayText = text;
|
|
1062
|
+
if (attachedFiles.length > 0) {
|
|
1063
|
+
const fileNames = attachedFiles.map(f => f.name || 'pasted-image').join(', ');
|
|
1064
|
+
displayText = displayText ? `${displayText}\n[Images: ${fileNames}]` : `[Images: ${fileNames}]`;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
appendMessage('user', displayText);
|
|
1068
|
+
|
|
1069
|
+
// Add to messages array
|
|
1070
|
+
const userMessage = {
|
|
1071
|
+
role: 'user',
|
|
1072
|
+
content: messageContent.length === 1 && messageContent[0].type === "text"
|
|
1073
|
+
? messageContent[0].text
|
|
1074
|
+
: messageContent
|
|
1075
|
+
};
|
|
1076
|
+
messages.push(userMessage);
|
|
1077
|
+
|
|
1078
|
+
// Clear input and attachments
|
|
656
1079
|
chatInput.value = '';
|
|
1080
|
+
attachedFiles = [];
|
|
1081
|
+
fileAttachment.value = '';
|
|
1082
|
+
updateInputPlaceholder(); // Reset placeholder
|
|
1083
|
+
updateAttachmentPreviewVisibility(); // Hide preview container
|
|
1084
|
+
updateAttachmentPreviews(); // Clear previews
|
|
657
1085
|
sendBtn.disabled = true;
|
|
1086
|
+
|
|
658
1087
|
// Streaming OpenAI completions (placeholder, adapt as needed)
|
|
659
1088
|
let llmText = '';
|
|
660
1089
|
const llmBubble = appendMessage('llm', '...');
|
|
661
1090
|
try {
|
|
662
1091
|
// Use the correct endpoint for chat completions
|
|
663
|
-
const
|
|
1092
|
+
const payload = {
|
|
1093
|
+
model: modelSelect.value,
|
|
1094
|
+
messages: messages,
|
|
1095
|
+
stream: true
|
|
1096
|
+
};
|
|
1097
|
+
const resp = await httpRequest(getServerBaseUrl() + '/api/v1/chat/completions', {
|
|
664
1098
|
method: 'POST',
|
|
665
1099
|
headers: { 'Content-Type': 'application/json' },
|
|
666
|
-
body: JSON.stringify(
|
|
667
|
-
model: modelSelect.value,
|
|
668
|
-
messages: messages,
|
|
669
|
-
stream: true
|
|
670
|
-
})
|
|
1100
|
+
body: JSON.stringify(payload)
|
|
671
1101
|
});
|
|
672
1102
|
if (!resp.body) throw new Error('No stream');
|
|
673
1103
|
const reader = resp.body.getReader();
|
|
@@ -703,16 +1133,25 @@
|
|
|
703
1133
|
}
|
|
704
1134
|
}
|
|
705
1135
|
}
|
|
1136
|
+
if (!llmText) throw new Error('No response');
|
|
706
1137
|
messages.push({ role: 'assistant', content: llmText });
|
|
707
1138
|
} catch (e) {
|
|
708
|
-
|
|
1139
|
+
let detail = e.message;
|
|
1140
|
+
try {
|
|
1141
|
+
const errPayload = { ...payload, stream: false };
|
|
1142
|
+
const errResp = await httpJson(getServerBaseUrl() + '/api/v1/chat/completions', {
|
|
1143
|
+
method: 'POST',
|
|
1144
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1145
|
+
body: JSON.stringify(errPayload)
|
|
1146
|
+
});
|
|
1147
|
+
if (errResp && errResp.detail) detail = errResp.detail;
|
|
1148
|
+
} catch (_) {}
|
|
1149
|
+
llmBubble.textContent = '[Error: ' + detail + ']';
|
|
1150
|
+
showErrorBanner(`Chat error: ${detail}`);
|
|
709
1151
|
}
|
|
710
1152
|
sendBtn.disabled = false;
|
|
711
1153
|
}
|
|
712
1154
|
sendBtn.onclick = sendMessage;
|
|
713
|
-
chatInput.addEventListener('keydown', function(e) {
|
|
714
|
-
if (e.key === 'Enter') sendMessage();
|
|
715
|
-
});
|
|
716
1155
|
|
|
717
1156
|
// Register & Install Model logic
|
|
718
1157
|
const registerForm = document.getElementById('register-model-form');
|
|
@@ -738,14 +1177,11 @@
|
|
|
738
1177
|
btn.disabled = true;
|
|
739
1178
|
btn.textContent = 'Installing...';
|
|
740
1179
|
try {
|
|
741
|
-
|
|
1180
|
+
await httpRequest(getServerBaseUrl() + '/api/v1/pull', {
|
|
742
1181
|
method: 'POST',
|
|
743
1182
|
headers: { 'Content-Type': 'application/json' },
|
|
744
1183
|
body: JSON.stringify(payload)
|
|
745
1184
|
});
|
|
746
|
-
if (!resp.ok) {
|
|
747
|
-
const err = await resp.json().catch(() => ({}));
|
|
748
|
-
throw new Error(err.detail || 'Failed to register model.'); }
|
|
749
1185
|
registerStatus.textContent = 'Model installed!';
|
|
750
1186
|
registerStatus.style.color = '#27ae60';
|
|
751
1187
|
registerStatus.className = 'register-status success';
|
|
@@ -756,6 +1192,7 @@
|
|
|
756
1192
|
registerStatus.textContent = e.message + ' See the Lemonade Server log for details.';
|
|
757
1193
|
registerStatus.style.color = '#dc3545';
|
|
758
1194
|
registerStatus.className = 'register-status error';
|
|
1195
|
+
showErrorBanner(`Model install failed: ${e.message}`);
|
|
759
1196
|
}
|
|
760
1197
|
btn.disabled = false;
|
|
761
1198
|
btn.textContent = 'Install';
|