Subh775 commited on
Commit
e96d7c2
·
1 Parent(s): 25f87a5

fixes & improvements..

Browse files
Files changed (4) hide show
  1. app.py +10 -2
  2. graph.py +22 -11
  3. static/app.js +191 -315
  4. static/index.html +1 -1
app.py CHANGED
@@ -18,7 +18,7 @@ import guard
18
  import retriever
19
  import auth as auth_module
20
  from config import EXPORT_SECRET, REDIS_URI, GOOGLE_CLIENT_ID
21
- from graph import chatbot, DEFAULT_MODEL
22
 
23
 
24
  app = FastAPI()
@@ -276,6 +276,8 @@ def chat(request: ChatRequest, req: Request):
276
 
277
  # Step 5: Stream LLM response via LangGraph
278
  yield sse_status("Preparing the explanation...")
 
 
279
  config = {
280
  "configurable": {
281
  "thread_id": request.thread_id,
@@ -285,6 +287,7 @@ def chat(request: ChatRequest, req: Request):
285
  "username": request.username,
286
  "student_profile": student_profile,
287
  "user_api_key": user_api_key,
 
288
  }
289
  }
290
 
@@ -305,6 +308,11 @@ def chat(request: ChatRequest, req: Request):
305
  "I'm getting a lot of questions right now. "
306
  "Please try again in a few seconds."
307
  )
 
 
 
 
 
308
  elif "401" in str(e) or "unauthorized" in error_str or "invalid" in error_str:
309
  yield sse_error(
310
  "API key issue. Please check your OpenRouter API key in Settings."
@@ -337,7 +345,7 @@ def chat(request: ChatRequest, req: Request):
337
  persona=request.persona, language=request.language,
338
  username=request.username, user_agent=user_agent,
339
  sources=sources, user_id=request.user_id,
340
- model_used=DEFAULT_MODEL,
341
  )
342
 
343
  return StreamingResponse(stream(), media_type="text/event-stream")
 
18
  import retriever
19
  import auth as auth_module
20
  from config import EXPORT_SECRET, REDIS_URI, GOOGLE_CLIENT_ID
21
+ from graph import chatbot, TEXT_MODEL, VISION_MODEL
22
 
23
 
24
  app = FastAPI()
 
276
 
277
  # Step 5: Stream LLM response via LangGraph
278
  yield sse_status("Preparing the explanation...")
279
+ has_image = bool(request.image)
280
+ model_used = VISION_MODEL if has_image else TEXT_MODEL
281
  config = {
282
  "configurable": {
283
  "thread_id": request.thread_id,
 
287
  "username": request.username,
288
  "student_profile": student_profile,
289
  "user_api_key": user_api_key,
290
+ "has_image": has_image,
291
  }
292
  }
293
 
 
308
  "I'm getting a lot of questions right now. "
309
  "Please try again in a few seconds."
310
  )
311
+ elif "402" in str(e) or "payment" in error_str or "credits" in error_str:
312
+ yield sse_error(
313
+ "Free credits exhausted. Please add credits at openrouter.ai/settings/credits "
314
+ "or update your API key in Settings."
315
+ )
316
  elif "401" in str(e) or "unauthorized" in error_str or "invalid" in error_str:
317
  yield sse_error(
318
  "API key issue. Please check your OpenRouter API key in Settings."
 
345
  persona=request.persona, language=request.language,
346
  username=request.username, user_agent=user_agent,
347
  sources=sources, user_id=request.user_id,
348
+ model_used=model_used,
349
  )
350
 
351
  return StreamingResponse(stream(), media_type="text/event-stream")
graph.py CHANGED
@@ -1,8 +1,9 @@
1
  """
2
- LangGraph definition — single chat node with dynamic OpenRouter model selection.
3
- Checkpointer: SQLite via official LangGraph SqliteSaver.from_conn_string().
4
 
5
- Ref: https://docs.langchain.com/oss/python/langgraph/add-memory
 
6
  """
7
 
8
  from langgraph.graph import StateGraph, START, END
@@ -23,22 +24,31 @@ _conn = sqlite3.connect(DB_PATH, check_same_thread=False)
23
  checkpointer = SqliteSaver(conn=_conn)
24
 
25
 
26
- # --- Model config ---
27
- DEFAULT_MODEL = "google/gemini-2.5-flash"
 
28
 
29
 
30
- def _get_llm(api_key: str = "", model: str = ""):
31
- """Create an LLM instance with the user's API key (BYOK) or shared fallback."""
32
  key = api_key or OPENROUTER_API_KEY
33
  if not key:
34
  raise ValueError("No API key available. Please add your OpenRouter key in Settings.")
35
- mdl = model or DEFAULT_MODEL
 
 
 
 
 
 
 
 
36
  return ChatOpenRouter(
37
  model=mdl,
38
  openrouter_api_key=key,
39
  temperature=0.5,
40
- max_tokens=8192,
41
- max_retries=2,
42
  streaming=True,
43
  )
44
 
@@ -60,12 +70,13 @@ def chat_node(state: ChatState, config: RunnableConfig):
60
  student_profile = cfg.get("student_profile", "")
61
  user_api_key = cfg.get("user_api_key", "")
62
  model_name = cfg.get("model", "")
 
63
 
64
  system_msg = SystemMessage(
65
  content=prompts.build(persona, context, language, username, student_profile)
66
  )
67
 
68
- llm = _get_llm(api_key=user_api_key, model=model_name)
69
  all_msgs = [system_msg] + state["messages"]
70
  response = llm.invoke(all_msgs)
71
 
 
1
  """
2
+ LangGraph definition — single chat node with free OpenRouter model selection.
3
+ Checkpointer: SQLite via official LangGraph SqliteSaver.
4
 
5
+ Text model: nvidia/llama-3.1-nemotron-ultra-253b-v1:free
6
+ Vision model: nex-agi/nex-n2-pro:free
7
  """
8
 
9
  from langgraph.graph import StateGraph, START, END
 
24
  checkpointer = SqliteSaver(conn=_conn)
25
 
26
 
27
+ # --- Free model config ---
28
+ TEXT_MODEL = "nvidia/llama-3.1-nemotron-ultra-253b-v1:free"
29
+ VISION_MODEL = "nex-agi/nex-n2-pro:free"
30
 
31
 
32
+ def _get_llm(api_key: str = "", model: str = "", has_image: bool = False):
33
+ """Create an LLM instance. Picks vision model if image is attached."""
34
  key = api_key or OPENROUTER_API_KEY
35
  if not key:
36
  raise ValueError("No API key available. Please add your OpenRouter key in Settings.")
37
+
38
+ # Model priority: explicit override > auto-select based on image
39
+ if model:
40
+ mdl = model
41
+ elif has_image:
42
+ mdl = VISION_MODEL
43
+ else:
44
+ mdl = TEXT_MODEL
45
+
46
  return ChatOpenRouter(
47
  model=mdl,
48
  openrouter_api_key=key,
49
  temperature=0.5,
50
+ max_tokens=4096,
51
+ max_retries=3,
52
  streaming=True,
53
  )
54
 
 
70
  student_profile = cfg.get("student_profile", "")
71
  user_api_key = cfg.get("user_api_key", "")
72
  model_name = cfg.get("model", "")
73
+ has_image = cfg.get("has_image", False)
74
 
75
  system_msg = SystemMessage(
76
  content=prompts.build(persona, context, language, username, student_profile)
77
  )
78
 
79
+ llm = _get_llm(api_key=user_api_key, model=model_name, has_image=has_image)
80
  all_msgs = [system_msg] + state["messages"]
81
  response = llm.invoke(all_msgs)
82
 
static/app.js CHANGED
@@ -32,44 +32,79 @@ const chatContainer = document.getElementById('chatContainer');
32
  const welcomeScreen = document.getElementById('welcomeScreen');
33
  const bottomInputContainer = document.getElementById('bottomInputContainer');
34
 
35
- // Bottom input bar
36
  const userInput = document.getElementById('userInput');
37
  const sendBtn = document.getElementById('sendBtn');
38
  const newChatBtn = document.getElementById('newChatBtn');
39
  const stopBtn = document.getElementById('stopBtn');
40
 
41
- // Hero input bar
42
  const heroInput = document.getElementById('heroInput');
43
  const heroSendBtn = document.getElementById('heroSendBtn');
44
  const heroUploadBtn = document.getElementById('heroUploadBtn');
45
  const heroImageInput = document.getElementById('heroImageInput');
46
 
47
- // Auth
48
  const byokInput = document.getElementById('byokInput');
49
  const byokSubmitBtn = document.getElementById('byokSubmitBtn');
50
 
51
- // User menu
52
  const userProfileBtn = document.getElementById('userProfileBtn');
53
  const userMenu = document.getElementById('userMenu');
54
  const userAvatar = document.getElementById('userAvatar');
55
  const userDisplayName = document.getElementById('userDisplayName');
56
  const logoutBtn = document.getElementById('logoutBtn');
57
 
58
- // Rail
59
  const railExpandBtn = document.getElementById('railExpandBtn');
