lemonade-sdk 8.0.6__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/network.py +18 -1
- lemonade/common/system_info.py +61 -44
- lemonade/tools/llamacpp/bench.py +3 -1
- lemonade/tools/llamacpp/load.py +13 -4
- lemonade/tools/llamacpp/utils.py +229 -61
- lemonade/tools/oga/load.py +239 -112
- lemonade/tools/oga/utils.py +19 -7
- lemonade/tools/server/llamacpp.py +30 -53
- lemonade/tools/server/serve.py +64 -123
- lemonade/tools/server/static/styles.css +208 -6
- lemonade/tools/server/static/webapp.html +510 -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 +90 -86
- {lemonade_sdk-8.0.6.dist-info → lemonade_sdk-8.1.1.dist-info}/METADATA +74 -24
- {lemonade_sdk-8.0.6.dist-info → lemonade_sdk-8.1.1.dist-info}/RECORD +27 -27
- lemonade_server/cli.py +79 -26
- lemonade_server/model_manager.py +4 -3
- lemonade_server/pydantic_models.py +1 -4
- lemonade_server/server_models.json +60 -11
- {lemonade_sdk-8.0.6.dist-info → lemonade_sdk-8.1.1.dist-info}/WHEEL +0 -0
- {lemonade_sdk-8.0.6.dist-info → lemonade_sdk-8.1.1.dist-info}/entry_points.txt +0 -0
- {lemonade_sdk-8.0.6.dist-info → lemonade_sdk-8.1.1.dist-info}/licenses/LICENSE +0 -0
- {lemonade_sdk-8.0.6.dist-info → lemonade_sdk-8.1.1.dist-info}/licenses/NOTICE.md +0 -0
- {lemonade_sdk-8.0.6.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 -->
|
|
@@ -109,6 +120,7 @@
|
|
|
109
120
|
</label>
|
|
110
121
|
<select id="register-recipe" name="recipe" required>
|
|
111
122
|
<option value="llamacpp">llamacpp</option>
|
|
123
|
+
<option value="oga-npu">oga-npu</option>
|
|
112
124
|
<option value="oga-hybrid">oga-hybrid</option>
|
|
113
125
|
<option value="oga-cpu">oga-cpu</option>
|
|
114
126
|
</select>
|
|
@@ -140,7 +152,12 @@
|
|
|
140
152
|
</table>
|
|
141
153
|
</div>
|
|
142
154
|
<div class="model-mgmt-pane">
|
|
143
|
-
<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>
|
|
144
161
|
<table class="model-table" id="suggested-models-table">
|
|
145
162
|
<tbody id="suggested-models-tbody"></tbody>
|
|
146
163
|
</table>
|
|
@@ -213,6 +230,50 @@
|
|
|
213
230
|
}
|
|
214
231
|
}
|
|
215
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
|
+
|
|
216
277
|
// Tab switching logic
|
|
217
278
|
function showTab(tab, updateHash = true) {
|
|
218
279
|
document.getElementById('tab-chat').classList.remove('active');
|
|
@@ -311,11 +372,36 @@
|
|
|
311
372
|
return `http://localhost:${port}`;
|
|
312
373
|
}
|
|
313
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
|
+
|
|
314
401
|
// Populate model dropdown from /api/v1/models endpoint
|
|
315
402
|
async function loadModels() {
|
|
316
403
|
try {
|
|
317
|
-
const
|
|
318
|
-
const data = await resp.json();
|
|
404
|
+
const data = await httpJson(getServerBaseUrl() + '/api/v1/models');
|
|
319
405
|
const select = document.getElementById('model-select');
|
|
320
406
|
select.innerHTML = '';
|
|
321
407
|
if (!data.data || !Array.isArray(data.data)) {
|
|
@@ -379,14 +465,50 @@
|
|
|
379
465
|
console.warn(`Model '${urlModel}' specified in URL not found in available models`);
|
|
380
466
|
}
|
|
381
467
|
}
|
|
468
|
+
|
|
469
|
+
// Update attachment button state after model is loaded
|
|
470
|
+
updateAttachmentButtonState();
|
|
382
471
|
} catch (e) {
|
|
383
472
|
const select = document.getElementById('model-select');
|
|
384
473
|
select.innerHTML = `<option>Error loading models: ${e.message}</option>`;
|
|
385
474
|
console.error('Error loading models:', e);
|
|
475
|
+
showErrorBanner(`Error loading models: ${e.message}`);
|
|
386
476
|
}
|
|
387
477
|
}
|
|
388
478
|
loadModels();
|
|
389
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
|
+
|
|
390
512
|
// Helper function to create model name with labels
|
|
391
513
|
function createModelNameWithLabels(modelId, allModels) {
|
|
392
514
|
// Create container for model name and labels
|
|
@@ -402,8 +524,14 @@
|
|
|
402
524
|
const modelData = allModels[modelId];
|
|
403
525
|
if (modelData && modelData.labels && Array.isArray(modelData.labels)) {
|
|
404
526
|
modelData.labels.forEach(label => {
|
|
405
|
-
const labelSpan = document.createElement('span');
|
|
406
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');
|
|
407
535
|
let labelClass = 'other';
|
|
408
536
|
if (labelLower === 'vision') {
|
|
409
537
|
labelClass = 'vision';
|
|
@@ -413,6 +541,8 @@
|
|
|
413
541
|
labelClass = 'reasoning';
|
|
414
542
|
} else if (labelLower === 'reranking') {
|
|
415
543
|
labelClass = 'reranking';
|
|
544
|
+
} else if (labelLower === 'coding') {
|
|
545
|
+
labelClass = 'coding';
|
|
416
546
|
}
|
|
417
547
|
labelSpan.className = `model-label ${labelClass}`;
|
|
418
548
|
labelSpan.textContent = label;
|
|
@@ -423,23 +553,92 @@
|
|
|
423
553
|
return container;
|
|
424
554
|
}
|
|
425
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
|
+
|
|
426
609
|
// Model Management Tab Logic
|
|
427
610
|
async function refreshModelMgmtUI() {
|
|
428
611
|
// Get installed models from /api/v1/models
|
|
429
612
|
let installed = [];
|
|
430
613
|
try {
|
|
431
|
-
const
|
|
432
|
-
const data = await resp.json();
|
|
614
|
+
const data = await httpJson(getServerBaseUrl() + '/api/v1/models');
|
|
433
615
|
if (data.data && Array.isArray(data.data)) {
|
|
434
616
|
installed = data.data.map(m => m.id || m.name || m);
|
|
435
617
|
}
|
|
436
|
-
} catch (e) {
|
|
618
|
+
} catch (e) {
|
|
619
|
+
showErrorBanner(`Error loading models: ${e.message}`);
|
|
620
|
+
}
|
|
437
621
|
// All models from server_models.json (window.SERVER_MODELS)
|
|
438
622
|
const allModels = window.SERVER_MODELS || {};
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
+
});
|
|
443
642
|
// Render installed models as a table (two columns, second is invisible)
|
|
444
643
|
const installedTbody = document.getElementById('installed-models-tbody');
|
|
445
644
|
installedTbody.innerHTML = '';
|
|
@@ -466,21 +665,17 @@
|
|
|
466
665
|
btn.textContent = 'Deleting...';
|
|
467
666
|
btn.style.backgroundColor = '#888';
|
|
468
667
|
try {
|
|
469
|
-
|
|
668
|
+
await httpRequest(getServerBaseUrl() + '/api/v1/delete', {
|
|
470
669
|
method: 'POST',
|
|
471
670
|
headers: { 'Content-Type': 'application/json' },
|
|
472
671
|
body: JSON.stringify({ model_name: mid })
|
|
473
672
|
});
|
|
474
|
-
if (!response.ok) {
|
|
475
|
-
const errorData = await response.json();
|
|
476
|
-
throw new Error(errorData.detail || 'Failed to delete model');
|
|
477
|
-
}
|
|
478
673
|
await refreshModelMgmtUI();
|
|
479
674
|
await loadModels(); // update chat dropdown too
|
|
480
675
|
} catch (e) {
|
|
481
676
|
btn.textContent = 'Error';
|
|
482
677
|
btn.disabled = false;
|
|
483
|
-
|
|
678
|
+
showErrorBanner(`Failed to delete model: ${e.message}`);
|
|
484
679
|
}
|
|
485
680
|
};
|
|
486
681
|
tdBtn.appendChild(btn);
|
|
@@ -489,43 +684,13 @@
|
|
|
489
684
|
tr.appendChild(tdBtn);
|
|
490
685
|
installedTbody.appendChild(tr);
|
|
491
686
|
});
|
|
492
|
-
|
|
687
|
+
|
|
688
|
+
// Render hot models and suggested models using the helper function
|
|
689
|
+
const hotTbody = document.getElementById('hot-models-tbody');
|
|
493
690
|
const suggestedTbody = document.getElementById('suggested-models-tbody');
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
const tdName = document.createElement('td');
|
|
498
|
-
|
|
499
|
-
tdName.appendChild(createModelNameWithLabels(mid, allModels));
|
|
500
|
-
tdName.style.paddingRight = '1em';
|
|
501
|
-
tdName.style.verticalAlign = 'middle';
|
|
502
|
-
const tdBtn = document.createElement('td');
|
|
503
|
-
tdBtn.style.width = '1%';
|
|
504
|
-
tdBtn.style.verticalAlign = 'middle';
|
|
505
|
-
const btn = document.createElement('button');
|
|
506
|
-
btn.textContent = '+';
|
|
507
|
-
btn.title = 'Install model';
|
|
508
|
-
btn.onclick = async function() {
|
|
509
|
-
btn.disabled = true;
|
|
510
|
-
btn.textContent = 'Installing...';
|
|
511
|
-
btn.classList.add('installing-btn');
|
|
512
|
-
try {
|
|
513
|
-
await fetch(getServerBaseUrl() + '/api/v1/pull', {
|
|
514
|
-
method: 'POST',
|
|
515
|
-
headers: { 'Content-Type': 'application/json' },
|
|
516
|
-
body: JSON.stringify({ model_name: mid })
|
|
517
|
-
});
|
|
518
|
-
await refreshModelMgmtUI();
|
|
519
|
-
await loadModels(); // update chat dropdown too
|
|
520
|
-
} catch (e) {
|
|
521
|
-
btn.textContent = 'Error';
|
|
522
|
-
}
|
|
523
|
-
};
|
|
524
|
-
tdBtn.appendChild(btn);
|
|
525
|
-
tr.appendChild(tdName);
|
|
526
|
-
tr.appendChild(tdBtn);
|
|
527
|
-
suggestedTbody.appendChild(tr);
|
|
528
|
-
});
|
|
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!");
|
|
529
694
|
}
|
|
530
695
|
// Initial load
|
|
531
696
|
refreshModelMgmtUI();
|
|
@@ -536,8 +701,211 @@
|
|
|
536
701
|
const chatHistory = document.getElementById('chat-history');
|
|
537
702
|
const chatInput = document.getElementById('chat-input');
|
|
538
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');
|
|
539
708
|
const modelSelect = document.getElementById('model-select');
|
|
540
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
|
+
}
|
|
541
909
|
|
|
542
910
|
function appendMessage(role, text, isMarkdown = false) {
|
|
543
911
|
const div = document.createElement('div');
|
|
@@ -647,24 +1015,88 @@
|
|
|
647
1015
|
|
|
648
1016
|
async function sendMessage() {
|
|
649
1017
|
const text = chatInput.value.trim();
|
|
650
|
-
if (!text) return;
|
|
651
|
-
|
|
652
|
-
|
|
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
|
|
653
1078
|
chatInput.value = '';
|
|
1079
|
+
attachedFiles = [];
|
|
1080
|
+
fileAttachment.value = '';
|
|
1081
|
+
updateInputPlaceholder(); // Reset placeholder
|
|
1082
|
+
updateAttachmentPreviewVisibility(); // Hide preview container
|
|
1083
|
+
updateAttachmentPreviews(); // Clear previews
|
|
654
1084
|
sendBtn.disabled = true;
|
|
1085
|
+
|
|
655
1086
|
// Streaming OpenAI completions (placeholder, adapt as needed)
|
|
656
1087
|
let llmText = '';
|
|
657
1088
|
const llmBubble = appendMessage('llm', '...');
|
|
658
1089
|
try {
|
|
659
1090
|
// Use the correct endpoint for chat completions
|
|
660
|
-
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', {
|
|
661
1097
|
method: 'POST',
|
|
662
1098
|
headers: { 'Content-Type': 'application/json' },
|
|
663
|
-
body: JSON.stringify(
|
|
664
|
-
model: modelSelect.value,
|
|
665
|
-
messages: messages,
|
|
666
|
-
stream: true
|
|
667
|
-
})
|
|
1099
|
+
body: JSON.stringify(payload)
|
|
668
1100
|
});
|
|
669
1101
|
if (!resp.body) throw new Error('No stream');
|
|
670
1102
|
const reader = resp.body.getReader();
|
|
@@ -700,16 +1132,25 @@
|
|
|
700
1132
|
}
|
|
701
1133
|
}
|
|
702
1134
|
}
|
|
1135
|
+
if (!llmText) throw new Error('No response');
|
|
703
1136
|
messages.push({ role: 'assistant', content: llmText });
|
|
704
1137
|
} catch (e) {
|
|
705
|
-
|
|
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}`);
|
|
706
1150
|
}
|
|
707
1151
|
sendBtn.disabled = false;
|
|
708
1152
|
}
|
|
709
1153
|
sendBtn.onclick = sendMessage;
|
|
710
|
-
chatInput.addEventListener('keydown', function(e) {
|
|
711
|
-
if (e.key === 'Enter') sendMessage();
|
|
712
|
-
});
|
|
713
1154
|
|
|
714
1155
|
// Register & Install Model logic
|
|
715
1156
|
const registerForm = document.getElementById('register-model-form');
|
|
@@ -735,14 +1176,11 @@
|
|
|
735
1176
|
btn.disabled = true;
|
|
736
1177
|
btn.textContent = 'Installing...';
|
|
737
1178
|
try {
|
|
738
|
-
|
|
1179
|
+
await httpRequest(getServerBaseUrl() + '/api/v1/pull', {
|
|
739
1180
|
method: 'POST',
|
|
740
1181
|
headers: { 'Content-Type': 'application/json' },
|
|
741
1182
|
body: JSON.stringify(payload)
|
|
742
1183
|
});
|
|
743
|
-
if (!resp.ok) {
|
|
744
|
-
const err = await resp.json().catch(() => ({}));
|
|
745
|
-
throw new Error(err.detail || 'Failed to register model.'); }
|
|
746
1184
|
registerStatus.textContent = 'Model installed!';
|
|
747
1185
|
registerStatus.style.color = '#27ae60';
|
|
748
1186
|
registerStatus.className = 'register-status success';
|
|
@@ -753,6 +1191,7 @@
|
|
|
753
1191
|
registerStatus.textContent = e.message + ' See the Lemonade Server log for details.';
|
|
754
1192
|
registerStatus.style.color = '#dc3545';
|
|
755
1193
|
registerStatus.className = 'register-status error';
|
|
1194
|
+
showErrorBanner(`Model install failed: ${e.message}`);
|
|
756
1195
|
}
|
|
757
1196
|
btn.disabled = false;
|
|
758
1197
|
btn.textContent = 'Install';
|