xenux4u commited on
Commit
baa54df
Β·
verified Β·
1 Parent(s): fe9d227

Update static/js/script.js

Browse files
Files changed (1) hide show
  1. static/js/script.js +124 -117
static/js/script.js CHANGED
@@ -1,31 +1,36 @@
1
  /**
2
  * ORBIT – Educational Research Assistant
3
- * V2 SCRIPT: Anti-Crash, Safe Storage, Full Logic
4
  */
5
 
6
  // ─────────────────────────────────────
7
  // 1. DOM Helper (Anti-Error)
8
  // ─────────────────────────────────────
9
- const $ = id => document.getElementById(id);
10
- const addEvt = (id, event, handler) => {
11
  const el = $(id);
12
  if(el) el.addEventListener(event, handler);
13
  };
14
 
15
  // ─────────────────────────────────────
16
- // 2. State & Data (GANTI KEY KE V2 BIAR BERSIH)
17
  // ─────────────────────────────────────
18
  let currentUser = null;
19
  let appSettings = null;
20
- // Pakai key baru "orbit_sessions_v2" untuk menghindari data korup lama
21
- let sessions = JSON.parse(localStorage.getItem('orbit_sessions_v2')) || {};
22
  let currentSid = null;
23
  let pdfText = null;
24
  let pdfFilename = null;
25
  let isBusy = false;
26
 
27
- const chatMessages = $("chat-messages");
28
- const chatTextarea = $("chat-textarea");
 
 
 
 
 
 
29
 
30
  // ─────────────────────────────────────
31
  // 3. Boot & Init
@@ -53,13 +58,7 @@ async function initApp() {
53
  } finally {
54
  populateModelSelect();
55
 
56
- // Bersihkan data jika bentuknya Array (korup dari versi lama)
57
- if (Array.isArray(sessions)) {
58
- sessions = {};
59
- saveSessions();
60
- }
61
-
62
- const ids = Object.keys(sessions).sort((a, b) => Number(b) - Number(a));
63
  if (ids.length > 0) {
64
  loadSession(ids[0]);
65
  } else {
@@ -72,7 +71,11 @@ async function initApp() {
72
  // 4. Session & History Logic
73
  // ─────────────────────────────────────
74
  function saveSessions() {
75
- localStorage.setItem('orbit_sessions_v2', JSON.stringify(sessions));
 
 
 
 
76
  }
77
 
78
  function newSession() {
@@ -85,25 +88,22 @@ function loadSession(id) {
85
  if (!sessions[id]) return;
86
  currentSid = id;
87
 
88
- // Sabuk pengaman: Pastikan array messages selalu ada
89
  if (!sessions[currentSid].messages) {
90
  sessions[currentSid].messages = [];
91
  }
92
 
93
- if(chatMessages) chatMessages.innerHTML = '';
 
94
 
95
- const msgs = sessions[id].messages || [];
96
  if (msgs.length > 0) {
97
  if($("welcome-msg")) $("welcome-msg").remove();
98
- msgs.forEach(m => renderBubble(m.role, m.displayContent || m.content, false));
 
 
99
  scrollBottom();
100
  } else {
101
- if(chatMessages) chatMessages.innerHTML = `
102
- <div id="welcome-msg" class="flex flex-col items-center justify-center h-full text-center px-4 py-10">
103
- <img src="/static/icon.png" class="w-16 h-16 mb-4 shadow-sm rounded-2xl" onerror="this.style.display='none'">
104
- <h2 class="text-2xl font-bold text-gray-800 mb-2">Welcome to ORBIT</h2>
105
- <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>
106
- </div>`;
107
  }
108
 
109
  renderSidebar();
@@ -113,30 +113,31 @@ function renderSidebar() {
113
  const list = $('history-list');
114
  if(!list) return;
115
 
116
- const ids = Object.keys(sessions).sort((a, b) => 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
- list.innerHTML = ids.map(id => {
 
123
  const isActive = (id === currentSid);
124
  const activeClass = isActive ? 'bg-accent-light text-accent font-semibold' : 'text-gray-600 hover:bg-white hover:shadow-sm font-medium';
125
- return `
126
- <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}">
127
- <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>
128
- <span class="truncate">${sessions[id].title || "New Chat"}</span>
129
- </button>`;
130
- }).join('');
131
  }
132
 
133
  function appendMessage(role, content, displayContent) {
134
  if(!sessions[currentSid]) newSession();
135
  if(!sessions[currentSid].messages) sessions[currentSid].messages = [];
136
 
137
- sessions[currentSid].messages.push({ role, content, displayContent });
138
 
139
- if (role === "user" && sessions[currentSid].messages.filter(m => m.role === "user").length === 1) {
 
 
140
  let snippet = String(displayContent).replace(/<[^>]*>?/gm, '');
141
  snippet = snippet.split(" ").slice(0, 5).join(" ") + "…";
142
  sessions[currentSid].title = snippet;
@@ -154,12 +155,13 @@ window.loadSession = function(id) {
154
  // 5. Chat UI & Render
155
  // ─────────────────────────────────────
156
  function scrollBottom() {
157
- if(chatMessages) chatMessages.scrollTop = chatMessages.scrollHeight;
 
158
  }
159
 
160
  function simpleMarkdown(text) {
161
  let h = String(text || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
162
- h = h.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, c) => `<pre><code>${c.trim()}</code></pre>`);
163
  h = h.replace(/`([^`\n]+)`/g, "<code>$1</code>");
164
  h = h.replace(/\*\*\*(.+?)\*\*\*/g, "<strong><em>$1</em></strong>");
165
  h = h.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
@@ -168,18 +170,18 @@ function simpleMarkdown(text) {
168
  h = h.replace(/^## (.+)$/gm, "<h2>$1</h2>");
169
  h = h.replace(/^# (.+)$/gm, "<h1>$1</h1>");
170
  h = h.replace(/^\s*[-*] (.+)/gm, "<li>$1</li>");
171
- h = h.split(/\n{2,}/).map(p => /^<(h[1-3]|ul|li|pre|blockquote)/.test(p.trim()) ? p : `<p>${p.replace(/\n/g, "<br>")}</p>`).join("\n");
172
  return h;
173
  }
174
 
175
- function renderBubble(role, content, animate = true) {
176
- const isUser = role === "user";
 
177
  const wrap = document.createElement("div");
178
- wrap.className = `flex ${isUser ? "justify-end" : "justify-start"} mb-6 ${animate ? " " + (isUser ? "bubble-user" : "bubble-ai") : ""}`;
179
 
180
- let innerHTML = "";
181
  if (isUser) {
182
- 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>`;
183
  } else {
184
  let parsedText = content;
185
  try {
@@ -187,23 +189,21 @@ function renderBubble(role, content, animate = true) {
187
  } catch (e) {
188
  parsedText = simpleMarkdown(content);
189
  }
190
- innerHTML = `
191
- <div class="flex gap-4 items-start w-full">
192
- <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'">
193
- <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>
194
- </div>`;
195
  }
196
 
197
- wrap.innerHTML = innerHTML;
198
- if(chatMessages) chatMessages.appendChild(wrap);
199
  if(animate) scrollBottom();
200
  }
201
 
202
- function renderSys(text, isErr = false) {
203
  const wrap = document.createElement("div");
204
- wrap.className = `flex justify-center mb-4`;
205
- wrap.innerHTML = `<span class="text-[11px] px-3 py-1.5 rounded-full ${isErr ? "bg-red-50 text-red-500" : "bg-gray-100 text-gray-500"}">${text}</span>`;
206
- if(chatMessages) chatMessages.appendChild(wrap);
 
 
207
  scrollBottom();
208
  }
209
 
@@ -213,30 +213,31 @@ function renderSys(text, isErr = false) {
213
  function setBusy(busy) {
214
  isBusy = busy;
215
  if($('btn-send')) $('btn-send').disabled = busy;
216
- if(chatTextarea) chatTextarea.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 raw = chatTextarea ? chatTextarea.value.trim() : "";
 
224
  if (!raw && !pdfText) return;
225
 
226
  let fullPrompt = raw;
227
- let displayPrompt = raw.replace(/\n/g, '<br>');
 
228
 
229
  if (pdfText) {
230
- fullPrompt = `[Attached Document: ${pdfFilename}]\n${pdfText}\n\nUser Question: ${raw}`;
231
- 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>${raw.replace(/\n/g, '<br>')}`;
232
  clearAttachment();
233
  }
234
 
235
- if(chatTextarea) { chatTextarea.value = ""; chatTextarea.style.height = "auto"; }
236
  if($("welcome-msg")) $("welcome-msg").remove();
237
 
238
- // Safety check session
239
- if (!sessions[currentSid] || !sessions[currentSid].messages) {
240
  newSession();
241
  }
242
 
@@ -245,16 +246,20 @@ async function sendChat() {
245
  appendMessage("user", fullPrompt, displayPrompt);
246
 
247
  const loadId = 'load-' + Date.now();
248
- if(chatMessages) {
249
- chatMessages.innerHTML += `<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>`;
 
250
  scrollBottom();
251
  }
252
 
253
  try {
 
 
 
254
  const payload = {
255
  prompt: fullPrompt,
256
- model: $("model-select") ? $("model-select").value : (appSettings?.current_model || ""),
257
- messages: sessions[currentSid].messages.slice(0, -1) // Aman karena udah di-check di atas
258
  };
259
 
260
  const res = await fetch("/api/chat", {
@@ -263,6 +268,7 @@ async function sendChat() {
263
  body: JSON.stringify(payload),
264
  });
265
  const data = await res.json();
 
266
  if($(loadId)) $(loadId).remove();
267
 
268
  if (!res.ok || data.error) {
@@ -273,19 +279,19 @@ async function sendChat() {
273
  }
274
  } catch (err) {
275
  if($(loadId)) $(loadId).remove();
276
- renderSys(`Network error: ${err.message}`, true);
277
  } finally {
278
  setBusy(false);
279
- if(chatTextarea) chatTextarea.focus();
280
  }
281
  }
282
 
283
- if(chatTextarea) {
284
- chatTextarea.addEventListener("input", function() {
285
  this.style.height = "auto";
286
  this.style.height = Math.min(this.scrollHeight, 160) + "px";
287
  });
288
- chatTextarea.addEventListener("keydown", e => {
289
  if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendChat(); }
290
  });
291
  }
@@ -309,10 +315,10 @@ function populateModelSelect() {
309
  list = ["Default Model"];
310
  }
311
 
312
- list.forEach(m => {
313
  const opt = document.createElement("option");
314
  opt.value = m;
315
- opt.textContent = m.length > 30 ? m.slice(0, 28) + "…" : m;
316
  if (appSettings && m === appSettings.current_model) opt.selected = true;
317
  modelSelect.appendChild(opt);
318
  });
@@ -324,16 +330,17 @@ function renderModelListSetting(containerId, models, provider) {
324
  const list = $(containerId);
325
  if(!list) return;
326
  list.innerHTML = "";
327
- (models || []).forEach((m, i) => {
328
  const row = document.createElement("div");
329
  row.className = "flex items-center justify-between px-2 py-1.5 rounded-lg hover:bg-white group transition-colors";
330
- 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>`;
331
  list.appendChild(row);
332
  });
333
- list.querySelectorAll(".btn-del-model").forEach(btn => {
334
- btn.addEventListener("click", () => {
335
- const idx = Number(btn.dataset.i);
336
- if (btn.dataset.prov === "OpenRouter") {
 
337
  appSettings.models_openrouter.splice(idx, 1);
338
  renderModelListSetting("models-list-openrouter", appSettings.models_openrouter, "OpenRouter");
339
  } else {
@@ -341,7 +348,7 @@ function renderModelListSetting(containerId, models, provider) {
341
  renderModelListSetting("models-list-nvidia", appSettings.models_nvidia, "Nvidia NIM");
342
  }
343
  });
344
- });
345
  }
346
 
347
  function openSettings() {
@@ -363,7 +370,7 @@ addEvt("btn-open-settings", "click", openSettings);
363
  addEvt("btn-close-settings", "click", closeSettings);
364
  addEvt("btn-cancel-settings", "click", closeSettings);
365
 
366
- addEvt("settings-provider", "change", () => {
367
  const prov = $("settings-provider").value;
368
  const urls = {
369
  "OpenRouter": "https://openrouter.ai/api/v1/chat/completions",
@@ -374,32 +381,37 @@ addEvt("settings-provider", "change", () => {
374
  if (urls[prov] && $("settings-url")) $("settings-url").value = urls[prov];
375
  });
376
 
377
- addEvt("btn-toggle-key", "click", () => {
378
  const inp = $("settings-apikey");
379
  if(inp) inp.type = inp.type === "password" ? "text" : "password";
380
  });
381
 
382
  function attachAddBtn(inputId, listKey, containerId, provLabel, btnId) {
383
- const doAdd = () => {
384
- const val = $(inputId)?.value.trim();
 
385
  if (!val || !appSettings) return;
386
  if (!appSettings[listKey].includes(val)) {
387
  appSettings[listKey].push(val);
388
- $(inputId).value = "";
389
  renderModelListSetting(containerId, appSettings[listKey], provLabel);
390
  }
391
  };
392
  addEvt(btnId, "click", doAdd);
393
- if($(inputId)) $(inputId).addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); doAdd(); } });
394
  }
395
  attachAddBtn("new-model-or", "models_openrouter", "models-list-openrouter", "OpenRouter", "btn-add-model-or");
396
  attachAddBtn("new-model-nv", "models_nvidia", "models-list-nvidia", "Nvidia NIM", "btn-add-model-nv");
397
 
398
- addEvt("btn-save-settings", "click", async () => {
 
 
 
 
399
  const payload = {
400
- provider: $("settings-provider")?.value,
401
- base_url: $("settings-url")?.value.trim(),
402
- api_key: $("settings-apikey")?.value.trim(),
403
  models_openrouter: appSettings.models_openrouter,
404
  models_nvidia: appSettings.models_nvidia,
405
  };
@@ -410,24 +422,25 @@ addEvt("btn-save-settings", "click", async () => {
410
  closeSettings();
411
  renderSys("Settings saved.");
412
  } catch (err) {
413
- renderSys(`Failed to save: ${err.message}`, true);
414
  }
415
  });
416
 
417
  // ─────────────────────────────────────
418
  // 8. Modals DOI
419
  // ─────────────────────────────────────
420
- addEvt("btn-open-doi", "click", () => {
421
  if($("doi-modal")) $("doi-modal").classList.remove("hidden");
422
  if($("doi-input")) { $("doi-input").value = ""; $("doi-input").focus(); }
423
  if($("doi-result")) $("doi-result").classList.add("hidden");
424
  if(window.innerWidth < 768) toggleSidebar();
425
  });
426
- addEvt("btn-close-doi", "click", () => { if($("doi-modal")) $("doi-modal").classList.add("hidden"); });
427
- addEvt("doi-input", "keydown", e => { if (e.key === "Enter") { $("btn-validate-doi-submit").click(); } });
428
 
429
- addEvt("btn-validate-doi-submit", "click", async () => {
430
- const doi = $("doi-input")?.value.trim();
 
431
  const resDiv = $("doi-result");
432
  if (!doi || !resDiv) return;
433
 
@@ -435,24 +448,18 @@ addEvt("btn-validate-doi-submit", "click", async () => {
435
  if($("btn-validate-doi-submit")) $("btn-validate-doi-submit").disabled = true;
436
 
437
  resDiv.classList.remove("hidden");
438
- 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>`;
439
 
440
  try {
441
- const res = await fetch("/api/validate_doi", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doi }) });
442
  const data = await res.json();
443
  if (!res.ok || data.error) {
444
- resDiv.innerHTML = `<p class="text-red-500 font-medium">Error: ${data.error || "Failed"}</p>`;
445
  } else {
446
- resDiv.innerHTML = `
447
- <div class="space-y-2">
448
- <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>
449
- <div><p class="text-[10px] font-semibold text-gray-400 uppercase">Authors</p><p class="text-sm text-gray-700">${data.authors}</p></div>
450
- <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>
451
- <div><p class="text-[10px] font-semibold text-gray-400 uppercase">Source</p><p class="text-sm text-gray-700">${data.journal}</p></div>
452
- </div>`;
453
  }
454
  } catch (err) {
455
- resDiv.innerHTML = `<p class="text-red-500">${err.message}</p>`;
456
  } finally {
457
  if($("doi-spinner")) $("doi-spinner").classList.add("hidden");
458
  if($("btn-validate-doi-submit")) $("btn-validate-doi-submit").disabled = false;
@@ -485,9 +492,9 @@ function handleClear() {
485
  sessions[currentSid].messages = [];
486
  sessions[currentSid].title = "New Chat";
487
  saveSessions();
488
- if(chatMessages) chatMessages.innerHTML = "";
489
  if($("welcome-screen")) $("welcome-screen").style.display = "flex";
490
- if(chatTitle) chatTitle.textContent = "New Chat";
491
  renderSidebar();
492
  }
493
  addEvt("btn-clear-chat-top", "click", handleClear);
@@ -498,10 +505,10 @@ function clearAttachment() {
498
  if($("attach-badge")) $("attach-badge").classList.add("hidden");
499
  }
500
  addEvt("btn-remove-attach", "click", clearAttachment);
501
- addEvt("btn-attach", "click", () => { if($("pdf-input")) $("pdf-input").click(); });
502
 
503
  if($("pdf-input")) {
504
- $("pdf-input").addEventListener("change", async e => {
505
  const file = e.target.files[0];
506
  if (!file) return;
507
  e.target.value = "";
@@ -515,9 +522,9 @@ if($("pdf-input")) {
515
  if (!res.ok || data.error) throw new Error(data.error);
516
  pdfText = data.text;
517
  pdfFilename = data.filename;
518
- if($("attach-name")) $("attach-name").textContent = `${data.filename} Β· ${data.word_count} words`;
519
  } catch (err) {
520
- alert(`Upload failed: ${err.message}`);
521
  clearAttachment();
522
  }
523
  });
@@ -529,7 +536,7 @@ if($("pdf-input")) {
529
  initApp();
530
 
531
  if ('serviceWorker' in navigator) {
532
- window.addEventListener('load', () => {
533
- navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(e => console.error(e));
534
  });
535
  }
 
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
 
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 {
 
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() {
 
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();
 
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;
 
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>");
 
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 {
 
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
 
 
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
 
 
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", {
 
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) {
 
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
  }
 
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
  });
 
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 {
 
348
  renderModelListSetting("models-list-nvidia", appSettings.models_nvidia, "Nvidia NIM");
349
  }
350
  });
351
+ }
352
  }
353
 
354
  function 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",
 
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
  };
 
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
 
 
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;
 
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);
 
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 = "";
 
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
  });
 
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
  }