Subh775 commited on
Commit
654b523
Β·
1 Parent(s): b58a7fe

rollback - safe changes

Browse files
Files changed (7) hide show
  1. app.py +12 -6
  2. graph.py +54 -174
  3. requirements.txt +2 -1
  4. static/app.js +378 -222
  5. static/index.html +2 -4
  6. static/style.css +52 -96
  7. tools.py +4 -22
app.py CHANGED
@@ -5,7 +5,6 @@ FastAPI application β€” routes for chat (SSE streaming), auth, settings, and sta
5
  import os
6
  import json
7
  import time
8
- import base64
9
  import io
10
  from typing import Optional
11
  from fastapi import FastAPI, Request, UploadFile, File
@@ -51,8 +50,8 @@ class ChatRequest(BaseModel):
51
  language: str = "auto"
52
  username: str = ""
53
  user_id: str = ""
54
- image: str = "" # base64 encoded image
55
- doc_text: str = "" # extracted text from an uploaded document
56
 
57
 
58
  class RenameRequest(BaseModel):
@@ -82,6 +81,7 @@ class FeedbackRequest(BaseModel):
82
  message: str
83
  attachments: Optional[list[dict]] = None # [{filename, content (b64), content_type}]
84
 
 
85
  @app.post("/feedback")
86
  async def submit_feedback(req: FeedbackRequest):
87
  ok = feed_module.send_feedback(
@@ -204,6 +204,7 @@ async def upload_doc(file: UploadFile = File(...)):
204
 
205
  APP_DIR = os.path.dirname(os.path.abspath(__file__))
206
 
 
207
  @app.get("/sw.js")
208
  def serve_sw():
209
  """Service worker must be served from root for PWA scope."""
@@ -262,9 +263,13 @@ def chat(request: ChatRequest, req: Request):
262
  yield sse_done()
263
  return
264
 
265
- # Step 2: Retrieve context from Pinecone
266
- results = retriever.search(request.message)
267
- context = retriever.format_context(results)
 
 
 
 
268
 
269
  # Step 3: Build message (text or multimodal with image / document)
270
  extra_context = ""
@@ -325,6 +330,7 @@ def chat(request: ChatRequest, req: Request):
325
  # Regular AI text token
326
  elif isinstance(chunk, AIMessage) and chunk.content:
327
  yield sse_token(chunk.content)
 
328
  except Exception as e:
329
  error_str = str(e).lower()
330
  if "429" in str(e) or "rate" in error_str:
 
5
  import os
6
  import json
7
  import time
 
8
  import io
9
  from typing import Optional
10
  from fastapi import FastAPI, Request, UploadFile, File
 
50
  language: str = "auto"
51
  username: str = ""
52
  user_id: str = ""
53
+ image: str = "" # base64 encoded image data
54
+ doc_text: str = "" # extracted text from attached document
55
 
56
 
57
  class RenameRequest(BaseModel):
 
81
  message: str
82
  attachments: Optional[list[dict]] = None # [{filename, content (b64), content_type}]
83
 
84
+
85
  @app.post("/feedback")
86
  async def submit_feedback(req: FeedbackRequest):
87
  ok = feed_module.send_feedback(
 
204
 
205
  APP_DIR = os.path.dirname(os.path.abspath(__file__))
206
 
207
+
208
  @app.get("/sw.js")
209
  def serve_sw():
210
  """Service worker must be served from root for PWA scope."""
 
263
  yield sse_done()
264
  return
265
 
266
+ # Step 2: Retrieve context from Pinecone (resilient to connection/latencies)
267
+ try:
268
+ results = retriever.search(request.message)
269
+ context = retriever.format_context(results)
270
+ except Exception as exc:
271
+ print(f"[PINECONE ERROR] Failed to retrieve context: {exc}")
272
+ context = "No relevant context found due to a temporary search error."
273
 
274
  # Step 3: Build message (text or multimodal with image / document)
275
  extra_context = ""
 
330
  # Regular AI text token
331
  elif isinstance(chunk, AIMessage) and chunk.content:
332
  yield sse_token(chunk.content)
333
+
334
  except Exception as e:
335
  error_str = str(e).lower()
336
  if "429" in str(e) or "rate" in error_str:
graph.py CHANGED
@@ -1,16 +1,9 @@
1
  """
2
  LangGraph β€” Intelligent Model Router for STEM Copilot.
3
 
4
- Routes queries to the best free OpenRouter model based on intent:
5
- - Math / derivation / proofs β†’ top reasoning models (rotated)
6
- - Physics / chemistry concepts β†’ strong general models (rotated)
7
- - Casual greetings ("hi", "ok") β†’ lightweight models
8
- - Image understanding β†’ vision-capable models (verified)
9
-
10
- Model pools are fetched from the OpenRouter API, cached for 10 min,
11
- and rotated per-category to avoid per-model rate limits.
12
- On 429 errors, the failed model is skipped and the next model in the
13
- pool is tried automatically (up to 2 retries).
14
  """
15
 
16
  from langgraph.graph import StateGraph, START, END
@@ -24,9 +17,6 @@ from typing import TypedDict, Annotated
24
 
25
  import sqlite3
26
  import time
27
- import json
28
- import urllib.request
29
- import threading
30
  from langgraph.checkpoint.sqlite import SqliteSaver # type:ignore
31
  from config import OPENROUTER_API_KEY, DB_PATH, LLM_TIMEOUT
32
  import prompts
@@ -38,26 +28,30 @@ _conn = sqlite3.connect(DB_PATH, check_same_thread=False)
38
  checkpointer = SqliteSaver(conn=_conn)
39
 
40
 
41
- # ── Constants ─────────────────────────────────────────────────
42
- DEFAULT_MODEL = "openai/gpt-oss-120b:free"
43
- MODELS_TTL = 10 * 60 # seconds
44
-
45
-
46
- # ── Hard model blocklist (Venice-served / known bad actors) ──
47
- # These models are temporarily rate-limited upstream on Venice and cause
48
- # 429 storms or routing errors. They are blocked regardless of the
49
- # OpenRouter API model list. Update as OpenRouter stabilises them.
50
- _BLOCKED_MODELS: frozenset[str] = frozenset([
51
- "meta-llama/llama-3.3-70b-instruct:free",
52
- "nousresearch/hermes-3-llama-3.1-405b:free",
53
- "qwen/qwen3-coder:free",
54
- ])
55
-
56
-
57
- def _is_guardrail(model_id: str) -> bool:
58
- """Auto-filter safety classifiers / guardrail models."""
59
- base = model_id.lower().split(":")[0]
60
- return any(kw in base for kw in ("guard", "shield", "safety", "moderation"))
 
 
 
 
61
 
62
 
63
  # ── Query Classification ─────────────────────────────────────
@@ -123,144 +117,35 @@ def _classify(text: str) -> str:
123
  return "general"
124
 
125
 
126
- # ── Model Scoring ─────────────────────────────────────────────
127
-
128
- def _score(model_id: str) -> int:
129
- """Higher score β‰ˆ better for hard reasoning tasks."""
130
- m = model_id.lower()
131
- s = 50
132
- for tag, pts in [
133
- ("253b", 100), ("250b", 100), ("200b", 95),
134
- ("120b", 90), ("110b", 88), ("100b", 85),
135
- ("70b", 70), ("72b", 70), ("65b", 68),
136
- ("49b", 55), ("46b", 55), ("40b", 55),
137
- ("27b", 40), ("32b", 42), ("34b", 42),
138
- ("13b", 20), ("14b", 20), ("8b", 15),
139
- ("3b", -20), ("1b", -40), ("0.5b", -50),
140
- ]:
141
- if tag in m:
142
- s += pts
143
- break
144
- if "nemotron" in m: s += 15
145
- if "gpt" in m: s += 10
146
- if "llama" in m: s += 5
147
- if "qwen" in m: s += 5
148
- if "deepseek" in m: s += 8
149
- return s
150
-
151
-
152
- # ── Model Pool Fetching & Caching ─────────────────────────────
153
-
154
- _cache: dict | None = None
155
- _cache_at = 0.0
156
- _lock = threading.Lock()
157
- _counters: dict[str, int] = {}
158
-
159
-
160
- def _fetch_pools() -> dict:
161
- """Fetch free models, detect vision support, build sorted pools."""
162
- global _cache, _cache_at
163
-
164
- with _lock:
165
- if _cache and (time.time() - _cache_at) < MODELS_TTL:
166
- return _cache
167
-
168
- try:
169
- req = urllib.request.Request(
170
- "https://openrouter.ai/api/v1/models",
171
- headers={
172
- "HTTP-Referer": "https://stemcopilot.app",
173
- "User-Agent": "STEMCopilot/1.0",
174
- },
175
- )
176
- with urllib.request.urlopen(req, timeout=10) as resp:
177
- raw = json.loads(resp.read().decode())
178
-
179
- text_all, vision_all = [], []
180
-
181
- for m in raw.get("data", []):
182
- mid = m.get("id", "")
183
- if not mid.endswith(":free"):
184
- continue
185
- if _is_guardrail(mid):
186
- continue
187
- if mid in _BLOCKED_MODELS:
188
- continue
189
-
190
- arch = m.get("architecture", {})
191
- modality = arch.get("modality", "")
192
- input_mods = arch.get("input_modalities", [])
193
- # Vision pool: any model accepting image, video, or audio input
194
- # Covers nemotron-omni (video+audio) and standard vision models
195
- has_vision = (
196
- "image" in modality
197
- or "image" in input_mods
198
- or "video" in input_mods
199
- or "audio" in input_mods
200
- )
201
-
202
- entry = {"id": mid, "score": _score(mid), "vision": has_vision}
203
- text_all.append(entry)
204
- if has_vision:
205
- vision_all.append(entry)
206
-
207
- text_all.sort(key=lambda x: x["score"], reverse=True)
208
- vision_all.sort(key=lambda x: x["score"], reverse=True)
209
-
210
- pools = {
211
- "reasoning": text_all[:6],
212
- "science": text_all[:8],
213
- "general": text_all[:10],
214
- "casual": text_all[-5:][::-1] if len(text_all) >= 5 else text_all[:3],
215
- "vision": vision_all,
216
- }
217
-
218
- with _lock:
219
- _cache = pools
220
- _cache_at = time.time()
221
-
222
- print(f"[ROUTER] {len(text_all)} free models, {len(vision_all)} vision-capable")
223
- for cat in ("reasoning", "casual", "vision"):
224
- ids = [e["id"] for e in pools[cat][:3]]
225
- if ids:
226
- print(f"[ROUTER] {cat}: {ids}")
227
-
228
- return pools
229
-
230
- except Exception as exc:
231
- print(f"[ROUTER] Fetch failed: {exc}")
232
- fallback_entry = {"id": DEFAULT_MODEL, "score": 50, "vision": False}
233
- return _cache or {
234
- k: [fallback_entry]
235
- for k in ("reasoning", "science", "general", "casual", "vision")
236
- }
237
 
 
238
 
239
- # ── Model Picker (round-robin) ────────────────────────────────
240
 
241
  def _pick(category: str, has_image: bool = False, skip_models: set | None = None) -> tuple[str, str]:
242
  """
243
- Return (model_id, actual_category).
244
- If has_image but no vision model exists, falls back to text category.
245
- skip_models: set of model IDs to skip (e.g. after a 429).
246
  """
247
- pools = _fetch_pools()
248
  skip = skip_models or set()
249
 
250
  if has_image:
251
- v = [m for m in pools.get("vision", []) if m["id"] not in skip]
252
  if v:
253
  idx = _counters.get("vision", 0) % len(v)
254
  _counters["vision"] = idx + 1
255
- return v[idx]["id"], "vision"
256
 
257
- pool = [m for m in (pools.get(category) or pools.get("general") or []) if m["id"] not in skip]
258
  if not pool:
259
- return DEFAULT_MODEL, category
 
 
 
 
260
 
261
  idx = _counters.get(category, 0) % len(pool)
262
  _counters[category] = idx + 1
263
- return pool[idx]["id"], category
264
 
265
 
266
  # ── Message helpers ────────────────────────────────────────────
@@ -300,9 +185,6 @@ def _strip_images(messages: list) -> list:
300
  # ── LLM factory ───────────────────────────────────────────────
301
 
302
  def _make_llm(api_key: str, model_id: str, bind_tools: bool = False):
303
- # Strip whitespace β€” hard refresh can leave a stale/empty string in config
304
- # which would be truthy enough to skip the fallback but empty for OpenRouter,
305
- # causing a 401 "User not found" error.
306
  key = (api_key or "").strip() or (OPENROUTER_API_KEY or "").strip()
307
  if not key:
308
  raise ValueError("No API key. Add your OpenRouter key in Settings.")
@@ -313,8 +195,7 @@ def _make_llm(api_key: str, model_id: str, bind_tools: bool = False):
313
  max_tokens=4096,
314
  max_retries=0,
315
  streaming=True,
316
- # Ignore Venice as a backend provider β€” it is temporarily rate-limited
317
- # upstream for several free models, causing 429 storms in the interface.
318
  openrouter_provider={"ignore": ["Venice"]},
319
  )
320
  if bind_tools:
@@ -375,7 +256,7 @@ def chat_node(state: ChatState, config: RunnableConfig):
375
 
376
  sys = SystemMessage(content=base_prompt)
377
 
378
- # Try up to 3 models on 429/auth errors
379
  skip_models: set[str] = set()
380
  last_error = None
381
 
@@ -396,18 +277,18 @@ def chat_node(state: ChatState, config: RunnableConfig):
396
  for _iter in range(5):
397
  resp = llm.invoke([sys] + loop_msgs, config=config)
398
 
399
- # No tool calls β†’ final answer, break out
400
  if not getattr(resp, "tool_calls", None):
401
  return {"messages": [resp]}
402
 
403
- # Execute each requested tool and collect results
404
  print(f"[TOOLS] iter={_iter+1} calls={[tc['name'] for tc in resp.tool_calls]}")
405
  loop_msgs.append(resp)
406
  for tc in resp.tool_calls:
407
  tool_msg = _run_tool(tc)
408
  loop_msgs.append(tool_msg)
409
 
410
- # Exceeded iterations β†’ final invoke without tools to force an answer
411
  final_llm = _make_llm(api_key, model_id, bind_tools=False)
412
  resp = final_llm.invoke([sys] + loop_msgs, config=config)
413
  return {"messages": [resp]}
@@ -415,17 +296,17 @@ def chat_node(state: ChatState, config: RunnableConfig):
415
  except Exception as e:
416
  err_str = str(e)
417
  last_error = e
418
- # Rotate on rate-limit (429) OR provider-level auth errors (401)
419
- # A 401 "User not found" can occur when OpenRouter routes to a
420
- # provider that does not recognise the key; rotating to another
421
- # model / provider resolves it without crashing the session.
422
- is_rate = "429" in err_str or "TooManyRequests" in err_str or "rate" in err_str.lower()
423
  is_auth_err = "401" in err_str or "User not found" in err_str or "Unauthorized" in err_str
424
- is_ignored = "404" in err_str and "ignored" in err_str.lower()
425
-
426
- if is_rate or is_auth_err or is_ignored:
427
- reason = "429/rate-limit" if is_rate else ("401/provider-auth" if is_auth_err else "404/providers-ignored")
428
- print(f"[ROUTER] {reason} on {model_id}, rotating...")
 
 
429
  skip_models.add(model_id)
430
  continue
431
  raise
@@ -434,7 +315,6 @@ def chat_node(state: ChatState, config: RunnableConfig):
434
 
435
 
436
  # ── Compile graph ─────────────────────────────────────────────
437
-
438
  _g = StateGraph(ChatState)
439
  _g.add_node("chat_node", chat_node)
440
  _g.add_edge(START, "chat_node")
 
1
  """
2
  LangGraph β€” Intelligent Model Router for STEM Copilot.
3
 
4
+ Routes queries to the best free OpenRouter model based on intent.
5
+ Uses static model pools (excluding Venice) to ensure stability and low latency.
6
+ Supports tool-calling (web search, YouTube transcript) inside a ReAct loop.
 
 
 
 
 
 
 
7
  """
8
 
9
  from langgraph.graph import StateGraph, START, END
 
17
 
18
  import sqlite3
19
  import time
 
 
 
20
  from langgraph.checkpoint.sqlite import SqliteSaver # type:ignore
21
  from config import OPENROUTER_API_KEY, DB_PATH, LLM_TIMEOUT
22
  import prompts
 
28
  checkpointer = SqliteSaver(conn=_conn)
29
 
30
 
31
+ # ── Static Model Pools (excluding Venice provider) ──
32
+ _STATIC_POOLS = {
33
+ "reasoning": [
34
+ "nvidia/nemotron-3-ultra-550b-a55b:free",
35
+ "nex-agi/nex-n2-pro:free",
36
+ "google/gemma-4-31b-it:free",
37
+ ],
38
+ "science": [
39
+ "nvidia/nemotron-nano-9b-v2:free",
40
+ "nvidia/nemotron-3-nano-30b-a3b:free",
41
+ ],
42
+ "general": [
43
+ "nvidia/nemotron-nano-9b-v2:free",
44
+ "nvidia/nemotron-3-nano-30b-a3b:free",
45
+ ],
46
+ "casual": [
47
+ "openai/gpt-oss-20b:free",
48
+ "nvidia/nemotron-3-nano-30b-a3b:free",
49
+ ],
50
+ "vision": [
51
+ "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
52
+ "google/gemma-4-31b-it:free",
53
+ ]
54
+ }
55
 
56
 
57
  # ── Query Classification ─────────────────────────────────────
 
117
  return "general"
118
 
119
 
120
+ # ── Model Picker (round-robin) ────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
+ _counters: dict[str, int] = {}
123
 
 
124
 
125
  def _pick(category: str, has_image: bool = False, skip_models: set | None = None) -> tuple[str, str]:
126
  """
127
+ Return (model_id, actual_category) from static pools.
 
 
128
  """
 
129
  skip = skip_models or set()
130
 
131
  if has_image:
132
+ v = [m for m in _STATIC_POOLS["vision"] if m not in skip]
133
  if v:
134
  idx = _counters.get("vision", 0) % len(v)
135
  _counters["vision"] = idx + 1
136
+ return v[idx], "vision"
137
 
138
+ pool = [m for m in (_STATIC_POOLS.get(category) or _STATIC_POOLS["general"]) if m not in skip]
139
  if not pool:
140
+ # Fallback to general list if no model is left in active pool
141
+ fallback_list = _STATIC_POOLS["casual"] + _STATIC_POOLS["general"]
142
+ pool = [m for m in fallback_list if m not in skip]
143
+ if not pool:
144
+ return "nvidia/nemotron-3-nano-30b-a3b:free", category
145
 
146
  idx = _counters.get(category, 0) % len(pool)
147
  _counters[category] = idx + 1
148
+ return pool[idx], category
149
 
150
 
151
  # ── Message helpers ────────────────────────────────────────────
 
185
  # ── LLM factory ───────────────────────────────────────────────
186
 
187
  def _make_llm(api_key: str, model_id: str, bind_tools: bool = False):
 
 
 
188
  key = (api_key or "").strip() or (OPENROUTER_API_KEY or "").strip()
189
  if not key:
190
  raise ValueError("No API key. Add your OpenRouter key in Settings.")
 
195
  max_tokens=4096,
196
  max_retries=0,
197
  streaming=True,
198
+ timeout=60, # 60 seconds timeout per request
 
199
  openrouter_provider={"ignore": ["Venice"]},
200
  )
