xenux4u commited on
Commit
20a2068
·
verified ·
1 Parent(s): f863bcd

Update static/js/script.js

Browse files
Files changed (1) hide show
  1. static/js/script.js +337 -401
static/js/script.js CHANGED
@@ -1,435 +1,371 @@
1
- document.addEventListener('DOMContentLoaded', () => {
2
- // Helper function anti-error
3
- const $ = id => document.getElementById(id);
4
- const addEvt = (id, event, handler) => {
5
- const el = $(id);
6
- if (el) el.addEventListener(event, handler);
7
- };
8
-
9
- let currentUser = null;
10
- let appSettings = null;
11
- let currentSid = null;
12
- let pdfText = "";
13
- let pdfFilename = "";
14
- let isBusy = false;
15
- let sessions = {};
16
-
17
- // 1. Data Recovery (Hapus memori nyangkut)
18
- try {
19
- const stored = localStorage.getItem('orbit_sessions_v4');
20
- sessions = stored ? JSON.parse(stored) : {};
21
- if (Array.isArray(sessions)) sessions = {};
22
- } catch(e) {
23
- sessions = {};
24
- }
25
-
26
- // 2. Init App & Load User Data
27
- async function initApp() {
28
- try {
29
- const userRes = await fetch('/api/me');
30
- if (userRes.status === 401) { window.location.href = '/login'; return; }
31
- if (!userRes.ok) throw new Error('Not logged in');
32
-
33
- currentUser = await userRes.json();
34
- if($('user-name')) $('user-name').textContent = currentUser.name || currentUser.email;
35
- if($('user-avatar') && currentUser.picture) $('user-avatar').src = currentUser.picture;
36
-
37
- const setRes = await fetch('/api/settings');
38
- if (setRes.ok) {
39
- appSettings = await setRes.json();
40
- populateModelSelect();
41
- }
42
- } catch (error) {
43
- console.error("Init Error:", error);
44
- } finally {
45
- const ids = Object.keys(sessions).sort((a,b) => Number(b) - Number(a));
46
- if (ids.length > 0) loadSession(ids[0]);
47
- else newSession();
48
  }
 
49
  }
 
50
 
51
- // 3. Multi-Session Logic
52
- function saveSessions() {
53
- try { localStorage.setItem('orbit_sessions_v4', JSON.stringify(sessions)); } catch(e) {}
54
- }
 
 
55
 
56
- function newSession() {
57
- const id = Date.now().toString();
58
- sessions[id] = { title: "New Chat", messages: [] };
59
- loadSession(id);
60
- }
61
- addEvt("btn-new-chat", "click", newSession);
62
-
63
- function loadSession(id) {
64
- if (!sessions[id]) return;
65
- currentSid = id;
66
- if (!sessions[id].messages) sessions[id].messages = [];
67
-
68
- const cm = $('chat-messages');
69
- const ws = $('welcome-screen');
70
- if(cm) cm.innerHTML = '';
71
-
72
- if (sessions[id].messages.length > 0) {
73
- if(ws) ws.style.display = 'none';
74
- sessions[id].messages.forEach(m => renderBubble(m.role, m.displayContent || m.content, false));
75
- } else {
76
- if(ws) ws.style.display = 'flex';
77
- }
78
 
79
- if($('chat-title')) $('chat-title').textContent = sessions[id].title;
80
- renderSidebar();
81
- scrollBottom();
82
- }
83
-
84
- function renderSidebar() {
85
- const list = $('history-list');
86
- if(!list) return;
 
87
  const ids = Object.keys(sessions).sort((a, b) => Number(b) - Number(a));
88
- if(ids.length === 0) {
89
- list.innerHTML = '<p class="text-xs text-gray-400 px-3 py-2 italic">No recent chats.</p>';
90
- return;
91
- }
92
- let html = "";
93
- ids.forEach(id => {
94
- const isActive = (id === currentSid);
95
- const cls = isActive ? 'bg-accent-light text-accent font-semibold' : 'text-gray-600 hover:bg-white hover:shadow-sm font-medium';
96
- html += `<button class="session-btn w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm transition-all text-left truncate ${cls}" data-id="${id}">
97
- <svg class="w-4 h-4 shrink-0 opacity-60" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"></path></svg>
98
- <span class="truncate">${sessions[id].title}</span>
99
- </button>`;
100
- });
101
- list.innerHTML = html;
102
- document.querySelectorAll('.session-btn').forEach(btn => {
103
- btn.addEventListener('click', () => {
104
- loadSession(btn.getAttribute('data-id'));
105
- if(window.innerWidth < 768) toggleSidebar();
106
- });
107
- });
108
  }
109
-
110
- function appendMessage(role, content, displayContent) {
111
- if(!sessions[currentSid]) newSession();
112
- if(!sessions[currentSid].messages) sessions[currentSid].messages = [];
113
-
114
- sessions[currentSid].messages.push({ role, content, displayContent });
115
-
116
- const userMsgs = sessions[currentSid].messages.filter(m => m.role === "user");
117
- if (role === "user" && userMsgs.length === 1) {
118
- let snippet = String(displayContent).replace(/<[^>]*>?/gm, '');
119
- snippet = snippet.split(" ").slice(0, 5).join(" ") + "…";
120
- sessions[currentSid].title = snippet;
121
- if($('chat-title')) $('chat-title').textContent = snippet;
122
- renderSidebar();
123
- }
124
- saveSessions();
 
 
 
 
 
 
 
 
 
 
 
125
  }
126
-
127
- // 4. Chat UI
128
- function scrollBottom() {
129
- const cm = $('chat-messages');
130
- if(cm) cm.scrollTop = cm.scrollHeight;
 
 
 
 
 
131
  }
132
-
133
- function renderBubble(role, content, animate = true) {
134
- const isUser = (role === "user");
135
- const wrap = document.createElement("div");
136
- wrap.className = `flex mb-6 ${isUser ? "justify-end" : "justify-start"} ${animate ? "bubble-"+role : ""}`;
137
-
138
- if (isUser) {
139
- wrap.innerHTML = `<div class="bg-accent text-white p-4 rounded-2xl rounded-tr-none max-w-[85%] shadow-sm text-[15px] leading-relaxed">${content}</div>`;
140
- } else {
141
- let parsed = content;
142
- try { parsed = (typeof marked !== 'undefined') ? marked.parse(content) : content.replace(/\n/g,'<br>'); } catch(e) { parsed = content; }
143
- wrap.innerHTML = `<div class="flex gap-4 items-start w-full"><img src="/static/icon.png" class="w-8 h-8 rounded-full border border-slate-200 mt-1 shadow-sm shrink-0" onerror="this.style.display='none'"><div class="bg-[#f8f9fa] border border-slate-200 p-5 rounded-2xl rounded-tl-none max-w-[90%] md:max-w-[85%] prose-orbit shadow-sm w-full">${parsed}</div></div>`;
144
- }
145
-
146
- if($('chat-messages')) $('chat-messages').appendChild(wrap);
147
- if(animate) scrollBottom();
 
 
 
 
 
 
148
  }
149
-
150
- // 5. Send API Logics
151
- function setBusy(busy) {
152
- isBusy = busy;
153
- if($('btn-send')) $('btn-send').disabled = busy;
154
- if($('chat-textarea')) $('chat-textarea').disabled = busy;
155
- if($('send-icon')) $('send-icon').classList.toggle("hidden", busy);
156
- if($('loading-icon')) $('loading-icon').classList.toggle("hidden", !busy);
 
 
 
 
 
 
 
 
 
 
 
157
  }
158
 
159
- async function sendChat() {
160
- if (isBusy) return;
161
- const raw = $('chat-textarea') ? $('chat-textarea').value.trim() : "";
162
- if (!raw && !pdfText) return;
163
-
164
- let fullPrompt = raw;
165
- let safeRaw = raw.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
166
- let displayPrompt = safeRaw.replace(/\n/g, '<br>');
167
-
168
- if (pdfText) {
169
- fullPrompt = `[Attached Document: ${pdfFilename}]\n${pdfText}\n\nUser Question: ${raw}`;
170
- displayPrompt = `<div class="bg-blue-500 text-white text-xs px-2.5 py-1.5 rounded w-fit mb-2 font-bold inline-block">📄 ${pdfFilename}</div><br>${safeRaw.replace(/\n/g, '<br>')}`;
171
- clearAttachment();
172
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
173
 
174
- if($('chat-textarea')) { $('chat-textarea').value = ""; $('chat-textarea').style.height = "auto"; }
175
- if($("welcome-screen")) $("welcome-screen").style.display = 'none';
176
-
177
- setBusy(true);
178
- renderBubble("user", displayPrompt);
179
- appendMessage("user", fullPrompt, displayPrompt);
180
-
181
- const loadId = 'load-' + Date.now();
182
- if($('chat-messages')) {
183
- $('chat-messages').insertAdjacentHTML('beforeend', `<div id="${loadId}" class="flex mb-6 gap-4 items-start"><img src="/static/icon.png" class="w-8 h-8 rounded-full border border-slate-200 mt-1 shadow-sm shrink-0"><div class="bg-[#f8f9fa] border border-slate-200 p-4 rounded-2xl rounded-tl-none text-gray-500 flex items-center gap-2"><svg class="w-4 h-4 animate-spin text-accent" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path></svg> Analyzing...</div></div>`);
184
- scrollBottom();
185
- }
186
 
187
- try {
188
- const currentModel = $('model-select') ? $('model-select').value : (appSettings?.current_model || "");
189
- const msgsToSend = sessions[currentSid].messages.slice(0, -1);
190
-
191
- const payload = { prompt: fullPrompt, model: currentModel, messages: msgsToSend };
192
- const res = await fetch("/api/chat", {
193
- method: "POST",
194
- headers: { "Content-Type": "application/json" },
195
- body: JSON.stringify(payload),
196
- });
197
- const data = await res.json();
198
-
199
- if($(loadId)) $(loadId).remove();
200
-
201
- if (!res.ok || data.error) {
202
- renderBubble("assistant", `<strong>Error:</strong> ${data.error || "Gagal menghubungi server"}`);
203
- } else {
204
- renderBubble("assistant", data.reply);
205
- appendMessage("assistant", data.reply, data.reply);
206
- }
207
- } catch (err) {
208
- if($(loadId)) $(loadId).remove();
209
- renderBubble("assistant", `<strong>System Error:</strong> ${err.message}`);
210
- } finally {
211
- setBusy(false);
212
- if($('chat-textarea')) $('chat-textarea').focus();
213
- }
214
- }
215
 
216
- addEvt("btn-send", "click", sendChat);
217
- if($('chat-textarea')) {
218
- $('chat-textarea').addEventListener("input", function() {
219
- this.style.height = "auto";
220
- this.style.height = Math.min(this.scrollHeight, 160) + "px";
221
- });
222
- $('chat-textarea').addEventListener("keydown", e => {
223
- if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendChat(); }
224
- });
225
  }
226
 
227
- // ─────────────────────────────────────
228
- // 6. Settings Modal Logic
229
- // ─────────────────────────────────────
230
- function populateModelSelect() {
231
- const ms = $("model-select");
232
- if(!ms) return;
233
- ms.innerHTML = "";
234
- let list = appSettings ? (appSettings.provider === "Nvidia NIM" ? appSettings.models_nvidia : (appSettings.provider === "OpenRouter" ? appSettings.models_openrouter : ["gemini-1.5-pro-latest", "gemini-1.5-flash-latest"])) : [];
235
- if(!list || list.length===0) list = ["default-model"];
236
 
237
- list.forEach(m => {
238
- const opt = document.createElement("option");
239
- opt.value = m; opt.textContent = m.length > 30 ? m.slice(0,28)+"" : m;
240
- if(appSettings && m === appSettings.current_model) opt.selected = true;
241
- ms.appendChild(opt);
242
  });
243
- }
 
244
 
245
- function renderModelListSetting(containerId, models, provider) {
246
- const list = $(containerId);
247
- if(!list) return;
248
- list.innerHTML = (models || []).map((m, i) => `
249
- <div class="flex items-center justify-between px-2 py-1.5 rounded-lg hover:bg-white group transition-colors">
250
- <span class="text-xs text-gray-700 truncate flex-1">${m}</span>
251
- <button data-i="${i}" data-prov="${provider}" class="btn-del-model text-gray-300 hover:text-red-400 ml-2 text-xs font-bold transition-opacity">✕</button>
252
- </div>
253
- `).join('');
254
-
255
- list.querySelectorAll(".btn-del-model").forEach(btn => {
256
- btn.addEventListener("click", () => {
257
- const idx = Number(btn.dataset.i);
258
- if (btn.dataset.prov === "OpenRouter") {
259
- appSettings.models_openrouter.splice(idx, 1);
260
- renderModelListSetting("models-list-openrouter", appSettings.models_openrouter, "OpenRouter");
261
- } else {
262
- appSettings.models_nvidia.splice(idx, 1);
263
- renderModelListSetting("models-list-nvidia", appSettings.models_nvidia, "Nvidia NIM");
264
- }
265
- });
266
- });
267
  }
 
268
 
269
- addEvt("btn-settings", "click", () => {
270
- if (appSettings) {
271
- if($("settings-provider")) $("settings-provider").value = appSettings.provider;
272
- if($("settings-url")) $("settings-url").value = appSettings.base_url || "";
273
- if($("settings-apikey")) $("settings-apikey").value = appSettings.api_key || "";
274
- renderModelListSetting("models-list-openrouter", appSettings.models_openrouter, "OpenRouter");
275
- renderModelListSetting("models-list-nvidia", appSettings.models_nvidia, "Nvidia NIM");
276
- }
277
- if($('settings-modal')) $('settings-modal').classList.remove('hidden');
278
- if(window.innerWidth < 768) toggleSidebar();
279
  });
280
-
281
- const closeSettings = () => { if($('settings-modal')) $('settings-modal').classList.add('hidden'); };
282
- addEvt("btn-close-settings", "click", closeSettings);
283
- addEvt("btn-cancel-settings", "click", closeSettings);
284
-
285
- addEvt("settings-provider", "change", () => {
286
- const prov = $("settings-provider").value;
287
- const urls = {
288
- "OpenRouter": "https://openrouter.ai/api/v1/chat/completions",
289
- "Nvidia NIM": "https://integrate.api.nvidia.com/v1/chat/completions",
290
- "Google Gemini": "https://generativelanguage.googleapis.com/v1beta/models/"
291
- };
292
- if (urls[prov] && $("settings-url")) $("settings-url").value = urls[prov];
293
  });
294
-
295
- addEvt("btn-toggle-key", "click", () => {
296
- const inp = $("settings-apikey");
297
- if(inp) inp.type = inp.type === "password" ? "text" : "password";
 
 
 
 
 
 
 
 
 
 
 
298
  });
299
-
300
- const attachModelBtn = (inputId, listKey, containerId, prov, btnId) => {
301
- const doAdd = () => {
302
- const val = $(inputId)?.value.trim();
303
- if (!val || !appSettings) return;
304
- if (!appSettings[listKey].includes(val)) {
305
- appSettings[listKey].push(val);
306
- $(inputId).value = "";
307
- renderModelListSetting(containerId, appSettings[listKey], prov);
308
- }
309
- };
310
- addEvt(btnId, "click", doAdd);
311
- if($(inputId)) $(inputId).addEventListener("keydown", e => { if (e.key === "Enter") doAdd(); });
 
 
 
 
 
 
 
 
 
312
  };
313
- attachModelBtn("new-model-or", "models_openrouter", "models-list-openrouter", "OpenRouter", "btn-add-model-or");
314
- attachModelBtn("new-model-nv", "models_nvidia", "models-list-nvidia", "Nvidia NIM", "btn-add-model-nv");
315
-
316
- addEvt("btn-save-settings", "click", async () => {
317
- const payload = {
318
- provider: $("settings-provider")?.value,
319
- base_url: $("settings-url")?.value.trim(),
320
- api_key: $("settings-apikey")?.value.trim(),
321
- models_openrouter: appSettings.models_openrouter,
322
- models_nvidia: appSettings.models_nvidia,
323
- };
324
- try {
325
- const res = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
326
- appSettings = await res.json();
327
- populateModelSelect();
328
- closeSettings();
329
- } catch (err) {
330
- console.error("Save error", err);
331
- }
332
- });
333
-
334
- // ─────────────────────────────────────
335
- // 7. DOI Modal
336
- // ─────────────────────────────────────
337
- addEvt("btn-doi", "click", () => {
338
- if($('doi-modal')) $('doi-modal').classList.remove('hidden');
339
- if($('doi-input')) { $('doi-input').value = ""; $('doi-input').focus(); }
340
- if($('doi-result')) $('doi-result').classList.add('hidden');
341
- if(window.innerWidth < 768) toggleSidebar();
342
- });
343
- addEvt("btn-close-doi", "click", () => { if($('doi-modal')) $('doi-modal').classList.add('hidden'); });
344
-
345
- addEvt("btn-validate-doi", "click", async () => {
346
- const doi = $("doi-input")?.value.trim();
347
- const resDiv = $("doi-result");
348
- if (!doi || !resDiv) return;
349
-
350
- if($("doi-spinner")) $("doi-spinner").classList.remove("hidden");
351
- if($("doi-btn-text")) $("doi-btn-text").textContent = "Validating...";
352
- if($("btn-validate-doi")) $("btn-validate-doi").disabled = true;
353
-
354
- resDiv.classList.remove("hidden");
355
- resDiv.innerHTML = `<div class="flex items-center gap-2 text-blue-600">Sedang memvalidasi...</div>`;
356
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  try {
358
- const res = await fetch("/api/validate_doi", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doi: doi }) });
359
  const data = await res.json();
360
- if (!res.ok || data.error) {
361
- resDiv.innerHTML = `<p class="text-red-500 font-medium">Error: ${data.error || "Gagal validasi"}</p>`;
362
- } else {
363
- resDiv.innerHTML = `<div class="space-y-2"><div><p class="text-[10px] font-semibold text-gray-400 uppercase">Title</p><p class="font-medium text-gray-800 text-sm">${data.title}</p></div><div><p class="text-[10px] font-semibold text-gray-400 uppercase">Authors</p><p class="text-sm text-gray-700">${data.authors}</p></div><div class="flex gap-6"><div><p class="text-[10px] font-semibold text-gray-400 uppercase">Year</p><p class="text-sm text-gray-700">${data.year}</p></div><div><p class="text-[10px] font-semibold text-gray-400 uppercase">Type</p><p class="text-sm text-gray-700">${data.type}</p></div></div><div><p class="text-[10px] font-semibold text-gray-400 uppercase">Source</p><p class="text-sm text-gray-700">${data.journal}</p></div></div>`;
364
- }
365
  } catch (err) {
366
- resDiv.innerHTML = `<p class="text-red-500">${err.message}</p>`;
367
- } finally {
368
- if($("doi-spinner")) $("doi-spinner").classList.add("hidden");
369
- if($("doi-btn-text")) $("doi-btn-text").textContent = "Validate";
370
- if($("btn-validate-doi")) $("btn-validate-doi").disabled = false;
371
  }
372
  });
 
