ORBIT / static /js /script.js
xenux4u's picture
Update static/js/script.js
e67e6d1 verified
/**
* 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 ? `<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(); };
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 = `<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>'); }
wrap.innerHTML = `<div class="flex gap-4 items-start w-full"><img src="/static/icon.png" class="w-8 h-8 rounded-full 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 w-full shadow-sm">${html}</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; // 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 = `<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}`; // Base64 ga kita simpen ke localstorage biar ga crash HP user
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;
// --- 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', `
<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 flex-col gap-2 min-w-[220px]">
<div class="flex items-center gap-3 text-accent text-sm font-bold">
<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>
Thinking Process
</div>
<div id="${textId}" class="text-xs font-semibold text-gray-500 animate-pulse pl-7 border-l-2 border-gray-200 ml-1.5 mt-1">Memulai inisialisasi...</div>
</div>
</div>
`);
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) => `<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);
}
});
// 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 = `<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);
// 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();
});