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.
- lemonade/cache.py +6 -1
- lemonade/cli.py +47 -5
- lemonade/common/inference_engines.py +13 -4
- lemonade/common/status.py +4 -4
- lemonade/common/system_info.py +544 -1
- lemonade/profilers/agt_power.py +437 -0
- lemonade/profilers/hwinfo_power.py +429 -0
- lemonade/tools/accuracy.py +143 -48
- lemonade/tools/adapter.py +6 -1
- lemonade/tools/bench.py +26 -8
- lemonade/tools/flm/__init__.py +1 -0
- lemonade/tools/flm/utils.py +303 -0
- lemonade/tools/huggingface/bench.py +6 -1
- lemonade/tools/llamacpp/bench.py +146 -27
- lemonade/tools/llamacpp/load.py +30 -2
- lemonade/tools/llamacpp/utils.py +393 -33
- lemonade/tools/oga/bench.py +5 -26
- lemonade/tools/oga/load.py +60 -121
- lemonade/tools/oga/migration.py +403 -0
- lemonade/tools/report/table.py +76 -8
- lemonade/tools/server/flm.py +133 -0
- lemonade/tools/server/llamacpp.py +220 -553
- lemonade/tools/server/serve.py +684 -168
- lemonade/tools/server/static/js/chat.js +666 -342
- lemonade/tools/server/static/js/model-settings.js +24 -3
- lemonade/tools/server/static/js/models.js +597 -73
- lemonade/tools/server/static/js/shared.js +79 -14
- lemonade/tools/server/static/logs.html +191 -0
- lemonade/tools/server/static/styles.css +491 -66
- lemonade/tools/server/static/webapp.html +83 -31
- lemonade/tools/server/tray.py +158 -38
- lemonade/tools/server/utils/macos_tray.py +226 -0
- lemonade/tools/server/utils/{system_tray.py → windows_tray.py} +13 -0
- lemonade/tools/server/webapp.py +4 -1
- lemonade/tools/server/wrapped_server.py +559 -0
- lemonade/version.py +1 -1
- lemonade_install/install.py +54 -611
- {lemonade_sdk-8.1.4.dist-info → lemonade_sdk-8.2.2.dist-info}/METADATA +29 -72
- lemonade_sdk-8.2.2.dist-info/RECORD +83 -0
- lemonade_server/cli.py +145 -37
- lemonade_server/model_manager.py +521 -37
- lemonade_server/pydantic_models.py +28 -1
- lemonade_server/server_models.json +246 -92
- lemonade_server/settings.py +39 -39
- lemonade/tools/quark/__init__.py +0 -0
- lemonade/tools/quark/quark_load.py +0 -173
- lemonade/tools/quark/quark_quantize.py +0 -439
- lemonade_sdk-8.1.4.dist-info/RECORD +0 -77
- {lemonade_sdk-8.1.4.dist-info → lemonade_sdk-8.2.2.dist-info}/WHEEL +0 -0
- {lemonade_sdk-8.1.4.dist-info → lemonade_sdk-8.2.2.dist-info}/entry_points.txt +0 -0
- {lemonade_sdk-8.1.4.dist-info → lemonade_sdk-8.2.2.dist-info}/licenses/LICENSE +0 -0
- {lemonade_sdk-8.1.4.dist-info → lemonade_sdk-8.2.2.dist-info}/licenses/NOTICE.md +0 -0
- {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
|
-
|
|
6
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
//
|
|
51
|
-
|
|
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
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
onSuccess: (
|
|
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: (
|
|
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 (
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
//
|
|
186
|
-
|
|
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 = '';
|
|
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 = '';
|
|
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 = '';
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
332
|
-
if (
|
|
333
|
-
|
|
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
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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]);
|
|
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
|
|
442
|
-
|
|
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
|
-
|
|
450
|
-
return bubble;
|
|
590
|
+
scrollChatToBottom();
|
|
591
|
+
return bubble;
|
|
451
592
|
}
|
|
452
593
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
541
|
-
const
|
|
542
|
-
if (!
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
585
|
-
|
|
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
|
-
|
|
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()
|
|
676
|
-
|
|
936
|
+
if (!chunk.trim()) continue;
|
|
937
|
+
|
|
677
938
|
// Handle Server-Sent Events format
|
|
678
939
|
const lines = chunk.split('\n');
|
|
679
|
-
for (const
|
|
680
|
-
if (
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
}
|
|
695
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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 =
|
|
1030
|
+
const assistantMsg = extractAssistantReasoning(llmText);
|
|
719
1031
|
messages.push({ role: 'assistant', ...assistantMsg });
|
|
1032
|
+
|
|
720
1033
|
} catch (e) {
|
|
721
|
-
|
|
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 = {
|
|
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
|
-
|
|
732
|
-
|
|
1048
|
+
if (e && e.name !== 'AbortError') {
|
|
1049
|
+
llmBubble.textContent = '[Error: ' + detail + ']';
|
|
1050
|
+
showErrorBanner(`Chat error: ${detail}`);
|
|
1051
|
+
}
|
|
733
1052
|
}
|
|
734
|
-
|
|
1053
|
+
}
|
|
1054
|
+
// Reset UI state after streaming finishes
|
|
1055
|
+
abortController = null;
|
|
1056
|
+
isStreaming = false;
|
|
1057
|
+
updateAttachmentButtonState();
|
|
1058
|
+
updateMessageContent(llmBubble, llmText, true);
|
|
735
1059
|
}
|