201
  if bind_tools:
 
256
 
257
  sys = SystemMessage(content=base_prompt)
258
 
259
+ # Try up to 3 models on failures / rate limits
260
  skip_models: set[str] = set()
261
  last_error = None
262
 
 
277
  for _iter in range(5):
278
  resp = llm.invoke([sys] + loop_msgs, config=config)
279
 
280
+ # No tool calls β†’ final answer
281
  if not getattr(resp, "tool_calls", None):
282
  return {"messages": [resp]}
283
 
284
+ # Execute requested tools and collect results
285
  print(f"[TOOLS] iter={_iter+1} calls={[tc['name'] for tc in resp.tool_calls]}")
286
  loop_msgs.append(resp)
287
  for tc in resp.tool_calls:
288
  tool_msg = _run_tool(tc)
289
  loop_msgs.append(tool_msg)
290
 
291
+ # Exceeded iterations β†’ final invoke without tools to force answer
292
  final_llm = _make_llm(api_key, model_id, bind_tools=False)
293
  resp = final_llm.invoke([sys] + loop_msgs, config=config)
294
  return {"messages": [resp]}
 
296
  except Exception as e:
297
  err_str = str(e)
298
  last_error = e
299
+
300
+ # Rotate on rate-limits, provider auth, timeout, or provider failure
301
+ is_rate = "429" in err_str or "TooManyRequests" in err_str or "rate" in err_str.lower()
 
 
302
  is_auth_err = "401" in err_str or "User not found" in err_str or "Unauthorized" in err_str
303
+ is_ignored = "ignored" in err_str.lower() or "no provider" in err_str.lower() or "404" in err_str
304
+ is_timeout = "timeout" in err_str.lower() or "timed out" in err_str.lower()
305
+ is_connection = "connection" in err_str.lower() or "connect" in err_str.lower()
306
+
307
+ if is_rate or is_auth_err or is_ignored or is_timeout or is_connection:
308
+ reason = "rate-limit" if is_rate else ("auth" if is_auth_err else ("ignored" if is_ignored else "timeout/connection"))
309
+ print(f"[ROUTER] Error ({reason}) on {model_id}, rotating...")
310
  skip_models.add(model_id)
