Spaces:
Running
Running
rollback - safe changes
Browse files- app.py +12 -6
- graph.py +54 -174
- requirements.txt +2 -1
- static/app.js +378 -222
- static/index.html +2 -4
- static/style.css +52 -96
- 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 = ""
|
| 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 |
-
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 6 |
-
|
| 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 |
-
# ββ
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
"
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
# ββ Query Classification βββββββββββββββββββββββββββββββββββββ
|
|
@@ -123,144 +117,35 @@ def _classify(text: str) -> str:
|
|
| 123 |
return "general"
|
| 124 |
|
| 125 |
|
| 126 |
-
# ββ Model
|
| 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
|
| 252 |
if v:
|
| 253 |
idx = _counters.get("vision", 0) % len(v)
|
| 254 |
_counters["vision"] = idx + 1
|
| 255 |
-
return v[idx]
|
| 256 |
|
| 257 |
-
pool = [m for m in (
|
| 258 |
if not pool:
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
|
| 261 |
idx = _counters.get(category, 0) % len(pool)
|
| 262 |
_counters[category] = idx + 1
|
| 263 |
-
return pool[idx]
|
| 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 |
-
#
|
| 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
|
| 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
|
| 400 |
if not getattr(resp, "tool_calls", None):
|
| 401 |
return {"messages": [resp]}
|
| 402 |
|
| 403 |
-
# Execute
|
| 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
|
| 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 |
-
|
| 419 |
-
#
|
| 420 |
-
|
| 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
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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] || '
|
| 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">×</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"
|
|
|
|
|
|
|
|
|
|
| 764 |
</button>
|
| 765 |
<div class="attach-menu" id="heroAttachMenuDynamic">
|
| 766 |
-
<button class="attach-menu-item" id="heroWebSearchToggleDynamic" data-active="
|
| 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="
|
| 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="
|
| 782 |
-
<textarea id="heroInputDynamic" placeholder="
|
| 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 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 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] || '
|
| 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'}">×</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 |
-
|
| 1320 |
-
|
| 1321 |
-
|
| 1322 |
-
|
| 1323 |
-
|
| 1324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1325 |
|
| 1326 |
-
|
| 1327 |
-
|
| 1328 |
-
|
| 1329 |
-
|
| 1330 |
-
|
| 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 |
-
|
| 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 |
-
|
| 1360 |
heroPreview.innerHTML = '';
|
| 1361 |
heroPreview.classList.remove('visible');
|
| 1362 |
}));
|
|
@@ -1366,15 +1250,220 @@ function handleImageFile(file, targetPreviewEl) {
|
|
| 1366 |
reader.readAsDataURL(file);
|
| 1367 |
}
|
| 1368 |
|
| 1369 |
-
|
| 1370 |
-
|
| 1371 |
-
|
| 1372 |
-
|
| 1373 |
-
|
| 1374 |
-
if (
|
| 1375 |
-
|
| 1376 |
-
|
| 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 |
-
|
| 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 |
-
//
|
| 1453 |
-
|
| 1454 |
-
|
| 1455 |
-
|
| 1456 |
-
|
| 1457 |
-
|
| 1458 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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">×</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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1501 |
}
|
| 1502 |
|
| 1503 |
.attach-plus-btn svg {
|
| 1504 |
-
transition: transform 0.
|
|
|
|
| 1505 |
}
|
| 1506 |
|
| 1507 |
-
.attach-plus-btn
|
| 1508 |
-
|
| 1509 |
-
|
| 1510 |
-
|
| 1511 |
|
| 1512 |
.attach-menu {
|
|
|
|
| 1513 |
position: absolute;
|
| 1514 |
-
bottom:
|
| 1515 |
-
left:
|
| 1516 |
-
background: var(--bg-
|
| 1517 |
border: 1px solid var(--border-color);
|
| 1518 |
-
border-radius:
|
| 1519 |
padding: 6px;
|
| 1520 |
-
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
| 1530 |
|
| 1531 |
.attach-menu-item {
|
| 1532 |
-
display: flex;
|
| 1533 |
-
align-items: center;
|
| 1534 |
-
gap: 10px;
|
| 1535 |
background: none;
|
| 1536 |
border: none;
|
| 1537 |
-
|
| 1538 |
-
padding:
|
| 1539 |
-
font-size:
|
| 1540 |
font-family: inherit;
|
| 1541 |
color: var(--text-secondary);
|
|
|
|
| 1542 |
cursor: pointer;
|
| 1543 |
-
|
| 1544 |
-
|
| 1545 |
-
|
| 1546 |
-
|
| 1547 |
-
|
| 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
|
| 1560 |
-
|
| 1561 |
-
|
|
|
|
|
|
|
|
|
|
| 1562 |
}
|
| 1563 |
|
| 1564 |
-
.attach-menu-item[data-active="true"]
|
| 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 |
-
|
| 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 |
-
.
|
| 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 |
-
|
| 1616 |
-
|
| 1617 |
-
|
| 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 |
-
|
| 1626 |
-
|
| 1627 |
-
|
| 1628 |
-
|
| 1629 |
-
|
| 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
|
| 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
|
| 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]
|