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