311
  continue
312
  raise
 
315
 
316
 
317
  # ── Compile graph ─────────────────────────────────────────────
 
318
  _g = StateGraph(ChatState)
319
  _g.add_node("chat_node", chat_node)
320
  _g.add_edge(START, "chat_node")
requirements.txt CHANGED
@@ -12,6 +12,7 @@ torch --index-url https://download.pytorch.org/whl/cpu
12
  google-auth
13
  langsmith
14
  langchain-community
15
- ddgs
 
16
  pdfplumber
17
  python-docx
 
12
  google-auth
13
  langsmith
14
  langchain-community
15
+ duckduckgo-search
16
+ youtube-transcript-api
17
  pdfplumber
18
  python-docx
static/app.js CHANGED
@@ -16,6 +16,10 @@ let pendingImage = null;
16
  let pendingImageDataUrl = null;
17
  let isHeroMode = true;
18
 
 
 
 
 
19
  let currentPersona = localStorage.getItem('stemcopilot_persona') || 'vidyut';
20
  let currentLanguage = localStorage.getItem('stemcopilot_language') || 'auto';
21
  let currentUsername = localStorage.getItem('stemcopilot_username') || '';
@@ -52,7 +56,6 @@ const heroAttachFileBtn = document.getElementById('heroAttachFileBtn');
52
  const heroImageInput = document.getElementById('heroImageInput');
53
  const heroDocInput = document.getElementById('heroDocInput');
54
  const heroToolProgressBar = document.getElementById('heroToolProgressBar');
55
- const heroToolProgressLabel = document.getElementById('heroToolProgressLabel');
56
  const heroTitle = document.getElementById('heroTitle');
57
 
58
  const byokInput = document.getElementById('byokInput');
@@ -85,10 +88,8 @@ const attachMenu = document.getElementById('attachMenu');
85
  const webSearchToggle = document.getElementById('webSearchToggle');
86
  const ytSearchToggle = document.getElementById('ytSearchToggle');
87
  const attachFileBtn = document.getElementById('attachFileBtn');
88
- const imageInput = document.getElementById('imageInput');
89
  const docInput = document.getElementById('docInput');
90
  const toolProgressBar = document.getElementById('toolProgressBar');
91
- const toolProgressLabel = document.getElementById('toolProgressLabel');
92
 
93
  const imagePreviewBar = document.getElementById('imagePreviewBar');
94
  const imagePreviewThumb = document.getElementById('imagePreviewThumb');