60
  const railNewChatBtn = document.getElementById('railNewChatBtn');
61
  const railProfileBtn = document.getElementById('railProfileBtn');
62
  const railAvatar = document.getElementById('railAvatar');
63
 
64
- // Settings
65
  const settingsOverlay = document.getElementById('settingsOverlay');
66
  const openSettingsBtn = document.getElementById('openSettingsBtn');
67
  const settingsCloseBtn = document.getElementById('settingsCloseBtn');
68
  const settingsModal = document.getElementById('settingsModal');
69
-
70
  const feedbackMenuBtn = document.getElementById('feedbackMenuBtn');
71
 
72
- // Feedback button in sidebar — opens Settings modal at the Feedback tab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  function openFeedbackInSettings() {
74
  userMenu.classList.remove('show');
75
  document.querySelectorAll('.settings-nav-btn').forEach(b => b.classList.remove('active'));
@@ -80,10 +115,7 @@ function openFeedbackInSettings() {
80
  if (fbTab) fbTab.classList.add('active');
81
  settingsOverlay.classList.add('show');
82
  }
83
-
84
- if (feedbackMenuBtn) {
85
- feedbackMenuBtn.addEventListener('click', openFeedbackInSettings);
86
- }
87
 
88
  function _bindFeedbackSubmit() {
89
  const submitFeedbackBtn = document.getElementById('submitFeedbackBtn');
@@ -92,51 +124,31 @@ function _bindFeedbackSubmit() {
92
  const cat = document.getElementById('feedbackCategory').value;
93
  const msg = document.getElementById('feedbackMessage').value;
94
  if (!msg.trim()) { document.getElementById('feedbackMessage').focus(); return; }
95
-
96
  submitFeedbackBtn.disabled = true;
97
  submitFeedbackBtn.textContent = 'Submitting...';
98
-
99
  fetch('/feedback', {
100
  method: 'POST',
101
  headers: { 'Content-Type': 'application/json' },
102
  body: JSON.stringify({
103
  user_id: currentUser ? currentUser.google_id : 'anonymous',
104
- category: cat,
105
- message: msg
106
  })
107
- }).then(r => r.json()).then(() => {
 
 
 
108
  settingsOverlay.classList.remove('show');
109
  document.getElementById('feedbackMessage').value = '';
110
- submitFeedbackBtn.disabled = false;
111
- submitFeedbackBtn.textContent = 'Submit Securely';
112
- alert('Feedback submitted. Thank you!');
113
  }).catch(() => {
 
 
114
  submitFeedbackBtn.disabled = false;
115
  submitFeedbackBtn.textContent = 'Submit Securely';
116
- alert('Could not submit feedback. Please try again.');
117
  });
118
  });
119
  }
120
 
121
- const usernameInput = document.getElementById('usernameInput');
122
- const languageSelect = document.getElementById('languageSelect');
123
- const profileInput = document.getElementById('profileInput');
124
- const saveProfileBtn = document.getElementById('saveProfileBtn');
125
- const settingsApiKeyInput = document.getElementById('settingsApiKeyInput');
126
- const saveApiKeyBtn = document.getElementById('saveApiKeyBtn');
127
-
128
- // Image upload (bottom bar)
129
- const uploadBtn = document.getElementById('uploadBtn');
130
- const imageInput = document.getElementById('imageInput');
131
- const imagePreviewBar = document.getElementById('imagePreviewBar');
132
- const imagePreviewThumb = document.getElementById('imagePreviewThumb');
133
- const imagePreviewRemove= document.getElementById('imagePreviewRemove');
134
-
135
- // PWA
136
- const installBanner = document.getElementById('installBanner');
137
- const installBtn = document.getElementById('installBtn');
138
- const installDismiss = document.getElementById('installDismiss');
139
-
140
 
141
  /* ============================================================
142
  AUTH — Google Sign-In
@@ -145,38 +157,26 @@ const installDismiss = document.getElementById('installDismiss');
145
  let _gsiInitialized = false;
146
 
147
  function initGoogleAuth() {
148
- return new Promise((resolve, reject) => {
149
  if (_gsiInitialized) { resolve(); return; }
150
-
151
  fetch('/auth/client_id')
152
  .then(r => r.json())
153
  .then(data => {
154
- if (!data.client_id) {
155
- showApp();
156
- resolve();
157
- return;
158
- }
159
-
160
  const script = document.createElement('script');
161
  script.src = 'https://accounts.google.com/gsi/client';
162
- script.async = true;
163
- script.defer = true;
164
  script.onload = () => {
165
  google.accounts.id.initialize({
166
  client_id: data.client_id,
167
  callback: handleGoogleCredential,
168
- auto_select: false,
169
- cancel_on_tap_outside: false,
170
  });
171
  _renderHiddenGoogleBtn();
172
  _gsiInitialized = true;
173
  resolve();
174
  };
175
- script.onerror = () => {
176
- console.error('[AUTH] Failed to load Google Identity Services');
177
- showApp();
178
- resolve();
179
- };
180
  document.head.appendChild(script);
181
  })
182
  .catch(() => { showApp(); resolve(); });
@@ -188,37 +188,23 @@ function _renderHiddenGoogleBtn(cb) {
188
  if (!container) return;
189
  container.innerHTML = '';
190
  google.accounts.id.renderButton(container, {
191
- type: 'standard',
192
- theme: 'filled_black',
193
- size: 'large',
194
- text: 'signin_with',
195
- shape: 'pill',
196
- width: 240,
197
  });
198
- setTimeout(() => {
199
- container.style.pointerEvents = 'auto';
200
- if (cb) cb();
201
- }, 150);
202
  }
203
 
204
  function triggerGoogleSignIn() {
205
  const tryClick = () => {
206
  const realBtn = document.querySelector('#gsi-hidden-btn [role="button"]');
207
- if (realBtn) {
208
- realBtn.click();
209
- } else {
210
- _renderHiddenGoogleBtn(() => {
211
- const btn = document.querySelector('#gsi-hidden-btn [role="button"]');
212
- if (btn) btn.click();
213
- });
214
- }
215
  };
216
-
217
- if (_gsiInitialized) {
218
- tryClick();
219
- } else {
220
- initGoogleAuth().then(tryClick);
221
- }
222
  }
223
 
224
  function handleGoogleCredential(response) {
@@ -227,16 +213,19 @@ function handleGoogleCredential(response) {
227
  headers: { 'Content-Type': 'application/json' },
228
  body: JSON.stringify({ token: response.credential }),
229
  })
230
- .then(r => r.json())
 
 
 
231
  .then(data => {
232
- if (data.error) { alert('Login failed: ' + data.error); return; }
233
  currentUser = data.user;
234
  localStorage.setItem('stemcopilot_user', JSON.stringify(currentUser));
235
  currentUsername = currentUser.name;
236
  localStorage.setItem('stemcopilot_username', currentUsername);
237
- if (!data.has_api_key) { showByok(); } else { showApp(); }
238
  })
239
- .catch(() => alert('Login failed. Please try again.'));
240
  }
241
 
242
  function checkExistingSession() {
@@ -254,7 +243,7 @@ function checkExistingSession() {
254
  return;
255
  }
256
  currentUser = data.user;
257
- if (!data.has_api_key) { showByok(); } else { showApp(); }
258
  })
259
  .catch(() => initGoogleAuth());
260
  } else {
@@ -281,9 +270,9 @@ if (byokSubmitBtn) byokSubmitBtn.addEventListener('click', () => {
281
  headers: { 'Content-Type': 'application/json' },
282
  body: JSON.stringify({ user_id: currentUser.google_id, key: key }),
283
  })
284
- .then(r => r.json())
285
  .then(() => showApp())
286
- .catch(() => alert('Failed to save key. Try again.'));
287
  });
288
 
289
 
@@ -315,12 +304,10 @@ function showApp() {
315
  if (usernameInput) usernameInput.value = currentUsername;
316
  if (languageSelect) languageSelect.value = currentLanguage;
317
 
318
- // Set active persona
319
  document.querySelectorAll('.persona-option').forEach(opt => {
320
  opt.classList.toggle('active', opt.dataset.persona === currentPersona);
321
  });
322
 
323
- // Load thread history
324
  const userId = currentUser ? currentUser.google_id : '';
325
  fetch('/threads?user_id=' + encodeURIComponent(userId))
326
  .then(r => r.json())
@@ -330,10 +317,8 @@ function showApp() {
330
  })
331
  .catch(() => {});
332
 
333
- // On mobile, ensure sidebar starts collapsed
334
- if (window.innerWidth <= 768) {
335
- collapseSidebar();
336
- }
337
 
338
  enterHeroMode();
339
  }
@@ -363,13 +348,12 @@ function exitHeroMode() {
363
 
364
 
365
  /* ============================================================
366
- SIDEBAR — Simplified, no drag (button + overlay only)
367
  ============================================================ */
368
 
369
  let sidebarOpen = false;
370
 
