lemonade-sdk 8.1.4__py3-none-any.whl → 8.2.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.

Files changed (53) hide show
  1. lemonade/cache.py +6 -1
  2. lemonade/cli.py +47 -5
  3. lemonade/common/inference_engines.py +13 -4
  4. lemonade/common/status.py +4 -4
  5. lemonade/common/system_info.py +544 -1
  6. lemonade/profilers/agt_power.py +437 -0
  7. lemonade/profilers/hwinfo_power.py +429 -0
  8. lemonade/tools/accuracy.py +143 -48
  9. lemonade/tools/adapter.py +6 -1
  10. lemonade/tools/bench.py +26 -8
  11. lemonade/tools/flm/__init__.py +1 -0
  12. lemonade/tools/flm/utils.py +303 -0
  13. lemonade/tools/huggingface/bench.py +6 -1
  14. lemonade/tools/llamacpp/bench.py +146 -27
  15. lemonade/tools/llamacpp/load.py +30 -2
  16. lemonade/tools/llamacpp/utils.py +393 -33
  17. lemonade/tools/oga/bench.py +5 -26
  18. lemonade/tools/oga/load.py +60 -121
  19. lemonade/tools/oga/migration.py +403 -0
  20. lemonade/tools/report/table.py +76 -8
  21. lemonade/tools/server/flm.py +133 -0
  22. lemonade/tools/server/llamacpp.py +220 -553
  23. lemonade/tools/server/serve.py +684 -168
  24. lemonade/tools/server/static/js/chat.js +666 -342
  25. lemonade/tools/server/static/js/model-settings.js +24 -3
  26. lemonade/tools/server/static/js/models.js +597 -73
  27. lemonade/tools/server/static/js/shared.js +79 -14
  28. lemonade/tools/server/static/logs.html +191 -0
  29. lemonade/tools/server/static/styles.css +491 -66
  30. lemonade/tools/server/static/webapp.html +83 -31
  31. lemonade/tools/server/tray.py +158 -38
  32. lemonade/tools/server/utils/macos_tray.py +226 -0
  33. lemonade/tools/server/utils/{system_tray.py → windows_tray.py} +13 -0
  34. lemonade/tools/server/webapp.py +4 -1
  35. lemonade/tools/server/wrapped_server.py +559 -0
  36. lemonade/version.py +1 -1
  37. lemonade_install/install.py +54 -611
  38. {lemonade_sdk-8.1.4.dist-info → lemonade_sdk-8.2.2.dist-info}/METADATA +29 -72
  39. lemonade_sdk-8.2.2.dist-info/RECORD +83 -0
  40. lemonade_server/cli.py +145 -37
  41. lemonade_server/model_manager.py +521 -37
  42. lemonade_server/pydantic_models.py +28 -1
  43. lemonade_server/server_models.json +246 -92
  44. lemonade_server/settings.py +39 -39
  45. lemonade/tools/quark/__init__.py +0 -0
  46. lemonade/tools/quark/quark_load.py +0 -173
  47. lemonade/tools/quark/quark_quantize.py +0 -439
  48. lemonade_sdk-8.1.4.dist-info/RECORD +0 -77
  49. {lemonade_sdk-8.1.4.dist-info → lemonade_sdk-8.2.2.dist-info}/WHEEL +0 -0
  50. {lemonade_sdk-8.1.4.dist-info → lemonade_sdk-8.2.2.dist-info}/entry_points.txt +0 -0
  51. {lemonade_sdk-8.1.4.dist-info → lemonade_sdk-8.2.2.dist-info}/licenses/LICENSE +0 -0
  52. {lemonade_sdk-8.1.4.dist-info → lemonade_sdk-8.2.2.dist-info}/licenses/NOTICE.md +0 -0
  53. {lemonade_sdk-8.1.4.dist-info → lemonade_sdk-8.2.2.dist-info}/top_level.txt +0 -0
@@ -1,18 +1,29 @@
1
1
  // Chat logic and functionality
2
2
  let messages = [];
3
3
  let attachedFiles = [];
4
+ let systemMessageElement = null;
5
+ let abortController = null;
4
6
 
5
- // Default model configuration
6
- const DEFAULT_MODEL = 'Qwen2.5-0.5B-Instruct-CPU';
7
+ const THINKING_ANIM_INTERVAL_MS = 550;
8
+ // Toggle this to false if you prefer plain dots only.
9
+ const THINKING_USE_LEMON = true;
10
+ const THINKING_FRAMES = THINKING_USE_LEMON
11
+ ? ['Thinking.','Thinking..','Thinking...','Thinking 🍋']
12
+ : ['Thinking.','Thinking..','Thinking...'];
7
13
 
8
14
  // Get DOM elements
9
- let chatHistory, chatInput, sendBtn, attachmentBtn, fileAttachment, attachmentsPreviewContainer, attachmentsPreviewRow, modelSelect;
15
+ let chatHistory, chatInput, attachmentBtn, fileAttachment, attachmentsPreviewContainer, attachmentsPreviewRow, modelSelect, toggleBtn;
16
+ // Track if a stream is currently active (separate from abortController existing briefly before validation)
17
+ let isStreaming = false;
18
+ // When the user scrolls up in the chat history, disable automatic scrolling until they scroll back to the bottom.
19
+ let autoscrollEnabled = true;
20
+ const AUTOSCROLL_TOLERANCE_PX = 10;
10
21
 
11
22
  // Initialize chat functionality when DOM is loaded