@@ -741,7 +742,7 @@ function createWelcomeScreen() {
741
  const name = currentUsername || (currentUser ? currentUser.name : '');
742
  const greeting = _HERO_GREETINGS[Math.floor(Math.random() * _HERO_GREETINGS.length)];
743
  const titleHtml = name ? `${greeting}, <span class="hero-name">${escapeHtml(name)}</span>?` : `${greeting}?`;
744
- const styleLabel = _PERSONA_LABELS[currentPersona] || 'Vidyut';
745
 
746
  div.innerHTML = `
747
  <img src="/assets/bot.png" alt="STEM Copilot" class="hero-bot-icon">
@@ -749,10 +750,8 @@ function createWelcomeScreen() {
749
  <div class="hero-input-wrap">
750
  <div class="hero-image-preview" id="heroImagePreviewDynamic">
751
  <div class="hero-image-preview-thumb" id="heroImagePreviewThumbDynamic"></div>
752
- <span class="image-preview-name" id="heroImagePreviewNameDynamic"></span>
753
  <button class="hero-image-preview-remove" id="heroImagePreviewRemoveDynamic">&times;</button>
754
  </div>
755
- <!-- Tool progress bar (hero dynamic) -->
756
  <div class="tool-progress-bar" id="heroToolProgressBarDynamic" style="display:none; padding: 0 16px;">
757
  <div class="tool-progress-label" id="heroToolProgressLabelDynamic">Searching...</div>
758
  <div class="tool-progress-track"><div class="tool-progress-fill" id="heroToolProgressFillDynamic"></div></div>
@@ -760,14 +759,17 @@ function createWelcomeScreen() {
760
  <div class="hero-input-box" id="heroInputBoxDynamic">
761
  <div class="attach-wrap" id="heroAttachWrapDynamic">
762
  <button class="attach-plus-btn" id="heroAttachPlusBtnDynamic" title="Attach or search">
763
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
 
 
 
764
  </button>
765
  <div class="attach-menu" id="heroAttachMenuDynamic">
766
- <button class="attach-menu-item" id="heroWebSearchToggleDynamic" data-active="${isWebSearchEnabled}">
767
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
768
  Web Search
769
  </button>
770
- <button class="attach-menu-item" id="heroYtSearchToggleDynamic" data-active="${isYtSearchEnabled}">
771
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
772
  YouTube Video
773
  </button>
@@ -778,8 +780,8 @@ function createWelcomeScreen() {
778
  </div>
779
  </div>
780
  <input type="file" id="heroImageInputDynamic" accept="image/*" style="display:none;">
781
- <input type="file" id="heroDocInputDynamic" accept="image/*,.pdf,.doc,.docx,.txt,.md,.py,.js,.ts,.csv" style="display:none;">
782
- <textarea id="heroInputDynamic" placeholder="${isWebSearchEnabled ? 'Search the web...' : (isYtSearchEnabled ? 'Paste a YouTube link...' : 'Ask STEM Copilot...')}" rows="1"></textarea>
783
  <div class="style-selector-wrap" id="heroStyleSelectorWrapDynamic">
784
  <button class="style-selector-btn" id="heroStyleSelectorBtnDynamic" title="Teaching style">
785
  <span id="heroStyleSelectorLabelDynamic">${styleLabel}</span>
@@ -803,22 +805,19 @@ function createWelcomeScreen() {
803
 
804
  const dynInput = div.querySelector('#heroInputDynamic');
805
  const dynAdaptive = div.querySelector('#heroAdaptiveBtnDynamic');
806
- const dynPlusBtn = div.querySelector('#heroAttachPlusBtnDynamic');
807
- const dynMenu = div.querySelector('#heroAttachMenuDynamic');
808
- const dynWebToggle = div.querySelector('#heroWebSearchToggleDynamic');
809
- const dynYtToggle = div.querySelector('#heroYtSearchToggleDynamic');
810
- const dynFileBtn = div.querySelector('#heroAttachFileBtnDynamic');
811
- const dynDocInput = div.querySelector('#heroDocInputDynamic');
812
- const dynImgInput = div.querySelector('#heroImageInputDynamic');
813
-
814
  const dynImgPreview = div.querySelector('#heroImagePreviewDynamic');
815
- const dynImgThumb = div.querySelector('#heroImagePreviewThumbDynamic');
816
- const dynImgName = div.querySelector('#heroImagePreviewNameDynamic');
817
- const dynImgRemove = div.querySelector('#heroImagePreviewRemoveDynamic');
818
  const dynStyleBtn = div.querySelector('#heroStyleSelectorBtnDynamic');
819
  const dynStyleDropdown = div.querySelector('#heroStyleDropdownDynamic');
820
  const dynStyleLabel = div.querySelector('#heroStyleSelectorLabelDynamic');
821
 
 
 
 
 
 
 
 
 
822
  // Auto-resize
823
  dynInput.addEventListener('input', function () {
824
  this.style.height = '54px';
@@ -832,14 +831,23 @@ function createWelcomeScreen() {
832
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); userInput.value = dynInput.value; sendMessage(); }
833
  });
834
 
835
- setupAttachMenu(dynPlusBtn, dynMenu, dynWebToggle, dynYtToggle, dynFileBtn, dynDocInput);
836
-
837
- dynDocInput.addEventListener('change', () => _handleCombinedFile(dynDocInput.files[0], true));
838
-
839
- dynImgRemove.addEventListener('click', () => {
840
- clearAttachments();
841
- dynImgPreview.classList.remove('visible');
842
- });
 
 
 
 
 
 
 
 
 
843
 
844
  // Style selector
845
  if (dynStyleBtn) {
@@ -852,7 +860,7 @@ function createWelcomeScreen() {
852
  _setPersona(opt.dataset.persona);
853
  dynStyleDropdown.classList.remove('show');
854
  dynStyleBtn.classList.remove('open');
855
- dynStyleLabel.textContent = _PERSONA_LABELS[currentPersona] || 'Vidyut';
856
  dynStyleDropdown.querySelectorAll('.style-option').forEach(o => o.classList.toggle('active', o.dataset.persona === currentPersona));
857
  });
858
  });
@@ -1111,8 +1119,6 @@ function _bindAdaptiveBtn(btn, inputEl) {
1111
  // Act as mic
1112
  if (!SpeechRecognition) { showToast('Voice input not supported in this browser.', 'error'); return; }
1113
 
1114
- if (!SpeechRecognition) { showToast('Voice input not supported in this browser.', 'error'); return; }
1115
-
1116
  if (btn.classList.contains('listening')) {
1117
  if (recognition) recognition.stop();
1118
  btn.classList.remove('listening');
@@ -1138,159 +1144,6 @@ function _bindAdaptiveBtn(btn, inputEl) {
1138
  });
1139
  }
1140
 
1141
- // --- Attach Menu Logic ---
1142
- let isWebSearchEnabled = false;
1143
- let isYtSearchEnabled = false;
1144
- let pendingDocText = '';
1145
-
1146
- function setupAttachMenu(plusBtn, menu, webToggle, ytToggle, fileBtn, fileInput) {
1147
- if (!plusBtn) return;
1148
- plusBtn.addEventListener('click', (e) => {
1149
- e.preventDefault();
1150
- e.stopPropagation();
1151
- const isOpen = menu.classList.contains('show');
1152
- document.querySelectorAll('.attach-menu').forEach(m => m.classList.remove('show'));
1153
- document.querySelectorAll('.attach-plus-btn').forEach(b => b.classList.remove('open'));
1154
- if (!isOpen) {
1155
- menu.classList.add('show');
1156
- plusBtn.classList.add('open');
1157
- }
1158
- });
1159
-
1160
- webToggle.addEventListener('click', () => {
1161
- isWebSearchEnabled = !isWebSearchEnabled;
1162
- webSearchToggle.dataset.active = isWebSearchEnabled;
1163
- if (heroWebSearchToggle) heroWebSearchToggle.dataset.active = isWebSearchEnabled;
1164
- if (isWebSearchEnabled) { isYtSearchEnabled = false; _syncToggles(); }
1165
- _updateInputPlaceholder();
1166
- menu.classList.remove('show'); plusBtn.classList.remove('open');
1167
- });
1168
-
1169
- ytToggle.addEventListener('click', () => {
1170
- isYtSearchEnabled = !isYtSearchEnabled;
1171
- ytSearchToggle.dataset.active = isYtSearchEnabled;
1172
- if (heroYtSearchToggle) heroYtSearchToggle.dataset.active = isYtSearchEnabled;
1173
- if (isYtSearchEnabled) { isWebSearchEnabled = false; _syncToggles(); }
1174
- _updateInputPlaceholder();
1175
- menu.classList.remove('show'); plusBtn.classList.remove('open');
1176
- });
1177
-
1178
- fileBtn.addEventListener('click', () => {
1179
- menu.classList.remove('show'); plusBtn.classList.remove('open');
1180
- fileInput.click();
1181
- });
1182
- }
1183
-
1184
- function _syncToggles() {
1185
- if (webSearchToggle) webSearchToggle.dataset.active = isWebSearchEnabled;
1186
- if (heroWebSearchToggle) heroWebSearchToggle.dataset.active = isWebSearchEnabled;
1187
- if (ytSearchToggle) ytSearchToggle.dataset.active = isYtSearchEnabled;
1188
- if (heroYtSearchToggle) heroYtSearchToggle.dataset.active = isYtSearchEnabled;
1189
- }
1190
-
1191
- function _updateInputPlaceholder() {
1192
- let ph = "Ask STEM Copilot...";
1193
- if (isWebSearchEnabled) ph = "Search the web...";
1194
- if (isYtSearchEnabled) ph = "Paste a YouTube link...";
1195
- if (userInput) userInput.placeholder = ph;
1196
- if (heroInput) heroInput.placeholder = ph;
1197
- }
1198
-
1199
- document.addEventListener('click', (e) => {
1200
- if (!e.target.closest('.attach-wrap')) {
1201
- document.querySelectorAll('.attach-menu').forEach(m => m.classList.remove('show'));
1202
- document.querySelectorAll('.attach-plus-btn').forEach(b => b.classList.remove('open'));
1203
- }
1204
- });
1205
-
1206
- setupAttachMenu(attachPlusBtn, attachMenu, webSearchToggle, ytSearchToggle, attachFileBtn, docInput);
1207
- if (heroAttachPlusBtn) {
1208
- setupAttachMenu(heroAttachPlusBtn, heroAttachMenu, heroWebSearchToggle, heroYtSearchToggle, heroAttachFileBtn, heroDocInput);
1209
- }
1210
-
1211
- if (docInput) docInput.setAttribute('accept', 'image/*,.pdf,.doc,.docx,.txt,.md,.py,.js,.ts,.csv');
1212
- if (heroDocInput) heroDocInput.setAttribute('accept', 'image/*,.pdf,.doc,.docx,.txt,.md,.py,.js,.ts,.csv');
1213
-
1214
- function _handleCombinedFile(file) {
1215
- if (!file) return;
1216
- if (file.type.startsWith('image/')) {
1217
- handleImageFile(file);
1218
- } else {
1219
- handleDocFile(file);
1220
- }
1221
- }
1222
-
1223
- if (docInput) docInput.addEventListener('change', () => _handleCombinedFile(docInput.files[0]));
1224
- if (heroDocInput) heroDocInput.addEventListener('change', () => _handleCombinedFile(heroDocInput.files[0]));
1225
-
1226
- async function handleDocFile(file) {
1227
- if (file.size > 10 * 1024 * 1024) { showToast('File too large (Max 10MB)', 'error'); return; }
1228
-
1229
- const filename = file.name;
1230
- const heroElem = document.getElementById('heroImagePreviewDynamic') || document.getElementById('heroImagePreview');
1231
- const isHero = isHeroMode && heroElem;
1232
- const pBar = isHero ? heroElem : imagePreviewBar;
1233
-
1234
- if (pBar) {
1235
- pBar.innerHTML = `
1236
- <div class="${isHero ? 'hero-image-preview-thumb' : 'image-preview-thumb'}" style="background-color: var(--brand); display:flex; align-items:center; justify-content:center;">
1237
- <svg style="width:16px;height:16px;color:#fff;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>
1238
- </div>
1239
- <span class="image-preview-name" id="docPreviewName">Extracting text...</span>
1240
- <button class="${isHero ? 'hero-image-preview-remove' : 'image-preview-remove'}">&times;</button>
1241
- `;
1242
-
1243
- const removeBtn = pBar.querySelector(isHero ? '.hero-image-preview-remove' : '.image-preview-remove');
1244
- if (removeBtn) {
1245
- removeBtn.addEventListener('click', () => {
1246
- clearAttachments();
1247
- pBar.style.display = 'none';
1248
- if (isHero) pBar.classList.remove('visible');
1249
- });
1250
- }
1251
-
1252
- if (isHero) pBar.classList.add('visible');
1253
- else pBar.style.display = 'flex';
1254
- }
1255
-
1256
- const fd = new FormData();
1257
- fd.append("file", file);
1258
- try {
1259
- const res = await fetch("/upload-doc", { method: "POST", body: fd });
1260
- const data = await res.json();
1261
- if (res.ok) {
1262
- pendingDocText = data.text;
1263
- const pName = pBar ? pBar.querySelector('#docPreviewName') : null;
1264
- if (pName) pName.textContent = data.filename || filename;
1265
- showToast('Document attached!', 'success');
1266
- } else {
1267
- clearAttachments();
1268
- if (pBar) {
1269
- pBar.style.display = 'none';
1270
- if (isHero) pBar.classList.remove('visible');
1271
- }
1272
- showToast(data.error || 'Failed to extract text', 'error');
1273
- }
1274
- } catch(e) {
1275
- clearAttachments();
1276
- if (pBar) {
1277
- pBar.style.display = 'none';
1278
- if (isHero) pBar.classList.remove('visible');
1279
- }
1280
- showToast('Upload failed', 'error');
1281
- }
1282
- }
1283
-
1284
- function clearAttachments() {
1285
- pendingImage = null;
1286
- pendingImageDataUrl = null;
1287
- pendingDocText = '';
1288
- if (imageInput) imageInput.value = '';
1289
- if (docInput) docInput.value = '';
1290
- if (heroImageInput) heroImageInput.value = '';
1291
- if (heroDocInput) heroDocInput.value = '';
1292
- }
1293
-
1294
  // Bind bottom input adaptive button
1295
  _bindAdaptiveBtn(adaptiveBtn, userInput);
1296
 
@@ -1298,7 +1151,7 @@ _bindAdaptiveBtn(adaptiveBtn, userInput);
1298
  _bindAdaptiveBtn(heroAdaptiveBtn, heroInput);
1299
 
1300
 
1301
- /* ── Image Upload ───────────────────────────────────────────── */
1302
 
1303
  function _makeChip(dataUrl, filename, onRemove) {
1304
  const chip = document.createElement('div');
@@ -1316,22 +1169,53 @@ function _makeChip(dataUrl, filename, onRemove) {
1316
  return chip;
1317
  }
1318
 
1319
- if (uploadBtn) uploadBtn.addEventListener('click', () => imageInput.click());
1320
- if (imageInput) imageInput.addEventListener('change', () => { if (imageInput.files[0]) handleImageFile(imageInput.files[0]); });
1321
- if (heroUploadBtn) heroUploadBtn.addEventListener('click', () => heroImageInput.click());
1322
- if (heroImageInput) heroImageInput.addEventListener('change', () => {
1323
- if (heroImageInput.files[0]) handleImageFile(heroImageInput.files[0]);
1324
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1325
 
1326
- document.addEventListener('paste', (e) => {
1327
- const items = e.clipboardData?.items;
1328
- if (!items) return;
1329
- for (const item of items) {
1330
- if (item.type.startsWith('image/')) { e.preventDefault(); handleImageFile(item.getAsFile()); return; }
1331
- }
1332
- });
 
 
 
 
 
 
 
 
1333
 
1334
  function handleImageFile(file, targetPreviewEl) {
 
1335
  const reader = new FileReader();
1336
  reader.onload = (e) => {
1337
  const dataUrl = e.target.result;
@@ -1343,7 +1227,7 @@ function handleImageFile(file, targetPreviewEl) {
1343
  if (imagePreviewBar) {
1344
  imagePreviewBar.innerHTML = '';
1345
  imagePreviewBar.appendChild(_makeChip(dataUrl, filename, () => {
1346
- clearAttachments();
1347
  imagePreviewBar.innerHTML = '';
1348
  imagePreviewBar.style.display = 'none';
1349
  }));
@@ -1356,7 +1240,7 @@ function handleImageFile(file, targetPreviewEl) {
1356
  if (heroPreview) {
1357
  heroPreview.innerHTML = '';
1358
  heroPreview.appendChild(_makeChip(dataUrl, filename, () => {
1359
- clearAttachments();
1360
  heroPreview.innerHTML = '';
1361
  heroPreview.classList.remove('visible');
1362
  }));
@@ -1366,15 +1250,220 @@ function handleImageFile(file, targetPreviewEl) {
1366
  reader.readAsDataURL(file);
1367
  }
1368
 
1369
- // Legacy remove buttons (static HTML fallbacks)
1370
- if (imagePreviewRemove) imagePreviewRemove.addEventListener('click', () => {
1371
- clearAttachments();
1372
- if (imagePreviewBar) imagePreviewBar.style.display = 'none';
1373
- });
1374
- if (heroImagePreviewRemove) heroImagePreviewRemove.addEventListener('click', () => {
1375
- clearAttachments();
1376
- if (heroImagePreview) heroImagePreview.classList.remove('visible');
1377
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1378
 
1379
 
1380
  /* ── Chat & Streaming ───────────────────────────────────────── */
@@ -1402,8 +1491,7 @@ if (stopBtn) {
1402
  if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; }
1403
  isSending = false;
1404
  _showAdaptiveBtn();
1405
- const ct = document.getElementById('currentThinking');
1406
- if (ct) ct.style.display = 'none';
1407
  document.querySelectorAll('.ai-avatar.pulsing').forEach(el => el.classList.remove('pulsing'));
1408
  document.querySelectorAll('.message-content.cursor').forEach(el => el.classList.remove('cursor'));
1409
  });
@@ -1428,6 +1516,17 @@ function sendMessage() {
1428
  <div>${escapeHtml(text)}</div>
1429
  </div>`;
1430
  chatContainer.appendChild(rowDiv);
 
 
 
 
 
 
 
 
 
 
 
1431
  } else appendMessage('user', text);
1432
 
1433
  userInput.value = '';
