xenux4u commited on
Commit
1dfaeb1
Β·
verified Β·
1 Parent(s): bf5b9eb

Update static/js/script.js

Browse files
Files changed (1) hide show
  1. static/js/script.js +393 -500
static/js/script.js CHANGED
@@ -1,542 +1,435 @@
1
- /**
2
- * ORBIT – Educational Research Assistant
3
- * V3 SCRIPT: Maximum Compatibility & Anti-Crash
4
- */
5
-
6
- // ─────────────────────────────────────
7
- // 1. DOM Helper (Anti-Error)
8
- // ─────────────────────────────────────
9
- const $ = function(id) { return document.getElementById(id); };
10
- const addEvt = function(id, event, handler) {
11
- const el = $(id);
12
- if(el) el.addEventListener(event, handler);
13
- };
14
-
15
- // ─────────────────────────────────────
16
- // 2. State & Data
17
- // ─────────────────────────────────────
18
- let currentUser = null;
19
- let appSettings = null;
20
- let sessions = {};
21
- let currentSid = null;
22
- let pdfText = null;
23
- let pdfFilename = null;
24
- let isBusy = false;
25
-
26
- // Coba load dari localStorage dengan aman
27
- try {
28
- const stored = localStorage.getItem('orbit_sessions_v3');
29
- sessions = stored ? JSON.parse(stored) : {};
30
- if (Array.isArray(sessions)) sessions = {};
31
- } catch(e) {
32
- sessions = {};
33
- }
34
-
35
- // ─────────────────────────────────────
36
- // 3. Boot & Init
37
- // ─────────────────────────────────────
38
- async function initApp() {
39
- try {
40
- const userRes = await fetch('/api/me');
41
- if (userRes.status === 401) {
42
- window.location.href = '/login';
43
- return;
44
- }
45
- if (!userRes.ok) throw new Error('Gagal load user');
46
-
47
- currentUser = await userRes.json();
48
-
49
- if($('user-name')) $('user-name').textContent = currentUser.name || currentUser.email;
50
- if($('user-avatar') && currentUser.picture) $('user-avatar').src = currentUser.picture;
51
 
52
- const setRes = await fetch('/api/settings');
53
- if (setRes.ok) {
54
- appSettings = await setRes.json();
55
- }
56
- } catch (error) {
57
- console.error("Init Error:", error);
58
- } finally {
59
- populateModelSelect();
60
-
61
- const ids = Object.keys(sessions).sort(function(a, b) { return Number(b) - Number(a); });
62
- if (ids.length > 0) {
63
- loadSession(ids[0]);
64
- } else {
65
- newSession();
66
- }
67
- }
68
- }
69
 
