Spaces:
Running
Running
Commit
·
20fd4b7
1
Parent(s):
a30fd70
Upd proj-spec filter + chat continuity LTM + sidebar
Browse files- app.py +129 -12
- static/auth.js +6 -0
- static/index.html +204 -68
- static/projects.js +298 -0
- static/script.js +57 -4
- static/sidebar.js +174 -0
- static/styles.css +403 -39
- utils/chunker.py +2 -1
- utils/rag.py +31 -29
app.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
# https://binkhoale1812-edsummariser.hf.space/
|
| 2 |
import os, io, re, uuid, json, time, logging
|
| 3 |
from typing import List, Dict, Any, Optional
|
|
|
|
| 4 |
|
| 5 |
from fastapi import FastAPI, UploadFile, File, Form, Request, HTTPException, BackgroundTasks
|
| 6 |
from fastapi.responses import FileResponse, JSONResponse, HTMLResponse
|
|
@@ -46,6 +47,8 @@ embedder = EmbeddingClient(model_name=os.getenv("EMBED_MODEL", "sentence-transfo
|
|
| 46 |
# Mongo / RAG store
|
| 47 |
rag = RAGStore(mongo_uri=os.getenv("MONGO_URI"), db_name=os.getenv("MONGO_DB", "studybuddy"))
|
| 48 |
ensure_indexes(rag)
|
|
|
|
|
|
|
| 49 |
# ────────────────────────────── Auth Helpers/Routes ───────────────────────────
|
| 50 |
import hashlib
|
| 51 |
import secrets
|
|
@@ -96,6 +99,103 @@ async def login(email: str = Form(...), password: str = Form(...)):
|
|
| 96 |
return {"email": email, "user_id": doc.get("user_id")}
|
| 97 |
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
# ────────────────────────────── Helpers ──────────────────────────────
|
| 101 |
def _infer_mime(filename: str) -> str:
|
|
@@ -131,6 +231,7 @@ async def upload_files(
|
|
| 131 |
request: Request,
|
| 132 |
background_tasks: BackgroundTasks,
|
| 133 |
user_id: str = Form(...),
|
|
|
|
| 134 |
files: List[UploadFile] = File(...),
|
| 135 |
):
|
| 136 |
"""
|
|
@@ -141,23 +242,27 @@ async def upload_files(
|
|
| 141 |
3) Merge captions into page text
|
| 142 |
4) Chunk into semantic cards (topic_name, summary, content + metadata)
|
| 143 |
5) Embed with all-MiniLM-L6-v2
|
| 144 |
-
6) Store in MongoDB with per-user and per-
|
| 145 |
7) Create a file-level summary
|
| 146 |
"""
|
| 147 |
job_id = str(uuid.uuid4())
|
|
|
|
| 148 |
# Read file bytes upfront to avoid reading from closed streams in background task
|
| 149 |
preloaded_files = []
|
| 150 |
for uf in files:
|
| 151 |
raw = await uf.read()
|
| 152 |
preloaded_files.append((uf.filename, raw))
|
|
|
|
| 153 |
# Process files in background
|
| 154 |
async def _process():
|
| 155 |
total_cards = 0
|
| 156 |
file_summaries = []
|
| 157 |
for fname, raw in preloaded_files:
|
| 158 |
logger.info(f"[{job_id}] Parsing {fname} ({len(raw)} bytes)")
|
|
|
|
| 159 |
# Extract pages from file
|
| 160 |
pages = _extract_pages(fname, raw)
|
|
|
|
| 161 |
# Caption images per page (if any)
|
| 162 |
num_imgs = sum(len(p.get("images", [])) for p in pages)
|
| 163 |
captions = []
|
|
@@ -173,46 +278,58 @@ async def upload_files(
|
|
| 173 |
captions.append(caps)
|
| 174 |
else:
|
| 175 |
captions = [[] for _ in pages]
|
|
|
|
| 176 |
# Merge captions into text
|
| 177 |
for idx, p in enumerate(pages):
|
| 178 |
if captions[idx]:
|
| 179 |
p["text"] = (p.get("text", "") + "\n\n" + "\n".join([f"[Image] {c}" for c in captions[idx]])).strip()
|
|
|
|
| 180 |
# Build cards
|
| 181 |
-
cards = build_cards_from_pages(pages, filename=fname, user_id=user_id)
|
| 182 |
logger.info(f"[{job_id}] Built {len(cards)} cards for {fname}")
|
|
|
|
| 183 |
# Embed & store
|
| 184 |
embeddings = embedder.embed([c["content"] for c in cards])
|
| 185 |
for c, vec in zip(cards, embeddings):
|
| 186 |
c["embedding"] = vec
|
|
|
|
| 187 |
# Store cards in MongoDB on card
|
| 188 |
rag.store_cards(cards)
|
| 189 |
total_cards += len(cards)
|
|
|
|
| 190 |
# File-level summary (cheap extractive)
|
| 191 |
full_text = "\n\n".join(p.get("text", "") for p in pages)
|
| 192 |
file_summary = cheap_summarize(full_text, max_sentences=6)
|
| 193 |
-
rag.upsert_file_summary(user_id=user_id, filename=fname, summary=file_summary)
|
| 194 |
file_summaries.append({"filename": fname, "summary": file_summary})
|
|
|
|
| 195 |
logger.info(f"[{job_id}] Ingestion complete. Total cards: {total_cards}")
|
|
|
|
| 196 |
# Kick off processing in background to keep UI responsive
|
| 197 |
background_tasks.add_task(_process)
|
| 198 |
return {"job_id": job_id, "status": "processing"}
|
| 199 |
|
| 200 |
|
| 201 |
@app.get("/cards")
|
| 202 |
-
def list_cards(user_id: str, filename: Optional[str] = None, limit: int = 50, skip: int = 0):
|
| 203 |
-
return rag.list_cards(user_id=user_id, filename=filename, limit=limit, skip=skip)
|
| 204 |
|
| 205 |
|
| 206 |
@app.get("/file-summary")
|
| 207 |
-
def get_file_summary(user_id: str, filename: str):
|
| 208 |
-
doc = rag.get_file_summary(user_id=user_id, filename=filename)
|
| 209 |
if not doc:
|
| 210 |
raise HTTPException(404, detail="No summary found for that file.")
|
| 211 |
return {"filename": filename, "summary": doc.get("summary", "")}
|
| 212 |
|
| 213 |
|
| 214 |
@app.post("/chat")
|
| 215 |
-
async def chat(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
"""
|
| 217 |
RAG chat that answers ONLY from uploaded materials.
|
| 218 |
- Preload all filenames + summaries; use NVIDIA to classify file relevance to question (true/false)
|
|
@@ -230,14 +347,14 @@ async def chat(user_id: str = Form(...), question: str = Form(...), k: int = For
|
|
| 230 |
# If the question is about a specific file, return the file summary
|
| 231 |
if m:
|
| 232 |
fn = m.group(1)
|
| 233 |
-
doc = rag.get_file_summary(user_id=user_id, filename=fn)
|
| 234 |
if doc:
|
| 235 |
return {"answer": doc.get("summary", ""), "sources": [{"filename": fn, "file_summary": True}]}
|
| 236 |
else:
|
| 237 |
return {"answer": "I couldn't find a summary for that file in your library.", "sources": []}
|
| 238 |
-
|
| 239 |
# 1) Preload file list + summaries
|
| 240 |
-
files_list = rag.list_files(user_id=user_id) # [{filename, summary}]
|
| 241 |
# Ask NVIDIA to mark relevance per file
|
| 242 |
relevant_map = await files_relevance(question, files_list, nvidia_rotator)
|
| 243 |
relevant_files = [fn for fn, ok in relevant_map.items() if ok]
|
|
@@ -272,7 +389,7 @@ async def chat(user_id: str = Form(...), question: str = Form(...), k: int = For
|
|
| 272 |
|
| 273 |
# 3) RAG vector search (restricted to relevant files if any)
|
| 274 |
q_vec = embedder.embed([question])[0]
|
| 275 |
-
hits = rag.vector_search(user_id=user_id, query_vector=q_vec, k=k, filenames=relevant_files if relevant_files else None)
|
| 276 |
if not hits:
|
| 277 |
return {
|
| 278 |
"answer": "I don't know based on your uploaded materials. Try uploading more sources or rephrasing the question.",
|
|
|
|
| 1 |
# https://binkhoale1812-edsummariser.hf.space/
|
| 2 |
import os, io, re, uuid, json, time, logging
|
| 3 |
from typing import List, Dict, Any, Optional
|
| 4 |
+
from datetime import datetime
|
| 5 |
|
| 6 |
from fastapi import FastAPI, UploadFile, File, Form, Request, HTTPException, BackgroundTasks
|
| 7 |
from fastapi.responses import FileResponse, JSONResponse, HTMLResponse
|
|
|
|
| 47 |
# Mongo / RAG store
|
| 48 |
rag = RAGStore(mongo_uri=os.getenv("MONGO_URI"), db_name=os.getenv("MONGO_DB", "studybuddy"))
|
| 49 |
ensure_indexes(rag)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
# ────────────────────────────── Auth Helpers/Routes ───────────────────────────
|
| 53 |
import hashlib
|
| 54 |
import secrets
|
|
|
|
| 99 |
return {"email": email, "user_id": doc.get("user_id")}
|
| 100 |
|
| 101 |
|
| 102 |
+
# ────────────────────────────── Project Management ───────────────────────────
|
| 103 |
+
@app.post("/projects/create")
|
| 104 |
+
async def create_project(user_id: str = Form(...), name: str = Form(...), description: str = Form("")):
|
| 105 |
+
"""Create a new project for a user"""
|
| 106 |
+
if not name.strip():
|
| 107 |
+
raise HTTPException(400, detail="Project name is required")
|
| 108 |
+
|
| 109 |
+
project_id = str(uuid.uuid4())
|
| 110 |
+
project = {
|
| 111 |
+
"project_id": project_id,
|
| 112 |
+
"user_id": user_id,
|
| 113 |
+
"name": name.strip(),
|
| 114 |
+
"description": description.strip(),
|
| 115 |
+
"created_at": datetime.utcnow(),
|
| 116 |
+
"updated_at": datetime.utcnow()
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
rag.db["projects"].insert_one(project)
|
| 120 |
+
logger.info(f"[PROJECT] Created project {name} for user {user_id}")
|
| 121 |
+
return project
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@app.get("/projects")
|
| 125 |
+
async def list_projects(user_id: str):
|
| 126 |
+
"""List all projects for a user"""
|
| 127 |
+
projects = list(rag.db["projects"].find(
|
| 128 |
+
{"user_id": user_id},
|
| 129 |
+
{"_id": 0}
|
| 130 |
+
).sort("updated_at", -1))
|
| 131 |
+
return {"projects": projects}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@app.get("/projects/{project_id}")
|
| 135 |
+
async def get_project(project_id: str, user_id: str):
|
| 136 |
+
"""Get a specific project (with user ownership check)"""
|
| 137 |
+
project = rag.db["projects"].find_one(
|
| 138 |
+
{"project_id": project_id, "user_id": user_id},
|
| 139 |
+
{"_id": 0}
|
| 140 |
+
)
|
| 141 |
+
if not project:
|
| 142 |
+
raise HTTPException(404, detail="Project not found")
|
| 143 |
+
return project
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@app.delete("/projects/{project_id}")
|
| 147 |
+
async def delete_project(project_id: str, user_id: str):
|
| 148 |
+
"""Delete a project and all its associated data"""
|
| 149 |
+
# Check ownership
|
| 150 |
+
project = rag.db["projects"].find_one({"project_id": project_id, "user_id": user_id})
|
| 151 |
+
if not project:
|
| 152 |
+
raise HTTPException(404, detail="Project not found")
|
| 153 |
+
|
| 154 |
+
# Delete project and all associated data
|
| 155 |
+
rag.db["projects"].delete_one({"project_id": project_id})
|
| 156 |
+
rag.db["chunks"].delete_many({"project_id": project_id})
|
| 157 |
+
rag.db["files"].delete_many({"project_id": project_id})
|
| 158 |
+
rag.db["chat_sessions"].delete_many({"project_id": project_id})
|
| 159 |
+
|
| 160 |
+
logger.info(f"[PROJECT] Deleted project {project_id} for user {user_id}")
|
| 161 |
+
return {"message": "Project deleted successfully"}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ────────────────────────────── Chat Sessions ──────────────────────────────
|
| 165 |
+
@app.post("/chat/save")
|
| 166 |
+
async def save_chat_message(
|
| 167 |
+
user_id: str = Form(...),
|
| 168 |
+
project_id: str = Form(...),
|
| 169 |
+
role: str = Form(...),
|
| 170 |
+
content: str = Form(...),
|
| 171 |
+
timestamp: Optional[float] = Form(None)
|
| 172 |
+
):
|
| 173 |
+
"""Save a chat message to the session"""
|
| 174 |
+
if role not in ["user", "assistant"]:
|
| 175 |
+
raise HTTPException(400, detail="Invalid role")
|
| 176 |
+
|
| 177 |
+
message = {
|
| 178 |
+
"user_id": user_id,
|
| 179 |
+
"project_id": project_id,
|
| 180 |
+
"role": role,
|
| 181 |
+
"content": content,
|
| 182 |
+
"timestamp": timestamp or time.time(),
|
| 183 |
+
"created_at": datetime.utcnow()
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
rag.db["chat_sessions"].insert_one(message)
|
| 187 |
+
return {"message": "Chat message saved"}
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
@app.get("/chat/history")
|
| 191 |
+
async def get_chat_history(user_id: str, project_id: str, limit: int = 100):
|
| 192 |
+
"""Get chat history for a project"""
|
| 193 |
+
messages = list(rag.db["chat_sessions"].find(
|
| 194 |
+
{"user_id": user_id, "project_id": project_id},
|
| 195 |
+
{"_id": 0}
|
| 196 |
+
).sort("timestamp", 1).limit(limit))
|
| 197 |
+
return {"messages": messages}
|
| 198 |
+
|
| 199 |
|
| 200 |
# ────────────────────────────── Helpers ──────────────────────────────
|
| 201 |
def _infer_mime(filename: str) -> str:
|
|
|
|
| 231 |
request: Request,
|
| 232 |
background_tasks: BackgroundTasks,
|
| 233 |
user_id: str = Form(...),
|
| 234 |
+
project_id: str = Form(...),
|
| 235 |
files: List[UploadFile] = File(...),
|
| 236 |
):
|
| 237 |
"""
|
|
|
|
| 242 |
3) Merge captions into page text
|
| 243 |
4) Chunk into semantic cards (topic_name, summary, content + metadata)
|
| 244 |
5) Embed with all-MiniLM-L6-v2
|
| 245 |
+
6) Store in MongoDB with per-user and per-project metadata
|
| 246 |
7) Create a file-level summary
|
| 247 |
"""
|
| 248 |
job_id = str(uuid.uuid4())
|
| 249 |
+
|
| 250 |
# Read file bytes upfront to avoid reading from closed streams in background task
|
| 251 |
preloaded_files = []
|
| 252 |
for uf in files:
|
| 253 |
raw = await uf.read()
|
| 254 |
preloaded_files.append((uf.filename, raw))
|
| 255 |
+
|
| 256 |
# Process files in background
|
| 257 |
async def _process():
|
| 258 |
total_cards = 0
|
| 259 |
file_summaries = []
|
| 260 |
for fname, raw in preloaded_files:
|
| 261 |
logger.info(f"[{job_id}] Parsing {fname} ({len(raw)} bytes)")
|
| 262 |
+
|
| 263 |
# Extract pages from file
|
| 264 |
pages = _extract_pages(fname, raw)
|
| 265 |
+
|
| 266 |
# Caption images per page (if any)
|
| 267 |
num_imgs = sum(len(p.get("images", [])) for p in pages)
|
| 268 |
captions = []
|
|
|
|
| 278 |
captions.append(caps)
|
| 279 |
else:
|
| 280 |
captions = [[] for _ in pages]
|
| 281 |
+
|
| 282 |
# Merge captions into text
|
| 283 |
for idx, p in enumerate(pages):
|
| 284 |
if captions[idx]:
|
| 285 |
p["text"] = (p.get("text", "") + "\n\n" + "\n".join([f"[Image] {c}" for c in captions[idx]])).strip()
|
| 286 |
+
|
| 287 |
# Build cards
|
| 288 |
+
cards = build_cards_from_pages(pages, filename=fname, user_id=user_id, project_id=project_id)
|
| 289 |
logger.info(f"[{job_id}] Built {len(cards)} cards for {fname}")
|
| 290 |
+
|
| 291 |
# Embed & store
|
| 292 |
embeddings = embedder.embed([c["content"] for c in cards])
|
| 293 |
for c, vec in zip(cards, embeddings):
|
| 294 |
c["embedding"] = vec
|
| 295 |
+
|
| 296 |
# Store cards in MongoDB on card
|
| 297 |
rag.store_cards(cards)
|
| 298 |
total_cards += len(cards)
|
| 299 |
+
|
| 300 |
# File-level summary (cheap extractive)
|
| 301 |
full_text = "\n\n".join(p.get("text", "") for p in pages)
|
| 302 |
file_summary = cheap_summarize(full_text, max_sentences=6)
|
| 303 |
+
rag.upsert_file_summary(user_id=user_id, project_id=project_id, filename=fname, summary=file_summary)
|
| 304 |
file_summaries.append({"filename": fname, "summary": file_summary})
|
| 305 |
+
|
| 306 |
logger.info(f"[{job_id}] Ingestion complete. Total cards: {total_cards}")
|
| 307 |
+
|
| 308 |
# Kick off processing in background to keep UI responsive
|
| 309 |
background_tasks.add_task(_process)
|
| 310 |
return {"job_id": job_id, "status": "processing"}
|
| 311 |
|
| 312 |
|
| 313 |
@app.get("/cards")
|
| 314 |
+
def list_cards(user_id: str, project_id: str, filename: Optional[str] = None, limit: int = 50, skip: int = 0):
|
| 315 |
+
return rag.list_cards(user_id=user_id, project_id=project_id, filename=filename, limit=limit, skip=skip)
|
| 316 |
|
| 317 |
|
| 318 |
@app.get("/file-summary")
|
| 319 |
+
def get_file_summary(user_id: str, project_id: str, filename: str):
|
| 320 |
+
doc = rag.get_file_summary(user_id=user_id, project_id=project_id, filename=filename)
|
| 321 |
if not doc:
|
| 322 |
raise HTTPException(404, detail="No summary found for that file.")
|
| 323 |
return {"filename": filename, "summary": doc.get("summary", "")}
|
| 324 |
|
| 325 |
|
| 326 |
@app.post("/chat")
|
| 327 |
+
async def chat(
|
| 328 |
+
user_id: str = Form(...),
|
| 329 |
+
project_id: str = Form(...),
|
| 330 |
+
question: str = Form(...),
|
| 331 |
+
k: int = Form(6)
|
| 332 |
+
):
|
| 333 |
"""
|
| 334 |
RAG chat that answers ONLY from uploaded materials.
|
| 335 |
- Preload all filenames + summaries; use NVIDIA to classify file relevance to question (true/false)
|
|
|
|
| 347 |
# If the question is about a specific file, return the file summary
|
| 348 |
if m:
|
| 349 |
fn = m.group(1)
|
| 350 |
+
doc = rag.get_file_summary(user_id=user_id, project_id=project_id, filename=fn)
|
| 351 |
if doc:
|
| 352 |
return {"answer": doc.get("summary", ""), "sources": [{"filename": fn, "file_summary": True}]}
|
| 353 |
else:
|
| 354 |
return {"answer": "I couldn't find a summary for that file in your library.", "sources": []}
|
| 355 |
+
|
| 356 |
# 1) Preload file list + summaries
|
| 357 |
+
files_list = rag.list_files(user_id=user_id, project_id=project_id) # [{filename, summary}]
|
| 358 |
# Ask NVIDIA to mark relevance per file
|
| 359 |
relevant_map = await files_relevance(question, files_list, nvidia_rotator)
|
| 360 |
relevant_files = [fn for fn, ok in relevant_map.items() if ok]
|
|
|
|
| 389 |
|
| 390 |
# 3) RAG vector search (restricted to relevant files if any)
|
| 391 |
q_vec = embedder.embed([question])[0]
|
| 392 |
+
hits = rag.vector_search(user_id=user_id, project_id=project_id, query_vector=q_vec, k=k, filenames=relevant_files if relevant_files else None)
|
| 393 |
if not hits:
|
| 394 |
return {
|
| 395 |
"answer": "I don't know based on your uploaded materials. Try uploading more sources or rephrasing the question.",
|
static/auth.js
CHANGED
|
@@ -20,6 +20,11 @@
|
|
| 20 |
chatSection.style.display = 'block';
|
| 21 |
// Hide modal if it was open
|
| 22 |
modal.classList.add('hidden');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
} else {
|
| 24 |
userInfo.style.display = 'none';
|
| 25 |
// Disable app sections for unauthenticated users
|
|
@@ -43,6 +48,7 @@
|
|
| 43 |
|
| 44 |
function clearUser() {
|
| 45 |
localStorage.removeItem('sb_user');
|
|
|
|
| 46 |
setAuthUI(null);
|
| 47 |
}
|
| 48 |
|
|
|
|
| 20 |
chatSection.style.display = 'block';
|
| 21 |
// Hide modal if it was open
|
| 22 |
modal.classList.add('hidden');
|
| 23 |
+
|
| 24 |
+
// Trigger project loading after successful auth
|
| 25 |
+
if (window.__sb_load_projects) {
|
| 26 |
+
window.__sb_load_projects();
|
| 27 |
+
}
|
| 28 |
} else {
|
| 29 |
userInfo.style.display = 'none';
|
| 30 |
// Disable app sections for unauthenticated users
|
|
|
|
| 48 |
|
| 49 |
function clearUser() {
|
| 50 |
localStorage.removeItem('sb_user');
|
| 51 |
+
localStorage.removeItem('sb_current_project');
|
| 52 |
setAuthUI(null);
|
| 53 |
}
|
| 54 |
|
static/index.html
CHANGED
|
@@ -8,91 +8,201 @@
|
|
| 8 |
<link rel="stylesheet" href="/static/styles.css">
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
-
<div class="container">
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
<div class="logo
|
| 16 |
-
<
|
| 17 |
-
<
|
|
|
|
|
|
|
|
|
|
| 18 |
</div>
|
| 19 |
</div>
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
<
|
| 25 |
</div>
|
| 26 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
</div>
|
| 28 |
-
</header>
|
| 29 |
|
| 30 |
-
|
| 31 |
-
<div class="
|
| 32 |
-
<
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
</div>
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
</div>
|
| 42 |
-
<input type="file" id="files" multiple accept=".pdf,.docx" hidden>
|
| 43 |
</div>
|
| 44 |
-
<div class="
|
| 45 |
-
<
|
| 46 |
-
<div class="file-items" id="file-items"></div>
|
| 47 |
</div>
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
</button>
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
<div class="progress-header">
|
| 58 |
-
<h4>Processing Documents</h4>
|
| 59 |
-
<span class="progress-status" id="progress-status">Initializing...</span>
|
| 60 |
-
</div>
|
| 61 |
-
<div class="progress-bar">
|
| 62 |
-
<div class="progress-fill" id="progress-fill"></div>
|
| 63 |
</div>
|
| 64 |
-
<div class="progress-log" id="progress-log"></div>
|
| 65 |
</div>
|
| 66 |
-
</section>
|
| 67 |
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
<
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
<input type="text" id="question" placeholder="Ask something about your documents..." disabled>
|
| 78 |
-
<button id="ask" class="btn-primary" disabled>
|
| 79 |
-
<span class="btn-text">Ask</span>
|
| 80 |
-
<span class="btn-loading" style="display:none;">
|
| 81 |
-
<div class="spinner"></div>
|
| 82 |
-
Thinking...
|
| 83 |
-
</span>
|
| 84 |
-
</button>
|
| 85 |
</div>
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
</div>
|
| 89 |
</div>
|
| 90 |
-
</div>
|
| 91 |
-
</section>
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
</div>
|
| 97 |
|
| 98 |
<!-- Auth Modal -->
|
|
@@ -139,6 +249,30 @@
|
|
| 139 |
</div>
|
| 140 |
</div>
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
<!-- Loading Overlay -->
|
| 143 |
<div id="loading-overlay" class="loading-overlay hidden">
|
| 144 |
<div class="loading-content">
|
|
@@ -149,6 +283,8 @@
|
|
| 149 |
</div>
|
| 150 |
|
| 151 |
<script src="/static/auth.js"></script>
|
|
|
|
|
|
|
| 152 |
<script src="/static/script.js"></script>
|
| 153 |
</body>
|
| 154 |
</html>
|
|
|
|
| 8 |
<link rel="stylesheet" href="/static/styles.css">
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
+
<div class="app-container">
|
| 12 |
+
<!-- Sidebar -->
|
| 13 |
+
<aside class="sidebar" id="sidebar">
|
| 14 |
+
<div class="sidebar-header">
|
| 15 |
+
<div class="logo">
|
| 16 |
+
<div class="logo-icon">📚</div>
|
| 17 |
+
<div class="logo-text">
|
| 18 |
+
<h1>StudyBuddy</h1>
|
| 19 |
+
<p>AI Document Analysis</p>
|
| 20 |
+
</div>
|
| 21 |
</div>
|
| 22 |
</div>
|
| 23 |
+
|
| 24 |
+
<!-- Website Menu -->
|
| 25 |
+
<div class="sidebar-section">
|
| 26 |
+
<div class="section-header">
|
| 27 |
+
<h3>Menu</h3>
|
| 28 |
</div>
|
| 29 |
+
<nav class="website-menu">
|
| 30 |
+
<a href="#" class="menu-item active" data-section="projects">
|
| 31 |
+
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 32 |
+
<path d="M3 7v10a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2H5a2 2 0 0 0-2-2z"/>
|
| 33 |
+
<path d="M8 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2H8V5z"/>
|
| 34 |
+
</svg>
|
| 35 |
+
<span>Projects</span>
|
| 36 |
+
</a>
|
| 37 |
+
<a href="#" class="menu-item" data-section="files">
|
| 38 |
+
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 39 |
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
| 40 |
+
<polyline points="14,2 14,8 20,8"/>
|
| 41 |
+
<line x1="16" y1="13" x2="8" y2="13"/>
|
| 42 |
+
<line x1="16" y1="17" x2="8" y2="17"/>
|
| 43 |
+
<polyline points="10,9 9,9 8,9"/>
|
| 44 |
+
</svg>
|
| 45 |
+
<span>Files</span>
|
| 46 |
+
</a>
|
| 47 |
+
<a href="#" class="menu-item" data-section="chat">
|
| 48 |
+
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 49 |
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
| 50 |
+
</svg>
|
| 51 |
+
<span>Chat</span>
|
| 52 |
+
</a>
|
| 53 |
+
<a href="#" class="menu-item" data-section="analytics">
|
| 54 |
+
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 55 |
+
<path d="M3 3v18h18"/>
|
| 56 |
+
<path d="M18.7 8l-5.1 5.2-2.8-2.7L7 14.3"/>
|
| 57 |
+
</svg>
|
| 58 |
+
<span>Analytics</span>
|
| 59 |
+
</a>
|
| 60 |
+
<a href="#" class="menu-item" data-section="settings">
|
| 61 |
+
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 62 |
+
<circle cx="12" cy="12" r="3"/>
|
| 63 |
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
| 64 |
+
</svg>
|
| 65 |
+
<span>Settings</span>
|
| 66 |
+
</a>
|
| 67 |
+
</nav>
|
| 68 |
</div>
|
|
|
|
| 69 |
|
| 70 |
+
<!-- Project Management -->
|
| 71 |
+
<div class="sidebar-section">
|
| 72 |
+
<div class="section-header">
|
| 73 |
+
<h3>Projects</h3>
|
| 74 |
+
<button id="new-project-btn" class="btn-icon" title="Create new project">+</button>
|
| 75 |
+
</div>
|
| 76 |
+
<div class="project-list" id="project-list">
|
| 77 |
+
<!-- Projects will be populated here -->
|
| 78 |
+
</div>
|
| 79 |
</div>
|
| 80 |
+
|
| 81 |
+
<!-- User Controls -->
|
| 82 |
+
<div class="sidebar-section">
|
| 83 |
+
<div class="section-header">
|
| 84 |
+
<h3>Account</h3>
|
| 85 |
+
</div>
|
| 86 |
+
<div class="user-info" id="user-info" style="display:none;">
|
| 87 |
+
<div class="user-avatar">👤</div>
|
| 88 |
+
<div class="user-details">
|
| 89 |
+
<span class="user-email" id="user-email"></span>
|
| 90 |
+
<button id="logout" class="btn-secondary">Logout</button>
|
| 91 |
</div>
|
|
|
|
| 92 |
</div>
|
| 93 |
+
<div class="theme-toggle-wrapper">
|
| 94 |
+
<button id="theme-toggle" class="btn-icon" title="Toggle theme">🌙</button>
|
|
|
|
| 95 |
</div>
|
| 96 |
+
</div>
|
| 97 |
+
</aside>
|
| 98 |
+
|
| 99 |
+
<!-- Main Content -->
|
| 100 |
+
<main class="main-content">
|
| 101 |
+
<!-- Top Bar with Hamburger Menu -->
|
| 102 |
+
<div class="top-bar">
|
| 103 |
+
<button id="sidebar-toggle" class="hamburger-menu" aria-label="Toggle sidebar">
|
| 104 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 105 |
+
<line x1="3" y1="6" x2="21" y2="6"/>
|
| 106 |
+
<line x1="3" y1="12" x2="21" y2="12"/>
|
| 107 |
+
<line x1="3" y1="18" x2="21" y2="18"/>
|
| 108 |
+
</svg>
|
| 109 |
</button>
|
| 110 |
+
<div class="top-bar-title">
|
| 111 |
+
<h2 id="page-title">StudyBuddy</h2>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
</div>
|
|
|
|
| 113 |
</div>
|
|
|
|
| 114 |
|
| 115 |
+
<div class="content-container">
|
| 116 |
+
<!-- Project Header -->
|
| 117 |
+
<header class="project-header" id="project-header" style="display:none;">
|
| 118 |
+
<div class="project-info">
|
| 119 |
+
<h2 id="current-project-name">Project Name</h2>
|
| 120 |
+
<p id="current-project-description">Project description</p>
|
| 121 |
+
</div>
|
| 122 |
+
<div class="project-actions">
|
| 123 |
+
<button id="delete-project-btn" class="btn-secondary" style="display:none;">Delete Project</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
</div>
|
| 125 |
+
</header>
|
| 126 |
+
|
| 127 |
+
<!-- Welcome Screen (when no project selected) -->
|
| 128 |
+
<div class="welcome-screen" id="welcome-screen">
|
| 129 |
+
<div class="welcome-content">
|
| 130 |
+
<div class="welcome-icon">🚀</div>
|
| 131 |
+
<h2>Welcome to StudyBuddy</h2>
|
| 132 |
+
<p>Create a new project to get started with AI-powered document analysis</p>
|
| 133 |
+
<button id="welcome-new-project" class="btn-primary">Create Your First Project</button>
|
| 134 |
</div>
|
| 135 |
</div>
|
|
|
|
|
|
|
| 136 |
|
| 137 |
+
<!-- Project Content (hidden until project selected) -->
|
| 138 |
+
<div class="project-content" id="project-content" style="display:none;">
|
| 139 |
+
<!-- Upload Section -->
|
| 140 |
+
<section class="card reveal" id="upload-section">
|
| 141 |
+
<div class="card-header">
|
| 142 |
+
<h2>📁 Upload Documents</h2>
|
| 143 |
+
<p>Upload your PDFs and DOCX files to start chatting with your materials</p>
|
| 144 |
+
</div>
|
| 145 |
+
<form id="upload-form">
|
| 146 |
+
<div class="file-drop-zone" id="file-drop-zone">
|
| 147 |
+
<div class="drop-zone-content">
|
| 148 |
+
<div class="drop-zone-icon">📄</div>
|
| 149 |
+
<p>Drag & drop files here or click to browse</p>
|
| 150 |
+
<span class="file-types">Supports: PDF, DOCX</span>
|
| 151 |
+
</div>
|
| 152 |
+
<input type="file" id="files" multiple accept=".pdf,.docx" hidden>
|
| 153 |
+
</div>
|
| 154 |
+
<div class="file-list" id="file-list" style="display:none;">
|
| 155 |
+
<h4>Selected Files:</h4>
|
| 156 |
+
<div class="file-items" id="file-items"></div>
|
| 157 |
+
</div>
|
| 158 |
+
<button type="submit" class="btn-primary" id="upload-btn">
|
| 159 |
+
<span class="btn-text">Upload Documents</span>
|
| 160 |
+
<span class="btn-loading" style="display:none;">
|
| 161 |
+
<div class="spinner"></div>
|
| 162 |
+
Processing...
|
| 163 |
+
</span>
|
| 164 |
+
</button>
|
| 165 |
+
</form>
|
| 166 |
+
<div class="upload-progress" id="upload-progress" style="display:none;">
|
| 167 |
+
<div class="progress-header">
|
| 168 |
+
<h4>Processing Documents</h4>
|
| 169 |
+
<span class="progress-status" id="progress-status">Initializing...</span>
|
| 170 |
+
</div>
|
| 171 |
+
<div class="progress-bar">
|
| 172 |
+
<div class="progress-fill" id="progress-fill"></div>
|
| 173 |
+
</div>
|
| 174 |
+
<div class="progress-log" id="progress-log"></div>
|
| 175 |
+
</div>
|
| 176 |
+
</section>
|
| 177 |
+
|
| 178 |
+
<!-- Chat Section -->
|
| 179 |
+
<section class="card reveal" id="chat-section">
|
| 180 |
+
<div class="card-header">
|
| 181 |
+
<h2>💬 Chat with Documents</h2>
|
| 182 |
+
<p>Ask questions about your uploaded materials and get AI-powered answers</p>
|
| 183 |
+
</div>
|
| 184 |
+
<div id="chat">
|
| 185 |
+
<div id="messages"></div>
|
| 186 |
+
<div class="chat-controls" id="chat-controls">
|
| 187 |
+
<div class="chat-input-wrapper">
|
| 188 |
+
<input type="text" id="question" placeholder="Ask something about your documents..." disabled>
|
| 189 |
+
<button id="ask" class="btn-primary" disabled>
|
| 190 |
+
<span class="btn-text">Ask</span>
|
| 191 |
+
<span class="btn-loading" style="display:none;">
|
| 192 |
+
<div class="spinner"></div>
|
| 193 |
+
Thinking...
|
| 194 |
+
</span>
|
| 195 |
+
</button>
|
| 196 |
+
</div>
|
| 197 |
+
<div class="chat-hint" id="chat-hint">
|
| 198 |
+
Upload documents first to start chatting
|
| 199 |
+
</div>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
</section>
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
</main>
|
| 206 |
</div>
|
| 207 |
|
| 208 |
<!-- Auth Modal -->
|
|
|
|
| 249 |
</div>
|
| 250 |
</div>
|
| 251 |
|
| 252 |
+
<!-- New Project Modal -->
|
| 253 |
+
<div id="new-project-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-labelledby="new-project-title">
|
| 254 |
+
<div class="modal-content">
|
| 255 |
+
<div class="modal-header">
|
| 256 |
+
<h2 id="new-project-title">Create New Project</h2>
|
| 257 |
+
<p class="modal-subtitle">Organize your documents into projects</p>
|
| 258 |
+
</div>
|
| 259 |
+
<form id="new-project-form">
|
| 260 |
+
<div class="form-group">
|
| 261 |
+
<label>Project Name</label>
|
| 262 |
+
<input type="text" id="project-name" placeholder="e.g., Research Paper, Course Notes" required>
|
| 263 |
+
</div>
|
| 264 |
+
<div class="form-group">
|
| 265 |
+
<label>Description (Optional)</label>
|
| 266 |
+
<textarea id="project-description" placeholder="Brief description of your project" rows="3"></textarea>
|
| 267 |
+
</div>
|
| 268 |
+
<div class="form-actions">
|
| 269 |
+
<button type="button" id="cancel-project" class="btn-secondary">Cancel</button>
|
| 270 |
+
<button type="submit" class="btn-primary">Create Project</button>
|
| 271 |
+
</div>
|
| 272 |
+
</form>
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
|
| 276 |
<!-- Loading Overlay -->
|
| 277 |
<div id="loading-overlay" class="loading-overlay hidden">
|
| 278 |
<div class="loading-content">
|
|
|
|
| 283 |
</div>
|
| 284 |
|
| 285 |
<script src="/static/auth.js"></script>
|
| 286 |
+
<script src="/static/sidebar.js"></script>
|
| 287 |
+
<script src="/static/projects.js"></script>
|
| 288 |
<script src="/static/script.js"></script>
|
| 289 |
</body>
|
| 290 |
</html>
|
static/projects.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ────────────────────────────── static/projects.js ──────────────────────────────
|
| 2 |
+
(function() {
|
| 3 |
+
// DOM elements
|
| 4 |
+
const newProjectBtn = document.getElementById('new-project-btn');
|
| 5 |
+
const projectList = document.getElementById('project-list');
|
| 6 |
+
const newProjectModal = document.getElementById('new-project-modal');
|
| 7 |
+
const newProjectForm = document.getElementById('new-project-form');
|
| 8 |
+
const cancelProjectBtn = document.getElementById('cancel-project');
|
| 9 |
+
const welcomeNewProjectBtn = document.getElementById('welcome-new-project');
|
| 10 |
+
const projectHeader = document.getElementById('project-header');
|
| 11 |
+
const currentProjectName = document.getElementById('current-project-name');
|
| 12 |
+
const currentProjectDescription = document.getElementById('current-project-description');
|
| 13 |
+
const deleteProjectBtn = document.getElementById('delete-project-btn');
|
| 14 |
+
const welcomeScreen = document.getElementById('welcome-screen');
|
| 15 |
+
const projectContent = document.getElementById('project-content');
|
| 16 |
+
|
| 17 |
+
// State
|
| 18 |
+
let currentProject = null;
|
| 19 |
+
let projects = [];
|
| 20 |
+
|
| 21 |
+
// Initialize
|
| 22 |
+
init();
|
| 23 |
+
|
| 24 |
+
function init() {
|
| 25 |
+
setupEventListeners();
|
| 26 |
+
loadProjects();
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function setupEventListeners() {
|
| 30 |
+
newProjectBtn.addEventListener('click', showNewProjectModal);
|
| 31 |
+
welcomeNewProjectBtn.addEventListener('click', showNewProjectModal);
|
| 32 |
+
cancelProjectBtn.addEventListener('click', hideNewProjectModal);
|
| 33 |
+
newProjectForm.addEventListener('submit', handleCreateProject);
|
| 34 |
+
deleteProjectBtn.addEventListener('click', handleDeleteProject);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
async function loadProjects() {
|
| 38 |
+
const user = window.__sb_get_user();
|
| 39 |
+
if (!user) return;
|
| 40 |
+
|
| 41 |
+
try {
|
| 42 |
+
const response = await fetch(`/projects?user_id=${user.user_id}`);
|
| 43 |
+
if (response.ok) {
|
| 44 |
+
const data = await response.json();
|
| 45 |
+
projects = data.projects || [];
|
| 46 |
+
renderProjectList();
|
| 47 |
+
|
| 48 |
+
// If no projects, show welcome screen
|
| 49 |
+
if (projects.length === 0) {
|
| 50 |
+
showWelcomeScreen();
|
| 51 |
+
} else {
|
| 52 |
+
// Select first project by default
|
| 53 |
+
selectProject(projects[0]);
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
} catch (error) {
|
| 57 |
+
console.error('Failed to load projects:', error);
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function renderProjectList() {
|
| 62 |
+
projectList.innerHTML = '';
|
| 63 |
+
|
| 64 |
+
projects.forEach(project => {
|
| 65 |
+
const projectItem = document.createElement('div');
|
| 66 |
+
projectItem.className = 'project-item';
|
| 67 |
+
if (currentProject && currentProject.project_id === project.project_id) {
|
| 68 |
+
projectItem.classList.add('active');
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
projectItem.innerHTML = `
|
| 72 |
+
<div class="project-item-icon">📁</div>
|
| 73 |
+
<div class="project-item-info">
|
| 74 |
+
<div class="project-item-name">${project.name}</div>
|
| 75 |
+
<div class="project-item-description">${project.description || 'No description'}</div>
|
| 76 |
+
</div>
|
| 77 |
+
<div class="project-item-actions">
|
| 78 |
+
<button class="project-item-delete" title="Delete project">🗑️</button>
|
| 79 |
+
</div>
|
| 80 |
+
`;
|
| 81 |
+
|
| 82 |
+
// Add click handlers
|
| 83 |
+
projectItem.addEventListener('click', (e) => {
|
| 84 |
+
if (!e.target.classList.contains('project-item-delete')) {
|
| 85 |
+
selectProject(project);
|
| 86 |
+
}
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
// Delete button handler
|
| 90 |
+
const deleteBtn = projectItem.querySelector('.project-item-delete');
|
| 91 |
+
deleteBtn.addEventListener('click', (e) => {
|
| 92 |
+
e.stopPropagation();
|
| 93 |
+
if (confirm(`Are you sure you want to delete "${project.name}"? This will remove all associated files and chat history.`)) {
|
| 94 |
+
deleteProject(project.project_id);
|
| 95 |
+
}
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
projectList.appendChild(projectItem);
|
| 99 |
+
});
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function selectProject(project) {
|
| 103 |
+
currentProject = project;
|
| 104 |
+
|
| 105 |
+
// Update UI
|
| 106 |
+
currentProjectName.textContent = project.name;
|
| 107 |
+
currentProjectDescription.textContent = project.description || 'No description';
|
| 108 |
+
|
| 109 |
+
// Show project content
|
| 110 |
+
projectHeader.style.display = 'flex';
|
| 111 |
+
welcomeScreen.style.display = 'none';
|
| 112 |
+
projectContent.style.display = 'block';
|
| 113 |
+
|
| 114 |
+
// Update project list
|
| 115 |
+
renderProjectList();
|
| 116 |
+
|
| 117 |
+
// Load chat history
|
| 118 |
+
loadChatHistory();
|
| 119 |
+
|
| 120 |
+
// Store current project in localStorage
|
| 121 |
+
localStorage.setItem('sb_current_project', JSON.stringify(project));
|
| 122 |
+
|
| 123 |
+
// Enable chat if user is authenticated
|
| 124 |
+
const user = window.__sb_get_user();
|
| 125 |
+
if (user) {
|
| 126 |
+
enableChat();
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
// Update page title to show project name
|
| 130 |
+
if (window.__sb_update_page_title) {
|
| 131 |
+
window.__sb_update_page_title(`Project: ${project.name}`);
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
function showWelcomeScreen() {
|
| 136 |
+
currentProject = null;
|
| 137 |
+
projectHeader.style.display = 'none';
|
| 138 |
+
welcomeScreen.style.display = 'flex';
|
| 139 |
+
projectContent.style.display = 'none';
|
| 140 |
+
localStorage.removeItem('sb_current_project');
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function showNewProjectModal() {
|
| 144 |
+
newProjectModal.classList.remove('hidden');
|
| 145 |
+
document.getElementById('project-name').focus();
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function hideNewProjectModal() {
|
| 149 |
+
newProjectModal.classList.add('hidden');
|
| 150 |
+
newProjectForm.reset();
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
async function handleCreateProject(e) {
|
| 154 |
+
e.preventDefault();
|
| 155 |
+
|
| 156 |
+
const user = window.__sb_get_user();
|
| 157 |
+
if (!user) {
|
| 158 |
+
alert('Please sign in to create a project');
|
| 159 |
+
return;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
const name = document.getElementById('project-name').value.trim();
|
| 163 |
+
const description = document.getElementById('project-description').value.trim();
|
| 164 |
+
|
| 165 |
+
if (!name) {
|
| 166 |
+
alert('Project name is required');
|
| 167 |
+
return;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
try {
|
| 171 |
+
const formData = new FormData();
|
| 172 |
+
formData.append('user_id', user.user_id);
|
| 173 |
+
formData.append('name', name);
|
| 174 |
+
formData.append('description', description);
|
| 175 |
+
|
| 176 |
+
const response = await fetch('/projects/create', { method: 'POST', body: formData });
|
| 177 |
+
|
| 178 |
+
if (response.ok) {
|
| 179 |
+
const project = await response.json();
|
| 180 |
+
projects.unshift(project);
|
| 181 |
+
renderProjectList();
|
| 182 |
+
selectProject(project);
|
| 183 |
+
hideNewProjectModal();
|
| 184 |
+
} else {
|
| 185 |
+
const error = await response.json();
|
| 186 |
+
alert(error.detail || 'Failed to create project');
|
| 187 |
+
}
|
| 188 |
+
} catch (error) {
|
| 189 |
+
alert('Failed to create project. Please try again.');
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
async function deleteProject(projectId) {
|
| 194 |
+
const user = window.__sb_get_user();
|
| 195 |
+
if (!user) return;
|
| 196 |
+
|
| 197 |
+
try {
|
| 198 |
+
const response = await fetch(`/projects/${projectId}?user_id=${user.user_id}`, {
|
| 199 |
+
method: 'DELETE'
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
if (response.ok) {
|
| 203 |
+
// Remove from local list
|
| 204 |
+
projects = projects.filter(p => p.project_id !== projectId);
|
| 205 |
+
|
| 206 |
+
// If this was the current project, clear it
|
| 207 |
+
if (currentProject && currentProject.project_id === projectId) {
|
| 208 |
+
currentProject = null;
|
| 209 |
+
if (projects.length > 0) {
|
| 210 |
+
selectProject(projects[0]);
|
| 211 |
+
} else {
|
| 212 |
+
showWelcomeScreen();
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
renderProjectList();
|
| 217 |
+
} else {
|
| 218 |
+
alert('Failed to delete project');
|
| 219 |
+
}
|
| 220 |
+
} catch (error) {
|
| 221 |
+
alert('Failed to delete project. Please try again.');
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
async function loadChatHistory() {
|
| 226 |
+
if (!currentProject) return;
|
| 227 |
+
|
| 228 |
+
const user = window.__sb_get_user();
|
| 229 |
+
if (!user) return;
|
| 230 |
+
|
| 231 |
+
try {
|
| 232 |
+
const response = await fetch(`/chat/history?user_id=${user.user_id}&project_id=${currentProject.project_id}`);
|
| 233 |
+
if (response.ok) {
|
| 234 |
+
const data = await response.json();
|
| 235 |
+
const messages = data.messages || [];
|
| 236 |
+
|
| 237 |
+
// Clear existing messages
|
| 238 |
+
const messagesContainer = document.getElementById('messages');
|
| 239 |
+
messagesContainer.innerHTML = '';
|
| 240 |
+
|
| 241 |
+
// Load chat history
|
| 242 |
+
messages.forEach(msg => {
|
| 243 |
+
appendMessage(msg.role, msg.content);
|
| 244 |
+
});
|
| 245 |
+
|
| 246 |
+
// Scroll to bottom
|
| 247 |
+
if (messages.length > 0) {
|
| 248 |
+
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
} catch (error) {
|
| 252 |
+
console.error('Failed to load chat history:', error);
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
function appendMessage(role, content) {
|
| 257 |
+
const messagesContainer = document.getElementById('messages');
|
| 258 |
+
const messageDiv = document.createElement('div');
|
| 259 |
+
messageDiv.className = `msg ${role}`;
|
| 260 |
+
messageDiv.textContent = content;
|
| 261 |
+
messagesContainer.appendChild(messageDiv);
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
function enableChat() {
|
| 265 |
+
const questionInput = document.getElementById('question');
|
| 266 |
+
const askBtn = document.getElementById('ask');
|
| 267 |
+
const chatHint = document.getElementById('chat-hint');
|
| 268 |
+
|
| 269 |
+
if (currentProject) {
|
| 270 |
+
questionInput.disabled = false;
|
| 271 |
+
askBtn.disabled = false;
|
| 272 |
+
chatHint.style.display = 'none';
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
// Public API
|
| 277 |
+
window.__sb_get_current_project = () => currentProject;
|
| 278 |
+
window.__sb_load_chat_history = loadChatHistory;
|
| 279 |
+
window.__sb_enable_chat = enableChat;
|
| 280 |
+
window.__sb_load_projects = loadProjects;
|
| 281 |
+
|
| 282 |
+
// Load current project from localStorage on page load
|
| 283 |
+
window.addEventListener('load', () => {
|
| 284 |
+
const savedProject = localStorage.getItem('sb_current_project');
|
| 285 |
+
if (savedProject) {
|
| 286 |
+
try {
|
| 287 |
+
const project = JSON.parse(savedProject);
|
| 288 |
+
// Check if project still exists in our list
|
| 289 |
+
const exists = projects.find(p => p.project_id === project.project_id);
|
| 290 |
+
if (exists) {
|
| 291 |
+
selectProject(exists);
|
| 292 |
+
}
|
| 293 |
+
} catch (e) {
|
| 294 |
+
localStorage.removeItem('sb_current_project');
|
| 295 |
+
}
|
| 296 |
+
}
|
| 297 |
+
});
|
| 298 |
+
})();
|
static/script.js
CHANGED
|
@@ -137,10 +137,13 @@
|
|
| 137 |
|
| 138 |
function updateUploadButton() {
|
| 139 |
const hasFiles = selectedFiles.length > 0;
|
| 140 |
-
|
|
|
|
| 141 |
|
| 142 |
-
if (hasFiles) {
|
| 143 |
uploadBtn.querySelector('.btn-text').textContent = `Upload ${selectedFiles.length} Document${selectedFiles.length > 1 ? 's' : ''}`;
|
|
|
|
|
|
|
| 144 |
} else {
|
| 145 |
uploadBtn.querySelector('.btn-text').textContent = 'Upload Documents';
|
| 146 |
}
|
|
@@ -169,6 +172,12 @@
|
|
| 169 |
return;
|
| 170 |
}
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
isUploading = true;
|
| 173 |
updateUploadButton();
|
| 174 |
showUploadProgress();
|
|
@@ -176,6 +185,7 @@
|
|
| 176 |
try {
|
| 177 |
const formData = new FormData();
|
| 178 |
formData.append('user_id', user.user_id);
|
|
|
|
| 179 |
selectedFiles.forEach(file => formData.append('files', file));
|
| 180 |
|
| 181 |
const response = await fetch('/upload', { method: 'POST', body: formData });
|
|
@@ -266,10 +276,19 @@
|
|
| 266 |
return;
|
| 267 |
}
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
// Add user message
|
| 270 |
appendMessage('user', question);
|
| 271 |
questionInput.value = '';
|
| 272 |
|
|
|
|
|
|
|
|
|
|
| 273 |
// Add thinking message
|
| 274 |
const thinkingMsg = appendMessage('thinking', 'Thinking...');
|
| 275 |
|
|
@@ -281,6 +300,7 @@
|
|
| 281 |
try {
|
| 282 |
const formData = new FormData();
|
| 283 |
formData.append('user_id', user.user_id);
|
|
|
|
| 284 |
formData.append('question', question);
|
| 285 |
formData.append('k', '6');
|
| 286 |
|
|
@@ -294,6 +314,9 @@
|
|
| 294 |
// Add assistant response
|
| 295 |
appendMessage('assistant', data.answer || 'No answer received');
|
| 296 |
|
|
|
|
|
|
|
|
|
|
| 297 |
// Add sources if available
|
| 298 |
if (data.sources && data.sources.length > 0) {
|
| 299 |
appendSources(data.sources);
|
|
@@ -303,7 +326,9 @@
|
|
| 303 |
}
|
| 304 |
} catch (error) {
|
| 305 |
thinkingMsg.remove();
|
| 306 |
-
|
|
|
|
|
|
|
| 307 |
} finally {
|
| 308 |
// Re-enable input
|
| 309 |
questionInput.disabled = false;
|
|
@@ -313,6 +338,21 @@
|
|
| 313 |
}
|
| 314 |
}
|
| 315 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
function appendMessage(role, text) {
|
| 317 |
const messageDiv = document.createElement('div');
|
| 318 |
messageDiv.className = `msg ${role}`;
|
|
@@ -376,10 +416,23 @@
|
|
| 376 |
function checkUserAuth() {
|
| 377 |
const user = window.__sb_get_user();
|
| 378 |
if (user) {
|
| 379 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
}
|
| 381 |
}
|
| 382 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
// Reveal animations
|
| 384 |
const observer = new IntersectionObserver((entries) => {
|
| 385 |
entries.forEach(entry => {
|
|
|
|
| 137 |
|
| 138 |
function updateUploadButton() {
|
| 139 |
const hasFiles = selectedFiles.length > 0;
|
| 140 |
+
const hasProject = window.__sb_get_current_project && window.__sb_get_current_project();
|
| 141 |
+
uploadBtn.disabled = !hasFiles || !hasProject || isUploading;
|
| 142 |
|
| 143 |
+
if (hasFiles && hasProject) {
|
| 144 |
uploadBtn.querySelector('.btn-text').textContent = `Upload ${selectedFiles.length} Document${selectedFiles.length > 1 ? 's' : ''}`;
|
| 145 |
+
} else if (!hasProject) {
|
| 146 |
+
uploadBtn.querySelector('.btn-text').textContent = 'Select a Project First';
|
| 147 |
} else {
|
| 148 |
uploadBtn.querySelector('.btn-text').textContent = 'Upload Documents';
|
| 149 |
}
|
|
|
|
| 172 |
return;
|
| 173 |
}
|
| 174 |
|
| 175 |
+
const currentProject = window.__sb_get_current_project && window.__sb_get_current_project();
|
| 176 |
+
if (!currentProject) {
|
| 177 |
+
alert('Please select a project first');
|
| 178 |
+
return;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
isUploading = true;
|
| 182 |
updateUploadButton();
|
| 183 |
showUploadProgress();
|
|
|
|
| 185 |
try {
|
| 186 |
const formData = new FormData();
|
| 187 |
formData.append('user_id', user.user_id);
|
| 188 |
+
formData.append('project_id', currentProject.project_id);
|
| 189 |
selectedFiles.forEach(file => formData.append('files', file));
|
| 190 |
|
| 191 |
const response = await fetch('/upload', { method: 'POST', body: formData });
|
|
|
|
| 276 |
return;
|
| 277 |
}
|
| 278 |
|
| 279 |
+
const currentProject = window.__sb_get_current_project && window.__sb_get_current_project();
|
| 280 |
+
if (!currentProject) {
|
| 281 |
+
alert('Please select a project first');
|
| 282 |
+
return;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
// Add user message
|
| 286 |
appendMessage('user', question);
|
| 287 |
questionInput.value = '';
|
| 288 |
|
| 289 |
+
// Save user message to chat history
|
| 290 |
+
await saveChatMessage(user.user_id, currentProject.project_id, 'user', question);
|
| 291 |
+
|
| 292 |
// Add thinking message
|
| 293 |
const thinkingMsg = appendMessage('thinking', 'Thinking...');
|
| 294 |
|
|
|
|
| 300 |
try {
|
| 301 |
const formData = new FormData();
|
| 302 |
formData.append('user_id', user.user_id);
|
| 303 |
+
formData.append('project_id', currentProject.project_id);
|
| 304 |
formData.append('question', question);
|
| 305 |
formData.append('k', '6');
|
| 306 |
|
|
|
|
| 314 |
// Add assistant response
|
| 315 |
appendMessage('assistant', data.answer || 'No answer received');
|
| 316 |
|
| 317 |
+
// Save assistant message to chat history
|
| 318 |
+
await saveChatMessage(user.user_id, currentProject.project_id, 'assistant', data.answer || 'No answer received');
|
| 319 |
+
|
| 320 |
// Add sources if available
|
| 321 |
if (data.sources && data.sources.length > 0) {
|
| 322 |
appendSources(data.sources);
|
|
|
|
| 326 |
}
|
| 327 |
} catch (error) {
|
| 328 |
thinkingMsg.remove();
|
| 329 |
+
const errorMsg = `⚠️ Error: ${error.message}`;
|
| 330 |
+
appendMessage('assistant', errorMsg);
|
| 331 |
+
await saveChatMessage(user.user_id, currentProject.project_id, 'assistant', errorMsg);
|
| 332 |
} finally {
|
| 333 |
// Re-enable input
|
| 334 |
questionInput.disabled = false;
|
|
|
|
| 338 |
}
|
| 339 |
}
|
| 340 |
|
| 341 |
+
async function saveChatMessage(userId, projectId, role, content) {
|
| 342 |
+
try {
|
| 343 |
+
const formData = new FormData();
|
| 344 |
+
formData.append('user_id', userId);
|
| 345 |
+
formData.append('project_id', projectId);
|
| 346 |
+
formData.append('role', role);
|
| 347 |
+
formData.append('content', content);
|
| 348 |
+
formData.append('timestamp', Date.now() / 1000);
|
| 349 |
+
|
| 350 |
+
await fetch('/chat/save', { method: 'POST', body: formData });
|
| 351 |
+
} catch (error) {
|
| 352 |
+
console.error('Failed to save chat message:', error);
|
| 353 |
+
}
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
function appendMessage(role, text) {
|
| 357 |
const messageDiv = document.createElement('div');
|
| 358 |
messageDiv.className = `msg ${role}`;
|
|
|
|
| 416 |
function checkUserAuth() {
|
| 417 |
const user = window.__sb_get_user();
|
| 418 |
if (user) {
|
| 419 |
+
// Check if we have a current project
|
| 420 |
+
const currentProject = window.__sb_get_current_project && window.__sb_get_current_project();
|
| 421 |
+
if (currentProject) {
|
| 422 |
+
enableChat();
|
| 423 |
+
}
|
| 424 |
}
|
| 425 |
}
|
| 426 |
|
| 427 |
+
// Listen for project changes
|
| 428 |
+
window.addEventListener('projectChanged', () => {
|
| 429 |
+
updateUploadButton();
|
| 430 |
+
const currentProject = window.__sb_get_current_project && window.__sb_get_current_project();
|
| 431 |
+
if (currentProject) {
|
| 432 |
+
enableChat();
|
| 433 |
+
}
|
| 434 |
+
});
|
| 435 |
+
|
| 436 |
// Reveal animations
|
| 437 |
const observer = new IntersectionObserver((entries) => {
|
| 438 |
entries.forEach(entry => {
|
static/sidebar.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ────────────────────────────── static/sidebar.js ──────────────────────────────
|
| 2 |
+
(function() {
|
| 3 |
+
// DOM elements
|
| 4 |
+
const sidebar = document.getElementById('sidebar');
|
| 5 |
+
const sidebarToggle = document.getElementById('sidebar-toggle');
|
| 6 |
+
const mainContent = document.querySelector('.main-content');
|
| 7 |
+
const pageTitle = document.getElementById('page-title');
|
| 8 |
+
const menuItems = document.querySelectorAll('.menu-item');
|
| 9 |
+
|
| 10 |
+
// State
|
| 11 |
+
let isSidebarOpen = true;
|
| 12 |
+
let currentSection = 'projects';
|
| 13 |
+
|
| 14 |
+
// Initialize
|
| 15 |
+
init();
|
| 16 |
+
|
| 17 |
+
function init() {
|
| 18 |
+
setupEventListeners();
|
| 19 |
+
updatePageTitle();
|
| 20 |
+
|
| 21 |
+
// Check if we should start with collapsed sidebar on mobile
|
| 22 |
+
if (window.innerWidth <= 1024) {
|
| 23 |
+
collapseSidebar();
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function setupEventListeners() {
|
| 28 |
+
// Sidebar toggle
|
| 29 |
+
sidebarToggle.addEventListener('click', toggleSidebar);
|
| 30 |
+
|
| 31 |
+
// Menu navigation
|
| 32 |
+
menuItems.forEach(item => {
|
| 33 |
+
item.addEventListener('click', (e) => {
|
| 34 |
+
e.preventDefault();
|
| 35 |
+
const section = item.dataset.section;
|
| 36 |
+
navigateToSection(section);
|
| 37 |
+
});
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
// Close sidebar when clicking outside on mobile
|
| 41 |
+
document.addEventListener('click', (e) => {
|
| 42 |
+
if (window.innerWidth <= 1024 && isSidebarOpen) {
|
| 43 |
+
if (!sidebar.contains(e.target) && !sidebarToggle.contains(e.target)) {
|
| 44 |
+
collapseSidebar();
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
// Handle window resize
|
| 50 |
+
window.addEventListener('resize', handleResize);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function toggleSidebar() {
|
| 54 |
+
if (isSidebarOpen) {
|
| 55 |
+
collapseSidebar();
|
| 56 |
+
} else {
|
| 57 |
+
expandSidebar();
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function expandSidebar() {
|
| 62 |
+
sidebar.classList.remove('collapsed');
|
| 63 |
+
mainContent.classList.remove('sidebar-collapsed');
|
| 64 |
+
isSidebarOpen = true;
|
| 65 |
+
|
| 66 |
+
// Update hamburger icon to close icon
|
| 67 |
+
updateHamburgerIcon();
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
function collapseSidebar() {
|
| 71 |
+
sidebar.classList.add('collapsed');
|
| 72 |
+
mainContent.classList.add('sidebar-collapsed');
|
| 73 |
+
isSidebarOpen = false;
|
| 74 |
+
|
| 75 |
+
// Update hamburger icon to menu icon
|
| 76 |
+
updateHamburgerIcon();
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
function updateHamburgerIcon() {
|
| 80 |
+
const svg = sidebarToggle.querySelector('svg');
|
| 81 |
+
if (isSidebarOpen) {
|
| 82 |
+
// Show close icon (X)
|
| 83 |
+
svg.innerHTML = `
|
| 84 |
+
<line x1="18" y1="6" x2="6" y2="18"/>
|
| 85 |
+
<line x1="6" y1="6" x2="18" y2="18"/>
|
| 86 |
+
`;
|
| 87 |
+
} else {
|
| 88 |
+
// Show hamburger icon (3 lines)
|
| 89 |
+
svg.innerHTML = `
|
| 90 |
+
<line x1="3" y1="6" x2="21" y2="6"/>
|
| 91 |
+
<line x1="3" y1="12" x2="21" y2="12"/>
|
| 92 |
+
<line x1="3" y1="18" x2="21" y2="18"/>
|
| 93 |
+
`;
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
function navigateToSection(section) {
|
| 98 |
+
// Update active menu item
|
| 99 |
+
menuItems.forEach(item => {
|
| 100 |
+
item.classList.remove('active');
|
| 101 |
+
if (item.dataset.section === section) {
|
| 102 |
+
item.classList.add('active');
|
| 103 |
+
}
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
currentSection = section;
|
| 107 |
+
updatePageTitle();
|
| 108 |
+
|
| 109 |
+
// Handle section-specific actions
|
| 110 |
+
switch (section) {
|
| 111 |
+
case 'projects':
|
| 112 |
+
// Projects section is always visible, no action needed
|
| 113 |
+
break;
|
| 114 |
+
case 'files':
|
| 115 |
+
// Could show file browser or file management interface
|
| 116 |
+
console.log('Navigate to Files section');
|
| 117 |
+
break;
|
| 118 |
+
case 'chat':
|
| 119 |
+
// Could show chat history or chat interface
|
| 120 |
+
console.log('Navigate to Chat section');
|
| 121 |
+
break;
|
| 122 |
+
case 'analytics':
|
| 123 |
+
// Could show usage analytics or insights
|
| 124 |
+
console.log('Navigate to Analytics section');
|
| 125 |
+
break;
|
| 126 |
+
case 'settings':
|
| 127 |
+
// Could show user settings or preferences
|
| 128 |
+
console.log('Navigate to Settings section');
|
| 129 |
+
break;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
// Close sidebar on mobile after navigation
|
| 133 |
+
if (window.innerWidth <= 1024) {
|
| 134 |
+
collapseSidebar();
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
function updatePageTitle() {
|
| 139 |
+
const titles = {
|
| 140 |
+
'projects': 'Projects',
|
| 141 |
+
'files': 'Files',
|
| 142 |
+
'chat': 'Chat',
|
| 143 |
+
'analytics': 'Analytics',
|
| 144 |
+
'settings': 'Settings'
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
pageTitle.textContent = titles[currentSection] || 'StudyBuddy';
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
function setPageTitle(title) {
|
| 151 |
+
pageTitle.textContent = title;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
function handleResize() {
|
| 155 |
+
if (window.innerWidth <= 1024) {
|
| 156 |
+
// On mobile/tablet, start with collapsed sidebar
|
| 157 |
+
if (!sidebar.classList.contains('collapsed')) {
|
| 158 |
+
collapseSidebar();
|
| 159 |
+
}
|
| 160 |
+
} else {
|
| 161 |
+
// On desktop, ensure sidebar is visible
|
| 162 |
+
if (sidebar.classList.contains('collapsed')) {
|
| 163 |
+
expandSidebar();
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
// Public API
|
| 169 |
+
window.__sb_toggle_sidebar = toggleSidebar;
|
| 170 |
+
window.__sb_collapse_sidebar = collapseSidebar;
|
| 171 |
+
window.__sb_expand_sidebar = expandSidebar;
|
| 172 |
+
window.__sb_navigate_to_section = navigateToSection;
|
| 173 |
+
window.__sb_update_page_title = setPageTitle;
|
| 174 |
+
})();
|
static/styles.css
CHANGED
|
@@ -23,6 +23,7 @@
|
|
| 23 |
--radius: 12px;
|
| 24 |
--radius-lg: 16px;
|
| 25 |
--radius-xl: 20px;
|
|
|
|
| 26 |
}
|
| 27 |
|
| 28 |
:root.light {
|
|
@@ -57,22 +58,37 @@ body {
|
|
| 57 |
background: var(--bg);
|
| 58 |
line-height: 1.6;
|
| 59 |
transition: background-color 0.3s ease, color 0.3s ease;
|
|
|
|
| 60 |
}
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
padding: 24px;
|
| 66 |
min-height: 100vh;
|
| 67 |
}
|
| 68 |
|
| 69 |
-
/*
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
| 71 |
display: flex;
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
border-bottom: 1px solid var(--border);
|
| 77 |
}
|
| 78 |
|
|
@@ -83,7 +99,7 @@ header {
|
|
| 83 |
}
|
| 84 |
|
| 85 |
.logo-icon {
|
| 86 |
-
font-size:
|
| 87 |
background: var(--gradient-primary);
|
| 88 |
-webkit-background-clip: text;
|
| 89 |
-webkit-text-fill-color: transparent;
|
|
@@ -91,7 +107,7 @@ header {
|
|
| 91 |
}
|
| 92 |
|
| 93 |
.logo-text h1 {
|
| 94 |
-
font-size:
|
| 95 |
font-weight: 800;
|
| 96 |
background: var(--gradient-primary);
|
| 97 |
-webkit-background-clip: text;
|
|
@@ -102,33 +118,321 @@ header {
|
|
| 102 |
|
| 103 |
.logo-text p {
|
| 104 |
color: var(--muted);
|
| 105 |
-
font-size:
|
| 106 |
-
margin:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
}
|
| 108 |
|
| 109 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
display: flex;
|
|
|
|
| 111 |
align-items: center;
|
| 112 |
-
|
| 113 |
}
|
| 114 |
|
| 115 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
display: flex;
|
| 117 |
align-items: center;
|
| 118 |
gap: 12px;
|
| 119 |
-
padding:
|
| 120 |
-
background:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
border-radius: var(--radius);
|
| 122 |
border: 1px solid var(--border);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
}
|
| 124 |
|
| 125 |
.user-avatar {
|
| 126 |
-
font-size:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
}
|
| 128 |
|
| 129 |
.user-email {
|
| 130 |
-
|
|
|
|
| 131 |
font-size: 14px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
}
|
| 133 |
|
| 134 |
/* Buttons */
|
|
@@ -661,7 +965,8 @@ header {
|
|
| 661 |
font-size: 14px;
|
| 662 |
}
|
| 663 |
|
| 664 |
-
.form-group input
|
|
|
|
| 665 |
width: 100%;
|
| 666 |
padding: 14px 16px;
|
| 667 |
border-radius: var(--radius);
|
|
@@ -670,14 +975,28 @@ header {
|
|
| 670 |
color: var(--text);
|
| 671 |
font-size: 16px;
|
| 672 |
transition: all 0.2s ease;
|
|
|
|
| 673 |
}
|
| 674 |
|
| 675 |
-
.form-group input:focus
|
|
|
|
| 676 |
outline: none;
|
| 677 |
border-color: var(--accent);
|
| 678 |
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
| 679 |
}
|
| 680 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 681 |
.modal-footer {
|
| 682 |
padding: 24px 32px;
|
| 683 |
text-align: center;
|
|
@@ -760,26 +1079,32 @@ header {
|
|
| 760 |
transform: none;
|
| 761 |
}
|
| 762 |
|
| 763 |
-
/* Footer */
|
| 764 |
-
footer {
|
| 765 |
-
text-align: center;
|
| 766 |
-
color: var(--muted);
|
| 767 |
-
margin-top: 48px;
|
| 768 |
-
padding: 24px 0;
|
| 769 |
-
border-top: 1px solid var(--border);
|
| 770 |
-
font-size: 14px;
|
| 771 |
-
}
|
| 772 |
-
|
| 773 |
/* Responsive */
|
| 774 |
-
@media (max-width:
|
| 775 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 776 |
padding: 16px;
|
| 777 |
}
|
| 778 |
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 783 |
}
|
| 784 |
|
| 785 |
.card {
|
|
@@ -794,6 +1119,45 @@ footer {
|
|
| 794 |
.chat-input-wrapper {
|
| 795 |
flex-direction: column;
|
| 796 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 797 |
}
|
| 798 |
|
| 799 |
/* Reduced motion */
|
|
|
|
| 23 |
--radius: 12px;
|
| 24 |
--radius-lg: 16px;
|
| 25 |
--radius-xl: 20px;
|
| 26 |
+
--sidebar-width: 280px;
|
| 27 |
}
|
| 28 |
|
| 29 |
:root.light {
|
|
|
|
| 58 |
background: var(--bg);
|
| 59 |
line-height: 1.6;
|
| 60 |
transition: background-color 0.3s ease, color 0.3s ease;
|
| 61 |
+
overflow-x: hidden;
|
| 62 |
}
|
| 63 |
|
| 64 |
+
/* App Layout */
|
| 65 |
+
.app-container {
|
| 66 |
+
display: flex;
|
|
|
|
| 67 |
min-height: 100vh;
|
| 68 |
}
|
| 69 |
|
| 70 |
+
/* Sidebar */
|
| 71 |
+
.sidebar {
|
| 72 |
+
width: var(--sidebar-width);
|
| 73 |
+
background: var(--card);
|
| 74 |
+
border-right: 1px solid var(--border);
|
| 75 |
display: flex;
|
| 76 |
+
flex-direction: column;
|
| 77 |
+
position: fixed;
|
| 78 |
+
left: 0;
|
| 79 |
+
top: 0;
|
| 80 |
+
bottom: 0;
|
| 81 |
+
z-index: 100;
|
| 82 |
+
transition: transform 0.3s ease;
|
| 83 |
+
overflow-y: auto;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.sidebar.collapsed {
|
| 87 |
+
transform: translateX(-100%);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.sidebar-header {
|
| 91 |
+
padding: 24px;
|
| 92 |
border-bottom: 1px solid var(--border);
|
| 93 |
}
|
| 94 |
|
|
|
|
| 99 |
}
|
| 100 |
|
| 101 |
.logo-icon {
|
| 102 |
+
font-size: 32px;
|
| 103 |
background: var(--gradient-primary);
|
| 104 |
-webkit-background-clip: text;
|
| 105 |
-webkit-text-fill-color: transparent;
|
|
|
|
| 107 |
}
|
| 108 |
|
| 109 |
.logo-text h1 {
|
| 110 |
+
font-size: 20px;
|
| 111 |
font-weight: 800;
|
| 112 |
background: var(--gradient-primary);
|
| 113 |
-webkit-background-clip: text;
|
|
|
|
| 118 |
|
| 119 |
.logo-text p {
|
| 120 |
color: var(--muted);
|
| 121 |
+
font-size: 12px;
|
| 122 |
+
margin: 2px 0 0 0;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.sidebar-section {
|
| 126 |
+
padding: 20px 24px;
|
| 127 |
+
border-bottom: 1px solid var(--border);
|
| 128 |
}
|
| 129 |
|
| 130 |
+
.sidebar-section:last-child {
|
| 131 |
+
border-bottom: none;
|
| 132 |
+
margin-top: auto;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.section-header {
|
| 136 |
display: flex;
|
| 137 |
+
justify-content: space-between;
|
| 138 |
align-items: center;
|
| 139 |
+
margin-bottom: 16px;
|
| 140 |
}
|
| 141 |
|
| 142 |
+
.section-header h3 {
|
| 143 |
+
font-size: 14px;
|
| 144 |
+
font-weight: 600;
|
| 145 |
+
color: var(--text-secondary);
|
| 146 |
+
text-transform: uppercase;
|
| 147 |
+
letter-spacing: 0.5px;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/* Website Menu */
|
| 151 |
+
.website-menu {
|
| 152 |
+
display: flex;
|
| 153 |
+
flex-direction: column;
|
| 154 |
+
gap: 4px;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.menu-item {
|
| 158 |
display: flex;
|
| 159 |
align-items: center;
|
| 160 |
gap: 12px;
|
| 161 |
+
padding: 12px 16px;
|
| 162 |
+
background: transparent;
|
| 163 |
+
border-radius: var(--radius);
|
| 164 |
+
border: 1px solid transparent;
|
| 165 |
+
cursor: pointer;
|
| 166 |
+
transition: all 0.2s ease;
|
| 167 |
+
text-decoration: none;
|
| 168 |
+
color: var(--text-secondary);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.menu-item:hover {
|
| 172 |
+
background: var(--bg-secondary);
|
| 173 |
+
border-color: var(--border);
|
| 174 |
+
color: var(--text);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.menu-item.active {
|
| 178 |
+
background: var(--gradient-accent);
|
| 179 |
+
border-color: transparent;
|
| 180 |
+
color: white;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.menu-icon {
|
| 184 |
+
width: 20px;
|
| 185 |
+
height: 20px;
|
| 186 |
+
flex-shrink: 0;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.menu-item span {
|
| 190 |
+
font-weight: 500;
|
| 191 |
+
font-size: 14px;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
/* Project List */
|
| 195 |
+
.project-list {
|
| 196 |
+
display: flex;
|
| 197 |
+
flex-direction: column;
|
| 198 |
+
gap: 8px;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.project-item {
|
| 202 |
+
display: flex;
|
| 203 |
+
align-items: center;
|
| 204 |
+
gap: 12px;
|
| 205 |
+
padding: 12px 16px;
|
| 206 |
+
background: var(--bg-secondary);
|
| 207 |
border-radius: var(--radius);
|
| 208 |
border: 1px solid var(--border);
|
| 209 |
+
cursor: pointer;
|
| 210 |
+
transition: all 0.2s ease;
|
| 211 |
+
position: relative;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.project-item:hover {
|
| 215 |
+
background: var(--card-hover);
|
| 216 |
+
border-color: var(--border-light);
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.project-item.active {
|
| 220 |
+
background: var(--gradient-accent);
|
| 221 |
+
border-color: transparent;
|
| 222 |
+
color: white;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.project-item-icon {
|
| 226 |
+
font-size: 16px;
|
| 227 |
+
opacity: 0.7;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.project-item-info {
|
| 231 |
+
flex: 1;
|
| 232 |
+
min-width: 0;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.project-item-name {
|
| 236 |
+
font-weight: 500;
|
| 237 |
+
font-size: 14px;
|
| 238 |
+
margin-bottom: 2px;
|
| 239 |
+
white-space: nowrap;
|
| 240 |
+
overflow: hidden;
|
| 241 |
+
text-overflow: ellipsis;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.project-item-description {
|
| 245 |
+
font-size: 12px;
|
| 246 |
+
color: var(--muted);
|
| 247 |
+
white-space: nowrap;
|
| 248 |
+
overflow: hidden;
|
| 249 |
+
text-overflow: ellipsis;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
.project-item.active .project-item-description {
|
| 253 |
+
color: rgba(255, 255, 255, 0.8);
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
.project-item-actions {
|
| 257 |
+
opacity: 0;
|
| 258 |
+
transition: opacity 0.2s ease;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
.project-item:hover .project-item-actions {
|
| 262 |
+
opacity: 1;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
.project-item-delete {
|
| 266 |
+
background: none;
|
| 267 |
+
border: none;
|
| 268 |
+
color: var(--error);
|
| 269 |
+
cursor: pointer;
|
| 270 |
+
padding: 4px;
|
| 271 |
+
border-radius: 4px;
|
| 272 |
+
font-size: 14px;
|
| 273 |
+
transition: background 0.2s ease;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.project-item-delete:hover {
|
| 277 |
+
background: rgba(239, 68, 68, 0.1);
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
/* User Info */
|
| 281 |
+
.user-info {
|
| 282 |
+
display: flex;
|
| 283 |
+
align-items: center;
|
| 284 |
+
gap: 12px;
|
| 285 |
+
margin-bottom: 16px;
|
| 286 |
}
|
| 287 |
|
| 288 |
.user-avatar {
|
| 289 |
+
font-size: 24px;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.user-details {
|
| 293 |
+
flex: 1;
|
| 294 |
+
min-width: 0;
|
| 295 |
}
|
| 296 |
|
| 297 |
.user-email {
|
| 298 |
+
display: block;
|
| 299 |
+
color: var(--text);
|
| 300 |
font-size: 14px;
|
| 301 |
+
font-weight: 500;
|
| 302 |
+
margin-bottom: 8px;
|
| 303 |
+
white-space: nowrap;
|
| 304 |
+
overflow: hidden;
|
| 305 |
+
text-overflow: ellipsis;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
.theme-toggle-wrapper {
|
| 309 |
+
display: flex;
|
| 310 |
+
justify-content: center;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
/* Top Bar */
|
| 314 |
+
.top-bar {
|
| 315 |
+
display: flex;
|
| 316 |
+
align-items: center;
|
| 317 |
+
gap: 16px;
|
| 318 |
+
padding: 16px 24px;
|
| 319 |
+
background: var(--card);
|
| 320 |
+
border-bottom: 1px solid var(--border);
|
| 321 |
+
position: sticky;
|
| 322 |
+
top: 0;
|
| 323 |
+
z-index: 50;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
.hamburger-menu {
|
| 327 |
+
background: none;
|
| 328 |
+
border: none;
|
| 329 |
+
color: var(--text-secondary);
|
| 330 |
+
cursor: pointer;
|
| 331 |
+
padding: 8px;
|
| 332 |
+
border-radius: var(--radius);
|
| 333 |
+
transition: all 0.2s ease;
|
| 334 |
+
display: flex;
|
| 335 |
+
align-items: center;
|
| 336 |
+
justify-content: center;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
.hamburger-menu:hover {
|
| 340 |
+
background: var(--bg-secondary);
|
| 341 |
+
color: var(--text);
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
.hamburger-menu svg {
|
| 345 |
+
width: 24px;
|
| 346 |
+
height: 24px;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
.top-bar-title h2 {
|
| 350 |
+
font-size: 20px;
|
| 351 |
+
font-weight: 600;
|
| 352 |
+
color: var(--text);
|
| 353 |
+
margin: 0;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
/* Main Content */
|
| 357 |
+
.main-content {
|
| 358 |
+
flex: 1;
|
| 359 |
+
margin-left: var(--sidebar-width);
|
| 360 |
+
min-height: 100vh;
|
| 361 |
+
background: var(--bg);
|
| 362 |
+
transition: margin-left 0.3s ease;
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
.main-content.sidebar-collapsed {
|
| 366 |
+
margin-left: 0;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
.content-container {
|
| 370 |
+
max-width: 1200px;
|
| 371 |
+
margin: 0 auto;
|
| 372 |
+
padding: 24px;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
/* Project Header */
|
| 376 |
+
.project-header {
|
| 377 |
+
display: flex;
|
| 378 |
+
justify-content: space-between;
|
| 379 |
+
align-items: flex-start;
|
| 380 |
+
margin-bottom: 32px;
|
| 381 |
+
padding: 24px;
|
| 382 |
+
background: var(--card);
|
| 383 |
+
border-radius: var(--radius-xl);
|
| 384 |
+
border: 1px solid var(--border);
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
.project-info h2 {
|
| 388 |
+
font-size: 28px;
|
| 389 |
+
font-weight: 700;
|
| 390 |
+
margin-bottom: 8px;
|
| 391 |
+
background: var(--gradient-primary);
|
| 392 |
+
-webkit-background-clip: text;
|
| 393 |
+
-webkit-text-fill-color: transparent;
|
| 394 |
+
background-clip: text;
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
.project-info p {
|
| 398 |
+
color: var(--muted);
|
| 399 |
+
font-size: 16px;
|
| 400 |
+
margin: 0;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
/* Welcome Screen */
|
| 404 |
+
.welcome-screen {
|
| 405 |
+
display: flex;
|
| 406 |
+
align-items: center;
|
| 407 |
+
justify-content: center;
|
| 408 |
+
min-height: 60vh;
|
| 409 |
+
text-align: center;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
.welcome-content {
|
| 413 |
+
max-width: 480px;
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
.welcome-icon {
|
| 417 |
+
font-size: 64px;
|
| 418 |
+
margin-bottom: 24px;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
.welcome-content h2 {
|
| 422 |
+
font-size: 32px;
|
| 423 |
+
font-weight: 700;
|
| 424 |
+
margin-bottom: 16px;
|
| 425 |
+
background: var(--gradient-primary);
|
| 426 |
+
-webkit-background-clip: text;
|
| 427 |
+
-webkit-text-fill-color: transparent;
|
| 428 |
+
background-clip: text;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
.welcome-content p {
|
| 432 |
+
color: var(--muted);
|
| 433 |
+
font-size: 18px;
|
| 434 |
+
margin-bottom: 32px;
|
| 435 |
+
line-height: 1.6;
|
| 436 |
}
|
| 437 |
|
| 438 |
/* Buttons */
|
|
|
|
| 965 |
font-size: 14px;
|
| 966 |
}
|
| 967 |
|
| 968 |
+
.form-group input,
|
| 969 |
+
.form-group textarea {
|
| 970 |
width: 100%;
|
| 971 |
padding: 14px 16px;
|
| 972 |
border-radius: var(--radius);
|
|
|
|
| 975 |
color: var(--text);
|
| 976 |
font-size: 16px;
|
| 977 |
transition: all 0.2s ease;
|
| 978 |
+
font-family: inherit;
|
| 979 |
}
|
| 980 |
|
| 981 |
+
.form-group input:focus,
|
| 982 |
+
.form-group textarea:focus {
|
| 983 |
outline: none;
|
| 984 |
border-color: var(--accent);
|
| 985 |
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
| 986 |
}
|
| 987 |
|
| 988 |
+
.form-group textarea {
|
| 989 |
+
resize: vertical;
|
| 990 |
+
min-height: 80px;
|
| 991 |
+
}
|
| 992 |
+
|
| 993 |
+
.form-actions {
|
| 994 |
+
display: flex;
|
| 995 |
+
gap: 12px;
|
| 996 |
+
justify-content: flex-end;
|
| 997 |
+
margin-top: 24px;
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
.modal-footer {
|
| 1001 |
padding: 24px 32px;
|
| 1002 |
text-align: center;
|
|
|
|
| 1079 |
transform: none;
|
| 1080 |
}
|
| 1081 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1082 |
/* Responsive */
|
| 1083 |
+
@media (max-width: 1024px) {
|
| 1084 |
+
.sidebar {
|
| 1085 |
+
transform: translateX(-100%);
|
| 1086 |
+
}
|
| 1087 |
+
|
| 1088 |
+
.sidebar.open {
|
| 1089 |
+
transform: translateX(0);
|
| 1090 |
+
}
|
| 1091 |
+
|
| 1092 |
+
.main-content {
|
| 1093 |
+
margin-left: 0;
|
| 1094 |
+
}
|
| 1095 |
+
|
| 1096 |
+
.content-container {
|
| 1097 |
padding: 16px;
|
| 1098 |
}
|
| 1099 |
|
| 1100 |
+
.top-bar {
|
| 1101 |
+
padding: 16px;
|
| 1102 |
+
}
|
| 1103 |
+
}
|
| 1104 |
+
|
| 1105 |
+
@media (max-width: 768px) {
|
| 1106 |
+
:root {
|
| 1107 |
+
--sidebar-width: 100vw;
|
| 1108 |
}
|
| 1109 |
|
| 1110 |
.card {
|
|
|
|
| 1119 |
.chat-input-wrapper {
|
| 1120 |
flex-direction: column;
|
| 1121 |
}
|
| 1122 |
+
|
| 1123 |
+
.project-header {
|
| 1124 |
+
flex-direction: column;
|
| 1125 |
+
gap: 16px;
|
| 1126 |
+
align-items: flex-start;
|
| 1127 |
+
}
|
| 1128 |
+
|
| 1129 |
+
.form-actions {
|
| 1130 |
+
flex-direction: column;
|
| 1131 |
+
}
|
| 1132 |
+
|
| 1133 |
+
.top-bar {
|
| 1134 |
+
padding: 12px 16px;
|
| 1135 |
+
}
|
| 1136 |
+
|
| 1137 |
+
.top-bar-title h2 {
|
| 1138 |
+
font-size: 18px;
|
| 1139 |
+
}
|
| 1140 |
+
|
| 1141 |
+
.hamburger-menu {
|
| 1142 |
+
padding: 6px;
|
| 1143 |
+
}
|
| 1144 |
+
|
| 1145 |
+
.hamburger-menu svg {
|
| 1146 |
+
width: 20px;
|
| 1147 |
+
height: 20px;
|
| 1148 |
+
}
|
| 1149 |
+
|
| 1150 |
+
.sidebar {
|
| 1151 |
+
width: 100vw;
|
| 1152 |
+
}
|
| 1153 |
+
|
| 1154 |
+
.sidebar-header {
|
| 1155 |
+
padding: 20px;
|
| 1156 |
+
}
|
| 1157 |
+
|
| 1158 |
+
.sidebar-section {
|
| 1159 |
+
padding: 16px 20px;
|
| 1160 |
+
}
|
| 1161 |
}
|
| 1162 |
|
| 1163 |
/* Reduced motion */
|
utils/chunker.py
CHANGED
|
@@ -32,7 +32,7 @@ def _by_headings(text: str):
|
|
| 32 |
return parts
|
| 33 |
|
| 34 |
|
| 35 |
-
def build_cards_from_pages(pages: List[Dict[str, Any]], filename: str, user_id: str) -> List[Dict[str, Any]]:
|
| 36 |
# Concatenate pages but keep page spans for metadata
|
| 37 |
full = ""
|
| 38 |
page_markers = []
|
|
@@ -74,6 +74,7 @@ def build_cards_from_pages(pages: List[Dict[str, Any]], filename: str, user_id:
|
|
| 74 |
last_page = pages[-1]['page_num'] if pages else 1
|
| 75 |
out.append({
|
| 76 |
"user_id": user_id,
|
|
|
|
| 77 |
"filename": filename,
|
| 78 |
"topic_name": topic[:120],
|
| 79 |
"summary": summary,
|
|
|
|
| 32 |
return parts
|
| 33 |
|
| 34 |
|
| 35 |
+
def build_cards_from_pages(pages: List[Dict[str, Any]], filename: str, user_id: str, project_id: str) -> List[Dict[str, Any]]:
|
| 36 |
# Concatenate pages but keep page spans for metadata
|
| 37 |
full = ""
|
| 38 |
page_markers = []
|
|
|
|
| 74 |
last_page = pages[-1]['page_num'] if pages else 1
|
| 75 |
out.append({
|
| 76 |
"user_id": user_id,
|
| 77 |
+
"project_id": project_id,
|
| 78 |
"filename": filename,
|
| 79 |
"topic_name": topic[:120],
|
| 80 |
"summary": summary,
|
utils/rag.py
CHANGED
|
@@ -6,13 +6,14 @@ from pymongo import MongoClient, ASCENDING, TEXT
|
|
| 6 |
from pymongo.collection import Collection
|
| 7 |
from pymongo.errors import PyMongoError
|
| 8 |
import numpy as np
|
|
|
|
| 9 |
from .logger import get_logger
|
| 10 |
|
| 11 |
VECTOR_DIM = 384 # all-MiniLM-L6-v2
|
| 12 |
INDEX_NAME = os.getenv("MONGO_VECTOR_INDEX", "vector_index")
|
| 13 |
USE_ATLAS_VECTOR = os.getenv("ATLAS_VECTOR", "0") == "1"
|
| 14 |
-
logger = get_logger("RAG", __name__)
|
| 15 |
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
class RAGStore:
|
|
@@ -34,30 +35,33 @@ class RAGStore:
|
|
| 34 |
self.chunks.insert_many(cards, ordered=False)
|
| 35 |
logger.info(f"Inserted {len(cards)} cards into MongoDB")
|
| 36 |
|
| 37 |
-
def upsert_file_summary(self, user_id: str, filename: str, summary: str):
|
| 38 |
self.files.update_one(
|
| 39 |
-
{"user_id": user_id, "filename": filename},
|
| 40 |
{"$set": {"summary": summary}},
|
| 41 |
upsert=True
|
| 42 |
)
|
| 43 |
-
logger.info(f"Upserted summary for {filename} (user {user_id})")
|
| 44 |
|
| 45 |
# ── Read ────────────────────────────────────────────────────────────────
|
| 46 |
-
def list_cards(self, user_id: str, filename: Optional[str], limit: int, skip: int):
|
| 47 |
-
q = {"user_id": user_id}
|
| 48 |
if filename:
|
| 49 |
q["filename"] = filename
|
| 50 |
cur = self.chunks.find(q, {"embedding": 0}).skip(skip).limit(limit).sort([("_id", ASCENDING)])
|
| 51 |
return list(cur)
|
| 52 |
|
| 53 |
-
def
|
| 54 |
-
|
| 55 |
-
return list(cur)
|
| 56 |
|
| 57 |
-
def
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
-
def vector_search(self, user_id: str, query_vector: List[float], k: int = 6, filenames: Optional[List[str]] = None):
|
| 61 |
if USE_ATLAS_VECTOR:
|
| 62 |
# Atlas Vector Search (requires pre-created index on 'embedding')
|
| 63 |
pipeline = [
|
|
@@ -69,41 +73,39 @@ class RAGStore:
|
|
| 69 |
"path": "embedding",
|
| 70 |
"k": k,
|
| 71 |
},
|
| 72 |
-
"filter": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
}
|
| 74 |
},
|
| 75 |
{"$project": {"embedding": 0, "score": {"$meta": "searchScore"}, "doc": "$$ROOT"}},
|
|
|
|
| 76 |
]
|
| 77 |
-
|
| 78 |
-
pipeline.append({"$match": {"doc.filename": {"$in": filenames}}})
|
| 79 |
-
pipeline.append({"$limit": k})
|
| 80 |
hits = list(self.chunks.aggregate(pipeline))
|
| 81 |
return [{"doc": h["doc"], "score": h["score"]} for h in hits]
|
| 82 |
-
# Fallback: scan limited sample and compute cosine locally
|
| 83 |
else:
|
| 84 |
-
|
| 85 |
-
|
| 86 |
if filenames:
|
| 87 |
q["filename"] = {"$in": filenames}
|
| 88 |
-
# Scan limited sample and compute cosine locally
|
| 89 |
sample = list(self.chunks.find(q).limit(max(2000, k*10)))
|
| 90 |
-
# If no sample, return empty list
|
| 91 |
if not sample:
|
| 92 |
return []
|
| 93 |
-
# Compute cosine similarity for each sample
|
| 94 |
qv = np.array(query_vector, dtype="float32")
|
| 95 |
-
scores = []
|
| 96 |
-
# Compute cosine similarity for each sample
|
| 97 |
for d in sample:
|
| 98 |
v = np.array(d.get("embedding", [0]*VECTOR_DIM), dtype="float32")
|
| 99 |
denom = (np.linalg.norm(qv) * np.linalg.norm(v)) or 1.0
|
| 100 |
sim = float(np.dot(qv, v) / denom)
|
| 101 |
scores.append((sim, d))
|
| 102 |
-
# Sort scores by cosine similarity in descending order
|
| 103 |
scores.sort(key=lambda x: x[0], reverse=True)
|
| 104 |
-
# Get top k sc ores
|
| 105 |
top = scores[:k]
|
| 106 |
-
# Log the results
|
| 107 |
logger.info(f"Vector search sample={len(sample)} returned top={len(top)}")
|
| 108 |
return [{"doc": d, "score": s} for (s, d) in top]
|
| 109 |
|
|
@@ -111,9 +113,9 @@ class RAGStore:
|
|
| 111 |
def ensure_indexes(store: RAGStore):
|
| 112 |
# Basic text index for fallback keyword search (optional)
|
| 113 |
try:
|
| 114 |
-
store.chunks.create_index([("user_id", ASCENDING), ("filename", ASCENDING)])
|
| 115 |
store.chunks.create_index([("content", TEXT), ("topic_name", TEXT), ("summary", TEXT)], name="text_idx")
|
| 116 |
-
store.files.create_index([("user_id", ASCENDING), ("filename", ASCENDING)], unique=True)
|
| 117 |
except PyMongoError as e:
|
| 118 |
logger.warning(f"Index creation warning: {e}")
|
| 119 |
# Note: For Atlas Vector, create an Atlas Search index named INDEX_NAME on field "embedding" with vector options.
|
|
|
|
| 6 |
from pymongo.collection import Collection
|
| 7 |
from pymongo.errors import PyMongoError
|
| 8 |
import numpy as np
|
| 9 |
+
import logging
|
| 10 |
from .logger import get_logger
|
| 11 |
|
| 12 |
VECTOR_DIM = 384 # all-MiniLM-L6-v2
|
| 13 |
INDEX_NAME = os.getenv("MONGO_VECTOR_INDEX", "vector_index")
|
| 14 |
USE_ATLAS_VECTOR = os.getenv("ATLAS_VECTOR", "0") == "1"
|
|
|
|
| 15 |
|
| 16 |
+
logger = get_logger("RAG", __name__)
|
| 17 |
|
| 18 |
|
| 19 |
class RAGStore:
|
|
|
|
| 35 |
self.chunks.insert_many(cards, ordered=False)
|
| 36 |
logger.info(f"Inserted {len(cards)} cards into MongoDB")
|
| 37 |
|
| 38 |
+
def upsert_file_summary(self, user_id: str, project_id: str, filename: str, summary: str):
|
| 39 |
self.files.update_one(
|
| 40 |
+
{"user_id": user_id, "project_id": project_id, "filename": filename},
|
| 41 |
{"$set": {"summary": summary}},
|
| 42 |
upsert=True
|
| 43 |
)
|
| 44 |
+
logger.info(f"Upserted summary for {filename} (user {user_id}, project {project_id})")
|
| 45 |
|
| 46 |
# ── Read ────────────────────────────────────────────────────────────────
|
| 47 |
+
def list_cards(self, user_id: str, project_id: str, filename: Optional[str], limit: int, skip: int):
|
| 48 |
+
q = {"user_id": user_id, "project_id": project_id}
|
| 49 |
if filename:
|
| 50 |
q["filename"] = filename
|
| 51 |
cur = self.chunks.find(q, {"embedding": 0}).skip(skip).limit(limit).sort([("_id", ASCENDING)])
|
| 52 |
return list(cur)
|
| 53 |
|
| 54 |
+
def get_file_summary(self, user_id: str, project_id: str, filename: str):
|
| 55 |
+
return self.files.find_one({"user_id": user_id, "project_id": project_id, "filename": filename})
|
|
|
|
| 56 |
|
| 57 |
+
def list_files(self, user_id: str, project_id: str):
|
| 58 |
+
"""List all files for a project with their summaries"""
|
| 59 |
+
return list(self.files.find(
|
| 60 |
+
{"user_id": user_id, "project_id": project_id},
|
| 61 |
+
{"_id": 0, "filename": 1, "summary": 1}
|
| 62 |
+
).sort("filename", ASCENDING))
|
| 63 |
|
| 64 |
+
def vector_search(self, user_id: str, project_id: str, query_vector: List[float], k: int = 6, filenames: Optional[List[str]] = None):
|
| 65 |
if USE_ATLAS_VECTOR:
|
| 66 |
# Atlas Vector Search (requires pre-created index on 'embedding')
|
| 67 |
pipeline = [
|
|
|
|
| 73 |
"path": "embedding",
|
| 74 |
"k": k,
|
| 75 |
},
|
| 76 |
+
"filter": {
|
| 77 |
+
"compound": {
|
| 78 |
+
"must": [
|
| 79 |
+
{"equals": {"path": "user_id", "value": user_id}},
|
| 80 |
+
{"equals": {"path": "project_id", "value": project_id}}
|
| 81 |
+
]
|
| 82 |
+
}
|
| 83 |
+
},
|
| 84 |
}
|
| 85 |
},
|
| 86 |
{"$project": {"embedding": 0, "score": {"$meta": "searchScore"}, "doc": "$$ROOT"}},
|
| 87 |
+
{"$limit": k},
|
| 88 |
]
|
| 89 |
+
# Append hit scoring algorithm
|
|
|
|
|
|
|
| 90 |
hits = list(self.chunks.aggregate(pipeline))
|
| 91 |
return [{"doc": h["doc"], "score": h["score"]} for h in hits]
|
|
|
|
| 92 |
else:
|
| 93 |
+
# Fallback: scan limited sample and compute cosine locally
|
| 94 |
+
q = {"user_id": user_id, "project_id": project_id}
|
| 95 |
if filenames:
|
| 96 |
q["filename"] = {"$in": filenames}
|
|
|
|
| 97 |
sample = list(self.chunks.find(q).limit(max(2000, k*10)))
|
|
|
|
| 98 |
if not sample:
|
| 99 |
return []
|
|
|
|
| 100 |
qv = np.array(query_vector, dtype="float32")
|
| 101 |
+
scores = []
|
|
|
|
| 102 |
for d in sample:
|
| 103 |
v = np.array(d.get("embedding", [0]*VECTOR_DIM), dtype="float32")
|
| 104 |
denom = (np.linalg.norm(qv) * np.linalg.norm(v)) or 1.0
|
| 105 |
sim = float(np.dot(qv, v) / denom)
|
| 106 |
scores.append((sim, d))
|
|
|
|
| 107 |
scores.sort(key=lambda x: x[0], reverse=True)
|
|
|
|
| 108 |
top = scores[:k]
|
|
|
|
| 109 |
logger.info(f"Vector search sample={len(sample)} returned top={len(top)}")
|
| 110 |
return [{"doc": d, "score": s} for (s, d) in top]
|
| 111 |
|
|
|
|
| 113 |
def ensure_indexes(store: RAGStore):
|
| 114 |
# Basic text index for fallback keyword search (optional)
|
| 115 |
try:
|
| 116 |
+
store.chunks.create_index([("user_id", ASCENDING), ("project_id", ASCENDING), ("filename", ASCENDING)])
|
| 117 |
store.chunks.create_index([("content", TEXT), ("topic_name", TEXT), ("summary", TEXT)], name="text_idx")
|
| 118 |
+
store.files.create_index([("user_id", ASCENDING), ("project_id", ASCENDING), ("filename", ASCENDING)], unique=True)
|
| 119 |
except PyMongoError as e:
|
| 120 |
logger.warning(f"Index creation warning: {e}")
|
| 121 |
# Note: For Atlas Vector, create an Atlas Search index named INDEX_NAME on field "embedding" with vector options.
|