File size: 27,898 Bytes
9c62054 40a2489 9c62054 5673e08 44fc334 9604a43 5673e08 21234f3 c7129d3 44fc334 e67e6d1 c7129d3 9b7d4c5 f0ad985 244c441 9b7d4c5 244c441 9b7d4c5 e67e6d1 66bdef6 eef5391 66bdef6 eef5391 ffdbd18 eef5391 e67e6d1 66bdef6 c7129d3 9b7d4c5 c7129d3 9604a43 44fc334 9604a43 5673e08 9604a43 9c62054 c7129d3 805bbe8 20a2068 44fc334 9604a43 5673e08 9c62054 5673e08 21234f3 9604a43 c397617 5673e08 44fc334 5673e08 9b7d4c5 9604a43 9c62054 5673e08 9b7d4c5 9604a43 66bdef6 9604a43 9b7d4c5 ffdbd18 9b7d4c5 ffdbd18 e67e6d1 9b7d4c5 9604a43 5673e08 40bd302 44fc334 c397617 7088e35 20a2068 9b7d4c5 9604a43 44fc334 c397617 5673e08 c7129d3 40bd302 9c62054 e67e6d1 66bdef6 9c62054 c7129d3 9c62054 44fc334 40bd302 9c62054 40bd302 9c62054 e67e6d1 1dfaeb1 21234f3 44fc334 9604a43 44fc334 9604a43 44fc334 21234f3 44fc334 21234f3 40a2489 f0ad985 44fc334 40a2489 44fc334 9604a43 40bd302 9604a43 40a2489 40bd302 9c62054 5673e08 c397617 5673e08 44fc334 9604a43 e67e6d1 44fc334 9604a43 9c62054 c7129d3 9604a43 e67e6d1 f0ad985 e67e6d1 f0ad985 e67e6d1 c7129d3 9604a43 e67e6d1 9604a43 44fc334 b559549 5673e08 9c62054 b559549 e67e6d1 f0ad985 b559549 9c62054 bb775b3 5673e08 44fc334 e67e6d1 f0ad985 e67e6d1 5673e08 b559549 5673e08 b559549 f0ad985 40a2489 f0ad985 40a2489 f0ad985 44fc334 5673e08 f0ad985 e67e6d1 bb775b3 36075bf 44fc334 9604a43 c397617 5673e08 44fc334 40bd302 9604a43 f8fbf35 9604a43 40bd302 9604a43 5673e08 44fc334 9604a43 44fc334 9604a43 c397617 44fc334 5673e08 44fc334 5673e08 9604a43 ffdbd18 66bdef6 eef5391 66bdef6 5673e08 44fc334 9604a43 44fc334 9604a43 c7129d3 5673e08 eef5391 9c62054 9b7d4c5 eef5391 66bdef6 eef5391 66bdef6 eef5391 66bdef6 805bbe8 66bdef6 e67e6d1 66bdef6 4e18ba0 1dfaeb1 9604a43 9c62054 9604a43 9c62054 9604a43 44fc334 e67e6d1 44fc334 e67e6d1 f0ad985 e67e6d1 9c62054 e67e6d1 9c62054 e67e6d1 f0ad985 e67e6d1 44fc334 21234f3 c7129d3 44fc334 5673e08 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 | /**
* ORBIT – Educational Research Assistant
* FULL V-MASTER SCRIPT - WITH VISION, DOCX, TRUE STREAMING & COPY BUTTON
*/
document.addEventListener('DOMContentLoaded', () => {
const $ = id => document.getElementById(id);
const addEvt = (id, event, handler) => { if($(id)) $(id).addEventListener(event, handler); };
const safeArr = arr => Array.isArray(arr) ? arr : [];
let currentSid = null;
let sessions = {};
let appSettings = null;
let isBusy = false;
// Penampung File Universal
let attachedFile = null;
const DEFAULT_OR_MODELS = [
"stepfun-ai/step-3.5-flash",
"baidu/cobuddy:free",
"poolside/laguna-xs.2:free",
"inclusionai/ring-2.6-1t:free",
"z-ai/glm-4.5-air:free",
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
"google/gemma-4-26b-a4b-it:free",
"google/gemma-4-31b-it:free",
"nvidia/llama-nemotron-embed-vl-1b-v2:free",
"minimax/minimax-m2.5:free",
"nousresearch/hermes-3-llama-3.1-405b:free",
"qwen/qwen3-next-80b-a3b-instruct:free",
"meta-llama/llama-3.3-70b-instruct:free"
];
// TOAST NOTIFICATION
function showToast(message, isError = false) {
let toast = $('orbit-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'orbit-toast';
toast.style.cssText = `
position: fixed; top: 24px; left: 50%; transform: translate(-50%, -20px);
padding: 12px 24px; border-radius: 50px; box-shadow: 0 10px 25px rgba(0,0,0,0.2);
font-size: 14px; font-weight: 600; color: white; z-index: 99999;
opacity: 0; transition: all 0.3s ease-in-out; display: flex; align-items: center; gap: 8px;
pointer-events: none;
`;
document.body.appendChild(toast);
}
toast.style.backgroundColor = isError ? '#ef4444' : '#10b981';
toast.innerHTML = isError ? `<span>❌</span> <span>${message}</span>` : `<span>✅</span> <span>${message}</span>`;
setTimeout(() => { toast.style.opacity = '1'; toast.style.transform = 'translate(-50%, 0)'; }, 10);
setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translate(-50%, -20px)'; }, 4000);
}
try {
const stored = localStorage.getItem('orbit_sessions_v14');
sessions = stored ? JSON.parse(stored) : {};
if (typeof sessions !== 'object' || Array.isArray(sessions)) sessions = {};
} catch(e) { sessions = {}; }
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
let deferredPrompt;
if ($('btn-install-pwa') && !window.matchMedia('(display-mode: standalone)').matches) {
$('btn-install-pwa').classList.remove('hidden');
}
window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredPrompt = e; });
addEvt('btn-install-pwa', 'click', async () => {
if (isIOS) {
alert("Apple iOS memblokir install otomatis.\n\nCara Install PWA di iPhone/iPad:\n1. Tekan ikon 'Share' (kotak dengan panah ke atas) di menu bawah Safari.\n2. Geser ke bawah dan pilih 'Add to Home Screen' (Tambahkan ke Layar Utama).");
} else if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if(outcome === 'accepted' && $('btn-install-pwa')) $('btn-install-pwa').classList.add('hidden');
deferredPrompt = null;
} else {
alert("Chrome memblokir pop-up otomatis.\n\nCara Install:\nKlik ikon Titik-Tiga (⋮) di pojok kanan atas browser, lalu pilih 'Tambahkan ke Layar Utama' (Add to Home screen).");
}
});
async function init() {
try {
const me = await fetch('/api/me', { cache: 'no-store' });
if(me.status === 401) { window.location.href = '/login'; return; }
if(me.ok) {
const user = await me.json();
if($('user-name')) $('user-name').textContent = user.name || user.email;
if($('user-avatar') && user.picture) $('user-avatar').src = user.picture;
}
const setRes = await fetch('/api/settings', { cache: 'no-store' });
if(setRes.ok) {
appSettings = await setRes.json();
appSettings.models_nvidia = safeArr(appSettings.models_nvidia);
appSettings.models_gemini = safeArr(appSettings.models_gemini);
appSettings.models_agentrouter = safeArr(appSettings.models_agentrouter);
appSettings.models_openai = safeArr(appSettings.models_openai);
if (!localStorage.getItem('orbit_force_free_v6')) {
appSettings.models_openrouter = [...DEFAULT_OR_MODELS];
localStorage.setItem('orbit_force_free_v6', 'true');
fetch('/api/settings', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(appSettings) }).catch(err => console.error("Auto-save failed", err));
} else {
appSettings.models_openrouter = safeArr(appSettings.models_openrouter);
}
}
} catch (e) {
console.error("Init err:", e);
} finally {
populateModelSelect();
const ids = Object.keys(sessions).sort((a,b) => b-a);
if(ids.length) loadSession(ids[0]); else newSession();
}
}
function save() { try { localStorage.setItem('orbit_sessions_v14', JSON.stringify(sessions)); } catch(e){} }
function newSession() { const id = Date.now().toString(); sessions[id] = { title: "New Chat", messages: [] }; loadSession(id); }
addEvt('btn-new-chat', 'click', newSession);
function loadSession(id) {
if(!sessions[id]) return;
currentSid = id;
const cm = $('chat-messages'); const ws = $('welcome-msg');
if(cm) cm.innerHTML = '';
if(sessions[id].messages && sessions[id].messages.length > 0) {
if(ws) ws.classList.add('hidden');
sessions[id].messages.forEach(m => renderBubble(m.role, m.displayContent || m.content));
} else {
if(ws) ws.classList.remove('hidden');
}
renderHistory();
const ca = $('chat-area'); if(ca) ca.scrollTop = ca.scrollHeight;
}
function renderHistory() {
const list = $('history-list'); if(!list) return;
const ids = Object.keys(sessions).sort((a,b) => b-a);
if(!ids.length) { list.innerHTML = '<p class="text-xs text-gray-400 px-3 py-2 italic">No recent chats.</p>'; return; }
list.innerHTML = ids.map(id => {
const active = (id === currentSid) ? 'bg-accent-light text-accent shadow-sm' : 'text-gray-600 hover:bg-white';
return `<button onclick="window.ls('${id}')" class="w-full text-left px-3 py-2.5 rounded-xl text-xs truncate font-medium ${active}">${sessions[id].title || "New Chat"}</button>`;
}).join('');
}
window.ls = id => { loadSession(id); if(window.innerWidth < 768) toggleSidebar(); };
// --- FUNGSI GLOBAL UNTUK TOMBOL COPY ---
window.copyMsg = async (btn, targetId) => {
const el = document.getElementById(targetId);
if(!el) return;
try {
// Ambil teks bersihnya aja, cocok buat dipaste ke Word
await navigator.clipboard.writeText(el.innerText);
// Simpan icon asli buat dibalikin nanti
const originalHTML = btn.innerHTML;
// Animasi ganti icon jadi Ceklis Hijau
btn.innerHTML = `<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path></svg>`;
// Balikin lagi icon copy-nya setelah 2 detik
setTimeout(() => { btn.innerHTML = originalHTML; }, 2000);
} catch(e) {
showToast("Gagal menyalin teks. Browser Anda mungkin memblokir clipboard.", true);
}
};
function renderBubble(role, content, msgId = null) {
const isUser = (role === 'user');
const wrap = document.createElement('div');
// UPDATE: Tambahin class 'group' buat efek tombol hover
wrap.className = `flex mb-6 ${isUser ? 'justify-end' : 'justify-start'} group`;
if(isUser) {
wrap.innerHTML = `<div class="bg-accent text-white p-4 rounded-2xl rounded-tr-none max-w-[85%] text-[15px] leading-relaxed shadow-sm">${content}</div>`;
} else {
let html = content; try { html = marked.parse(content); } catch(e) { html = content.replace(/\n/g, '<br>'); }
const safeMsgId = msgId || 'msg-' + Date.now() + Math.floor(Math.random() * 1000);
// UPDATE: Injeksi tombol Copy di kanan atas bubble (absolute)
wrap.innerHTML = `<div class="flex gap-4 items-start w-full relative">
<img src="/static/icon.png" class="w-8 h-8 rounded-full shadow-sm shrink-0" onerror="this.style.display='none'">
<div class="relative bg-[#f8f9fa] border border-slate-200 p-5 rounded-2xl rounded-tl-none max-w-[90%] md:max-w-[85%] w-full shadow-sm">
<div id="${safeMsgId}" class="prose-orbit sm:pr-8">${html}</div>
<button onclick="window.copyMsg(this, '${safeMsgId}')" class="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all duration-200 absolute top-3 right-3 p-2 bg-white border border-slate-200 text-slate-400 hover:text-orbit-primary hover:border-orbit-primary hover:bg-blue-50 rounded-xl shadow-sm z-10" title="Copy Text">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
</button>
</div>
</div>`;
}
if($('chat-messages')) {
$('chat-messages').appendChild(wrap);
const ca = $('chat-area'); if(ca) ca.scrollTop = ca.scrollHeight;
}
}
async function sendChat() {
if(isBusy) return;
const raw = $('chat-textarea').value.trim();
if(!raw && !attachedFile) return;
$('chat-textarea').value = ''; $('chat-textarea').style.height = 'auto';
if($('welcome-msg')) $('welcome-msg').classList.add('hidden');
let full = raw; let display = raw.replace(/\n/g, '<br>');
let payloadImage = null;
let historyContent = raw;
if (attachedFile) {
if (attachedFile.type === 'document') {
full = `[Document: ${attachedFile.filename}]\n${attachedFile.text}\n\nUser: ${raw}`;
historyContent = full;
display = `<div class="bg-emerald-500 text-white text-[10px] px-2 py-1 rounded w-fit mb-2 font-bold flex items-center gap-1"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"></path></svg>${attachedFile.filename}</div>${display}`;
} else if (attachedFile.type === 'image') {
payloadImage = { base64: attachedFile.base64, mime: attachedFile.mime };
historyContent = `[Gambar Diunggah: ${attachedFile.filename}]\n\n${raw}`;
display = `<div class="mb-2"><img src="data:${attachedFile.mime};base64,${attachedFile.base64}" class="max-h-[200px] rounded-xl border border-gray-200 shadow-sm bg-white" /></div>${display}`;
}
attachedFile = null;
$('attach-badge').classList.add('hidden');
$('pdf-input').value = '';
}
if(!sessions[currentSid].messages || !sessions[currentSid].messages.length) sessions[currentSid].title = raw.slice(0, 20) || "New Chat";
sessions[currentSid].messages.push({ role: 'user', content: historyContent, displayContent: display });
renderBubble('user', display);
isBusy = true; $('btn-send').disabled = true;
const loadId = 'load-' + Date.now();
if($('chat-messages')) {
$('chat-messages').insertAdjacentHTML('beforeend', `
<div id="${loadId}" class="flex mb-8 gap-4 items-start">
<img src="/static/icon.png" class="w-8 h-8 rounded-full shadow-sm shrink-0">
<div class="bg-white border border-gray-200 px-5 py-4 rounded-[24px] rounded-tl-[8px] shadow-sm flex items-center gap-3">
<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>
<span class="text-xs font-semibold text-gray-500">Membaca dokumen...</span>
</div>
</div>
`);
const ca = $('chat-area'); if(ca) ca.scrollTop = ca.scrollHeight;
}
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: full,
model: $('model-select').value,
messages: sessions[currentSid].messages.slice(0,-1),
image: payloadImage
})
});
if($(loadId)) $(loadId).remove();
if(!res.ok) {
let errText = "Server Error";
try { const errData = await res.json(); errText = errData.error; }
catch(e) { errText = await res.text(); }
throw new Error(errText);
}
let assistantContent = "";
const msgId = 'msg-' + Date.now();
renderBubble('assistant', '<span class="animate-pulse text-gray-400">✍️ Mengetik...</span>', msgId);
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.substring(6).trim();
if (dataStr === '[DONE]') continue;
try {
const data = JSON.parse(dataStr);
if (data.error) {
assistantContent += `\n**Peringatan Server:** ${data.error}`;
} else if (data.text) {
assistantContent += data.text;
}
const contentDiv = document.getElementById(msgId);
if (contentDiv) {
try { contentDiv.innerHTML = marked.parse(assistantContent); }
catch(e) { contentDiv.innerHTML = assistantContent.replace(/\n/g, '<br>'); }
}
const ca = document.getElementById('chat-area');
if (ca) ca.scrollTop = ca.scrollHeight;
} catch(e) { }
}
}
}
sessions[currentSid].messages.push({ role: 'assistant', content: assistantContent });
save();
renderHistory();
} catch(e) {
if($(loadId)) $(loadId).remove();
renderBubble('assistant', `**Error/Koneksi Terputus:** ${e.message}`);
} finally { isBusy = false; $('btn-send').disabled = false; }
}
addEvt('btn-send', 'click', sendChat);
addEvt('chat-textarea', 'keydown', e => { if(e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); } });
if($('chat-textarea')) $('chat-textarea').addEventListener('input', function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 160) + 'px'; });
const provMap = { "OpenRouter": "or", "Nvidia NIM": "nv", "Google Gemini": "gem", "AgentRouter": "ar", "Custom OpenAI": "oai" };
function syncProviderUI() {
const prov = $('settings-provider').value;
document.querySelectorAll('.prov-sec').forEach(el => el.classList.add('hidden'));
const secId = provMap[prov];
if(secId && $(`sec-${secId}`)) $(`sec-${secId}`).classList.remove('hidden');
}
function populateModelSelect() {
const ms = $('model-select'); if(!ms) return;
ms.innerHTML = "";
let list = ["gemini-1.5-pro-latest", "gemini-1.5-flash-latest"];
if(appSettings) {
const mapKey = { "OpenRouter": "models_openrouter", "Nvidia NIM": "models_nvidia", "Google Gemini": "models_gemini", "AgentRouter": "models_agentrouter", "Custom OpenAI": "models_openai" };
const k = mapKey[appSettings.provider];
if(appSettings[k] && appSettings[k].length > 0) list = appSettings[k];
}
list.forEach(m => { const opt = document.createElement('option'); opt.value = m; opt.textContent = m; if(appSettings && m === appSettings.current_model) opt.selected = true; ms.appendChild(opt); });
}
function renderDynamicLists() {
if(!appSettings) return;
const draw = (arr, listId, k) => {
const lst = $(listId); if(!lst) return;
lst.innerHTML = safeArr(arr).map((m,i) => `<div class="flex items-center justify-between px-2 py-1.5 rounded hover:bg-white group"><span class="text-xs truncate flex-1">${m}</span><button data-i="${i}" data-k="${k}" class="btn-del text-red-400 font-bold ml-2">✕</button></div>`).join('');
lst.querySelectorAll('.btn-del').forEach(b => {
b.addEventListener('click', function() { appSettings[this.dataset.k].splice(Number(this.dataset.i), 1); renderDynamicLists(); });
});
};
draw(appSettings.models_openrouter, 'list-or', 'models_openrouter');
draw(appSettings.models_nvidia, 'list-nv', 'models_nvidia');
draw(appSettings.models_gemini, 'list-gem', 'models_gemini');
draw(appSettings.models_agentrouter, 'list-ar', 'models_agentrouter');
draw(appSettings.models_openai, 'list-oai', 'models_openai');
}
addEvt('btn-settings', 'click', () => {
if(appSettings) {
$('settings-provider').value = appSettings.provider || "OpenRouter";
$('settings-apikey').value = appSettings.api_key || "";
$('settings-url').value = appSettings.base_url || "";
renderDynamicLists();
syncProviderUI();
}
$('settings-modal').classList.remove('hidden');
if(window.innerWidth < 768) toggleSidebar();
});
addEvt('btn-close-settings', 'click', () => $('settings-modal').classList.add('hidden'));
addEvt('btn-cancel-settings', 'click', () => $('settings-modal').classList.add('hidden'));
addEvt('settings-provider', 'change', () => {
const prov = $('settings-provider').value;
const urls = { "OpenRouter": "https://openrouter.ai/api/v1/chat/completions", "Nvidia NIM": "https://integrate.api.nvidia.com/v1/chat/completions", "Google Gemini": "https://generativelanguage.googleapis.com/v1beta/models/", "AgentRouter": "https://agentrouter.org/v1/chat/completions" };
if (urls[prov] && $('settings-url')) $('settings-url').value = urls[prov];
syncProviderUI();
});
addEvt('btn-toggle-key', 'click', () => { const inp = $('settings-apikey'); if(inp) inp.type = inp.type === 'password' ? 'text' : 'password'; });
const bindAdd = (btnId, inpId, listKey) => {
const f = () => {
const val = $(inpId)?.value.trim();
if(!val || !appSettings) return;
if(!Array.isArray(appSettings[listKey])) appSettings[listKey] = [];
if(!appSettings[listKey].includes(val)) { appSettings[listKey].push(val); $(inpId).value = ""; renderDynamicLists(); }
};
addEvt(btnId, 'click', f);
if($(inpId)) $(inpId).addEventListener('keydown', e => { if(e.key==='Enter') f(); });
};
bindAdd('btn-add-or', 'inp-or', 'models_openrouter');
bindAdd('btn-add-nv', 'inp-nv', 'models_nvidia');
bindAdd('btn-add-gem', 'inp-gem', 'models_gemini');
bindAdd('btn-add-ar', 'inp-ar', 'models_agentrouter');
bindAdd('btn-add-oai', 'inp-oai', 'models_openai');
addEvt('btn-save-settings', 'click', async () => {
const btn = $('btn-save-settings');
const originalText = btn.textContent;
btn.innerHTML = `<svg class="w-4 h-4 animate-spin inline mr-2" 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>Saving...`;
btn.disabled = true;
const payload = {
provider: $('settings-provider').value,
base_url: $('settings-url').value,
api_key: $('settings-apikey').value,
models_openrouter: safeArr(appSettings.models_openrouter),
models_nvidia: safeArr(appSettings.models_nvidia),
models_gemini: safeArr(appSettings.models_gemini),
models_agentrouter: safeArr(appSettings.models_agentrouter),
models_openai: safeArr(appSettings.models_openai),
current_model: $('model-select').value
};
try {
const res = await fetch('/api/settings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if(res.ok) {
appSettings = await res.json();
populateModelSelect();
setTimeout(() => {
$('settings-modal').classList.add('hidden');
showToast("Settings saved successfully!");
}, 300);
} else {
throw new Error("Gagal terhubung ke database server");
}
} catch(e) {
console.error(e);
showToast(`Error: ${e.message}`, true);
} finally {
setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 300);
}
});
addEvt('btn-doi', 'click', () => {
$('doi-modal').classList.remove('hidden');
if($('doi-input')) { $('doi-input').value = ""; $('doi-input').focus(); }
if($('doi-result')) $('doi-result').classList.add('hidden');
if(window.innerWidth < 768) toggleSidebar();
});
addEvt('btn-close-doi', 'click', () => $('doi-modal').classList.add('hidden'));
if($('doi-input')) {
$('doi-input').addEventListener('keydown', e => {
if(e.key === 'Enter') {
e.preventDefault();
if($('btn-validate-doi-submit')) $('btn-validate-doi-submit').click();
}
});
}
addEvt('btn-validate-doi-submit', 'click', async () => {
const doi = $('doi-input').value.trim(); if(!doi) return;
$('doi-result').classList.remove('hidden'); $('doi-result').innerHTML = "Validating...";
try {
const res = await fetch('/api/validate_doi', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({doi}) });
const d = await res.json();
if(!res.ok || d.error) $('doi-result').innerHTML = `<p class="text-red-500 font-medium">Error: ${d.error || "Gagal"}</p>`;
else $('doi-result').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">${d.title}</p></div><div><p class="text-[10px] font-semibold text-gray-400 uppercase">Authors</p><p class="text-sm text-gray-700">${d.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">${d.year}</p></div><div><p class="text-[10px] font-semibold text-gray-400 uppercase">Type</p><p class="text-sm text-gray-700">${d.type}</p></div></div><div><p class="text-[10px] font-semibold text-gray-400 uppercase">Source</p><p class="text-sm text-gray-700">${d.journal}</p></div></div>`;
} catch(e) {
$('doi-result').innerHTML = `<p class="text-red-500 font-medium">Error: ${e.message}</p>`;
}
});
function toggleSidebar() { $('sidebar').classList.toggle('-translate-x-full'); $('sidebar-overlay').classList.toggle('hidden'); }
addEvt('btn-hamburger', 'click', toggleSidebar); addEvt('btn-close-sidebar', 'click', toggleSidebar); addEvt('sidebar-overlay', 'click', toggleSidebar);
function clr() {
if(!currentSid) return; sessions[currentSid].messages = []; sessions[currentSid].title = "New Chat"; save(); loadSession(currentSid);
}
addEvt('btn-clear-chat-top', 'click', clr); addEvt('btn-clear-chat-mobile', 'click', clr);
addEvt('btn-attach', 'click', () => $('pdf-input').click());
addEvt('btn-remove-attach', 'click', () => { attachedFile = null; $('attach-badge').classList.add('hidden'); $('pdf-input').value = ''; });
if($('pdf-input')) {
$('pdf-input').addEventListener('change', async e => {
const f = e.target.files[0]; if(!f) return;
const allowed = ['pdf', 'doc', 'docx', 'jpg', 'jpeg', 'png'];
const ext = f.name.split('.').pop().toLowerCase();
if(!allowed.includes(ext)) {
showToast("Error: Ekstensi file tidak valid atau tidak diterima!", true);
$('pdf-input').value = '';
return;
}
const fd = new FormData(); fd.append('file', f);
$('attach-badge').classList.remove('hidden');
$('attach-name').textContent = "Memproses file...";
try {
const res = await fetch('/api/upload_file', { method: 'POST', body: fd });
const d = await res.json();
if(res.ok) {
attachedFile = d;
$('attach-name').textContent = d.filename;
} else {
showToast(d.error || "Gagal mengunggah file.", true);
$('attach-badge').classList.add('hidden');
$('pdf-input').value = '';
}
} catch(e) {
showToast(e.message, true);
$('attach-badge').classList.add('hidden');
$('pdf-input').value = '';
}
});
}
init();
}); |