lemonade-sdk 8.1.8__py3-none-any.whl → 8.1.10__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.

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