@@ -1448,14 +1547,36 @@ function sendMessage() {
1448
  function streamResponse(text) {
1449
  const imageData = pendingImage || '';
1450
  const docData = pendingDocText || '';
1451
-
1452
- // Add prefix to message if tools are active
1453
- let finalMessage = text;
1454
- if (isWebSearchEnabled) finalMessage = `[Please use web search to answer this] ${finalMessage}`;
1455
- if (isYtSearchEnabled) finalMessage = `[Please check this YouTube video] ${finalMessage}`;
1456
-
1457
- clearAttachments();
1458
- if (imagePreviewBar) imagePreviewBar.style.display = 'none';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1459
 
1460
  const rowDiv = document.createElement('div');
1461
  rowDiv.classList.add('message-row', 'ai');
@@ -1490,12 +1611,27 @@ function streamResponse(text) {
1490
  }, RENDER_INTERVAL);
1491
  }
1492
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1493
  const payload = {
1494
- message: text, thread_id: currentThreadId,
1495
  persona: currentPersona, language: currentLanguage,
1496
  username: currentUsername,
1497
  user_id: currentUser ? currentUser.google_id : '',
1498
  image: imageData,
 
1499
  };
1500
 
1501
  currentAbortController = new AbortController();
@@ -1534,6 +1670,26 @@ function streamResponse(text) {
1534
  _showActionsBar(actionsEl, contentEl);
1535
  isSending = false; _showAdaptiveBtn(); currentStreamStopped = true; return;
1536
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1537
  if (data.token !== undefined) {
1538
  if (currentStreamStopped) return;
1539
  if (firstToken) { thinkingEl.style.display = 'none'; contentEl.style.display = 'block'; contentEl.classList.add('cursor'); firstToken = false; }
 
16
  let pendingImageDataUrl = null;
17
  let isHeroMode = true;
18
 
19
+ let isWebSearchEnabled = false;
20
+ let isYtSearchEnabled = false;
21
+ let pendingDocText = '';
22
+
23
  let currentPersona = localStorage.getItem('stemcopilot_persona') || 'vidyut';
24
  let currentLanguage = localStorage.getItem('stemcopilot_language') || 'auto';
25
  let currentUsername = localStorage.getItem('stemcopilot_username') || '';
 
56
  const heroImageInput = document.getElementById('heroImageInput');
57
  const heroDocInput = document.getElementById('heroDocInput');
58
  const heroToolProgressBar = document.getElementById('heroToolProgressBar');
 
59
  const heroTitle = document.getElementById('heroTitle');
60
 
61
  const byokInput = document.getElementById('byokInput');
 
88
  const webSearchToggle = document.getElementById('webSearchToggle');
89
  const ytSearchToggle = document.getElementById('ytSearchToggle');
90
  const attachFileBtn = document.getElementById('attachFileBtn');
 
91
  const docInput = document.getElementById('docInput');
92
  const toolProgressBar = document.getElementById('toolProgressBar');
 
93
 
94
  const imagePreviewBar = document.getElementById('imagePreviewBar');
95
  const imagePreviewThumb = document.getElementById('imagePreviewThumb');
 
742
  const name = currentUsername || (currentUser ? currentUser.name : '');
743
  const greeting = _HERO_GREETINGS[Math.floor(Math.random() * _HERO_GREETINGS.length)];
744
  const titleHtml = name ? `${greeting}, <span class="hero-name">${escapeHtml(name)}</span>?` : `${greeting}?`;
745
+ const styleLabel = _PERSONA_LABELS[currentPersona] || 'Guru';
746
 
747
  div.innerHTML = `
748
  <img src="/assets/bot.png" alt="STEM Copilot" class="hero-bot-icon">
 
750
  <div class="hero-input-wrap">
751
  <div class="hero-image-preview" id="heroImagePreviewDynamic">
752
  <div class="hero-image-preview-thumb" id="heroImagePreviewThumbDynamic"></div>
 
753
  <button class="hero-image-preview-remove" id="heroImagePreviewRemoveDynamic">&times;</button>
754
  </div>
 
755
  <div class="tool-progress-bar" id="heroToolProgressBarDynamic" style="display:none; padding: 0 16px;">
756
  <div class="tool-progress-label" id="heroToolProgressLabelDynamic">Searching...</div>
757
  <div class="tool-progress-track"><div class="tool-progress-fill" id="heroToolProgressFillDynamic"></div></div>
 
759
  <div class="hero-input-box" id="heroInputBoxDynamic">
760
  <div class="attach-wrap" id="heroAttachWrapDynamic">
761
  <button class="attach-plus-btn" id="heroAttachPlusBtnDynamic" title="Attach or search">
762
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
763
+ stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
764
+ <line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
765
+ </svg>
766
  </button>
767
  <div class="attach-menu" id="heroAttachMenuDynamic">
768
+ <button class="attach-menu-item" id="heroWebSearchToggleDynamic" data-active="false">
769
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
770
  Web Search
771
  </button>
772
+ <button class="attach-menu-item" id="heroYtSearchToggleDynamic" data-active="false">
773
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
774
  YouTube Video
775
  </button>
 
780
  </div>
781
  </div>
782
  <input type="file" id="heroImageInputDynamic" accept="image/*" style="display:none;">
783
+ <input type="file" id="heroDocInputDynamic" accept=".pdf,.doc,.docx,.txt,.md,.py,.js,.ts,.csv" style="display:none;">
784
+ <textarea id="heroInputDynamic" placeholder="Ask STEM Copilot..." rows="1"></textarea>
785
  <div class="style-selector-wrap" id="heroStyleSelectorWrapDynamic">
786
  <button class="style-selector-btn" id="heroStyleSelectorBtnDynamic" title="Teaching style">
787
  <span id="heroStyleSelectorLabelDynamic">${styleLabel}</span>
 
805
 
806
  const dynInput = div.querySelector('#heroInputDynamic');
807
  const dynAdaptive = div.querySelector('#heroAdaptiveBtnDynamic');
 
 
 
 
 
 
 
 
808
  const dynImgPreview = div.querySelector('#heroImagePreviewDynamic');
 
 
 
809
  const dynStyleBtn = div.querySelector('#heroStyleSelectorBtnDynamic');
810
  const dynStyleDropdown = div.querySelector('#heroStyleDropdownDynamic');
811
  const dynStyleLabel = div.querySelector('#heroStyleSelectorLabelDynamic');
812
 
813
+ // Dynamic attach elements
814
+ const dynPlusBtn = div.querySelector('#heroAttachPlusBtnDynamic');
815
+ const dynMenu = div.querySelector('#heroAttachMenuDynamic');
816
+ const dynWebSearchToggle = div.querySelector('#heroWebSearchToggleDynamic');
817
+ const dynYtSearchToggle = div.querySelector('#heroYtSearchToggleDynamic');
818
+ const dynAttachFileBtn = div.querySelector('#heroAttachFileBtnDynamic');
819
+ const dynDocInput = div.querySelector('#heroDocInputDynamic');
820
+
821
  // Auto-resize
822
  dynInput.addEventListener('input', function () {
823
  this.style.height = '54px';
 
831
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); userInput.value = dynInput.value; sendMessage(); }
832
  });
833
 
834
+ // Setup dynamic attach menu
835
+ setupAttachMenu(
836
+ dynPlusBtn,
837
+ dynMenu,
838
+ dynWebSearchToggle,
839
+ dynYtSearchToggle,
840
+ dynAttachFileBtn,
841
+ null,
842
+ dynDocInput,
843
+ dynImgPreview
844
+ );
845
+
846
+ // Sync toggle visual states immediately
847
+ setTimeout(() => {
848
+ _syncToggles();
849
+ _updateInputPlaceholder();
850
+ }, 0);
851
 
852
  // Style selector
853
  if (dynStyleBtn) {
 
860
  _setPersona(opt.dataset.persona);
861
  dynStyleDropdown.classList.remove('show');
862
  dynStyleBtn.classList.remove('open');
863
+ dynStyleLabel.textContent = _PERSONA_LABELS[currentPersona] || 'Guru';
864
  dynStyleDropdown.querySelectorAll('.style-option').forEach(o => o.classList.toggle('active', o.dataset.persona === currentPersona));
865
  });
866
  });
 
1119
  // Act as mic
1120
  if (!SpeechRecognition) { showToast('Voice input not supported in this browser.', 'error'); return; }
1121
 
 
 
1122
  if (btn.classList.contains('listening')) {
1123
  if (recognition) recognition.stop();
1124
  btn.classList.remove('listening');
 
1144
  });
1145
  }
1146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1147
  // Bind bottom input adaptive button
1148
  _bindAdaptiveBtn(adaptiveBtn, userInput);
1149
 
 
1151
  _bindAdaptiveBtn(heroAdaptiveBtn, heroInput);
1152
 
1153
 
1154
+ /* ── Image & Document Upload & Attach Menu ──────────────────────────────── */
1155
 
1156
  function _makeChip(dataUrl, filename, onRemove) {
1157
  const chip = document.createElement('div');
 
1169
  return chip;
1170
  }
1171
 
1172
+ function _makeDocChip(filename, onRemove, statusText = '') {
1173
+ const chip = document.createElement('div');
1174
+ chip.className = 'image-chip doc-chip';
1175
+ const docIcon = `
1176
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin: auto; display: block; color: var(--brand);">
1177
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
1178
+ <polyline points="14 2 14 8 20 8"/>
1179
+ <line x1="16" y1="13" x2="8" y2="13"/>
1180
+ <line x1="16" y1="17" x2="8" y2="17"/>
1181
+ </svg>
1182
+ `;
1183
+ chip.innerHTML = `
1184
+ <div class="image-chip-thumb" style="display: flex; align-items: center; justify-content: center; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.2); flex-shrink: 0; width: 32px; height: 32px; border-radius: 5px;">
1185
+ ${docIcon}
1186
+ </div>
1187
+ <div style="display: flex; flex-direction: column; min-width: 0; justify-content: center; margin-left: 6px; margin-right: 6px;">
1188
+ <span class="image-chip-name" style="max-width: 110px;">${escapeHtml(filename)}</span>
1189
+ ${statusText ? `<span style="font-size: 8px; color: var(--text-muted); line-height: 1.1;">${escapeHtml(statusText)}</span>` : ''}
1190
+ </div>
1191
+ <button class="image-chip-remove" title="Remove" style="background: none; border: none; color: var(--text-muted); font-size: 14px; line-height: 1; cursor: pointer; padding: 0 2px; flex-shrink: 0; display: flex; align-items: center;">
1192
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1193
+ </button>
1194
+ `;
1195
+ chip.querySelector('.image-chip-remove').addEventListener('click', (e) => {
1196
+ e.stopPropagation(); onRemove();
1197
+ });
1198
+ return chip;
1199
+ }
1200
 
1201
+ function _clearImage() {
1202
+ pendingImage = null;
1203
+ pendingImageDataUrl = null;
1204
+ if (imageInput) imageInput.value = '';
1205
+ if (heroImageInput) heroImageInput.value = '';
1206
+ const dynImgInput = document.getElementById('heroImageInputDynamic');
1207
+ if (dynImgInput) dynImgInput.value = '';
1208
+ }
1209
+
1210
+ function _clearDoc() {
1211
+ pendingDocText = '';
1212
+ if (docInput) docInput.value = '';
1213
+ const dynDocInput = document.getElementById('heroDocInputDynamic');
1214
+ if (dynDocInput) dynDocInput.value = '';
1215
+ }
1216
 
1217
  function handleImageFile(file, targetPreviewEl) {
1218
+ _clearDoc();
1219
  const reader = new FileReader();
1220
  reader.onload = (e) => {
1221
  const dataUrl = e.target.result;
 
1227
  if (imagePreviewBar) {
1228
  imagePreviewBar.innerHTML = '';
1229
  imagePreviewBar.appendChild(_makeChip(dataUrl, filename, () => {
1230
+ _clearImage();
1231
  imagePreviewBar.innerHTML = '';
1232
  imagePreviewBar.style.display = 'none';
1233
  }));
 
1240
  if (heroPreview) {
1241
  heroPreview.innerHTML = '';
1242
  heroPreview.appendChild(_makeChip(dataUrl, filename, () => {
1243
+ _clearImage();
1244
  heroPreview.innerHTML = '';
1245
  heroPreview.classList.remove('visible');
1246
  }));
 
1250
  reader.readAsDataURL(file);
1251
  }
1252
 
1253
+ function handleDocFile(file, targetPreviewEl) {
1254
+ _clearImage();
1255
+ pendingDocText = '';
1256
+
1257
+ const container = targetPreviewEl || (isHeroMode ? (document.getElementById('heroImagePreviewDynamic') || heroImagePreview) : imagePreviewBar);
1258
+ if (!container) return;
1259
+
1260
+ // Clear sibling container
1261
+ if (container === imagePreviewBar) {
1262
+ const heroPrev = document.getElementById('heroImagePreviewDynamic') || heroImagePreview;
1263
+ if (heroPrev) {
1264
+ heroPrev.innerHTML = '';
1265
+ heroPrev.classList.remove('visible');
1266
+ }
1267
+ } else {
1268
+ if (imagePreviewBar) {
1269
+ imagePreviewBar.innerHTML = '';
1270
+ imagePreviewBar.style.display = 'none';
1271
+ }
1272
+ }
1273
+
1274
+ const ext = file.name.split('.').pop().toLowerCase();
1275
+ const isText = ['txt', 'md', 'py', 'js', 'ts', 'csv', 'json', 'html', 'css'].includes(ext) || file.type.startsWith('text/');
1276
+
1277
+ if (isText) {
1278
+ const reader = new FileReader();
1279
+ reader.onload = (e) => {
1280
+ pendingDocText = e.target.result;
1281
+ container.innerHTML = '';
1282
+ container.appendChild(_makeDocChip(file.name, () => {
1283
+ _clearDoc();
1284
+ container.innerHTML = '';
1285
+ if (container === imagePreviewBar) {
1286
+ container.style.display = 'none';
1287
+ } else {
1288
+ container.classList.remove('visible');
1289
+ }
1290
+ }, 'Attached locally'));
1291
+
1292
+ if (container === imagePreviewBar) {
1293
+ container.style.display = 'flex';
1294
+ } else {
1295
+ container.classList.add('visible');
1296
+ }
1297
+ };
1298
+ reader.readAsText(file);
1299
+ } else {
1300
+ // PDF/DOCX/DOC requiring backend extraction
1301
+ container.innerHTML = '';
1302
+ container.appendChild(_makeDocChip(file.name, () => {
1303
+ _clearDoc();
1304
+ container.innerHTML = '';
1305
+ if (container === imagePreviewBar) {
1306
+ container.style.display = 'none';
1307
+ } else {
1308
+ container.classList.remove('visible');
1309
+ }
1310
+ }, 'Extracting text...'));
1311
+
1312
+ if (container === imagePreviewBar) {
1313
+ container.style.display = 'flex';
1314
+ } else {
1315
+ container.classList.add('visible');
1316
+ }
1317
+
1318
+ const formData = new FormData();
1319
+ formData.append('file', file);
1320
+
1321
+ fetch('/upload-doc', {
1322
+ method: 'POST',
1323
+ body: formData
1324
+ })
1325
+ .then(res => {
1326
+ if (!res.ok) throw new Error('Failed to parse file');
1327
+ return res.json();
1328
+ })
1329
+ .then(data => {
1330
+ if (data.error) throw new Error(data.error);
1331
+ pendingDocText = data.text;
1332
+
1333
+ container.innerHTML = '';
1334
+ container.appendChild(_makeDocChip(file.name, () => {
1335
+ _clearDoc();
1336
+ container.innerHTML = '';
1337
+ if (container === imagePreviewBar) {
1338
+ container.style.display = 'none';
1339
+ } else {
1340
+ container.classList.remove('visible');
1341
+ }
1342
+ }, 'Extracted successfully'));
1343
+ })
1344
+ .catch(err => {
1345
+ showToast(err.message || 'Failed to extract text from document.', 'error');
1346
+ container.innerHTML = '';
1347
+ container.appendChild(_makeDocChip(file.name, () => {
1348
+ _clearDoc();
1349
+ container.innerHTML = '';
1350
+ if (container === imagePreviewBar) {
1351
+ container.style.display = 'none';
1352
+ } else {
1353
+ container.classList.remove('visible');
1354
+ }
1355
+ }, 'Extraction failed'));
1356
+ });
1357
+ }
1358
+ }
1359
+
1360
+ function handleAttachedFile(file, targetPreviewEl) {
1361
+ if (!file) return;
1362
+ if (file.type.startsWith('image/')) {
1363
+ handleImageFile(file, targetPreviewEl);
1364
+ } else {
1365
+ handleDocFile(file, targetPreviewEl);
1366
+ }
1367
+ }
1368
+
1369
+ function _syncToggles() {
1370
+ const list = [
1371
+ { web: webSearchToggle, yt: ytSearchToggle },
1372
+ { web: document.getElementById('heroWebSearchToggleDynamic'), yt: document.getElementById('heroYtSearchToggleDynamic') }
1373
+ ];
1374
+ list.forEach(item => {
1375
+ if (item.web) {
1376
+ item.web.classList.toggle('active', isWebSearchEnabled);
1377
+ item.web.setAttribute('data-active', isWebSearchEnabled ? 'true' : 'false');
1378
+ }
1379
+ if (item.yt) {
1380
+ item.yt.classList.toggle('active', isYtSearchEnabled);
1381
+ item.yt.setAttribute('data-active', isYtSearchEnabled ? 'true' : 'false');
1382
+ }
1383
+ });
1384
+ }
1385
+
1386
+ function _updateInputPlaceholder() {
1387
+ let placeholder = 'Ask STEM Copilot...';
1388
+ if (isWebSearchEnabled) {
1389
+ placeholder = 'Search the web and ask...';
1390
+ } else if (isYtSearchEnabled) {
1391
+ placeholder = 'Paste YouTube URL or ask about video...';
1392
+ }
1393
+
1394
+ const inputs = [userInput, document.getElementById('heroInputDynamic')];
1395
+ inputs.forEach(inp => {
1396
+ if (inp) inp.placeholder = placeholder;
1397
+ });
1398
+ }
1399
+
1400
+ function setupAttachMenu(plusBtn, menuEl, webSearchToggleEl, ytSearchToggleEl, attachFileBtnEl, imageInputEl, docInputEl, previewEl) {
1401
+ if (!plusBtn || !menuEl) return;
1402
+
1403
+ plusBtn.addEventListener('click', (e) => {
1404
+ e.stopPropagation();
1405
+ const isOpen = menuEl.classList.contains('show');
1406
+ closeAllMenus();
1407
+ if (!isOpen) {
1408
+ menuEl.classList.add('show');
1409
+ plusBtn.classList.add('open');
1410
+ }
1411
+ });
1412
+
1413
+ if (webSearchToggleEl) {
1414
+ webSearchToggleEl.addEventListener('click', (e) => {
1415
+ e.stopPropagation();
1416
+ isWebSearchEnabled = !isWebSearchEnabled;
1417
+ if (isWebSearchEnabled) isYtSearchEnabled = false;
1418
+ _syncToggles();
1419
+ _updateInputPlaceholder();
1420
+ menuEl.classList.remove('show');
1421
+ plusBtn.classList.remove('open');
1422
+ });
1423
+ }
1424
+
1425
+ if (ytSearchToggleEl) {
1426
+ ytSearchToggleEl.addEventListener('click', (e) => {
1427
+ e.stopPropagation();
1428
+ isYtSearchEnabled = !isYtSearchEnabled;
1429
+ if (isYtSearchEnabled) isWebSearchEnabled = false;
1430
+ _syncToggles();
1431
+ _updateInputPlaceholder();
1432
+ menuEl.classList.remove('show');
1433
+ plusBtn.classList.remove('open');
1434
+ });
1435
+ }
1436
+
1437
+ if (attachFileBtnEl && docInputEl) {
1438
+ attachFileBtnEl.addEventListener('click', (e) => {
1439
+ e.stopPropagation();
1440
+ menuEl.classList.remove('show');
1441
+ plusBtn.classList.remove('open');
1442
+ docInputEl.accept = "image/*,.pdf,.doc,.docx,.txt,.md,.py,.js,.ts,.csv";
1443
+ docInputEl.click();
1444
+ });
1445
+ }
1446
+
1447
+ if (docInputEl) {
1448
+ docInputEl.addEventListener('change', () => {
1449
+ if (docInputEl.files[0]) {
1450
+ handleAttachedFile(docInputEl.files[0], previewEl);
1451
+ }
1452
+ });
1453
+ }
1454
+ }
1455
+
1456
+ // Bind bottom static attach elements
1457
+ setupAttachMenu(
1458
+ attachPlusBtn,
1459
+ attachMenu,
1460
+ webSearchToggle,
1461
+ ytSearchToggle,
1462
+ attachFileBtn,
1463
+ null,
1464
+ docInput,
1465
+ imagePreviewBar
1466
+ );
1467
 
1468
 
1469
  /* ── Chat & Streaming ───────────────────────────────────────── */
 
1491
  if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; }
1492
  isSending = false;
1493
  _showAdaptiveBtn();
1494
+ document.querySelectorAll('.thinking-indicator').forEach(el => el.style.display = 'none');
 
1495
  document.querySelectorAll('.ai-avatar.pulsing').forEach(el => el.classList.remove('pulsing'));
1496
  document.querySelectorAll('.message-content.cursor').forEach(el => el.classList.remove('cursor'));
1497
  });
 
1516
  <div>${escapeHtml(text)}</div>
1517
  </div>`;
1518
  chatContainer.appendChild(rowDiv);
1519
+ } else if (pendingDocText) {
1520
+ const rowDiv = document.createElement('div');
1521
+ rowDiv.classList.add('message-row', 'user');
1522
+ rowDiv.innerHTML = `<div class="message-content">
1523
+ <div style="display:flex; align-items:center; gap:6px; background:rgba(255,255,255,0.06); padding:6px 10px; border-radius:8px; margin-bottom:6px; font-size:11px; width:fit-content; border: 1px solid rgba(255,255,255,0.1);">
1524
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--brand);"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
1525
+ <span>Document Attached</span>
1526
+ </div>
1527
+ <div>${escapeHtml(text)}</div>
1528
+ </div>`;
1529
+ chatContainer.appendChild(rowDiv);
1530
  } else appendMessage('user', text);
1531
 
1532
  userInput.value = '';
 
1547
  function streamResponse(text) {
1548
  const imageData = pendingImage || '';
1549
  const docData = pendingDocText || '';
1550
+
1551
+ // Clear attachment states
1552
+ pendingImage = null;
1553
+ pendingImageDataUrl = null;
1554
+ pendingDocText = '';
1555
+
1556
+ if (imagePreviewBar) {
1557
+ imagePreviewBar.innerHTML = '';
1558
+ imagePreviewBar.style.display = 'none';
1559
+ }
1560
+ const heroPreview = document.getElementById('heroImagePreviewDynamic') || heroImagePreview;
1561
+ if (heroPreview) {
1562
+ heroPreview.innerHTML = '';
1563
+ heroPreview.classList.remove('visible');
1564
+ }
1565
+ if (imageInput) imageInput.value = '';
1566
+ if (heroImageInput) heroImageInput.value = '';
1567
+ if (docInput) docInput.value = '';
1568
+ const dynDocInput = document.getElementById('heroDocInputDynamic');
1569
+ if (dynDocInput) dynDocInput.value = '';
1570
+
1571
+ // Set up progress bars
1572
+ const pBar = isHeroMode ? document.getElementById('heroToolProgressBarDynamic') : toolProgressBar;
1573
+ const pLabel = isHeroMode ? document.getElementById('heroToolProgressLabelDynamic') : toolProgressLabel;
1574
+ const pFill = isHeroMode ? document.getElementById('heroToolProgressFillDynamic') : toolProgressFill;
1575
+
1576
+ if (pBar) {
1577
+ pBar.style.display = 'none';
1578
+ if (pFill) pFill.style.width = '0%';
1579
+ }
1580
 
1581
  const rowDiv = document.createElement('div');
1582
  rowDiv.classList.add('message-row', 'ai');
 
1611
  }, RENDER_INTERVAL);