373
 
374
- // ─────────────────────────────────────
375
- // 8. Sidebar & PDF logic
376
- // ─────────────────────────────────────
377
- function toggleSidebar() {
378
- if (window.innerWidth < 768) {
379
- if($('sidebar')) $('sidebar').classList.toggle("-translate-x-full");
380
- if($('sidebar-overlay')) $('sidebar-overlay').classList.toggle("hidden");
381
- } else {
382
- if($('sidebar')) $('sidebar').classList.toggle("hidden");
383
- }
384
- }
385
- addEvt("btn-hamburger", "click", toggleSidebar);
386
- addEvt("btn-toggle-sidebar", "click", toggleSidebar);
387
- addEvt("sidebar-overlay", "click", toggleSidebar);
388
-
389
- function handleClear() {
390
- if (!currentSid) return;
391
- sessions[currentSid].messages = [];
392
- sessions[currentSid].title = "New Chat";
393
- saveSessions();
394
- if($('chat-messages')) $('chat-messages').innerHTML = "";
395
- if($('welcome-screen')) $('welcome-screen').style.display = "flex";
396
- if($('chat-title')) $('chat-title').textContent = "New Chat";
397
- renderSidebar();
398
- }
399
- addEvt("btn-clear-chat", "click", handleClear);
400
- addEvt("btn-clear-chat-mobile", "click", handleClear);
401
-
402
- function clearAttachment() {
403
- pdfText = null; pdfFilename = null;
404
- if($("pdf-input")) $("pdf-input").value = "";
405
- if($("attach-badge")) $("attach-badge").classList.add("hidden");
406
- }
407
- addEvt("btn-remove-attach", "click", clearAttachment);
408
- addEvt("btn-attach", "click", () => { if($("pdf-input")) $("pdf-input").click(); });
409
-
410
- if($("pdf-input")) {
411
- $("pdf-input").addEventListener("change", async e => {
412
- const file = e.target.files[0];
413
- if (!file) return;
414
- e.target.value = "";
415
- const fd = new FormData();
416
- fd.append("file", file);
417
- if($("attach-name")) $("attach-name").textContent = "Extracting text...";
418
- if($("attach-badge")) $("attach-badge").classList.remove("hidden");
419
- try {
420
- const res = await fetch("/api/upload_pdf", { method: "POST", body: fd });
421
- const data = await res.json();
422
- if (!res.ok || data.error) throw new Error(data.error);
423
- pdfText = data.text;
424
- pdfFilename = data.filename;
425
- if($("attach-name")) $("attach-name").textContent = `${data.filename} · ${data.word_count} words`;
426
- } catch (err) {
427
- alert(`Upload failed: ${err.message}`);
428
- clearAttachment();
429
- }
430
- });
431
- }
432
 
