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.

@@ -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 -->
@@ -141,7 +152,12 @@
141
152
  </table>
142
153
  </div>
143
154
  <div class="model-mgmt-pane">
144
- <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>
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 resp = await fetch(getServerBaseUrl() + '/api/v1/models');
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 resp = await fetch(getServerBaseUrl() + '/api/v1/models');
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
- // Filter suggested models not installed
443
- const suggested = Object.keys(allModels).filter(
444
- k => allModels[k].suggested && !installed.includes(k)
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
- const response = await fetch(getServerBaseUrl() + '/api/v1/delete', {
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
- alert(`Failed to delete model: ${e.message}`);
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
- // 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');
496
690
  const suggestedTbody = document.getElementById('suggested-models-tbody');
497
- suggestedTbody.innerHTML = '';
498
- suggested.forEach(mid => {
499
- const tr = document.createElement('tr');
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
- appendMessage('user', text);
655
- 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
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 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', {
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
- 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}`);
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
- const resp = await fetch(getServerBaseUrl() + '/api/v1/pull', {
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';