12
23
  document.addEventListener('DOMContentLoaded', function() {
13
24
  chatHistory = document.getElementById('chat-history');
14
25
  chatInput = document.getElementById('chat-input');
15
- sendBtn = document.getElementById('send-btn');
26
+ toggleBtn = document.getElementById('toggle-btn');
16
27
  attachmentBtn = document.getElementById('attachment-btn');
17
28
  fileAttachment = document.getElementById('file-attachment');
18
29
  attachmentsPreviewContainer = document.getElementById('attachments-preview-container');
@@ -21,61 +32,80 @@ document.addEventListener('DOMContentLoaded', function() {
21
32
 
22
33
  // Set up event listeners
23
34
  setupChatEventListeners();
24
-
35
+
36
+ // Pause autoscroll when user scrolls up in the chat history. If they scroll back to bottom, resume.
37
+ if (chatHistory) {
38
+ chatHistory.addEventListener('scroll', function () {
39
+ try {
40
+ const atBottom = chatHistory.scrollTop + chatHistory.clientHeight >= chatHistory.scrollHeight - AUTOSCROLL_TOLERANCE_PX;
41
+ if (atBottom) {
42
+ if (!autoscrollEnabled) {
43
+ autoscrollEnabled = true;
44
+ chatHistory.classList.remove('autoscroll-paused');
45
+ }
46
+ } else {
47
+ if (autoscrollEnabled) {
48
+ autoscrollEnabled = false;
49
+ chatHistory.classList.add('autoscroll-paused');
50
+ }
51
+ }
52
+ } catch (_) {}
53
+ });
54
+ }
55
+
25
56
  // Initialize model dropdown (will be populated when models.js calls updateModelStatusIndicator)
26
57
  initializeModelDropdown();
27
-
58
+
28
59
  // Update attachment button state periodically
29
60
  updateAttachmentButtonState();
30
61
  setInterval(updateAttachmentButtonState, 1000);
62
+
63
+ // Display initial system message
64
+ displaySystemMessage();
31
65
  });
32
66
 
33
67
  function setupChatEventListeners() {
34
- // Send button click
35
- sendBtn.onclick = sendMessage;
36
-
37
- // Attachment button click
38
- attachmentBtn.onclick = () => {
39
- if (!currentLoadedModel) {
40
- alert('Please load a model first before attaching images.');
41
- return;
42
- }
43
- if (!isVisionModel(currentLoadedModel)) {
44
- alert(`The current model "${currentLoadedModel}" does not support image inputs. Please load a model with "Vision" capabilities to attach images.`);
45
- return;
68
+ // Toggle button click – send or stop streaming
69
+ toggleBtn.onclick = function () {
70
+ if (abortController) {
71
+ abortCurrentRequest();
72
+ } else {
73
+ sendMessage();
46
74
  }
47
- fileAttachment.click();
48
75
  };
49
76
 
50
- // File input change
51
- fileAttachment.addEventListener('change', handleFileSelection);
77
+ // Send on Enter, clear attachments on Escape
78
+ if (chatInput) {
79
+ chatInput.addEventListener('keydown', handleChatInputKeydown);
80
+ chatInput.addEventListener('paste', handleChatInputPaste);
81
+ }
52
82
 
53
- // Chat input events
54
- chatInput.addEventListener('keydown', handleChatInputKeydown);
55
- chatInput.addEventListener('paste', handleChatInputPaste);
56
-
57
- // Model select change
58
- modelSelect.addEventListener('change', handleModelSelectChange);
59
-
60
- // Send button click
61
- sendBtn.addEventListener('click', function() {
62
- // Check if we have a loaded model
63
- if (currentLoadedModel && modelSelect.value !== '' && !modelSelect.disabled) {
64
- sendMessage();
65
- } else if (!currentLoadedModel) {
66
- // Auto-load default model and send
67
- autoLoadDefaultModelAndSend();
68
- }
69
- });
83
+ // Open file picker and handle image selection
84
+ if (attachmentBtn && fileAttachment) {
85
+ attachmentBtn.addEventListener('click', function () {
86
+ // Let the selection handler validate vision capability, etc.
87
+ fileAttachment.click();
88
+ });
89
+ fileAttachment.addEventListener('change', handleFileSelection);
90
+ }
91
+
92
+ // React to model selection changes
93
+ if (modelSelect) {
94
+ modelSelect.addEventListener('change', handleModelSelectChange);
95
+ }
70
96
  }
71
97
 
72
98
  // Initialize model dropdown with available models
73
99
  function initializeModelDropdown() {
74
100
  const allModels = window.SERVER_MODELS || {};
75
-
101
+
76
102
  // Clear existing options except the first one
77
- modelSelect.innerHTML = '<option value="">Pick a model</option>';
78
-
103
+ const indicator = document.getElementById('model-status-indicator');
104
+ if (indicator.classList.contains('offline') || modelSelect.value === 'server-offline') {
105
+ modelSelect.value = 'server-offline';
106
+ } else {
107
+ modelSelect.innerHTML = '<option value="">Click to select a model ▼</option>';
108
+ }
79
109
  // Add only installed models to dropdown
80
110
  Object.keys(allModels).forEach(modelId => {
81
111
  // Only add if the model is installed
@@ -86,7 +116,7 @@ function initializeModelDropdown() {
86
116
  modelSelect.appendChild(option);
87
117
  }
88
118
  });
89
-
119
+
90
120
  // Set current selection based on loaded model
91
121
  updateModelSelectValue();
92
122
  }
@@ -96,10 +126,15 @@ window.initializeModelDropdown = initializeModelDropdown;
96
126
 
97
127
  // Update model select value to match currently loaded model
98
128
  function updateModelSelectValue() {
99
- if (currentLoadedModel) {
129
+ const indicator = document.getElementById('model-status-indicator');
130
+ if (currentLoadedModel && indicator.classList.contains('loading')) {
131
+ modelSelect.value = 'loading-model';
132
+ } else if (currentLoadedModel) {
100
133
  modelSelect.value = currentLoadedModel;
134
+ } else if (indicator.classList.contains('offline') && modelSelect.value === 'server-offline') {
135
+ modelSelect.value = 'server-offline';
101
136
  } else {
102
- modelSelect.value = '';
137
+ return;
103
138
  }
104
139
  }
105
140
 
@@ -109,38 +144,40 @@ window.updateModelSelectValue = updateModelSelectValue;
109
144
  // Handle model selection change
110
145
  async function handleModelSelectChange() {
111
146
  const selectedModel = modelSelect.value;
112
-
113
- if (!selectedModel) {
114
- return; // "Pick a model" selected
115
- }
116
-
117
- if (selectedModel === currentLoadedModel) {
118
- return; // Same model already loaded
119
- }
120
-
147
+
148
+ if (!selectedModel) return; // "Click to select a model ▼" selected
149
+ if (selectedModel === currentLoadedModel) return; // Same model already loaded
150
+
121
151
  // Use the standardized load function
122
152
  await loadModelStandardized(selectedModel, {
123
153
  onLoadingStart: (modelId) => {
124
154
  // Update dropdown to show loading state with model name
125
- const loadingOption = modelSelect.querySelector('option[value=""]');
155
+ const loadingOption = document.createElement('option');
156
+ const select = document.getElementById('model-select');
157
+ select.innerHTML = '';
158
+
126
159
  if (loadingOption) {
160
+ loadingOption.value = 'loading-model';
127
161
  loadingOption.textContent = `Loading ${modelId}...`;
162
+ loadingOption.hidden = true;
163
+ select.appendChild(loadingOption);
128
164
  }
165
+ // Gray out send button during loading
166
+ updateAttachmentButtonState();
129
167
  },
130
168
  onLoadingEnd: (modelId, success) => {
131
169
  // Reset the default option text
132
170
  const defaultOption = modelSelect.querySelector('option[value=""]');
133
- if (defaultOption) {
134
- defaultOption.textContent = 'Pick a model';
135
- }
136
- },
137
- onSuccess: (loadedModelId) => {
138
- // Update attachment button state for new model
171
+ if (defaultOption) defaultOption.textContent = 'Click to select a model ▼';
172
+ // Update button state after loading completes
173
+ updateAttachmentButtonState();
174
+ },
175
+ onSuccess: () => {
139
176
  updateAttachmentButtonState();
140
177
  },
141
- onError: (error, failedModelId) => {
142
- // Reset dropdown to previous value on error
178
+ onError: () => {
143
179
  updateModelSelectValue();
180
+ updateAttachmentButtonState();
144
181
  }
145
182
  });
146
183
  }
@@ -149,77 +186,47 @@ async function handleModelSelectChange() {
149
186
  function updateAttachmentButtonState() {
150
187
  // Update model dropdown selection
151
188
  updateModelSelectValue();
152
-
189
+
153
190
  // Update send button state based on model loading
154
- if (modelSelect.disabled) {
155
- sendBtn.disabled = true;
156
- sendBtn.textContent = 'Loading...';
157
- } else {
158
- sendBtn.disabled = false;
159
- sendBtn.textContent = 'Send';
191
+ if (toggleBtn) {
192
+ const loading = !!(modelSelect && modelSelect.disabled);
193
+ if (isStreaming) {
194
+ toggleBtn.disabled = false;
195
+ toggleBtn.textContent = 'Stop';
196
+ } else {
197
+ // Gray out send button if no model is loaded or if loading
198
+ toggleBtn.disabled = loading || !currentLoadedModel;
199
+ toggleBtn.textContent = 'Send';
200
+ }
160
201
  }
161
202
 
162
203
  if (!currentLoadedModel) {
163
204
  attachmentBtn.style.opacity = '0.5';
164
205
  attachmentBtn.style.cursor = 'not-allowed';
165
206
  attachmentBtn.title = 'Load a model first';
166
- return;
167
- }
168
-
169
- const isVision = isVisionModel(currentLoadedModel);
170
-
171
- if (isVision) {
172
- attachmentBtn.style.opacity = '1';
173
- attachmentBtn.style.cursor = 'pointer';
174
- attachmentBtn.title = 'Attach images';
175
207
  } else {
176
- attachmentBtn.style.opacity = '0.5';
177
- attachmentBtn.style.cursor = 'not-allowed';
178
- attachmentBtn.title = 'Image attachments not supported by this model';
208
+ const isVision = isVisionModel(currentLoadedModel);
209
+
210
+ if (isVision) {
211
+ attachmentBtn.style.opacity = '1';
212
+ attachmentBtn.style.cursor = 'pointer';
213
+ attachmentBtn.title = 'Attach images';
214
+ } else {
215
+ attachmentBtn.style.opacity = '0.5';
216
+ attachmentBtn.style.cursor = 'not-allowed';
217
+ attachmentBtn.title = 'Image attachments not supported by this model';
218
+ }
179
219
  }
220
+
221
+ // Update system message when model state changes
222
+ displaySystemMessage();
180
223
  }
181
224
 
182
225
  // Make updateAttachmentButtonState accessible globally
183
226
  window.updateAttachmentButtonState = updateAttachmentButtonState;
184
227
 
185
- // Auto-load default model and send message
186
- async function autoLoadDefaultModelAndSend() {
187
- // Check if default model is available and installed
188
- if (!window.SERVER_MODELS || !window.SERVER_MODELS[DEFAULT_MODEL]) {
189
- showErrorBanner('No models available. Please install a model first.');
190
- return;
191
- }
192
-
193
- if (!window.installedModels || !window.installedModels.has(DEFAULT_MODEL)) {
194
- showErrorBanner('Default model is not installed. Please install it from the Model Management tab.');
195
- return;
196
- }
197
-
198
- // Store the message to send after loading
199
- const messageToSend = chatInput.value.trim();
200
- if (!messageToSend && attachedFiles.length === 0) {
201
- return; // Nothing to send
202
- }
203
-
204
- // Use the standardized load function
205
- const success = await loadModelStandardized(DEFAULT_MODEL, {
206
- onLoadingStart: (modelId) => {
207
- // Custom UI updates for auto-loading
208
- sendBtn.textContent = 'Loading model...';
209
- },
210
- onLoadingEnd: (modelId, loadSuccess) => {
211
- // Reset send button text
212
- sendBtn.textContent = 'Send';
213
- },
214
- onSuccess: (loadedModelId) => {
215
- // Send the message after successful load
216
- sendMessage(messageToSend);
217
- },
218
- onError: (error, failedModelId) => {
219
- console.error('Error auto-loading default model:', error);
220
- }
221
- });
222
- }
228
+ // Make displaySystemMessage accessible globally
229
+ window.displaySystemMessage = displaySystemMessage;
223
230
 
224
231
  // Check if model supports vision and update attachment button
225
232
  function checkCurrentModel() {
@@ -237,15 +244,15 @@ function handleFileSelection() {
237
244
  // Check if current model supports vision
238
245
  if (!currentLoadedModel) {
239
246
  alert('Please load a model first before attaching images.');
240
- fileAttachment.value = ''; // Clear the input
247
+ fileAttachment.value = '';
241
248
  return;
242
249
  }
243
250
  if (!isVisionModel(currentLoadedModel)) {
244
251
  alert(`The current model "${currentLoadedModel}" does not support image inputs. Please load a model with "Vision" capabilities.`);
245
- fileAttachment.value = ''; // Clear the input
252
+ fileAttachment.value = '';
246
253
  return;
247
254
  }
248
-
255
+
249
256
  // Filter only image files
250
257
  const imageFiles = Array.from(fileAttachment.files).filter(file => {
251
258
  if (!file.type.startsWith('image/')) {
@@ -254,17 +261,17 @@ function handleFileSelection() {
254
261
  }
255
262
  return true;
256
263
  });
257
-
264
+
258
265
  if (imageFiles.length === 0) {
259
266
  alert('Please select only image files (PNG, JPG, GIF, etc.)');
260
- fileAttachment.value = ''; // Clear the input
267
+ fileAttachment.value = '';
261
268
  return;
262
269
  }
263
-
270
+
264
271
  if (imageFiles.length !== fileAttachment.files.length) {
265
272
  alert(`${fileAttachment.files.length - imageFiles.length} non-image file(s) were skipped. Only image files are supported.`);
266
273
  }
267
-
274
+
268
275
  attachedFiles = imageFiles;
269
276
  updateInputPlaceholder();
270
277
  updateAttachmentPreviewVisibility();
@@ -277,33 +284,30 @@ function handleChatInputKeydown(e) {
277
284
  if (e.key === 'Escape' && attachedFiles.length > 0) {
278
285
  e.preventDefault();
279
286
  clearAttachments();
280
- } else if (e.key === 'Enter') {
281
- // Check if we have a loaded model
287
+ } else if (e.key === 'Enter' && !e.shiftKey) {
288
+ e.preventDefault();
289
+ // Only send if we have a loaded model
282
290
  if (currentLoadedModel && modelSelect.value !== '' && !modelSelect.disabled) {
283
291
  sendMessage();
284
- } else if (!currentLoadedModel) {
285
- // Auto-load default model and send
286
- autoLoadDefaultModelAndSend();
287
292
  }
293
+ // Otherwise do nothing - button is grayed out
288
294
  }
289
295
  }
290
296
 
291
297
  // Handle paste events for images
292
298
  async function handleChatInputPaste(e) {
293
- e.preventDefault();
294
-
295
299
  const clipboardData = e.clipboardData || window.clipboardData;
296
300
  const items = clipboardData.items;
297
301
  let hasImage = false;
298
302
  let pastedText = '';
299
-
303
+
300
304
  // Check for text content first
301
305
  for (let item of items) {
302
306
  if (item.type === 'text/plain') {
303
307
  pastedText = clipboardData.getData('text/plain');
304
308
  }
305
309
  }
306
-
310
+
307
311
  // Check for images
308
312
  for (let item of items) {
309
313
  if (item.type.indexOf('image') !== -1) {
@@ -314,10 +318,7 @@ async function handleChatInputPaste(e) {
314
318
  const currentModel = modelSelect.value;
315
319
  if (!isVisionModel(currentModel)) {
316
320
  alert(`The selected model "${currentModel}" does not support image inputs. Please select a model with "Vision" capabilities to paste images.`);
317
- // Only paste text, skip the image
318
- if (pastedText) {
319
- chatInput.value = pastedText;
320
- }
321
+ // Don't prevent default if we're not handling the paste
321
322
  return;
322
323
  }
323
324
  // Add to attachedFiles array only if it's an image and model supports vision
@@ -327,16 +328,40 @@ async function handleChatInputPaste(e) {
327
328
  }
328
329
  }
329
330
  }
330
-
331
- // Update input box content - only show text, images will be indicated separately
332
- if (pastedText) {
333
- chatInput.value = pastedText;
331
+
332
+ // If we have images, prevent default and handle specially
333
+ if (hasImage && attachedFiles.length > 0) {
334
+ e.preventDefault();
335
+
336
+ // If there's also text, insert it at cursor position
337
+ if (pastedText) {
338
+ insertTextAtCursor(chatInput, pastedText);
339
+ }
340
+
341
+ // Update placeholder to show attached images
342
+ updateInputPlaceholder();
343
+ updateAttachmentPreviewVisibility();
344
+ updateAttachmentPreviews();
334
345
  }
346
+ // If no images, let the browser handle the paste normally (preserves cursor position and undo)
347
+ }
348
+
349
+ // Helper function to insert text at cursor position
350
+ function insertTextAtCursor(textElement, text) {
351
+ const start = textElement.selectionStart;
352
+ const end = textElement.selectionEnd;
353
+ const currentValue = textElement.value;
335
354
 
336
- // Update placeholder to show attached images
337
- updateInputPlaceholder();
338
- updateAttachmentPreviewVisibility();
339
- updateAttachmentPreviews();
355
+ // Insert the text at the cursor position
356
+ const newValue = currentValue.substring(0, start) + text + currentValue.substring(end);
357
+ textElement.value = newValue;
358
+
359
+ // Move cursor to end of inserted text
360
+ const newCursorPos = start + text.length;
361
+ textElement.setSelectionRange(newCursorPos, newCursorPos);
362
+
363
+ // Focus the element to ensure cursor is visible
364
+ textElement.focus();
340
365
  }
341
366
 
342
367
  function clearAttachments() {
@@ -358,46 +383,42 @@ function updateAttachmentPreviewVisibility() {
358
383
  function updateAttachmentPreviews() {
359
384
  // Clear existing previews
360
385
  attachmentsPreviewRow.innerHTML = '';
361
-
362
- if (attachedFiles.length === 0) {
363
- return;
364
- }
365
-
386
+
387
+ if (attachedFiles.length === 0) return;
388
+
366
389
  attachedFiles.forEach((file, index) => {
367
390
  // Skip non-image files (extra safety check)
368
391
  if (!file.type.startsWith('image/')) {
369
392
  console.warn(`Skipping non-image file in preview: ${file.name} (${file.type})`);
370
393
  return;
371
394
  }
372
-
395
+
373
396
  const previewDiv = document.createElement('div');
374
397
  previewDiv.className = 'attachment-preview';
375
-
398
+
376
399
  // Create thumbnail
377
400
  const thumbnail = document.createElement('img');
378
401
  thumbnail.className = 'attachment-thumbnail';
379
402
  thumbnail.alt = file.name;
380
-
403
+
381
404
  // Create filename display
382
405
  const filename = document.createElement('div');
383
406
  filename.className = 'attachment-filename';
384
407
  filename.textContent = file.name || `pasted-image-${index + 1}`;
385
408
  filename.title = file.name || `pasted-image-${index + 1}`;
386
-
409
+
387
410
  // Create remove button
388
411
  const removeBtn = document.createElement('button');
389
412
  removeBtn.className = 'attachment-remove-btn';
390
413
  removeBtn.innerHTML = '✕';
391
414
  removeBtn.title = 'Remove this image';
392
415
  removeBtn.onclick = () => removeAttachment(index);
393
-
416
+
394
417
  // Generate thumbnail for image
395
418
  const reader = new FileReader();
396
- reader.onload = (e) => {
397
- thumbnail.src = e.target.result;
398
- };
419
+ reader.onload = (e) => { thumbnail.src = e.target.result; };
399
420
  reader.readAsDataURL(file);
400
-
421
+
401
422
  previewDiv.appendChild(thumbnail);
402
423
  previewDiv.appendChild(filename);
403
424
  previewDiv.appendChild(removeBtn);
@@ -426,178 +447,406 @@ function fileToBase64(file) {
426
447
  return new Promise((resolve, reject) => {
427
448
  const reader = new FileReader();
428
449
  reader.readAsDataURL(file);
429
- reader.onload = () => resolve(reader.result.split(',')[1]); // Remove data:image/...;base64, prefix
450
+ reader.onload = () => resolve(reader.result.split(',')[1]);
430
451
  reader.onerror = error => reject(error);
431
452
  });
432
453
  }
433
454
 
455
+ /**
456
+ * Incrementally (re)renders reasoning + answer without blowing away the header so user
457
+ * collapsing/expanding persists while tokens stream.
458
+ */
459
+ function updateMessageContent(bubbleElement, text, isMarkdown = false) {
460
+ if (!isMarkdown) {
461
+ bubbleElement.textContent = text;
462
+ return;
463
+ }
464
+
465
+ const { main, thought, isThinking } = parseReasoningBlocks(text);
466
+
467
+ // Pure normal markdown (no reasoning)
468
+ if (!thought.trim()) {
469
+ // If structure existed before, replace fully (safe—no toggle needed)
470
+ bubbleElement.innerHTML = renderMarkdown(main);
471
+ delete bubbleElement.dataset.thinkExpanded;
472
+ return;
473
+ }
474
+
475
+ // Determine current expanded state (user preference) or default
476
+ let expanded;
477
+ if (bubbleElement.dataset.thinkExpanded === 'true') expanded = true;
478
+ else if (bubbleElement.dataset.thinkExpanded === 'false') expanded = false;
479
+ else expanded = !!isThinking; // default: open while still streaming until user intervenes
480
+
481
+ // Create structure once
482
+ let container = bubbleElement.querySelector('.think-tokens-container');
483
+ let thoughtContent, headerChevron, headerLabel, mainDiv;
484
+
485
+ if (!container) {
486
+ bubbleElement.innerHTML = ''; // first time constructing reasoning UI
487
+
488
+ container = document.createElement('div');
489
+ container.className = 'think-tokens-container' + (expanded ? '' : ' collapsed');
490
+
491
+ const header = document.createElement('div');
492
+ header.className = 'think-tokens-header';
493
+ header.onclick = function () { toggleThinkTokens(header); };
494
+
495
+ headerChevron = document.createElement('span');
496
+ headerChevron.className = 'think-tokens-chevron';
497
+ headerChevron.textContent = expanded ? '▼' : '▶';
498
+
499
+ headerLabel = document.createElement('span');
500
+ headerLabel.className = 'think-tokens-label';
501
+ header.appendChild(headerChevron);
502
+ header.appendChild(headerLabel);
503
+
504
+ thoughtContent = document.createElement('div');
505
+ thoughtContent.className = 'think-tokens-content';
506
+ thoughtContent.style.display = expanded ? 'block' : 'none';
507
+
508
+ container.appendChild(header);
509
+ container.appendChild(thoughtContent);
510
+ bubbleElement.appendChild(container);
511
+
512
+ if (main.trim()) {
513
+ mainDiv = document.createElement('div');
514
+ mainDiv.className = 'main-response';
515
+ bubbleElement.appendChild(mainDiv);
516
+ }
517
+ } else {
518
+ thoughtContent = container.querySelector('.think-tokens-content');
519
+ headerChevron = container.querySelector('.think-tokens-chevron');
520
+ headerLabel = container.querySelector('.think-tokens-label');
521
+ mainDiv = bubbleElement.querySelector('.main-response');
522
+ }
523
+
524
+ // Update label & chevron (don’t override user-expanded state)
525
+ headerChevron.textContent = expanded ? '▼' : '▶';
526
+ // Animation-aware label handling
527
+ if (isThinking) {
528
+ // If not already animating, seed an initial frame then start animation
529
+ if (bubbleElement.dataset.thinkAnimActive !== '1') {
530
+ headerLabel.textContent = THINKING_FRAMES[0];
531
+ startThinkingAnimation(container);
532
+ }
533
+ } else {
534
+ // Stop any animation and set final label
535
+ if (bubbleElement.dataset.thinkAnimActive === '1') {
536
+ stopThinkingAnimation(container);
537
+ } else {
538
+ headerLabel.textContent = 'Thought Process';
539
+ }
540
+ }
541
+
542
+ // Update reasoning content (can re-run markdown safely)
543
+ thoughtContent.innerHTML = renderMarkdown(thought);
544
+
545
+ // Update main answer section
546
+ if (main.trim()) {
547
+ if (!mainDiv) {
548
+ mainDiv = document.createElement('div');
549
+ mainDiv.className = 'main-response';
550
+ bubbleElement.appendChild(mainDiv);
551
+ }
552
+ mainDiv.innerHTML = renderMarkdown(main);
553
+ } else if (mainDiv) {
554
+ mainDiv.remove();
555
+ }
556
+
557
+ // Persist preference
558
+ bubbleElement.dataset.thinkExpanded = expanded ? 'true' : 'false';
559
+ }
560
+
561
+ // Scroll helper that respects user's scroll interaction. If autoscroll is disabled
562
+ // because the user scrolled up, this will not force the view to the bottom.
563
+ function scrollChatToBottom(force = false) {
564
+ if (!chatHistory) return;
565
+ if (force || autoscrollEnabled) {
566
+ // Small timeout to allow DOM insertion/layout to finish in streaming cases
567
+ setTimeout(() => {
568
+ try { chatHistory.scrollTop = chatHistory.scrollHeight; } catch (_) {}
569
+ }, 0);
570
+ }
571
+ }
572
+
434
573
  function appendMessage(role, text, isMarkdown = false) {
435
574
  const div = document.createElement('div');
436
575
  div.className = 'chat-message ' + role;
437
576
  // Add a bubble for iMessage style
438
577
  const bubble = document.createElement('div');
439
578
  bubble.className = 'chat-bubble ' + role;
440
-
441
- if (role === 'llm' && isMarkdown) {
442
- bubble.innerHTML = renderMarkdownWithThinkTokens(text);
579
+
580
+ // Check if isMarkdown is true, regardless of role
581
+ if (isMarkdown) {
582
+ // Build structure via incremental updater (ensures later token updates won’t wipe user toggle)
583
+ updateMessageContent(bubble, text, true);
443
584
  } else {
444
585
  bubble.textContent = text;
445
586
  }
446
-
587
+
447
588
  div.appendChild(bubble);
448
589
  chatHistory.appendChild(div);
449
- chatHistory.scrollTop = chatHistory.scrollHeight;
450
- return bubble; // Return the bubble element for streaming updates
590
+ scrollChatToBottom();
591
+ return bubble;
451
592
  }
452
593
 
453
- function updateMessageContent(bubbleElement, text, isMarkdown = false) {
454
- if (isMarkdown) {
455
- bubbleElement.innerHTML = renderMarkdownWithThinkTokens(text);
456
- } else {
457
- bubbleElement.textContent = text;
594
+ // Display system message based on current state
595
+ function displaySystemMessage() {
596
+ // Remove existing system message if it exists
597
+ if (systemMessageElement) {
598
+ systemMessageElement.remove();
599
+ systemMessageElement = null;
600
+ }
601
+
602
+ // Don't show system message if there are already user/LLM messages
603
+ if (messages.length > 0) return;
604
+
605
+ let messageText = '';
606
+
607
+ // Check if any models are installed
608
+ const hasInstalledModels = window.installedModels && window.installedModels.size > 0;
609
+
610
+ if (!hasInstalledModels) {
611
+ // No models installed - show first message
612
+ messageText = `Welcome to Lemonade! To get started:
613
+ 1. Head over to the Model Management tab.
614
+ 2. Use the 📥Download button to download a model.
615
+ 3. Use the 🚀Load button to load the model.
616
+ 4. Come back to this tab, and you are ready to chat with the model.`;
617
+ } else if (!currentLoadedModel) {
618
+ // Models available but none loaded - show second message
619
+ messageText = 'Welcome to Lemonade! Choose a model from the dropdown menu below to load it and start chatting.';
620
+ }
621
+
622
+ if (messageText) {
623
+ const div = document.createElement('div');
624
+ div.className = 'chat-message system';
625
+ div.setAttribute('data-system-message', 'true');
626
+
627
+ const bubble = document.createElement('div');
628
+ bubble.className = 'chat-bubble system';
629
+ bubble.textContent = messageText;
630
+
631
+ div.appendChild(bubble);
632
+ chatHistory.appendChild(div);
633
+ scrollChatToBottom();
634
+
635
+ systemMessageElement = div;
458
636
  }
459
637
  }
460
638
 
461
- function renderMarkdownWithThinkTokens(text) {
462
- // Check if text contains opening think tag
463
- if (text.includes('<think>')) {
464
- if (text.includes('</think>')) {
465
- // Complete think block - handle as before
466
- const thinkMatch = text.match(/<think>(.*?)<\/think>/s);
467
- if (thinkMatch) {
468
- const thinkContent = thinkMatch[1].trim();
469
- const mainResponse = text.replace(/<think>.*?<\/think>/s, '').trim();
470
-
471
- // Create collapsible structure
472
- let html = '';
473
- if (thinkContent) {
474
- html += `
475
- <div class="think-tokens-container">
476
- <div class="think-tokens-header" onclick="toggleThinkTokens(this)">
477
- <span class="think-tokens-chevron">▼</span>
478
- <span class="think-tokens-label">Thinking...</span>
479
- </div>
480
- <div class="think-tokens-content">
481
- ${renderMarkdown(thinkContent)}
482
- </div>
483
- </div>
484
- `;
485
- }
486
- if (mainResponse) {
487
- html += `<div class="main-response">${renderMarkdown(mainResponse)}</div>`;
488
- }
489
- return html;
639
+ function abortCurrentRequest() {
640
+ if (abortController) {
641
+ // Abort the in-flight fetch stream immediately
642
+ abortController.abort();
643
+
644
+ // Also signal the server to halt generation promptly (helps slow CPU backends)
645
+ try {
646
+ // Fire-and-forget; no await to avoid blocking UI
647
+ fetch(getServerBaseUrl() + '/api/v1/halt', { method: 'GET', keepalive: true }).catch(() => {});
648
+ } catch (_) {}
649
+ abortController = null;
650
+ isStreaming = false;
651
+ updateAttachmentButtonState();
652
+ console.log('Streaming request aborted by user.');
653
+ }
654
+ }
655
+
656
+
657
+ // ---------- Reasoning Parsing (Harmony + <think>) ----------
658
+
659
+ function parseReasoningBlocks(raw) {
660
+ if (raw == null) return { main: '', thought: '', isThinking: false };
661
+ // Added additional Harmony variants: <|channel|>analysis<|channel|>, <|channel|>analysis<|message|>, <|channel|>analysis<|assistant|>
662
+ const RE_OPEN = /<think>|<\|channel\|>analysis<\|(channel|message|assistant)\|>/;
663
+ const RE_CLOSE = /<\/think>|<\|end\|>/;
664
+
665
+ let remaining = String(raw);
666
+ let main = '';
667
+ let thought = '';
668
+ let isThinking = false;
669
+
670
+ while (true) {
671
+ const openIdx = remaining.search(RE_OPEN);
672
+ if (openIdx === -1) {
673
+ if (isThinking) {
674
+ thought += remaining;
675
+ } else {
676
+ main += remaining;
490
677
  }
678
+ break;
679
+ }
680
+
681
+ // Text before the opener
682
+ if (isThinking) {
683
+ thought += remaining.slice(0, openIdx);
491
684
  } else {
492
- // Partial think block - only opening tag found, still being generated
493
- const thinkMatch = text.match(/<think>(.*)/s);
494
- if (thinkMatch) {
495
- const thinkContent = thinkMatch[1];
496
- const beforeThink = text.substring(0, text.indexOf('<think>'));
497
-
498
- let html = '';
499
- if (beforeThink.trim()) {
500
- html += `<div class="main-response">${renderMarkdown(beforeThink)}</div>`;
501
- }
502
-
503
- html += `
504
- <div class="think-tokens-container">
505
- <div class="think-tokens-header" onclick="toggleThinkTokens(this)">
506
- <span class="think-tokens-chevron">▼</span>
507
- <span class="think-tokens-label">Thinking...</span>
508
- </div>
509
- <div class="think-tokens-content">
510
- ${renderMarkdown(thinkContent)}
511
- </div>
512
- </div>
513
- `;
514
-
515
- return html;
516
- }
685
+ main += remaining.slice(0, openIdx);
517
686
  }
687
+
688
+ // Drop the opener
689
+ remaining = remaining.slice(openIdx).replace(RE_OPEN, '');
690
+ isThinking = true;
691
+
692
+ const closeIdx = remaining.search(RE_CLOSE);
693
+ if (closeIdx === -1) {
694
+ // Still streaming reasoning (no closer yet)
695
+ thought += remaining;
696
+ break;
697
+ }
698
+
699
+ // Add reasoning segment up to closer
700
+ thought += remaining.slice(0, closeIdx);
701
+ remaining = remaining.slice(closeIdx).replace(RE_CLOSE, '');
702
+ isThinking = false;
703
+ // Loop to look for additional reasoning blocks
518
704
  }
519
-
520
- // Fallback to normal markdown rendering
521
- return renderMarkdown(text);
705
+ return { main, thought, isThinking };
522
706
  }
523
707
 
708
+ function renderMarkdownWithThinkTokens(text, preservedExpanded) {
709
+ const { main, thought, isThinking } = parseReasoningBlocks(text);
710
+
711
+ if (!thought.trim()) {
712
+ return renderMarkdown(main);
713
+ }
714
+
715
+ // If we have a preserved user preference, honor it. Otherwise default:
716
+ // open while streaming (original behavior) else collapsed = false.
717
+ let expanded = (typeof preservedExpanded === 'boolean')
718
+ ? preservedExpanded
719
+ : !!isThinking;
720
+
721
+ const chevron = expanded ? '▼' : '▶';
722
+ const label = expanded && isThinking ? 'Thinking...' : (expanded ? 'Thought Process' : 'Thought Process');
723
+
724
+ let html = `
725
+ <div class="think-tokens-container${expanded ? '' : ' collapsed'}">
726
+ <div class="think-tokens-header" onclick="toggleThinkTokens(this)">
727
+ <span class="think-tokens-chevron">${chevron}</span>
728
+ <span class="think-tokens-label">${label}</span>
729
+ </div>
730
+ <div class="think-tokens-content" style="display:${expanded ? 'block' : 'none'};">
731
+ ${renderMarkdown(thought)}
732
+ </div>
733
+ </div>
734
+ `;
735
+ if (main.trim()) {
736
+ html += `<div class="main-response">${renderMarkdown(main)}</div>`;
737
+ }
738
+ return html;
739
+ }
740
+
741
+ function extractAssistantReasoning(fullText) {
742
+ const { main, thought } = parseReasoningBlocks(fullText);
743
+ const result = { content: (main || '').trim(), raw: fullText };
744
+ if (thought && thought.trim()) result.reasoning_content = thought.trim();
745
+ return result;
746
+ }
747
+
748
+ // -----------------------------------------------------------
749
+
524
750
  function toggleThinkTokens(header) {
525
751
  const container = header.parentElement;
526
752
  const content = container.querySelector('.think-tokens-content');
527
753
  const chevron = header.querySelector('.think-tokens-chevron');
528
-
529
- if (content.style.display === 'none') {
530
- content.style.display = 'block';
531
- chevron.textContent = '▼';
532
- container.classList.remove('collapsed');
533
- } else {
754
+ const bubble = header.closest('.chat-bubble');
755
+
756
+ const nowCollapsed = !container.classList.contains('collapsed'); // current (before toggle) expanded?
757
+ if (nowCollapsed) {
758
+ // Collapse
534
759
  content.style.display = 'none';
535
760
  chevron.textContent = '▶';
536
761
  container.classList.add('collapsed');
762
+ if (bubble) bubble.dataset.thinkExpanded = 'false';
763
+ } else {
764
+ // Expand
765
+ content.style.display = 'block';
766
+ chevron.textContent = '▼';
767
+ container.classList.remove('collapsed');
768
+ if (bubble) bubble.dataset.thinkExpanded = 'true';
537
769
  }
538
770
  }
539
771
 
540
- async function sendMessage() {
541
- const text = chatInput.value.trim();
542
- if (!text && attachedFiles.length === 0) return;
543
-
544
- // Check if a model is loaded, if not, automatically load the default model
772
+ function startThinkingAnimation(container) {
773
+ const bubble = container.closest('.chat-bubble');
774
+ if (!bubble || bubble.dataset.thinkAnimActive === '1') return;
775
+ const labelEl = container.querySelector('.think-tokens-label');
776
+ if (!labelEl) return;
777
+ bubble.dataset.thinkAnimActive = '1';
778
+ let i = 0;
779
+ const update = () => {
780
+ // If streaming ended mid-cycle, stop.
781
+ if (bubble.dataset.thinkAnimActive !== '1') return;
782
+ labelEl.textContent = THINKING_FRAMES[i % THINKING_FRAMES.length];
783
+ i++;
784
+ bubble.dataset.thinkAnimId = String(setTimeout(update, THINKING_ANIM_INTERVAL_MS));
785
+ };
786
+ update();
787
+ }
788
+
789
+ function stopThinkingAnimation(container, finalLabel = 'Thought Process') {
790
+ const bubble = container.closest('.chat-bubble');
791
+ if (!bubble) return;
792
+ bubble.dataset.thinkAnimActive = '0';
793
+ const id = bubble.dataset.thinkAnimId;
794
+ if (id) {
795
+ clearTimeout(Number(id));
796
+ delete bubble.dataset.thinkAnimId;
797
+ }
798
+ const labelEl = container.querySelector('.think-tokens-label');
799
+ if (labelEl) labelEl.textContent = finalLabel;
800
+ }
801
+
802
+ async function sendMessage(existingTextIfAny) {
803
+ const text = (existingTextIfAny !== undefined ? existingTextIfAny : chatInput.value.trim());
804
+
805
+ // Prepare abort controller for this request
806
+ abortController = new AbortController();
807
+ // UI state: set button to Stop
808
+ if (toggleBtn) {
809
+ toggleBtn.disabled = false;
810
+ toggleBtn.textContent = 'Stop';
811
+ }
812
+ if (!text && attachedFiles.length === 0) {
813
+ // Nothing to send; revert button state and clear abort handle
814
+ abortController = null;
815
+ updateAttachmentButtonState();
816
+ return;
817
+ }
818
+
819
+ isStreaming = true;
820
+
821
+ // Remove system message when user starts chatting
822
+ if (systemMessageElement) {
823
+ systemMessageElement.remove();
824
+ systemMessageElement = null;
825
+ }
826
+
827
+ // Check if a model is loaded
545
828
  if (!currentLoadedModel) {
546
- const allModels = window.SERVER_MODELS || {};
547
-
548
- if (allModels[DEFAULT_MODEL]) {
549
- try {
550
- // Show loading message
551
- const loadingBubble = appendMessage('system', 'Loading default model, please wait...');
552
-
553
- // Load the default model
554
- await httpRequest(getServerBaseUrl() + '/api/v1/load', {
555
- method: 'POST',
556
- headers: { 'Content-Type': 'application/json' },
557
- body: JSON.stringify({ model_name: DEFAULT_MODEL })
558
- });
559
-
560
- // Update model status
561
- await updateModelStatusIndicator();
562
-
563
- // Remove loading message
564
- loadingBubble.parentElement.remove();
565
-
566
- // Show success message briefly
567
- const successBubble = appendMessage('system', `Loaded ${DEFAULT_MODEL} successfully!`);
568
- setTimeout(() => {
569
- successBubble.parentElement.remove();
570
- }, 2000);
571
-
572
- } catch (error) {
573
- alert('Please load a model first before sending messages.');
574
- return;
575
- }
576
- } else {
577
- alert('Please load a model first before sending messages.');
578
- return;
579
- }
829
+ alert('Please load a model first before sending messages.');
830
+ abortController = null;
831
+ isStreaming = false;
832
+ updateAttachmentButtonState();
833
+ return;
580
834
  }
581
-
835
+
582
836
  // Check if trying to send images to non-vision model
583
- if (attachedFiles.length > 0) {
584
- if (!isVisionModel(currentLoadedModel)) {
585
- alert(`Cannot send images to model "${currentLoadedModel}" as it does not support vision. Please load a model with "Vision" capabilities or remove the attached images.`);
586
- return;
587
- }
837
+ if (attachedFiles.length > 0 && !isVisionModel(currentLoadedModel)) {
838
+ alert(`Cannot send images to model "${currentLoadedModel}" as it does not support vision. Please load a model with "Vision" capabilities or remove the attached images.`);
839
+ return;
588
840
  }
589
-
841
+
590
842
  // Create message content
591
843
  let messageContent = [];
592
-
844
+
593
845
  // Add text if present
594
846
  if (text) {
595
- messageContent.push({
596
- type: "text",
597
- text: text
598
- });
847
+ messageContent.push({ type: "text", text: text });
599
848
  }
600
-
849
+
601
850
  // Add images if present
602
851
  if (attachedFiles.length > 0) {
603
852
  for (const file of attachedFiles) {
@@ -606,9 +855,7 @@ async function sendMessage() {
606
855
  const base64 = await fileToBase64(file);
607
856
  messageContent.push({
608
857
  type: "image_url",
609
- image_url: {
610
- url: `data:${file.type};base64,${base64}`
611
- }
858
+ image_url: { url: `data:${file.type};base64,${base64}` }
612
859
  });
613
860
  } catch (error) {
614
861
  console.error('Error converting image to base64:', error);
@@ -616,25 +863,25 @@ async function sendMessage() {
616
863
  }
617
864
  }
618
865
  }
619
-
866
+
620
867
  // Display user message (show text and file names)
621
868
  let displayText = text;
622
869
  if (attachedFiles.length > 0) {
623
870
  const fileNames = attachedFiles.map(f => f.name || 'pasted-image').join(', ');
624
871
  displayText = displayText ? `${displayText}\n[Images: ${fileNames}]` : `[Images: ${fileNames}]`;
625
872
  }
626
-
627
- appendMessage('user', displayText);
628
-
873
+
874
+ appendMessage('user', displayText, true);
875
+
629
876
  // Add to messages array
630
877
  const userMessage = {
631
878
  role: 'user',
632
- content: messageContent.length === 1 && messageContent[0].type === "text"
633
- ? messageContent[0].text
879
+ content: messageContent.length === 1 && messageContent[0].type === "text"
880
+ ? messageContent[0].text
634
881
  : messageContent
635
882
  };
636
883
  messages.push(userMessage);
637
-
884
+
638
885
  // Clear input and attachments
639
886
  chatInput.value = '';
640
887
  attachedFiles = [];
@@ -642,8 +889,8 @@ async function sendMessage() {
642
889
  updateInputPlaceholder(); // Reset placeholder
643
890
  updateAttachmentPreviewVisibility(); // Hide preview container
644
891
  updateAttachmentPreviews(); // Clear previews
645
- sendBtn.disabled = true;
646
-
892
+ // Keep the Send/Stop button enabled during streaming so user can abort.
893
+
647
894
  // Streaming OpenAI completions (placeholder, adapt as needed)
648
895
  let llmText = '';
649
896
  const llmBubble = appendMessage('llm', '...');
@@ -651,76 +898,146 @@ async function sendMessage() {
651
898
  // Use the correct endpoint for chat completions with model settings
652
899
  const modelSettings = getCurrentModelSettings ? getCurrentModelSettings() : {};
653
900
  console.log('Applying model settings to API request:', modelSettings);
654
-
901
+
655
902
  const payload = {
656
903
  model: currentLoadedModel,
657
904
  messages: messages,
658
905
  stream: true,
659
906
  ...modelSettings // Apply current model settings
660
907
  };
661
-
908
+
662
909
  const resp = await httpRequest(getServerBaseUrl() + '/api/v1/chat/completions', {
663
910
  method: 'POST',
664
911
  headers: { 'Content-Type': 'application/json' },
665
- body: JSON.stringify(payload)
912
+ body: JSON.stringify(payload),
913
+ signal: abortController ? abortController.signal : undefined
666
914
  });
667
915
  if (!resp.body) throw new Error('No stream');
668
916
  const reader = resp.body.getReader();
669
917
  let decoder = new TextDecoder();
670
918
  llmBubble.textContent = '';
919
+
920
+ const reasoningEnabled = (() => {
921
+ try {
922
+ const meta = window.SERVER_MODELS?.[currentLoadedModel];
923
+ return Array.isArray(meta?.labels) && meta.labels.includes('reasoning');
924
+ } catch (_) { return false; }
925
+ })();
926
+
927
+ let thinkOpened = false;
928
+ let thinkClosed = false;
929
+ let reasoningSchemaActive = false; // true if we saw delta.reasoning object
930
+ let receivedAnyReasoning = false; // true once any reasoning (schema or reasoning_content) arrived
931
+
671
932
  while (true) {
672
933
  const { done, value } = await reader.read();
673
934
  if (done) break;
674
935
  const chunk = decoder.decode(value);
675
- if (chunk.trim() === 'data: [DONE]' || chunk.trim() === '[DONE]') continue;
676
-
936
+ if (!chunk.trim()) continue;
937
+
677
938
  // Handle Server-Sent Events format
678
939
  const lines = chunk.split('\n');
679
- for (const line of lines) {
680
- if (line.startsWith('data: ')) {
681
- const jsonStr = line.substring(6).trim();
682
- if (jsonStr === '[DONE]') continue;
683
-
684
- try {
685
- const delta = JSON.parse(jsonStr);
686
- if (delta.choices && delta.choices[0] && delta.choices[0].delta) {
687
- const content = delta.choices[0].delta.content;
688
- if (content) {
689
- llmText += unescapeJsonString(content);
690
- updateMessageContent(llmBubble, llmText, true);
691
- chatHistory.scrollTop = chatHistory.scrollHeight;
940
+ for (const rawLine of lines) {
941
+ if (!rawLine.startsWith('data: ')) continue;
942
+ const jsonStr = rawLine.slice(6).trim();
943
+ if (!jsonStr || jsonStr === '[DONE]') continue;
944
+
945
+ let deltaObj;
946
+ try { deltaObj = JSON.parse(jsonStr); } catch { continue; }
947
+ const choiceDelta = deltaObj?.choices?.[0]?.delta;
948
+ if (!choiceDelta) continue;
949
+
950
+ // 1. OpenAI reasoning object (preferred schema)
951
+ if (choiceDelta.reasoning && !thinkClosed) {
952
+ reasoningSchemaActive = true;
953
+ const r = choiceDelta.reasoning;
954
+ if (!thinkOpened) {
955
+ llmText += '<think>';
956
+ thinkOpened = true;
957
+ }
958
+ if (Array.isArray(r.content)) {
959
+ for (const seg of r.content) {
960
+ if (seg?.type === 'output_text' && seg.text) {
961
+ llmText += unescapeJsonString(seg.text);
962
+ receivedAnyReasoning = true;
692
963
  }
693
964
  }
694
- } catch (parseErr) {
695
- console.warn('Failed to parse JSON:', jsonStr, parseErr);
965
+ }
966
+ if (r.done && !thinkClosed) {
967
+ llmText += '</think>';
968
+ thinkClosed = true;
696
969
  }
697
970
  }
971
+
972
+ // 2. llama.cpp style: reasoning_content (string fragments)
973
+ if (choiceDelta.reasoning_content && !thinkClosed) {
974
+ if (!thinkOpened) {
975
+ llmText += '<think>';
976
+ thinkOpened = true;
977
+ }
978
+ llmText += unescapeJsonString(choiceDelta.reasoning_content);
979
+ receivedAnyReasoning = true;
980
+ // We DO NOT close yet; we’ll close when first normal content arrives.
981
+ }
982
+
983
+ // 3. Plain content tokens
984
+ if (choiceDelta.content) {
985
+ let c = unescapeJsonString(choiceDelta.content);
986
+
987
+ // If we are inside reasoning (opened, not closed) and this is the first visible answer token,
988
+ // close the reasoning block before appending (unless model already emitted </think> itself).
989
+ if (thinkOpened && !thinkClosed) {
990
+ if (c.startsWith('</think>')) {
991
+ // Model closed it explicitly; strip that tag and mark closed
992
+ c = c.replace(/^<\/think>\s*/, '');
993
+ thinkClosed = true;
994
+ } else {
995
+ // Close ourselves (covers reasoning_content path & schema early content anomaly)
996
+ if (receivedAnyReasoning || reasoningEnabled) {
997
+ llmText += '</think>';
998
+ thinkClosed = true;
999
+ }
1000
+ }
1001
+ }
1002
+
1003
+ // If content stream itself begins a new reasoning section (rare), handle gracefully
1004
+ if (!thinkOpened && /<think>/.test(c)) {
1005
+ thinkOpened = true;
1006
+ const parts = c.split(/<think>/);
1007
+ // parts[0] is anything before accidental <think>, treat as normal visible content
1008
+ llmText += parts[0];
1009
+ // Everything after opener treated as reasoning until a closing tag or we decide to close
1010
+ llmText += '<think>' + parts.slice(1).join('<think>');
1011
+ receivedAnyReasoning = true;
1012
+ updateMessageContent(llmBubble, llmText, true);
1013
+ scrollChatToBottom();
1014
+ continue;
1015
+ }
1016
+
1017
+ llmText += c;
1018
+ }
1019
+
1020
+ updateMessageContent(llmBubble, llmText, true);
1021
+ scrollChatToBottom();
698
1022
  }
699
1023
  }
700
- if (!llmText) throw new Error('No response');
701
-
702
- // Split assistant response into content and reasoning_content so llama.cpp's Jinja does not need to parse <think> tags
703
- function splitAssistantResponse(text) {
704
- const THINK_OPEN = '<think>';
705
- const THINK_CLOSE = '</think>';
706
- const result = { content: text };
707
- const start = text.indexOf(THINK_OPEN);
708
- const end = text.indexOf(THINK_CLOSE);
709
- if (start !== -1 && end !== -1 && end > start) {
710
- const reasoning = text.substring(start + THINK_OPEN.length, end).trim();
711
- const visible = (text.substring(0, start) + text.substring(end + THINK_CLOSE.length)).trim();
712
- if (reasoning) result.reasoning_content = reasoning;
713
- result.content = visible;
714
- }
715
- return result;
1024
+
1025
+ // Final safety close (e.g., model stopped mid-reasoning)
1026
+ if (thinkOpened && !thinkClosed) {
1027
+ llmText += '</think>';
716
1028
  }
717
1029
 
718
- const assistantMsg = splitAssistantResponse(llmText);
1030
+ const assistantMsg = extractAssistantReasoning(llmText);
719
1031
  messages.push({ role: 'assistant', ...assistantMsg });
1032
+
720
1033
  } catch (e) {
721
- let detail = e.message;
1034
+ // If the request was aborted by the user, just clean up UI without error banner
1035
+ if (e.name === 'AbortError') {
1036
+ console.log('Chat request aborted by user.');
1037
+ } else {
1038
+ let detail = e.message;
722
1039
  try {
723
- const errPayload = { ...payload, stream: false };
1040
+ const errPayload = { model: currentLoadedModel, messages: messages, stream: false };
724
1041
  const errResp = await httpJson(getServerBaseUrl() + '/api/v1/chat/completions', {
725
1042
  method: 'POST',
726
1043
  headers: { 'Content-Type': 'application/json' },
@@ -728,8 +1045,15 @@ async function sendMessage() {
728
1045
  });
729
1046
  if (errResp && errResp.detail) detail = errResp.detail;
730
1047
  } catch (_) {}
731
- llmBubble.textContent = '[Error: ' + detail + ']';
732
- showErrorBanner(`Chat error: ${detail}`);
1048
+ if (e && e.name !== 'AbortError') {
1049
+ llmBubble.textContent = '[Error: ' + detail + ']';
1050
+ showErrorBanner(`Chat error: ${detail}`);
1051
+ }
733
1052
  }
734
- sendBtn.disabled = false;
1053
+ }
1054
+ // Reset UI state after streaming finishes
1055
+ abortController = null;
1056
+ isStreaming = false;
1057
+ updateAttachmentButtonState();
1058
+ updateMessageContent(llmBubble, llmText, true);
735
1059
  }