/** * ORBIT – Educational Research Assistant * FULL V-MASTER SCRIPT - WITH VISION (IMAGE), DOCX SUPPORT & THINKING ILLUSION */ 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 = [ "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 ? ` ${message}` : ` ${message}`; 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 = '

No recent chats.

'; return; } list.innerHTML = ids.map(id => { const active = (id === currentSid) ? 'bg-accent-light text-accent shadow-sm' : 'text-gray-600 hover:bg-white'; return ``; }).join(''); } window.ls = id => { loadSession(id); if(window.innerWidth < 768) toggleSidebar(); }; function renderBubble(role, content) { const isUser = (role === 'user'); const wrap = document.createElement('div'); wrap.className = `flex mb-6 ${isUser ? 'justify-end' : 'justify-start'}`; if(isUser) { wrap.innerHTML = `
${content}
`; } else { let html = content; try { html = marked.parse(content); } catch(e) { html = content.replace(/\n/g, '
'); } wrap.innerHTML = `
${html}
`; } 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, '
'); let payloadImage = null; let historyContent = raw; // Khusus buat disimpen di memori lokal biar ga overload // JIKA ADA FILE ATTACHED if (attachedFile) { if (attachedFile.type === 'document') { full = `[Document: ${attachedFile.filename}]\n${attachedFile.text}\n\nUser: ${raw}`; historyContent = full; display = `
${attachedFile.filename}
${display}`; } else if (attachedFile.type === 'image') { payloadImage = { base64: attachedFile.base64, mime: attachedFile.mime }; historyContent = `[Gambar Diunggah: ${attachedFile.filename}]\n\n${raw}`; // Base64 ga kita simpen ke localstorage biar ga crash HP user display = `
${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; // --- ANIMASI KERANGKA BERPIKIR --- const loadId = 'load-' + Date.now(); const textId = 'text-' + Date.now(); const thinkingSteps = [ "Memahami konteks pertanyaan...", "Memindai literatur dan referensi yang relevan...", "Mengekstrak poin-poin penting...", "Menyusun kerangka sintesis...", "Menyempurnakan tata bahasa..." ]; if($('chat-messages')) { $('chat-messages').insertAdjacentHTML('beforeend', `
Thinking Process
Memulai inisialisasi...
`); const ca = $('chat-area'); if(ca) ca.scrollTop = ca.scrollHeight; } let stepIndex = 0; const thinkingInterval = setInterval(() => { const txtEl = $(textId); if(txtEl) { txtEl.textContent = thinkingSteps[stepIndex % thinkingSteps.length]; stepIndex++; } }, 2200); 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 // Ngirim payload ke backend }) }); const data = await res.json(); clearInterval(thinkingInterval); if($(loadId)) $(loadId).remove(); if(!res.ok) throw new Error(data.error || "Server error"); sessions[currentSid].messages.push({ role: 'assistant', content: data.reply }); renderBubble('assistant', data.reply); save(); renderHistory(); } catch(e) { clearInterval(thinkingInterval); if($(loadId)) $(loadId).remove(); renderBubble('assistant', `**Error:** ${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) => `
${m}
`).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 = `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); } }); // 7. DOI MODAL 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 = `

Error: ${d.error || "Gagal"}

`; else $('doi-result').innerHTML = `

Title

${d.title}

Authors

${d.authors}

Year

${d.year}

Type

${d.type}

Source

${d.journal}

`; } catch(e) { $('doi-result').innerHTML = `

Error: ${e.message}

`; } }); 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); // 8. LOGIKA UPLOAD MULTI-FILE 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; // VALIDASI EKSTENSI (GUE TAMBAHIN DISINI SESUAI MINTA LU) 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 = ''; // Reset inputnya return; } const fd = new FormData(); fd.append('file', f); $('attach-badge').classList.remove('hidden'); $('attach-name').textContent = "Memproses file..."; try { // Tembak ke endpoint yang udah gue update di app.py const res = await fetch('/api/upload_file', { method: 'POST', body: fd }); const d = await res.json(); if(res.ok) { attachedFile = d; // Nampung data (text atau gambar base64) $('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(); });