433
- // Eksekusi jalan pertama
434
- initApp();
435
- });
 
 
 
1
+ /**
2
+ * ORBIT – Educational Research Assistant
3
+ * PWA + Safe Modal + Marked JS Rendering
4
+ */
5
+
6
+ const $ = id => document.getElementById(id);
7
+ const addEvt = (id, event, handler) => {
8
+ const el = $(id);
9
+ if(el) el.addEventListener(event, handler);
10
+ };
11
+
12
+ let currentUser = null;
13
+ let appSettings = null;
14
+ let sessions = {};
15
+ let currentSid = null;
16
+ let pdfText = null;
17
+ let pdfFilename = null;
18
+ let isBusy = false;
19
+
20
+ // PWA: Tangkap prompt install dari browser
21
+ let deferredPrompt;
22
+ window.addEventListener('beforeinstallprompt', (e) => {
23
+ e.preventDefault();
24
+ deferredPrompt = e;
25
+ const btnInstall = $('btn-install-pwa');
26
+ if (btnInstall) btnInstall.classList.remove('hidden');
27
+ });
28
+
29
+ addEvt("btn-install-pwa", "click", async () => {
30
+ if (deferredPrompt) {
31
+ deferredPrompt.prompt();
32
+ const { outcome } = await deferredPrompt.userChoice;
33
+ if (outcome === 'accepted') {
34
+ $('btn-install-pwa').classList.add('hidden');
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  }
36
+ deferredPrompt = null;
37
  }
38
+ });
39
 