1612
  }
1613
 
1614
+ // Build the final message with search instructions if toggled
1615
+ let finalMessage = text;
1616
+ if (isWebSearchEnabled) {
1617
+ finalMessage = `[System Instruction: Web Search is enabled. Please search the web using the web_search tool if needed to answer the user's query.]\nUser Query: ${text}`;
1618
+ } else if (isYtSearchEnabled) {
1619
+ finalMessage = `[System Instruction: YouTube Video Search is enabled. Please extract the YouTube URL or ID from the user's query and retrieve its transcript using the yt_transcript tool before responding.]\nUser Query: ${text}`;
1620
+ }
1621
+
1622
+ // Clear search toggle states
1623
+ isWebSearchEnabled = false;
1624
+ isYtSearchEnabled = false;
1625
+ _syncToggles();
1626
+ _updateInputPlaceholder();
1627
+
1628
  const payload = {
1629
+ message: finalMessage, thread_id: currentThreadId,
1630
  persona: currentPersona, language: currentLanguage,
1631
  username: currentUsername,
1632
  user_id: currentUser ? currentUser.google_id : '',
1633
  image: imageData,
1634
+ doc_text: docData,
1635
  };
1636
 
1637
  currentAbortController = new AbortController();
 
1670
  _showActionsBar(actionsEl, contentEl);
