Upload 8 files
Browse files- .gitattributes +2 -0
- static/docs/dokumen.pdf +0 -0
- static/icon.png +3 -0
- static/js/script.js +642 -0
- static/js/sw.js +77 -0
- static/manifest.json +26 -0
- static/orbit.png +3 -0
- templates/index.html +542 -0
- templates/login.html +290 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
static/icon.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
static/orbit.png filter=lfs diff=lfs merge=lfs -text
|
static/docs/dokumen.pdf
ADDED
|
Binary file (24.6 kB). View file
|
|
|
static/icon.png
ADDED
|
|
Git LFS Details
|
static/js/script.js
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ORBIT β SaaS Client Logic
|
| 3 |
+
* Settings & API key live on the server (DB-backed).
|
| 4 |
+
* Chat sessions stored in localStorage, keyed per user.
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 8 |
+
// Constants
|
| 9 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 10 |
+
const PROVIDER_URLS = {
|
| 11 |
+
"OpenRouter": "https://openrouter.ai/api/v1/chat/completions",
|
| 12 |
+
"Nvidia NIM": "https://integrate.api.nvidia.com/v1/chat/completions",
|
| 13 |
+
"Google Gemini": "https://generativelanguage.googleapis.com/v1beta/models/",
|
| 14 |
+
"AgentRouter": "https://agentrouter.org/v1/chat/completions",
|
| 15 |
+
"Custom OpenAI": "https://api.openai.com/v1/chat/completions",
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 19 |
+
// State
|
| 20 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 21 |
+
let currentUser = null;
|
| 22 |
+
let appSettings = null;
|
| 23 |
+
let sessions = {};
|
| 24 |
+
let currentSid = null;
|
| 25 |
+
let attachment = null; // { text, filename, wordCount }
|
| 26 |
+
let isBusy = false;
|
| 27 |
+
let sessionsKey = "orbit_sessions";
|
| 28 |
+
|
| 29 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 30 |
+
// DOM
|
| 31 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 32 |
+
const $ = id => document.getElementById(id);
|
| 33 |
+
const chatMessages = $("chat-messages");
|
| 34 |
+
const chatTextarea = $("chat-textarea");
|
| 35 |
+
const btnSend = $("btn-send");
|
| 36 |
+
const sendIcon = $("send-icon");
|
| 37 |
+
const loadingIcon = $("loading-icon");
|
| 38 |
+
const welcomeScreen = $("welcome-screen");
|
| 39 |
+
const attachBadge = $("attach-badge");
|
| 40 |
+
const attachNameEl = $("attach-name");
|
| 41 |
+
const modelSelect = $("model-select");
|
| 42 |
+
const historyList = $("history-list");
|
| 43 |
+
const chatTitle = $("chat-title");
|
| 44 |
+
|
| 45 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 46 |
+
// LocalStorage
|
| 47 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 48 |
+
function saveSessions() {
|
| 49 |
+
try { localStorage.setItem(sessionsKey, JSON.stringify(sessions)); } catch (_) {}
|
| 50 |
+
}
|
| 51 |
+
function loadSessions() {
|
| 52 |
+
try { sessions = JSON.parse(localStorage.getItem(sessionsKey) || "{}"); }
|
| 53 |
+
catch (_) { sessions = {}; }
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 57 |
+
// Session Management
|
| 58 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
function newSession() {
|
| 60 |
+
const id = String(Date.now());
|
| 61 |
+
sessions[id] = { title: "New Chat", messages: [] };
|
| 62 |
+
currentSid = id;
|
| 63 |
+
saveSessions();
|
| 64 |
+
return id;
|
| 65 |
+
}
|
| 66 |
+
function getMessages() {
|
| 67 |
+
if (!currentSid || !sessions[currentSid]) newSession();
|
| 68 |
+
return sessions[currentSid].messages;
|
| 69 |
+
}
|
| 70 |
+
function appendMessage(role, content) {
|
| 71 |
+
const msgs = getMessages();
|
| 72 |
+
msgs.push({ role, content });
|
| 73 |
+
if (role === "user" && msgs.filter(m => m.role === "user").length === 1) {
|
| 74 |
+
sessions[currentSid].title = content.split(" ").slice(0, 7).join(" ") + "β¦";
|
| 75 |
+
chatTitle.textContent = sessions[currentSid].title;
|
| 76 |
+
renderSidebar();
|
| 77 |
+
}
|
| 78 |
+
saveSessions();
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 82 |
+
// Sidebar
|
| 83 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 84 |
+
function renderSidebar() {
|
| 85 |
+
historyList.innerHTML = "";
|
| 86 |
+
const entries = Object.entries(sessions).sort(([a], [b]) => Number(b) - Number(a));
|
| 87 |
+
entries.forEach(([id, s]) => {
|
| 88 |
+
const btn = document.createElement("button");
|
| 89 |
+
const isActive = id === currentSid;
|
| 90 |
+
btn.className = `sidebar-item w-full text-left px-3 py-2 rounded-xl text-xs truncate transition-all ${
|
| 91 |
+
isActive
|
| 92 |
+
? "bg-accent-light text-accent font-semibold"
|
| 93 |
+
: "text-gray-600 hover:bg-white hover:shadow-sm font-medium"
|
| 94 |
+
}`;
|
| 95 |
+
btn.textContent = s.title || "New Chat";
|
| 96 |
+
btn.addEventListener("click", () => loadSession(id));
|
| 97 |
+
historyList.appendChild(btn);
|
| 98 |
+
});
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function loadSession(id) {
|
| 102 |
+
currentSid = id;
|
| 103 |
+
clearChatView();
|
| 104 |
+
const msgs = sessions[id]?.messages || [];
|
| 105 |
+
if (msgs.length) {
|
| 106 |
+
hideWelcome();
|
| 107 |
+
msgs.forEach(m => renderBubble(m.role, m.content, false));
|
| 108 |
+
scrollBottom();
|
| 109 |
+
} else {
|
| 110 |
+
showWelcome();
|
| 111 |
+
}
|
| 112 |
+
chatTitle.textContent = sessions[id]?.title || "New Chat";
|
| 113 |
+
renderSidebar();
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 117 |
+
// Model Dropdown β Dynamic by Provider
|
| 118 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 119 |
+
function populateModelSelect() {
|
| 120 |
+
if (!appSettings) return;
|
| 121 |
+
const list = appSettings.provider === "Nvidia NIM"
|
| 122 |
+
? (appSettings.models_nvidia || [])
|
| 123 |
+
: (appSettings.models_openrouter || []);
|
| 124 |
+
|
| 125 |
+
modelSelect.innerHTML = "";
|
| 126 |
+
list.forEach(m => {
|
| 127 |
+
const opt = document.createElement("option");
|
| 128 |
+
opt.value = m;
|
| 129 |
+
opt.textContent = m.length > 30 ? m.slice(0, 28) + "β¦" : m;
|
| 130 |
+
if (m === appSettings.current_model) opt.selected = true;
|
| 131 |
+
modelSelect.appendChild(opt);
|
| 132 |
+
});
|
| 133 |
+
// fallback: if current_model not in list, select first
|
| 134 |
+
if (modelSelect.options.length && !modelSelect.value) {
|
| 135 |
+
modelSelect.selectedIndex = 0;
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
modelSelect.addEventListener("change", async () => {
|
| 140 |
+
if (!appSettings) return;
|
| 141 |
+
appSettings.current_model = modelSelect.value;
|
| 142 |
+
try {
|
| 143 |
+
await fetch("/api/settings", {
|
| 144 |
+
method: "POST",
|
| 145 |
+
headers: { "Content-Type": "application/json" },
|
| 146 |
+
body: JSON.stringify({ current_model: modelSelect.value }),
|
| 147 |
+
});
|
| 148 |
+
} catch (_) {}
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 152 |
+
// Chat UI Helpers
|
| 153 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 154 |
+
function showWelcome() {
|
| 155 |
+
welcomeScreen.style.display = "";
|
| 156 |
+
welcomeScreen.style.opacity = "1";
|
| 157 |
+
}
|
| 158 |
+
function hideWelcome() {
|
| 159 |
+
welcomeScreen.style.display = "none";
|
| 160 |
+
}
|
| 161 |
+
function clearChatView() {
|
| 162 |
+
chatMessages.innerHTML = "";
|
| 163 |
+
}
|
| 164 |
+
function scrollBottom() {
|
| 165 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
function simpleMarkdown(text) {
|
| 169 |
+
let h = text
|
| 170 |
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
| 171 |
+
// code blocks
|
| 172 |
+
h = h.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, c) =>
|
| 173 |
+
`<pre><code>${c.trim()}</code></pre>`);
|
| 174 |
+
// inline code
|
| 175 |
+
h = h.replace(/`([^`\n]+)`/g, "<code>$1</code>");
|
| 176 |
+
// bold / italic
|
| 177 |
+
h = h.replace(/\*\*\*(.+?)\*\*\*/g, "<strong><em>$1</em></strong>");
|
| 178 |
+
h = h.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
| 179 |
+
h = h.replace(/\*(.+?)\*/g, "<em>$1</em>");
|
| 180 |
+
// headings
|
| 181 |
+
h = h.replace(/^### (.+)$/gm, "<h3>$1</h3>");
|
| 182 |
+
h = h.replace(/^## (.+)$/gm, "<h2>$1</h2>");
|
| 183 |
+
h = h.replace(/^# (.+)$/gm, "<h1>$1</h1>");
|
| 184 |
+
// blockquote
|
| 185 |
+
h = h.replace(/^> (.+)$/gm, "<blockquote>$1</blockquote>");
|
| 186 |
+
// lists
|
| 187 |
+
h = h.replace(/^\s*[-*] (.+)/gm, "<li>$1</li>");
|
| 188 |
+
h = h.replace(/(<li>.*<\/li>\n?)+/g, m => `<ul>${m}</ul>`);
|
| 189 |
+
// paragraphs
|
| 190 |
+
h = h.split(/\n{2,}/).map(p =>
|
| 191 |
+
/^<(h[1-3]|ul|li|pre|blockquote)/.test(p.trim())
|
| 192 |
+
? p : `<p>${p.replace(/\n/g, "<br>")}</p>`
|
| 193 |
+
).join("\n");
|
| 194 |
+
return h;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function renderBubble(role, content, animate = true) {
|
| 198 |
+
const isUser = role === "user";
|
| 199 |
+
const wrap = document.createElement("div");
|
| 200 |
+
wrap.className = `flex ${isUser ? "justify-end" : "justify-start"} px-4 md:px-16 py-2${animate ? " " + (isUser ? "bubble-user" : "bubble-ai") : ""}`;
|
| 201 |
+
|
| 202 |
+
const col = document.createElement("div");
|
| 203 |
+
col.className = isUser ? "flex flex-col items-end" : "flex flex-col items-start";
|
| 204 |
+
|
| 205 |
+
const label = document.createElement("p");
|
| 206 |
+
label.className = `text-[10px] font-semibold mb-1 ${isUser ? "text-accent text-right" : "text-gray-400"}`;
|
| 207 |
+
label.textContent = isUser ? "You" : "ORBIT";
|
| 208 |
+
|
| 209 |
+
const bub = document.createElement("div");
|
| 210 |
+
bub.className = isUser
|
| 211 |
+
? "max-w-xl bg-accent-light text-gray-800 rounded-2xl rounded-br-sm px-4 py-2.5 text-sm leading-relaxed"
|
| 212 |
+
: "max-w-2xl bg-[#F8F9FA] text-gray-800 rounded-2xl rounded-bl-sm px-4 py-2.5 text-sm prose-orbit";
|
| 213 |
+
|
| 214 |
+
if (isUser) bub.textContent = content;
|
| 215 |
+
else bub.innerHTML = simpleMarkdown(content);
|
| 216 |
+
|
| 217 |
+
col.appendChild(label);
|
| 218 |
+
col.appendChild(bub);
|
| 219 |
+
wrap.appendChild(col);
|
| 220 |
+
chatMessages.appendChild(wrap);
|
| 221 |
+
if (animate) scrollBottom();
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
function renderTyping() {
|
| 225 |
+
const w = document.createElement("div");
|
| 226 |
+
w.id = "typing-indicator";
|
| 227 |
+
w.className = "flex justify-start px-4 md:px-16 py-2 bubble-ai";
|
| 228 |
+
w.innerHTML = `<div class="bg-[#F8F9FA] rounded-2xl rounded-bl-sm px-4 py-3 flex items-center gap-1.5">
|
| 229 |
+
<span class="typing-dot w-2 h-2 rounded-full bg-gray-400 inline-block"></span>
|
| 230 |
+
<span class="typing-dot w-2 h-2 rounded-full bg-gray-400 inline-block"></span>
|
| 231 |
+
<span class="typing-dot w-2 h-2 rounded-full bg-gray-400 inline-block"></span>
|
| 232 |
+
</div>`;
|
| 233 |
+
chatMessages.appendChild(w);
|
| 234 |
+
scrollBottom();
|
| 235 |
+
}
|
| 236 |
+
function removeTyping() {
|
| 237 |
+
const e = $("typing-indicator");
|
| 238 |
+
if (e) e.remove();
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
function renderSys(text, isErr = false) {
|
| 242 |
+
const d = document.createElement("div");
|
| 243 |
+
d.className = "flex justify-center px-4 py-2";
|
| 244 |
+
d.innerHTML = `<span class="text-[11px] px-3 py-1.5 rounded-full ${
|
| 245 |
+
isErr ? "bg-red-50 text-red-500" : "bg-gray-100 text-gray-500"
|
| 246 |
+
}">${text}</span>`;
|
| 247 |
+
chatMessages.appendChild(d);
|
| 248 |
+
scrollBottom();
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 252 |
+
// Busy State
|
| 253 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 254 |
+
function setBusy(busy) {
|
| 255 |
+
isBusy = busy;
|
| 256 |
+
btnSend.disabled = busy;
|
| 257 |
+
chatTextarea.disabled = busy;
|
| 258 |
+
sendIcon.classList.toggle("hidden", busy);
|
| 259 |
+
loadingIcon.classList.toggle("hidden", !busy);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 263 |
+
// Send Chat
|
| 264 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 265 |
+
async function sendChat() {
|
| 266 |
+
if (isBusy) return;
|
| 267 |
+
const raw = chatTextarea.value.trim();
|
| 268 |
+
if (!raw) return;
|
| 269 |
+
|
| 270 |
+
let fullPrompt = raw;
|
| 271 |
+
if (attachment) {
|
| 272 |
+
fullPrompt = `Document Context:\n${attachment.text}\n\nUser Question: ${raw}`;
|
| 273 |
+
clearAttachment();
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
chatTextarea.value = "";
|
| 277 |
+
autoResize();
|
| 278 |
+
hideWelcome();
|
| 279 |
+
setBusy(true);
|
| 280 |
+
|
| 281 |
+
renderBubble("user", raw);
|
| 282 |
+
appendMessage("user", fullPrompt);
|
| 283 |
+
renderTyping();
|
| 284 |
+
|
| 285 |
+
try {
|
| 286 |
+
const res = await fetch("/api/chat", {
|
| 287 |
+
method: "POST",
|
| 288 |
+
headers: { "Content-Type": "application/json" },
|
| 289 |
+
body: JSON.stringify({
|
| 290 |
+
prompt: fullPrompt,
|
| 291 |
+
model: modelSelect.value || appSettings?.current_model || "",
|
| 292 |
+
messages: getMessages().slice(0, -1),
|
| 293 |
+
}),
|
| 294 |
+
});
|
| 295 |
+
const data = await res.json();
|
| 296 |
+
removeTyping();
|
| 297 |
+
if (!res.ok || data.error) {
|
| 298 |
+
renderSys(data.error || "Unknown error.", true);
|
| 299 |
+
} else {
|
| 300 |
+
renderBubble("assistant", data.reply);
|
| 301 |
+
appendMessage("assistant", data.reply);
|
| 302 |
+
}
|
| 303 |
+
} catch (err) {
|
| 304 |
+
removeTyping();
|
| 305 |
+
renderSys(`Network error: ${err.message}`, true);
|
| 306 |
+
} finally {
|
| 307 |
+
setBusy(false);
|
| 308 |
+
chatTextarea.focus();
|
| 309 |
+
}
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 313 |
+
// Textarea Auto-resize
|
| 314 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 315 |
+
function autoResize() {
|
| 316 |
+
chatTextarea.style.height = "auto";
|
| 317 |
+
chatTextarea.style.height = Math.min(chatTextarea.scrollHeight, 160) + "px";
|
| 318 |
+
}
|
| 319 |
+
chatTextarea.addEventListener("input", autoResize);
|
| 320 |
+
chatTextarea.addEventListener("keydown", e => {
|
| 321 |
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendChat(); }
|
| 322 |
+
});
|
| 323 |
+
btnSend.addEventListener("click", sendChat);
|
| 324 |
+
|
| 325 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 326 |
+
// PDF Attachment
|
| 327 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 328 |
+
function clearAttachment() {
|
| 329 |
+
attachment = null;
|
| 330 |
+
attachBadge.classList.add("hidden");
|
| 331 |
+
attachNameEl.textContent = "";
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
$("btn-attach").addEventListener("click", () => $("pdf-input").click());
|
| 335 |
+
$("btn-remove-attach").addEventListener("click", clearAttachment);
|
| 336 |
+
|
| 337 |
+
$("pdf-input").addEventListener("change", async e => {
|
| 338 |
+
const file = e.target.files[0];
|
| 339 |
+
if (!file) return;
|
| 340 |
+
e.target.value = "";
|
| 341 |
+
if (!file.name.toLowerCase().endsWith(".pdf")) {
|
| 342 |
+
renderSys("Only PDF files are supported.", true);
|
| 343 |
+
return;
|
| 344 |
+
}
|
| 345 |
+
const fd = new FormData();
|
| 346 |
+
fd.append("file", file);
|
| 347 |
+
renderSys(`Uploading "${file.name}"β¦`);
|
| 348 |
+
try {
|
| 349 |
+
const res = await fetch("/api/upload_pdf", { method: "POST", body: fd });
|
| 350 |
+
const data = await res.json();
|
| 351 |
+
if (!res.ok || data.error) { renderSys(data.error, true); return; }
|
| 352 |
+
attachment = { text: data.text, filename: data.filename, wordCount: data.word_count };
|
| 353 |
+
attachNameEl.textContent = `${data.filename} Β· ${data.word_count} words`;
|
| 354 |
+
attachBadge.classList.remove("hidden");
|
| 355 |
+
renderSys(`"${data.filename}" attached β ${data.word_count} words extracted.`);
|
| 356 |
+
} catch (err) {
|
| 357 |
+
renderSys(`Upload failed: ${err.message}`, true);
|
| 358 |
+
}
|
| 359 |
+
});
|
| 360 |
+
|
| 361 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 362 |
+
// DOI Modal
|
| 363 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 364 |
+
$("btn-doi").addEventListener("click", () => {
|
| 365 |
+
$("doi-modal").classList.remove("hidden");
|
| 366 |
+
$("doi-input").focus();
|
| 367 |
+
});
|
| 368 |
+
$("btn-close-doi").addEventListener("click", () => $("doi-modal").classList.add("hidden"));
|
| 369 |
+
$("doi-modal").addEventListener("click", e => {
|
| 370 |
+
if (e.target === $("doi-modal")) $("doi-modal").classList.add("hidden");
|
| 371 |
+
});
|
| 372 |
+
$("doi-input").addEventListener("keydown", e => {
|
| 373 |
+
if (e.key === "Enter") $("btn-validate-doi").click();
|
| 374 |
+
});
|
| 375 |
+
|
| 376 |
+
$("btn-validate-doi").addEventListener("click", async () => {
|
| 377 |
+
const doi = $("doi-input").value.trim();
|
| 378 |
+
if (!doi) return;
|
| 379 |
+
$("doi-spinner").classList.remove("hidden");
|
| 380 |
+
$("doi-btn-text").textContent = "Validatingβ¦";
|
| 381 |
+
$("btn-validate-doi").disabled = true;
|
| 382 |
+
$("doi-result").classList.add("hidden");
|
| 383 |
+
|
| 384 |
+
try {
|
| 385 |
+
const res = await fetch("/api/validate_doi", {
|
| 386 |
+
method: "POST",
|
| 387 |
+
headers: { "Content-Type": "application/json" },
|
| 388 |
+
body: JSON.stringify({ doi }),
|
| 389 |
+
});
|
| 390 |
+
const data = await res.json();
|
| 391 |
+
$("doi-result").classList.remove("hidden");
|
| 392 |
+
if (!res.ok || data.error) {
|
| 393 |
+
$("doi-result").innerHTML = `<p class="text-red-500">${data.error}</p>`;
|
| 394 |
+
} else {
|
| 395 |
+
$("doi-result").innerHTML = `
|
| 396 |
+
<div class="space-y-2">
|
| 397 |
+
<div><p class="text-[10px] font-semibold text-gray-400 uppercase">Title</p>
|
| 398 |
+
<p class="font-medium text-gray-800 text-sm">${data.title}</p></div>
|
| 399 |
+
<div><p class="text-[10px] font-semibold text-gray-400 uppercase">Authors</p>
|
| 400 |
+
<p class="text-sm text-gray-700">${data.authors}</p></div>
|
| 401 |
+
<div class="flex gap-6">
|
| 402 |
+
<div><p class="text-[10px] font-semibold text-gray-400 uppercase">Year</p>
|
| 403 |
+
<p class="text-sm text-gray-700">${data.year}</p></div>
|
| 404 |
+
<div><p class="text-[10px] font-semibold text-gray-400 uppercase">Type</p>
|
| 405 |
+
<p class="text-sm text-gray-700">${data.type}</p></div>
|
| 406 |
+
</div>
|
| 407 |
+
<div><p class="text-[10px] font-semibold text-gray-400 uppercase">Source</p>
|
| 408 |
+
<p class="text-sm text-gray-700">${data.journal}</p></div>
|
| 409 |
+
<div><p class="text-[10px] font-semibold text-gray-400 uppercase">DOI</p>
|
| 410 |
+
<p><a href="https://doi.org/${data.doi}" target="_blank" class="text-accent hover:underline text-sm">${data.doi}</a></p></div>
|
| 411 |
+
</div>`;
|
| 412 |
+
renderSys(`DOI validated: ${data.title} (${data.year})`);
|
| 413 |
+
}
|
| 414 |
+
} catch (err) {
|
| 415 |
+
$("doi-result").classList.remove("hidden");
|
| 416 |
+
$("doi-result").innerHTML = `<p class="text-red-500">${err.message}</p>`;
|
| 417 |
+
} finally {
|
| 418 |
+
$("doi-spinner").classList.add("hidden");
|
| 419 |
+
$("doi-btn-text").textContent = "Validate";
|
| 420 |
+
$("btn-validate-doi").disabled = false;
|
| 421 |
+
}
|
| 422 |
+
});
|
| 423 |
+
|
| 424 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 425 |
+
// Settings Modal
|
| 426 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 427 |
+
function renderModelList(containerId, models, provider) {
|
| 428 |
+
const list = $(containerId);
|
| 429 |
+
list.innerHTML = "";
|
| 430 |
+
(models || []).forEach((m, i) => {
|
| 431 |
+
const row = document.createElement("div");
|
| 432 |
+
row.className = "flex items-center justify-between px-2 py-1.5 rounded-lg hover:bg-white group transition-colors";
|
| 433 |
+
row.innerHTML = `
|
| 434 |
+
<span class="text-xs text-gray-700 truncate flex-1">${m}</span>
|
| 435 |
+
<button data-i="${i}" data-prov="${provider}"
|
| 436 |
+
class="btn-del-model text-gray-300 hover:text-red-400 ml-2 text-xs font-bold opacity-0 group-hover:opacity-100 transition-opacity">β</button>`;
|
| 437 |
+
list.appendChild(row);
|
| 438 |
+
});
|
| 439 |
+
list.querySelectorAll(".btn-del-model").forEach(btn => {
|
| 440 |
+
btn.addEventListener("click", () => {
|
| 441 |
+
const idx = Number(btn.dataset.i);
|
| 442 |
+
const prov = btn.dataset.prov;
|
| 443 |
+
if (prov === "OpenRouter") {
|
| 444 |
+
appSettings.models_openrouter.splice(idx, 1);
|
| 445 |
+
renderModelList("models-list-openrouter", appSettings.models_openrouter, "OpenRouter");
|
| 446 |
+
} else {
|
| 447 |
+
appSettings.models_nvidia.splice(idx, 1);
|
| 448 |
+
renderModelList("models-list-nvidia", appSettings.models_nvidia, "Nvidia NIM");
|
| 449 |
+
}
|
| 450 |
+
});
|
| 451 |
+
});
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
function openSettings() {
|
| 455 |
+
if (!appSettings) return;
|
| 456 |
+
$("settings-provider").value = appSettings.provider;
|
| 457 |
+
$("settings-url").value = appSettings.base_url || "";
|
| 458 |
+
$("settings-apikey").value = appSettings.api_key || "";
|
| 459 |
+
renderModelList("models-list-openrouter", appSettings.models_openrouter, "OpenRouter");
|
| 460 |
+
renderModelList("models-list-nvidia", appSettings.models_nvidia, "Nvidia NIM");
|
| 461 |
+
$("settings-modal").classList.remove("hidden");
|
| 462 |
+
}
|
| 463 |
+
function closeSettings() {
|
| 464 |
+
$("settings-modal").classList.add("hidden");
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
$("settings-provider").addEventListener("change", () => {
|
| 468 |
+
const prov = $("settings-provider").value;
|
| 469 |
+
if (PROVIDER_URLS[prov]) $("settings-url").value = PROVIDER_URLS[prov];
|
| 470 |
+
});
|
| 471 |
+
|
| 472 |
+
$("btn-toggle-key").addEventListener("click", () => {
|
| 473 |
+
const inp = $("settings-apikey");
|
| 474 |
+
inp.type = inp.type === "password" ? "text" : "password";
|
| 475 |
+
});
|
| 476 |
+
|
| 477 |
+
// Add-model handlers
|
| 478 |
+
function addModelHandler(inputId, listKey, containerId, provLabel, btnSuffix) {
|
| 479 |
+
function doAdd() {
|
| 480 |
+
const val = $(inputId).value.trim();
|
| 481 |
+
if (!val || !appSettings) return;
|
| 482 |
+
if (!appSettings[listKey].includes(val)) {
|
| 483 |
+
appSettings[listKey].push(val);
|
| 484 |
+
$(inputId).value = "";
|
| 485 |
+
renderModelList(containerId, appSettings[listKey], provLabel);
|
| 486 |
+
}
|
| 487 |
+
}
|
| 488 |
+
$(`btn-add-model-${btnSuffix}`).addEventListener("click", doAdd);
|
| 489 |
+
$(inputId).addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); doAdd(); } });
|
| 490 |
+
}
|
| 491 |
+
addModelHandler("new-model-or", "models_openrouter", "models-list-openrouter", "OpenRouter", "or");
|
| 492 |
+
addModelHandler("new-model-nv", "models_nvidia", "models-list-nvidia", "Nvidia NIM", "nv");
|
| 493 |
+
|
| 494 |
+
$("btn-save-settings").addEventListener("click", async () => {
|
| 495 |
+
const payload = {
|
| 496 |
+
provider: $("settings-provider").value,
|
| 497 |
+
base_url: $("settings-url").value.trim(),
|
| 498 |
+
api_key: $("settings-apikey").value.trim(),
|
| 499 |
+
models_openrouter: appSettings.models_openrouter,
|
| 500 |
+
models_nvidia: appSettings.models_nvidia,
|
| 501 |
+
};
|
| 502 |
+
try {
|
| 503 |
+
const res = await fetch("/api/settings", {
|
| 504 |
+
method: "POST",
|
| 505 |
+
headers: { "Content-Type": "application/json" },
|
| 506 |
+
body: JSON.stringify(payload),
|
| 507 |
+
});
|
| 508 |
+
appSettings = await res.json();
|
| 509 |
+
populateModelSelect();
|
| 510 |
+
closeSettings();
|
| 511 |
+
renderSys("Settings saved successfully.");
|
| 512 |
+
} catch (err) {
|
| 513 |
+
renderSys(`Could not save settings: ${err.message}`, true);
|
| 514 |
+
}
|
| 515 |
+
});
|
| 516 |
+
|
| 517 |
+
$("btn-settings").addEventListener("click", openSettings);
|
| 518 |
+
$("btn-close-settings").addEventListener("click", closeSettings);
|
| 519 |
+
$("btn-cancel-settings").addEventListener("click", closeSettings);
|
| 520 |
+
$("settings-modal").addEventListener("click", e => {
|
| 521 |
+
if (e.target === $("settings-modal")) closeSettings();
|
| 522 |
+
});
|
| 523 |
+
|
| 524 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 525 |
+
// Sidebar / Nav Buttons
|
| 526 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 527 |
+
$("btn-new-chat").addEventListener("click", () => {
|
| 528 |
+
newSession();
|
| 529 |
+
clearChatView();
|
| 530 |
+
showWelcome();
|
| 531 |
+
clearAttachment();
|
| 532 |
+
chatTitle.textContent = "New Chat";
|
| 533 |
+
renderSidebar();
|
| 534 |
+
chatTextarea.focus();
|
| 535 |
+
});
|
| 536 |
+
|
| 537 |
+
$("btn-clear-chat").addEventListener("click", handleClear);
|
| 538 |
+
$("btn-clear-chat-mobile").addEventListener("click", handleClear);
|
| 539 |
+
|
| 540 |
+
function handleClear() {
|
| 541 |
+
if (!currentSid) return;
|
| 542 |
+
sessions[currentSid].messages = [];
|
| 543 |
+
sessions[currentSid].title = "New Chat";
|
| 544 |
+
saveSessions();
|
| 545 |
+
clearChatView();
|
| 546 |
+
showWelcome();
|
| 547 |
+
chatTitle.textContent = "New Chat";
|
| 548 |
+
renderSidebar();
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
function toggleSidebar() {
|
| 552 |
+
const sidebar = $("sidebar");
|
| 553 |
+
const overlay = $("sidebar-overlay");
|
| 554 |
+
|
| 555 |
+
// Mobile behavior
|
| 556 |
+
if (window.innerWidth < 768) {
|
| 557 |
+
if (sidebar.classList.contains("-translate-x-full")) {
|
| 558 |
+
sidebar.classList.remove("-translate-x-full");
|
| 559 |
+
overlay.classList.remove("hidden");
|
| 560 |
+
} else {
|
| 561 |
+
sidebar.classList.add("-translate-x-full");
|
| 562 |
+
overlay.classList.add("hidden");
|
| 563 |
+
}
|
| 564 |
+
} else {
|
| 565 |
+
// Desktop behavior (hide completely)
|
| 566 |
+
sidebar.classList.toggle("hidden");
|
| 567 |
+
}
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
$("btn-toggle-sidebar").addEventListener("click", toggleSidebar);
|
| 571 |
+
$("btn-hamburger").addEventListener("click", toggleSidebar);
|
| 572 |
+
$("sidebar-overlay").addEventListener("click", toggleSidebar);
|
| 573 |
+
|
| 574 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 575 |
+
// PWA Service Worker
|
| 576 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 577 |
+
if ("serviceWorker" in navigator) {
|
| 578 |
+
navigator.serviceWorker.register("/static/js/sw.js")
|
| 579 |
+
.catch(e => console.warn("[ORBIT] SW:", e));
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 583 |
+
// Boot
|
| 584 |
+
// βββββββββββββββββββββββββββββββββββββ
|
| 585 |
+
async function init() {
|
| 586 |
+
try {
|
| 587 |
+
// 1. Load current user
|
| 588 |
+
const meRes = await fetch("/api/me");
|
| 589 |
+
if (meRes.status === 401) { window.location.href = "/login"; return; }
|
| 590 |
+
currentUser = await meRes.json();
|
| 591 |
+
|
| 592 |
+
// 2. Populate sidebar profile
|
| 593 |
+
const avatar = $("user-avatar");
|
| 594 |
+
if (currentUser.picture) {
|
| 595 |
+
avatar.src = currentUser.picture;
|
| 596 |
+
avatar.alt = currentUser.name;
|
| 597 |
+
} else {
|
| 598 |
+
avatar.style.background = "#1a73e8";
|
| 599 |
+
}
|
| 600 |
+
$("user-name").textContent = currentUser.name || currentUser.email || "User";
|
| 601 |
+
|
| 602 |
+
// 3. Scope sessions to user
|
| 603 |
+
sessionsKey = `orbit_sessions_${currentUser.id}`;
|
| 604 |
+
loadSessions();
|
| 605 |
+
|
| 606 |
+
// 4. Load server settings
|
| 607 |
+
const setRes = await fetch("/api/settings");
|
| 608 |
+
if (!setRes.ok) throw new Error("Failed to load settings");
|
| 609 |
+
appSettings = await setRes.json();
|
| 610 |
+
populateModelSelect();
|
| 611 |
+
|
| 612 |
+
// 5. Restore or create chat session
|
| 613 |
+
const ids = Object.keys(sessions).sort((a, b) => Number(b) - Number(a));
|
| 614 |
+
if (ids.length) {
|
| 615 |
+
currentSid = ids[0];
|
| 616 |
+
const msgs = sessions[currentSid]?.messages || [];
|
| 617 |
+
if (msgs.length) {
|
| 618 |
+
hideWelcome();
|
| 619 |
+
msgs.forEach(m => renderBubble(m.role, m.content, false));
|
| 620 |
+
const t = sessions[currentSid]?.title || "New Chat";
|
| 621 |
+
chatTitle.textContent = t;
|
| 622 |
+
scrollBottom();
|
| 623 |
+
} else {
|
| 624 |
+
showWelcome();
|
| 625 |
+
}
|
| 626 |
+
} else {
|
| 627 |
+
newSession();
|
| 628 |
+
showWelcome();
|
| 629 |
+
}
|
| 630 |
+
renderSidebar();
|
| 631 |
+
|
| 632 |
+
// 6. First-run hint if no API key
|
| 633 |
+
if (!appSettings.api_key) {
|
| 634 |
+
setTimeout(() => renderSys("Welcome to ORBIT! Open Settings to add your API key."), 500);
|
| 635 |
+
}
|
| 636 |
+
} catch (err) {
|
| 637 |
+
renderSys(`Startup error: ${err.message}`, true);
|
| 638 |
+
}
|
| 639 |
+
chatTextarea.focus();
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
init();
|
static/js/sw.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// βββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
// ORBIT Service Worker β sw.js
|
| 3 |
+
// Caches the app shell for offline/PWA support.
|
| 4 |
+
// βββββββββββββββββββββββββββββββββββββββββββββ
|
| 5 |
+
|
| 6 |
+
const CACHE_NAME = "orbit-v1";
|
| 7 |
+
const APP_SHELL = [
|
| 8 |
+
"/",
|
| 9 |
+
"/static/js/script.js",
|
| 10 |
+
"/static/manifest.json",
|
| 11 |
+
// Tailwind & Google Fonts are network-first; listed here for fallback
|
| 12 |
+
];
|
| 13 |
+
|
| 14 |
+
// Install: pre-cache shell assets
|
| 15 |
+
self.addEventListener("install", (event) => {
|
| 16 |
+
event.waitUntil(
|
| 17 |
+
caches.open(CACHE_NAME).then((cache) => {
|
| 18 |
+
return cache.addAll(APP_SHELL).catch((err) => {
|
| 19 |
+
console.warn("[SW] Pre-cache partial failure:", err);
|
| 20 |
+
});
|
| 21 |
+
})
|
| 22 |
+
);
|
| 23 |
+
self.skipWaiting();
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
// Activate: purge old caches
|
| 27 |
+
self.addEventListener("activate", (event) => {
|
| 28 |
+
event.waitUntil(
|
| 29 |
+
caches.keys().then((keys) =>
|
| 30 |
+
Promise.all(
|
| 31 |
+
keys
|
| 32 |
+
.filter((k) => k !== CACHE_NAME)
|
| 33 |
+
.map((k) => caches.delete(k))
|
| 34 |
+
)
|
| 35 |
+
)
|
| 36 |
+
);
|
| 37 |
+
self.clients.claim();
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
// Fetch: Network-first for API calls, Cache-first for static assets
|
| 41 |
+
self.addEventListener("fetch", (event) => {
|
| 42 |
+
const url = new URL(event.request.url);
|
| 43 |
+
|
| 44 |
+
// Never cache API endpoints β always go to network
|
| 45 |
+
if (url.pathname.startsWith("/api/")) {
|
| 46 |
+
event.respondWith(fetch(event.request));
|
| 47 |
+
return;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// Cache-first strategy for static assets
|
| 51 |
+
event.respondWith(
|
| 52 |
+
caches.match(event.request).then((cached) => {
|
| 53 |
+
if (cached) return cached;
|
| 54 |
+
return fetch(event.request)
|
| 55 |
+
.then((networkResponse) => {
|
| 56 |
+
// Cache successful GET responses for the shell
|
| 57 |
+
if (
|
| 58 |
+
networkResponse.ok &&
|
| 59 |
+
event.request.method === "GET" &&
|
| 60 |
+
(url.origin === self.location.origin ||
|
| 61 |
+
url.hostname.includes("googleapis.com") ||
|
| 62 |
+
url.hostname.includes("tailwindcss.com"))
|
| 63 |
+
) {
|
| 64 |
+
const clone = networkResponse.clone();
|
| 65 |
+
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
| 66 |
+
}
|
| 67 |
+
return networkResponse;
|
| 68 |
+
})
|
| 69 |
+
.catch(() => {
|
| 70 |
+
// Offline fallback: return cached root if page navigation fails
|
| 71 |
+
if (event.request.mode === "navigate") {
|
| 72 |
+
return caches.match("/");
|
| 73 |
+
}
|
| 74 |
+
});
|
| 75 |
+
})
|
| 76 |
+
);
|
| 77 |
+
});
|
static/manifest.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "ORBIT - Educational Research Assistant",
|
| 3 |
+
"short_name": "ORBIT",
|
| 4 |
+
"description": "Your AI-powered educational research assistant. Chat, analyze PDFs, and validate DOIs.",
|
| 5 |
+
"start_url": "/",
|
| 6 |
+
"display": "standalone",
|
| 7 |
+
"background_color": "#FFFFFF",
|
| 8 |
+
"theme_color": "#1a73e8",
|
| 9 |
+
"orientation": "portrait-primary",
|
| 10 |
+
"icons": [
|
| 11 |
+
{
|
| 12 |
+
"src": "/static/icons/icon-192.png",
|
| 13 |
+
"sizes": "192x192",
|
| 14 |
+
"type": "image/png",
|
| 15 |
+
"purpose": "any maskable"
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"src": "/static/icons/icon-512.png",
|
| 19 |
+
"sizes": "512x512",
|
| 20 |
+
"type": "image/png",
|
| 21 |
+
"purpose": "any maskable"
|
| 22 |
+
}
|
| 23 |
+
],
|
| 24 |
+
"categories": ["education", "productivity"],
|
| 25 |
+
"screenshots": []
|
| 26 |
+
}
|
static/orbit.png
ADDED
|
Git LFS Details
|
templates/index.html
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>ORBIT β Educational Research Assistant</title>
|
| 8 |
+
<link rel="icon" type="image/png" href="/static/icon.png" />
|
| 9 |
+
<meta name="description" content="ORBIT AI-powered educational research assistant." />
|
| 10 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 11 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 12 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
| 13 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 14 |
+
<script>
|
| 15 |
+
tailwind.config = {
|
| 16 |
+
theme: {
|
| 17 |
+
extend: {
|
| 18 |
+
fontFamily: { sans: ['Inter', 'sans-serif'] },
|
| 19 |
+
colors: {
|
| 20 |
+
accent: { DEFAULT: '#1a73e8', hover: '#1557b0', light: '#e8f0fe' }
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
},
|
| 24 |
+
};
|
| 25 |
+
</script>
|
| 26 |
+
<style>
|
| 27 |
+
* {
|
| 28 |
+
box-sizing: border-box;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
body {
|
| 32 |
+
font-family: 'Inter', sans-serif;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
::-webkit-scrollbar {
|
| 36 |
+
width: 5px;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
::-webkit-scrollbar-track {
|
| 40 |
+
background: transparent;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
::-webkit-scrollbar-thumb {
|
| 44 |
+
background: #d1d5db;
|
| 45 |
+
border-radius: 99px;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
#chat-textarea {
|
| 49 |
+
resize: none;
|
| 50 |
+
min-height: 24px;
|
| 51 |
+
max-height: 160px;
|
| 52 |
+
overflow-y: auto;
|
| 53 |
+
line-height: 1.5;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.bubble-ai {
|
| 57 |
+
animation: fadeUp .25s ease both;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.bubble-user {
|
| 61 |
+
animation: fadeUp .20s ease both;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
@keyframes fadeUp {
|
| 65 |
+
from {
|
| 66 |
+
opacity: 0;
|
| 67 |
+
transform: translateY(8px);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
to {
|
| 71 |
+
opacity: 1;
|
| 72 |
+
transform: translateY(0);
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.typing-dot {
|
| 77 |
+
animation: blink 1.2s infinite;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.typing-dot:nth-child(2) {
|
| 81 |
+
animation-delay: .2s;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.typing-dot:nth-child(3) {
|
| 85 |
+
animation-delay: .4s;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
@keyframes blink {
|
| 89 |
+
|
| 90 |
+
0%,
|
| 91 |
+
80%,
|
| 92 |
+
100% {
|
| 93 |
+
opacity: .2;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
40% {
|
| 97 |
+
opacity: 1;
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.sidebar-item {
|
| 102 |
+
transition: background .13s, color .13s;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.modal-back {
|
| 106 |
+
animation: fadeIn .18s ease;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.modal-box {
|
| 110 |
+
animation: scaleIn .22s cubic-bezier(.34, 1.56, .64, 1);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
@keyframes fadeIn {
|
| 114 |
+
from {
|
| 115 |
+
opacity: 0;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
to {
|
| 119 |
+
opacity: 1;
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
@keyframes scaleIn {
|
| 124 |
+
from {
|
| 125 |
+
opacity: 0;
|
| 126 |
+
transform: scale(.95);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
to {
|
| 130 |
+
opacity: 1;
|
| 131 |
+
transform: scale(1);
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/* Markdown */
|
| 136 |
+
.prose-orbit h1,
|
| 137 |
+
.prose-orbit h2,
|
| 138 |
+
.prose-orbit h3 {
|
| 139 |
+
font-weight: 600;
|
| 140 |
+
margin: .6em 0 .3em;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.prose-orbit p {
|
| 144 |
+
margin: .25em 0;
|
| 145 |
+
line-height: 1.7;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.prose-orbit ul {
|
| 149 |
+
list-style: disc;
|
| 150 |
+
padding-left: 1.4em;
|
| 151 |
+
margin: .3em 0;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.prose-orbit ol {
|
| 155 |
+
list-style: decimal;
|
| 156 |
+
padding-left: 1.4em;
|
| 157 |
+
margin: .3em 0;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.prose-orbit li {
|
| 161 |
+
margin: .2em 0;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.prose-orbit code {
|
| 165 |
+
background: #f0f4f9;
|
| 166 |
+
padding: .1em .35em;
|
| 167 |
+
border-radius: 4px;
|
| 168 |
+
font-size: .87em;
|
| 169 |
+
font-family: monospace;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.prose-orbit pre {
|
| 173 |
+
background: #f0f4f9;
|
| 174 |
+
border-radius: 8px;
|
| 175 |
+
padding: 1em;
|
| 176 |
+
overflow-x: auto;
|
| 177 |
+
margin: .5em 0;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.prose-orbit pre code {
|
| 181 |
+
background: none;
|
| 182 |
+
padding: 0;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.prose-orbit strong {
|
| 186 |
+
font-weight: 600;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.prose-orbit a {
|
| 190 |
+
color: #1a73e8;
|
| 191 |
+
text-decoration: underline;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.prose-orbit blockquote {
|
| 195 |
+
border-left: 3px solid #e0e0e0;
|
| 196 |
+
padding-left: .75em;
|
| 197 |
+
color: #6b7280;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/* Input pill shadow */
|
| 201 |
+
.pill-shadow {
|
| 202 |
+
box-shadow: 0 4px 28px rgba(26, 115, 232, 0.10), 0 1px 6px rgba(0, 0, 0, 0.07);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
/* Sidebar slide transition */
|
| 206 |
+
#sidebar {
|
| 207 |
+
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
/* iOS safe-area bottom padding */
|
| 211 |
+
.safe-bottom {
|
| 212 |
+
padding-bottom: max(1.25rem, env(safe-area-inset-bottom));
|
| 213 |
+
}
|
| 214 |
+
</style>
|
| 215 |
+
</head>
|
| 216 |
+
|
| 217 |
+
<body class="flex flex-col md:flex-row h-screen overflow-hidden bg-white text-gray-800 antialiased">
|
| 218 |
+
|
| 219 |
+
<!-- βββββββββββββββββββββββββββββββββββ
|
| 220 |
+
MOBILE TOP NAVBAR (hidden on md+)
|
| 221 |
+
βββββββββββββββββββββββββββββββββββ -->
|
| 222 |
+
<nav class="md:hidden flex items-center px-4 h-14 bg-white border-b border-gray-100 shrink-0 z-40 relative">
|
| 223 |
+
<button id="btn-hamburger" class="p-2 rounded-lg hover:bg-gray-100 transition-colors mr-3" aria-label="Open menu">
|
| 224 |
+
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
| 225 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
| 226 |
+
</svg>
|
| 227 |
+
</button>
|
| 228 |
+
<div class="flex items-center gap-2">
|
| 229 |
+
<div class="w-7 h-7 rounded-full flex items-center justify-center shadow-sm overflow-hidden bg-white">
|
| 230 |
+
<img src="/static/icon.png" alt="ORBIT Logo" class="w-full h-full object-cover" />
|
| 231 |
+
</div>
|
| 232 |
+
<span class="font-bold text-base text-accent tracking-tight">ORBIT</span>
|
| 233 |
+
</div>
|
| 234 |
+
<!-- Mobile Clear Chat Button -->
|
| 235 |
+
<button id="btn-clear-chat-mobile" class="ml-auto p-2 text-gray-400 hover:text-red-400 transition-colors"
|
| 236 |
+
title="Clear Chat">
|
| 237 |
+
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
| 238 |
+
<path stroke-linecap="round" stroke-linejoin="round"
|
| 239 |
+
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
| 240 |
+
</svg>
|
| 241 |
+
</button>
|
| 242 |
+
</nav>
|
| 243 |
+
|
| 244 |
+
<!-- Mobile overlay backdrop -->
|
| 245 |
+
<div id="sidebar-overlay" class="hidden fixed inset-0 bg-black bg-opacity-50 z-40 md:hidden" aria-hidden="true"></div>
|
| 246 |
+
|
| 247 |
+
<!-- βββββββββββββββββββββββββββββββββββ
|
| 248 |
+
SIDEBAR
|
| 249 |
+
βββββββββββββββββββββββββββββββββββ -->
|
| 250 |
+
<aside id="sidebar"
|
| 251 |
+
class="fixed md:relative flex flex-col w-64 min-w-[256px] bg-[#F8F9FA] border-r border-gray-100 h-full md:h-screen z-50 shrink-0 -translate-x-full md:translate-x-0">
|
| 252 |
+
|
| 253 |
+
<!-- Logo -->
|
| 254 |
+
<div class="flex items-center gap-2.5 px-5 pt-6 pb-4">
|
| 255 |
+
<div class="w-8 h-8 rounded-full flex items-center justify-center shadow-sm overflow-hidden bg-white">
|
| 256 |
+
<img src="/static/icon.png" alt="ORBIT Logo" class="w-full h-full object-cover" />
|
| 257 |
+
</div>
|
| 258 |
+
<span class="font-bold text-[17px] text-accent tracking-tight">ORBIT</span>
|
| 259 |
+
</div>
|
| 260 |
+
|
| 261 |
+
<!-- New Chat -->
|
| 262 |
+
<div class="px-3 pb-3">
|
| 263 |
+
<button id="btn-new-chat"
|
| 264 |
+
class="w-full flex items-center justify-center gap-2 bg-accent hover:bg-accent-hover text-white font-semibold text-sm rounded-xl h-9 shadow-sm transition-colors">
|
| 265 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
| 266 |
+
<path d="M12 5v14M5 12h14" />
|
| 267 |
+
</svg>
|
| 268 |
+
New Chat
|
| 269 |
+
</button>
|
| 270 |
+
</div>
|
| 271 |
+
|
| 272 |
+
<!-- History label -->
|
| 273 |
+
<div class="px-4 pb-1">
|
| 274 |
+
<p class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest">Recent</p>
|
| 275 |
+
</div>
|
| 276 |
+
|
| 277 |
+
<!-- History list -->
|
| 278 |
+
<div id="history-list" class="flex-1 overflow-y-auto px-3 space-y-0.5 pb-2"></div>
|
| 279 |
+
|
| 280 |
+
<!-- Bottom nav -->
|
| 281 |
+
<div class="border-t border-gray-200 pt-2 px-3 space-y-0.5 pb-1">
|
| 282 |
+
<button id="btn-doi"
|
| 283 |
+
class="sidebar-item w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-gray-600 hover:bg-white hover:text-accent hover:shadow-sm">
|
| 284 |
+
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
| 285 |
+
<path
|
| 286 |
+
d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
| 287 |
+
</svg>
|
| 288 |
+
Validate DOI
|
| 289 |
+
</button>
|
| 290 |
+
<a id="btn-howto" href="/static/docs/dokumen.pdf" target="_blank"
|
| 291 |
+
class="sidebar-item w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-gray-600 hover:bg-white hover:text-accent hover:shadow-sm">
|
| 292 |
+
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
| 293 |
+
<path d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" stroke-linecap="round" stroke-linejoin="round"/>
|
| 294 |
+
</svg>
|
| 295 |
+
How To
|
| 296 |
+
</a>
|
| 297 |
+
<button id="btn-settings"
|
| 298 |
+
class="sidebar-item w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-gray-600 hover:bg-white hover:text-accent hover:shadow-sm">
|
| 299 |
+
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
| 300 |
+
<path
|
| 301 |
+
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
| 302 |
+
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
| 303 |
+
</svg>
|
| 304 |
+
Settings
|
| 305 |
+
</button>
|
| 306 |
+
</div>
|
| 307 |
+
|
| 308 |
+
<!-- User Profile -->
|
| 309 |
+
<div class="border-t border-gray-100 px-4 py-3 flex items-center gap-3">
|
| 310 |
+
<img id="user-avatar" src="" alt="Profile"
|
| 311 |
+
class="w-8 h-8 rounded-full object-cover bg-accent-light ring-1 ring-gray-200 shrink-0" />
|
| 312 |
+
<div class="flex-1 min-w-0">
|
| 313 |
+
<p id="user-name" class="text-xs font-semibold text-gray-700 truncate">Loadingβ¦</p>
|
| 314 |
+
<a href="/auth/logout" class="text-[10px] text-gray-400 hover:text-red-400 transition-colors">Sign out</a>
|
| 315 |
+
</div>
|
| 316 |
+
</div>
|
| 317 |
+
</aside>
|
| 318 |
+
|
| 319 |
+
<!-- βββββββββββββββββββββββββββββββββββ
|
| 320 |
+
MAIN CONTENT
|
| 321 |
+
βββββββββββββββββββββββββββββββββββ -->
|
| 322 |
+
<main class="flex-1 flex flex-col h-screen md:h-screen overflow-hidden bg-white relative w-full min-w-0">
|
| 323 |
+
|
| 324 |
+
<!-- Desktop Top bar (hidden on mobile β mobile has its own navbar above) -->
|
| 325 |
+
<header class="hidden md:flex items-center justify-between px-5 py-3 border-b border-gray-100 shrink-0">
|
| 326 |
+
<button id="btn-toggle-sidebar" class="p-1.5 rounded-lg hover:bg-gray-100 transition-colors"
|
| 327 |
+
title="Toggle Sidebar">
|
| 328 |
+
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
| 329 |
+
<path d="M4 6h16M4 12h16M4 18h16" />
|
| 330 |
+
</svg>
|
| 331 |
+
</button>
|
| 332 |
+
<span id="chat-title" class="text-sm font-medium text-gray-400">New Chat</span>
|
| 333 |
+
<button id="btn-clear-chat"
|
| 334 |
+
class="text-xs text-gray-400 hover:text-red-400 transition-colors px-2 py-1 rounded-lg hover:bg-red-50">Clear</button>
|
| 335 |
+
</header>
|
| 336 |
+
|
| 337 |
+
<!-- Welcome screen (shown when chat is empty) -->
|
| 338 |
+
<div id="welcome-screen"
|
| 339 |
+
class="absolute inset-0 top-[40px] md:top-[53px] bottom-[96px] flex flex-col items-center justify-center pointer-events-none z-10">
|
| 340 |
+
<div class="text-center select-none">
|
| 341 |
+
<div class="w-20 h-20 mx-auto mb-4 drop-shadow-md">
|
| 342 |
+
<img src="/static/orbit.png" alt="ORBIT" class="w-full h-full object-contain" />
|
| 343 |
+
</div>
|
| 344 |
+
<h1 class="text-2xl font-bold text-gray-800 mb-1.5 tracking-tight">Welcome to ORBIT</h1>
|
| 345 |
+
<p class="text-sm text-gray-400 font-medium">Your Educational Research Assistant</p>
|
| 346 |
+
</div>
|
| 347 |
+
</div>
|
| 348 |
+
|
| 349 |
+
<!-- Chat messages -->
|
| 350 |
+
<div id="chat-messages" class="flex-1 overflow-y-auto relative z-0 pb-2"></div>
|
| 351 |
+
|
| 352 |
+
<!-- Attachment badge -->
|
| 353 |
+
<div id="attach-badge" class="hidden shrink-0 mx-auto w-full max-w-2xl px-5 pb-1">
|
| 354 |
+
<div
|
| 355 |
+
class="inline-flex items-center gap-2 bg-accent-light text-accent text-xs font-semibold px-3 py-1.5 rounded-full">
|
| 356 |
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
| 357 |
+
<path stroke-linecap="round" stroke-linejoin="round"
|
| 358 |
+
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" />
|
| 359 |
+
</svg>
|
| 360 |
+
<span id="attach-name">document.pdf</span>
|
| 361 |
+
<button id="btn-remove-attach"
|
| 362 |
+
class="ml-0.5 opacity-60 hover:opacity-100 transition-opacity font-bold">β</button>
|
| 363 |
+
</div>
|
| 364 |
+
</div>
|
| 365 |
+
|
| 366 |
+
<!-- ββ Floating Pill Input Bar (ALWAYS VISIBLE) ββ -->
|
| 367 |
+
<div class="shrink-0 px-3 md:px-4 pt-2 pb-2 safe-bottom relative z-20">
|
| 368 |
+
<div class="max-w-2xl mx-auto flex items-center justify-between px-2 pb-2">
|
| 369 |
+
<select id="model-select"
|
| 370 |
+
class="bg-transparent border-none text-[11px] text-gray-400 font-medium focus:outline-none cursor-pointer max-w-[170px] px-1 hover:text-gray-600 transition-colors appearance-none">
|
| 371 |
+
</select>
|
| 372 |
+
<p class="hidden md:block text-[10px] text-gray-300">ORBIT may make errors. Verify important academic
|
| 373 |
+
information.</p>
|
| 374 |
+
</div>
|
| 375 |
+
|
| 376 |
+
<div id="input-pill"
|
| 377 |
+
class="w-full max-w-2xl mx-auto bg-[#F8F9FA] rounded-[26px] pill-shadow flex flex-col px-2 py-2 border border-gray-200/70">
|
| 378 |
+
|
| 379 |
+
<!-- Main row -->
|
| 380 |
+
<div class="flex items-end gap-1.5">
|
| 381 |
+
|
| 382 |
+
<!-- Attach PDF button -->
|
| 383 |
+
<button id="btn-attach" title="Attach PDF"
|
| 384 |
+
class="shrink-0 w-9 h-9 flex items-center justify-center rounded-full hover:bg-white transition-colors text-gray-400 hover:text-accent">
|
| 385 |
+
<svg class="w-[18px] h-[18px]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
| 386 |
+
<path stroke-linecap="round" stroke-linejoin="round"
|
| 387 |
+
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" />
|
| 388 |
+
</svg>
|
| 389 |
+
</button>
|
| 390 |
+
<input id="pdf-input" type="file" accept=".pdf" class="hidden" />
|
| 391 |
+
|
| 392 |
+
<!-- Textarea -->
|
| 393 |
+
<textarea id="chat-textarea" rows="1" placeholder="Ask ORBIT anythingβ¦"
|
| 394 |
+
class="flex-1 bg-transparent border-none outline-none resize-none text-sm text-gray-800 placeholder-gray-400 py-1.5 leading-relaxed"></textarea>
|
| 395 |
+
|
| 396 |
+
<!-- Send button -->
|
| 397 |
+
<button id="btn-send"
|
| 398 |
+
class="shrink-0 w-9 h-9 flex items-center justify-center rounded-full bg-accent hover:bg-accent-hover text-white transition-all shadow-sm disabled:opacity-40 disabled:cursor-not-allowed">
|
| 399 |
+
<svg id="send-icon" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5"
|
| 400 |
+
viewBox="0 0 24 24">
|
| 401 |
+
<path stroke-linecap="round" stroke-linejoin="round"
|
| 402 |
+
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
|
| 403 |
+
</svg>
|
| 404 |
+
<svg id="loading-icon" class="w-4 h-4 animate-spin hidden" fill="none" viewBox="0 0 24 24">
|
| 405 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
| 406 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
| 407 |
+
</svg>
|
| 408 |
+
</button>
|
| 409 |
+
</div>
|
| 410 |
+
</div>
|
| 411 |
+
</div>
|
| 412 |
+
</main>
|
| 413 |
+
|
| 414 |
+
<!-- βββββββββββββββββββββββββββββββββββ
|
| 415 |
+
SETTINGS MODAL
|
| 416 |
+
βββββββββββββββββββββββββββββββββββ -->
|
| 417 |
+
<div id="settings-modal"
|
| 418 |
+
class="hidden fixed inset-0 z-50 flex items-center justify-center modal-back bg-black/30 backdrop-blur-sm">
|
| 419 |
+
<div class="modal-box bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
| 420 |
+
<div class="flex items-center justify-between px-6 pt-6 pb-4 border-b border-gray-100">
|
| 421 |
+
<h2 class="text-base font-bold text-gray-800">Settings</h2>
|
| 422 |
+
<button id="btn-close-settings"
|
| 423 |
+
class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors">
|
| 424 |
+
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
| 425 |
+
<path d="M6 18L18 6M6 6l12 12" />
|
| 426 |
+
</svg>
|
| 427 |
+
</button>
|
| 428 |
+
</div>
|
| 429 |
+
<div class="px-6 py-5 space-y-5">
|
| 430 |
+
|
| 431 |
+
<!-- Provider -->
|
| 432 |
+
<div>
|
| 433 |
+
<label class="block text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-1.5">Provider</label>
|
| 434 |
+
<select id="settings-provider"
|
| 435 |
+
class="w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-accent bg-[#f8f9fa]">
|
| 436 |
+
<option value="OpenRouter">OpenRouter</option>
|
| 437 |
+
<option value="Nvidia NIM">Nvidia NIM</option>
|
| 438 |
+
<option value="Google Gemini">Google Gemini</option>
|
| 439 |
+
<option value="AgentRouter">AgentRouter</option>
|
| 440 |
+
<option value="Custom OpenAI">Custom OpenAI-Compatible</option>
|
| 441 |
+
</select>
|
| 442 |
+
</div>
|
| 443 |
+
|
| 444 |
+
<!-- Base URL -->
|
| 445 |
+
<div>
|
| 446 |
+
<label class="block text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-1.5">Base URL</label>
|
| 447 |
+
<input id="settings-url" type="text"
|
| 448 |
+
class="w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-accent bg-[#f8f9fa]" />
|
| 449 |
+
</div>
|
| 450 |
+
|
| 451 |
+
<!-- API Key -->
|
| 452 |
+
<div>
|
| 453 |
+
<label class="block text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-1.5">API Key</label>
|
| 454 |
+
<div class="relative">
|
| 455 |
+
<input id="settings-apikey" type="password" placeholder="sk-or-v1-β¦"
|
| 456 |
+
class="w-full border border-gray-200 rounded-xl px-3 py-2.5 pr-10 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-accent bg-[#f8f9fa]" />
|
| 457 |
+
<button id="btn-toggle-key"
|
| 458 |
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors">
|
| 459 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
| 460 |
+
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
| 461 |
+
<path
|
| 462 |
+
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
| 463 |
+
</svg>
|
| 464 |
+
</button>
|
| 465 |
+
</div>
|
| 466 |
+
</div>
|
| 467 |
+
|
| 468 |
+
<!-- OpenRouter Models -->
|
| 469 |
+
<div id="section-models-openrouter">
|
| 470 |
+
<label class="block text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-1.5">OpenRouter
|
| 471 |
+
Models</label>
|
| 472 |
+
<div id="models-list-openrouter"
|
| 473 |
+
class="bg-[#f8f9fa] border border-gray-200 rounded-xl p-2 max-h-32 overflow-y-auto space-y-1 mb-2"></div>
|
| 474 |
+
<div class="flex gap-2">
|
| 475 |
+
<input id="new-model-or" type="text" placeholder="model-id (OpenRouter)"
|
| 476 |
+
class="flex-1 border border-gray-200 rounded-xl px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-accent bg-[#f8f9fa]" />
|
| 477 |
+
<button id="btn-add-model-or"
|
| 478 |
+
class="px-4 py-2 bg-accent hover:bg-accent-hover text-white text-sm font-semibold rounded-xl transition-colors">Add</button>
|
| 479 |
+
</div>
|
| 480 |
+
</div>
|
| 481 |
+
|
| 482 |
+
<!-- Nvidia NIM Models -->
|
| 483 |
+
<div id="section-models-nvidia">
|
| 484 |
+
<label class="block text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-1.5">Nvidia NIM
|
| 485 |
+
Models</label>
|
| 486 |
+
<div id="models-list-nvidia"
|
| 487 |
+
class="bg-[#f8f9fa] border border-gray-200 rounded-xl p-2 max-h-32 overflow-y-auto space-y-1 mb-2"></div>
|
| 488 |
+
<div class="flex gap-2">
|
| 489 |
+
<input id="new-model-nv" type="text" placeholder="model-id (Nvidia NIM)"
|
| 490 |
+
class="flex-1 border border-gray-200 rounded-xl px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-accent bg-[#f8f9fa]" />
|
| 491 |
+
<button id="btn-add-model-nv"
|
| 492 |
+
class="px-4 py-2 bg-accent hover:bg-accent-hover text-white text-sm font-semibold rounded-xl transition-colors">Add</button>
|
| 493 |
+
</div>
|
| 494 |
+
</div>
|
| 495 |
+
|
| 496 |
+
</div>
|
| 497 |
+
<div class="px-6 pb-6 flex gap-3 justify-end border-t border-gray-100 pt-4">
|
| 498 |
+
<button id="btn-cancel-settings"
|
| 499 |
+
class="px-5 py-2.5 text-sm font-medium text-gray-600 hover:bg-gray-100 rounded-xl transition-colors">Cancel</button>
|
| 500 |
+
<button id="btn-save-settings"
|
| 501 |
+
class="px-5 py-2.5 text-sm font-semibold bg-accent hover:bg-accent-hover text-white rounded-xl shadow-sm transition-colors">Save
|
| 502 |
+
Settings</button>
|
| 503 |
+
</div>
|
| 504 |
+
</div>
|
| 505 |
+
</div>
|
| 506 |
+
|
| 507 |
+
<!-- βββββββββββββββββββββββββββββββββββ
|
| 508 |
+
DOI MODAL
|
| 509 |
+
βββββββββββββββββββββββββββββββββββ -->
|
| 510 |
+
<div id="doi-modal"
|
| 511 |
+
class="hidden fixed inset-0 z-50 flex items-center justify-center modal-back bg-black/30 backdrop-blur-sm">
|
| 512 |
+
<div class="modal-box bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4">
|
| 513 |
+
<div class="flex items-center justify-between px-6 pt-6 pb-4 border-b border-gray-100">
|
| 514 |
+
<h2 class="text-base font-bold text-gray-800">Validate DOI</h2>
|
| 515 |
+
<button id="btn-close-doi"
|
| 516 |
+
class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors">
|
| 517 |
+
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
| 518 |
+
<path d="M6 18L18 6M6 6l12 12" />
|
| 519 |
+
</svg>
|
| 520 |
+
</button>
|
| 521 |
+
</div>
|
| 522 |
+
<div class="px-6 py-5 space-y-4">
|
| 523 |
+
<input id="doi-input" type="text" placeholder="e.g. 10.1000/xyz123 or https://doi.org/β¦"
|
| 524 |
+
class="w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-accent bg-[#f8f9fa]" />
|
| 525 |
+
<button id="btn-validate-doi"
|
| 526 |
+
class="w-full py-2.5 bg-accent hover:bg-accent-hover text-white text-sm font-semibold rounded-xl shadow-sm transition-colors flex items-center justify-center gap-2">
|
| 527 |
+
<span id="doi-btn-text">Validate</span>
|
| 528 |
+
<svg id="doi-spinner" class="w-4 h-4 animate-spin hidden" fill="none" viewBox="0 0 24 24">
|
| 529 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
| 530 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
| 531 |
+
</svg>
|
| 532 |
+
</button>
|
| 533 |
+
<div id="doi-result" class="hidden bg-[#f8f9fa] border border-gray-200 rounded-xl p-4 text-sm text-gray-700">
|
| 534 |
+
</div>
|
| 535 |
+
</div>
|
| 536 |
+
</div>
|
| 537 |
+
</div>
|
| 538 |
+
|
| 539 |
+
<script src="/static/js/script.js"></script>
|
| 540 |
+
</body>
|
| 541 |
+
|
| 542 |
+
</html>
|
templates/login.html
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>ORBIT β Sign In</title>
|
| 7 |
+
<link rel="icon" type="image/png" href="/static/icon.png" />
|
| 8 |
+
<meta name="description" content="Sign in to ORBIT, your AI-powered educational research assistant." />
|
| 9 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 10 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 11 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
| 12 |
+
<style>
|
| 13 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 14 |
+
|
| 15 |
+
:root {
|
| 16 |
+
--accent: #1a73e8;
|
| 17 |
+
--accent-dark: #1557b0;
|
| 18 |
+
--accent-light: #e8f0fe;
|
| 19 |
+
--bg: #f0f4f9;
|
| 20 |
+
--white: #ffffff;
|
| 21 |
+
--gray-100: #f1f3f5;
|
| 22 |
+
--gray-200: #e2e6ea;
|
| 23 |
+
--gray-400: #9aa0a6;
|
| 24 |
+
--gray-700: #3c4043;
|
| 25 |
+
--gray-800: #202124;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
html, body {
|
| 29 |
+
height: 100%;
|
| 30 |
+
font-family: 'Inter', system-ui, sans-serif;
|
| 31 |
+
background: linear-gradient(135deg, #e8f0fe 0%, #f0f4f9 40%, #dce8fb 100%);
|
| 32 |
+
display: flex;
|
| 33 |
+
align-items: center;
|
| 34 |
+
justify-content: center;
|
| 35 |
+
overflow: hidden;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* ββ Animated background blobs ββ */
|
| 39 |
+
.bg-blob {
|
| 40 |
+
position: fixed;
|
| 41 |
+
border-radius: 50%;
|
| 42 |
+
filter: blur(80px);
|
| 43 |
+
opacity: 0.25;
|
| 44 |
+
pointer-events: none;
|
| 45 |
+
}
|
| 46 |
+
.bg-blob-1 {
|
| 47 |
+
width: 500px; height: 500px;
|
| 48 |
+
background: #1a73e8;
|
| 49 |
+
top: -150px; right: -150px;
|
| 50 |
+
animation: blobFloat 18s ease-in-out infinite alternate;
|
| 51 |
+
}
|
| 52 |
+
.bg-blob-2 {
|
| 53 |
+
width: 400px; height: 400px;
|
| 54 |
+
background: #4285f4;
|
| 55 |
+
bottom: -120px; left: -120px;
|
| 56 |
+
animation: blobFloat 22s ease-in-out infinite alternate-reverse;
|
| 57 |
+
}
|
| 58 |
+
.bg-blob-3 {
|
| 59 |
+
width: 250px; height: 250px;
|
| 60 |
+
background: #34a853;
|
| 61 |
+
top: 60%; left: 60%;
|
| 62 |
+
opacity: 0.10;
|
| 63 |
+
animation: blobFloat 14s ease-in-out infinite;
|
| 64 |
+
}
|
| 65 |
+
@keyframes blobFloat {
|
| 66 |
+
from { transform: translate(0, 0) scale(1); }
|
| 67 |
+
to { transform: translate(30px, 20px) scale(1.08); }
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/* ββ Card ββ */
|
| 71 |
+
.card {
|
| 72 |
+
position: relative;
|
| 73 |
+
background: rgba(255, 255, 255, 0.82);
|
| 74 |
+
backdrop-filter: blur(24px) saturate(180%);
|
| 75 |
+
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
| 76 |
+
border: 1px solid rgba(255, 255, 255, 0.70);
|
| 77 |
+
border-radius: 28px;
|
| 78 |
+
box-shadow:
|
| 79 |
+
0 32px 80px rgba(26, 115, 232, 0.12),
|
| 80 |
+
0 8px 24px rgba(0, 0, 0, 0.07),
|
| 81 |
+
inset 0 1px 0 rgba(255,255,255,0.9);
|
| 82 |
+
width: 100%;
|
| 83 |
+
max-width: 380px;
|
| 84 |
+
padding: 48px 40px 40px;
|
| 85 |
+
display: flex;
|
| 86 |
+
flex-direction: column;
|
| 87 |
+
align-items: center;
|
| 88 |
+
text-align: center;
|
| 89 |
+
z-index: 10;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/* ββ Logo ββ */
|
| 93 |
+
.logo-wrap {
|
| 94 |
+
position: relative;
|
| 95 |
+
width: 120px; height: 120px;
|
| 96 |
+
margin-bottom: 28px;
|
| 97 |
+
}
|
| 98 |
+
.logo-ring {
|
| 99 |
+
position: absolute;
|
| 100 |
+
inset: -8px;
|
| 101 |
+
border-radius: 50%;
|
| 102 |
+
border: 2px dashed rgba(26, 115, 232, 0.35);
|
| 103 |
+
animation: spin 14s linear infinite;
|
| 104 |
+
}
|
| 105 |
+
.logo-ring-inner {
|
| 106 |
+
position: absolute;
|
| 107 |
+
inset: -14px;
|
| 108 |
+
border-radius: 50%;
|
| 109 |
+
border: 1.5px dashed rgba(26, 115, 232, 0.15);
|
| 110 |
+
animation: spin 22s linear infinite reverse;
|
| 111 |
+
}
|
| 112 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 113 |
+
.logo-circle {
|
| 114 |
+
width: 72px; height: 72px;
|
| 115 |
+
border-radius: 50%;
|
| 116 |
+
background: linear-gradient(135deg, #1a73e8, #4285f4);
|
| 117 |
+
box-shadow: 0 8px 24px rgba(26, 115, 232, 0.40), 0 2px 8px rgba(26,115,232,0.20);
|
| 118 |
+
display: flex;
|
| 119 |
+
align-items: center;
|
| 120 |
+
justify-content: center;
|
| 121 |
+
}
|
| 122 |
+
.logo-circle svg {
|
| 123 |
+
width: 32px; height: 32px;
|
| 124 |
+
color: #fff;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/* ββ Typography ββ */
|
| 128 |
+
.title {
|
| 129 |
+
font-size: 26px;
|
| 130 |
+
font-weight: 700;
|
| 131 |
+
color: var(--gray-800);
|
| 132 |
+
letter-spacing: -0.4px;
|
| 133 |
+
margin-bottom: 6px;
|
| 134 |
+
}
|
| 135 |
+
.subtitle {
|
| 136 |
+
font-size: 13.5px;
|
| 137 |
+
color: var(--gray-400);
|
| 138 |
+
font-weight: 400;
|
| 139 |
+
margin-bottom: 36px;
|
| 140 |
+
line-height: 1.5;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/* ββ Divider ββ */
|
| 144 |
+
.divider {
|
| 145 |
+
width: 100%;
|
| 146 |
+
display: flex;
|
| 147 |
+
align-items: center;
|
| 148 |
+
gap: 12px;
|
| 149 |
+
margin-bottom: 20px;
|
| 150 |
+
}
|
| 151 |
+
.divider-line {
|
| 152 |
+
flex: 1;
|
| 153 |
+
height: 1px;
|
| 154 |
+
background: var(--gray-200);
|
| 155 |
+
}
|
| 156 |
+
.divider-text {
|
| 157 |
+
font-size: 11px;
|
| 158 |
+
color: var(--gray-400);
|
| 159 |
+
font-weight: 500;
|
| 160 |
+
text-transform: uppercase;
|
| 161 |
+
letter-spacing: 0.08em;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/* ββ Google Button ββ */
|
| 165 |
+
.google-btn {
|
| 166 |
+
display: flex;
|
| 167 |
+
align-items: center;
|
| 168 |
+
justify-content: center;
|
| 169 |
+
gap: 10px;
|
| 170 |
+
width: 100%;
|
| 171 |
+
padding: 13px 20px;
|
| 172 |
+
background: var(--white);
|
| 173 |
+
border: 1.5px solid var(--gray-200);
|
| 174 |
+
border-radius: 16px;
|
| 175 |
+
font-family: 'Inter', sans-serif;
|
| 176 |
+
font-size: 14px;
|
| 177 |
+
font-weight: 600;
|
| 178 |
+
color: var(--gray-700);
|
| 179 |
+
cursor: pointer;
|
| 180 |
+
text-decoration: none;
|
| 181 |
+
transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
| 182 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
| 183 |
+
position: relative;
|
| 184 |
+
overflow: hidden;
|
| 185 |
+
}
|
| 186 |
+
.google-btn::before {
|
| 187 |
+
content: '';
|
| 188 |
+
position: absolute;
|
| 189 |
+
inset: 0;
|
| 190 |
+
background: linear-gradient(135deg, transparent, rgba(26,115,232,0.04));
|
| 191 |
+
opacity: 0;
|
| 192 |
+
transition: opacity 0.18s;
|
| 193 |
+
}
|
| 194 |
+
.google-btn:hover {
|
| 195 |
+
border-color: #aac4ef;
|
| 196 |
+
box-shadow: 0 6px 20px rgba(26, 115, 232, 0.16);
|
| 197 |
+
transform: translateY(-1px);
|
| 198 |
+
color: var(--accent);
|
| 199 |
+
}
|
| 200 |
+
.google-btn:hover::before { opacity: 1; }
|
| 201 |
+
.google-btn:active {
|
| 202 |
+
transform: translateY(0);
|
| 203 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
| 204 |
+
}
|
| 205 |
+
.google-btn svg { flex-shrink: 0; }
|
| 206 |
+
|
| 207 |
+
/* ββ Footer note ββ */
|
| 208 |
+
.footer-note {
|
| 209 |
+
font-size: 11px;
|
| 210 |
+
color: #bcc5d0;
|
| 211 |
+
margin-top: 24px;
|
| 212 |
+
line-height: 1.65;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
/* ββ Beta badge ββ */
|
| 216 |
+
.beta-badge {
|
| 217 |
+
display: inline-flex;
|
| 218 |
+
align-items: center;
|
| 219 |
+
gap: 5px;
|
| 220 |
+
background: linear-gradient(135deg, #e8f0fe, #dce8fb);
|
| 221 |
+
color: var(--accent);
|
| 222 |
+
font-size: 10.5px;
|
| 223 |
+
font-weight: 600;
|
| 224 |
+
letter-spacing: 0.06em;
|
| 225 |
+
text-transform: uppercase;
|
| 226 |
+
padding: 4px 10px;
|
| 227 |
+
border-radius: 99px;
|
| 228 |
+
border: 1px solid rgba(26,115,232,0.18);
|
| 229 |
+
margin-bottom: 14px;
|
| 230 |
+
}
|
| 231 |
+
.beta-badge span { opacity: 0.7; }
|
| 232 |
+
|
| 233 |
+
/* ββ Responsive ββ */
|
| 234 |
+
@media (max-width: 480px) {
|
| 235 |
+
.card { margin: 16px; padding: 36px 24px 32px; }
|
| 236 |
+
.title { font-size: 22px; }
|
| 237 |
+
}
|
| 238 |
+
</style>
|
| 239 |
+
</head>
|
| 240 |
+
<body>
|
| 241 |
+
|
| 242 |
+
<!-- Background blobs -->
|
| 243 |
+
<div class="bg-blob bg-blob-1" aria-hidden="true"></div>
|
| 244 |
+
<div class="bg-blob bg-blob-2" aria-hidden="true"></div>
|
| 245 |
+
<div class="bg-blob bg-blob-3" aria-hidden="true"></div>
|
| 246 |
+
|
| 247 |
+
<!-- Login Card -->
|
| 248 |
+
<main class="card" role="main">
|
| 249 |
+
|
| 250 |
+
<!-- Logo -->
|
| 251 |
+
<div class="logo-wrap" aria-hidden="true">
|
| 252 |
+
<div class="logo-ring-inner"></div>
|
| 253 |
+
<div class="logo-ring"></div>
|
| 254 |
+
<img src="/static/orbit.png" alt="ORBIT Logo" style="width: 100%; height: 100%; object-fit: contain; position: relative; z-index: 10;" />
|
| 255 |
+
</div>
|
| 256 |
+
|
| 257 |
+
<!-- Beta badge -->
|
| 258 |
+
<div class="beta-badge">
|
| 259 |
+
<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" aria-hidden="true"><circle cx="4" cy="4" r="4"/></svg>
|
| 260 |
+
<span>Beta</span>
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
<h1 class="title">Welcome to ORBIT</h1>
|
| 264 |
+
<p class="subtitle">Your AI-powered Educational<br>Research Assistant</p>
|
| 265 |
+
|
| 266 |
+
<div class="divider" aria-hidden="true">
|
| 267 |
+
<div class="divider-line"></div>
|
| 268 |
+
<span class="divider-text">Sign in to continue</span>
|
| 269 |
+
<div class="divider-line"></div>
|
| 270 |
+
</div>
|
| 271 |
+
|
| 272 |
+
<!-- Google Sign-In -->
|
| 273 |
+
<a id="btn-google-signin" href="/auth/login" class="google-btn" role="button" aria-label="Continue with Google">
|
| 274 |
+
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
|
| 275 |
+
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
| 276 |
+
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
| 277 |
+
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
| 278 |
+
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
| 279 |
+
</svg>
|
| 280 |
+
Continue with Google
|
| 281 |
+
</a>
|
| 282 |
+
|
| 283 |
+
<p class="footer-note">
|
| 284 |
+
By signing in you agree to use this service responsibly.<br>
|
| 285 |
+
Your API keys are stored securely and never shared.
|
| 286 |
+
</p>
|
| 287 |
+
</main>
|
| 288 |
+
|
| 289 |
+
</body>
|
| 290 |
+
</html>
|