40
+ // Load Memori
41
+ try {
42
+ const stored = localStorage.getItem('orbit_sessions_v5');
43
+ sessions = stored ? JSON.parse(stored) : {};
44
+ if (Array.isArray(sessions)) sessions = {};
45
+ } catch(e) { sessions = {}; }
46
 
47
+ async function initApp() {
48
+ try {
49
+ const userRes = await fetch('/api/me');
50
+ if (userRes.status === 401) { window.location.href = '/login'; return; }
51
+ currentUser = await userRes.json();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
+ if($('user-name')) $('user-name').textContent = currentUser.name || currentUser.email;
54
+ if($('user-avatar') && currentUser.picture) $('user-avatar').src = currentUser.picture;
55
+
56
+ const setRes = await fetch('/api/settings');
57
+ if (setRes.ok) appSettings = await setRes.json();
58
+ } catch (error) {
59
+ console.error("Init Error", error);
60
+ } finally {
61
+ populateModelSelect();
62
  const ids = Object.keys(sessions).sort((a, b) => Number(b) - Number(a));
63
+ if (ids.length > 0) loadSession(ids[0]);
64
+ else newSession();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  }
66
+ }
67
+
68
+ function saveSessions() {
69
+ try { localStorage.setItem('orbit_sessions_v5', JSON.stringify(sessions)); } catch(e) {}
70
+ }
71
+
72
+ function newSession() {
73
+ const id = Date.now().toString();
74
+ sessions[id] = { title: "New Chat", messages: [] };
75
+ loadSession(id);
76
+ }
77
+
78
+ function loadSession(id) {
79
+ if (!sessions[id]) return;
80
+ currentSid = id;
81
+ if (!sessions[currentSid].messages) sessions[currentSid].messages = [];
82
+
83
+ const cm = $("chat-messages");
84
+ if(cm) cm.innerHTML = '';
85
+
86
+ const msgs = sessions[id].messages;
87
+ if (msgs.length > 0) {
88
+ if($("welcome-msg")) $("welcome-msg").style.display = 'none';
89
+ msgs.forEach(m => renderBubble(m.role, m.displayContent || m.content, false));
90
+ scrollBottom();
91
+ } else {
92
+ if($("welcome-msg")) $("welcome-msg").style.display = 'flex';
93
  }
94
+ renderSidebar();
95
+ }
96
+
97
+ function renderSidebar() {
98
+ const list = $('history-list');
99
+ if(!list) return;
100
+ const ids = Object.keys(sessions).sort((a, b) => Number(b) - Number(a));
101
+ if(ids.length === 0) {
102
+ list.innerHTML = '<p class="text-xs text-gray-400 px-3 py-2 italic">No recent chats.</p>';
103
+ return;
104
  }
105
+ list.innerHTML = ids.map(id => {
106
+ const isActive = (id === currentSid);
107
+ const cls = isActive ? 'bg-accent-light text-accent font-semibold' : 'text-gray-600 hover:bg-white hover:shadow-sm font-medium';
108
+ return `<button onclick="window.loadSession('${id}')" class="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm transition-all text-left truncate ${cls}"><svg class="w-4 h-4 shrink-0 opacity-60" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"></path></svg><span class="truncate">${sessions[id].title}</span></button>`;
109
+ }).join('');
110
+ }
111
+
112
+ window.loadSession = function(id) {
113
+ loadSession(id);
114
+ if(window.innerWidth < 768) toggleSidebar();
115
+ };
116
+
117
+ function appendMessage(role, content, displayContent) {
118
+ if(!sessions[currentSid]) newSession();
119
+ if(!sessions[currentSid].messages) sessions[currentSid].messages = [];
120
+
121
+ sessions[currentSid].messages.push({ role, content, displayContent });
122
+
123
+ if (role === "user" && sessions[currentSid].messages.filter(m => m.role === "user").length === 1) {
124
+ let snippet = String(displayContent).replace(/<[^>]*>?/gm, '');
125
+ sessions[currentSid].title = snippet.split(" ").slice(0, 5).join(" ") + "…";
126
+ renderSidebar();
127
  }
128
+ saveSessions();
129
+ }
130
+
131
+ function scrollBottom() {
132
+ const cm = $("chat-messages");
133
+ if(cm) cm.scrollTop = cm.scrollHeight;
134
+ }
135
+
136
+ function renderBubble(role, content, animate = true) {
137
+ const isUser = (role === "user");
138
+ const wrap = document.createElement("div");
139
+ wrap.className = `flex mb-6 ${isUser ? "justify-end" : "justify-start"} ${animate ? "bubble-"+role : ""}`;
140
+
141
+ if (isUser) {
142
+ wrap.innerHTML = `<div class="bg-accent text-white p-4 rounded-2xl rounded-tr-none max-w-[85%] shadow-sm text-[15px] leading-relaxed">${content}</div>`;
143
+ } else {
144
+ let parsedText = content;
145
+ try { parsedText = (typeof marked !== 'undefined') ? marked.parse(content) : content.replace(/\n/g, "<br>"); } catch (e) { parsedText = content; }
146
+ wrap.innerHTML = `<div class="flex gap-4 items-start w-full"><img src="/static/icon.png" class="w-8 h-8 rounded-full border border-slate-200 mt-1 shadow-sm shrink-0" onerror="this.style.display='none'"><div class="bg-[#f8f9fa] border border-slate-200 p-5 rounded-2xl rounded-tl-none max-w-[90%] md:max-w-[85%] prose-orbit shadow-sm w-full">${parsedText}</div></div>`;
147
  }
148
 
149
+ if($("chat-messages")) $("chat-messages").appendChild(wrap);
150
+ if(animate) scrollBottom();
151
+ }
152
+
153
+ function setBusy(busy) {
154
+ isBusy = busy;
155
+ if($('btn-send')) $('btn-send').disabled = busy;
156
+ if($('chat-textarea')) $('chat-textarea').disabled = busy;
157
+ if($('send-icon')) $('send-icon').classList.toggle("hidden", busy);
158
+ if($('loading-icon')) $('loading-icon').classList.toggle("hidden", !busy);
159
+ }
160
+
161
+ async function sendChat() {
162
+ if (isBusy) return;
163
+ const inputEl = $("chat-textarea");
164
+ const raw = inputEl ? inputEl.value.trim() : "";
165
+ if (!raw && !pdfText) return;
166
+
167
+ let fullPrompt = raw;
168
+ let safeRaw = raw.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
169
+ let displayPrompt = safeRaw.replace(/\n/g, '<br>');
170
+
171
+ if (pdfText) {
172
+ fullPrompt = `[Attached Document: ${pdfFilename}]\n${pdfText}\n\nUser Question: ${raw}`;
173
+ displayPrompt = `<div class="bg-blue-500 text-white text-xs px-2.5 py-1.5 rounded w-fit mb-2 font-bold inline-block">📄 ${pdfFilename}</div><br>${safeRaw.replace(/\n/g, '<br>')}`;
174
+ clearAttachment();
175
+ }
176
 
177
+ if(inputEl) { inputEl.value = ""; inputEl.style.height = "auto"; }
178
+ if($("welcome-msg")) $("welcome-msg").style.display = 'none';
179
+
180
+ if (!currentSid || !sessions[currentSid]) newSession();
 
 
 
 
 
 
 
 
181
 
182
+ setBusy(true);
183
+ renderBubble("user", displayPrompt);
184
+ appendMessage("user", fullPrompt, displayPrompt);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
 
186
+ const loadId = 'load-' + Date.now();
187
+ if($("chat-messages")) {
188
+ $("chat-messages").insertAdjacentHTML('beforeend', `<div id="${loadId}" class="flex mb-6 gap-4 items-start"><img src="/static/icon.png" class="w-8 h-8 rounded-full border border-slate-200 mt-1 shadow-sm shrink-0"><div class="bg-[#f8f9fa] border border-slate-200 p-4 rounded-2xl rounded-tl-none text-gray-500 flex items-center gap-2"><svg class="w-4 h-4 animate-spin text-accent" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path></svg> Analyzing...</div></div>`);
189
+ scrollBottom();
 
 
 
 
 
190
  }
191
 
192
+ try {
193
+ const currentModel = $("model-select") ? $("model-select").value : (appSettings ? appSettings.current_model : "");
194
+ const msgsToSend = (sessions[currentSid] && sessions[currentSid].messages) ? sessions[currentSid].messages.slice(0, -1) : [];
 
 
 
 
 
 
195
 
196
+ const res = await fetch("/api/chat", {
197
+ method: "POST",
198
+ headers: { "Content-Type": "application/json" },
199
+ body: JSON.stringify({ prompt: fullPrompt, model: currentModel, messages: msgsToSend }),
 
200
  });
201
+ const data = await res.json();
202
+ if($(loadId)) $(loadId).remove();
203
 
204
+ if (!res.ok || data.error) {
205
+ renderBubble("assistant", `<strong>Error:</strong> ${data.error || "Unknown error"}`);
206
+ } else {
207
+ renderBubble("assistant", data.reply);
208
+ appendMessage("assistant", data.reply, data.reply);
209
+ }
210
+ } catch (err) {
211
+ if($(loadId)) $(loadId).remove();
212
+ renderBubble("assistant", `<strong>Network error:</strong> ${err.message}`);
213
+ } finally {
214
+ setBusy(false);
215
+ if($("chat-textarea")) $("chat-textarea").focus();
 
 
 
 
 
 
 
 
 
 
216
  }
217
+ }
218
 