1671
  isSending = false; _showAdaptiveBtn(); currentStreamStopped = true; return;
1672
  }
1673
+ if (data.tool_event) {
1674
+ if (data.tool_event === 'tool_start') {
1675
+ if (pLabel) pLabel.textContent = data.tool === 'web_search' ? 'Searching the web...' : 'Fetching YouTube transcript...';
1676
+ if (pFill) pFill.style.width = '40%';
1677
+ if (pBar) pBar.style.display = 'block';
1678
+ let w = 40;
1679
+ const iv = setInterval(() => {
1680
+ if (!pBar || pBar.style.display === 'none' || w >= 90) { clearInterval(iv); return; }
1681
+ w += 5;
1682
+ if (pFill) pFill.style.width = w + '%';
1683
+ }, 300);
1684
+ } else if (data.tool_event === 'tool_end') {
1685
+ if (pFill) pFill.style.width = '100%';
1686
+ setTimeout(() => {
1687
+ if (pBar) pBar.style.display = 'none';
1688
+ if (pFill) pFill.style.width = '0%';
1689
+ if (pLabel) pLabel.textContent = 'Searching...';
1690
+ }, 500);
1691
+ }
1692
+ }
1693
  if (data.token !== undefined) {
1694
  if (currentStreamStopped) return;
1695
  if (firstToken) { thinkingEl.style.display = 'none'; contentEl.style.display = 'block'; contentEl.classList.add('cursor'); firstToken = false; }
static/index.html CHANGED
@@ -269,7 +269,7 @@
269
  <img src="/assets/bot.png" alt="STEM Copilot" class="hero-bot-icon">
270
  <h1 class="hero-title" id="heroTitle">What should we study today?</h1>
271
  <div class="hero-input-wrap">
272
- <!-- Image preview in hero -->
273
  <div class="hero-image-preview" id="heroImagePreview">
274
  <div class="hero-image-preview-thumb" id="heroImagePreviewThumb"></div>
275
  <span class="image-preview-name" id="heroImagePreviewName"></span>
@@ -344,7 +344,6 @@
344
  </button>
345
  </div>
346
  </div>
347
- <!-- Hero pills REMOVED per todo item 3 -->
348
  </div>
349
  </div>
350
 
@@ -355,7 +354,7 @@
355
  <div class="tool-progress-label" id="toolProgressLabel">Searching...</div>
356
  <div class="tool-progress-track"><div class="tool-progress-fill" id="toolProgressFill"></div></div>
357
  </div>
358
- <!-- Image preview -->
359
  <div class="image-preview-bar" id="imagePreviewBar" style="display:none;">
360
  <div class="image-preview-thumb" id="imagePreviewThumb"></div>
361
  <span class="image-preview-name" id="imagePreviewName"></span>
@@ -389,7 +388,6 @@
389
  <input type="file" id="imageInput" accept="image/*" style="display:none;">
390
  <input type="file" id="docInput" accept=".pdf,.doc,.docx,.txt,.md,.py,.js,.ts,.csv" style="display:none;">
391
  <textarea id="userInput" placeholder="Ask STEM Copilot..." rows="1"></textarea>
392
-
393
  <!-- Teaching style selector -->
394
  <div class="style-selector-wrap" id="styleSelectorWrap">
395
  <button class="style-selector-btn" id="styleSelectorBtn" title="Teaching style">
 
269
  <img src="/assets/bot.png" alt="STEM Copilot" class="hero-bot-icon">
270
  <h1 class="hero-title" id="heroTitle">What should we study today?</h1>
271
  <div class="hero-input-wrap">
272
+ <!-- Image / Doc preview in hero -->
273
  <div class="hero-image-preview" id="heroImagePreview">
274
  <div class="hero-image-preview-thumb" id="heroImagePreviewThumb"></div>
275
  <span class="image-preview-name" id="heroImagePreviewName"></span>
 
344
  </button>
345
  </div>
346
  </div>
 
347
  </div>
348
  </div>
349
 
 
354
  <div class="tool-progress-label" id="toolProgressLabel">Searching...</div>
355
  <div class="tool-progress-track"><div class="tool-progress-fill" id="toolProgressFill"></div></div>
356
  </div>
357
+ <!-- Image/Doc preview -->
358
  <div class="image-preview-bar" id="imagePreviewBar" style="display:none;">
359
  <div class="image-preview-thumb" id="imagePreviewThumb"></div>
360
  <span class="image-preview-name" id="imagePreviewName"></span>
 
388
  <input type="file" id="imageInput" accept="image/*" style="display:none;">
389
  <input type="file" id="docInput" accept=".pdf,.doc,.docx,.txt,.md,.py,.js,.ts,.csv" style="display:none;">
390
  <textarea id="userInput" placeholder="Ask STEM Copilot..." rows="1"></textarea>
 
391
  <!-- Teaching style selector -->
392
  <div class="style-selector-wrap" id="styleSelectorWrap">
393
  <button class="style-selector-btn" id="styleSelectorBtn" title="Teaching style">
static/style.css CHANGED
@@ -1480,13 +1480,11 @@ textarea, input, select {
1480
  box-shadow: 0 0 0 3px var(--brand-glow), 0 4px 20px rgba(0, 0, 0, 0.2);
1481
  }
1482
 
1483
- /* ── + Attach button & menu ──────────────────────────────── */
1484
-
1485
  .attach-wrap {
1486
  position: relative;
1487
  display: flex;
1488
  align-items: center;
1489
- flex-shrink: 0;
1490
  }
1491
 
1492
  .attach-plus-btn {
@@ -1497,58 +1495,60 @@ textarea, input, select {
1497
  padding: 16px 4px 16px 16px;
1498
  display: flex;
1499
  align-items: center;
1500
- transition: color 0.2s;
 
 
 
 
 
 
1501
  }
1502
 
1503
  .attach-plus-btn svg {
1504
- transition: transform 0.2s;
 
1505
  }
1506
 
1507
- .attach-plus-btn:hover { color: var(--brand); }
1508
- .attach-plus-btn:hover svg { transform: rotate(45deg); }
1509
- .attach-plus-btn.open { color: var(--brand); }
1510
- .attach-plus-btn.open svg { transform: rotate(45deg); }
1511
 
1512
  .attach-menu {
 
1513
  position: absolute;
1514
- bottom: calc(100% + 8px);
1515
- left: 0;
1516
- background: var(--bg-card);
1517
  border: 1px solid var(--border-color);
1518
- border-radius: 12px;
1519
  padding: 6px;
1520
- display: none;
 
 
1521
  flex-direction: column;
1522
  gap: 2px;
1523
- min-width: 180px;
1524
- box-shadow: 0 8px 24px rgba(0,0,0,0.25);
1525
- z-index: 200;
1526
- animation: fadeIn 0.15s ease-out;
1527
  }
1528
 
1529
- .attach-menu.show { display: flex; }
 
 
1530
 
1531
  .attach-menu-item {
1532
- display: flex;
1533
- align-items: center;
1534
- gap: 10px;
1535
  background: none;
1536
  border: none;
1537
- border-radius: 8px;
1538
- padding: 9px 12px;
1539
- font-size: 13px;
1540
  font-family: inherit;
1541
  color: var(--text-secondary);
 
1542
  cursor: pointer;
1543
- text-align: left;
1544
- transition: background 0.15s, color 0.15s;
1545
- }
1546
-
1547
- .attach-menu-item svg {
1548
- width: 15px;
1549
- height: 15px;
1550
- flex-shrink: 0;
1551
- opacity: 0.7;
1552
  }
1553
 
1554
  .attach-menu-item:hover {
@@ -1556,79 +1556,35 @@ textarea, input, select {
1556
  color: var(--text-primary);
1557
  }
1558
 
1559
- .attach-menu-item[data-active="true"] {
1560
- color: var(--brand);
1561
- background: var(--brand-glow);
 
 
 
1562
  }
1563
 
1564
- .attach-menu-item[data-active="true"] svg { opacity: 1; }
1565
-
1566
- /* Active search mode chips shown near input */
1567
- .search-mode-chip {
1568
- display: inline-flex;
1569
- align-items: center;
1570
- gap: 6px;
1571
- font-size: 11px;
1572
- font-weight: 600;
1573
  color: var(--brand);
1574
- background: var(--brand-glow);
1575
- border: 1px solid var(--brand);
1576
- border-radius: 20px;
1577
- padding: 3px 10px 3px 8px;
1578
- cursor: pointer;
1579
- transition: opacity 0.2s;
1580
- }
1581
-
1582
- .search-mode-chip:hover { opacity: 0.7; }
1583
-
1584
- /* ── Tool progress bar ──────────────────────────────────── */
1585
-
1586
- .tool-progress-bar {
1587
- max-width: 800px;
1588
- width: 100%;
1589
- margin-bottom: 6px;
1590
  }
1591
 
1592
- .tool-progress-label {
1593
- font-size: 11px;
1594
  color: var(--brand);
1595
- font-weight: 600;
1596
- margin-bottom: 4px;
1597
- letter-spacing: 0.03em;
1598
- }
1599
-
1600
- .tool-progress-track {
1601
- height: 2px;
1602
- background: var(--border-color);
1603
- border-radius: 2px;
1604
- overflow: hidden;
1605
- }
1606
-
1607
- .tool-progress-fill {
1608
- height: 100%;
1609
- width: 0%;
1610
- background: linear-gradient(90deg, var(--brand), #f5a623);
1611
- border-radius: 2px;
1612
- animation: toolProgressAnim 1.8s ease-in-out infinite;
1613
  }
1614
 
1615
- @keyframes toolProgressAnim {
1616
- 0% { width: 5%; margin-left: 0%; }
1617
- 50% { width: 60%; margin-left: 20%; }
1618
- 100% { width: 5%; margin-left: 90%; }
1619
- }
1620
-
1621
- /* image-preview-name (filename label in preview bar) */
1622
- .image-preview-name {
1623
- font-size: 11px;
1624
  color: var(--text-secondary);
1625
- white-space: nowrap;
1626
- overflow: hidden;
1627
- text-overflow: ellipsis;
1628
- max-width: 140px;
1629
- flex: 1;
1630
  }
1631
 
 
1632
 
1633
  /* Teaching style selector */
1634
  .style-selector-wrap {
 
1480
  box-shadow: 0 0 0 3px var(--brand-glow), 0 4px 20px rgba(0, 0, 0, 0.2);
1481
  }
1482
 
 
 
1483
  .attach-wrap {
1484
  position: relative;
1485
  display: flex;
1486
  align-items: center;
1487
+ justify-content: center;
1488
  }
1489
 
1490
  .attach-plus-btn {
 
1495
  padding: 16px 4px 16px 16px;
1496
  display: flex;
1497
  align-items: center;
1498
+ justify-content: center;
1499
+ transition: color 0.2s, transform 0.2s;
1500
+ height: 100%;
1501
+ }
1502
+
1503
+ .attach-plus-btn:hover {
1504
+ color: var(--text-primary);
1505
  }
1506
 
1507
  .attach-plus-btn svg {
1508
+ transition: transform 0.25s ease-in-out;
1509
+ transform-origin: center;
1510
  }
1511
 
1512
+ .attach-plus-btn.open svg {
1513
+ transform: rotate(45deg);
1514
+ transform-origin: center;
1515
+ }
1516
 
1517
  .attach-menu {
1518
+ display: none;
1519
  position: absolute;
1520
+ bottom: 52px;
1521
+ left: 8px;
1522
+ background: var(--bg-elevated);
1523
  border: 1px solid var(--border-color);
1524
+ border-radius: var(--radius-lg);
1525
  padding: 6px;
1526
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
1527
+ z-index: 101;
1528
+ min-width: 170px;
1529
  flex-direction: column;
1530
  gap: 2px;
 
 
 
 
1531
  }
1532
 
1533
+ .attach-menu.show {
1534
+ display: flex;
1535
+ }
1536
 
1537
  .attach-menu-item {
 
 
 
1538
  background: none;
1539
  border: none;
1540
+ text-align: left;
1541
+ padding: 10px 12px;
1542
+ font-size: 12px;
1543
  font-family: inherit;
1544
  color: var(--text-secondary);
1545
+ border-radius: var(--radius-sm);
1546
  cursor: pointer;
1547
+ display: flex;
1548
+ align-items: center;
1549
+ gap: 8px;
1550
+ transition: background 0.2s, color 0.2s;
1551
+ width: 100%;
 
 
 
 
1552
  }
1553
 
1554
  .attach-menu-item:hover {
 
1556
  color: var(--text-primary);
1557
  }
1558
 
1559
+ .attach-menu-item svg {
1560
+ width: 14px;
1561
+ height: 14px;
1562
+ stroke-width: 2.2;
1563
+ color: var(--text-secondary);
1564
+ transition: color 0.2s;
1565
  }
1566
 
1567
+ .attach-menu-item[data-active="true"] {
 
 
 
 
 
 
 
 
1568
  color: var(--brand);
1569
+ font-weight: 500;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1570
  }
1571
 
1572
+ .attach-menu-item[data-active="true"] svg {
 
1573
  color: var(--brand);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1574
  }
1575
 
1576
+ .upload-btn {
1577
+ background: none;
1578
+ border: none;
 
 
 
 
 
 
1579
  color: var(--text-secondary);
1580
+ cursor: pointer;
1581
+ padding: 16px 4px 16px 16px;
1582
+ display: flex;
1583
+ align-items: center;
1584
+ transition: color 0.2s;
1585
  }
1586
 
1587
+ .upload-btn:hover { color: var(--text-primary); }
1588
 
1589
  /* Teaching style selector */
1590
  .style-selector-wrap {
tools.py CHANGED
@@ -1,21 +1,9 @@
1
- """
2
- tools.py β€” LangChain tools for STEM Copilot agent.
3
-
4
- Provides:
5
- - web_search: DuckDuckGo full-text search (text only, no API key needed)
6
- - yt_transcript: YouTube video transcript via youtube-transcript-api
7
- (works on HF Spaces without custom URL endpoints)
8
- """
9
-
10
  from __future__ import annotations
11
-
12
  import re
13
  from langchain_community.tools import DuckDuckGoSearchRun
14
  from langchain_core.tools import tool
15
 
16
-
17
- # ── Web search ────────────────────────────────────────────────
18
-
19
  web_search = DuckDuckGoSearchRun(
20
  name="web_search",
21
  description=(
@@ -26,11 +14,8 @@ web_search = DuckDuckGoSearchRun(
26
  ),
27
  )
28
 
29
-
30
- # ── YouTube transcript ────────────────────────────────────────
31
-
32
  def _extract_video_id(url_or_id: str) -> str | None:
33
- """Extract YouTube video ID from a URL or bare ID."""
34
  patterns = [
35
  r"(?:v=|youtu\.be/|embed/|shorts/)([A-Za-z0-9_-]{11})",
36
  r"^([A-Za-z0-9_-]{11})$",
@@ -41,7 +26,6 @@ def _extract_video_id(url_or_id: str) -> str | None:
41
  return m.group(1)
42
  return None
43
 
44
-
45
  @tool
46
  def yt_transcript(youtube_url: str) -> str:
47
  """
@@ -59,7 +43,7 @@ def yt_transcript(youtube_url: str) -> str:
59
  return f"Could not extract a valid YouTube video ID from: {youtube_url}"
60
 
61
  try:
62
- from youtube_transcript_api import YouTubeTranscriptApi # type: ignore
63
  transcript_list = YouTubeTranscriptApi.get_transcript(video_id)
64
  transcript = " ".join(seg["text"] for seg in transcript_list)
65
  if not transcript.strip():
@@ -75,7 +59,5 @@ def yt_transcript(youtube_url: str) -> str:
75
  )
76
  return f"Error fetching transcript: {exc}"
77
 
78
-
79
- # ── Exported tool list ─────────────────────────────────────────
80
-
81
  TOOLS = [web_search, yt_transcript]
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
 
2
  import re
3
  from langchain_community.tools import DuckDuckGoSearchRun
4
  from langchain_core.tools import tool
5
 
6
+ # DuckDuckGo Search Run tool
 
 
7
  web_search = DuckDuckGoSearchRun(
8
  name="web_search",
9
  description=(
 
14
  ),
15
  )
16
 
 
 
 
17
  def _extract_video_id(url_or_id: str) -> str | None:
18
+ """Extract YouTube 11-character video ID from URL or bare ID."""
19
  patterns = [
20
  r"(?:v=|youtu\.be/|embed/|shorts/)([A-Za-z0-9_-]{11})",
21
  r"^([A-Za-z0-9_-]{11})$",
 
26
  return m.group(1)
27
  return None
28
 
 
29
  @tool
30
  def yt_transcript(youtube_url: str) -> str:
31
  """
 
43
  return f"Could not extract a valid YouTube video ID from: {youtube_url}"
44
 
45
  try:
46
+ from youtube_transcript_api import YouTubeTranscriptApi
47
  transcript_list = YouTubeTranscriptApi.get_transcript(video_id)
48
  transcript = " ".join(seg["text"] for seg in transcript_list)
49
  if not transcript.strip():
 
59
  )
60
  return f"Error fetching transcript: {exc}"
61
 
62
+ # Exported tools list for LangGraph
 
 
63
  TOOLS = [web_search, yt_transcript]