Pepguy commited on
Commit
f7017e2
Β·
verified Β·
1 Parent(s): 736d0d4

Create index.html

Browse files
Files changed (1) hide show
  1. public/index.html +504 -0
public/index.html ADDED
@@ -0,0 +1,504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <title>AI Developer Hub</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.6/purify.min.js"></script>
10
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
11
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
12
+ <script>
13
+ tailwind.config = {
14
+ darkMode: 'class',
15
+ theme: { extend: { colors: { gray: { 850: '#1f2937', 900: '#111827', 950: '#030712' } } } }
16
+ }
17
+ </script>
18
+ <style>
19
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
20
+ ::-webkit-scrollbar-track { background: transparent; }
21
+ ::-webkit-scrollbar-thumb { background: #374151; border-radius: 4px; }
22
+ ::-webkit-scrollbar-thumb:hover { background: #4b5563; }
23
+
24
+ .markdown-body { font-size: 0.95rem; line-height: 1.6; }
25
+ .markdown-body p { margin-bottom: 1rem; }
26
+ .markdown-body p:last-child { margin-bottom: 0; }
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
+ .expand-btn { position: absolute; top: 0.5rem; right: 4.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; }
35
+ .expand-btn:hover { background: #6b7280; }
36
+
37
+ .markdown-body pre.snippet-collapsed { max-height: 8rem; overflow: hidden !important; }
38
+ .markdown-body pre.snippet-collapsed::after {
39
+ content: "";
40
+ position: absolute;
41
+ bottom: 0; left: 0; right: 0;
42
+ height: 3rem;
43
+ background: linear-gradient(transparent, #1e1e1e);
44
+ pointer-events: none;
45
+ }
46
+
47
+ .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; }
48
+ </style>
49
+ </head>
50
+ <body class="bg-gray-950 text-gray-200 h-[100dvh] flex font-sans overflow-hidden antialiased">
51
+
52
+ <div id="mobile-overlay" class="fixed inset-0 bg-black/60 z-20 hidden lg:hidden transition-opacity" onclick="toggleSidebar()"></div>
53
+
54
+ <aside id="sidebar" class="absolute lg:relative z-30 inset-y-0 left-0 w-72 lg:w-64 bg-gray-900 border-r border-gray-800 transform -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out flex flex-col shadow-2xl lg:shadow-none">
55
+ <div class="p-4 border-b border-gray-800 flex justify-between items-center bg-gray-900">
56
+ <h1 class="font-bold text-lg text-white tracking-wide">Dev AI Hub</h1>
57
+ <div class="flex items-center gap-2">
58
+ <button onclick="createNewChat()" class="p-2 text-blue-400 hover:text-blue-300 bg-gray-800 rounded-md transition" title="New Chat">
59
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
60
+ </button>
61
+ <button onclick="toggleSidebar()" class="p-2 text-gray-400 hover:text-white lg:hidden">
62
+ <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="M6 18L18 6M6 6l12 12"></path></svg>
63
+ </button>
64
+ </div>
65
+ </div>
66
+ <div id="chat-list" class="flex-1 overflow-y-auto p-3 space-y-2"></div>
67
+ </aside>
68
+
69
+ <main class="flex-1 flex flex-col h-full w-full bg-gray-950 min-w-0 relative">
70
+ <header class="h-14 min-h-[3.5rem] border-b border-gray-800 flex items-center px-4 lg:px-6 justify-between bg-gray-900/80 backdrop-blur-sm z-10 shrink-0">
71
+ <div class="flex items-center gap-3 overflow-hidden group w-full max-w-[45%]">
72
+ <button onclick="toggleSidebar()" class="lg:hidden p-1 -ml-1 text-gray-400 hover:text-white shrink-0">
73
+ <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>
74
+ </button>
75
+
76
+ <div id="title-display" class="flex items-center gap-2 overflow-hidden w-full">
77
+ <h2 id="current-chat-title" class="font-semibold text-gray-100 truncate text-sm lg:text-base">Select or create a chat</h2>
78
+ <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">
79
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>
80
+ </button>
81
+ </div>
82
+
83
+ <div id="title-edit" class="hidden items-center gap-2 w-full">
84
+ <input type="text" id="title-input" class="bg-gray-800 text-sm lg:text-base text-gray-100 border border-blue-500 rounded px-2 py-1 outline-none w-full" />
85
+ <button onclick="saveTitle()" class="text-blue-400 hover:text-blue-300 p-1 shrink-0 bg-gray-800 rounded">
86
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
87
+ </button>
88
+ <button onclick="cancelTitleEdit()" class="text-gray-400 hover:text-red-400 p-1 shrink-0 bg-gray-800 rounded">
89
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
90
+ </button>
91
+ </div>
92
+ </div>
93
+
94
+ <div class="flex items-center gap-3 lg:gap-4 shrink-0 pl-2">
95
+ <div class="hidden sm:flex flex-col items-end text-[10px] text-blue-300 bg-blue-900/30 border border-blue-800/50 px-2 py-1 rounded-md font-mono leading-tight">
96
+ <span class="font-semibold text-[11px]">Total: <span id="token-total">0</span></span>
97
+ <span class="text-blue-400/70">In: <span id="token-in">0</span> | Out: <span id="token-out">0</span></span>
98
+ </div>
99
+
100
+ <select id="model-select" class="bg-gray-800 text-xs lg:text-sm rounded-md border border-gray-700 px-2 py-1.5 outline-none focus:border-blue-500 transition-colors cursor-pointer text-gray-200">
101
+ <option value="claude">Claude Sonnet 3.5</option>
102
+ <option value="haiku">Claude Haiku</option>
103
+ <option value="maverick">Llama 3 Maverick</option>
104
+ </select>
105
+ <button onclick="deleteCurrentChat()" class="text-gray-500 hover:text-red-400 transition-colors p-1" title="Delete Chat">
106
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
107
+ </button>
108
+ </div>
109
+ </header>
110
+
111
+ <div id="chat-window" class="flex-1 overflow-y-auto p-4 lg:p-6 space-y-6 scroll-smooth"></div>
112
+
113
+ <button id="scroll-bottom-btn" onclick="scrollToBottom()" class="hidden absolute bottom-28 right-8 p-2 bg-gray-800/80 hover:bg-gray-700 text-gray-300 hover:text-white rounded-full shadow-lg border border-gray-700 backdrop-blur-sm transition-all z-20">
114
+ <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="M19 14l-7 7m0 0l-7-7m7 7V3"></path></svg>
115
+ </button>
116
+
117
+ <div class="p-3 lg:p-4 bg-gray-900 border-t border-gray-800 shrink-0 relative z-30">
118
+ <div class="max-w-4xl mx-auto">
119
+ <div id="image-preview-container" class="flex flex-wrap gap-2 mb-2 hidden px-1"></div>
120
+ <div class="flex items-end gap-2 bg-gray-850 rounded-xl border border-gray-700 p-2 focus-within:border-blue-500 focus-within:ring-1 focus-within:ring-blue-500 transition-all shadow-sm">
121
+ <label class="cursor-pointer p-2 text-gray-400 hover:text-blue-400 transition shrink-0 rounded-lg hover:bg-gray-800">
122
+ <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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
123
+ <input type="file" id="image-input" accept="image/*" multiple class="hidden" onchange="handleImageSelect(event)">
124
+ </label>
125
+ <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>
126
+
127
+ <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">
128
+ <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>
129
+ </button>
130
+ <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">
131
+ <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>
132
+ </button>
133
+ </div>
134
+ <div class="text-center mt-2 text-[10px] text-gray-600">Shift + Enter for new line</div>
135
+ </div>
136
+ </div>
137
+ </main>
138
+
139
+ <script>
140
+ marked.setOptions({
141
+ highlight: function(code, lang) {
142
+ const language = hljs.getLanguage(lang) ? lang : 'plaintext';
143
+ return hljs.highlight(code, { language }).value;
144
+ }
145
+ });
146
+
147
+ // ─── State ────────────────────────────────────────────────────────────────
148
+ let currentChatId = null;
149
+ let attachedImages =[];
150
+ let pollingInterval = null;
151
+ let currentTokens = { total: 0, in: 0, out: 0 };
152
+
153
+ // ─── UI Helpers ───────────────────────────────────────────────────────────
154
+ function toggleInputState(isGenerating) {
155
+ document.getElementById('send-btn').classList.toggle('hidden', isGenerating);
156
+ document.getElementById('stop-btn').classList.toggle('hidden', !isGenerating);
157
+ }
158
+
159
+ async function stopGeneration() {
160
+ if (!currentChatId) return;
161
+ toggleInputState(false);
162
+ try {
163
+ await fetch(`/api/chats/${currentChatId}/stop`, { method: 'POST' });
164
+ } catch (err) {
165
+ console.error("Failed to send stop signal", err);
166
+ }
167
+ }
168
+
169
+ const chatWindow = document.getElementById('chat-window');
170
+ const scrollBtn = document.getElementById('scroll-bottom-btn');
171
+
172
+ chatWindow.addEventListener('scroll', () => {
173
+ const dist = chatWindow.scrollHeight - chatWindow.scrollTop - chatWindow.clientHeight;
174
+ scrollBtn.classList.toggle('hidden', dist <= 100);
175
+ });
176
+
177
+ function scrollToBottom() {
178
+ chatWindow.scrollTo({ top: chatWindow.scrollHeight, behavior: 'smooth' });
179
+ }
180
+
181
+ function updateTokenDisplay(total, input, output) {
182
+ currentTokens = { total, in: input, out: output };
183
+ document.getElementById('token-total').innerText = (total || 0).toLocaleString();
184
+ document.getElementById('token-in').innerText = (input || 0).toLocaleString();
185
+ document.getElementById('token-out').innerText = (output || 0).toLocaleString();
186
+ }
187
+
188
+ // ─── Copy buttons & snippet collapse ──────────────────────────────────────
189
+ function attachCopyButtons(scope) {
190
+ (scope || document).querySelectorAll('.markdown-body pre').forEach(pre => {
191
+ if (pre.querySelector('.copy-btn')) return;
192
+
193
+ const expandBtn = document.createElement('button');
194
+ expandBtn.className = 'expand-btn';
195
+ expandBtn.innerText = 'Expand';
196
+ expandBtn.style.display = 'none';
197
+
198
+ const btn = document.createElement('button');
199
+ btn.className = 'copy-btn';
200
+ btn.innerText = 'Copy';
201
+ btn.onclick = () => {
202
+ const codeBlock = pre.querySelector('code');
203
+ let text = '';
204
+ if (codeBlock) {
205
+ text = codeBlock.textContent;
206
+ } else {
207
+ const clone = pre.cloneNode(true);
208
+ clone.querySelectorAll('.copy-btn, .expand-btn').forEach(b => b.remove());
209
+ text = clone.textContent;
210
+ }
211
+ navigator.clipboard.writeText(text.trim());
212
+ btn.innerText = 'Copied!';
213
+ setTimeout(() => btn.innerText = 'Copy', 2000);
214
+ };
215
+
216
+ pre.appendChild(expandBtn);
217
+ pre.appendChild(btn);
218
+
219
+ if (pre.scrollHeight > 160) {
220
+ pre.classList.add('snippet-collapsed');
221
+ expandBtn.style.display = 'block';
222
+
223
+ expandBtn.onclick = () => {
224
+ if (pre.classList.contains('snippet-collapsed')) {
225
+ pre.classList.remove('snippet-collapsed');
226
+ expandBtn.innerText = 'Collapse';
227
+ } else {
228
+ pre.classList.add('snippet-collapsed');
229
+ expandBtn.innerText = 'Expand';
230
+ }
231
+ };
232
+ }
233
+ });
234
+ }
235
+
236
+ // ─── Title editing ────────────────────────────────────────────────────────
237
+ function enableTitleEdit() {
238
+ if (!currentChatId) return;
239
+ document.getElementById('title-display').classList.add('hidden');
240
+ const edit = document.getElementById('title-edit');
241
+ edit.classList.remove('hidden');
242
+ edit.classList.add('flex');
243
+ const input = document.getElementById('title-input');
244
+ input.value = document.getElementById('current-chat-title').innerText;
245
+ input.focus();
246
+ input.select();
247
+ }
248
+
249
+ function cancelTitleEdit() {
250
+ document.getElementById('title-edit').classList.add('hidden');
251
+ document.getElementById('title-edit').classList.remove('flex');
252
+ document.getElementById('title-display').classList.remove('hidden');
253
+ }
254
+
255
+ async function saveTitle() {
256
+ if (!currentChatId) return cancelTitleEdit();
257
+ const newTitle = document.getElementById('title-input').value.trim();
258
+ if (!newTitle) return cancelTitleEdit();
259
+ document.getElementById('current-chat-title').innerText = newTitle;
260
+ cancelTitleEdit();
261
+ await fetch(`/api/chats/${currentChatId}/title`, {
262
+ method: 'PUT',
263
+ headers: { 'Content-Type': 'application/json' },
264
+ body: JSON.stringify({ title: newTitle })
265
+ });
266
+ loadSidebar();
267
+ }
268
+
269
+ document.getElementById('title-input').addEventListener('keydown', e => {
270
+ if (e.key === 'Enter') saveTitle();
271
+ if (e.key === 'Escape') cancelTitleEdit();
272
+ });
273
+
274
+ function toggleSidebar() {
275
+ document.getElementById('sidebar').classList.toggle('-translate-x-full');
276
+ document.getElementById('mobile-overlay').classList.toggle('hidden');
277
+ }
278
+
279
+ const textarea = document.getElementById('message-input');
280
+ textarea.addEventListener('input', function() {
281
+ this.style.height = 'auto';
282
+ this.style.height = Math.min(this.scrollHeight, 128) + 'px';
283
+ });
284
+
285
+ // ─── Sidebar / Chat list ──────────────────────────────────────────────────
286
+ async function loadSidebar() {
287
+ const res = await fetch('/api/chats');
288
+ const chats = await res.json();
289
+ document.getElementById('chat-list').innerHTML = chats.map(c => `
290
+ <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'}">
291
+ <div class="text-sm font-medium truncate text-gray-200">${c.title}</div>
292
+ <div class="text-[10px] text-gray-500 mt-1 flex justify-between">
293
+ <span>Total: ${c.totalTokens.toLocaleString()}</span>
294
+ <span class="opacity-70">↑${(c.outputTokens || 0).toLocaleString()}</span>
295
+ </div>
296
+ </div>
297
+ `).join('');
298
+ }
299
+
300
+ async function createNewChat() {
301
+ const res = await fetch('/api/chats', { method: 'POST' });
302
+ const chat = await res.json();
303
+ selectChat(chat.id);
304
+ loadSidebar();
305
+ if (window.innerWidth < 1024) toggleSidebar();
306
+ }
307
+
308
+ async function deleteCurrentChat() {
309
+ if (!currentChatId) return;
310
+ if (!confirm("Permanently delete this chat?")) return;
311
+ await fetch(`/api/chats/${currentChatId}`, { method: 'DELETE' });
312
+ currentChatId = null;
313
+ document.getElementById('chat-window').innerHTML = '';
314
+ document.getElementById('current-chat-title').innerText = 'Select or create a chat';
315
+ document.getElementById('edit-title-btn').classList.add('hidden');
316
+ updateTokenDisplay(0, 0, 0);
317
+ toggleInputState(false);
318
+ loadSidebar();
319
+ }
320
+
321
+ // ─── Select / render chat ���────────────────────────────────────────────────
322
+ async function selectChat(id) {
323
+ currentChatId = id;
324
+ stopPolling();
325
+
326
+ const res = await fetch(`/api/chats/${id}`);
327
+ const chat = await res.json();
328
+
329
+ // Strict: Discard everything but the last 3 messages from memory
330
+ chat.messages = chat.messages.slice(-3);
331
+
332
+ document.getElementById('current-chat-title').innerText = chat.title;
333
+ document.getElementById('edit-title-btn').classList.remove('hidden');
334
+ updateTokenDisplay(chat.totalTokens, chat.inputTokens, chat.outputTokens);
335
+ cancelTitleEdit();
336
+ renderMessages(chat.messages);
337
+ loadSidebar();
338
+
339
+ if (window.innerWidth < 1024 && !document.getElementById('sidebar').classList.contains('-translate-x-full')) {
340
+ toggleSidebar();
341
+ }
342
+
343
+ toggleInputState(chat.isGenerating);
344
+ if (chat.isGenerating) pollGeneratingChat(id);
345
+ }
346
+
347
+ function renderMessages(messages) {
348
+ const isAtBottom = chatWindow.scrollHeight - chatWindow.clientHeight <= chatWindow.scrollTop + 20;
349
+
350
+ // Strict: Only render the last 3 messages
351
+ const limitedMessages = messages.slice(-3);
352
+
353
+ chatWindow.innerHTML = limitedMessages.map(m => {
354
+ let html = '';
355
+ if (m.reasoning) {
356
+ html += `<div class="reasoning-block"><i>Thinking Process</i><br/>${marked.parse(m.reasoning)}</div>`;
357
+ }
358
+ if (m.content) {
359
+ html += DOMPurify.sanitize(marked.parse(m.content));
360
+ }
361
+ return `
362
+ <div class="flex ${m.role === 'user' ? 'justify-end' : 'justify-start'} w-full">
363
+ <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'}">
364
+ ${m.role === 'user' ? `<div class="whitespace-pre-wrap">${m.content}</div>` : html}
365
+ </div>
366
+ </div>`;
367
+ }).join('');
368
+
369
+ attachCopyButtons();
370
+ if (isAtBottom) scrollToBottom();
371
+ }
372
+
373
+ // ─── Polling (for resumed/in-progress sessions) ───────────────────────────
374
+ function stopPolling() {
375
+ if (pollingInterval !== null) {
376
+ clearInterval(pollingInterval);
377
+ pollingInterval = null;
378
+ }
379
+ }
380
+
381
+ function pollGeneratingChat(id) {
382
+ stopPolling();
383
+ pollingInterval = setInterval(async () => {
384
+ const res = await fetch(`/api/chats/${id}`);
385
+ const chat = await res.json();
386
+
387
+ // Memory management: Discard all but last 3
388
+ chat.messages = chat.messages.slice(-3);
389
+
390
+ renderMessages(chat.messages);
391
+ updateTokenDisplay(chat.totalTokens, chat.inputTokens, chat.outputTokens);
392
+
393
+ const titleEl = document.getElementById('current-chat-title');
394
+ const titleEditHidden = !document.getElementById('title-display').classList.contains('hidden');
395
+ if (titleEditHidden && chat.title !== titleEl.innerText) {
396
+ titleEl.innerText = chat.title;
397
+ loadSidebar();
398
+ }
399
+
400
+ if (!chat.isGenerating) {
401
+ stopPolling();
402
+ if (currentChatId === id) toggleInputState(false);
403
+ }
404
+ }, 1500);
405
+ }
406
+
407
+ // ─── Image attach ─────────────────────────────────────────────────────────
408
+ function handleImageSelect(event) {
409
+ const container = document.getElementById('image-preview-container');
410
+ container.classList.remove('hidden');
411
+ Array.from(event.target.files).forEach(file => {
412
+ const reader = new FileReader();
413
+ reader.onload = e => {
414
+ attachedImages.push(e.target.result);
415
+ container.innerHTML += `
416
+ <div class="relative group">
417
+ <img src="${e.target.result}" class="h-14 w-14 object-cover rounded-lg border border-gray-600 shadow-sm">
418
+ </div>`;
419
+ };
420
+ reader.readAsDataURL(file);
421
+ });
422
+ }
423
+
424
+ // ─── Send message & stream ────────────────────────────────────────────────
425
+ async function sendMessage() {
426
+ const input = document.getElementById('message-input');
427
+ const text = input.value.trim();
428
+ if (!text || !currentChatId) return;
429
+
430
+ // Prune UI memory before adding new ones: ensure we only have room for 1 existing + 2 new
431
+ const container = document.getElementById('chat-window');
432
+ while (container.children.length > 1) {
433
+ container.removeChild(container.firstChild);
434
+ }
435
+
436
+ const payload = {
437
+ model: document.getElementById('model-select').value,
438
+ prompt: text,
439
+ images: attachedImages
440
+ };
441
+
442
+ // Optimistic user bubble + spinner
443
+ container.innerHTML += `
444
+ <div class="flex justify-end w-full">
445
+ <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>
446
+ </div>
447
+ <div class="flex justify-start w-full" id="temp-ai-wrapper">
448
+ <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">
449
+ <span class="flex items-center gap-2 text-gray-400">
450
+ <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>
451
+ Thinking...
452
+ </span>
453
+ </div>
454
+ </div>`;
455
+ scrollToBottom();
456
+
457
+ // Reset input
458
+ input.value = '';
459
+ input.style.height = 'auto';
460
+ attachedImages =[];
461
+ document.getElementById('image-preview-container').innerHTML = '';
462
+ document.getElementById('image-preview-container').classList.add('hidden');
463
+
464
+ toggleInputState(true);
465
+
466
+ try {
467
+ const response = await fetch(`/api/chats/${currentChatId}/stream`, {
468
+ method: 'POST',
469
+ headers: { 'Content-Type': 'application/json' },
470
+ body: JSON.stringify(payload)
471
+ });
472
+
473
+ if (!response.ok) throw new Error("API stream rejected. Chat might be busy.");
474
+
475
+ const reader = response.body.getReader();
476
+ const decoder = new TextDecoder('utf-8');
477
+
478
+ let aiContent = '';
479
+ let aiReasoning = '';
480
+
481
+ let buffer = '';
482
+ const MARKER_LEN = 9;
483
+
484
+ function flushBuffer(final = false) {
485
+ let progress = true;
486
+ while (progress) {
487
+ progress = false;
488
+
489
+ const thinkIdx = buffer.indexOf('__THINK__');
490
+ const usageIdx = buffer.indexOf('__USAGE__');
491
+
492
+ if (usageIdx !== -1) {
493
+ if (usageIdx > 0) aiContent += buffer.slice(0, usageIdx);
494
+ const jsonStr = buffer.slice(usageIdx + MARKER_LEN);
495
+ try {
496
+ const usageData = JSON.parse(jsonStr);
497
+ updateTokenDisplay(
498
+ currentTokens.total + usageData.totalTokens,
499
+ currentTokens.in + usageData.inputTokens,
500
+ currentTokens.out + usageData.outputTokens
501
+ );
502
+ buffer = '';
503
+ progress = true;
504
+ } catch (_) {