lemonade-sdk 8.1.0__py3-none-any.whl → 8.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of lemonade-sdk might be problematic. Click here for more details.

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