219
+ addEvt("btn-send", "click", sendChat);
220
+ if($("chat-textarea")) {
221
+ $("chat-textarea").addEventListener("input", function() {
222
+ this.style.height = "auto";
223
+ this.style.height = Math.min(this.scrollHeight, 160) + "px";
 
 
 
 
 
224
  });
225
+ $("chat-textarea").addEventListener("keydown", function(e) {
226
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendChat(); }
 
 
 
 
 
 
 
 
 
 
 
227
  });
228
+ }
229
+
230
+ // ─────────────────────────────────────
231
+ // Helper Modals
232
+ // ─────────────────────────────────────
233
+ function populateModelSelect() {
234
+ const ms = $("model-select");
235
+ if(!ms) return;
236
+ ms.innerHTML = "";
237
+ let list = appSettings ? (appSettings.provider === "Nvidia NIM" ? appSettings.models_nvidia : (appSettings.provider === "OpenRouter" ? appSettings.models_openrouter : ["gemini-1.5-pro-latest", "gemini-1.5-flash-latest"])) : ["Default Model"];
238
+ list.forEach(m => {
239
+ const opt = document.createElement("option");
240
+ opt.value = m; opt.textContent = m.length > 30 ? m.slice(0, 28) + "…" : m;
241
+ if (appSettings && m === appSettings.current_model) opt.selected = true;
242
+ ms.appendChild(opt);
243
  });
244
+ if (ms.options.length && !ms.value) ms.selectedIndex = 0;
245
+ }
246
+
247
+ addEvt("btn-open-settings", "click", () => {
248
+ if (appSettings) {
249
+ if($("settings-provider")) $("settings-provider").value = appSettings.provider;
250
+ if($("settings-url")) $("settings-url").value = appSettings.base_url || "";
251
+ if($("settings-apikey")) $("settings-apikey").value = appSettings.api_key || "";
252
+ }
253
+ if($("settings-modal")) $("settings-modal").classList.remove("hidden");
254
+ if(window.innerWidth < 768) toggleSidebar();
255
+ });
256
+ addEvt("btn-close-settings", "click", () => { if($("settings-modal")) $("settings-modal").classList.add("hidden"); });
257
+ addEvt("btn-cancel-settings", "click", () => { if($("settings-modal")) $("settings-modal").classList.add("hidden"); });
258
+
259
+ addEvt("btn-save-settings", "click", async () => {
260
+ const payload = {
261
+ provider: $("settings-provider")?.value,
262
+ base_url: $("settings-url")?.value.trim(),
263
+ api_key: $("settings-apikey")?.value.trim(),
264
+ models_openrouter: appSettings?.models_openrouter || [],
265
+ models_nvidia: appSettings?.models_nvidia || [],
266
  };
267
+ try {
268
+ const res = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
269
+ appSettings = await res.json();
270
+ populateModelSelect();
271
+ if($("settings-modal")) $("settings-modal").classList.add("hidden");
272
+ } catch (err) { console.error("Save error", err); }
273
+ });
274
+
275
+ addEvt("btn-open-doi", "click", () => {
276
+ if($("doi-modal")) $("doi-modal").classList.remove("hidden");
277
+ if($("doi-input")) { $("doi-input").value = ""; $("doi-input").focus(); }
278
+ if($("doi-result")) $("doi-result").classList.add("hidden");
279
+ if(window.innerWidth < 768) toggleSidebar();
280
+ });
281
+ addEvt("btn-close-doi", "click", () => { if($("doi-modal")) $("doi-modal").classList.add("hidden"); });
282
+ if($("doi-input")) { $("doi-input").addEventListener("keydown", e => { if (e.key === "Enter" && $("btn-validate-doi-submit")) $("btn-validate-doi-submit").click(); }); }
283
+
284
+ addEvt("btn-validate-doi-submit", "click", async () => {
285
+ const doi = $("doi-input")?.value.trim();
286
+ const resDiv = $("doi-result");
287
+ if (!doi || !resDiv) return;
288
+
289
+ if($("doi-spinner")) $("doi-spinner").classList.remove("hidden");
290
+ if($("btn-validate-doi-submit")) $("btn-validate-doi-submit").disabled = true;
291
+ resDiv.classList.remove("hidden");
292
+ resDiv.innerHTML = `<div class="flex items-center gap-2 text-blue-600">Validating...</div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
 
294
+ try {
295
+ const res = await fetch("/api/validate_doi", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doi: doi }) });
296
+ const data = await res.json();
297
+ if (!res.ok || data.error) resDiv.innerHTML = `<p class="text-red-500 font-medium">Error: ${data.error}</p>`;
298
+ else resDiv.innerHTML = `<div><p class="text-[10px] font-semibold text-gray-400 uppercase">Title</p><p class="font-medium text-gray-800 text-sm">${data.title}</p></div>`;
299
+ } catch (err) { resDiv.innerHTML = `<p class="text-red-500">${err.message}</p>`; }
300
+ finally {
301
+ if($("doi-spinner")) $("doi-spinner").classList.add("hidden");
302
+ if($("btn-validate-doi-submit")) $("btn-validate-doi-submit").disabled = false;
303
+ }
304
+ });
305
+
306
+ // UI Toggles & Upload
307
+ function toggleSidebar() {
308
+ if (window.innerWidth < 768) {
309
+ if($("sidebar")) $("sidebar").classList.toggle("-translate-x-full");
310
+ if($("sidebar-overlay")) $("sidebar-overlay").classList.toggle("hidden");
311
+ } else {
312
+ if($("sidebar")) $("sidebar").classList.toggle("hidden");
313
+ }
314
+ }
315
+ addEvt("btn-hamburger", "click", toggleSidebar);
316
+ addEvt("btn-toggle-sidebar", "click", toggleSidebar);
317
+ addEvt("btn-close-sidebar", "click", toggleSidebar);
318
+ addEvt("sidebar-overlay", "click", toggleSidebar);
319
+
320
+ function handleClear() {
321
+ if (!currentSid) return;
322
+ sessions[currentSid].messages = [];
323
+ sessions[currentSid].title = "New Chat";
324
+ saveSessions();
325
+ if($("chat-messages")) $("chat-messages").innerHTML = "";
326
+ if($("welcome-screen")) $("welcome-screen").style.display = "flex";
327
+ renderSidebar();
328
+ }
329
+ addEvt("btn-new-chat", "click", newSession);
330
+ addEvt("btn-clear-chat-top", "click", handleClear);
331
+ addEvt("btn-clear-chat-mobile", "click", handleClear);
332
+
333
+ function clearAttachment() {
334
+ pdfText = null; pdfFilename = null;
335
+ if($("pdf-input")) $("pdf-input").value = "";
336
+ if($("attach-badge")) $("attach-badge").classList.add("hidden");
337
+ }
338
+ addEvt("btn-remove-attach", "click", clearAttachment);
339
+ addEvt("btn-attach", "click", () => { if($("pdf-input")) $("pdf-input").click(); });
340
+
341
+ if($("pdf-input")) {
342
+ $("pdf-input").addEventListener("change", async e => {
343
+ const file = e.target.files[0];
344
+ if (!file) return;
345
+ e.target.value = "";
346
+ const fd = new FormData();
347
+ fd.append("file", file);
348
+ if($("attach-name")) $("attach-name").textContent = "Extracting text...";
349
+ if($("attach-badge")) $("attach-badge").classList.remove("hidden");
350
  try {
351
+ const res = await fetch("/api/upload_pdf", { method: "POST", body: fd });
352
  const data = await res.json();
353
+ if (!res.ok || data.error) throw new Error(data.error);
354
+ pdfText = data.text;
355
+ pdfFilename = data.filename;
356
+ if($("attach-name")) $("attach-name").textContent = `${data.filename} · ${data.word_count} words`;
 
357
  } catch (err) {
358
+ alert(`Upload failed: ${err.message}`);
359
+ clearAttachment();
 
 
 
360
  }
361
  });
362
+ }
363
 
364
+ // Start
365
+ initApp();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
 
367
+ if ('serviceWorker' in navigator) {
368
+ window.addEventListener('load', () => {
369
+ navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(e => console.error(e));
370
+ });
371
+ }