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.

@@ -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()">&times;</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
- <input type="text" id="chat-input" placeholder="Type your message..." />
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">&#x1F4CE;</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>Suggested Models</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 resp = await fetch(getServerBaseUrl() + '/api/v1/models');
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 resp = await fetch(getServerBaseUrl() + '/api/v1/models');
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
- // Filter suggested models not installed
440
- const suggested = Object.keys(allModels).filter(
441
- k => allModels[k].suggested && !installed.includes(k)
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
- const response = await fetch(getServerBaseUrl() + '/api/v1/delete', {
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
- alert(`Failed to delete model: ${e.message}`);
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
- // Render suggested models as a table
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
- suggestedTbody.innerHTML = '';
495
- suggested.forEach(mid => {
496
- const tr = document.createElement('tr');
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
- appendMessage('user', text);
652
- messages.push({ role: 'user', content: text });
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 resp = await fetch(getServerBaseUrl() + '/api/v1/chat/completions', {
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
- llmBubble.textContent = '[Error: ' + e.message + ']';
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
- const resp = await fetch(getServerBaseUrl() + '/api/v1/pull', {
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';