LiamKhoaLe commited on
Commit
20fd4b7
·
1 Parent(s): a30fd70

Upd proj-spec filter + chat continuity LTM + sidebar

Browse files
Files changed (9) hide show
  1. app.py +129 -12
  2. static/auth.js +6 -0
  3. static/index.html +204 -68
  4. static/projects.js +298 -0
  5. static/script.js +57 -4
  6. static/sidebar.js +174 -0
  7. static/styles.css +403 -39
  8. utils/chunker.py +2 -1
  9. 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-filename metadata
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(user_id: str = Form(...), question: str = Form(...), k: int = Form(6)):
 
 
 
 
 
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
- <header>
13
- <div class="logo">
14
- <div class="logo-icon">📚</div>
15
- <div class="logo-text">
16
- <h1>StudyBuddy</h1>
17
- <p>AI-powered document analysis & chat</p>
 
 
 
18
  </div>
19
  </div>
20
- <div class="auth-controls">
21
- <div class="user-info" id="user-info" style="display:none;">
22
- <span class="user-avatar">👤</span>
23
- <span class="user-email" id="user-email"></span>
24
- <button id="logout" class="btn-secondary">Logout</button>
25
  </div>
26
- <button id="theme-toggle" class="btn-icon" title="Toggle theme">🌙</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  </div>
28
- </header>
29
 
30
- <section class="card reveal" id="upload-section">
31
- <div class="card-header">
32
- <h2>📁 Upload Documents</h2>
33
- <p>Upload your PDFs and DOCX files to start chatting with your materials</p>
 
 
 
 
 
34
  </div>
35
- <form id="upload-form">
36
- <div class="file-drop-zone" id="file-drop-zone">
37
- <div class="drop-zone-content">
38
- <div class="drop-zone-icon">📄</div>
39
- <p>Drag & drop files here or click to browse</p>
40
- <span class="file-types">Supports: PDF, DOCX</span>
 
 
 
 
 
41
  </div>
42
- <input type="file" id="files" multiple accept=".pdf,.docx" hidden>
43
  </div>
44
- <div class="file-list" id="file-list" style="display:none;">
45
- <h4>Selected Files:</h4>
46
- <div class="file-items" id="file-items"></div>
47
  </div>
48
- <button type="submit" class="btn-primary" id="upload-btn">
49
- <span class="btn-text">Upload Documents</span>
50
- <span class="btn-loading" style="display:none;">
51
- <div class="spinner"></div>
52
- Processing...
53
- </span>
 
 
 
 
 
 
 
54
  </button>
55
- </form>
56
- <div class="upload-progress" id="upload-progress" style="display:none;">
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
- <section class="card reveal" id="chat-section">
69
- <div class="card-header">
70
- <h2>💬 Chat with Documents</h2>
71
- <p>Ask questions about your uploaded materials and get AI-powered answers</p>
72
- </div>
73
- <div id="chat">
74
- <div id="messages"></div>
75
- <div class="chat-controls" id="chat-controls">
76
- <div class="chat-input-wrapper">
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
- <div class="chat-hint" id="chat-hint">
87
- Upload documents first to start chatting
 
 
 
 
 
 
 
88
  </div>
89
  </div>
90
- </div>
91
- </section>
92
 
93
- <footer>
94
- <small>StudyBuddy RAG FastAPI on Hugging Face Spaces • MongoDB Vector • BLIP captions</small>
95
- </footer>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- uploadBtn.disabled = !hasFiles || isUploading;
 
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
- appendMessage('assistant', `⚠️ Error: ${error.message}`);
 
 
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
- enableChat();
 
 
 
 
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
- .container {
63
- max-width: 1200px;
64
- margin: 0 auto;
65
- padding: 24px;
66
  min-height: 100vh;
67
  }
68
 
69
- /* Header */
70
- header {
 
 
 
71
  display: flex;
72
- justify-content: space-between;
73
- align-items: center;
74
- margin-bottom: 48px;
75
- padding: 24px 0;
 
 
 
 
 
 
 
 
 
 
 
 
76
  border-bottom: 1px solid var(--border);
77
  }
78
 
@@ -83,7 +99,7 @@ header {
83
  }
84
 
85
  .logo-icon {
86
- font-size: 48px;
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: 32px;
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: 16px;
106
- margin: 4px 0 0 0;
 
 
 
 
 
107
  }
108
 
109
- .auth-controls {
 
 
 
 
 
110
  display: flex;
 
111
  align-items: center;
112
- gap: 16px;
113
  }
114
 
115
- .user-info {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  display: flex;
117
  align-items: center;
118
  gap: 12px;
119
- padding: 8px 16px;
120
- background: var(--card);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  border-radius: var(--radius);
122
  border: 1px solid var(--border);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  }
124
 
125
  .user-avatar {
126
- font-size: 20px;
 
 
 
 
 
127
  }
128
 
129
  .user-email {
130
- color: var(--text-secondary);
 
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: 768px) {
775
- .container {
 
 
 
 
 
 
 
 
 
 
 
 
776
  padding: 16px;
777
  }
778
 
779
- header {
780
- flex-direction: column;
781
- gap: 24px;
782
- text-align: center;
 
 
 
 
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 list_files(self, user_id: str) -> List[Dict[str, Any]]:
54
- cur = self.files.find({"user_id": user_id}, {"_id": 0})
55
- return list(cur)
56
 
57
- def get_file_summary(self, user_id: str, filename: str):
58
- return self.files.find_one({"user_id": user_id, "filename": filename})
 
 
 
 
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": {"equals": {"path": "user_id", "value": user_id}},
 
 
 
 
 
 
 
73
  }
74
  },
75
  {"$project": {"embedding": 0, "score": {"$meta": "searchScore"}, "doc": "$$ROOT"}},
 
76
  ]
77
- if filenames:
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
- q = {"user_id": user_id}
85
- # Apply filename filter if provided
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.