Pepguy commited on
Commit
f11837d
Β·
verified Β·
1 Parent(s): b6b6593

Update public/index.html

Browse files
Files changed (1) hide show
  1. public/index.html +422 -489
public/index.html CHANGED
@@ -27,10 +27,10 @@
27
  .markdown-body code:not(pre code) { background: #374151; padding: 0.2rem 0.4rem; border-radius: 0.25rem; font-size: 0.85em; }
28
  .markdown-body pre { background: #1e1e1e; padding: 2.5rem 1rem 1rem 1rem; border-radius: 0.5rem; position: relative; overflow-x: auto; margin-bottom: 1rem; }
29
  .markdown-body pre code { font-size: 0.85rem; }
30
-
31
  .copy-btn { position: absolute; top: 0.5rem; right: 0.5rem; background: #4b5563; color: #e5e7eb; border: none; padding: 0.25rem 0.75rem; border-radius: 0.375rem; cursor: pointer; font-size: 0.75rem; transition: background 0.2s; }
32
  .copy-btn:hover { background: #6b7280; }
33
-
34
  .reasoning-block { border-left: 3px solid #6366f1; padding-left: 1rem; margin-bottom: 1rem; color: #9ca3af; font-size: 0.9em; background: rgba(99, 102, 241, 0.05); padding: 0.75rem; border-radius: 0 0.5rem 0.5rem 0; }
35
  </style>
36
  </head>
@@ -59,7 +59,7 @@
59
  <button onclick="toggleSidebar()" class="lg:hidden p-1 -ml-1 text-gray-400 hover:text-white shrink-0">
60
  <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
61
  </button>
62
-
63
  <div id="title-display" class="flex items-center gap-2 overflow-hidden w-full">
64
  <h2 id="current-chat-title" class="font-semibold text-gray-100 truncate text-sm lg:text-base">Select or create a chat</h2>
65
  <button onclick="enableTitleEdit()" id="edit-title-btn" class="hidden text-gray-500 hover:text-blue-400 p-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" title="Rename Chat">
@@ -110,531 +110,464 @@
110
  <input type="file" id="image-input" accept="image/*" multiple class="hidden" onchange="handleImageSelect(event)">
111
  </label>
112
  <textarea id="message-input" rows="1" class="flex-1 bg-transparent resize-none outline-none text-[15px] p-2 max-h-32 min-h-[40px] text-gray-100 placeholder-gray-500" placeholder="Message the AI..."></textarea>
113
-
114
  <button onclick="sendMessage()" id="send-btn" class="p-2 text-blue-400 hover:text-blue-300 transition shrink-0 rounded-lg hover:bg-gray-800">
115
  <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path></svg>
116
  </button>
117
  <button onclick="stopGeneration()" id="stop-btn" class="hidden p-2 text-red-500 hover:text-red-400 transition shrink-0 rounded-lg hover:bg-gray-800" title="Stop Generation">
118
  <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"></rect></svg>
119
  </button>
120
-
121
  </div>
122
  <div class="text-center mt-2 text-[10px] text-gray-600">Shift + Enter for new line</div>
123
  </div>
124
  </div>
125
  </main>
126
 
127
- <!-- ================================= -->
128
- <!-- CLIENT SCRIPT (REPLACE SCRIPT TAG) -->
129
- <!-- ================================= -->
130
-
131
- <script>
132
-
133
- marked.setOptions({
134
- highlight(code, lang) {
135
- const language =
136
- hljs.getLanguage(lang)
137
- ? lang
138
- : 'plaintext';
139
-
140
- return hljs.highlight(code, {
141
- language
142
- }).value;
143
- }
144
- });
145
-
146
- let currentChatId = null;
147
- let attachedImages = [];
148
-
149
- const chatWindow =
150
- document.getElementById('chat-window');
151
-
152
- const scrollBtn =
153
- document.getElementById('scroll-bottom-btn');
154
-
155
- // =================================
156
- // SAFE HTML
157
- // =================================
158
-
159
- function escapeHtml(str = "") {
160
- return str
161
- .replaceAll("&", "&amp;")
162
- .replaceAll("<", "&lt;")
163
- .replaceAll(">", "&gt;")
164
- .replaceAll('"', "&quot;")
165
- .replaceAll("'", "&#39;");
166
- }
167
-
168
- // =================================
169
- // TOKEN DISPLAY
170
- // =================================
171
-
172
- function updateTokenDisplay(
173
- total,
174
- input,
175
- output
176
- ) {
177
-
178
- document.getElementById('token-total')
179
- .innerText =
180
- (total || 0).toLocaleString();
181
-
182
- document.getElementById('token-in')
183
- .innerText =
184
- (input || 0).toLocaleString();
185
-
186
- document.getElementById('token-out')
187
- .innerText =
188
- (output || 0).toLocaleString();
189
- }
190
-
191
- // =================================
192
- // RENDER SINGLE MESSAGE
193
- // =================================
194
-
195
- function createMessageElement(
196
- role,
197
- content = "",
198
- reasoning = ""
199
- ) {
200
-
201
- const wrapper =
202
- document.createElement('div');
203
-
204
- wrapper.className =
205
- `flex ${
206
- role === 'user'
207
- ? 'justify-end'
208
- : 'justify-start'
209
- } w-full`;
210
-
211
- const bubble =
212
- document.createElement('div');
213
-
214
- bubble.className =
215
- `max-w-[95%] lg:max-w-[85%]
216
- rounded-2xl p-4 shadow-sm
217
- ${
218
- role === 'user'
219
- ? 'bg-blue-600 text-white rounded-br-sm'
220
- : 'bg-gray-850 border border-gray-800 text-gray-200 markdown-body rounded-bl-sm'
221
- }`;
222
-
223
- if (role === 'user') {
224
-
225
- bubble.innerHTML =
226
- `<div class="whitespace-pre-wrap">${escapeHtml(content)}</div>`;
227
-
228
- } else {
229
-
230
- let html = "";
231
-
232
- if (reasoning) {
233
- html += `
234
- <div class="reasoning-block">
235
- <i>Thinking Process</i><br/>
236
- ${DOMPurify.sanitize(
237
- marked.parse(reasoning)
238
- )}
239
- </div>`;
240
- }
241
-
242
- html += DOMPurify.sanitize(
243
- marked.parse(content)
244
- );
245
-
246
- bubble.innerHTML = html;
247
- }
248
-
249
- wrapper.appendChild(bubble);
250
-
251
- return wrapper;
252
- }
253
-
254
- // =================================
255
- // RENDER CHAT
256
- // =================================
257
-
258
- function renderMessages(messages) {
259
-
260
- chatWindow.innerHTML = '';
261
-
262
- messages.forEach(m => {
263
-
264
- chatWindow.appendChild(
265
- createMessageElement(
266
- m.role,
267
- m.content,
268
- m.reasoning
269
- )
270
- );
271
- });
272
-
273
- addCopyButtons();
274
-
275
- scrollToBottom();
276
- }
277
-
278
- // =================================
279
- // COPY BUTTONS
280
- // =================================
281
-
282
- function addCopyButtons() {
283
-
284
- document
285
- .querySelectorAll('.markdown-body pre')
286
- .forEach(pre => {
287
-
288
- if (
289
- pre.querySelector('.copy-btn')
290
- ) return;
291
-
292
- const btn =
293
- document.createElement('button');
294
-
295
- btn.className = 'copy-btn';
296
-
297
- btn.innerText = 'Copy';
298
-
299
- btn.onclick = () => {
300
-
301
- const codeText =
302
- pre.querySelector('code')
303
- ?.innerText || '';
304
-
305
- navigator.clipboard.writeText(
306
- codeText.trim()
307
- );
308
-
309
- btn.innerText = 'Copied';
310
-
311
- setTimeout(() => {
312
- btn.innerText = 'Copy';
313
- }, 2000);
314
- };
315
-
316
- pre.appendChild(btn);
317
- });
318
- }
319
-
320
- // =================================
321
- // SCROLL
322
- // =================================
323
-
324
- function scrollToBottom() {
325
-
326
- chatWindow.scrollTo({
327
- top: chatWindow.scrollHeight,
328
- behavior: 'smooth'
329
- });
330
- }
331
-
332
- // =================================
333
- // SIDEBAR
334
- // =================================
335
-
336
- async function loadSidebar() {
337
-
338
- const res =
339
- await fetch('/api/chats');
340
-
341
- const chats =
342
- await res.json();
343
-
344
- document.getElementById('chat-list')
345
- .innerHTML = chats.map(c => `
346
-
347
- <div
348
- onclick="selectChat('${c.id}')"
349
- class="p-3 rounded-xl cursor-pointer
350
- ${
351
- c.id === currentChatId
352
- ? 'bg-gray-800'
353
- : 'hover:bg-gray-800/50'
354
- }">
355
-
356
- <div class="text-sm font-medium truncate text-gray-200">
357
- ${escapeHtml(c.title)}
358
- </div>
359
-
360
- </div>
361
-
362
- `).join('');
363
- }
364
-
365
- // =================================
366
- // SELECT CHAT
367
- // =================================
368
-
369
- async function selectChat(id) {
370
-
371
- currentChatId = id;
372
-
373
- const res =
374
- await fetch(`/api/chats/${id}`);
375
-
376
- const chat =
377
- await res.json();
378
-
379
- document.getElementById(
380
- 'current-chat-title'
381
- ).innerText = chat.title;
382
-
383
- renderMessages(chat.messages);
384
-
385
- updateTokenDisplay(
386
- chat.totalTokens,
387
- chat.inputTokens,
388
- chat.outputTokens
389
- );
390
-
391
- loadSidebar();
392
- }
393
-
394
- // =================================
395
- // CREATE CHAT
396
- // =================================
397
-
398
- async function createNewChat() {
399
-
400
- const res =
401
- await fetch('/api/chats', {
402
- method: 'POST'
403
- });
404
-
405
- const chat =
406
- await res.json();
407
-
408
- await selectChat(chat.id);
409
- }
410
-
411
- // =================================
412
- // SEND MESSAGE
413
- // =================================
414
-
415
- async function sendMessage() {
416
-
417
- const input =
418
- document.getElementById(
419
- 'message-input'
420
- );
421
-
422
- const text =
423
- input.value.trim();
424
-
425
- if (!text || !currentChatId)
426
- return;
427
-
428
- // ADD USER MESSAGE
429
- const userElement =
430
- createMessageElement(
431
- 'user',
432
- text
433
- );
434
-
435
- chatWindow.appendChild(userElement);
436
-
437
- // ADD AI PLACEHOLDER
438
- const aiWrapper =
439
- document.createElement('div');
440
-
441
- aiWrapper.className =
442
- 'flex justify-start w-full';
443
-
444
- aiWrapper.innerHTML = `
445
- <div
446
- class="max-w-[95%] lg:max-w-[85%]
447
- rounded-2xl rounded-bl-sm p-4
448
- bg-gray-850 border border-gray-800
449
- text-gray-200 markdown-body"
450
- id="streaming-ai-msg">
451
-
452
- <span class="animate-pulse text-gray-400">
453
- Thinking...
454
- </span>
455
-
456
- </div>
457
- `;
458
-
459
- chatWindow.appendChild(aiWrapper);
460
-
461
- scrollToBottom();
462
-
463
- input.value = '';
464
-
465
- let aiContent = "";
466
- let aiReasoning = "";
467
-
468
- try {
469
-
470
- const response =
471
- await fetch(
472
- `/api/chats/${currentChatId}/stream`,
473
- {
474
- method: 'POST',
475
-
476
- headers: {
477
- 'Content-Type':
478
- 'application/json'
479
- },
480
-
481
- body: JSON.stringify({
482
- model:
483
- document.getElementById(
484
- 'model-select'
485
- ).value,
486
-
487
- prompt: text,
488
-
489
- images: attachedImages
490
- })
491
  }
492
- );
493
-
494
- const reader =
495
- response.body
496
- .pipeThrough(
497
- new TextDecoderStream()
498
- )
499
- .getReader();
500
-
501
- let buffer = "";
502
 
503
- while (true) {
504
-
505
- const {
506
- value,
507
- done
508
- } = await reader.read();
509
-
510
- if (done) break;
511
-
512
- buffer += value;
513
-
514
- const events =
515
- buffer.split("\n\n");
516
-
517
- buffer = events.pop();
518
-
519
- for (const event of events) {
520
-
521
- const lines =
522
- event.split("\n");
523
-
524
- let type = "";
525
- let data = "";
526
-
527
- for (const line of lines) {
528
-
529
- if (
530
- line.startsWith("event:")
531
- ) {
532
- type =
533
- line.replace(
534
- "event:",
535
- ""
536
- ).trim();
537
- }
538
-
539
- if (
540
- line.startsWith("data:")
541
- ) {
542
- data =
543
- line.replace(
544
- "data:",
545
- ""
546
- ).trim();
547
- }
548
  }
549
 
550
- if (!data) continue;
 
551
 
552
- const parsed =
553
- JSON.parse(data);
 
 
554
 
555
- if (type === 'thinking') {
 
 
556
 
557
- aiReasoning += parsed.text;
 
 
 
 
 
558
 
559
- } else if (type === 'text') {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
560
 
561
- aiContent += parsed.text;
 
 
 
 
 
 
 
 
 
 
 
562
 
563
- } else if (type === 'usage') {
 
 
 
 
564
 
565
- updateTokenDisplay(
566
- parsed.totalTokens,
567
- parsed.inputTokens,
568
- parsed.outputTokens
569
- );
 
 
 
 
 
 
 
570
  }
571
 
572
- let html = "";
 
 
 
573
 
574
- if (aiReasoning) {
575
- html += `
576
- <div class="reasoning-block">
577
- <i>Thinking Process</i><br/>
578
- ${DOMPurify.sanitize(
579
- marked.parse(aiReasoning)
580
- )}
581
- </div>
582
- `;
583
  }
584
 
585
- html += DOMPurify.sanitize(
586
- marked.parse(aiContent)
587
- );
588
-
589
- document.getElementById(
590
- 'streaming-ai-msg'
591
- ).innerHTML = html;
 
 
 
 
 
 
 
 
 
 
 
 
 
592
 
593
- addCopyButtons();
 
 
 
 
 
 
594
 
595
- scrollToBottom();
596
- }
597
- }
 
 
 
 
 
 
 
 
 
598
 
599
- } catch (err) {
 
 
 
600
 
601
- console.error(err);
 
602
 
603
- document.getElementById(
604
- 'streaming-ai-msg'
605
- ).innerHTML = `
606
- <span class="text-red-400">
607
- Connection lost.
608
- </span>
609
- `;
610
- }
611
- }
612
 
613
- // =================================
614
- // ENTER SEND
615
- // =================================
616
 
617
- document.getElementById(
618
- 'message-input'
619
- ).addEventListener('keydown', e => {
620
 
621
- if (
622
- e.key === 'Enter' &&
623
- !e.shiftKey
624
- ) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
625
 
626
- e.preventDefault();
 
 
 
 
 
 
627
 
628
- sendMessage();
629
- }
630
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631
 
632
- // =================================
633
- // INIT
634
- // =================================
 
 
 
 
 
 
 
 
 
 
 
 
 
635
 
636
- loadSidebar();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
637
 
638
- </script>
 
 
 
 
 
 
 
 
 
 
 
 
639
  </body>
640
- </html>
 
27
  .markdown-body code:not(pre code) { background: #374151; padding: 0.2rem 0.4rem; border-radius: 0.25rem; font-size: 0.85em; }
28
  .markdown-body pre { background: #1e1e1e; padding: 2.5rem 1rem 1rem 1rem; border-radius: 0.5rem; position: relative; overflow-x: auto; margin-bottom: 1rem; }
29
  .markdown-body pre code { font-size: 0.85rem; }
30
+
31
  .copy-btn { position: absolute; top: 0.5rem; right: 0.5rem; background: #4b5563; color: #e5e7eb; border: none; padding: 0.25rem 0.75rem; border-radius: 0.375rem; cursor: pointer; font-size: 0.75rem; transition: background 0.2s; }
32
  .copy-btn:hover { background: #6b7280; }
33
+
34
  .reasoning-block { border-left: 3px solid #6366f1; padding-left: 1rem; margin-bottom: 1rem; color: #9ca3af; font-size: 0.9em; background: rgba(99, 102, 241, 0.05); padding: 0.75rem; border-radius: 0 0.5rem 0.5rem 0; }
35
  </style>
36
  </head>
 
59
  <button onclick="toggleSidebar()" class="lg:hidden p-1 -ml-1 text-gray-400 hover:text-white shrink-0">
60
  <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
61
  </button>
62
+
63
  <div id="title-display" class="flex items-center gap-2 overflow-hidden w-full">
64
  <h2 id="current-chat-title" class="font-semibold text-gray-100 truncate text-sm lg:text-base">Select or create a chat</h2>
65
  <button onclick="enableTitleEdit()" id="edit-title-btn" class="hidden text-gray-500 hover:text-blue-400 p-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" title="Rename Chat">
 
110
  <input type="file" id="image-input" accept="image/*" multiple class="hidden" onchange="handleImageSelect(event)">
111
  </label>
112
  <textarea id="message-input" rows="1" class="flex-1 bg-transparent resize-none outline-none text-[15px] p-2 max-h-32 min-h-[40px] text-gray-100 placeholder-gray-500" placeholder="Message the AI..."></textarea>
113
+
114
  <button onclick="sendMessage()" id="send-btn" class="p-2 text-blue-400 hover:text-blue-300 transition shrink-0 rounded-lg hover:bg-gray-800">
115
  <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path></svg>
116
  </button>
117
  <button onclick="stopGeneration()" id="stop-btn" class="hidden p-2 text-red-500 hover:text-red-400 transition shrink-0 rounded-lg hover:bg-gray-800" title="Stop Generation">
118
  <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"></rect></svg>
119
  </button>
 
120
  </div>
121
  <div class="text-center mt-2 text-[10px] text-gray-600">Shift + Enter for new line</div>
122
  </div>
123
  </div>
124
  </main>
125
 
126
+ <script>
127
+ marked.setOptions({
128
+ highlight: function(code, lang) {
129
+ const language = hljs.getLanguage(lang) ? lang : 'plaintext';
130
+ return hljs.highlight(code, { language }).value;
131
+ }
132
+ });
133
+
134
+ // ─── State ────────────────────────────────────────────────────────────────
135
+ let currentChatId = null;
136
+ let attachedImages = [];
137
+ let pollingInterval = null; // FIX: always explicitly null when cleared
138
+ let currentTokens = { total: 0, in: 0, out: 0 };
139
+
140
+ // ─── UI Helpers ───────────────────────────────────────────────────────────
141
+ function toggleInputState(isGenerating) {
142
+ document.getElementById('send-btn').classList.toggle('hidden', isGenerating);
143
+ document.getElementById('stop-btn').classList.toggle('hidden', !isGenerating);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  }
 
 
 
 
 
 
 
 
 
 
145
 
146
+ async function stopGeneration() {
147
+ if (!currentChatId) return;
148
+ toggleInputState(false);
149
+ try {
150
+ await fetch(`/api/chats/${currentChatId}/stop`, { method: 'POST' });
151
+ } catch (err) {
152
+ console.error("Failed to send stop signal", err);
153
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  }
155
 
156
+ const chatWindow = document.getElementById('chat-window');
157
+ const scrollBtn = document.getElementById('scroll-bottom-btn');
158
 
159
+ chatWindow.addEventListener('scroll', () => {
160
+ const dist = chatWindow.scrollHeight - chatWindow.scrollTop - chatWindow.clientHeight;
161
+ scrollBtn.classList.toggle('hidden', dist <= 100);
162
+ });
163
 
164
+ function scrollToBottom() {
165
+ chatWindow.scrollTo({ top: chatWindow.scrollHeight, behavior: 'smooth' });
166
+ }
167
 
168
+ function updateTokenDisplay(total, input, output) {
169
+ currentTokens = { total, in: input, out: output };
170
+ document.getElementById('token-total').innerText = (total || 0).toLocaleString();
171
+ document.getElementById('token-in').innerText = (input || 0).toLocaleString();
172
+ document.getElementById('token-out').innerText = (output || 0).toLocaleString();
173
+ }
174
 
175
+ // ─── Copy buttons ─────────────────────────────────────────────────────────
176
+ function attachCopyButtons(scope) {
177
+ (scope || document).querySelectorAll('.markdown-body pre').forEach(pre => {
178
+ if (pre.querySelector('.copy-btn')) return;
179
+ const btn = document.createElement('button');
180
+ btn.className = 'copy-btn';
181
+ btn.innerText = 'Copy';
182
+ btn.onclick = () => {
183
+ const text = pre.querySelector('code')?.innerText || pre.innerText.replace('Copy', '');
184
+ navigator.clipboard.writeText(text.trim());
185
+ btn.innerText = 'Copied!';
186
+ setTimeout(() => btn.innerText = 'Copy', 2000);
187
+ };
188
+ pre.appendChild(btn);
189
+ });
190
+ }
191
 
192
+ // ─── Title editing ────────────────────────────────────────────────────────
193
+ function enableTitleEdit() {
194
+ if (!currentChatId) return;
195
+ document.getElementById('title-display').classList.add('hidden');
196
+ const edit = document.getElementById('title-edit');
197
+ edit.classList.remove('hidden');
198
+ edit.classList.add('flex');
199
+ const input = document.getElementById('title-input');
200
+ input.value = document.getElementById('current-chat-title').innerText;
201
+ input.focus();
202
+ input.select();
203
+ }
204
 
205
+ function cancelTitleEdit() {
206
+ document.getElementById('title-edit').classList.add('hidden');
207
+ document.getElementById('title-edit').classList.remove('flex');
208
+ document.getElementById('title-display').classList.remove('hidden');
209
+ }
210
 
211
+ async function saveTitle() {
212
+ if (!currentChatId) return cancelTitleEdit();
213
+ const newTitle = document.getElementById('title-input').value.trim();
214
+ if (!newTitle) return cancelTitleEdit();
215
+ document.getElementById('current-chat-title').innerText = newTitle;
216
+ cancelTitleEdit();
217
+ await fetch(`/api/chats/${currentChatId}/title`, {
218
+ method: 'PUT',
219
+ headers: { 'Content-Type': 'application/json' },
220
+ body: JSON.stringify({ title: newTitle })
221
+ });
222
+ loadSidebar();
223
  }
224
 
225
+ document.getElementById('title-input').addEventListener('keydown', e => {
226
+ if (e.key === 'Enter') saveTitle();
227
+ if (e.key === 'Escape') cancelTitleEdit();
228
+ });
229
 
230
+ function toggleSidebar() {
231
+ document.getElementById('sidebar').classList.toggle('-translate-x-full');
232
+ document.getElementById('mobile-overlay').classList.toggle('hidden');
 
 
 
 
 
 
233
  }
234
 
235
+ const textarea = document.getElementById('message-input');
236
+ textarea.addEventListener('input', function() {
237
+ this.style.height = 'auto';
238
+ this.style.height = Math.min(this.scrollHeight, 128) + 'px';
239
+ });
240
+
241
+ // ─── Sidebar / Chat list ──────────────────────────────────────────────────
242
+ async function loadSidebar() {
243
+ const res = await fetch('/api/chats');
244
+ const chats = await res.json();
245
+ document.getElementById('chat-list').innerHTML = chats.map(c => `
246
+ <div onclick="selectChat('${c.id}')" class="p-3 rounded-xl cursor-pointer transition border border-transparent ${c.id === currentChatId ? 'bg-gray-800 border-gray-700' : 'hover:bg-gray-800/50'}">
247
+ <div class="text-sm font-medium truncate text-gray-200">${c.title}</div>
248
+ <div class="text-[10px] text-gray-500 mt-1 flex justify-between">
249
+ <span>Total: ${c.totalTokens.toLocaleString()}</span>
250
+ <span class="opacity-70">↑${(c.outputTokens || 0).toLocaleString()}</span>
251
+ </div>
252
+ </div>
253
+ `).join('');
254
+ }
255
 
256
+ async function createNewChat() {
257
+ const res = await fetch('/api/chats', { method: 'POST' });
258
+ const chat = await res.json();
259
+ selectChat(chat.id);
260
+ loadSidebar();
261
+ if (window.innerWidth < 1024) toggleSidebar();
262
+ }
263
 
264
+ async function deleteCurrentChat() {
265
+ if (!currentChatId) return;
266
+ if (!confirm("Permanently delete this chat?")) return;
267
+ await fetch(`/api/chats/${currentChatId}`, { method: 'DELETE' });
268
+ currentChatId = null;
269
+ document.getElementById('chat-window').innerHTML = '';
270
+ document.getElementById('current-chat-title').innerText = 'Select or create a chat';
271
+ document.getElementById('edit-title-btn').classList.add('hidden');
272
+ updateTokenDisplay(0, 0, 0);
273
+ toggleInputState(false);
274
+ loadSidebar();
275
+ }
276
 
277
+ // ─── Select / render chat ─────────────────────────────────────────────────
278
+ async function selectChat(id) {
279
+ currentChatId = id;
280
+ stopPolling();
281
 
282
+ const res = await fetch(`/api/chats/${id}`);
283
+ const chat = await res.json();
284
 
285
+ document.getElementById('current-chat-title').innerText = chat.title;
286
+ document.getElementById('edit-title-btn').classList.remove('hidden');
287
+ updateTokenDisplay(chat.totalTokens, chat.inputTokens, chat.outputTokens);
288
+ cancelTitleEdit();
289
+ renderMessages(chat.messages);
290
+ loadSidebar();
 
 
 
291
 
292
+ if (window.innerWidth < 1024 && !document.getElementById('sidebar').classList.contains('-translate-x-full')) {
293
+ toggleSidebar();
294
+ }
295
 
296
+ toggleInputState(chat.isGenerating);
297
+ if (chat.isGenerating) pollGeneratingChat(id);
298
+ }
299
 
300
+ function renderMessages(messages) {
301
+ const isAtBottom = chatWindow.scrollHeight - chatWindow.clientHeight <= chatWindow.scrollTop + 20;
302
+
303
+ chatWindow.innerHTML = messages.map(m => {
304
+ let html = '';
305
+ if (m.reasoning) {
306
+ html += `<div class="reasoning-block"><i>Thinking Process</i><br/>${marked.parse(m.reasoning)}</div>`;
307
+ }
308
+ if (m.content) {
309
+ html += DOMPurify.sanitize(marked.parse(m.content));
310
+ }
311
+ return `
312
+ <div class="flex ${m.role === 'user' ? 'justify-end' : 'justify-start'} w-full">
313
+ <div class="max-w-[95%] lg:max-w-[85%] rounded-2xl p-4 shadow-sm ${m.role === 'user' ? 'bg-blue-600 text-white rounded-br-sm' : 'bg-gray-850 border border-gray-800 text-gray-200 markdown-body rounded-bl-sm'}">
314
+ ${m.role === 'user' ? `<div class="whitespace-pre-wrap">${m.content}</div>` : html}
315
+ </div>
316
+ </div>`;
317
+ }).join('');
318
+
319
+ attachCopyButtons();
320
+ if (isAtBottom) scrollToBottom();
321
+ }
322
 
323
+ // ─── Polling (for resumed/in-progress sessions) ───────────────────────────
324
+ function stopPolling() {
325
+ if (pollingInterval !== null) {
326
+ clearInterval(pollingInterval);
327
+ pollingInterval = null; // FIX: always null after clear
328
+ }
329
+ }
330
 
331
+ function pollGeneratingChat(id) {
332
+ stopPolling();
333
+ pollingInterval = setInterval(async () => {
334
+ const res = await fetch(`/api/chats/${id}`);
335
+ const chat = await res.json();
336
+
337
+ renderMessages(chat.messages);
338
+ updateTokenDisplay(chat.totalTokens, chat.inputTokens, chat.outputTokens);
339
+
340
+ const titleEl = document.getElementById('current-chat-title');
341
+ const titleEditHidden = !document.getElementById('title-display').classList.contains('hidden');
342
+ if (titleEditHidden && chat.title !== titleEl.innerText) {
343
+ titleEl.innerText = chat.title;
344
+ loadSidebar();
345
+ }
346
+
347
+ if (!chat.isGenerating) {
348
+ stopPolling(); // FIX: use helper so pollingInterval becomes null
349
+ if (currentChatId === id) toggleInputState(false);
350
+ }
351
+ }, 1500);
352
+ }
353
 
354
+ // ─── Image attach ─────────────────────────────────────────────────────────
355
+ function handleImageSelect(event) {
356
+ const container = document.getElementById('image-preview-container');
357
+ container.classList.remove('hidden');
358
+ Array.from(event.target.files).forEach(file => {
359
+ const reader = new FileReader();
360
+ reader.onload = e => {
361
+ attachedImages.push(e.target.result);
362
+ container.innerHTML += `
363
+ <div class="relative group">
364
+ <img src="${e.target.result}" class="h-14 w-14 object-cover rounded-lg border border-gray-600 shadow-sm">
365
+ </div>`;
366
+ };
367
+ reader.readAsDataURL(file);
368
+ });
369
+ }
370
 
371
+ // ─── Send message & stream ────────────────────────────────────────────────
372
+ async function sendMessage() {
373
+ const input = document.getElementById('message-input');
374
+ const text = input.value.trim();
375
+ if (!text || !currentChatId) return;
376
+
377
+ const payload = {
378
+ model: document.getElementById('model-select').value,
379
+ prompt: text,
380
+ images: attachedImages
381
+ };
382
+
383
+ // Optimistic user bubble + spinner
384
+ const container = document.getElementById('chat-window');
385
+ container.innerHTML += `
386
+ <div class="flex justify-end w-full">
387
+ <div class="max-w-[95%] lg:max-w-[85%] rounded-2xl rounded-br-sm p-4 bg-blue-600 text-white shadow-sm whitespace-pre-wrap">${text}</div>
388
+ </div>
389
+ <div class="flex justify-start w-full" id="temp-ai-wrapper">
390
+ <div class="max-w-[95%] lg:max-w-[85%] rounded-2xl rounded-bl-sm p-4 bg-gray-850 border border-gray-800 text-gray-200 markdown-body shadow-sm" id="temp-ai-msg">
391
+ <span class="flex items-center gap-2 text-gray-400">
392
+ <svg class="animate-spin h-4 w-4" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
393
+ Thinking...
394
+ </span>
395
+ </div>
396
+ </div>`;
397
+ scrollToBottom();
398
+
399
+ // Reset input
400
+ input.value = '';
401
+ input.style.height = 'auto';
402
+ attachedImages = [];
403
+ document.getElementById('image-preview-container').innerHTML = '';
404
+ document.getElementById('image-preview-container').classList.add('hidden');
405
+
406
+ toggleInputState(true);
407
+
408
+ try {
409
+ const response = await fetch(`/api/chats/${currentChatId}/stream`, {
410
+ method: 'POST',
411
+ headers: { 'Content-Type': 'application/json' },
412
+ body: JSON.stringify(payload)
413
+ });
414
+
415
+ if (!response.ok) throw new Error("API stream rejected. Chat might be busy.");
416
+
417
+ const reader = response.body.getReader();
418
+ const decoder = new TextDecoder('utf-8');
419
+
420
+ let aiContent = '';
421
+ let aiReasoning = '';
422
+
423
+ // ── FIX: persistent buffer so markers are never split across chunks ──
424
+ let buffer = '';
425
+ const MARKER_LEN = 9; // '__THINK__' and '__USAGE__' are both 9 chars
426
+
427
+ function flushBuffer(final = false) {
428
+ // Keep processing until we can't make progress
429
+ let progress = true;
430
+ while (progress) {
431
+ progress = false;
432
+
433
+ const thinkIdx = buffer.indexOf('__THINK__');
434
+ const usageIdx = buffer.indexOf('__USAGE__');
435
+
436
+ if (usageIdx !== -1) {
437
+ // Everything before __USAGE__ is plain content
438
+ if (usageIdx > 0) aiContent += buffer.slice(0, usageIdx);
439
+ const jsonStr = buffer.slice(usageIdx + MARKER_LEN);
440
+ try {
441
+ const usageData = JSON.parse(jsonStr);
442
+ updateTokenDisplay(
443
+ currentTokens.total + usageData.totalTokens,
444
+ currentTokens.in + usageData.inputTokens,
445
+ currentTokens.out + usageData.outputTokens
446
+ );
447
+ buffer = '';
448
+ progress = true;
449
+ } catch (_) {
450
+ // Incomplete JSON β€” keep buffer from __USAGE__ onwards and wait
451
+ buffer = buffer.slice(usageIdx);
452
+ }
453
+
454
+ } else if (thinkIdx !== -1) {
455
+ // Flush content before the marker
456
+ if (thinkIdx > 0) {
457
+ aiContent += buffer.slice(0, thinkIdx);
458
+ buffer = buffer.slice(thinkIdx);
459
+ }
460
+ // buffer now starts with __THINK__; find where reasoning ends
461
+ const afterMarker = buffer.slice(MARKER_LEN);
462
+ const nextThink = afterMarker.indexOf('__THINK__');
463
+ const nextUsage = afterMarker.indexOf('__USAGE__');
464
+ const candidates = [nextThink, nextUsage].filter(x => x !== -1);
465
+ const nextMarker = candidates.length ? Math.min(...candidates) : -1;
466
+
467
+ if (nextMarker !== -1) {
468
+ // We have a full reasoning segment
469
+ aiReasoning += afterMarker.slice(0, nextMarker);
470
+ buffer = afterMarker.slice(nextMarker);
471
+ progress = true;
472
+ } else {
473
+ // Reasoning continues β€” consume safely up to last MARKER_LEN chars
474
+ const safeLen = afterMarker.length - MARKER_LEN;
475
+ if (safeLen > 0) {
476
+ aiReasoning += afterMarker.slice(0, safeLen);
477
+ buffer = '__THINK__' + afterMarker.slice(safeLen);
478
+ }
479
+ // else not enough data yet β€” wait for next chunk
480
+ }
481
+
482
+ } else {
483
+ // No markers visible β€” safely flush all but the last MARKER_LEN chars
484
+ // (a marker could be arriving split across the boundary)
485
+ if (final) {
486
+ aiContent += buffer;
487
+ buffer = '';
488
+ progress = true;
489
+ } else {
490
+ const safeLen = buffer.length - MARKER_LEN;
491
+ if (safeLen > 0) {
492
+ aiContent += buffer.slice(0, safeLen);
493
+ buffer = buffer.slice(safeLen);
494
+ progress = true;
495
+ }
496
+ // else buffer too small to safely consume β€” wait
497
+ }
498
+ }
499
+ }
500
+ }
501
+
502
+ while (true) {
503
+ const { value, done } = await reader.read();
504
+
505
+ if (done) {
506
+ flushBuffer(true); // flush remainder on stream close
507
+ break;
508
+ }
509
+
510
+ buffer += decoder.decode(value, { stream: true });
511
+ flushBuffer(false);
512
+
513
+ // Live render
514
+ let tempHtml = '';
515
+ if (aiReasoning) tempHtml += `<div class="reasoning-block"><i>Thinking Process</i><br/>${marked.parse(aiReasoning)}</div>`;
516
+ if (aiContent) tempHtml += DOMPurify.sanitize(marked.parse(aiContent));
517
+
518
+ const tempMsg = document.getElementById('temp-ai-msg');
519
+ if (tempMsg) {
520
+ tempMsg.innerHTML = tempHtml || "<span class='animate-pulse text-gray-400'>Thinking...</span>";
521
+ }
522
+
523
+ if (container.scrollHeight - container.scrollTop - container.clientHeight < 100) {
524
+ scrollToBottom();
525
+ }
526
+ }
527
+
528
+ // ── FIX: replace temp bubble in-place β€” no selectChat(), no DOM wipe ──
529
+ const tempWrapper = document.getElementById('temp-ai-wrapper');
530
+ if (tempWrapper) {
531
+ let finalHtml = '';
532
+ if (aiReasoning) finalHtml += `<div class="reasoning-block"><i>Thinking Process</i><br/>${marked.parse(aiReasoning)}</div>`;
533
+ if (aiContent) finalHtml += DOMPurify.sanitize(marked.parse(aiContent));
534
+
535
+ tempWrapper.outerHTML = `
536
+ <div class="flex justify-start w-full">
537
+ <div class="max-w-[95%] lg:max-w-[85%] rounded-2xl rounded-bl-sm p-4 bg-gray-850 border border-gray-800 text-gray-200 markdown-body shadow-sm">
538
+ ${finalHtml || "<span class='text-gray-500'>No response generated.</span>"}
539
+ </div>
540
+ </div>`;
541
+ attachCopyButtons();
542
+ }
543
+
544
+ // Refresh sidebar token counts only β€” no full re-render
545
+ loadSidebar();
546
+
547
+ } catch (error) {
548
+ console.error(error);
549
+ const tempMsg = document.getElementById('temp-ai-msg');
550
+ if (tempMsg) {
551
+ tempMsg.innerHTML = "<span class='text-red-400'>Connection lost or chat busy. Reload to sync.</span>";
552
+ }
553
+ } finally {
554
+ // FIX: pollingInterval is null when not polling, so this check is reliable
555
+ if (pollingInterval === null) toggleInputState(false);
556
+ }
557
+ }
558
 
559
+ // ─── Keyboard shortcut ────────────────────────────────────────────────────
560
+ document.getElementById('message-input').addEventListener('keydown', e => {
561
+ if (e.key === 'Enter' && !e.shiftKey) {
562
+ e.preventDefault();
563
+ if (!document.getElementById('send-btn').classList.contains('hidden')) {
564
+ sendMessage();
565
+ }
566
+ }
567
+ });
568
+
569
+ // ─── Boot ─────────────���───────────────────────────────────────────────────
570
+ loadSidebar();
571
+ </script>
572
  </body>
573
+ </html>