lemonade-sdk 8.1.1__py3-none-any.whl → 8.1.3__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/common/inference_engines.py +1 -1
- lemonade/tools/llamacpp/utils.py +114 -14
- lemonade/tools/management_tools.py +1 -1
- lemonade/tools/oga/utils.py +54 -33
- lemonade/tools/server/llamacpp.py +96 -4
- lemonade/tools/server/serve.py +80 -10
- lemonade/tools/server/static/js/chat.js +735 -0
- lemonade/tools/server/static/js/model-settings.js +162 -0
- lemonade/tools/server/static/js/models.js +865 -0
- lemonade/tools/server/static/js/shared.js +491 -0
- lemonade/tools/server/static/styles.css +652 -26
- lemonade/tools/server/static/webapp.html +145 -1091
- lemonade/tools/server/tray.py +1 -1
- lemonade/tools/server/utils/port.py +5 -4
- lemonade/version.py +1 -1
- {lemonade_sdk-8.1.1.dist-info → lemonade_sdk-8.1.3.dist-info}/METADATA +7 -6
- {lemonade_sdk-8.1.1.dist-info → lemonade_sdk-8.1.3.dist-info}/RECORD +26 -22
- {lemonade_sdk-8.1.1.dist-info → lemonade_sdk-8.1.3.dist-info}/entry_points.txt +1 -0
- lemonade_server/cli.py +66 -17
- lemonade_server/model_manager.py +1 -1
- lemonade_server/pydantic_models.py +15 -3
- lemonade_server/server_models.json +54 -3
- {lemonade_sdk-8.1.1.dist-info → lemonade_sdk-8.1.3.dist-info}/WHEEL +0 -0
- {lemonade_sdk-8.1.1.dist-info → lemonade_sdk-8.1.3.dist-info}/licenses/LICENSE +0 -0
- {lemonade_sdk-8.1.1.dist-info → lemonade_sdk-8.1.3.dist-info}/licenses/NOTICE.md +0 -0
- {lemonade_sdk-8.1.1.dist-info → lemonade_sdk-8.1.3.dist-info}/top_level.txt +0 -0
|
@@ -31,14 +31,26 @@
|
|
|
31
31
|
<main class="main">
|
|
32
32
|
<div class="tab-container">
|
|
33
33
|
<div class="tabs">
|
|
34
|
-
<
|
|
35
|
-
|
|
34
|
+
<div class="tab-group">
|
|
35
|
+
<button class="tab active" id="tab-chat" onclick="showTab('chat')">LLM Chat</button>
|
|
36
|
+
<button class="tab" id="tab-model-settings" onclick="showTab('settings')">Model Settings</button>
|
|
37
|
+
<button class="tab" id="tab-models" onclick="showTab('models')">Model Management</button>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<!-- Model Status Indicator integrated into tab bar -->
|
|
41
|
+
<div class="model-status-indicator" id="model-status-indicator">
|
|
42
|
+
<div class="status-light" id="status-light"></div>
|
|
43
|
+
<span class="model-status-text" id="model-status-text">Loading...</span>
|
|
44
|
+
<button class="model-action-btn" id="model-unload-btn" style="display: none;" title="Unload model">⏏</button>
|
|
45
|
+
</div>
|
|
36
46
|
</div>
|
|
37
47
|
<div class="tab-content active" id="content-chat">
|
|
38
48
|
<div class="chat-container">
|
|
39
49
|
<div class="chat-history" id="chat-history"></div>
|
|
40
50
|
<div class="chat-input-row">
|
|
41
|
-
<select id="model-select"
|
|
51
|
+
<select id="model-select" class="model-select">
|
|
52
|
+
<option value="">Pick a model</option>
|
|
53
|
+
</select>
|
|
42
54
|
<div class="input-with-indicator">
|
|
43
55
|
<input type="text" id="chat-input" placeholder="Type your message..." />
|
|
44
56
|
</div>
|
|
@@ -91,76 +103,136 @@
|
|
|
91
103
|
</div>
|
|
92
104
|
</div>
|
|
93
105
|
</div>
|
|
94
|
-
<div class="tab-content" id="content-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
Model Name
|
|
102
|
-
<span class="tooltip-icon" data-tooltip="Enter a unique short name for your model. This is how the model will be referenced by Lemonade Server and connected apps. It will be prefixed with 'user.' to distinguish it from the built-in models.">ⓘ</span>
|
|
103
|
-
</label>
|
|
104
|
-
<div class="register-model-name-group">
|
|
105
|
-
<span class="register-model-prefix styled-prefix">user.</span>
|
|
106
|
-
<input type="text" id="register-model-name" name="model_name" placeholder="Gemma-3-12b-it-GGUF" required autocomplete="off">
|
|
106
|
+
<div class="tab-content" id="content-settings">
|
|
107
|
+
<div class="model-settings-container">
|
|
108
|
+
<div class="settings-form">
|
|
109
|
+
<div class="setting-row">
|
|
110
|
+
<label for="setting-temperature">Temperature:</label>
|
|
111
|
+
<input type="number" id="setting-temperature" min="0" max="2" step="0.1" placeholder="default" />
|
|
112
|
+
<span class="setting-description">Controls randomness in responses (0 = deterministic, 2 = very random)</span>
|
|
107
113
|
</div>
|
|
114
|
+
<div class="setting-row">
|
|
115
|
+
<label for="setting-top-k">Top K:</label>
|
|
116
|
+
<input type="number" id="setting-top-k" min="1" max="100" step="1" placeholder="default" />
|
|
117
|
+
<span class="setting-description">Limits token selection to top K most likely tokens</span>
|
|
108
118
|
</div>
|
|
109
|
-
<div class="
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
<span class="
|
|
113
|
-
</label>
|
|
114
|
-
<input type="text" id="register-checkpoint" name="checkpoint" placeholder="unsloth/gemma-3-12b-it-GGUF:Q4_0" class="register-textbox" autocomplete="off">
|
|
119
|
+
<div class="setting-row">
|
|
120
|
+
<label for="setting-top-p">Top P:</label>
|
|
121
|
+
<input type="number" id="setting-top-p" min="0" max="1" step="0.01" placeholder="default" />
|
|
122
|
+
<span class="setting-description">Nucleus sampling - considers tokens with cumulative probability up to P</span>
|
|
115
123
|
</div>
|
|
116
|
-
<div class="
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
<span class="
|
|
120
|
-
</label>
|
|
121
|
-
<select id="register-recipe" name="recipe" required>
|
|
122
|
-
<option value="llamacpp">llamacpp</option>
|
|
123
|
-
<option value="oga-npu">oga-npu</option>
|
|
124
|
-
<option value="oga-hybrid">oga-hybrid</option>
|
|
125
|
-
<option value="oga-cpu">oga-cpu</option>
|
|
126
|
-
</select>
|
|
127
|
-
<a href="https://lemonade-server.ai/docs/lemonade_api/" target="_blank" class="register-doc-link">More info</a>
|
|
124
|
+
<div class="setting-row">
|
|
125
|
+
<label for="setting-repeat-penalty">Repeat Penalty:</label>
|
|
126
|
+
<input type="number" id="setting-repeat-penalty" min="0.5" max="2" step="0.05" placeholder="default" />
|
|
127
|
+
<span class="setting-description">Penalty for repeating tokens (1 = no penalty, >1 = less repetition)</span>
|
|
128
128
|
</div>
|
|
129
|
-
<div class="
|
|
130
|
-
|
|
131
|
-
mmproj file
|
|
132
|
-
<span class="tooltip-icon" data-tooltip="Specify an mmproj file from the same Hugging Face checkpoint as the model. This is used for multimodal models, such as VLMs. Leave empty if not needed.">ⓘ</span>
|
|
133
|
-
</label>
|
|
134
|
-
<input type="text" id="register-mmproj" name="mmproj" placeholder="(Optional) mmproj-F16.gguf" autocomplete="off">
|
|
135
|
-
<label class="register-label reasoning-inline">
|
|
136
|
-
<input type="checkbox" id="register-reasoning" name="reasoning">
|
|
137
|
-
Reasoning
|
|
138
|
-
<span class="tooltip-icon" data-tooltip="Enable to inform Lemonade Server that the model has reasoning capabilities that will use thinking tokens.">ⓘ</span>
|
|
139
|
-
</label>
|
|
129
|
+
<div class="setting-actions">
|
|
130
|
+
<button id="reset-settings-btn" class="reset-btn">Reset to Defaults</button>
|
|
140
131
|
</div>
|
|
141
|
-
|
|
142
|
-
<button type="submit" id="register-submit">Install</button>
|
|
143
|
-
<span id="register-model-status" class="register-status"></span> </div>
|
|
144
|
-
</form>
|
|
132
|
+
</div>
|
|
145
133
|
</div>
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
<
|
|
152
|
-
|
|
134
|
+
</div>
|
|
135
|
+
<div class="tab-content" id="content-models">
|
|
136
|
+
<div class="model-browser-container">
|
|
137
|
+
<div class="model-browser-sidebar">
|
|
138
|
+
<div class="model-category" data-category="hot">
|
|
139
|
+
<div class="category-header active" onclick="toggleCategory('hot')">
|
|
140
|
+
<span class="category-icon">🔥</span>
|
|
141
|
+
<span class="category-name">Hot Models</span>
|
|
142
|
+
</div>
|
|
143
|
+
<div class="category-content expanded" id="category-hot"></div>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div class="model-category-section">
|
|
147
|
+
<div class="section-header">
|
|
148
|
+
<span class="section-icon">🔧</span>
|
|
149
|
+
<span class="section-name">By Recipe</span>
|
|
150
|
+
</div>
|
|
151
|
+
<div class="section-content">
|
|
152
|
+
<div class="subcategory" data-recipe="llamacpp" onclick="selectRecipe('llamacpp')">llama.cpp</div>
|
|
153
|
+
<div class="subcategory" data-recipe="oga-hybrid" onclick="selectRecipe('oga-hybrid')">OGA Hybrid</div>
|
|
154
|
+
<div class="subcategory" data-recipe="oga-npu" onclick="selectRecipe('oga-npu')">OGA NPU</div>
|
|
155
|
+
<div class="subcategory" data-recipe="oga-cpu" onclick="selectRecipe('oga-cpu')">OGA CPU</div>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<div class="model-category-section">
|
|
160
|
+
<div class="section-header">
|
|
161
|
+
<span class="section-icon">🏷️</span>
|
|
162
|
+
<span class="section-name">By Category</span>
|
|
163
|
+
</div>
|
|
164
|
+
<div class="section-content">
|
|
165
|
+
<div class="subcategory" data-label="coding" onclick="selectLabel('coding')">Coding</div>
|
|
166
|
+
<div class="subcategory" data-label="vision" onclick="selectLabel('vision')">Vision</div>
|
|
167
|
+
<div class="subcategory" data-label="reasoning" onclick="selectLabel('reasoning')">Reasoning</div>
|
|
168
|
+
<div class="subcategory" data-label="reranking" onclick="selectLabel('reranking')">Reranking</div>
|
|
169
|
+
<div class="subcategory" data-label="embeddings" onclick="selectLabel('embeddings')">Embeddings</div>
|
|
170
|
+
<div class="subcategory" data-label="custom" onclick="selectLabel('custom')">Custom</div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<div class="model-category" data-category="add">
|
|
175
|
+
<div class="category-header" onclick="showAddModelForm()">
|
|
176
|
+
<span class="category-icon">➕</span>
|
|
177
|
+
<span class="category-name">Add a Model</span>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
153
180
|
</div>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
<
|
|
157
|
-
<tbody id="hot-models-tbody"></tbody>
|
|
158
|
-
</table>
|
|
181
|
+
|
|
182
|
+
<div class="model-browser-main">
|
|
183
|
+
<div class="model-list" id="model-list"></div>
|
|
159
184
|
|
|
160
|
-
|
|
161
|
-
<
|
|
162
|
-
<
|
|
163
|
-
|
|
185
|
+
<!-- Add Model Form (hidden by default) -->
|
|
186
|
+
<div class="add-model-form-main" id="add-model-form-main" style="display: none;">
|
|
187
|
+
<form id="register-model-form" autocomplete="off" class="form-content">
|
|
188
|
+
<div class="register-form-row">
|
|
189
|
+
<label class="register-label">
|
|
190
|
+
Model Name
|
|
191
|
+
<span class="tooltip-icon" data-tooltip="Enter a unique short name for your model. This is how the model will be referenced by Lemonade Server and connected apps. It will be prefixed with 'user.' to distinguish it from the built-in models.">ⓘ</span>
|
|
192
|
+
</label>
|
|
193
|
+
<div class="register-model-name-group">
|
|
194
|
+
<span class="register-model-prefix styled-prefix">user.</span>
|
|
195
|
+
<input type="text" id="register-model-name" name="model_name" placeholder="Gemma-3-12b-it-GGUF" required autocomplete="off">
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
<div class="register-form-row">
|
|
199
|
+
<label class="register-label">
|
|
200
|
+
Checkpoint
|
|
201
|
+
<span class="tooltip-icon" data-tooltip="Specify the model checkpoint path from Hugging Face (e.g., org-name/model-name:variant).">ⓘ</span>
|
|
202
|
+
</label>
|
|
203
|
+
<input type="text" id="register-checkpoint" name="checkpoint" placeholder="unsloth/gemma-3-12b-it-GGUF:Q4_0" class="register-textbox" autocomplete="off">
|
|
204
|
+
</div>
|
|
205
|
+
<div class="register-form-row">
|
|
206
|
+
<label class="register-label">
|
|
207
|
+
Recipe
|
|
208
|
+
<span class="tooltip-icon" data-tooltip="Select the Lemonade recipe corresponding to the inference engine and device Lemonade Server should use for the model. Use llamacpp for GGUF models. For OGA/ONNX models, click the More Info button to learn about the oga-* recipes.">ⓘ</span>
|
|
209
|
+
</label>
|
|
210
|
+
<select id="register-recipe" name="recipe" required>
|
|
211
|
+
<option value="llamacpp">llamacpp</option>
|
|
212
|
+
<option value="oga-npu">oga-npu</option>
|
|
213
|
+
<option value="oga-hybrid">oga-hybrid</option>
|
|
214
|
+
<option value="oga-cpu">oga-cpu</option>
|
|
215
|
+
</select>
|
|
216
|
+
<a href="https://lemonade-server.ai/docs/lemonade_api/" target="_blank" class="register-doc-link">More info</a>
|
|
217
|
+
</div>
|
|
218
|
+
<div class="register-form-row register-form-row-tight">
|
|
219
|
+
<label class="register-label">
|
|
220
|
+
mmproj file
|
|
221
|
+
<span class="tooltip-icon" data-tooltip="Specify an mmproj file from the same Hugging Face checkpoint as the model. This is used for multimodal models, such as VLMs. Leave empty if not needed.">ⓘ</span>
|
|
222
|
+
</label>
|
|
223
|
+
<input type="text" id="register-mmproj" name="mmproj" placeholder="(Optional) mmproj-F16.gguf" autocomplete="off">
|
|
224
|
+
<label class="register-label reasoning-inline">
|
|
225
|
+
<input type="checkbox" id="register-reasoning" name="reasoning">
|
|
226
|
+
Reasoning
|
|
227
|
+
<span class="tooltip-icon" data-tooltip="Enable to inform Lemonade Server that the model has reasoning capabilities that will use thinking tokens.">ⓘ</span>
|
|
228
|
+
</label>
|
|
229
|
+
</div>
|
|
230
|
+
<div class="register-form-row register-form-row-tight">
|
|
231
|
+
<button type="submit" id="register-submit">Install</button>
|
|
232
|
+
<span id="register-model-status" class="register-status"></span>
|
|
233
|
+
</div>
|
|
234
|
+
</form>
|
|
235
|
+
</div>
|
|
164
236
|
</div>
|
|
165
237
|
</div>
|
|
166
238
|
</div>
|
|
@@ -170,1034 +242,16 @@
|
|
|
170
242
|
<div class="dad-joke">When life gives you LLMs, make an LLM aide.</div>
|
|
171
243
|
<div class="copyright">Copyright 2025 AMD</div>
|
|
172
244
|
</footer>
|
|
245
|
+
|
|
246
|
+
<!-- External libraries -->
|
|
173
247
|
<script src="https://cdn.jsdelivr.net/npm/openai@4.21.0/dist/openai.min.js"></script>
|
|
174
248
|
<script src="https://cdn.jsdelivr.net/npm/marked@9.1.0/marked.min.js"></script>
|
|
175
|
-
<script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>
|
|
176
249
|
<script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
processEscapes: true,
|
|
184
|
-
processEnvironments: true
|
|
185
|
-
},
|
|
186
|
-
options: {
|
|
187
|
-
skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre']
|
|
188
|
-
}
|
|
189
|
-
};
|
|
190
|
-
</script>
|
|
191
|
-
<script>
|
|
192
|
-
// Configure marked.js for safe HTML rendering
|
|
193
|
-
marked.setOptions({
|
|
194
|
-
breaks: true,
|
|
195
|
-
gfm: true,
|
|
196
|
-
sanitize: false,
|
|
197
|
-
smartLists: true,
|
|
198
|
-
smartypants: true
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
// Function to unescape JSON strings
|
|
202
|
-
function unescapeJsonString(str) {
|
|
203
|
-
try {
|
|
204
|
-
return str.replace(/\\n/g, '\n')
|
|
205
|
-
.replace(/\\t/g, '\t')
|
|
206
|
-
.replace(/\\r/g, '\r')
|
|
207
|
-
.replace(/\\"/g, '"')
|
|
208
|
-
.replace(/\\\\/g, '\\');
|
|
209
|
-
} catch (error) {
|
|
210
|
-
console.error('Error unescaping string:', error);
|
|
211
|
-
return str;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Function to safely render markdown with MathJax support
|
|
216
|
-
function renderMarkdown(text) {
|
|
217
|
-
try {
|
|
218
|
-
const html = marked.parse(text);
|
|
219
|
-
// Trigger MathJax to process the new content
|
|
220
|
-
if (window.MathJax && window.MathJax.typesetPromise) {
|
|
221
|
-
// Use a timeout to ensure DOM is updated before typesetting
|
|
222
|
-
setTimeout(() => {
|
|
223
|
-
window.MathJax.typesetPromise();
|
|
224
|
-
}, 0);
|
|
225
|
-
}
|
|
226
|
-
return html;
|
|
227
|
-
} catch (error) {
|
|
228
|
-
console.error('Error rendering markdown:', error);
|
|
229
|
-
return text; // fallback to plain text
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Display an error message in the banner
|
|
234
|
-
function showErrorBanner(msg) {
|
|
235
|
-
const banner = document.getElementById('error-banner');
|
|
236
|
-
if (!banner) return;
|
|
237
|
-
const msgEl = document.getElementById('error-banner-msg');
|
|
238
|
-
const fullMsg = msg + '\nCheck the Lemonade Server logs via the system tray app for more information.';
|
|
239
|
-
if (msgEl) {
|
|
240
|
-
msgEl.textContent = fullMsg;
|
|
241
|
-
} else {
|
|
242
|
-
banner.textContent = fullMsg;
|
|
243
|
-
}
|
|
244
|
-
banner.style.display = 'flex';
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function hideErrorBanner() {
|
|
248
|
-
const banner = document.getElementById('error-banner');
|
|
249
|
-
if (banner) banner.style.display = 'none';
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Helper fetch wrappers that surface server error details
|
|
253
|
-
async function httpRequest(url, options = {}) {
|
|
254
|
-
const resp = await fetch(url, options);
|
|
255
|
-
if (!resp.ok) {
|
|
256
|
-
let detail = resp.statusText || 'Request failed';
|
|
257
|
-
try {
|
|
258
|
-
const contentType = resp.headers.get('content-type') || '';
|
|
259
|
-
if (contentType.includes('application/json')) {
|
|
260
|
-
const data = await resp.json();
|
|
261
|
-
if (data && data.detail) detail = data.detail;
|
|
262
|
-
} else {
|
|
263
|
-
const text = await resp.text();
|
|
264
|
-
if (text) detail = text.trim();
|
|
265
|
-
}
|
|
266
|
-
} catch (_) {}
|
|
267
|
-
throw new Error(detail);
|
|
268
|
-
}
|
|
269
|
-
return resp;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
async function httpJson(url, options = {}) {
|
|
273
|
-
const resp = await httpRequest(url, options);
|
|
274
|
-
return await resp.json();
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Tab switching logic
|
|
278
|
-
function showTab(tab, updateHash = true) {
|
|
279
|
-
document.getElementById('tab-chat').classList.remove('active');
|
|
280
|
-
document.getElementById('tab-models').classList.remove('active');
|
|
281
|
-
document.getElementById('content-chat').classList.remove('active');
|
|
282
|
-
document.getElementById('content-models').classList.remove('active');
|
|
283
|
-
if (tab === 'chat') {
|
|
284
|
-
document.getElementById('tab-chat').classList.add('active');
|
|
285
|
-
document.getElementById('content-chat').classList.add('active');
|
|
286
|
-
if (updateHash) {
|
|
287
|
-
window.location.hash = 'llm-chat';
|
|
288
|
-
}
|
|
289
|
-
} else {
|
|
290
|
-
document.getElementById('tab-models').classList.add('active');
|
|
291
|
-
document.getElementById('content-models').classList.add('active');
|
|
292
|
-
if (updateHash) {
|
|
293
|
-
window.location.hash = 'model-management';
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Handle hash changes for anchor navigation
|
|
299
|
-
function handleHashChange() {
|
|
300
|
-
const hash = window.location.hash.slice(1); // Remove the # symbol
|
|
301
|
-
if (hash === 'llm-chat') {
|
|
302
|
-
showTab('chat', false);
|
|
303
|
-
} else if (hash === 'model-management') {
|
|
304
|
-
showTab('models', false);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// Initialize tab based on URL hash on page load
|
|
309
|
-
function initializeTabFromHash() {
|
|
310
|
-
const hash = window.location.hash.slice(1);
|
|
311
|
-
if (hash === 'llm-chat') {
|
|
312
|
-
showTab('chat', false);
|
|
313
|
-
} else if (hash === 'model-management') {
|
|
314
|
-
showTab('models', false);
|
|
315
|
-
}
|
|
316
|
-
// If no hash or unrecognized hash, keep default (chat tab is already active)
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// Listen for hash changes
|
|
320
|
-
window.addEventListener('hashchange', handleHashChange);
|
|
321
|
-
|
|
322
|
-
// Initialize on page load
|
|
323
|
-
document.addEventListener('DOMContentLoaded', initializeTabFromHash);
|
|
324
|
-
|
|
325
|
-
// Toggle Add Model form
|
|
326
|
-
function toggleAddModelForm() {
|
|
327
|
-
const form = document.querySelector('.model-mgmt-register-form');
|
|
328
|
-
form.classList.toggle('collapsed');
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Handle image load failures for app logos
|
|
332
|
-
function handleImageFailure(img) {
|
|
333
|
-
const logoItem = img.closest('.app-logo-item');
|
|
334
|
-
if (logoItem) {
|
|
335
|
-
logoItem.classList.add('image-failed');
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Set up image error handlers when DOM is loaded
|
|
340
|
-
document.addEventListener('DOMContentLoaded', function() {
|
|
341
|
-
const logoImages = document.querySelectorAll('.app-logo-img');
|
|
342
|
-
logoImages.forEach(function(img) {
|
|
343
|
-
let imageLoaded = false;
|
|
344
|
-
|
|
345
|
-
img.addEventListener('load', function() {
|
|
346
|
-
imageLoaded = true;
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
img.addEventListener('error', function() {
|
|
350
|
-
if (!imageLoaded) {
|
|
351
|
-
handleImageFailure(this);
|
|
352
|
-
}
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
// Also check if image is already broken (cached failure)
|
|
356
|
-
if (img.complete && img.naturalWidth === 0) {
|
|
357
|
-
handleImageFailure(img);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Timeout fallback for slow connections (5 seconds)
|
|
361
|
-
setTimeout(function() {
|
|
362
|
-
if (!imageLoaded && !img.complete) {
|
|
363
|
-
handleImageFailure(img);
|
|
364
|
-
}
|
|
365
|
-
}, 5000);
|
|
366
|
-
});
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
// Helper to get server base URL
|
|
370
|
-
function getServerBaseUrl() {
|
|
371
|
-
const port = window.SERVER_PORT || 8000;
|
|
372
|
-
return `http://localhost:${port}`;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// Check if current model supports vision
|
|
376
|
-
function isVisionModel(modelId) {
|
|
377
|
-
const allModels = window.SERVER_MODELS || {};
|
|
378
|
-
const modelData = allModels[modelId];
|
|
379
|
-
if (modelData && modelData.labels && Array.isArray(modelData.labels)) {
|
|
380
|
-
return modelData.labels.some(label => label.toLowerCase() === 'vision');
|
|
381
|
-
}
|
|
382
|
-
return false;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Update attachment button state based on current model
|
|
386
|
-
function updateAttachmentButtonState() {
|
|
387
|
-
const currentModel = modelSelect.value;
|
|
388
|
-
const isVision = isVisionModel(currentModel);
|
|
389
|
-
|
|
390
|
-
if (isVision) {
|
|
391
|
-
attachmentBtn.style.opacity = '1';
|
|
392
|
-
attachmentBtn.style.cursor = 'pointer';
|
|
393
|
-
attachmentBtn.title = 'Attach images';
|
|
394
|
-
} else {
|
|
395
|
-
attachmentBtn.style.opacity = '0.5';
|
|
396
|
-
attachmentBtn.style.cursor = 'not-allowed';
|
|
397
|
-
attachmentBtn.title = 'Image attachments not supported by this model';
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// Populate model dropdown from /api/v1/models endpoint
|
|
402
|
-
async function loadModels() {
|
|
403
|
-
try {
|
|
404
|
-
const data = await httpJson(getServerBaseUrl() + '/api/v1/models');
|
|
405
|
-
const select = document.getElementById('model-select');
|
|
406
|
-
select.innerHTML = '';
|
|
407
|
-
if (!data.data || !Array.isArray(data.data)) {
|
|
408
|
-
select.innerHTML = '<option>No models found (malformed response)</option>';
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
if (data.data.length === 0) {
|
|
412
|
-
select.innerHTML = '<option>No models available</option>';
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Filter out embedding models from chat interface
|
|
417
|
-
const allModels = window.SERVER_MODELS || {};
|
|
418
|
-
let filteredModels = [];
|
|
419
|
-
let defaultIndex = 0;
|
|
420
|
-
|
|
421
|
-
// Check if model is specified in URL parameters
|
|
422
|
-
const urlModel = new URLSearchParams(window.location.search).get('model');
|
|
423
|
-
let urlModelIndex = -1;
|
|
424
|
-
|
|
425
|
-
data.data.forEach(function(model, index) {
|
|
426
|
-
const modelId = model.id || model.name || model;
|
|
427
|
-
const modelInfo = allModels[modelId] || {};
|
|
428
|
-
const labels = modelInfo.labels || [];
|
|
429
|
-
|
|
430
|
-
// Skip models with "embeddings" or "reranking" label
|
|
431
|
-
if (labels.includes('embeddings') || labels.includes('reranking')) {
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
filteredModels.push(modelId);
|
|
436
|
-
const opt = document.createElement('option');
|
|
437
|
-
opt.value = modelId;
|
|
438
|
-
opt.textContent = modelId;
|
|
439
|
-
|
|
440
|
-
// Check if this model matches the URL parameter
|
|
441
|
-
if (urlModel && modelId === urlModel) {
|
|
442
|
-
urlModelIndex = filteredModels.length - 1;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// Default fallback for backwards compatibility
|
|
446
|
-
if (modelId === 'Llama-3.2-1B-Instruct-Hybrid') {
|
|
447
|
-
defaultIndex = filteredModels.length - 1;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
select.appendChild(opt);
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
if (filteredModels.length === 0) {
|
|
454
|
-
select.innerHTML = '<option>No chat models available</option>';
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// Select the URL-specified model if found, otherwise use default
|
|
459
|
-
if (urlModelIndex !== -1) {
|
|
460
|
-
select.selectedIndex = urlModelIndex;
|
|
461
|
-
console.log(`Selected model from URL parameter: ${urlModel}`);
|
|
462
|
-
} else {
|
|
463
|
-
select.selectedIndex = defaultIndex;
|
|
464
|
-
if (urlModel) {
|
|
465
|
-
console.warn(`Model '${urlModel}' specified in URL not found in available models`);
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
// Update attachment button state after model is loaded
|
|
470
|
-
updateAttachmentButtonState();
|
|
471
|
-
} catch (e) {
|
|
472
|
-
const select = document.getElementById('model-select');
|
|
473
|
-
select.innerHTML = `<option>Error loading models: ${e.message}</option>`;
|
|
474
|
-
console.error('Error loading models:', e);
|
|
475
|
-
showErrorBanner(`Error loading models: ${e.message}`);
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
loadModels();
|
|
479
|
-
|
|
480
|
-
// Add model change handler to clear attachments if switching to non-vision model
|
|
481
|
-
document.addEventListener('DOMContentLoaded', function() {
|
|
482
|
-
const modelSelect = document.getElementById('model-select');
|
|
483
|
-
if (modelSelect) {
|
|
484
|
-
modelSelect.addEventListener('change', function() {
|
|
485
|
-
const currentModel = this.value;
|
|
486
|
-
updateAttachmentButtonState(); // Update button visual state
|
|
487
|
-
|
|
488
|
-
if (attachedFiles.length > 0 && !isVisionModel(currentModel)) {
|
|
489
|
-
if (confirm(`The selected model "${currentModel}" does not support images. Would you like to remove the attached images?`)) {
|
|
490
|
-
clearAttachments();
|
|
491
|
-
} else {
|
|
492
|
-
// Find a vision model to switch back to
|
|
493
|
-
const allModels = window.SERVER_MODELS || {};
|
|
494
|
-
const visionModels = Array.from(this.options).filter(option =>
|
|
495
|
-
isVisionModel(option.value)
|
|
496
|
-
);
|
|
497
|
-
|
|
498
|
-
if (visionModels.length > 0) {
|
|
499
|
-
this.value = visionModels[0].value;
|
|
500
|
-
updateAttachmentButtonState(); // Update button state again
|
|
501
|
-
alert(`Switched back to "${visionModels[0].value}" which supports images.`);
|
|
502
|
-
} else {
|
|
503
|
-
alert('No vision models available. Images will be cleared.');
|
|
504
|
-
clearAttachments();
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
});
|
|
509
|
-
}
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
// Helper function to create model name with labels
|
|
513
|
-
function createModelNameWithLabels(modelId, allModels) {
|
|
514
|
-
// Create container for model name and labels
|
|
515
|
-
const container = document.createElement('div');
|
|
516
|
-
container.className = 'model-labels-container';
|
|
517
|
-
|
|
518
|
-
// Add model name
|
|
519
|
-
const nameSpan = document.createElement('span');
|
|
520
|
-
nameSpan.textContent = modelId;
|
|
521
|
-
container.appendChild(nameSpan);
|
|
522
|
-
|
|
523
|
-
// Add labels if they exist
|
|
524
|
-
const modelData = allModels[modelId];
|
|
525
|
-
if (modelData && modelData.labels && Array.isArray(modelData.labels)) {
|
|
526
|
-
modelData.labels.forEach(label => {
|
|
527
|
-
const labelLower = label.toLowerCase();
|
|
528
|
-
|
|
529
|
-
// Skip "hot" labels since they have their own section
|
|
530
|
-
if (labelLower === 'hot') {
|
|
531
|
-
return;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
const labelSpan = document.createElement('span');
|
|
535
|
-
let labelClass = 'other';
|
|
536
|
-
if (labelLower === 'vision') {
|
|
537
|
-
labelClass = 'vision';
|
|
538
|
-
} else if (labelLower === 'embeddings') {
|
|
539
|
-
labelClass = 'embeddings';
|
|
540
|
-
} else if (labelLower === 'reasoning') {
|
|
541
|
-
labelClass = 'reasoning';
|
|
542
|
-
} else if (labelLower === 'reranking') {
|
|
543
|
-
labelClass = 'reranking';
|
|
544
|
-
} else if (labelLower === 'coding') {
|
|
545
|
-
labelClass = 'coding';
|
|
546
|
-
}
|
|
547
|
-
labelSpan.className = `model-label ${labelClass}`;
|
|
548
|
-
labelSpan.textContent = label;
|
|
549
|
-
container.appendChild(labelSpan);
|
|
550
|
-
});
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
return container;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// Helper function to render a model table section
|
|
557
|
-
function renderModelTable(tbody, models, allModels, emptyMessage) {
|
|
558
|
-
tbody.innerHTML = '';
|
|
559
|
-
if (models.length === 0) {
|
|
560
|
-
const tr = document.createElement('tr');
|
|
561
|
-
const td = document.createElement('td');
|
|
562
|
-
td.colSpan = 2;
|
|
563
|
-
td.textContent = emptyMessage;
|
|
564
|
-
td.style.textAlign = 'center';
|
|
565
|
-
td.style.fontStyle = 'italic';
|
|
566
|
-
td.style.color = '#666';
|
|
567
|
-
td.style.padding = '1em';
|
|
568
|
-
tr.appendChild(td);
|
|
569
|
-
tbody.appendChild(tr);
|
|
570
|
-
} else {
|
|
571
|
-
models.forEach(mid => {
|
|
572
|
-
const tr = document.createElement('tr');
|
|
573
|
-
const tdName = document.createElement('td');
|
|
574
|
-
|
|
575
|
-
tdName.appendChild(createModelNameWithLabels(mid, allModels));
|
|
576
|
-
tdName.style.paddingRight = '1em';
|
|
577
|
-
tdName.style.verticalAlign = 'middle';
|
|
578
|
-
const tdBtn = document.createElement('td');
|
|
579
|
-
tdBtn.style.width = '1%';
|
|
580
|
-
tdBtn.style.verticalAlign = 'middle';
|
|
581
|
-
const btn = document.createElement('button');
|
|
582
|
-
btn.textContent = '+';
|
|
583
|
-
btn.title = 'Install model';
|
|
584
|
-
btn.onclick = async function() {
|
|
585
|
-
btn.disabled = true;
|
|
586
|
-
btn.textContent = 'Installing...';
|
|
587
|
-
btn.classList.add('installing-btn');
|
|
588
|
-
try {
|
|
589
|
-
await httpRequest(getServerBaseUrl() + '/api/v1/pull', {
|
|
590
|
-
method: 'POST',
|
|
591
|
-
headers: { 'Content-Type': 'application/json' },
|
|
592
|
-
body: JSON.stringify({ model_name: mid })
|
|
593
|
-
});
|
|
594
|
-
await refreshModelMgmtUI();
|
|
595
|
-
await loadModels(); // update chat dropdown too
|
|
596
|
-
} catch (e) {
|
|
597
|
-
btn.textContent = 'Error';
|
|
598
|
-
showErrorBanner(`Failed to install model: ${e.message}`);
|
|
599
|
-
}
|
|
600
|
-
};
|
|
601
|
-
tdBtn.appendChild(btn);
|
|
602
|
-
tr.appendChild(tdName);
|
|
603
|
-
tr.appendChild(tdBtn);
|
|
604
|
-
tbody.appendChild(tr);
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// Model Management Tab Logic
|
|
610
|
-
async function refreshModelMgmtUI() {
|
|
611
|
-
// Get installed models from /api/v1/models
|
|
612
|
-
let installed = [];
|
|
613
|
-
try {
|
|
614
|
-
const data = await httpJson(getServerBaseUrl() + '/api/v1/models');
|
|
615
|
-
if (data.data && Array.isArray(data.data)) {
|
|
616
|
-
installed = data.data.map(m => m.id || m.name || m);
|
|
617
|
-
}
|
|
618
|
-
} catch (e) {
|
|
619
|
-
showErrorBanner(`Error loading models: ${e.message}`);
|
|
620
|
-
}
|
|
621
|
-
// All models from server_models.json (window.SERVER_MODELS)
|
|
622
|
-
const allModels = window.SERVER_MODELS || {};
|
|
623
|
-
|
|
624
|
-
// Separate hot models and regular suggested models not installed
|
|
625
|
-
const hotModels = [];
|
|
626
|
-
const regularSuggested = [];
|
|
627
|
-
|
|
628
|
-
Object.keys(allModels).forEach(k => {
|
|
629
|
-
if (allModels[k].suggested && !installed.includes(k)) {
|
|
630
|
-
const modelData = allModels[k];
|
|
631
|
-
const hasHotLabel = modelData.labels && modelData.labels.some(label =>
|
|
632
|
-
label.toLowerCase() === 'hot'
|
|
633
|
-
);
|
|
634
|
-
|
|
635
|
-
if (hasHotLabel) {
|
|
636
|
-
hotModels.push(k);
|
|
637
|
-
} else {
|
|
638
|
-
regularSuggested.push(k);
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
});
|
|
642
|
-
// Render installed models as a table (two columns, second is invisible)
|
|
643
|
-
const installedTbody = document.getElementById('installed-models-tbody');
|
|
644
|
-
installedTbody.innerHTML = '';
|
|
645
|
-
installed.forEach(function(mid) {
|
|
646
|
-
var tr = document.createElement('tr');
|
|
647
|
-
var tdName = document.createElement('td');
|
|
648
|
-
|
|
649
|
-
tdName.appendChild(createModelNameWithLabels(mid, allModels));
|
|
650
|
-
tdName.style.paddingRight = '1em';
|
|
651
|
-
tdName.style.verticalAlign = 'middle';
|
|
652
|
-
|
|
653
|
-
var tdBtn = document.createElement('td');
|
|
654
|
-
tdBtn.style.width = '1%';
|
|
655
|
-
tdBtn.style.verticalAlign = 'middle';
|
|
656
|
-
const btn = document.createElement('button');
|
|
657
|
-
btn.textContent = '−';
|
|
658
|
-
btn.title = 'Delete model';
|
|
659
|
-
btn.style.cursor = 'pointer';
|
|
660
|
-
btn.onclick = async function() {
|
|
661
|
-
if (!confirm(`Are you sure you want to delete the model "${mid}"?`)) {
|
|
662
|
-
return;
|
|
663
|
-
}
|
|
664
|
-
btn.disabled = true;
|
|
665
|
-
btn.textContent = 'Deleting...';
|
|
666
|
-
btn.style.backgroundColor = '#888';
|
|
667
|
-
try {
|
|
668
|
-
await httpRequest(getServerBaseUrl() + '/api/v1/delete', {
|
|
669
|
-
method: 'POST',
|
|
670
|
-
headers: { 'Content-Type': 'application/json' },
|
|
671
|
-
body: JSON.stringify({ model_name: mid })
|
|
672
|
-
});
|
|
673
|
-
await refreshModelMgmtUI();
|
|
674
|
-
await loadModels(); // update chat dropdown too
|
|
675
|
-
} catch (e) {
|
|
676
|
-
btn.textContent = 'Error';
|
|
677
|
-
btn.disabled = false;
|
|
678
|
-
showErrorBanner(`Failed to delete model: ${e.message}`);
|
|
679
|
-
}
|
|
680
|
-
};
|
|
681
|
-
tdBtn.appendChild(btn);
|
|
682
|
-
|
|
683
|
-
tr.appendChild(tdName);
|
|
684
|
-
tr.appendChild(tdBtn);
|
|
685
|
-
installedTbody.appendChild(tr);
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
// Render hot models and suggested models using the helper function
|
|
689
|
-
const hotTbody = document.getElementById('hot-models-tbody');
|
|
690
|
-
const suggestedTbody = document.getElementById('suggested-models-tbody');
|
|
691
|
-
|
|
692
|
-
renderModelTable(hotTbody, hotModels, allModels, "Nice, you've already installed all these models!");
|
|
693
|
-
renderModelTable(suggestedTbody, regularSuggested, allModels, "Nice, you've already installed all these models!");
|
|
694
|
-
}
|
|
695
|
-
// Initial load
|
|
696
|
-
refreshModelMgmtUI();
|
|
697
|
-
// Optionally, refresh when switching to the tab
|
|
698
|
-
document.getElementById('tab-models').addEventListener('click', refreshModelMgmtUI);
|
|
699
|
-
|
|
700
|
-
// Chat logic (streaming with OpenAI JS client placeholder)
|
|
701
|
-
const chatHistory = document.getElementById('chat-history');
|
|
702
|
-
const chatInput = document.getElementById('chat-input');
|
|
703
|
-
const sendBtn = document.getElementById('send-btn');
|
|
704
|
-
const attachmentBtn = document.getElementById('attachment-btn');
|
|
705
|
-
const fileAttachment = document.getElementById('file-attachment');
|
|
706
|
-
const attachmentsPreviewContainer = document.getElementById('attachments-preview-container');
|
|
707
|
-
const attachmentsPreviewRow = document.getElementById('attachments-preview-row');
|
|
708
|
-
const modelSelect = document.getElementById('model-select');
|
|
709
|
-
let messages = [];
|
|
710
|
-
let attachedFiles = [];
|
|
711
|
-
|
|
712
|
-
attachmentBtn.onclick = () => {
|
|
713
|
-
const currentModel = modelSelect.value;
|
|
714
|
-
if (!isVisionModel(currentModel)) {
|
|
715
|
-
alert(`The selected model "${currentModel}" does not support image inputs. Please select a model with "Vision" capabilities to attach images.`);
|
|
716
|
-
return;
|
|
717
|
-
}
|
|
718
|
-
fileAttachment.click();
|
|
719
|
-
};
|
|
720
|
-
|
|
721
|
-
function clearAttachments() {
|
|
722
|
-
attachedFiles = [];
|
|
723
|
-
fileAttachment.value = '';
|
|
724
|
-
updateInputPlaceholder();
|
|
725
|
-
updateAttachmentPreviewVisibility();
|
|
726
|
-
updateAttachmentPreviews();
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
function updateAttachmentPreviewVisibility() {
|
|
730
|
-
if (attachedFiles.length > 0) {
|
|
731
|
-
attachmentsPreviewContainer.classList.add('has-attachments');
|
|
732
|
-
} else {
|
|
733
|
-
attachmentsPreviewContainer.classList.remove('has-attachments');
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
function updateAttachmentPreviews() {
|
|
738
|
-
// Clear existing previews
|
|
739
|
-
attachmentsPreviewRow.innerHTML = '';
|
|
740
|
-
|
|
741
|
-
if (attachedFiles.length === 0) {
|
|
742
|
-
return;
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
attachedFiles.forEach((file, index) => {
|
|
746
|
-
// Skip non-image files (extra safety check)
|
|
747
|
-
if (!file.type.startsWith('image/')) {
|
|
748
|
-
console.warn(`Skipping non-image file in preview: ${file.name} (${file.type})`);
|
|
749
|
-
return;
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
const previewDiv = document.createElement('div');
|
|
753
|
-
previewDiv.className = 'attachment-preview';
|
|
754
|
-
|
|
755
|
-
// Create thumbnail
|
|
756
|
-
const thumbnail = document.createElement('img');
|
|
757
|
-
thumbnail.className = 'attachment-thumbnail';
|
|
758
|
-
thumbnail.alt = file.name;
|
|
759
|
-
|
|
760
|
-
// Create filename display
|
|
761
|
-
const filename = document.createElement('div');
|
|
762
|
-
filename.className = 'attachment-filename';
|
|
763
|
-
filename.textContent = file.name || `pasted-image-${index + 1}`;
|
|
764
|
-
filename.title = file.name || `pasted-image-${index + 1}`;
|
|
765
|
-
|
|
766
|
-
// Create remove button
|
|
767
|
-
const removeBtn = document.createElement('button');
|
|
768
|
-
removeBtn.className = 'attachment-remove-btn';
|
|
769
|
-
removeBtn.innerHTML = '✕';
|
|
770
|
-
removeBtn.title = 'Remove this image';
|
|
771
|
-
removeBtn.onclick = () => removeAttachment(index);
|
|
772
|
-
|
|
773
|
-
// Generate thumbnail for image
|
|
774
|
-
const reader = new FileReader();
|
|
775
|
-
reader.onload = (e) => {
|
|
776
|
-
thumbnail.src = e.target.result;
|
|
777
|
-
};
|
|
778
|
-
reader.readAsDataURL(file);
|
|
779
|
-
|
|
780
|
-
previewDiv.appendChild(thumbnail);
|
|
781
|
-
previewDiv.appendChild(filename);
|
|
782
|
-
previewDiv.appendChild(removeBtn);
|
|
783
|
-
attachmentsPreviewRow.appendChild(previewDiv);
|
|
784
|
-
});
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
function removeAttachment(index) {
|
|
788
|
-
attachedFiles.splice(index, 1);
|
|
789
|
-
updateInputPlaceholder();
|
|
790
|
-
updateAttachmentPreviewVisibility();
|
|
791
|
-
updateAttachmentPreviews();
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
fileAttachment.addEventListener('change', () => {
|
|
795
|
-
if (fileAttachment.files.length > 0) {
|
|
796
|
-
// Check if current model supports vision
|
|
797
|
-
const currentModel = modelSelect.value;
|
|
798
|
-
if (!isVisionModel(currentModel)) {
|
|
799
|
-
alert(`The selected model "${currentModel}" does not support image inputs. Please select a model with "Vision" capabilities or choose a different model.`);
|
|
800
|
-
fileAttachment.value = ''; // Clear the input
|
|
801
|
-
return;
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
// Filter only image files
|
|
805
|
-
const imageFiles = Array.from(fileAttachment.files).filter(file => {
|
|
806
|
-
if (!file.type.startsWith('image/')) {
|
|
807
|
-
console.warn(`Skipping non-image file: ${file.name} (${file.type})`);
|
|
808
|
-
return false;
|
|
809
|
-
}
|
|
810
|
-
return true;
|
|
811
|
-
});
|
|
812
|
-
|
|
813
|
-
if (imageFiles.length === 0) {
|
|
814
|
-
alert('Please select only image files (PNG, JPG, GIF, etc.)');
|
|
815
|
-
fileAttachment.value = ''; // Clear the input
|
|
816
|
-
return;
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
if (imageFiles.length !== fileAttachment.files.length) {
|
|
820
|
-
alert(`${fileAttachment.files.length - imageFiles.length} non-image file(s) were skipped. Only image files are supported.`);
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
attachedFiles = imageFiles;
|
|
824
|
-
updateInputPlaceholder();
|
|
825
|
-
updateAttachmentPreviewVisibility();
|
|
826
|
-
updateAttachmentPreviews();
|
|
827
|
-
}
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
// Handle paste events for images
|
|
831
|
-
chatInput.addEventListener('paste', async (e) => {
|
|
832
|
-
e.preventDefault();
|
|
833
|
-
|
|
834
|
-
const clipboardData = e.clipboardData || window.clipboardData;
|
|
835
|
-
const items = clipboardData.items;
|
|
836
|
-
let hasImage = false;
|
|
837
|
-
let pastedText = '';
|
|
838
|
-
|
|
839
|
-
// Check for text content first
|
|
840
|
-
for (let item of items) {
|
|
841
|
-
if (item.type === 'text/plain') {
|
|
842
|
-
pastedText = clipboardData.getData('text/plain');
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
// Check for images
|
|
847
|
-
for (let item of items) {
|
|
848
|
-
if (item.type.indexOf('image') !== -1) {
|
|
849
|
-
hasImage = true;
|
|
850
|
-
const file = item.getAsFile();
|
|
851
|
-
if (file && file.type.startsWith('image/')) {
|
|
852
|
-
// Check if current model supports vision before adding image
|
|
853
|
-
const currentModel = modelSelect.value;
|
|
854
|
-
if (!isVisionModel(currentModel)) {
|
|
855
|
-
alert(`The selected model "${currentModel}" does not support image inputs. Please select a model with "Vision" capabilities to paste images.`);
|
|
856
|
-
// Only paste text, skip the image
|
|
857
|
-
if (pastedText) {
|
|
858
|
-
chatInput.value = pastedText;
|
|
859
|
-
}
|
|
860
|
-
return;
|
|
861
|
-
}
|
|
862
|
-
// Add to attachedFiles array only if it's an image and model supports vision
|
|
863
|
-
attachedFiles.push(file);
|
|
864
|
-
} else if (file) {
|
|
865
|
-
console.warn(`Skipping non-image pasted file: ${file.name || 'unknown'} (${file.type})`);
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
// Update input box content - only show text, images will be indicated separately
|
|
871
|
-
if (pastedText) {
|
|
872
|
-
chatInput.value = pastedText;
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// Update placeholder to show attached images
|
|
876
|
-
updateInputPlaceholder();
|
|
877
|
-
updateAttachmentPreviewVisibility();
|
|
878
|
-
updateAttachmentPreviews();
|
|
879
|
-
});
|
|
880
|
-
|
|
881
|
-
// Function to update input placeholder to show attached files
|
|
882
|
-
function updateInputPlaceholder() {
|
|
883
|
-
if (attachedFiles.length > 0) {
|
|
884
|
-
chatInput.placeholder = `Type your message... (${attachedFiles.length} image${attachedFiles.length > 1 ? 's' : ''} attached)`;
|
|
885
|
-
} else {
|
|
886
|
-
chatInput.placeholder = 'Type your message...';
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
// Add keyboard shortcut to clear attachments
|
|
891
|
-
chatInput.addEventListener('keydown', function(e) {
|
|
892
|
-
if (e.key === 'Escape' && attachedFiles.length > 0) {
|
|
893
|
-
e.preventDefault();
|
|
894
|
-
clearAttachments();
|
|
895
|
-
} else if (e.key === 'Enter') {
|
|
896
|
-
sendMessage();
|
|
897
|
-
}
|
|
898
|
-
});
|
|
899
|
-
|
|
900
|
-
// Function to convert file to base64
|
|
901
|
-
function fileToBase64(file) {
|
|
902
|
-
return new Promise((resolve, reject) => {
|
|
903
|
-
const reader = new FileReader();
|
|
904
|
-
reader.readAsDataURL(file);
|
|
905
|
-
reader.onload = () => resolve(reader.result.split(',')[1]); // Remove data:image/...;base64, prefix
|
|
906
|
-
reader.onerror = error => reject(error);
|
|
907
|
-
});
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
function appendMessage(role, text, isMarkdown = false) {
|
|
911
|
-
const div = document.createElement('div');
|
|
912
|
-
div.className = 'chat-message ' + role;
|
|
913
|
-
// Add a bubble for iMessage style
|
|
914
|
-
const bubble = document.createElement('div');
|
|
915
|
-
bubble.className = 'chat-bubble ' + role;
|
|
916
|
-
|
|
917
|
-
if (role === 'llm' && isMarkdown) {
|
|
918
|
-
bubble.innerHTML = renderMarkdownWithThinkTokens(text);
|
|
919
|
-
} else {
|
|
920
|
-
bubble.textContent = text;
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
div.appendChild(bubble);
|
|
924
|
-
chatHistory.appendChild(div);
|
|
925
|
-
chatHistory.scrollTop = chatHistory.scrollHeight;
|
|
926
|
-
return bubble; // Return the bubble element for streaming updates
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
function updateMessageContent(bubbleElement, text, isMarkdown = false) {
|
|
930
|
-
if (isMarkdown) {
|
|
931
|
-
bubbleElement.innerHTML = renderMarkdownWithThinkTokens(text);
|
|
932
|
-
} else {
|
|
933
|
-
bubbleElement.textContent = text;
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
function renderMarkdownWithThinkTokens(text) {
|
|
938
|
-
// Check if text contains opening think tag
|
|
939
|
-
if (text.includes('<think>')) {
|
|
940
|
-
if (text.includes('</think>')) {
|
|
941
|
-
// Complete think block - handle as before
|
|
942
|
-
const thinkMatch = text.match(/<think>(.*?)<\/think>/s);
|
|
943
|
-
if (thinkMatch) {
|
|
944
|
-
const thinkContent = thinkMatch[1].trim();
|
|
945
|
-
const mainResponse = text.replace(/<think>.*?<\/think>/s, '').trim();
|
|
946
|
-
|
|
947
|
-
// Create collapsible structure
|
|
948
|
-
let html = '';
|
|
949
|
-
if (thinkContent) {
|
|
950
|
-
html += `
|
|
951
|
-
<div class="think-tokens-container">
|
|
952
|
-
<div class="think-tokens-header" onclick="toggleThinkTokens(this)">
|
|
953
|
-
<span class="think-tokens-chevron">▼</span>
|
|
954
|
-
<span class="think-tokens-label">Thinking...</span>
|
|
955
|
-
</div>
|
|
956
|
-
<div class="think-tokens-content">
|
|
957
|
-
${renderMarkdown(thinkContent)}
|
|
958
|
-
</div>
|
|
959
|
-
</div>
|
|
960
|
-
`;
|
|
961
|
-
}
|
|
962
|
-
if (mainResponse) {
|
|
963
|
-
html += `<div class="main-response">${renderMarkdown(mainResponse)}</div>`;
|
|
964
|
-
}
|
|
965
|
-
return html;
|
|
966
|
-
}
|
|
967
|
-
} else {
|
|
968
|
-
// Partial think block - only opening tag found, still being generated
|
|
969
|
-
const thinkMatch = text.match(/<think>(.*)/s);
|
|
970
|
-
if (thinkMatch) {
|
|
971
|
-
const thinkContent = thinkMatch[1];
|
|
972
|
-
const beforeThink = text.substring(0, text.indexOf('<think>'));
|
|
973
|
-
|
|
974
|
-
let html = '';
|
|
975
|
-
if (beforeThink.trim()) {
|
|
976
|
-
html += `<div class="main-response">${renderMarkdown(beforeThink)}</div>`;
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
html += `
|
|
980
|
-
<div class="think-tokens-container">
|
|
981
|
-
<div class="think-tokens-header" onclick="toggleThinkTokens(this)">
|
|
982
|
-
<span class="think-tokens-chevron">▼</span>
|
|
983
|
-
<span class="think-tokens-label">Thinking...</span>
|
|
984
|
-
</div>
|
|
985
|
-
<div class="think-tokens-content">
|
|
986
|
-
${renderMarkdown(thinkContent)}
|
|
987
|
-
</div>
|
|
988
|
-
</div>
|
|
989
|
-
`;
|
|
990
|
-
|
|
991
|
-
return html;
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
// Fallback to normal markdown rendering
|
|
997
|
-
return renderMarkdown(text);
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
function toggleThinkTokens(header) {
|
|
1001
|
-
const container = header.parentElement;
|
|
1002
|
-
const content = container.querySelector('.think-tokens-content');
|
|
1003
|
-
const chevron = header.querySelector('.think-tokens-chevron');
|
|
1004
|
-
|
|
1005
|
-
if (content.style.display === 'none') {
|
|
1006
|
-
content.style.display = 'block';
|
|
1007
|
-
chevron.textContent = '▼';
|
|
1008
|
-
container.classList.remove('collapsed');
|
|
1009
|
-
} else {
|
|
1010
|
-
content.style.display = 'none';
|
|
1011
|
-
chevron.textContent = '▶';
|
|
1012
|
-
container.classList.add('collapsed');
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
async function sendMessage() {
|
|
1017
|
-
const text = chatInput.value.trim();
|
|
1018
|
-
if (!text && attachedFiles.length === 0) return;
|
|
1019
|
-
|
|
1020
|
-
// Check if trying to send images to non-vision model
|
|
1021
|
-
if (attachedFiles.length > 0) {
|
|
1022
|
-
const currentModel = modelSelect.value;
|
|
1023
|
-
if (!isVisionModel(currentModel)) {
|
|
1024
|
-
alert(`Cannot send images to model "${currentModel}" as it does not support vision. Please select a model with "Vision" capabilities or remove the attached images.`);
|
|
1025
|
-
return;
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
// Create message content
|
|
1030
|
-
let messageContent = [];
|
|
1031
|
-
|
|
1032
|
-
// Add text if present
|
|
1033
|
-
if (text) {
|
|
1034
|
-
messageContent.push({
|
|
1035
|
-
type: "text",
|
|
1036
|
-
text: text
|
|
1037
|
-
});
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
// Add images if present
|
|
1041
|
-
if (attachedFiles.length > 0) {
|
|
1042
|
-
for (const file of attachedFiles) {
|
|
1043
|
-
if (file.type.startsWith('image/')) {
|
|
1044
|
-
try {
|
|
1045
|
-
const base64 = await fileToBase64(file);
|
|
1046
|
-
messageContent.push({
|
|
1047
|
-
type: "image_url",
|
|
1048
|
-
image_url: {
|
|
1049
|
-
url: `data:${file.type};base64,${base64}`
|
|
1050
|
-
}
|
|
1051
|
-
});
|
|
1052
|
-
} catch (error) {
|
|
1053
|
-
console.error('Error converting image to base64:', error);
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
// Display user message (show text and file names)
|
|
1060
|
-
let displayText = text;
|
|
1061
|
-
if (attachedFiles.length > 0) {
|
|
1062
|
-
const fileNames = attachedFiles.map(f => f.name || 'pasted-image').join(', ');
|
|
1063
|
-
displayText = displayText ? `${displayText}\n[Images: ${fileNames}]` : `[Images: ${fileNames}]`;
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
appendMessage('user', displayText);
|
|
1067
|
-
|
|
1068
|
-
// Add to messages array
|
|
1069
|
-
const userMessage = {
|
|
1070
|
-
role: 'user',
|
|
1071
|
-
content: messageContent.length === 1 && messageContent[0].type === "text"
|
|
1072
|
-
? messageContent[0].text
|
|
1073
|
-
: messageContent
|
|
1074
|
-
};
|
|
1075
|
-
messages.push(userMessage);
|
|
1076
|
-
|
|
1077
|
-
// Clear input and attachments
|
|
1078
|
-
chatInput.value = '';
|
|
1079
|
-
attachedFiles = [];
|
|
1080
|
-
fileAttachment.value = '';
|
|
1081
|
-
updateInputPlaceholder(); // Reset placeholder
|
|
1082
|
-
updateAttachmentPreviewVisibility(); // Hide preview container
|
|
1083
|
-
updateAttachmentPreviews(); // Clear previews
|
|
1084
|
-
sendBtn.disabled = true;
|
|
1085
|
-
|
|
1086
|
-
// Streaming OpenAI completions (placeholder, adapt as needed)
|
|
1087
|
-
let llmText = '';
|
|
1088
|
-
const llmBubble = appendMessage('llm', '...');
|
|
1089
|
-
try {
|
|
1090
|
-
// Use the correct endpoint for chat completions
|
|
1091
|
-
const payload = {
|
|
1092
|
-
model: modelSelect.value,
|
|
1093
|
-
messages: messages,
|
|
1094
|
-
stream: true
|
|
1095
|
-
};
|
|
1096
|
-
const resp = await httpRequest(getServerBaseUrl() + '/api/v1/chat/completions', {
|
|
1097
|
-
method: 'POST',
|
|
1098
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1099
|
-
body: JSON.stringify(payload)
|
|
1100
|
-
});
|
|
1101
|
-
if (!resp.body) throw new Error('No stream');
|
|
1102
|
-
const reader = resp.body.getReader();
|
|
1103
|
-
let decoder = new TextDecoder();
|
|
1104
|
-
llmBubble.textContent = '';
|
|
1105
|
-
while (true) {
|
|
1106
|
-
const { done, value } = await reader.read();
|
|
1107
|
-
if (done) break;
|
|
1108
|
-
const chunk = decoder.decode(value);
|
|
1109
|
-
if (chunk.trim() === 'data: [DONE]' || chunk.trim() === '[DONE]') continue;
|
|
1110
|
-
|
|
1111
|
-
// Handle Server-Sent Events format
|
|
1112
|
-
const lines = chunk.split('\n');
|
|
1113
|
-
for (const line of lines) {
|
|
1114
|
-
if (line.startsWith('data: ')) {
|
|
1115
|
-
const jsonStr = line.substring(6).trim();
|
|
1116
|
-
if (jsonStr === '[DONE]') continue;
|
|
1117
|
-
|
|
1118
|
-
try {
|
|
1119
|
-
const parsed = JSON.parse(jsonStr);
|
|
1120
|
-
if (parsed.choices && parsed.choices[0] && parsed.choices[0].delta && parsed.choices[0].delta.content) {
|
|
1121
|
-
llmText += parsed.choices[0].delta.content;
|
|
1122
|
-
updateMessageContent(llmBubble, llmText, true);
|
|
1123
|
-
}
|
|
1124
|
-
} catch (e) {
|
|
1125
|
-
// Fallback to regex parsing if JSON parsing fails
|
|
1126
|
-
const match = jsonStr.match(/"content"\s*:\s*"((?:\\.|[^"\\])*)"/);
|
|
1127
|
-
if (match && match[1]) {
|
|
1128
|
-
llmText += unescapeJsonString(match[1]);
|
|
1129
|
-
updateMessageContent(llmBubble, llmText, true);
|
|
1130
|
-
}
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
if (!llmText) throw new Error('No response');
|
|
1136
|
-
messages.push({ role: 'assistant', content: llmText });
|
|
1137
|
-
} catch (e) {
|
|
1138
|
-
let detail = e.message;
|
|
1139
|
-
try {
|
|
1140
|
-
const errPayload = { ...payload, stream: false };
|
|
1141
|
-
const errResp = await httpJson(getServerBaseUrl() + '/api/v1/chat/completions', {
|
|
1142
|
-
method: 'POST',
|
|
1143
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1144
|
-
body: JSON.stringify(errPayload)
|
|
1145
|
-
});
|
|
1146
|
-
if (errResp && errResp.detail) detail = errResp.detail;
|
|
1147
|
-
} catch (_) {}
|
|
1148
|
-
llmBubble.textContent = '[Error: ' + detail + ']';
|
|
1149
|
-
showErrorBanner(`Chat error: ${detail}`);
|
|
1150
|
-
}
|
|
1151
|
-
sendBtn.disabled = false;
|
|
1152
|
-
}
|
|
1153
|
-
sendBtn.onclick = sendMessage;
|
|
1154
|
-
|
|
1155
|
-
// Register & Install Model logic
|
|
1156
|
-
const registerForm = document.getElementById('register-model-form');
|
|
1157
|
-
const registerStatus = document.getElementById('register-model-status');
|
|
1158
|
-
if (registerForm) {
|
|
1159
|
-
registerForm.onsubmit = async function(e) {
|
|
1160
|
-
e.preventDefault();
|
|
1161
|
-
registerStatus.textContent = '';
|
|
1162
|
-
let name = document.getElementById('register-model-name').value.trim();
|
|
1163
|
-
// Always prepend 'user.' if not already present
|
|
1164
|
-
if (!name.startsWith('user.')) {
|
|
1165
|
-
name = 'user.' + name;
|
|
1166
|
-
}
|
|
1167
|
-
const checkpoint = document.getElementById('register-checkpoint').value.trim();
|
|
1168
|
-
const recipe = document.getElementById('register-recipe').value;
|
|
1169
|
-
const reasoning = document.getElementById('register-reasoning').checked;
|
|
1170
|
-
const mmproj = document.getElementById('register-mmproj').value.trim();
|
|
1171
|
-
if (!name || !recipe) { return; }
|
|
1172
|
-
const payload = { model_name: name, recipe, reasoning };
|
|
1173
|
-
if (checkpoint) payload.checkpoint = checkpoint;
|
|
1174
|
-
if (mmproj) payload.mmproj = mmproj;
|
|
1175
|
-
const btn = document.getElementById('register-submit');
|
|
1176
|
-
btn.disabled = true;
|
|
1177
|
-
btn.textContent = 'Installing...';
|
|
1178
|
-
try {
|
|
1179
|
-
await httpRequest(getServerBaseUrl() + '/api/v1/pull', {
|
|
1180
|
-
method: 'POST',
|
|
1181
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1182
|
-
body: JSON.stringify(payload)
|
|
1183
|
-
});
|
|
1184
|
-
registerStatus.textContent = 'Model installed!';
|
|
1185
|
-
registerStatus.style.color = '#27ae60';
|
|
1186
|
-
registerStatus.className = 'register-status success';
|
|
1187
|
-
registerForm.reset();
|
|
1188
|
-
await refreshModelMgmtUI();
|
|
1189
|
-
await loadModels(); // update chat dropdown too
|
|
1190
|
-
} catch (e) {
|
|
1191
|
-
registerStatus.textContent = e.message + ' See the Lemonade Server log for details.';
|
|
1192
|
-
registerStatus.style.color = '#dc3545';
|
|
1193
|
-
registerStatus.className = 'register-status error';
|
|
1194
|
-
showErrorBanner(`Model install failed: ${e.message}`);
|
|
1195
|
-
}
|
|
1196
|
-
btn.disabled = false;
|
|
1197
|
-
btn.textContent = 'Install';
|
|
1198
|
-
refreshModelMgmtUI();
|
|
1199
|
-
};
|
|
1200
|
-
}
|
|
1201
|
-
</script>
|
|
250
|
+
|
|
251
|
+
<!-- Application JavaScript -->
|
|
252
|
+
<script src="/static/js/shared.js"></script>
|
|
253
|
+
<script src="/static/js/models.js"></script>
|
|
254
|
+
<script src="/static/js/model-settings.js"></script>
|
|
255
|
+
<script src="/static/js/chat.js"></script>
|
|
1202
256
|
</body>
|
|
1203
257
|
</html>
|