lemonade-sdk 8.1.8__py3-none-any.whl → 8.1.9__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.
- lemonade/cli.py +47 -1
- lemonade/profilers/agt_power.py +437 -0
- lemonade/profilers/hwinfo_power.py +429 -0
- lemonade/tools/llamacpp/utils.py +15 -4
- lemonade/tools/oga/load.py +15 -2
- lemonade/tools/server/llamacpp.py +3 -12
- lemonade/tools/server/static/js/chat.js +481 -242
- lemonade/tools/server/static/js/models.js +106 -29
- lemonade/tools/server/static/js/shared.js +4 -2
- lemonade/tools/server/static/styles.css +114 -68
- lemonade/tools/server/static/webapp.html +19 -23
- lemonade/version.py +1 -1
- {lemonade_sdk-8.1.8.dist-info → lemonade_sdk-8.1.9.dist-info}/METADATA +2 -2
- {lemonade_sdk-8.1.8.dist-info → lemonade_sdk-8.1.9.dist-info}/RECORD +20 -18
- lemonade_server/server_models.json +24 -6
- {lemonade_sdk-8.1.8.dist-info → lemonade_sdk-8.1.9.dist-info}/WHEEL +0 -0
- {lemonade_sdk-8.1.8.dist-info → lemonade_sdk-8.1.9.dist-info}/entry_points.txt +0 -0
- {lemonade_sdk-8.1.8.dist-info → lemonade_sdk-8.1.9.dist-info}/licenses/LICENSE +0 -0
- {lemonade_sdk-8.1.8.dist-info → lemonade_sdk-8.1.9.dist-info}/licenses/NOTICE.md +0 -0
- {lemonade_sdk-8.1.8.dist-info → lemonade_sdk-8.1.9.dist-info}/top_level.txt +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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: (
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
},
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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 = '';
|
|
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 = '';
|
|
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 = '';
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]);
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
579
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
599
|
-
const
|
|
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
|
-
|
|
649
|
-
|
|
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()
|
|
740
|
-
|
|
924
|
+
if (!chunk.trim()) continue;
|
|
925
|
+
|
|
741
926
|
// Handle Server-Sent Events format
|
|
742
927
|
const lines = chunk.split('\n');
|
|
743
|
-
for (const
|
|
744
|
-
if (
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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 =
|
|
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 = {
|
|
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
|
+
}
|