70
- // ─────────────────────────────────────
71
- // 4. Session & History Logic
72
- // ─────────────────────────────────────
73
- function saveSessions() {
74
  try {
75
- localStorage.setItem('orbit_sessions_v3', JSON.stringify(sessions));
 
 
76
  } catch(e) {
77
- console.error("Gagal save session", e);
78
  }
79
- }
80
-
81
- function newSession() {
82
- const id = Date.now().toString();
83
- sessions[id] = { title: "New Chat", messages: [] };
84
- loadSession(id);
85
- }
86
-
87
- function loadSession(id) {
88
- if (!sessions[id]) return;
89
- currentSid = id;
90
-
91
- if (!sessions[currentSid].messages) {
92
- sessions[currentSid].messages = [];
 
 
 
 
 
 
 
 
 
 
93
  }
94
 
95
- const cm = $("chat-messages");
96
- if(cm) cm.innerHTML = '';
97
-
98
- const msgs = sessions[id].messages;
99
- if (msgs.length > 0) {
100
- if($("welcome-msg")) $("welcome-msg").remove();
101
- msgs.forEach(function(m) {
102
- renderBubble(m.role, m.displayContent || m.content, false);
103
- });
104
- scrollBottom();
105
- } else {
106
- if(cm) cm.innerHTML = '<div id="welcome-msg" class="flex flex-col items-center justify-center h-full text-center px-4 py-10"><img src="/static/icon.png" class="w-16 h-16 mb-4 shadow-sm rounded-2xl" onerror="this.style.display=\'none\'"><h2 class="text-2xl font-bold text-gray-800 mb-2">Welcome to ORBIT</h2><p class="text-gray-500 text-sm max-w-md">Your AI-powered Educational Research Assistant. Upload a document or type a prompt below to begin.</p></div>';
107
  }
108
-
109
- renderSidebar();
110
- }
111
-
112
- function renderSidebar() {
113
- const list = $('history-list');
114
- if(!list) return;
115
-
116
- const ids = Object.keys(sessions).sort(function(a, b) { return Number(b) - Number(a); });
117
- if(ids.length === 0) {
118
- list.innerHTML = '<p class="text-xs text-gray-400 px-3 py-2 italic">No recent chats.</p>';
119
- return;
120
  }
 
121
 
122
- let htmlStr = "";
123
- ids.forEach(function(id) {
124
- const isActive = (id === currentSid);
125
- const activeClass = isActive ? 'bg-accent-light text-accent font-semibold' : 'text-gray-600 hover:bg-white hover:shadow-sm font-medium';
126
- const title = sessions[id].title || "New Chat";
127
- htmlStr += '<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 ' + activeClass + '"><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">' + title + '</span></button>';
128
- });
129
- list.innerHTML = htmlStr;
130
- }
131
-
132
- function appendMessage(role, content, displayContent) {
133
- if(!sessions[currentSid]) newSession();
134
- if(!sessions[currentSid].messages) sessions[currentSid].messages = [];
135
-
136
- sessions[currentSid].messages.push({ role: role, content: content, displayContent: displayContent });
137
-
138
- // Auto title
139
- const userMsgs = sessions[currentSid].messages.filter(function(m) { return m.role === "user"; });
140
- if (role === "user" && userMsgs.length === 1) {
141
- let snippet = String(displayContent).replace(/<[^>]*>?/gm, '');
142
- snippet = snippet.split(" ").slice(0, 5).join(" ") + "…";
143
- sessions[currentSid].title = snippet;
144
  renderSidebar();
 
145
  }
146
- saveSessions();
147
- }
148
-
149
- window.loadSession = function(id) {
150
- loadSession(id);
151
- if(window.innerWidth < 768) toggleSidebar();
152
- };
153
-
154
- // ─────────────────────────────────────
155
- // 5. Chat UI & Render
156
- // ─────────────────────────────────────
157
- function scrollBottom() {
158
- const cm = $("chat-messages");
159
- if(cm) cm.scrollTop = cm.scrollHeight;
160
- }
161
-
162
- function simpleMarkdown(text) {
163
- let h = String(text || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
164
- h = h.replace(/```(\w*)\n?([\s\S]*?)```/g, function(_, lang, c) { return '<pre><code>' + c.trim() + '</code></pre>'; });
165
- h = h.replace(/`([^`\n]+)`/g, "<code>$1</code>");
166
- h = h.replace(/\*\*\*(.+?)\*\*\*/g, "<strong><em>$1</em></strong>");
167
- h = h.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
168
- h = h.replace(/\*(.+?)\*/g, "<em>$1</em>");
169
- h = h.replace(/^### (.+)$/gm, "<h3>$1</h3>");
170
- h = h.replace(/^## (.+)$/gm, "<h2>$1</h2>");
171
- h = h.replace(/^# (.+)$/gm, "<h1>$1</h1>");
172
- h = h.replace(/^\s*[-*] (.+)/gm, "<li>$1</li>");
173
- h = h.split(/\n{2,}/).map(function(p) { return /^<(h[1-3]|ul|li|pre|blockquote)/.test(p.trim()) ? p : '<p>' + p.replace(/\n/g, "<br>") + '</p>'; }).join("\n");
174
- return h;
175
- }
176
-
177
- function renderBubble(role, content, animate) {
178
- if (animate === undefined) animate = true;
179
- const isUser = (role === "user");
180
- const wrap = document.createElement("div");
181
- wrap.className = "flex mb-6 " + (isUser ? "justify-end" : "justify-start") + (animate ? " bubble-" + role : "");
182
-
183
- if (isUser) {
184
- 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>';
185
- } else {
186
- let parsedText = content;
187
- try {
188
- parsedText = (typeof marked !== 'undefined') ? marked.parse(content) : simpleMarkdown(content);
189
- } catch (e) {
190
- parsedText = simpleMarkdown(content);
191
  }
192
- 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>';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  }
194
 
195
- const cm = $("chat-messages");
196
- if(cm) cm.appendChild(wrap);
197
- if(animate) scrollBottom();
198
- }
199
-
200
- function renderSys(text, isErr) {
201
- const wrap = document.createElement("div");
202
- wrap.className = "flex justify-center mb-4";
203
- const bgClass = isErr ? "bg-red-50 text-red-500" : "bg-gray-100 text-gray-500";
204
- wrap.innerHTML = '<span class="text-[11px] px-3 py-1.5 rounded-full ' + bgClass + '">' + text + '</span>';
205
- const cm = $("chat-messages");
206
- if(cm) cm.appendChild(wrap);
207
- scrollBottom();
208
- }
209
-
210
- // ─────────────────────────────────────
211
- // 6. Send Chat & API
212
- // ─────────────────────────────────────
213
- function setBusy(busy) {
214
- isBusy = busy;
215
- if($('btn-send')) $('btn-send').disabled = busy;
216
- if($('chat-textarea')) $('chat-textarea').disabled = busy;
217
- if($('send-icon')) $('send-icon').classList.toggle("hidden", busy);
218
- if($('loading-icon')) $('loading-icon').classList.toggle("hidden", !busy);
219
- }
220
-
221
- async function sendChat() {
222
- if (isBusy) return;
223
- const inputEl = $("chat-textarea");
224
- const raw = inputEl ? inputEl.value.trim() : "";
225
- if (!raw && !pdfText) return;
226
-
227
- let fullPrompt = raw;
228
- let safeRaw = raw.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
229
- let displayPrompt = safeRaw.replace(/\n/g, '<br>');
230
-
231
- if (pdfText) {
232
- fullPrompt = "[Attached Document: " + pdfFilename + "]\n" + pdfText + "\n\nUser Question: " + raw;
233
- 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>');
234
- clearAttachment();
235
  }
236
 
237
- if(inputEl) { inputEl.value = ""; inputEl.style.height = "auto"; }
238
- if($("welcome-msg")) $("welcome-msg").remove();
239
-
240
- if (!currentSid || !sessions[currentSid] || !sessions[currentSid].messages) {
241
- newSession();
242
  }
243
 
244
- setBusy(true);
245
- renderBubble("user", displayPrompt);
246
- appendMessage("user", fullPrompt, displayPrompt);
 
247
 
248
- const loadId = 'load-' + Date.now();
249
- const cm = $("chat-messages");
250
- if(cm) {
251
- cm.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>');
252
- scrollBottom();
 
 
 
 
 
253
  }
254
 
255
- try {
256
- const currentModel = $("model-select") ? $("model-select").value : (appSettings ? appSettings.current_model : "");
257
- const msgsToSend = (sessions[currentSid] && sessions[currentSid].messages) ? sessions[currentSid].messages.slice(0, -1) : [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
 
259
- const payload = {
260
- prompt: fullPrompt,
261
- model: currentModel,
262
- messages: msgsToSend
263
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
 
265
- const res = await fetch("/api/chat", {
266
- method: "POST",
267
- headers: { "Content-Type": "application/json" },
268
- body: JSON.stringify(payload),
 
269
  });
270
- const data = await res.json();
 
 
 
 
 
 
 
 
 
 
 
 
 
271
 
272
- if($(loadId)) $(loadId).remove();
 
 
 
 
 
 
273
 
274
- if (!res.ok || data.error) {
275
- renderSys(data.error || "Unknown error.", true);
276
- } else {
277
- renderBubble("assistant", data.reply);
278
- appendMessage("assistant", data.reply, data.reply);
279
- }
280
- } catch (err) {
281
- if($(loadId)) $(loadId).remove();
282
- renderSys("Network error: " + err.message, true);
283
- } finally {
284
- setBusy(false);
285
- if($("chat-textarea")) $("chat-textarea").focus();
 
 
 
 
 
 
 
 
 
 
286
  }
287
- }
288
 
289
- if($("chat-textarea")) {
290
- $("chat-textarea").addEventListener("input", function() {
291
- this.style.height = "auto";
292
- this.style.height = Math.min(this.scrollHeight, 160) + "px";
293
- });
294
- $("chat-textarea").addEventListener("keydown", function(e) {
295
- if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendChat(); }
 
 
 
296
  });
297
- }
298
- addEvt("btn-send", "click", sendChat);
299
-
300
- // ─────────────────────────────────────
301
- // 7. Modals & Settings
302
- // ─────────────────────────────────────
303
- function populateModelSelect() {
304
- const modelSelect = $("model-select");
305
- if(!modelSelect) return;
306
- modelSelect.innerHTML = "";
307
-
308
- let list = [];
309
- if(appSettings) {
310
- if (appSettings.provider === "Google Gemini") list = ["gemini-1.5-pro-latest", "gemini-1.5-flash-latest"];
311
- else if (appSettings.provider === "OpenRouter") list = appSettings.models_openrouter || [];
312
- else if (appSettings.provider === "Nvidia NIM") list = appSettings.models_nvidia || [];
313
- else list = [appSettings.current_model || "default-model"];
314
- } else {
315
- list = ["Default Model"];
316
- }
317
 
318
- list.forEach(function(m) {
319
- const opt = document.createElement("option");
320
- opt.value = m;
321
- opt.textContent = (m && m.length > 30) ? m.slice(0, 28) + "…" : m;
322
- if (appSettings && m === appSettings.current_model) opt.selected = true;
323
- modelSelect.appendChild(opt);
 
 
 
 
 
 
324
  });
325
-
326
- if (modelSelect.options.length && !modelSelect.value) modelSelect.selectedIndex = 0;
327
- }
328
-
329
- function renderModelListSetting(containerId, models, provider) {
330
- const list = $(containerId);
331
- if(!list) return;
332
- list.innerHTML = "";
333
- (models || []).forEach(function(m, i) {
334
- const row = document.createElement("div");
335
- row.className = "flex items-center justify-between px-2 py-1.5 rounded-lg hover:bg-white group transition-colors";
336
- row.innerHTML = '<span class="text-xs text-gray-700 truncate flex-1">' + m + '</span><button data-i="' + i + '" data-prov="' + provider + '" class="btn-del-model text-gray-300 hover:text-red-400 ml-2 text-xs font-bold opacity-0 group-hover:opacity-100 transition-opacity">βœ•</button>';
337
- list.appendChild(row);
338
  });
339
- const btns = list.querySelectorAll(".btn-del-model");
340
- for(let i=0; i<btns.length; i++) {
341
- btns[i].addEventListener("click", function() {
342
- const idx = Number(this.dataset.i);
343
- if (this.dataset.prov === "OpenRouter") {
344
- appSettings.models_openrouter.splice(idx, 1);
345
- renderModelListSetting("models-list-openrouter", appSettings.models_openrouter, "OpenRouter");
346
- } else {
347
- appSettings.models_nvidia.splice(idx, 1);
348
- renderModelListSetting("models-list-nvidia", appSettings.models_nvidia, "Nvidia NIM");
349
  }
350
- });
351
- }
352
- }
353
-
354
- function openSettings() {
355
- if (!appSettings) return;
356
- if($("settings-provider")) $("settings-provider").value = appSettings.provider;
357
- if($("settings-url")) $("settings-url").value = appSettings.base_url || "";
358
- if($("settings-apikey")) $("settings-apikey").value = appSettings.api_key || "";
359
- renderModelListSetting("models-list-openrouter", appSettings.models_openrouter, "OpenRouter");
360
- renderModelListSetting("models-list-nvidia", appSettings.models_nvidia, "Nvidia NIM");
361
- if($("settings-modal")) $("settings-modal").classList.remove("hidden");
362
- if(window.innerWidth < 768) toggleSidebar();
363
- }
364
-
365
- function closeSettings() {
366
- if($("settings-modal")) $("settings-modal").classList.add("hidden");
367
- }
368
-
369
- addEvt("btn-open-settings", "click", openSettings);
370
- addEvt("btn-close-settings", "click", closeSettings);
371
- addEvt("btn-cancel-settings", "click", closeSettings);
372
-
373
- addEvt("settings-provider", "change", function() {
374
- const prov = $("settings-provider").value;
375
- const urls = {
376
- "OpenRouter": "https://openrouter.ai/api/v1/chat/completions",
377
- "Nvidia NIM": "https://integrate.api.nvidia.com/v1/chat/completions",
378
- "AgentRouter": "https://agentrouter.org/v1/chat/completions",
379
- "Google Gemini": "https://generativelanguage.googleapis.com/v1beta/models/"
380
- };
381
- if (urls[prov] && $("settings-url")) $("settings-url").value = urls[prov];
382
- });
383
-
384
- addEvt("btn-toggle-key", "click", function() {
385
- const inp = $("settings-apikey");
386
- if(inp) inp.type = inp.type === "password" ? "text" : "password";
387
- });
388
-
389
- function attachAddBtn(inputId, listKey, containerId, provLabel, btnId) {
390
- const doAdd = function() {
391
- const inp = $(inputId);
392
- const val = inp ? inp.value.trim() : "";
393
- if (!val || !appSettings) return;
394
- if (!appSettings[listKey].includes(val)) {
395
- appSettings[listKey].push(val);
396
- inp.value = "";
397
- renderModelListSetting(containerId, appSettings[listKey], provLabel);
398
- }
399
- };
400
- addEvt(btnId, "click", doAdd);
401
- if($(inputId)) $(inputId).addEventListener("keydown", function(e) { if (e.key === "Enter") { e.preventDefault(); doAdd(); } });
402
- }
403
- attachAddBtn("new-model-or", "models_openrouter", "models-list-openrouter", "OpenRouter", "btn-add-model-or");
404
- attachAddBtn("new-model-nv", "models_nvidia", "models-list-nvidia", "Nvidia NIM", "btn-add-model-nv");
405
-
406
- addEvt("btn-save-settings", "click", async function() {
407
- const pEl = $("settings-provider");
408
- const uEl = $("settings-url");
409
- const kEl = $("settings-apikey");
410
-
411
- const payload = {
412
- provider: pEl ? pEl.value : "",
413
- base_url: uEl ? uEl.value.trim() : "",
414
- api_key: kEl ? kEl.value.trim() : "",
415
- models_openrouter: appSettings.models_openrouter,
416
- models_nvidia: appSettings.models_nvidia,
417
  };
418
- try {
419
- const res = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
420
- appSettings = await res.json();
421
- populateModelSelect();
422
- closeSettings();
423
- renderSys("Settings saved.");
424
- } catch (err) {
425
- renderSys("Failed to save: " + err.message, true);
426
- }
427
- });
428
-
429
- // ─────────────────────────────────────
430
- // 8. Modals DOI
431
- // ─────────────────────────────────────
432
- addEvt("btn-open-doi", "click", function() {
433
- if($("doi-modal")) $("doi-modal").classList.remove("hidden");
434
- if($("doi-input")) { $("doi-input").value = ""; $("doi-input").focus(); }
435
- if($("doi-result")) $("doi-result").classList.add("hidden");
436
- if(window.innerWidth < 768) toggleSidebar();
437
- });
438
- addEvt("btn-close-doi", "click", function() { if($("doi-modal")) $("doi-modal").classList.add("hidden"); });
439
- if($("doi-input")) { $("doi-input").addEventListener("keydown", function(e) { if (e.key === "Enter") { const btn = $("btn-validate-doi-submit"); if(btn) btn.click(); } }); }
440
-
441
- addEvt("btn-validate-doi-submit", "click", async function() {
442
- const inp = $("doi-input");
443
- const doi = inp ? inp.value.trim() : "";
444
- const resDiv = $("doi-result");
445
- if (!doi || !resDiv) return;
446
-
447
- if($("doi-spinner")) $("doi-spinner").classList.remove("hidden");
448
- if($("btn-validate-doi-submit")) $("btn-validate-doi-submit").disabled = true;
449
-
450
- resDiv.classList.remove("hidden");
451
- resDiv.innerHTML = '<div class="flex items-center gap-2 text-blue-600"><svg class="w-4 h-4 animate-spin" 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> Validating...</div>';
452
 
453
- try {
454
- const res = await fetch("/api/validate_doi", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doi: doi }) });
455
- const data = await res.json();
456
- if (!res.ok || data.error) {
457
- resDiv.innerHTML = '<p class="text-red-500 font-medium">Error: ' + (data.error || "Failed") + '</p>';
458
- } else {
459
- 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>';
 
 
 
 
 
 
 
 
460
  }
461
- } catch (err) {
462
- resDiv.innerHTML = '<p class="text-red-500">' + err.message + '</p>';
463
- } finally {
464
- if($("doi-spinner")) $("doi-spinner").classList.add("hidden");
465
- if($("btn-validate-doi-submit")) $("btn-validate-doi-submit").disabled = false;
466
- }
467
- });
468
-
469
- // ─────────────────────────────────────
470
- // 9. Sidebar & PDF Logic
471
- // ─────────────────────────────────────
472
- function toggleSidebar() {
473
- const sidebar = $("sidebar");
474
- const overlay = $("sidebar-overlay");
475
- if (window.innerWidth < 768) {
476
- if (sidebar) sidebar.classList.toggle("-translate-x-full");
477
- if (overlay) overlay.classList.toggle("hidden");
478
- } else {
479
- if (sidebar) sidebar.classList.toggle("hidden");
480
- }
481
- }
482
-
483
- addEvt("btn-toggle-sidebar", "click", toggleSidebar);
484
- addEvt("btn-hamburger", "click", toggleSidebar);
485
- addEvt("btn-close-sidebar", "click", toggleSidebar);
486
- addEvt("sidebar-overlay", "click", toggleSidebar);
487
-
488
- addEvt("btn-new-chat", "click", newSession);
489
-
490
- function handleClear() {
491
- if (!currentSid || !sessions[currentSid]) return;
492
- sessions[currentSid].messages = [];
493
- sessions[currentSid].title = "New Chat";
494
- saveSessions();
495
- if($("chat-messages")) $("chat-messages").innerHTML = "";
496
- if($("welcome-screen")) $("welcome-screen").style.display = "flex";
497
- if($("chat-title")) $("chat-title").textContent = "New Chat";
498
- renderSidebar();
499
- }
500
- addEvt("btn-clear-chat-top", "click", handleClear);
501
-
502
- function clearAttachment() {
503
- pdfText = null; pdfFilename = null;
504
- if($("pdf-input")) $("pdf-input").value = "";
505
- if($("attach-badge")) $("attach-badge").classList.add("hidden");
506
- }
507
- addEvt("btn-remove-attach", "click", clearAttachment);
508
- addEvt("btn-attach", "click", function() { if($("pdf-input")) $("pdf-input").click(); });
509
-
510
- if($("pdf-input")) {
511
- $("pdf-input").addEventListener("change", async function(e) {
512
- const file = e.target.files[0];
513
- if (!file) return;
514
- e.target.value = "";
515
- const fd = new FormData();
516
- fd.append("file", file);
517
- if($("attach-name")) $("attach-name").textContent = "Extracting text...";
518
- if($("attach-badge")) $("attach-badge").classList.remove("hidden");
519
  try {
520
- const res = await fetch("/api/upload_pdf", { method: "POST", body: fd });
521
  const data = await res.json();
522
- if (!res.ok || data.error) throw new Error(data.error);
523
- pdfText = data.text;
524
- pdfFilename = data.filename;
525
- if($("attach-name")) $("attach-name").textContent = data.filename + ' Β· ' + data.word_count + ' words';
 
526
  } catch (err) {
527
- alert("Upload failed: " + err.message);
528
- clearAttachment();
 
 
 
529
  }
530
  });
531
- }
532
-
533
- // ─────────────────────────────────────
534
- // 10. Start Application
535
- // ─────────────────────────────────────
536
- initApp();
537
-
538
- if ('serviceWorker' in navigator) {
539
- window.addEventListener('load', function() {
540
- navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(function(e) { console.error(e); });
541
- });
542
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ });