xenux4u commited on
Commit
7088e35
Β·
verified Β·
1 Parent(s): 6f35a9b

Update static/js/script.js

Browse files
Files changed (1) hide show
  1. static/js/script.js +144 -142
static/js/script.js CHANGED
@@ -25,8 +25,6 @@ let isBusy = false;
25
 
26
  const chatMessages = $("chat-messages");
27
  const chatTextarea = $("chat-textarea");
28
- const welcomeScreen = $("welcome-screen");
29
- const chatTitle = $("chat-title");
30
 
31
  // ─────────────────────────────────────
32
  // 3. Boot & Init
@@ -48,19 +46,19 @@ async function initApp() {
48
  const setRes = await fetch('/api/settings');
49
  if (setRes.ok) {
50
  appSettings = await setRes.json();
51
- populateModelDropdown(appSettings);
52
  }
 
 
 
 
 
53
 
54
- // Load Sesi Chat
55
  const ids = Object.keys(sessions).sort((a, b) => Number(b) - Number(a));
56
  if (ids.length > 0) {
57
  loadSession(ids[0]);
58
  } else {
59
  newSession();
60
  }
61
-
62
- } catch (error) {
63
- console.error("Init Error:", error);
64
  }
65
  }
66
 
@@ -80,18 +78,22 @@ function newSession() {
80
  function loadSession(id) {
81
  if (!sessions[id]) return;
82
  currentSid = id;
83
- chatMessages.innerHTML = '';
84
 
85
- const msgs = sessions[id].messages;
86
  if (msgs.length > 0) {
87
- if(welcomeScreen) welcomeScreen.style.display = "none";
88
  msgs.forEach(m => renderBubble(m.role, m.displayContent || m.content, false));
89
  scrollBottom();
90
  } else {
91
- if(welcomeScreen) welcomeScreen.style.display = "flex";
 
 
 
 
 
92
  }
93
 
94
- if(chatTitle) chatTitle.textContent = sessions[id].title;
95
  renderSidebar();
96
  }
97
 
@@ -109,39 +111,39 @@ function renderSidebar() {
109
  const isActive = (id === currentSid);
110
  const activeClass = isActive ? 'bg-accent-light text-accent font-semibold' : 'text-gray-600 hover:bg-white hover:shadow-sm font-medium';
111
  return `
112
- <button onclick="loadSession('${id}')" class="sidebar-item w-full text-left px-3 py-2 rounded-xl text-xs truncate transition-all ${activeClass}">
113
- ${sessions[id].title}
 
114
  </button>`;
115
  }).join('');
116
  }
117
 
118
  function appendMessage(role, content, displayContent) {
 
119
  sessions[currentSid].messages.push({ role, content, displayContent });
120
 
121
- // Auto-buat judul dari pesan pertama
122
  if (role === "user" && sessions[currentSid].messages.filter(m => m.role === "user").length === 1) {
123
- let snippet = displayContent.replace(/<[^>]*>?/gm, ''); // Hapus HTML tags
124
  snippet = snippet.split(" ").slice(0, 5).join(" ") + "…";
125
  sessions[currentSid].title = snippet;
126
- if(chatTitle) chatTitle.textContent = snippet;
127
  renderSidebar();
128
  }
129
  saveSessions();
130
  }
131
 
132
- // Global function biar bisa dipanggil dari onClick HTML
133
  window.loadSession = function(id) {
134
  loadSession(id);
135
  if(window.innerWidth < 768) toggleSidebar();
136
  };
137
 
138
  // ─────────────────────────────────────
139
- // 5. Chat UI (Render Markdown & Bubble)
140
  // ─────────────────────────────────────
141
  function scrollBottom() {
142
- chatMessages.scrollTop = chatMessages.scrollHeight;
143
  }
144
 
 
145
  function simpleMarkdown(text) {
146
  let h = String(text || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
147
  h = h.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, c) => `<pre><code>${c.trim()}</code></pre>`);
@@ -152,9 +154,7 @@ function simpleMarkdown(text) {
152
  h = h.replace(/^### (.+)$/gm, "<h3>$1</h3>");
153
  h = h.replace(/^## (.+)$/gm, "<h2>$1</h2>");
154
  h = h.replace(/^# (.+)$/gm, "<h1>$1</h1>");
155
- h = h.replace(/^> (.+)$/gm, "<blockquote>$1</blockquote>");
156
  h = h.replace(/^\s*[-*] (.+)/gm, "<li>$1</li>");
157
- h = h.replace(/(<li>.*<\/li>\n?)+/g, m => `<ul>${m}</ul>`);
158
  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");
159
  return h;
160
  }
@@ -162,49 +162,35 @@ function simpleMarkdown(text) {
162
  function renderBubble(role, content, animate = true) {
163
  const isUser = role === "user";
164
  const wrap = document.createElement("div");
165
- wrap.className = `flex ${isUser ? "justify-end" : "justify-start"} px-4 md:px-16 py-2${animate ? " " + (isUser ? "bubble-user" : "bubble-ai") : ""}`;
166
-
167
- const col = document.createElement("div");
168
- col.className = isUser ? "flex flex-col items-end" : "flex flex-col items-start";
169
-
170
- const label = document.createElement("p");
171
- label.className = `text-[10px] font-semibold mb-1 ${isUser ? "text-accent text-right" : "text-gray-400"}`;
172
- label.textContent = isUser ? "You" : "ORBIT";
173
-
174
- const bub = document.createElement("div");
175
- bub.className = isUser
176
- ? "max-w-xl bg-accent-light text-gray-800 rounded-2xl rounded-br-sm px-4 py-2.5 text-sm leading-relaxed"
177
- : "max-w-2xl bg-[#F8F9FA] text-gray-800 rounded-2xl rounded-bl-sm px-4 py-2.5 text-sm prose-orbit";
178
 
179
- if (isUser) bub.innerHTML = content; // Mengizinkan tag dokumen PDF
180
- else bub.innerHTML = simpleMarkdown(content);
181
-
182
- col.appendChild(label);
183
- col.appendChild(bub);
184
- wrap.appendChild(col);
185
- chatMessages.appendChild(wrap);
186
- if (animate) scrollBottom();
187
- }
188
-
189
- function renderTyping() {
190
- const w = document.createElement("div");
191
- w.id = "typing-indicator";
192
- w.className = "flex justify-start px-4 md:px-16 py-2 bubble-ai";
193
- w.innerHTML = `<div class="bg-[#F8F9FA] rounded-2xl rounded-bl-sm px-4 py-3 flex items-center gap-1.5"><span class="typing-dot w-2 h-2 rounded-full bg-gray-400 inline-block"></span><span class="typing-dot w-2 h-2 rounded-full bg-gray-400 inline-block"></span><span class="typing-dot w-2 h-2 rounded-full bg-gray-400 inline-block"></span></div>`;
194
- chatMessages.appendChild(w);
195
- scrollBottom();
196
- }
197
 
198
- function removeTyping() {
199
- const e = $("typing-indicator");
200
- if (e) e.remove();
201
  }
202
 
203
  function renderSys(text, isErr = false) {
204
- const d = document.createElement("div");
205
- d.className = "flex justify-center px-4 py-2";
206
- d.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>`;
207
- chatMessages.appendChild(d);
208
  scrollBottom();
209
  }
210
 
@@ -221,7 +207,7 @@ function setBusy(busy) {
221
 
222
  async function sendChat() {
223
  if (isBusy) return;
224
- const raw = chatTextarea.value.trim();
225
  if (!raw && !pdfText) return;
226
 
227
  let fullPrompt = raw;
@@ -229,17 +215,22 @@ async function sendChat() {
229
 
230
  if (pdfText) {
231
  fullPrompt = `[Attached Document: ${pdfFilename}]\n${pdfText}\n\nUser Question: ${raw}`;
232
- displayPrompt = `<div class="bg-blue-500 text-white text-xs px-2 py-1 rounded w-fit mb-1 font-bold">πŸ“„ ${pdfFilename}</div>${raw.replace(/\n/g, '<br>')}`;
233
  clearAttachment();
234
  }
235
 
236
  if(chatTextarea) { chatTextarea.value = ""; chatTextarea.style.height = "auto"; }
237
- if(welcomeScreen) welcomeScreen.style.display = "none";
 
238
  setBusy(true);
239
-
240
  renderBubble("user", displayPrompt);
241
  appendMessage("user", fullPrompt, displayPrompt);
242
- renderTyping();
 
 
 
 
 
243
 
244
  try {
245
  const payload = {
@@ -254,7 +245,7 @@ async function sendChat() {
254
  body: JSON.stringify(payload),
255
  });
256
  const data = await res.json();
257
- removeTyping();
258
 
259
  if (!res.ok || data.error) {
260
  renderSys(data.error || "Unknown error.", true);
@@ -263,7 +254,7 @@ async function sendChat() {
263
  appendMessage("assistant", data.reply, data.reply);
264
  }
265
  } catch (err) {
266
- removeTyping();
267
  renderSys(`Network error: ${err.message}`, true);
268
  } finally {
269
  setBusy(false);
@@ -283,9 +274,35 @@ if(chatTextarea) {
283
  addEvt("btn-send", "click", sendChat);
284
 
285
  // ─────────────────────────────────────
286
- // 7. Modals (Settings & DOI)
287
  // ─────────────────────────────────────
288
- function renderModelList(containerId, models, provider) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  const list = $(containerId);
290
  if(!list) return;
291
  list.innerHTML = "";
@@ -300,10 +317,10 @@ function renderModelList(containerId, models, provider) {
300
  const idx = Number(btn.dataset.i);
301
  if (btn.dataset.prov === "OpenRouter") {
302
  appSettings.models_openrouter.splice(idx, 1);
303
- renderModelList("models-list-openrouter", appSettings.models_openrouter, "OpenRouter");
304
  } else {
305
  appSettings.models_nvidia.splice(idx, 1);
306
- renderModelList("models-list-nvidia", appSettings.models_nvidia, "Nvidia NIM");
307
  }
308
  });
309
  });
@@ -314,8 +331,8 @@ function openSettings() {
314
  if($("settings-provider")) $("settings-provider").value = appSettings.provider;
315
  if($("settings-url")) $("settings-url").value = appSettings.base_url || "";
316
  if($("settings-apikey")) $("settings-apikey").value = appSettings.api_key || "";
317
- renderModelList("models-list-openrouter", appSettings.models_openrouter, "OpenRouter");
318
- renderModelList("models-list-nvidia", appSettings.models_nvidia, "Nvidia NIM");
319
  if($("settings-modal")) $("settings-modal").classList.remove("hidden");
320
  if(window.innerWidth < 768) toggleSidebar();
321
  }
@@ -324,7 +341,8 @@ function closeSettings() {
324
  if($("settings-modal")) $("settings-modal").classList.add("hidden");
325
  }
326
 
327
- addEvt("btn-settings", "click", openSettings); // INI ID YANG BENAR DI HTML LU
 
328
  addEvt("btn-close-settings", "click", closeSettings);
329
  addEvt("btn-cancel-settings", "click", closeSettings);
330
 
@@ -344,21 +362,21 @@ addEvt("btn-toggle-key", "click", () => {
344
  if(inp) inp.type = inp.type === "password" ? "text" : "password";
345
  });
346
 
347
- function addModelHandler(inputId, listKey, containerId, provLabel, btnId) {
348
  const doAdd = () => {
349
  const val = $(inputId)?.value.trim();
350
  if (!val || !appSettings) return;
351
  if (!appSettings[listKey].includes(val)) {
352
  appSettings[listKey].push(val);
353
  $(inputId).value = "";
354
- renderModelList(containerId, appSettings[listKey], provLabel);
355
  }
356
  };
357
  addEvt(btnId, "click", doAdd);
358
- addEvt(inputId, "keydown", e => { if (e.key === "Enter") { e.preventDefault(); doAdd(); } });
359
  }
360
- addModelHandler("new-model-or", "models_openrouter", "models-list-openrouter", "OpenRouter", "btn-add-model-or");
361
- addModelHandler("new-model-nv", "models_nvidia", "models-list-nvidia", "Nvidia NIM", "btn-add-model-nv");
362
 
363
  addEvt("btn-save-settings", "click", async () => {
364
  const payload = {
@@ -371,60 +389,61 @@ addEvt("btn-save-settings", "click", async () => {
371
  try {
372
  const res = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
373
  appSettings = await res.json();
374
- populateModelDropdown(appSettings);
375
  closeSettings();
376
- renderSys("Settings saved.");
377
  } catch (err) {
378
- renderSys(`Failed to save: ${err.message}`, true);
379
  }
380
  });
381
 
382
- // DOI LOGIC
383
- addEvt("btn-doi", "click", () => { // INI ID YANG BENAR DI HTML LU
 
 
384
  if($("doi-modal")) $("doi-modal").classList.remove("hidden");
385
- if($("doi-input")) $("doi-input").focus();
 
386
  if(window.innerWidth < 768) toggleSidebar();
387
  });
388
  addEvt("btn-close-doi", "click", () => { if($("doi-modal")) $("doi-modal").classList.add("hidden"); });
389
- addEvt("doi-input", "keydown", e => { if (e.key === "Enter") { $("btn-validate-doi").click(); } });
390
 
391
- addEvt("btn-validate-doi", "click", async () => {
392
  const doi = $("doi-input")?.value.trim();
393
- if (!doi) return;
 
394
 
395
  if($("doi-spinner")) $("doi-spinner").classList.remove("hidden");
396
- if($("doi-btn-text")) $("doi-btn-text").textContent = "Validating…";
397
- if($("btn-validate-doi")) $("btn-validate-doi").disabled = true;
398
- if($("doi-result")) $("doi-result").classList.add("hidden");
 
399
 
400
  try {
401
  const res = await fetch("/api/validate_doi", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doi }) });
402
  const data = await res.json();
403
- if($("doi-result")) {
404
- $("doi-result").classList.remove("hidden");
405
- if (!res.ok || data.error) {
406
- $("doi-result").innerHTML = `<p class="text-red-500">${data.error}</p>`;
407
- } else {
408
- $("doi-result").innerHTML = `
409
- <div class="space-y-2">
410
- <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>
411
- <div><p class="text-[10px] font-semibold text-gray-400 uppercase">Authors</p><p class="text-sm text-gray-700">${data.authors}</p></div>
412
- <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>
413
- <div><p class="text-[10px] font-semibold text-gray-400 uppercase">Source</p><p class="text-sm text-gray-700">${data.journal}</p></div>
414
- </div>`;
415
- }
416
  }
417
  } catch (err) {
418
- if($("doi-result")) { $("doi-result").classList.remove("hidden"); $("doi-result").innerHTML = `<p class="text-red-500">${err.message}</p>`; }
419
  } finally {
420
  if($("doi-spinner")) $("doi-spinner").classList.add("hidden");
421
- if($("doi-btn-text")) $("doi-btn-text").textContent = "Validate";
422
- if($("btn-validate-doi")) $("btn-validate-doi").disabled = false;
423
  }
424
  });
425
 
426
  // ─────────────────────────────────────
427
- // 8. Sidebar & PDF Logic
428
  // ─────────────────────────────────────
429
  function toggleSidebar() {
430
  const sidebar = $("sidebar");
@@ -439,6 +458,7 @@ function toggleSidebar() {
439
 
440
  addEvt("btn-toggle-sidebar", "click", toggleSidebar);
441
  addEvt("btn-hamburger", "click", toggleSidebar);
 
442
  addEvt("sidebar-overlay", "click", toggleSidebar);
443
 
444
  addEvt("btn-new-chat", "click", newSession);
@@ -448,13 +468,12 @@ function handleClear() {
448
  sessions[currentSid].messages = [];
449
  sessions[currentSid].title = "New Chat";
450
  saveSessions();
451
- chatMessages.innerHTML = "";
452
- if(welcomeScreen) welcomeScreen.style.display = "flex";
453
  if(chatTitle) chatTitle.textContent = "New Chat";
454
  renderSidebar();
455
  }
456
- addEvt("btn-clear-chat", "click", handleClear);
457
- addEvt("btn-clear-chat-mobile", "click", handleClear);
458
 
459
  function clearAttachment() {
460
  pdfText = null; pdfFilename = null;
@@ -471,46 +490,29 @@ if($("pdf-input")) {
471
  e.target.value = "";
472
  const fd = new FormData();
473
  fd.append("file", file);
474
- renderSys(`Uploading "${file.name}"…`);
 
475
  try {
476
  const res = await fetch("/api/upload_pdf", { method: "POST", body: fd });
477
  const data = await res.json();
478
- if (!res.ok || data.error) { renderSys(data.error, true); return; }
479
  pdfText = data.text;
480
  pdfFilename = data.filename;
481
  if($("attach-name")) $("attach-name").textContent = `${data.filename} Β· ${data.word_count} words`;
482
- if($("attach-badge")) $("attach-badge").classList.remove("hidden");
483
- renderSys(`"${data.filename}" attached.`);
484
  } catch (err) {
485
- renderSys(`Upload failed: ${err.message}`, true);
 
486
  }
487
  });
488
  }
489
 
490
- function populateModelSelect() {
491
- const list = appSettings.provider === "Nvidia NIM" ? (appSettings.models_nvidia || []) : (appSettings.models_openrouter || []);
492
- const modelSelect = $("model-select");
493
- if(!modelSelect) return;
494
- modelSelect.innerHTML = "";
495
- list.forEach(m => {
496
- const opt = document.createElement("option");
497
- opt.value = m;
498
- opt.textContent = m.length > 30 ? m.slice(0, 28) + "…" : m;
499
- if (m === appSettings.current_model) opt.selected = true;
500
- modelSelect.appendChild(opt);
501
- });
502
- if (modelSelect.options.length && !modelSelect.value) modelSelect.selectedIndex = 0;
503
- }
504
-
505
- if($("model-select")) {
506
- $("model-select").addEventListener("change", async () => {
507
- if (!appSettings) return;
508
- appSettings.current_model = $("model-select").value;
509
- try { await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ current_model: $("model-select").value }) }); } catch (_) {}
510
- });
511
- }
512
-
513
  // ─────────────────────────────────────
514
- // 9. Go!
515
  // ─────────────────────────────────────
516
- initApp();
 
 
 
 
 
 
 
25
 
26
  const chatMessages = $("chat-messages");
27
  const chatTextarea = $("chat-textarea");
 
 
28
 
29
  // ─────────────────────────────────────
30
  // 3. Boot & Init
 
46
  const setRes = await fetch('/api/settings');
47
  if (setRes.ok) {
48
  appSettings = await setRes.json();
 
49
  }
50
+ } catch (error) {
51
+ console.error("Init Error:", error);
52
+ } finally {
53
+ // Harus selalu dijalankan meskipun gagal fetch
54
+ populateModelSelect();
55
 
 
56
  const ids = Object.keys(sessions).sort((a, b) => Number(b) - Number(a));
57
  if (ids.length > 0) {
58
  loadSession(ids[0]);
59
  } else {
60
  newSession();
61
  }
 
 
 
62
  }
63
  }
64
 
 
78
  function loadSession(id) {
79
  if (!sessions[id]) return;
80
  currentSid = id;
81
+ if(chatMessages) chatMessages.innerHTML = '';
82
 
83
+ const msgs = sessions[id].messages || [];
84
  if (msgs.length > 0) {
85
+ if($("welcome-msg")) $("welcome-msg").remove();
86
  msgs.forEach(m => renderBubble(m.role, m.displayContent || m.content, false));
87
  scrollBottom();
88
  } else {
89
+ if(chatMessages) chatMessages.innerHTML = `
90
+ <div id="welcome-msg" class="flex flex-col items-center justify-center h-full text-center px-4 py-10">
91
+ <img src="/static/icon.png" class="w-16 h-16 mb-4 shadow-sm rounded-2xl" onerror="this.style.display='none'">
92
+ <h2 class="text-2xl font-bold text-gray-800 mb-2">Welcome to ORBIT</h2>
93
+ <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>
94
+ </div>`;
95
  }
96
 
 
97
  renderSidebar();
98
  }
99
 
 
111
  const isActive = (id === currentSid);
112
  const activeClass = isActive ? 'bg-accent-light text-accent font-semibold' : 'text-gray-600 hover:bg-white hover:shadow-sm font-medium';
113
  return `
114
+ <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}">
115
+ <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>
116
+ <span class="truncate">${sessions[id].title}</span>
117
  </button>`;
118
  }).join('');
119
  }
120
 
121
  function appendMessage(role, content, displayContent) {
122
+ if(!sessions[currentSid]) return;
123
  sessions[currentSid].messages.push({ role, content, displayContent });
124
 
 
125
  if (role === "user" && sessions[currentSid].messages.filter(m => m.role === "user").length === 1) {
126
+ let snippet = String(displayContent).replace(/<[^>]*>?/gm, '');
127
  snippet = snippet.split(" ").slice(0, 5).join(" ") + "…";
128
  sessions[currentSid].title = snippet;
 
129
  renderSidebar();
130
  }
131
  saveSessions();
132
  }
133
 
 
134
  window.loadSession = function(id) {
135
  loadSession(id);
136
  if(window.innerWidth < 768) toggleSidebar();
137
  };
138
 
139
  // ─────────────────────────────────────
140
+ // 5. Chat UI & Render
141
  // ─────────────────────────────────────
142
  function scrollBottom() {
143
+ if(chatMessages) chatMessages.scrollTop = chatMessages.scrollHeight;
144
  }
145
 
146
+ // Fallback Markdown jika Marked.js gagal load
147
  function simpleMarkdown(text) {
148
  let h = String(text || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
149
  h = h.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, c) => `<pre><code>${c.trim()}</code></pre>`);
 
154
  h = h.replace(/^### (.+)$/gm, "<h3>$1</h3>");
155
  h = h.replace(/^## (.+)$/gm, "<h2>$1</h2>");
156
  h = h.replace(/^# (.+)$/gm, "<h1>$1</h1>");
 
157
  h = h.replace(/^\s*[-*] (.+)/gm, "<li>$1</li>");
 
158
  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");
159
  return h;
160
  }
 
162
  function renderBubble(role, content, animate = true) {
163
  const isUser = role === "user";
164
  const wrap = document.createElement("div");
165
+ wrap.className = `flex ${isUser ? "justify-end" : "justify-start"} mb-6 ${animate ? " " + (isUser ? "bubble-user" : "bubble-ai") : ""}`;
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
+ let innerHTML = "";
168
+ if (isUser) {
169
+ 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>`;
170
+ } else {
171
+ let parsedText = content;
172
+ try {
173
+ parsedText = (typeof marked !== 'undefined') ? marked.parse(content) : simpleMarkdown(content);
174
+ } catch (e) {
175
+ parsedText = simpleMarkdown(content);
176
+ }
177
+ innerHTML = `
178
+ <div class="flex gap-4 items-start w-full">
179
+ <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'">
180
+ <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>
181
+ </div>`;
182
+ }
 
 
183
 
184
+ wrap.innerHTML = innerHTML;
185
+ if(chatMessages) chatMessages.appendChild(wrap);
186
+ if(animate) scrollBottom();
187
  }
188
 
189
  function renderSys(text, isErr = false) {
190
+ const wrap = document.createElement("div");
191
+ wrap.className = `flex justify-center mb-4`;
192
+ 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>`;
193
+ if(chatMessages) chatMessages.appendChild(wrap);
194
  scrollBottom();
195
  }
196
 
 
207
 
208
  async function sendChat() {
209
  if (isBusy) return;
210
+ const raw = chatTextarea ? chatTextarea.value.trim() : "";
211
  if (!raw && !pdfText) return;
212
 
213
  let fullPrompt = raw;
 
215
 
216
  if (pdfText) {
217
  fullPrompt = `[Attached Document: ${pdfFilename}]\n${pdfText}\n\nUser Question: ${raw}`;
218
+ 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>')}`;
219
  clearAttachment();
220
  }
221
 
222
  if(chatTextarea) { chatTextarea.value = ""; chatTextarea.style.height = "auto"; }
223
+ if($("welcome-msg")) $("welcome-msg").remove();
224
+
225
  setBusy(true);
 
226
  renderBubble("user", displayPrompt);
227
  appendMessage("user", fullPrompt, displayPrompt);
228
+
229
+ const loadId = 'load-' + Date.now();
230
+ if(chatMessages) {
231
+ 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>`;
232
+ scrollBottom();
233
+ }
234
 
235
  try {
236
  const payload = {
 
245
  body: JSON.stringify(payload),
246
  });
247
  const data = await res.json();
248
+ if($(loadId)) $(loadId).remove();
249
 
250
  if (!res.ok || data.error) {
251
  renderSys(data.error || "Unknown error.", true);
 
254
  appendMessage("assistant", data.reply, data.reply);
255
  }
256
  } catch (err) {
257
+ if($(loadId)) $(loadId).remove();
258
  renderSys(`Network error: ${err.message}`, true);
259
  } finally {
260
  setBusy(false);
 
274
  addEvt("btn-send", "click", sendChat);
275
 
276
  // ─────────────────────────────────────
277
+ // 7. Modals & Settings
278
  // ─────────────────────────────────────
279
+ function populateModelSelect() {
280
+ const modelSelect = $("model-select");
281
+ if(!modelSelect) return;
282
+ modelSelect.innerHTML = "";
283
+
284
+ let list = [];
285
+ if(appSettings) {
286
+ if (appSettings.provider === "Google Gemini") list = ["gemini-1.5-pro-latest", "gemini-1.5-flash-latest"];
287
+ else if (appSettings.provider === "OpenRouter") list = appSettings.models_openrouter || [];
288
+ else if (appSettings.provider === "Nvidia NIM") list = appSettings.models_nvidia || [];
289
+ else list = [appSettings.current_model || "default-model"];
290
+ } else {
291
+ list = ["Default Model"];
292
+ }
293
+
294
+ list.forEach(m => {
295
+ const opt = document.createElement("option");
296
+ opt.value = m;
297
+ opt.textContent = m.length > 30 ? m.slice(0, 28) + "…" : m;
298
+ if (appSettings && m === appSettings.current_model) opt.selected = true;
299
+ modelSelect.appendChild(opt);
300
+ });
301
+
302
+ if (modelSelect.options.length && !modelSelect.value) modelSelect.selectedIndex = 0;
303
+ }
304
+
305
+ function renderModelListSetting(containerId, models, provider) {
306
  const list = $(containerId);
307
  if(!list) return;
308
  list.innerHTML = "";
 
317
  const idx = Number(btn.dataset.i);
318
  if (btn.dataset.prov === "OpenRouter") {
319
  appSettings.models_openrouter.splice(idx, 1);
320
+ renderModelListSetting("models-list-openrouter", appSettings.models_openrouter, "OpenRouter");
321
  } else {
322
  appSettings.models_nvidia.splice(idx, 1);
323
+ renderModelListSetting("models-list-nvidia", appSettings.models_nvidia, "Nvidia NIM");
324
  }
325
  });
326
  });
 
331
  if($("settings-provider")) $("settings-provider").value = appSettings.provider;
332
  if($("settings-url")) $("settings-url").value = appSettings.base_url || "";
333
  if($("settings-apikey")) $("settings-apikey").value = appSettings.api_key || "";
334
+ renderModelListSetting("models-list-openrouter", appSettings.models_openrouter, "OpenRouter");
335
+ renderModelListSetting("models-list-nvidia", appSettings.models_nvidia, "Nvidia NIM");
336
  if($("settings-modal")) $("settings-modal").classList.remove("hidden");
337
  if(window.innerWidth < 768) toggleSidebar();
338
  }
 
341
  if($("settings-modal")) $("settings-modal").classList.add("hidden");
342
  }
343
 
344
+ // Event Listeners Modal Settings
345
+ addEvt("btn-open-settings", "click", openSettings); // Sesuai dengan ID HTML terbaru
346
  addEvt("btn-close-settings", "click", closeSettings);
347
  addEvt("btn-cancel-settings", "click", closeSettings);
348
 
 
362
  if(inp) inp.type = inp.type === "password" ? "text" : "password";
363
  });
364
 
365
+ function attachAddBtn(inputId, listKey, containerId, provLabel, btnId) {
366
  const doAdd = () => {
367
  const val = $(inputId)?.value.trim();
368
  if (!val || !appSettings) return;
369
  if (!appSettings[listKey].includes(val)) {
370
  appSettings[listKey].push(val);
371
  $(inputId).value = "";
372
+ renderModelListSetting(containerId, appSettings[listKey], provLabel);
373
  }
374
  };
375
  addEvt(btnId, "click", doAdd);
376
+ if($(inputId)) $(inputId).addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); doAdd(); } });
377
  }
378
+ attachAddBtn("new-model-or", "models_openrouter", "models-list-openrouter", "OpenRouter", "btn-add-model-or");
379
+ attachAddBtn("new-model-nv", "models_nvidia", "models-list-nvidia", "Nvidia NIM", "btn-add-model-nv");
380
 
381
  addEvt("btn-save-settings", "click", async () => {
382
  const payload = {
 
389
  try {
390
  const res = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
391
  appSettings = await res.json();
392
+ populateModelSelect();
393
  closeSettings();
394
+ renderSys("Settings berhasil disimpan.");
395
  } catch (err) {
396
+ renderSys(`Gagal save setting: ${err.message}`, true);
397
  }
398
  });
399
 
400
+ // ─────────────────────────────────────
401
+ // 8. Modals DOI
402
+ // ─────────────────────────────────────
403
+ addEvt("btn-open-doi", "click", () => {
404
  if($("doi-modal")) $("doi-modal").classList.remove("hidden");
405
+ if($("doi-input")) { $("doi-input").value = ""; $("doi-input").focus(); }
406
+ if($("doi-result")) $("doi-result").classList.add("hidden");
407
  if(window.innerWidth < 768) toggleSidebar();
408
  });
409
  addEvt("btn-close-doi", "click", () => { if($("doi-modal")) $("doi-modal").classList.add("hidden"); });
410
+ addEvt("doi-input", "keydown", e => { if (e.key === "Enter") { $("btn-validate-doi-submit").click(); } });
411
 
412
+ addEvt("btn-validate-doi-submit", "click", async () => {
413
  const doi = $("doi-input")?.value.trim();
414
+ const resDiv = $("doi-result");
415
+ if (!doi || !resDiv) return;
416
 
417
  if($("doi-spinner")) $("doi-spinner").classList.remove("hidden");
418
+ if($("btn-validate-doi-submit")) $("btn-validate-doi-submit").disabled = true;
419
+
420
+ resDiv.classList.remove("hidden");
421
+ 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>`;
422
 
423
  try {
424
  const res = await fetch("/api/validate_doi", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doi }) });
425
  const data = await res.json();
426
+ if (!res.ok || data.error) {
427
+ resDiv.innerHTML = `<p class="text-red-500 font-medium">Error: ${data.error || "Gagal validasi"}</p>`;
428
+ } else {
429
+ resDiv.innerHTML = `
430
+ <div class="space-y-2">
431
+ <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>
432
+ <div><p class="text-[10px] font-semibold text-gray-400 uppercase">Authors</p><p class="text-sm text-gray-700">${data.authors}</p></div>
433
+ <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>
434
+ <div><p class="text-[10px] font-semibold text-gray-400 uppercase">Source</p><p class="text-sm text-gray-700">${data.journal}</p></div>
435
+ </div>`;
 
 
 
436
  }
437
  } catch (err) {
438
+ resDiv.innerHTML = `<p class="text-red-500">${err.message}</p>`;
439
  } finally {
440
  if($("doi-spinner")) $("doi-spinner").classList.add("hidden");
441
+ if($("btn-validate-doi-submit")) $("btn-validate-doi-submit").disabled = false;
 
442
  }
443
  });
444
 
445
  // ─────────────────────────────────────
446
+ // 9. Sidebar & PDF Logic
447
  // ─────────────────────────────────────
448
  function toggleSidebar() {
449
  const sidebar = $("sidebar");
 
458
 
459
  addEvt("btn-toggle-sidebar", "click", toggleSidebar);
460
  addEvt("btn-hamburger", "click", toggleSidebar);
461
+ addEvt("btn-close-sidebar", "click", toggleSidebar);
462
  addEvt("sidebar-overlay", "click", toggleSidebar);
463
 
464
  addEvt("btn-new-chat", "click", newSession);
 
468
  sessions[currentSid].messages = [];
469
  sessions[currentSid].title = "New Chat";
470
  saveSessions();
471
+ if(chatMessages) chatMessages.innerHTML = "";
472
+ if($("welcome-screen")) $("welcome-screen").style.display = "flex";
473
  if(chatTitle) chatTitle.textContent = "New Chat";
474
  renderSidebar();
475
  }
476
+ addEvt("btn-clear-chat-top", "click", handleClear);
 
477
 
478
  function clearAttachment() {
479
  pdfText = null; pdfFilename = null;
 
490
  e.target.value = "";
491
  const fd = new FormData();
492
  fd.append("file", file);
493
+ if($("attach-name")) $("attach-name").textContent = "Extracting text...";
494
+ if($("attach-badge")) $("attach-badge").classList.remove("hidden");
495
  try {
496
  const res = await fetch("/api/upload_pdf", { method: "POST", body: fd });
497
  const data = await res.json();
498
+ if (!res.ok || data.error) throw new Error(data.error);
499
  pdfText = data.text;
500
  pdfFilename = data.filename;
501
  if($("attach-name")) $("attach-name").textContent = `${data.filename} Β· ${data.word_count} words`;
 
 
502
  } catch (err) {
503
+ alert(`Upload failed: ${err.message}`);
504
+ clearAttachment();
505
  }
506
  });
507
  }
508
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
509
  // ─────────────────────────────────────
510
+ // 10. Start Application
511
  // ─────────────────────────────────────
512
+ initApp();
513
+
514
+ if ('serviceWorker' in navigator) {
515
+ window.addEventListener('load', () => {
516
+ navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(e => console.error(e));
517
+ });
518
+ }