xenux4u commited on
Commit
36075bf
Β·
verified Β·
1 Parent(s): aed93d9

Upload 8 files

Browse files
.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

  • SHA256: 0eec93923c5445d983afcfceb9de0e72ee40e27c4c8194c29c0aca3b0127612e
  • Pointer size: 132 Bytes
  • Size of remote file: 2.55 MB
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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

  • SHA256: 95589f94733d5e56e889c7c76086e7adfd02be33c1d0cce015336cfbeef032c7
  • Pointer size: 132 Bytes
  • Size of remote file: 3.01 MB
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>