371
  function _cleanSidebarStyles() {
372
- // Remove ANY leftover inline styles from old drag system
373
  sidebar.style.transition = '';
374
  sidebar.style.transform = '';
375
  sidebar.style.opacity = '';
@@ -385,9 +369,7 @@ function openSidebar() {
385
  sidebarOpen = true;
386
  _cleanSidebarStyles();
387
  sidebar.classList.remove('collapsed');
388
- if (sidebarOverlay && window.innerWidth <= 768) {
389
- sidebarOverlay.classList.add('visible');
390
- }
391
  if (sidebarRail) sidebarRail.classList.remove('visible');
392
  }
393
 
@@ -396,34 +378,24 @@ function closeSidebar() {
396
  _cleanSidebarStyles();
397
  sidebar.classList.add('collapsed');
398
  if (sidebarOverlay) sidebarOverlay.classList.remove('visible');
399
- // Show rail on desktop only
400
- if (sidebarRail && window.innerWidth > 768) {
401
- sidebarRail.classList.add('visible');
402
- }
403
  }
404
 
405
  function toggleSidebar() {
406
- if (sidebarOpen) closeSidebar();
407
- else openSidebar();
408
  }
409
 
410
- // Toggle button in header
411
- if (toggleSidebarBtn) {
412
- toggleSidebarBtn.addEventListener('click', toggleSidebar);
413
- }
414
 
415
- // Overlay tap closes sidebar
416
  if (sidebarOverlay) {
417
  sidebarOverlay.addEventListener('click', closeSidebar);
418
  sidebarOverlay.addEventListener('touchstart', (e) => {
419
- e.preventDefault();
420
- closeSidebar();
421
  }, { passive: false });
422
  }
423
 
424
- // Simple edge-swipe: swipe from left edge opens, swipe left on overlay closes
425
- let _touchStartX = 0;
426
- let _touchStartY = 0;
427
  document.addEventListener('touchstart', (e) => {
428
  if (window.innerWidth > 768) return;
429
  _touchStartX = e.changedTouches[0].clientX;
@@ -434,19 +406,13 @@ document.addEventListener('touchend', (e) => {
434
  if (window.innerWidth > 768) return;
435
  const dx = e.changedTouches[0].clientX - _touchStartX;
436
  const dy = Math.abs(e.changedTouches[0].clientY - _touchStartY);
437
- // Must be more horizontal than vertical
438
- if (dy > Math.abs(dx) * 0.8) return;
439
- // Swipe right from left edge open
440
- if (!sidebarOpen && _touchStartX < 30 && dx > 60) {
441
- openSidebar();
442
- }
443
- // Swipe left → close
444
- if (sidebarOpen && dx < -60) {
445
- closeSidebar();
446
- }
447
  }, { passive: true });
448
 
449
- // Rail buttons
450
  if (railExpandBtn) railExpandBtn.addEventListener('click', openSidebar);
451
  if (railNewChatBtn) railNewChatBtn.addEventListener('click', () => startNewChat());
452
  if (railProfileBtn) railProfileBtn.addEventListener('click', () => {
@@ -454,13 +420,8 @@ if (railProfileBtn) railProfileBtn.addEventListener('click', () => {
454
  setTimeout(() => { if (userProfileBtn) userProfileBtn.click(); }, 150);
455
  });
456
 
457
- // Mobile FAB toggle
458
  const mobileFabToggle = document.getElementById('mobileFabToggle');
459
- if (mobileFabToggle) {
460
- mobileFabToggle.addEventListener('click', toggleSidebar);
461
- }
462
-
463
- // New chat button
464
  if (newChatBtn) newChatBtn.addEventListener('click', startNewChat);
465
 
466
  function startNewChat() {
@@ -469,7 +430,6 @@ function startNewChat() {
469
  chatContainer.appendChild(createWelcomeScreen());
470
  enterHeroMode();
471
  renderHistory();
472
- // Close sidebar on mobile after starting new chat
473
  if (window.innerWidth <= 768) closeSidebar();
474
  }
475
 
@@ -506,36 +466,19 @@ function createWelcomeScreen() {
506
  const dynFileInput = div.querySelector('#heroImageInputDynamic');
507
 
508
  dynInput.addEventListener('input', function() {
509
- this.style.height = '54px';
510
- this.style.height = this.scrollHeight + 'px';
511
  });
512
-
513
  dynInput.addEventListener('keydown', function(e) {
514
  if (e.key === 'Enter' && !e.shiftKey) {
515
- e.preventDefault();
516
- userInput.value = dynInput.value;
517
- sendMessage();
518
  }
519
  });
520
-
521
- dynSend.addEventListener('click', () => {
522
- userInput.value = dynInput.value;
523
- sendMessage();
524
- });
525
-
526
  dynUpload.addEventListener('click', () => dynFileInput.click());
527
- dynFileInput.addEventListener('change', () => {
528
- const file = dynFileInput.files[0];
529
- if (file) handleImageFile(file);
530
- });
531
-
532
  div.querySelectorAll('.hero-pill').forEach(p => {
533
- p.addEventListener('click', () => {
534
- userInput.value = p.dataset.query;
535
- sendMessage();
536
- });
537
  });
538
-
539
  return div;
540
  }
541
 
@@ -578,8 +521,6 @@ function loadThread(threadId) {
578
  renderHistory();
579
  chatContainer.innerHTML = '';
580
  exitHeroMode();
581
-
582
- // Close sidebar on mobile
583
  if (window.innerWidth <= 768) closeSidebar();
584
 
585
  fetch('/history/' + threadId)
@@ -589,10 +530,7 @@ function loadThread(threadId) {
589
  const sender = msg.role === 'user' ? 'user' : 'ai';
590
  let textContent = msg.content;
591
  if (Array.isArray(msg.content)) {
592
- textContent = msg.content
593
- .filter(part => part.type === 'text')
594
- .map(part => part.text)
595
- .join('');
596
  }
597
  const el = appendMessage(sender, textContent);
598
  if (sender === 'ai') renderFinalContent(el, textContent);
@@ -606,12 +544,10 @@ function loadThread(threadId) {
606
  ============================================================ */
607
 
608
  function toggleMenu(e, btn) {
609
- e.stopPropagation();
610
- closeAllMenus();
611
  btn.nextElementSibling.classList.add('show');
612
  btn.classList.add('menu-open');
613
  }
614
-
615
  document.addEventListener('click', closeAllMenus);
616
 
617
  function closeAllMenus() {
@@ -637,29 +573,20 @@ function renameChat(e, optionEl) {
637
  const titleSpan = item.querySelector('.chat-title');
638
  const threadId = item.getAttribute('data-thread-id');
639
  closeAllMenus();
640
-
641
  const currentTitle = titleSpan.innerText;
642
  const input = document.createElement('input');
643
- input.type = 'text';
644
- input.value = currentTitle;
645
- input.className = 'rename-input';
646
- titleSpan.replaceWith(input);
647
- input.focus();
648
  input.selectionStart = input.selectionEnd = input.value.length;
649
 
650
  function saveRename() {
651
  const newTitle = input.value.trim() || 'Untitled Chat';
652
- titleSpan.innerText = newTitle;
653
- input.replaceWith(titleSpan);
654
  const thread = threads.find(t => t.id === threadId);
655
  if (thread) thread.title = newTitle;
656
- fetch('/rename', {
657
- method: 'POST',
658
- headers: { 'Content-Type': 'application/json' },
659
- body: JSON.stringify({ thread_id: threadId, title: newTitle })
660
- });
661
  }
662
-
663
  input.addEventListener('blur', saveRename);
664
  input.addEventListener('keydown', evt => {
665
  if (evt.key === 'Enter') saveRename();
@@ -674,28 +601,21 @@ function renameChat(e, optionEl) {
674
  ============================================================ */
675
 
676
  if (userProfileBtn) userProfileBtn.addEventListener('click', (e) => {
677
- e.stopPropagation();
678
- userMenu.classList.toggle('show');
679
  });
680
-
681
  if (openSettingsBtn) openSettingsBtn.addEventListener('click', () => {
682
- userMenu.classList.remove('show');
683
- settingsOverlay.classList.add('show');
684
  });
685
-
686
  if (settingsCloseBtn) settingsCloseBtn.addEventListener('click', () => settingsOverlay.classList.remove('show'));
687
  if (settingsOverlay) settingsOverlay.addEventListener('click', (e) => {
688
  if (e.target === settingsOverlay) settingsOverlay.classList.remove('show');
689
  });
690
-
691
  if (logoutBtn) logoutBtn.addEventListener('click', () => {
692
  localStorage.removeItem('stemcopilot_user');
693
  localStorage.removeItem('stemcopilot_username');
694
- currentUser = null;
695
- location.reload();
696
  });
697
 
698
- // Settings tabs
699
  document.querySelectorAll('.settings-nav-btn').forEach(btn => {
700
  btn.addEventListener('click', () => {
701
  document.querySelectorAll('.settings-nav-btn').forEach(b => b.classList.remove('active'));
@@ -706,19 +626,15 @@ document.querySelectorAll('.settings-nav-btn').forEach(btn => {
706
  });
707
  });
708
 
709
- // Username
710
  if (usernameInput) usernameInput.addEventListener('input', () => {
711
  currentUsername = usernameInput.value.trim();
712
  localStorage.setItem('stemcopilot_username', currentUsername);
713
  });
714
-
715
- // Language
716
  if (languageSelect) languageSelect.addEventListener('change', () => {
717
  currentLanguage = languageSelect.value;
718
  localStorage.setItem('stemcopilot_language', currentLanguage);
719
  });
720
 
721
- // Persona selection
722
  document.querySelectorAll('.persona-option').forEach(opt => {
723
  opt.addEventListener('click', () => {
724
  document.querySelectorAll('.persona-option').forEach(o => o.classList.remove('active'));
@@ -728,26 +644,41 @@ document.querySelectorAll('.persona-option').forEach(opt => {
728
  });
729
  });
730
 
731
- // Save API key
732
  if (saveApiKeyBtn) saveApiKeyBtn.addEventListener('click', () => {
733
  const key = settingsApiKeyInput.value.trim();
734
  if (!key || key.startsWith('••')) return;
735
  if (!currentUser) return;
 
 
736
  fetch('/user/apikey', {
737
  method: 'POST',
738
  headers: { 'Content-Type': 'application/json' },
739
  body: JSON.stringify({ user_id: currentUser.google_id, key: key }),
740
- }).then(() => { settingsApiKeyInput.value = '••••••••••••'; });
 
 
 
 
 
 
 
 
 
 
 
741
  });
742
 
743
- // Save student profile
744
  if (saveProfileBtn) saveProfileBtn.addEventListener('click', () => {
745
  if (!currentUser) return;
746
  fetch('/user/profile', {
747
  method: 'POST',
748
  headers: { 'Content-Type': 'application/json' },
749
  body: JSON.stringify({ user_id: currentUser.google_id, profile: profileInput.value }),
750
- });
 
 
 
751
  });
752
 
753
 
@@ -756,27 +687,16 @@ if (saveProfileBtn) saveProfileBtn.addEventListener('click', () => {
756
  ============================================================ */
757
 
758
  if (uploadBtn) uploadBtn.addEventListener('click', () => imageInput.click());
759
-
760
- if (imageInput) imageInput.addEventListener('change', () => {
761
- const file = imageInput.files[0];
762
- if (file) handleImageFile(file);
763
- });
764
-
765
  if (heroUploadBtn) heroUploadBtn.addEventListener('click', () => heroImageInput.click());
766
- if (heroImageInput) heroImageInput.addEventListener('change', () => {
767
- const file = heroImageInput.files[0];
768
- if (file) handleImageFile(file);
769
- });
770
 
771
- // Paste
772
  document.addEventListener('paste', (e) => {
773
  const items = e.clipboardData?.items;
774
  if (!items) return;
775
  for (const item of items) {
776
  if (item.type.startsWith('image/')) {
777
- e.preventDefault();
778
- handleImageFile(item.getAsFile());
779
- return;
780
  }
781
  }
782
  });
@@ -800,81 +720,69 @@ if (imagePreviewRemove) imagePreviewRemove.addEventListener('click', () => {
800
 
801
 
802
  /* ============================================================
803
- CHAT & STREAMING WITH LIVE MARKDOWN RENDERING
804
  ============================================================ */
805
 
806
  let currentAbortController = null;
 
807
 
808
  function _showStopBtn() {
809
  if (sendBtn) sendBtn.style.display = 'none';
810
- if (stopBtn) {
811
- stopBtn.style.display = 'flex';
812
- stopBtn.classList.add('visible');
813
- }
814
  }
815
-
816
  function _showSendBtn() {
817
- if (stopBtn) {
818
- stopBtn.style.display = 'none';
819
- stopBtn.classList.remove('visible');
820
- }
821
  if (sendBtn) sendBtn.style.display = 'flex';
822
  }
823
 
824
  if (stopBtn) {
825
  stopBtn.addEventListener('click', () => {
 
 
 
 
 
 
826
  if (currentAbortController) {
827
  currentAbortController.abort();
828
  currentAbortController = null;
829
  }
 
830
  isSending = false;
831
  _showSendBtn();
832
  const currentThinking = document.getElementById('currentThinking');
833
  if (currentThinking) currentThinking.style.display = 'none';
834
  document.querySelectorAll('.ai-avatar.pulsing').forEach(el => el.classList.remove('pulsing'));
 
 
835
  });
836
  }
837
 
838
  if (userInput) {
839
  userInput.addEventListener('input', function () {
840
- this.style.height = '54px';
841
- this.style.height = this.scrollHeight + 'px';
842
  });
843
-
844
  userInput.addEventListener('keydown', function (e) {
845
- if (e.key === 'Enter' && !e.shiftKey) {
846
- e.preventDefault();
847
- sendMessage();
848
- }
849
  });
850
  }
851
 
852
  if (sendBtn) sendBtn.addEventListener('click', sendMessage);
853
 
854
- // Hero pills (static)
855
  document.querySelectorAll('.hero-pill').forEach(p => {
856
- p.addEventListener('click', () => {
857
- userInput.value = p.dataset.query;
858
- sendMessage();
859
- });
860
  });
861
 
862
- // Hero send (static)
863
  if (heroSendBtn) heroSendBtn.addEventListener('click', () => {
864
- userInput.value = heroInput.value;
865
- sendMessage();
866
  });
867
-
868
  if (heroInput) {
869
  heroInput.addEventListener('input', function() {
870
- this.style.height = '54px';
871
- this.style.height = this.scrollHeight + 'px';
872
  });
873
  heroInput.addEventListener('keydown', function(e) {
874
  if (e.key === 'Enter' && !e.shiftKey) {
875
- e.preventDefault();
876
- userInput.value = heroInput.value;
877
- sendMessage();
878
  }
879
  });
880
  }
@@ -882,9 +790,7 @@ if (heroInput) {
882
  function sendMessage() {
883
  const text = userInput.value.trim();
884
  if (!text || isSending) return;
885
-
886
  if (isHeroMode) exitHeroMode();
887
-
888
  isSending = true;
889
 
890
  if (pendingImage) {
@@ -899,8 +805,7 @@ function sendMessage() {
899
  appendMessage('user', text);
900
  }
901
 
902
- userInput.value = '';
903
- userInput.style.height = '54px';
904
 
905
  const exists = threads.find(t => t.id === currentThreadId);
906
  if (!exists) {
@@ -908,11 +813,8 @@ function sendMessage() {
908
  addThreadToSidebar(currentThreadId, title);
909
  } else {
910
  const idx = threads.indexOf(exists);
911
- threads.splice(idx, 1);
912
- threads.unshift(exists);
913
- renderHistory();
914
  }
915
-
916
  streamResponse(text);
917
  }
918
 
@@ -935,7 +837,6 @@ function streamResponse(text) {
935
  `;
936
  chatContainer.appendChild(rowDiv);
937
  scrollToBottom();
938
-
939
  _showStopBtn();
940
 
941
  const thinkingEl = rowDiv.querySelector('#currentThinking');
@@ -943,24 +844,21 @@ function streamResponse(text) {
943
  const contentEl = rowDiv.querySelector('.message-content');
944
  let rawText = '';
945
  let firstToken = true;
 
946
 
947
  let renderTimer = null;
948
  const RENDER_INTERVAL = 120;
949
-
950
  function scheduleRender() {
951
- if (renderTimer) return;
952
  renderTimer = setTimeout(() => {
953
  renderTimer = null;
954
- renderFinalContent(contentEl, rawText);
955
- scrollToBottom();
956
  }, RENDER_INTERVAL);
957
  }
958
 
959
  const payload = {
960
- message: text,
961
- thread_id: currentThreadId,
962
- persona: currentPersona,
963
- language: currentLanguage,
964
  username: currentUsername,
965
  user_id: currentUser ? currentUser.google_id : '',
966
  image: imageData,
@@ -975,13 +873,14 @@ function streamResponse(text) {
975
  signal: currentAbortController.signal,
976
  }).then(response => {
977
  const reader = response.body.getReader();
 
978
  const decoder = new TextDecoder();
979
  let buffer = '';
980
 
981
  function read() {
982
  reader.read().then(({ done, value }) => {
983
- if (done) {
984
- finishStream(thinkingEl, contentEl, rawText, renderTimer);
985
  return;
986
  }
987
 
@@ -990,31 +889,22 @@ function streamResponse(text) {
990
  buffer = lines.pop();
991
 
992
  lines.forEach(line => {
 
993
  if (!line.startsWith('data: ')) return;
994
- const payload = line.substring(6);
995
-
996
- if (payload === '[DONE]') {
997
  finishStream(thinkingEl, contentEl, rawText, renderTimer);
998
- return;
999
  }
1000
-
1001
  try {
1002
- const data = JSON.parse(payload);
1003
-
1004
- if (data.status === 'thinking') {
1005
- thinkingText.textContent = data.message;
1006
- return;
1007
- }
1008
-
1009
  if (data.error) {
1010
  thinkingEl.style.display = 'none';
1011
  contentEl.style.display = 'block';
1012
  contentEl.innerHTML = `<div class="error-message">${escapeHtml(data.error)}</div>`;
1013
- isSending = false;
1014
- _showSendBtn();
1015
- return;
1016
  }
1017
-
1018
  if (data.token !== undefined) {
1019
  if (firstToken) {
1020
  thinkingEl.style.display = 'none';
@@ -1025,29 +915,36 @@ function streamResponse(text) {
1025
  rawText += data.token;
1026
  scheduleRender();
1027
  }
1028
- } catch (e) { /* ignore parse errors */ }
1029
  });
1030
 
1031
- read();
 
 
 
 
 
 
 
 
1032
  });
1033
  }
1034
-
1035
  read();
1036
- }).catch((err) => {
1037
  if (err.name === 'AbortError') {
 
1038
  thinkingEl.style.display = 'none';
1039
  contentEl.style.display = 'block';
1040
- isSending = false;
1041
- _showSendBtn();
1042
  return;
1043
  }
1044
  thinkingEl.style.display = 'none';
1045
  contentEl.style.display = 'block';
1046
  contentEl.innerHTML = '<div class="error-message">Could not connect to the server. Please try again.</div>';
1047
- isSending = false;
1048
- _showSendBtn();
1049
  }).finally(() => {
1050
  currentAbortController = null;
 
1051
  const avatar = rowDiv.querySelector('#avatarThinking');
1052
  if (avatar) avatar.classList.remove('pulsing');
1053
  });
@@ -1085,9 +982,7 @@ function appendMessage(sender, text) {
1085
  return rowDiv.querySelector('.message-content');
1086
  }
1087
 
1088
- function scrollToBottom() {
1089
- chatContainer.scrollTop = chatContainer.scrollHeight;
1090
- }
1091
 
1092
  function escapeHtml(text) {
1093
  if (typeof text !== 'string') return '';
@@ -1103,37 +998,15 @@ function escapeHtml(text) {
1103
 
1104
  function renderFinalContent(element, rawText) {
1105
  if (!rawText) return;
1106
-
1107
  const blocks = [];
1108
  let safeText = rawText;
1109
-
1110
- safeText = safeText.replace(/\$\$[\s\S]*?\$\$/g, match => {
1111
- blocks.push(match);
1112
- return `%%LATEX_${blocks.length - 1}%%`;
1113
- });
1114
-
1115
- safeText = safeText.replace(/\$[^\$\n]+?\$/g, match => {
1116
- blocks.push(match);
1117
- return `%%LATEX_${blocks.length - 1}%%`;
1118
- });
1119
-
1120
- safeText = safeText.replace(/\\\[[\s\S]*?\\\]/g, match => {
1121
- blocks.push(match);
1122
- return `%%LATEX_${blocks.length - 1}%%`;
1123
- });
1124
- safeText = safeText.replace(/\\\([\s\S]*?\\\)/g, match => {
1125
- blocks.push(match);
1126
- return `%%LATEX_${blocks.length - 1}%%`;
1127
- });
1128
-
1129
  let html = typeof marked !== 'undefined' ? marked.parse(safeText) : safeText;
1130
-
1131
- blocks.forEach((block, i) => {
1132
- html = html.replace(`%%LATEX_${i}%%`, block);
1133
- });
1134
-
1135
  element.innerHTML = html;
1136
-
1137
  if (typeof renderMathInElement !== 'undefined') {
1138
  renderMathInElement(element, {
1139
  delimiters: [
@@ -1161,7 +1034,7 @@ let deferredPrompt = null;
1161
  window.addEventListener('beforeinstallprompt', (e) => {
1162
  e.preventDefault();
1163
  deferredPrompt = e;
1164
- // Show install banner (mobile + desktop, unless dismissed)
1165
  const dismissed = localStorage.getItem('stemcopilot_install_dismissed');
1166
  if (!dismissed && installBanner) {
1167
  installBanner.style.display = 'flex';
@@ -1188,6 +1061,9 @@ if (installDismiss) installDismiss.addEventListener('click', () => {
1188
  INIT
1189
  ============================================================ */
1190
 
 
 
 
1191
  window.addEventListener('load', () => {
1192
  checkExistingSession();
1193
  _bindFeedbackSubmit();
 
32
  const welcomeScreen = document.getElementById('welcomeScreen');
33
  const bottomInputContainer = document.getElementById('bottomInputContainer');
34
 
 
35
  const userInput = document.getElementById('userInput');
36
  const sendBtn = document.getElementById('sendBtn');
37
  const newChatBtn = document.getElementById('newChatBtn');
38
  const stopBtn = document.getElementById('stopBtn');
39
 
 
40
  const heroInput = document.getElementById('heroInput');
41
  const heroSendBtn = document.getElementById('heroSendBtn');
42
  const heroUploadBtn = document.getElementById('heroUploadBtn');
43
  const heroImageInput = document.getElementById('heroImageInput');
44
 
 
45
  const byokInput = document.getElementById('byokInput');
46
  const byokSubmitBtn = document.getElementById('byokSubmitBtn');
47
 
 
48
  const userProfileBtn = document.getElementById('userProfileBtn');
49
  const userMenu = document.getElementById('userMenu');
50
  const userAvatar = document.getElementById('userAvatar');
51
  const userDisplayName = document.getElementById('userDisplayName');
52
  const logoutBtn = document.getElementById('logoutBtn');
53
 
 
54
  const railExpandBtn = document.getElementById('railExpandBtn');
55
  const railNewChatBtn = document.getElementById('railNewChatBtn');
56
  const railProfileBtn = document.getElementById('railProfileBtn');
57
  const railAvatar = document.getElementById('railAvatar');
58
 
 
59
  const settingsOverlay = document.getElementById('settingsOverlay');
60
  const openSettingsBtn = document.getElementById('openSettingsBtn');
61
  const settingsCloseBtn = document.getElementById('settingsCloseBtn');
62
  const settingsModal = document.getElementById('settingsModal');
 
63
  const feedbackMenuBtn = document.getElementById('feedbackMenuBtn');
64
 
65
+ const usernameInput = document.getElementById('usernameInput');
66
+ const languageSelect = document.getElementById('languageSelect');
67
+ const profileInput = document.getElementById('profileInput');
68
+ const saveProfileBtn = document.getElementById('saveProfileBtn');
69
+ const settingsApiKeyInput = document.getElementById('settingsApiKeyInput');
70
+ const saveApiKeyBtn = document.getElementById('saveApiKeyBtn');
71
+
72
+ const uploadBtn = document.getElementById('uploadBtn');
73
+ const imageInput = document.getElementById('imageInput');
74
+ const imagePreviewBar = document.getElementById('imagePreviewBar');
75
+ const imagePreviewThumb = document.getElementById('imagePreviewThumb');
76
+ const imagePreviewRemove= document.getElementById('imagePreviewRemove');
77
+
78
+ const installBanner = document.getElementById('installBanner');
79
+ const installBtn = document.getElementById('installBtn');
80
+ const installDismiss = document.getElementById('installDismiss');
81
+
82
+
83
+ /* ============================================================
84
+ TOAST NOTIFICATION
85
+ ============================================================ */
86
+ function showToast(msg, type = 'info') {
87
+ const t = document.createElement('div');
88
+ t.textContent = msg;
89
+ Object.assign(t.style, {
90
+ position: 'fixed', bottom: '80px', left: '50%', transform: 'translateX(-50%)',
91
+ background: type === 'error' ? '#ff4a4a' : type === 'success' ? '#2ea44f' : '#333',
92
+ color: '#fff', padding: '10px 20px', borderRadius: '10px', fontSize: '13px',
93
+ fontFamily: 'inherit', zIndex: '9999', boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
94
+ transition: 'opacity 0.3s', opacity: '0', maxWidth: '90vw', textAlign: 'center',
95
+ });
96
+ document.body.appendChild(t);
97
+ requestAnimationFrame(() => t.style.opacity = '1');
98
+ setTimeout(() => {
99
+ t.style.opacity = '0';
100
+ setTimeout(() => t.remove(), 300);
101
+ }, 2500);
102
+ }
103
+
104
+
105
+ /* ============================================================
106
+ FEEDBACK
107
+ ============================================================ */
108
  function openFeedbackInSettings() {
109
  userMenu.classList.remove('show');
110
  document.querySelectorAll('.settings-nav-btn').forEach(b => b.classList.remove('active'));
 
115
  if (fbTab) fbTab.classList.add('active');
116
  settingsOverlay.classList.add('show');
117
  }
118
+ if (feedbackMenuBtn) feedbackMenuBtn.addEventListener('click', openFeedbackInSettings);
 
 
 
119
 
120
  function _bindFeedbackSubmit() {
121
  const submitFeedbackBtn = document.getElementById('submitFeedbackBtn');
 
124
  const cat = document.getElementById('feedbackCategory').value;
125
  const msg = document.getElementById('feedbackMessage').value;
126
  if (!msg.trim()) { document.getElementById('feedbackMessage').focus(); return; }
 
127
  submitFeedbackBtn.disabled = true;
128
  submitFeedbackBtn.textContent = 'Submitting...';
 
129
  fetch('/feedback', {
130
  method: 'POST',
131
  headers: { 'Content-Type': 'application/json' },
132
  body: JSON.stringify({
133
  user_id: currentUser ? currentUser.google_id : 'anonymous',
134
+ category: cat, message: msg
 
135
  })
136
+ }).then(r => {
137
+ if (!r.ok) throw new Error('Server error');
138
+ return r.json();
139
+ }).then(() => {
140
  settingsOverlay.classList.remove('show');
141
  document.getElementById('feedbackMessage').value = '';
142
+ showToast('Feedback submitted. Thank you!', 'success');
 
 
143
  }).catch(() => {
144
+ showToast('Could not submit feedback. Please try again.', 'error');
145
+ }).finally(() => {
146
  submitFeedbackBtn.disabled = false;
147
  submitFeedbackBtn.textContent = 'Submit Securely';
 
148
  });
149
  });
150
  }
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
  /* ============================================================
154
  AUTH — Google Sign-In
 
157
  let _gsiInitialized = false;
158
 
159
  function initGoogleAuth() {
160
+ return new Promise((resolve) => {
161
  if (_gsiInitialized) { resolve(); return; }
 
162
  fetch('/auth/client_id')
163
  .then(r => r.json())
164
  .then(data => {
165
+ if (!data.client_id) { showApp(); resolve(); return; }
 
 
 
 
 
166
  const script = document.createElement('script');
167
  script.src = 'https://accounts.google.com/gsi/client';
168
+ script.async = true; script.defer = true;
 
169
  script.onload = () => {
170
  google.accounts.id.initialize({
171
  client_id: data.client_id,
172
  callback: handleGoogleCredential,
173
+ auto_select: false, cancel_on_tap_outside: false,
 
174
  });
175
  _renderHiddenGoogleBtn();
176
  _gsiInitialized = true;
177
  resolve();
178
  };
179
+ script.onerror = () => { showApp(); resolve(); };
 
 
 
 
180
  document.head.appendChild(script);
181
  })
182
  .catch(() => { showApp(); resolve(); });
 
188
  if (!container) return;
189
  container.innerHTML = '';
190
  google.accounts.id.renderButton(container, {
191
+ type: 'standard', theme: 'filled_black', size: 'large',
192
+ text: 'signin_with', shape: 'pill', width: 240,
 
 
 
 
193
  });
194
+ setTimeout(() => { container.style.pointerEvents = 'auto'; if (cb) cb(); }, 150);
 
 
 
195
  }
196
 
197
  function triggerGoogleSignIn() {
198
  const tryClick = () => {
199
  const realBtn = document.querySelector('#gsi-hidden-btn [role="button"]');
200
+ if (realBtn) { realBtn.click(); }
201
+ else { _renderHiddenGoogleBtn(() => {
202
+ const btn = document.querySelector('#gsi-hidden-btn [role="button"]');
203
+ if (btn) btn.click();
204
+ }); }
 
 
 
205
  };
206
+ if (_gsiInitialized) tryClick();
207
+ else initGoogleAuth().then(tryClick);
 
 
 
 
208
  }
209
 
210
  function handleGoogleCredential(response) {
 
213
  headers: { 'Content-Type': 'application/json' },
214
  body: JSON.stringify({ token: response.credential }),
215
  })
216
+ .then(r => {
217
+ if (!r.ok) throw new Error('Auth failed');
218
+ return r.json();
219
+ })
220
  .then(data => {
221
+ if (data.error) { showToast('Login failed: ' + data.error, 'error'); return; }
222
  currentUser = data.user;
223
  localStorage.setItem('stemcopilot_user', JSON.stringify(currentUser));
224
  currentUsername = currentUser.name;
225
  localStorage.setItem('stemcopilot_username', currentUsername);
226
+ if (!data.has_api_key) showByok(); else showApp();
227
  })
228
+ .catch(() => showToast('Login failed. Check your connection and try again.', 'error'));
229
  }
230
 
231
  function checkExistingSession() {
 
243
  return;
244
  }
245
  currentUser = data.user;
246
+ if (!data.has_api_key) showByok(); else showApp();
247
  })
248
  .catch(() => initGoogleAuth());
249
  } else {
 
270
  headers: { 'Content-Type': 'application/json' },
271
  body: JSON.stringify({ user_id: currentUser.google_id, key: key }),
272
  })
273
+ .then(r => { if (!r.ok) throw new Error(); return r.json(); })
274
  .then(() => showApp())
275
+ .catch(() => showToast('Failed to save key. Try again.', 'error'));
276
  });
277
 
278
 
 
304
  if (usernameInput) usernameInput.value = currentUsername;
305
  if (languageSelect) languageSelect.value = currentLanguage;
306
 
 
307
  document.querySelectorAll('.persona-option').forEach(opt => {
308
  opt.classList.toggle('active', opt.dataset.persona === currentPersona);
309
  });
310
 
 
311
  const userId = currentUser ? currentUser.google_id : '';
312
  fetch('/threads?user_id=' + encodeURIComponent(userId))
313
  .then(r => r.json())
 
317
  })
318
  .catch(() => {});
319
 
320
+ // Ensure sidebar starts collapsed on mobile
321
+ if (window.innerWidth <= 768) closeSidebar();
 
 
322
 
323
  enterHeroMode();
324
  }
 
348
 
349
 
350
  /* ============================================================
351
+ SIDEBAR
352
  ============================================================ */
353
 
354
  let sidebarOpen = false;
355
 
356
  function _cleanSidebarStyles() {
 
357
  sidebar.style.transition = '';
358
  sidebar.style.transform = '';
359
  sidebar.style.opacity = '';
 
369
  sidebarOpen = true;
370
  _cleanSidebarStyles();
371
  sidebar.classList.remove('collapsed');
372
+ if (sidebarOverlay && window.innerWidth <= 768) sidebarOverlay.classList.add('visible');
 
 
373
  if (sidebarRail) sidebarRail.classList.remove('visible');
374
  }
375
 
 
378
  _cleanSidebarStyles();
379
  sidebar.classList.add('collapsed');
380
  if (sidebarOverlay) sidebarOverlay.classList.remove('visible');
381
+ if (sidebarRail && window.innerWidth > 768) sidebarRail.classList.add('visible');
 
 
 
382
  }
383
 
384
  function toggleSidebar() {
385
+ if (sidebarOpen) closeSidebar(); else openSidebar();
 
386
  }
387
 
388
+ if (toggleSidebarBtn) toggleSidebarBtn.addEventListener('click', toggleSidebar);
 
 
 
389
 
 
390
  if (sidebarOverlay) {
391
  sidebarOverlay.addEventListener('click', closeSidebar);
392
  sidebarOverlay.addEventListener('touchstart', (e) => {
393
+ e.preventDefault(); closeSidebar();
 
394
  }, { passive: false });
395
  }
396
 
397
+ // Edge swipe 60px zone, mid-screen horizontal swipe
398
+ let _touchStartX = 0, _touchStartY = 0;
 
399
  document.addEventListener('touchstart', (e) => {
400
  if (window.innerWidth > 768) return;
401
  _touchStartX = e.changedTouches[0].clientX;
 
406
  if (window.innerWidth > 768) return;
407
  const dx = e.changedTouches[0].clientX - _touchStartX;
408
  const dy = Math.abs(e.changedTouches[0].clientY - _touchStartY);
409
+ if (dy > Math.abs(dx) * 0.8) return; // too vertical
410
+ // Swipe right from left 60px edge → open
411
+ if (!sidebarOpen && _touchStartX < 60 && dx > 50) openSidebar();
412
+ // Swipe left anywhere close
413
+ if (sidebarOpen && dx < -50) closeSidebar();
 
 
 
 
 
414
  }, { passive: true });
415
 
 
416
  if (railExpandBtn) railExpandBtn.addEventListener('click', openSidebar);
417
  if (railNewChatBtn) railNewChatBtn.addEventListener('click', () => startNewChat());
418
  if (railProfileBtn) railProfileBtn.addEventListener('click', () => {
 
420
  setTimeout(() => { if (userProfileBtn) userProfileBtn.click(); }, 150);
421
  });
422
 
 
423
  const mobileFabToggle = document.getElementById('mobileFabToggle');
424
+ if (mobileFabToggle) mobileFabToggle.addEventListener('click', toggleSidebar);
 
 
 
 
425
  if (newChatBtn) newChatBtn.addEventListener('click', startNewChat);
426
 
427
  function startNewChat() {
 
430
  chatContainer.appendChild(createWelcomeScreen());
431
  enterHeroMode();
432
  renderHistory();
 
433
  if (window.innerWidth <= 768) closeSidebar();
434
  }
435
 
 
466
  const dynFileInput = div.querySelector('#heroImageInputDynamic');
467
 
468
  dynInput.addEventListener('input', function() {
469
+ this.style.height = '54px'; this.style.height = this.scrollHeight + 'px';
 
470
  });
 
471
  dynInput.addEventListener('keydown', function(e) {
472
  if (e.key === 'Enter' && !e.shiftKey) {
473
+ e.preventDefault(); userInput.value = dynInput.value; sendMessage();
 
 
474
  }
475
  });
476
+ dynSend.addEventListener('click', () => { userInput.value = dynInput.value; sendMessage(); });
 
 
 
 
 
477
  dynUpload.addEventListener('click', () => dynFileInput.click());
478
+ dynFileInput.addEventListener('change', () => { if (dynFileInput.files[0]) handleImageFile(dynFileInput.files[0]); });
 
 
 
 
479
  div.querySelectorAll('.hero-pill').forEach(p => {
480
+ p.addEventListener('click', () => { userInput.value = p.dataset.query; sendMessage(); });
 
 
 
481
  });
 
482
  return div;
483
  }
484
 
 
521
  renderHistory();
522
  chatContainer.innerHTML = '';
523
  exitHeroMode();
 
 
524
  if (window.innerWidth <= 768) closeSidebar();
525
 
526
  fetch('/history/' + threadId)
 
530
  const sender = msg.role === 'user' ? 'user' : 'ai';
531
  let textContent = msg.content;
532
  if (Array.isArray(msg.content)) {
533
+ textContent = msg.content.filter(p => p.type === 'text').map(p => p.text).join('');
 
 
 
534
  }
535
  const el = appendMessage(sender, textContent);
536
  if (sender === 'ai') renderFinalContent(el, textContent);
 
544
  ============================================================ */
545
 
546
  function toggleMenu(e, btn) {
547
+ e.stopPropagation(); closeAllMenus();
 
548
  btn.nextElementSibling.classList.add('show');
549
  btn.classList.add('menu-open');
550
  }
 
551
  document.addEventListener('click', closeAllMenus);
552
 
553
  function closeAllMenus() {
 
573
  const titleSpan = item.querySelector('.chat-title');
574
  const threadId = item.getAttribute('data-thread-id');
575
  closeAllMenus();
 
576
  const currentTitle = titleSpan.innerText;
577
  const input = document.createElement('input');
578
+ input.type = 'text'; input.value = currentTitle; input.className = 'rename-input';
579
+ titleSpan.replaceWith(input); input.focus();
 
 
 
580
  input.selectionStart = input.selectionEnd = input.value.length;
581
 
582
  function saveRename() {
583
  const newTitle = input.value.trim() || 'Untitled Chat';
584
+ titleSpan.innerText = newTitle; input.replaceWith(titleSpan);
 
585
  const thread = threads.find(t => t.id === threadId);
586
  if (thread) thread.title = newTitle;
587
+ fetch('/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' },
588
+ body: JSON.stringify({ thread_id: threadId, title: newTitle }) });
 
 
 
589
  }
 
590
  input.addEventListener('blur', saveRename);
591
  input.addEventListener('keydown', evt => {
592
  if (evt.key === 'Enter') saveRename();
 
601
  ============================================================ */
602
 
603
  if (userProfileBtn) userProfileBtn.addEventListener('click', (e) => {
604
+ e.stopPropagation(); userMenu.classList.toggle('show');
 
605
  });
 
606
  if (openSettingsBtn) openSettingsBtn.addEventListener('click', () => {
607
+ userMenu.classList.remove('show'); settingsOverlay.classList.add('show');
 
608
  });
 
609
  if (settingsCloseBtn) settingsCloseBtn.addEventListener('click', () => settingsOverlay.classList.remove('show'));
610
  if (settingsOverlay) settingsOverlay.addEventListener('click', (e) => {
611
  if (e.target === settingsOverlay) settingsOverlay.classList.remove('show');
612
  });
 
613
  if (logoutBtn) logoutBtn.addEventListener('click', () => {
614
  localStorage.removeItem('stemcopilot_user');
615
  localStorage.removeItem('stemcopilot_username');
616
+ currentUser = null; location.reload();
 
617
  });
618
 
 
619
  document.querySelectorAll('.settings-nav-btn').forEach(btn => {
620
  btn.addEventListener('click', () => {
621
  document.querySelectorAll('.settings-nav-btn').forEach(b => b.classList.remove('active'));
 
626
  });
627
  });
628
 
 
629
  if (usernameInput) usernameInput.addEventListener('input', () => {
630
  currentUsername = usernameInput.value.trim();
631
  localStorage.setItem('stemcopilot_username', currentUsername);
632
  });
 
 
633
  if (languageSelect) languageSelect.addEventListener('change', () => {
634
  currentLanguage = languageSelect.value;
635
  localStorage.setItem('stemcopilot_language', currentLanguage);
636
  });
637
 
 
638
  document.querySelectorAll('.persona-option').forEach(opt => {
639
  opt.addEventListener('click', () => {
640
  document.querySelectorAll('.persona-option').forEach(o => o.classList.remove('active'));
 
644
  });
645
  });
646
 
647
+ // Save API key — with success/error feedback
648
  if (saveApiKeyBtn) saveApiKeyBtn.addEventListener('click', () => {
649
  const key = settingsApiKeyInput.value.trim();
650
  if (!key || key.startsWith('••')) return;
651
  if (!currentUser) return;
652
+ saveApiKeyBtn.disabled = true;
653
+ saveApiKeyBtn.textContent = 'Saving...';
654
  fetch('/user/apikey', {
655
  method: 'POST',
656
  headers: { 'Content-Type': 'application/json' },
657
  body: JSON.stringify({ user_id: currentUser.google_id, key: key }),
658
+ }).then(r => {
659
+ if (!r.ok) throw new Error();
660
+ return r.json();
661
+ }).then(() => {
662
+ settingsApiKeyInput.value = '••••••••••••';
663
+ showToast('API key saved successfully!', 'success');
664
+ }).catch(() => {
665
+ showToast('Failed to save API key. Please try again.', 'error');
666
+ }).finally(() => {
667
+ saveApiKeyBtn.disabled = false;
668
+ saveApiKeyBtn.textContent = 'Save Key';
669
+ });
670
  });
671
 
 
672
  if (saveProfileBtn) saveProfileBtn.addEventListener('click', () => {
673
  if (!currentUser) return;
674
  fetch('/user/profile', {
675
  method: 'POST',
676
  headers: { 'Content-Type': 'application/json' },
677
  body: JSON.stringify({ user_id: currentUser.google_id, profile: profileInput.value }),
678
+ }).then(r => {
679
+ if (!r.ok) throw new Error();
680
+ showToast('Profile saved!', 'success');
681
+ }).catch(() => showToast('Failed to save profile.', 'error'));
682
  });
683
 
684
 
 
687
  ============================================================ */
688
 
689
  if (uploadBtn) uploadBtn.addEventListener('click', () => imageInput.click());
690
+ if (imageInput) imageInput.addEventListener('change', () => { if (imageInput.files[0]) handleImageFile(imageInput.files[0]); });
 
 
 
 
 
691
  if (heroUploadBtn) heroUploadBtn.addEventListener('click', () => heroImageInput.click());
692
+ if (heroImageInput) heroImageInput.addEventListener('change', () => { if (heroImageInput.files[0]) handleImageFile(heroImageInput.files[0]); });
 
 
 
693
 
 
694
  document.addEventListener('paste', (e) => {
695
  const items = e.clipboardData?.items;
696
  if (!items) return;
697
  for (const item of items) {
698
  if (item.type.startsWith('image/')) {
699
+ e.preventDefault(); handleImageFile(item.getAsFile()); return;
 
 
700
  }
701
  }
702
  });
 
720
 
721
 
722
  /* ============================================================
723
+ CHAT & STREAMING with REAL stop button
724
  ============================================================ */
725
 
726
  let currentAbortController = null;
727
+ let currentStreamReader = null; // ← key: we keep a ref to cancel it
728
 
729
  function _showStopBtn() {
730
  if (sendBtn) sendBtn.style.display = 'none';
731
+ if (stopBtn) { stopBtn.style.display = 'flex'; stopBtn.classList.add('visible'); }
 
 
 
732
  }
 
733
  function _showSendBtn() {
734
+ if (stopBtn) { stopBtn.style.display = 'none'; stopBtn.classList.remove('visible'); }
 
 
 
735
  if (sendBtn) sendBtn.style.display = 'flex';
736
  }
737
 
738
  if (stopBtn) {
739
  stopBtn.addEventListener('click', () => {
740
+ // 1) Cancel the stream reader so no more tokens arrive
741
+ if (currentStreamReader) {
742
+ try { currentStreamReader.cancel(); } catch(_) {}
743
+ currentStreamReader = null;
744
+ }
745
+ // 2) Abort the fetch connection so server detects disconnect
746
  if (currentAbortController) {
747
  currentAbortController.abort();
748
  currentAbortController = null;
749
  }
750
+ // 3) Reset UI state
751
  isSending = false;
752
  _showSendBtn();
753
  const currentThinking = document.getElementById('currentThinking');
754
  if (currentThinking) currentThinking.style.display = 'none';
755
  document.querySelectorAll('.ai-avatar.pulsing').forEach(el => el.classList.remove('pulsing'));
756
+ // 4) Remove the typing cursor from partial response
757
+ document.querySelectorAll('.message-content.cursor').forEach(el => el.classList.remove('cursor'));
758
  });
759
  }
760
 
761
  if (userInput) {
762
  userInput.addEventListener('input', function () {
763
+ this.style.height = '54px'; this.style.height = this.scrollHeight + 'px';
 
764
  });
 
765
  userInput.addEventListener('keydown', function (e) {
766
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
 
 
 
767
  });
768
  }
769
 
770
  if (sendBtn) sendBtn.addEventListener('click', sendMessage);
771
 
 
772
  document.querySelectorAll('.hero-pill').forEach(p => {
773
+ p.addEventListener('click', () => { userInput.value = p.dataset.query; sendMessage(); });
 
 
 
774
  });
775
 
 
776
  if (heroSendBtn) heroSendBtn.addEventListener('click', () => {
777
+ userInput.value = heroInput.value; sendMessage();
 
778
  });
 
779
  if (heroInput) {
780
  heroInput.addEventListener('input', function() {
781
+ this.style.height = '54px'; this.style.height = this.scrollHeight + 'px';
 
782
  });
783
  heroInput.addEventListener('keydown', function(e) {
784
  if (e.key === 'Enter' && !e.shiftKey) {
785
+ e.preventDefault(); userInput.value = heroInput.value; sendMessage();
 
 
786
  }
787
  });
788
  }
 
790
  function sendMessage() {
791
  const text = userInput.value.trim();
792
  if (!text || isSending) return;
 
793
  if (isHeroMode) exitHeroMode();
 
794
  isSending = true;
795
 
796
  if (pendingImage) {
 
805
  appendMessage('user', text);
806
  }
807
 
808
+ userInput.value = ''; userInput.style.height = '54px';
 
809
 
810
  const exists = threads.find(t => t.id === currentThreadId);
811
  if (!exists) {
 
813
  addThreadToSidebar(currentThreadId, title);
814
  } else {
815
  const idx = threads.indexOf(exists);
816
+ threads.splice(idx, 1); threads.unshift(exists); renderHistory();
 
 
817
  }
 
818
  streamResponse(text);
819
  }
820
 
 
837
  `;
838
  chatContainer.appendChild(rowDiv);
839
  scrollToBottom();
 
840
  _showStopBtn();
841
 
842
  const thinkingEl = rowDiv.querySelector('#currentThinking');
 
844
  const contentEl = rowDiv.querySelector('.message-content');
845
  let rawText = '';
846
  let firstToken = true;
847
+ let stopped = false;
848
 
849
  let renderTimer = null;
850
  const RENDER_INTERVAL = 120;
 
851
  function scheduleRender() {
852
+ if (renderTimer || stopped) return;
853
  renderTimer = setTimeout(() => {
854
  renderTimer = null;
855
+ if (!stopped) { renderFinalContent(contentEl, rawText); scrollToBottom(); }
 
856
  }, RENDER_INTERVAL);
857
  }
858
 
859
  const payload = {
860
+ message: text, thread_id: currentThreadId,
861
+ persona: currentPersona, language: currentLanguage,
 
 
862
  username: currentUsername,
863
  user_id: currentUser ? currentUser.google_id : '',
864
  image: imageData,
 
873
  signal: currentAbortController.signal,
874
  }).then(response => {
875
  const reader = response.body.getReader();
876
+ currentStreamReader = reader; // ← store so stop button can cancel it
877
  const decoder = new TextDecoder();
878
  let buffer = '';
879
 
880
  function read() {
881
  reader.read().then(({ done, value }) => {
882
+ if (done || stopped) {
883
+ if (!stopped) finishStream(thinkingEl, contentEl, rawText, renderTimer);
884
  return;
885
  }
886
 
 
889
  buffer = lines.pop();
890
 
891
  lines.forEach(line => {
892
+ if (stopped) return;
893
  if (!line.startsWith('data: ')) return;
894
+ const pl = line.substring(6);
895
+ if (pl === '[DONE]') {
 
896
  finishStream(thinkingEl, contentEl, rawText, renderTimer);
897
+ stopped = true; return;
898
  }
 
899
  try {
900
+ const data = JSON.parse(pl);
901
+ if (data.status === 'thinking') { thinkingText.textContent = data.message; return; }
 
 
 
 
 
902
  if (data.error) {
903
  thinkingEl.style.display = 'none';
904
  contentEl.style.display = 'block';
905
  contentEl.innerHTML = `<div class="error-message">${escapeHtml(data.error)}</div>`;
906
+ isSending = false; _showSendBtn(); stopped = true; return;
 
 
907
  }
 
908
  if (data.token !== undefined) {
909
  if (firstToken) {
910
  thinkingEl.style.display = 'none';
 
915
  rawText += data.token;
916
  scheduleRender();
917
  }
918
+ } catch (_) {}
919
  });
920
 
921
+ if (!stopped) read();
922
+ }).catch(err => {
923
+ // reader.read() rejected — happens on abort/cancel
924
+ if (err.name === 'AbortError' || stopped) return;
925
+ // Genuine error
926
+ thinkingEl.style.display = 'none';
927
+ contentEl.style.display = 'block';
928
+ if (!rawText) contentEl.innerHTML = '<div class="error-message">Connection lost. Please try again.</div>';
929
+ isSending = false; _showSendBtn();
930
  });
931
  }
 
932
  read();
933
+ }).catch(err => {
934
  if (err.name === 'AbortError') {
935
+ // User clicked stop before any response — that's fine
936
  thinkingEl.style.display = 'none';
937
  contentEl.style.display = 'block';
938
+ isSending = false; _showSendBtn();
 
939
  return;
940
  }
941
  thinkingEl.style.display = 'none';
942
  contentEl.style.display = 'block';
943
  contentEl.innerHTML = '<div class="error-message">Could not connect to the server. Please try again.</div>';
944
+ isSending = false; _showSendBtn();
 
945
  }).finally(() => {
946
  currentAbortController = null;
947
+ currentStreamReader = null;
948
  const avatar = rowDiv.querySelector('#avatarThinking');
949
  if (avatar) avatar.classList.remove('pulsing');
950
  });
 
982
  return rowDiv.querySelector('.message-content');
983
  }
984
 
985
+ function scrollToBottom() { chatContainer.scrollTop = chatContainer.scrollHeight; }
 
 
986
 
987
  function escapeHtml(text) {
988
  if (typeof text !== 'string') return '';
 
998
 
999
  function renderFinalContent(element, rawText) {
1000
  if (!rawText) return;
 
1001
  const blocks = [];
1002
  let safeText = rawText;
1003
+ safeText = safeText.replace(/\$\$[\s\S]*?\$\$/g, m => { blocks.push(m); return `%%LATEX_${blocks.length-1}%%`; });
1004
+ safeText = safeText.replace(/\$[^\$\n]+?\$/g, m => { blocks.push(m); return `%%LATEX_${blocks.length-1}%%`; });
1005
+ safeText = safeText.replace(/\\\[[\s\S]*?\\\]/g, m => { blocks.push(m); return `%%LATEX_${blocks.length-1}%%`; });
1006
+ safeText = safeText.replace(/\\\([\s\S]*?\\\)/g, m => { blocks.push(m); return `%%LATEX_${blocks.length-1}%%`; });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1007
  let html = typeof marked !== 'undefined' ? marked.parse(safeText) : safeText;
1008
+ blocks.forEach((block, i) => { html = html.replace(`%%LATEX_${i}%%`, block); });
 
 
 
 
1009
  element.innerHTML = html;
 
1010
  if (typeof renderMathInElement !== 'undefined') {
1011
  renderMathInElement(element, {
1012
  delimiters: [
 
1034
  window.addEventListener('beforeinstallprompt', (e) => {
1035
  e.preventDefault();
1036
  deferredPrompt = e;
1037
+ // Show install banner immediately works on login screen too
1038
  const dismissed = localStorage.getItem('stemcopilot_install_dismissed');
1039
  if (!dismissed && installBanner) {
1040
  installBanner.style.display = 'flex';
 
1061
  INIT
1062
  ============================================================ */
1063
 
1064
+ // Ensure sidebar starts collapsed in DOM (matches CSS default)
1065
+ if (sidebar) sidebar.classList.add('collapsed');
1066
+
1067
  window.addEventListener('load', () => {
1068
  checkExistingSession();
1069
  _bindFeedbackSubmit();
static/index.html CHANGED
@@ -81,7 +81,7 @@
81
  <div class="app-container" id="appContainer" style="display:none;">
82
 
83
  <!-- Sidebar (full) -->
84
- <aside class="sidebar" id="sidebar">
85
  <div class="sidebar-top">
86
  <div class="sidebar-logo-row">
87
  <img src="/assets/stembotix.png" alt="STEMbotix" class="sidebar-logo">
 
81
  <div class="app-container" id="appContainer" style="display:none;">
82
 
83
  <!-- Sidebar (full) -->
84
+ <aside class="sidebar collapsed" id="sidebar">
85
  <div class="sidebar-top">
86
  <div class="sidebar-logo-row">
87
  <img src="/assets/stembotix.png" alt="STEMbotix" class="sidebar-logo">