Jiya3177 commited on
Commit
bb497db
·
2 Parent(s): e4249bd87c191f

chore: merge upstream dev

Browse files
.github/dependabot.yml ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: pip
4
+ directory: /
5
+ target-branch: dev
6
+ schedule:
7
+ interval: weekly
8
+ day: monday
9
+ time: "09:00"
10
+ timezone: Asia/Kolkata
11
+ open-pull-requests-limit: 5
12
+ labels:
13
+ - dependencies
14
+ - python
15
+ groups:
16
+ root-python-minor-patch:
17
+ update-types:
18
+ - minor
19
+ - patch
20
+
21
+ - package-ecosystem: pip
22
+ directory: /backend
23
+ target-branch: dev
24
+ schedule:
25
+ interval: weekly
26
+ day: monday
27
+ time: "09:15"
28
+ timezone: Asia/Kolkata
29
+ open-pull-requests-limit: 5
30
+ labels:
31
+ - dependencies
32
+ - python
33
+ - backend
34
+ groups:
35
+ backend-python-minor-patch:
36
+ update-types:
37
+ - minor
38
+ - patch
39
+
40
+ - package-ecosystem: npm
41
+ directory: /frontend
42
+ target-branch: dev
43
+ schedule:
44
+ interval: weekly
45
+ day: monday
46
+ time: "09:30"
47
+ timezone: Asia/Kolkata
48
+ open-pull-requests-limit: 5
49
+ labels:
50
+ - dependencies
51
+ - javascript
52
+ - frontend
53
+ groups:
54
+ frontend-npm-minor-patch:
55
+ update-types:
56
+ - minor
57
+ - patch
58
+ ignore:
59
+ - dependency-name: "eslint"
60
+ versions: [">= 10.0.0"]
61
+
62
+ - package-ecosystem: github-actions
63
+ directory: /
64
+ target-branch: dev
65
+ schedule:
66
+ interval: weekly
67
+ day: monday
68
+ time: "09:45"
69
+ timezone: Asia/Kolkata
70
+ open-pull-requests-limit: 5
71
+ labels:
72
+ - dependencies
73
+ - github-actions
74
+ groups:
75
+ github-actions-minor-patch:
76
+ update-types:
77
+ - minor
78
+ - patch
README.md CHANGED
@@ -437,8 +437,9 @@ docker compose up --build
437
  | `POST` | `/api/v1/auth/register` | ❌ | Create a new user account |
438
  | `POST` | `/api/v1/auth/login` | ❌ | Login and receive JWT token |
439
  | `GET` | `/api/v1/auth/me` | ✅ | Get current user profile |
440
- | `POST` | `/api/v1/documents/upload` | ✅ | Upload PDF/DOCX and trigger indexing |
441
  | `GET` | `/api/v1/documents` | ✅ | List all documents for current user |
 
442
  | `DELETE` | `/api/v1/documents/{id}` | ✅ | Delete a document and its vector data |
443
  | `POST` | `/api/v1/chat/ask/stream` | ✅ | Ask a question (SSE streaming response) |
444
  | `GET` | `/api/v1/chat/history/{doc_id}` | ✅ | Get chat history for a document |
 
437
  | `POST` | `/api/v1/auth/register` | ❌ | Create a new user account |
438
  | `POST` | `/api/v1/auth/login` | ❌ | Login and receive JWT token |
439
  | `GET` | `/api/v1/auth/me` | ✅ | Get current user profile |
440
+ | `POST` | `/api/v1/documents/upload` | ✅ | Upload PDF/DOCX and enqueue background indexing (`202 Accepted`) |
441
  | `GET` | `/api/v1/documents` | ✅ | List all documents for current user |
442
+ | `GET` | `/api/v1/documents/{id}/status` | ✅ | Poll background document processing status |
443
  | `DELETE` | `/api/v1/documents/{id}` | ✅ | Delete a document and its vector data |
444
  | `POST` | `/api/v1/chat/ask/stream` | ✅ | Ask a question (SSE streaming response) |
445
  | `GET` | `/api/v1/chat/history/{doc_id}` | ✅ | Get chat history for a document |
backend/app/models.py CHANGED
@@ -21,6 +21,7 @@ class User(Base):
21
  hashed_password = Column(String(255), nullable=False)
22
  is_admin = Column(Boolean, default=False)
23
  created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
 
24
 
25
  # Relationships
26
  documents = relationship("Document", back_populates="owner", cascade="all, delete-orphan")
 
21
  hashed_password = Column(String(255), nullable=False)
22
  is_admin = Column(Boolean, default=False)
23
  created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
24
+ last_login = Column(DateTime, nullable=True, index=True)
25
 
26
  # Relationships
27
  documents = relationship("Document", back_populates="owner", cascade="all, delete-orphan")
backend/app/routes/auth.py CHANGED
@@ -1,6 +1,7 @@
1
  """
2
  Auth API routes — register, login, and user profile.
3
  """
 
4
  from fastapi import APIRouter, Depends, HTTPException, status
5
  from langsmith import expect
6
  from sqlalchemy.exc import SQLAlchemyError
@@ -105,6 +106,10 @@ def login(payload: UserLogin, db: Session = Depends(get_db)):
105
  detail="Invalid email or password",
106
  )
107
 
 
 
 
 
108
  access_token = create_access_token(user.id)
109
  refresh_token = create_refresh_token(user.id)
110
 
 
1
  """
2
  Auth API routes — register, login, and user profile.
3
  """
4
+ from datetime import datetime, timezone
5
  from fastapi import APIRouter, Depends, HTTPException, status
6
  from langsmith import expect
7
  from sqlalchemy.exc import SQLAlchemyError
 
106
  detail="Invalid email or password",
107
  )
108
 
109
+ user.last_login = datetime.now(timezone.utc)
110
+ db.commit()
111
+ db.refresh(user)
112
+
113
  access_token = create_access_token(user.id)
114
  refresh_token = create_refresh_token(user.id)
115
 
backend/app/routes/chat.py CHANGED
@@ -1,12 +1,19 @@
1
  """
2
  Chat routes — ask questions with RAG, stream responses via SSE, manage history.
3
  """
 
4
  import json
 
 
5
  import logging
6
  from typing import Optional
7
 
8
  from fastapi import APIRouter, Depends, HTTPException, Request
9
- from fastapi.responses import StreamingResponse
 
 
 
 
10
  from sqlalchemy.orm import Session
11
 
12
  from app.database import get_db
@@ -258,32 +265,31 @@ def export_chat_history(
258
  ):
259
  """Export the chat history for a document as a downloadable file.
260
 
261
- Supports Markdown (.md) or plain text (.txt) export. The function accepts
262
  authentication via either the standard `Authorization: Bearer <token>`
263
  header (handled by the dependency chain) or a `token` query parameter to
264
  facilitate browser-initiated downloads that cannot set custom headers.
265
 
266
  Args:
267
  document_id: The unique identifier of the document whose chat history is to be exported.
268
- format: Output format, either "md" (Markdown) or "txt" (plain text). Defaults to "md".
269
  token: Optional JWT token passed as a query parameter. Used for browser
270
  downloads when the `Authorization` header is not available.
271
  db: SQLAlchemy database session, obtained from the dependency.
272
 
273
  Returns:
274
  Response: A FastAPI `Response` object with:
275
- - `content`: Formatted chat history as a string.
276
- - `media_type`: `text/markdown` or `text/plain`.
277
  - `headers`: `Content-Disposition` attachment header with a generated filename.
278
 
279
  Raises:
280
  HTTPException: 401 if neither the token query parameter nor a valid
281
  bearer token provides an authenticated user.
282
- HTTPException: 400 if the `format` parameter is not "md" or "txt".
283
  HTTPException: 404 if the document does not exist or does not belong
284
  to the user, or if no chat messages are found for the document.
285
  """
286
- from fastapi import Request
287
  from app.auth import decode_token as _decode
288
 
289
  # Resolve user from query-param token (browser download links can't set headers)
@@ -296,8 +302,8 @@ def export_chat_history(
296
  if resolved_user is None:
297
  raise HTTPException(status_code=401, detail="Authentication required")
298
 
299
- if format not in ("md", "txt"):
300
- raise HTTPException(status_code=400, detail="Format must be 'md' or 'txt'")
301
 
302
  # Verify document exists and belongs to user
303
  doc = db.query(Document).filter(
@@ -325,15 +331,18 @@ def export_chat_history(
325
  content = _format_markdown(doc, messages)
326
  media_type = "text/markdown"
327
  extension = "md"
328
- else:
329
  content = _format_plaintext(doc, messages)
330
  media_type = "text/plain"
331
  extension = "txt"
 
 
 
 
332
 
333
  safe_name = doc.original_name.rsplit(".", 1)[0]
334
  filename = f"{safe_name}_chat_history.{extension}"
335
 
336
- from fastapi.responses import Response
337
  return Response(
338
  content=content,
339
  media_type=media_type,
@@ -532,3 +541,80 @@ def _format_plaintext(doc, messages) -> str:
532
 
533
  return "\n".join(lines)
534
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  Chat routes — ask questions with RAG, stream responses via SSE, manage history.
3
  """
4
+ import html
5
  import json
6
+ from datetime import datetime
7
+ from io import BytesIO
8
  import logging
9
  from typing import Optional
10
 
11
  from fastapi import APIRouter, Depends, HTTPException, Request
12
+ from fastapi.responses import Response, StreamingResponse
13
+ from reportlab.lib.pagesizes import letter
14
+ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
15
+ from reportlab.lib.units import inch
16
+ from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
17
  from sqlalchemy.orm import Session
18
 
19
  from app.database import get_db
 
265
  ):
266
  """Export the chat history for a document as a downloadable file.
267
 
268
+ Supports Markdown (.md), plain text (.txt), or PDF (.pdf) export. The function accepts
269
  authentication via either the standard `Authorization: Bearer <token>`
270
  header (handled by the dependency chain) or a `token` query parameter to
271
  facilitate browser-initiated downloads that cannot set custom headers.
272
 
273
  Args:
274
  document_id: The unique identifier of the document whose chat history is to be exported.
275
+ format: Output format, either "md" (Markdown), "txt" (plain text), or "pdf". Defaults to "md".
276
  token: Optional JWT token passed as a query parameter. Used for browser
277
  downloads when the `Authorization` header is not available.
278
  db: SQLAlchemy database session, obtained from the dependency.
279
 
280
  Returns:
281
  Response: A FastAPI `Response` object with:
282
+ - `content`: Formatted chat history as a string or PDF bytes.
283
+ - `media_type`: `text/markdown`, `text/plain`, or `application/pdf`.
284
  - `headers`: `Content-Disposition` attachment header with a generated filename.
285
 
286
  Raises:
287
  HTTPException: 401 if neither the token query parameter nor a valid
288
  bearer token provides an authenticated user.
289
+ HTTPException: 400 if the `format` parameter is not "md", "txt", or "pdf".
290
  HTTPException: 404 if the document does not exist or does not belong
291
  to the user, or if no chat messages are found for the document.
292
  """
 
293
  from app.auth import decode_token as _decode
294
 
295
  # Resolve user from query-param token (browser download links can't set headers)
 
302
  if resolved_user is None:
303
  raise HTTPException(status_code=401, detail="Authentication required")
304
 
305
+ if format not in ("md", "txt", "pdf"):
306
+ raise HTTPException(status_code=400, detail="Format must be 'md', 'txt', or 'pdf'")
307
 
308
  # Verify document exists and belongs to user
309
  doc = db.query(Document).filter(
 
331
  content = _format_markdown(doc, messages)
332
  media_type = "text/markdown"
333
  extension = "md"
334
+ elif format == "txt":
335
  content = _format_plaintext(doc, messages)
336
  media_type = "text/plain"
337
  extension = "txt"
338
+ else:
339
+ content = _format_pdf(doc, messages)
340
+ media_type = "application/pdf"
341
+ extension = "pdf"
342
 
343
  safe_name = doc.original_name.rsplit(".", 1)[0]
344
  filename = f"{safe_name}_chat_history.{extension}"
345
 
 
346
  return Response(
347
  content=content,
348
  media_type=media_type,
 
541
 
542
  return "\n".join(lines)
543
 
544
+
545
+ def _format_pdf(doc, messages) -> bytes:
546
+ """Format chat history as a PDF document."""
547
+ buffer = BytesIO()
548
+ pdf = SimpleDocTemplate(
549
+ buffer,
550
+ pagesize=letter,
551
+ leftMargin=0.75 * inch,
552
+ rightMargin=0.75 * inch,
553
+ topMargin=0.75 * inch,
554
+ bottomMargin=0.75 * inch,
555
+ )
556
+
557
+ styles = getSampleStyleSheet()
558
+ metadata_style = styles["Normal"]
559
+ metadata_style.spaceAfter = 6
560
+ content_style = ParagraphStyle(
561
+ "ChatContent",
562
+ parent=styles["BodyText"],
563
+ leading=14,
564
+ spaceAfter=10,
565
+ )
566
+ source_style = ParagraphStyle(
567
+ "ChatSource",
568
+ parent=styles["BodyText"],
569
+ leftIndent=14,
570
+ leading=12,
571
+ spaceAfter=4,
572
+ )
573
+
574
+ story = [
575
+ Paragraph(f"Chat History - {html.escape(doc.original_name)}", styles["Title"]),
576
+ Spacer(1, 0.15 * inch),
577
+ Paragraph(f"Document: {html.escape(doc.original_name)}", metadata_style),
578
+ Paragraph(f"Exported at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", metadata_style),
579
+ Paragraph(f"Total messages: {len(messages)}", metadata_style),
580
+ Spacer(1, 0.2 * inch),
581
+ ]
582
+
583
+ for msg in messages:
584
+ timestamp = msg.created_at.strftime("%Y-%m-%d %H:%M:%S") if msg.created_at else ""
585
+ role_label = "You" if msg.role == "user" else "Assistant"
586
+
587
+ story.append(Paragraph(f"<b>{html.escape(role_label)}</b>", styles["Heading3"]))
588
+ story.append(Paragraph(html.escape(timestamp), styles["Italic"]))
589
+ story.append(Paragraph(_pdf_text(msg.content), content_style))
590
+
591
+ if msg.role == "assistant" and msg.sources_json:
592
+ try:
593
+ sources = json.loads(msg.sources_json)
594
+ if sources:
595
+ story.append(Paragraph("<b>Sources:</b>", metadata_style))
596
+ for i, src in enumerate(sources, 1):
597
+ filename = html.escape(str(src.get("filename", "Unknown")))
598
+ page = html.escape(str(src.get("page", "?")))
599
+ confidence = html.escape(str(src.get("confidence", 0)))
600
+ story.append(
601
+ Paragraph(
602
+ f"[{i}] {filename}, Page {page} (Confidence: {confidence}%)",
603
+ source_style,
604
+ )
605
+ )
606
+ text_preview = str(src.get("text", "")).strip()
607
+ if text_preview:
608
+ story.append(Paragraph(_pdf_text(text_preview), source_style))
609
+ except Exception:
610
+ pass
611
+
612
+ story.append(Spacer(1, 0.15 * inch))
613
+
614
+ pdf.build(story)
615
+ return buffer.getvalue()
616
+
617
+
618
+ def _pdf_text(text: str) -> str:
619
+ """Escape text for ReportLab paragraphs while preserving line breaks."""
620
+ return html.escape(text or "").replace("\n", "<br/>")
backend/app/routes/documents.py CHANGED
@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session
16
 
17
  from app.database import get_db
18
  from app.models import User, Document
19
- from app.schemas import DocumentResponse, DocumentListResponse
20
  from app.auth import get_current_user
21
  from app.config import get_settings
22
  from app.rag.chunker import chunk_document, get_page_count
@@ -191,7 +191,7 @@ def _ingest_document(document_id: str, filepath: str, original_name: str, user_i
191
  db.close()
192
 
193
 
194
- @router.post("/upload", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED)
195
  async def upload_document(
196
  background_tasks: BackgroundTasks,
197
  file: UploadFile = File(...),
@@ -199,11 +199,13 @@ async def upload_document(
199
  db: Session = Depends(get_db),
200
  ):
201
  """
202
- Upload a document for RAG processing.
203
 
204
  Validates the uploaded file (extension, size, MIME type, integrity),
205
  saves it to the user's directory, creates a database record with status
206
- 'pending', and schedules a background task for chunking and embedding.
 
 
207
 
208
  Args:
209
  background_tasks: FastAPI BackgroundTasks instance to run the ingestion process asynchronously.
@@ -272,6 +274,30 @@ async def upload_document(
272
  return DocumentResponse.model_validate(document)
273
 
274
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  @router.get("/", response_model=DocumentListResponse)
276
  def list_documents(
277
  page: int = Query(1, ge=1),
 
16
 
17
  from app.database import get_db
18
  from app.models import User, Document
19
+ from app.schemas import DocumentResponse, DocumentListResponse, DocumentStatusResponse
20
  from app.auth import get_current_user
21
  from app.config import get_settings
22
  from app.rag.chunker import chunk_document, get_page_count
 
191
  db.close()
192
 
193
 
194
+ @router.post("/upload", response_model=DocumentResponse, status_code=status.HTTP_202_ACCEPTED)
195
  async def upload_document(
196
  background_tasks: BackgroundTasks,
197
  file: UploadFile = File(...),
 
199
  db: Session = Depends(get_db),
200
  ):
201
  """
202
+ Upload a document and enqueue RAG processing.
203
 
204
  Validates the uploaded file (extension, size, MIME type, integrity),
205
  saves it to the user's directory, creates a database record with status
206
+ 'pending', schedules a background task for chunking and embedding, and
207
+ returns 202 Accepted immediately so large documents do not block the API
208
+ request while embeddings are generated.
209
 
210
  Args:
211
  background_tasks: FastAPI BackgroundTasks instance to run the ingestion process asynchronously.
 
274
  return DocumentResponse.model_validate(document)
275
 
276
 
277
+ @router.get("/{document_id}/status", response_model=DocumentStatusResponse)
278
+ def get_document_status(
279
+ document_id: str,
280
+ user: User = Depends(get_current_user),
281
+ db: Session = Depends(get_db),
282
+ ):
283
+ """
284
+ Poll processing status for a single uploaded document.
285
+
286
+ This endpoint lets clients refresh the upload lifecycle without fetching
287
+ the entire document list. The returned status is one of the existing
288
+ document states: pending, processing, ready, or failed.
289
+ """
290
+ doc = db.query(Document).filter(
291
+ Document.id == document_id,
292
+ Document.user_id == user.id,
293
+ ).first()
294
+
295
+ if not doc:
296
+ raise HTTPException(status_code=404, detail="Document not found")
297
+
298
+ return DocumentStatusResponse.model_validate(doc)
299
+
300
+
301
  @router.get("/", response_model=DocumentListResponse)
302
  def list_documents(
303
  page: int = Query(1, ge=1),
backend/app/schemas.py CHANGED
@@ -75,6 +75,17 @@ class DocumentResponse(BaseModel):
75
  from_attributes = True
76
 
77
 
 
 
 
 
 
 
 
 
 
 
 
78
  class DocumentListResponse(BaseModel):
79
  items: List[DocumentResponse]
80
  total: int
 
75
  from_attributes = True
76
 
77
 
78
+ class DocumentStatusResponse(BaseModel):
79
+ id: str
80
+ status: str
81
+ page_count: int
82
+ chunk_count: int
83
+ error_message: Optional[str] = None
84
+
85
+ class Config:
86
+ from_attributes = True
87
+
88
+
89
  class DocumentListResponse(BaseModel):
90
  items: List[DocumentResponse]
91
  total: int
backend/requirements.txt CHANGED
@@ -49,3 +49,4 @@ python-magic-bin==0.4.27; sys_platform == "win32" # for windows
49
  python-magic; sys_platform != "win32"
50
  python-docx
51
  pypdf
 
 
49
  python-magic; sys_platform != "win32"
50
  python-docx
51
  pypdf
52
+ reportlab
backend/scripts/vector_cleanup.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Cleanup script for ChromaDB vectors belonging to inactive users.
3
+
4
+ By default, a user is considered inactive if they have not logged in for
5
+ 30 days. Users without last_login are skipped to avoid deleting vectors for
6
+ legacy accounts before activity tracking existed.
7
+
8
+ Run manually:
9
+ python backend/scripts/vector_cleanup.py
10
+
11
+ Environment:
12
+ VECTOR_CLEANUP_INACTIVE_DAYS=30
13
+ VECTOR_CLEANUP_DRY_RUN=true
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import os
19
+ import sys
20
+ from datetime import datetime, timedelta, timezone
21
+ from pathlib import Path
22
+
23
+ from sqlalchemy import inspect, or_, text
24
+
25
+ # Allow running this file directly from the repository root.
26
+ BACKEND_DIR = Path(__file__).resolve().parents[1]
27
+ if str(BACKEND_DIR) not in sys.path:
28
+ sys.path.insert(0, str(BACKEND_DIR))
29
+
30
+ from app.database import SessionLocal # noqa: E402
31
+ from app.models import User # noqa: E402
32
+ from app.rag.vectorstore import delete_user_collection # noqa: E402
33
+
34
+ logger = logging.getLogger("vector_cleanup")
35
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
36
+
37
+
38
+ def _env_bool(name: str, default: bool = False) -> bool:
39
+ value = os.getenv(name)
40
+ if value is None:
41
+ return default
42
+ return value.strip().lower() in {"1", "true", "yes", "on"}
43
+
44
+
45
+
46
+ def ensure_last_login_column() -> None:
47
+ """Add users.last_login for SQLite installs that do not run migrations."""
48
+ db = SessionLocal()
49
+ try:
50
+ bind = db.get_bind()
51
+ inspector = inspect(bind)
52
+ columns = {column["name"] for column in inspector.get_columns("users")}
53
+ if "last_login" not in columns:
54
+ logger.info("Adding missing users.last_login column")
55
+ db.execute(text("ALTER TABLE users ADD COLUMN last_login DATETIME"))
56
+ db.commit()
57
+ finally:
58
+ db.close()
59
+
60
+
61
+ def cleanup_inactive_user_vectors(
62
+ inactive_days: int | None = None,
63
+ dry_run: bool | None = None,
64
+ ) -> dict[str, int]:
65
+ """Delete Chroma collections for users inactive past the threshold."""
66
+ ensure_last_login_column()
67
+
68
+ days = inactive_days or int(os.getenv("VECTOR_CLEANUP_INACTIVE_DAYS", "30"))
69
+ is_dry_run = _env_bool("VECTOR_CLEANUP_DRY_RUN", False) if dry_run is None else dry_run
70
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days)
71
+
72
+ stats = {
73
+ "scanned": 0,
74
+ "eligible": 0,
75
+ "deleted": 0,
76
+ "skipped_no_login": 0,
77
+ "failed": 0,
78
+ }
79
+
80
+ db = SessionLocal()
81
+ try:
82
+ users = db.query(User).filter(
83
+ or_(User.last_login.is_(None), User.last_login < cutoff)
84
+ ).all()
85
+
86
+ for user in users:
87
+ stats["scanned"] += 1
88
+
89
+ if user.last_login is None:
90
+ stats["skipped_no_login"] += 1
91
+ logger.info(
92
+ "Skipping user %s because last_login is missing",
93
+ user.id,
94
+ )
95
+ continue
96
+
97
+ stats["eligible"] += 1
98
+ logger.info(
99
+ "User %s inactive since %s; deleting collection=%s dry_run=%s",
100
+ user.id,
101
+ user.last_login,
102
+ f"user_{user.id.replace('-', '_')}"[:63],
103
+ is_dry_run,
104
+ )
105
+
106
+ if is_dry_run:
107
+ continue
108
+
109
+ try:
110
+ delete_user_collection(user.id)
111
+ stats["deleted"] += 1
112
+ except Exception as exc: # defensive script boundary
113
+ stats["failed"] += 1
114
+ logger.warning(
115
+ "Failed deleting vector collection for user %s: %s",
116
+ user.id,
117
+ exc,
118
+ exc_info=True,
119
+ )
120
+
121
+ logger.info("Vector cleanup complete: %s", stats)
122
+ return stats
123
+ finally:
124
+ db.close()
125
+
126
+
127
+ if __name__ == "__main__":
128
+ cleanup_inactive_user_vectors()
frontend/package-lock.json CHANGED
@@ -22,7 +22,8 @@
22
  "remark-gfm": "^4.0.1",
23
  "shadcn": "^4.3.1",
24
  "tailwind-merge": "^3.5.0",
25
- "tw-animate-css": "^1.4.0"
 
26
  },
27
  "devDependencies": {
28
  "@tailwindcss/postcss": "^4",
@@ -1106,9 +1107,6 @@
1106
  "cpu": [
1107
  "arm"
1108
  ],
1109
- "libc": [
1110
- "glibc"
1111
- ],
1112
  "license": "LGPL-3.0-or-later",
1113
  "optional": true,
1114
  "os": [
@@ -1125,9 +1123,6 @@
1125
  "cpu": [
1126
  "arm64"
1127
  ],
1128
- "libc": [
1129
- "glibc"
1130
- ],
1131
  "license": "LGPL-3.0-or-later",
1132
  "optional": true,
1133
  "os": [
@@ -1144,9 +1139,6 @@
1144
  "cpu": [
1145
  "ppc64"
1146
  ],
1147
- "libc": [
1148
- "glibc"
1149
- ],
1150
  "license": "LGPL-3.0-or-later",
1151
  "optional": true,
1152
  "os": [
@@ -1163,9 +1155,6 @@
1163
  "cpu": [
1164
  "riscv64"
1165
  ],
1166
- "libc": [
1167
- "glibc"
1168
- ],
1169
  "license": "LGPL-3.0-or-later",
1170
  "optional": true,
1171
  "os": [
@@ -1182,9 +1171,6 @@
1182
  "cpu": [
1183
  "s390x"
1184
  ],
1185
- "libc": [
1186
- "glibc"
1187
- ],
1188
  "license": "LGPL-3.0-or-later",
1189
  "optional": true,
1190
  "os": [
@@ -1201,9 +1187,6 @@
1201
  "cpu": [
1202
  "x64"
1203
  ],
1204
- "libc": [
1205
- "glibc"
1206
- ],
1207
  "license": "LGPL-3.0-or-later",
1208
  "optional": true,
1209
  "os": [
@@ -1220,9 +1203,6 @@
1220
  "cpu": [
1221
  "arm64"
1222
  ],
1223
- "libc": [
1224
- "musl"
1225
- ],
1226
  "license": "LGPL-3.0-or-later",
1227
  "optional": true,
1228
  "os": [
@@ -1239,9 +1219,6 @@
1239
  "cpu": [
1240
  "x64"
1241
  ],
1242
- "libc": [
1243
- "musl"
1244
- ],
1245
  "license": "LGPL-3.0-or-later",
1246
  "optional": true,
1247
  "os": [
@@ -1258,9 +1235,6 @@
1258
  "cpu": [
1259
  "arm"
1260
  ],
1261
- "libc": [
1262
- "glibc"
1263
- ],
1264
  "license": "Apache-2.0",
1265
  "optional": true,
1266
  "os": [
@@ -1283,9 +1257,6 @@
1283
  "cpu": [
1284
  "arm64"
1285
  ],
1286
- "libc": [
1287
- "glibc"
1288
- ],
1289
  "license": "Apache-2.0",
1290
  "optional": true,
1291
  "os": [
@@ -1308,9 +1279,6 @@
1308
  "cpu": [
1309
  "ppc64"
1310
  ],
1311
- "libc": [
1312
- "glibc"
1313
- ],
1314
  "license": "Apache-2.0",
1315
  "optional": true,
1316
  "os": [
@@ -1333,9 +1301,6 @@
1333
  "cpu": [
1334
  "riscv64"
1335
  ],
1336
- "libc": [
1337
- "glibc"
1338
- ],
1339
  "license": "Apache-2.0",
1340
  "optional": true,
1341
  "os": [
@@ -1358,9 +1323,6 @@
1358
  "cpu": [
1359
  "s390x"
1360
  ],
1361
- "libc": [
1362
- "glibc"
1363
- ],
1364
  "license": "Apache-2.0",
1365
  "optional": true,
1366
  "os": [
@@ -1383,9 +1345,6 @@
1383
  "cpu": [
1384
  "x64"
1385
  ],
1386
- "libc": [
1387
- "glibc"
1388
- ],
1389
  "license": "Apache-2.0",
1390
  "optional": true,
1391
  "os": [
@@ -1408,9 +1367,6 @@
1408
  "cpu": [
1409
  "arm64"
1410
  ],
1411
- "libc": [
1412
- "musl"
1413
- ],
1414
  "license": "Apache-2.0",
1415
  "optional": true,
1416
  "os": [
@@ -1433,9 +1389,6 @@
1433
  "cpu": [
1434
  "x64"
1435
  ],
1436
- "libc": [
1437
- "musl"
1438
- ],
1439
  "license": "Apache-2.0",
1440
  "optional": true,
1441
  "os": [
@@ -1856,9 +1809,6 @@
1856
  "cpu": [
1857
  "arm64"
1858
  ],
1859
- "libc": [
1860
- "glibc"
1861
- ],
1862
  "license": "MIT",
1863
  "optional": true,
1864
  "os": [
@@ -1879,9 +1829,6 @@
1879
  "cpu": [
1880
  "arm64"
1881
  ],
1882
- "libc": [
1883
- "musl"
1884
- ],
1885
  "license": "MIT",
1886
  "optional": true,
1887
  "os": [
@@ -1902,9 +1849,6 @@
1902
  "cpu": [
1903
  "riscv64"
1904
  ],
1905
- "libc": [
1906
- "glibc"
1907
- ],
1908
  "license": "MIT",
1909
  "optional": true,
1910
  "os": [
@@ -1925,9 +1869,6 @@
1925
  "cpu": [
1926
  "x64"
1927
  ],
1928
- "libc": [
1929
- "glibc"
1930
- ],
1931
  "license": "MIT",
1932
  "optional": true,
1933
  "os": [
@@ -1948,9 +1889,6 @@
1948
  "cpu": [
1949
  "x64"
1950
  ],
1951
- "libc": [
1952
- "musl"
1953
- ],
1954
  "license": "MIT",
1955
  "optional": true,
1956
  "os": [
@@ -2072,9 +2010,6 @@
2072
  "cpu": [
2073
  "arm64"
2074
  ],
2075
- "libc": [
2076
- "glibc"
2077
- ],
2078
  "license": "MIT",
2079
  "optional": true,
2080
  "os": [
@@ -2091,9 +2026,6 @@
2091
  "cpu": [
2092
  "arm64"
2093
  ],
2094
- "libc": [
2095
- "musl"
2096
- ],
2097
  "license": "MIT",
2098
  "optional": true,
2099
  "os": [
@@ -2110,9 +2042,6 @@
2110
  "cpu": [
2111
  "x64"
2112
  ],
2113
- "libc": [
2114
- "glibc"
2115
- ],
2116
  "license": "MIT",
2117
  "optional": true,
2118
  "os": [
@@ -2129,9 +2058,6 @@
2129
  "cpu": [
2130
  "x64"
2131
  ],
2132
- "libc": [
2133
- "musl"
2134
- ],
2135
  "license": "MIT",
2136
  "optional": true,
2137
  "os": [
@@ -2446,9 +2372,6 @@
2446
  "arm64"
2447
  ],
2448
  "dev": true,
2449
- "libc": [
2450
- "glibc"
2451
- ],
2452
  "license": "MIT",
2453
  "optional": true,
2454
  "os": [
@@ -2466,9 +2389,6 @@
2466
  "arm64"
2467
  ],
2468
  "dev": true,
2469
- "libc": [
2470
- "musl"
2471
- ],
2472
  "license": "MIT",
2473
  "optional": true,
2474
  "os": [
@@ -2486,9 +2406,6 @@
2486
  "x64"
2487
  ],
2488
  "dev": true,
2489
- "libc": [
2490
- "glibc"
2491
- ],
2492
  "license": "MIT",
2493
  "optional": true,
2494
  "os": [
@@ -2506,9 +2423,6 @@
2506
  "x64"
2507
  ],
2508
  "dev": true,
2509
- "libc": [
2510
- "musl"
2511
- ],
2512
  "license": "MIT",
2513
  "optional": true,
2514
  "os": [
@@ -3206,9 +3120,6 @@
3206
  "arm64"
3207
  ],
3208
  "dev": true,
3209
- "libc": [
3210
- "glibc"
3211
- ],
3212
  "license": "MIT",
3213
  "optional": true,
3214
  "os": [
@@ -3223,9 +3134,6 @@
3223
  "arm64"
3224
  ],
3225
  "dev": true,
3226
- "libc": [
3227
- "musl"
3228
- ],
3229
  "license": "MIT",
3230
  "optional": true,
3231
  "os": [
@@ -3240,9 +3148,6 @@
3240
  "ppc64"
3241
  ],
3242
  "dev": true,
3243
- "libc": [
3244
- "glibc"
3245
- ],
3246
  "license": "MIT",
3247
  "optional": true,
3248
  "os": [
@@ -3257,9 +3162,6 @@
3257
  "riscv64"
3258
  ],
3259
  "dev": true,
3260
- "libc": [
3261
- "glibc"
3262
- ],
3263
  "license": "MIT",
3264
  "optional": true,
3265
  "os": [
@@ -3274,9 +3176,6 @@
3274
  "riscv64"
3275
  ],
3276
  "dev": true,
3277
- "libc": [
3278
- "musl"
3279
- ],
3280
  "license": "MIT",
3281
  "optional": true,
3282
  "os": [
@@ -3291,9 +3190,6 @@
3291
  "s390x"
3292
  ],
3293
  "dev": true,
3294
- "libc": [
3295
- "glibc"
3296
- ],
3297
  "license": "MIT",
3298
  "optional": true,
3299
  "os": [
@@ -3308,9 +3204,6 @@
3308
  "x64"
3309
  ],
3310
  "dev": true,
3311
- "libc": [
3312
- "glibc"
3313
- ],
3314
  "license": "MIT",
3315
  "optional": true,
3316
  "os": [
@@ -3325,9 +3218,6 @@
3325
  "x64"
3326
  ],
3327
  "dev": true,
3328
- "libc": [
3329
- "musl"
3330
- ],
3331
  "license": "MIT",
3332
  "optional": true,
3333
  "os": [
@@ -7288,9 +7178,6 @@
7288
  "arm64"
7289
  ],
7290
  "dev": true,
7291
- "libc": [
7292
- "glibc"
7293
- ],
7294
  "license": "MPL-2.0",
7295
  "optional": true,
7296
  "os": [
@@ -7312,9 +7199,6 @@
7312
  "arm64"
7313
  ],
7314
  "dev": true,
7315
- "libc": [
7316
- "musl"
7317
- ],
7318
  "license": "MPL-2.0",
7319
  "optional": true,
7320
  "os": [
@@ -7336,9 +7220,6 @@
7336
  "x64"
7337
  ],
7338
  "dev": true,
7339
- "libc": [
7340
- "glibc"
7341
- ],
7342
  "license": "MPL-2.0",
7343
  "optional": true,
7344
  "os": [
@@ -7360,9 +7241,6 @@
7360
  "x64"
7361
  ],
7362
  "dev": true,
7363
- "libc": [
7364
- "musl"
7365
- ],
7366
  "license": "MPL-2.0",
7367
  "optional": true,
7368
  "os": [
@@ -11763,6 +11641,35 @@
11763
  "zod": "^3.25.0 || ^4.0.0"
11764
  }
11765
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11766
  "node_modules/zwitch": {
11767
  "version": "2.0.4",
11768
  "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
 
22
  "remark-gfm": "^4.0.1",
23
  "shadcn": "^4.3.1",
24
  "tailwind-merge": "^3.5.0",
25
+ "tw-animate-css": "^1.4.0",
26
+ "zustand": "^5.0.13"
27
  },
28
  "devDependencies": {
29
  "@tailwindcss/postcss": "^4",
 
1107
  "cpu": [
1108
  "arm"
1109
  ],
 
 
 
1110
  "license": "LGPL-3.0-or-later",
1111
  "optional": true,
1112
  "os": [
 
1123
  "cpu": [
1124
  "arm64"
1125
  ],
 
 
 
1126
  "license": "LGPL-3.0-or-later",
1127
  "optional": true,
1128
  "os": [
 
1139
  "cpu": [
1140
  "ppc64"
1141
  ],
 
 
 
1142
  "license": "LGPL-3.0-or-later",
1143
  "optional": true,
1144
  "os": [
 
1155
  "cpu": [
1156
  "riscv64"
1157
  ],
 
 
 
1158
  "license": "LGPL-3.0-or-later",
1159
  "optional": true,
1160
  "os": [
 
1171
  "cpu": [
1172
  "s390x"
1173
  ],
 
 
 
1174
  "license": "LGPL-3.0-or-later",
1175
  "optional": true,
1176
  "os": [
 
1187
  "cpu": [
1188
  "x64"
1189
  ],
 
 
 
1190
  "license": "LGPL-3.0-or-later",
1191
  "optional": true,
1192
  "os": [
 
1203
  "cpu": [
1204
  "arm64"
1205
  ],
 
 
 
1206
  "license": "LGPL-3.0-or-later",
1207
  "optional": true,
1208
  "os": [
 
1219
  "cpu": [
1220
  "x64"
1221
  ],
 
 
 
1222
  "license": "LGPL-3.0-or-later",
1223
  "optional": true,
1224
  "os": [
 
1235
  "cpu": [
1236
  "arm"
1237
  ],
 
 
 
1238
  "license": "Apache-2.0",
1239
  "optional": true,
1240
  "os": [
 
1257
  "cpu": [
1258
  "arm64"
1259
  ],
 
 
 
1260
  "license": "Apache-2.0",
1261
  "optional": true,
1262
  "os": [
 
1279
  "cpu": [
1280
  "ppc64"
1281
  ],
 
 
 
1282
  "license": "Apache-2.0",
1283
  "optional": true,
1284
  "os": [
 
1301
  "cpu": [
1302
  "riscv64"
1303
  ],
 
 
 
1304
  "license": "Apache-2.0",
1305
  "optional": true,
1306
  "os": [
 
1323
  "cpu": [
1324
  "s390x"
1325
  ],
 
 
 
1326
  "license": "Apache-2.0",
1327
  "optional": true,
1328
  "os": [
 
1345
  "cpu": [
1346
  "x64"
1347
  ],
 
 
 
1348
  "license": "Apache-2.0",
1349
  "optional": true,
1350
  "os": [
 
1367
  "cpu": [
1368
  "arm64"
1369
  ],
 
 
 
1370
  "license": "Apache-2.0",
1371
  "optional": true,
1372
  "os": [
 
1389
  "cpu": [
1390
  "x64"
1391
  ],
 
 
 
1392
  "license": "Apache-2.0",
1393
  "optional": true,
1394
  "os": [
 
1809
  "cpu": [
1810
  "arm64"
1811
  ],
 
 
 
1812
  "license": "MIT",
1813
  "optional": true,
1814
  "os": [
 
1829
  "cpu": [
1830
  "arm64"
1831
  ],
 
 
 
1832
  "license": "MIT",
1833
  "optional": true,
1834
  "os": [
 
1849
  "cpu": [
1850
  "riscv64"
1851
  ],
 
 
 
1852
  "license": "MIT",
1853
  "optional": true,
1854
  "os": [
 
1869
  "cpu": [
1870
  "x64"
1871
  ],
 
 
 
1872
  "license": "MIT",
1873
  "optional": true,
1874
  "os": [
 
1889
  "cpu": [
1890
  "x64"
1891
  ],
 
 
 
1892
  "license": "MIT",
1893
  "optional": true,
1894
  "os": [
 
2010
  "cpu": [
2011
  "arm64"
2012
  ],
 
 
 
2013
  "license": "MIT",
2014
  "optional": true,
2015
  "os": [
 
2026
  "cpu": [
2027
  "arm64"
2028
  ],
 
 
 
2029
  "license": "MIT",
2030
  "optional": true,
2031
  "os": [
 
2042
  "cpu": [
2043
  "x64"
2044
  ],
 
 
 
2045
  "license": "MIT",
2046
  "optional": true,
2047
  "os": [
 
2058
  "cpu": [
2059
  "x64"
2060
  ],
 
 
 
2061
  "license": "MIT",
2062
  "optional": true,
2063
  "os": [
 
2372
  "arm64"
2373
  ],
2374
  "dev": true,
 
 
 
2375
  "license": "MIT",
2376
  "optional": true,
2377
  "os": [
 
2389
  "arm64"
2390
  ],
2391
  "dev": true,
 
 
 
2392
  "license": "MIT",
2393
  "optional": true,
2394
  "os": [
 
2406
  "x64"
2407
  ],
2408
  "dev": true,
 
 
 
2409
  "license": "MIT",
2410
  "optional": true,
2411
  "os": [
 
2423
  "x64"
2424
  ],
2425
  "dev": true,
 
 
 
2426
  "license": "MIT",
2427
  "optional": true,
2428
  "os": [
 
3120
  "arm64"
3121
  ],
3122
  "dev": true,
 
 
 
3123
  "license": "MIT",
3124
  "optional": true,
3125
  "os": [
 
3134
  "arm64"
3135
  ],
3136
  "dev": true,
 
 
 
3137
  "license": "MIT",
3138
  "optional": true,
3139
  "os": [
 
3148
  "ppc64"
3149
  ],
3150
  "dev": true,
 
 
 
3151
  "license": "MIT",
3152
  "optional": true,
3153
  "os": [
 
3162
  "riscv64"
3163
  ],
3164
  "dev": true,
 
 
 
3165
  "license": "MIT",
3166
  "optional": true,
3167
  "os": [
 
3176
  "riscv64"
3177
  ],
3178
  "dev": true,
 
 
 
3179
  "license": "MIT",
3180
  "optional": true,
3181
  "os": [
 
3190
  "s390x"
3191
  ],
3192
  "dev": true,
 
 
 
3193
  "license": "MIT",
3194
  "optional": true,
3195
  "os": [
 
3204
  "x64"
3205
  ],
3206
  "dev": true,
 
 
 
3207
  "license": "MIT",
3208
  "optional": true,
3209
  "os": [
 
3218
  "x64"
3219
  ],
3220
  "dev": true,
 
 
 
3221
  "license": "MIT",
3222
  "optional": true,
3223
  "os": [
 
7178
  "arm64"
7179
  ],
7180
  "dev": true,
 
 
 
7181
  "license": "MPL-2.0",
7182
  "optional": true,
7183
  "os": [
 
7199
  "arm64"
7200
  ],
7201
  "dev": true,
 
 
 
7202
  "license": "MPL-2.0",
7203
  "optional": true,
7204
  "os": [
 
7220
  "x64"
7221
  ],
7222
  "dev": true,
 
 
 
7223
  "license": "MPL-2.0",
7224
  "optional": true,
7225
  "os": [
 
7241
  "x64"
7242
  ],
7243
  "dev": true,
 
 
 
7244
  "license": "MPL-2.0",
7245
  "optional": true,
7246
  "os": [
 
11641
  "zod": "^3.25.0 || ^4.0.0"
11642
  }
11643
  },
11644
+ "node_modules/zustand": {
11645
+ "version": "5.0.13",
11646
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
11647
+ "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
11648
+ "license": "MIT",
11649
+ "engines": {
11650
+ "node": ">=12.20.0"
11651
+ },
11652
+ "peerDependencies": {
11653
+ "@types/react": ">=18.0.0",
11654
+ "immer": ">=9.0.6",
11655
+ "react": ">=18.0.0",
11656
+ "use-sync-external-store": ">=1.2.0"
11657
+ },
11658
+ "peerDependenciesMeta": {
11659
+ "@types/react": {
11660
+ "optional": true
11661
+ },
11662
+ "immer": {
11663
+ "optional": true
11664
+ },
11665
+ "react": {
11666
+ "optional": true
11667
+ },
11668
+ "use-sync-external-store": {
11669
+ "optional": true
11670
+ }
11671
+ }
11672
+ },
11673
  "node_modules/zwitch": {
11674
  "version": "2.0.4",
11675
  "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
frontend/package.json CHANGED
@@ -23,7 +23,8 @@
23
  "remark-gfm": "^4.0.1",
24
  "shadcn": "^4.3.1",
25
  "tailwind-merge": "^3.5.0",
26
- "tw-animate-css": "^1.4.0"
 
27
  },
28
  "devDependencies": {
29
  "@tailwindcss/postcss": "^4",
 
23
  "remark-gfm": "^4.0.1",
24
  "shadcn": "^4.3.1",
25
  "tailwind-merge": "^3.5.0",
26
+ "tw-animate-css": "^1.4.0",
27
+ "zustand": "^5.0.13"
28
  },
29
  "devDependencies": {
30
  "@tailwindcss/postcss": "^4",
frontend/src/components/chat/ChatPanel.tsx CHANGED
@@ -3,38 +3,28 @@
3
  import { useState, useRef, useEffect } from "react";
4
  import type { DocInfo } from "@/app/dashboard/page";
5
  import { api, API_BASE } from "@/lib/api";
 
6
  import { Button } from "@/components/ui/button";
7
  import { Textarea } from "@/components/ui/textarea";
8
  import MessageBubble from "./MessageBubble";
9
  import SourceCard from "./SourceCard";
10
  import { Send, Loader2, Trash2, MessageSquare, Download } from "lucide-react";
11
 
12
- export interface SourceChunk {
13
- text: string;
14
- filename: string;
15
- page: number;
16
- score: number;
17
- confidence: number;
18
- }
19
-
20
- export interface ChatMsg {
21
- id: string;
22
- role: "user" | "assistant";
23
- content: string;
24
- sources: SourceChunk[];
25
- isStreaming?: boolean;
26
- }
27
-
28
  interface Props {
29
  activeDoc: DocInfo | null;
30
  onCitationClick: (page: number) => void;
31
  }
32
 
33
  export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
34
- const [messages, setMessages] = useState<ChatMsg[]>([]);
35
- const [input, setInput] = useState("");
36
- const [streaming, setStreaming] = useState(false);
37
- const [isTyping, setIsTyping] = useState(false);
 
 
 
 
 
38
  const [showExportMenu, setShowExportMenu] = useState(false);
39
  const textareaRef = useRef<HTMLTextAreaElement>(null);
40
  const bottomRef = useRef<HTMLDivElement>(null);
@@ -63,6 +53,12 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
63
  bottomRef.current?.scrollIntoView({ behavior: "smooth" });
64
  }, [messages]);
65
 
 
 
 
 
 
 
66
  // Load history on doc change
67
  useEffect(() => {
68
  if (!activeDoc) {
@@ -102,7 +98,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
102
  return () => {
103
  cancelled = true;
104
  };
105
- }, [activeDoc]);
106
 
107
  const handleSend = async () => {
108
  if (!input.trim() || streaming) return;
@@ -211,7 +207,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
211
  }
212
  };
213
 
214
- const handleExport = (format: "md" | "txt") => {
215
  if (!activeDoc) return;
216
  setShowExportMenu(false);
217
  const token = localStorage.getItem("token");
@@ -350,6 +346,14 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
350
  <span className="text-base">📄</span>
351
  Plain Text (.txt)
352
  </button>
 
 
 
 
 
 
 
 
353
  </div>
354
  )}
355
  </div>
@@ -369,4 +373,4 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
369
  </div>
370
  </div>
371
  );
372
- }
 
3
  import { useState, useRef, useEffect } from "react";
4
  import type { DocInfo } from "@/app/dashboard/page";
5
  import { api, API_BASE } from "@/lib/api";
6
+ import { useChatStore, type ChatMsg, type SourceChunk } from "@/store/chat-store";
7
  import { Button } from "@/components/ui/button";
8
  import { Textarea } from "@/components/ui/textarea";
9
  import MessageBubble from "./MessageBubble";
10
  import SourceCard from "./SourceCard";
11
  import { Send, Loader2, Trash2, MessageSquare, Download } from "lucide-react";
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  interface Props {
14
  activeDoc: DocInfo | null;
15
  onCitationClick: (page: number) => void;
16
  }
17
 
18
  export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
19
+ const messages = useChatStore((state) => state.messages);
20
+ const input = useChatStore((state) => state.input);
21
+ const streaming = useChatStore((state) => state.streaming);
22
+ const isTyping = useChatStore((state) => state.isTyping);
23
+ const setMessages = useChatStore((state) => state.setMessages);
24
+ const setInput = useChatStore((state) => state.setInput);
25
+ const setStreaming = useChatStore((state) => state.setStreaming);
26
+ const setIsTyping = useChatStore((state) => state.setIsTyping);
27
+ const resetChat = useChatStore((state) => state.resetChat);
28
  const [showExportMenu, setShowExportMenu] = useState(false);
29
  const textareaRef = useRef<HTMLTextAreaElement>(null);
30
  const bottomRef = useRef<HTMLDivElement>(null);
 
53
  bottomRef.current?.scrollIntoView({ behavior: "smooth" });
54
  }, [messages]);
55
 
56
+ useEffect(() => {
57
+ return () => {
58
+ resetChat();
59
+ };
60
+ }, [resetChat]);
61
+
62
  // Load history on doc change
63
  useEffect(() => {
64
  if (!activeDoc) {
 
98
  return () => {
99
  cancelled = true;
100
  };
101
+ }, [activeDoc, resetChat, setMessages]);
102
 
103
  const handleSend = async () => {
104
  if (!input.trim() || streaming) return;
 
207
  }
208
  };
209
 
210
+ const handleExport = (format: "md" | "txt" | "pdf") => {
211
  if (!activeDoc) return;
212
  setShowExportMenu(false);
213
  const token = localStorage.getItem("token");
 
346
  <span className="text-base">📄</span>
347
  Plain Text (.txt)
348
  </button>
349
+ <button
350
+ id="export-pdf-btn"
351
+ onClick={() => handleExport("pdf")}
352
+ className="w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
353
+ >
354
+ <span className="text-base">📕</span>
355
+ PDF (.pdf)
356
+ </button>
357
  </div>
358
  )}
359
  </div>
 
373
  </div>
374
  </div>
375
  );
376
+ }
frontend/src/components/chat/MessageBubble.tsx CHANGED
@@ -3,7 +3,7 @@
3
  import { useState, useRef } from "react";
4
  import ReactMarkdown from "react-markdown";
5
  import remarkGfm from "remark-gfm";
6
- import type { ChatMsg } from "./ChatPanel";
7
  import { Brain, User, Copy, Check } from "lucide-react";
8
  import { Button } from "@/components/ui/button";
9
 
 
3
  import { useState, useRef } from "react";
4
  import ReactMarkdown from "react-markdown";
5
  import remarkGfm from "remark-gfm";
6
+ import type { ChatMsg } from "@/store/chat-store";
7
  import { Brain, User, Copy, Check } from "lucide-react";
8
  import { Button } from "@/components/ui/button";
9
 
frontend/src/components/chat/SourceCard.tsx CHANGED
@@ -1,7 +1,7 @@
1
  "use client";
2
 
3
  import { useState } from "react";
4
- import type { SourceChunk } from "./ChatPanel";
5
  import { Badge } from "@/components/ui/badge";
6
  import { Button } from "@/components/ui/button";
7
  import { ChevronDown, ChevronUp, FileText, Eye } from "lucide-react";
 
1
  "use client";
2
 
3
  import { useState } from "react";
4
+ import type { SourceChunk } from "@/store/chat-store";
5
  import { Badge } from "@/components/ui/badge";
6
  import { Button } from "@/components/ui/button";
7
  import { ChevronDown, ChevronUp, FileText, Eye } from "lucide-react";
frontend/src/lib/auth.tsx CHANGED
@@ -1,73 +1,24 @@
1
  "use client";
2
 
3
- import React, { createContext, useContext, useEffect, useState, useCallback } from "react";
4
- import { api } from "./api";
5
-
6
- interface User {
7
- id: string;
8
- username: string;
9
- email: string;
10
- is_admin: boolean;
11
- created_at: string;
12
- }
13
-
14
- interface AuthContextType {
15
- user: User | null;
16
- token: string | null;
17
- loading: boolean;
18
- login: (email: string, password: string) => Promise<void>;
19
- register: (username: string, email: string, password: string) => Promise<void>;
20
- logout: () => void;
21
- }
22
-
23
- const AuthContext = createContext<AuthContextType | undefined>(undefined);
24
 
25
  export function AuthProvider({ children }: { children: React.ReactNode }) {
26
- const [user, setUser] = useState<User | null>(null);
27
- // Lazy initializer reads localStorage once — avoids setState-in-effect lint error
28
- const [token, setToken] = useState<string | null>(
29
- () => (typeof window !== "undefined" ? localStorage.getItem("token") : null)
30
- );
31
- // loading=true only when a token exists and needs server validation.
32
- // If there's no token we're already done — no effect setState needed.
33
- const [loading, setLoading] = useState<boolean>(
34
- () => typeof window !== "undefined" && !!localStorage.getItem("token")
35
- );
36
 
37
- // ── Validate saved token on mount ─────────────────
38
- // NOTE: no synchronous setState here — setLoading/setUser/setToken are
39
- // only called inside async callbacks (.then / .catch / .finally).
40
  useEffect(() => {
41
- if (!token) return; // loading is already false when token is null
42
- api
43
- .get<User>("/api/v1/auth/me", { token })
44
- .then(setUser)
45
- .catch(() => {
46
- localStorage.removeItem("token");
47
- localStorage.removeItem("refresh_token");
48
- setToken(null);
49
- })
50
- .finally(() => setLoading(false));
51
- // eslint-disable-next-line react-hooks/exhaustive-deps
52
- }, []); // intentionally runs once on mount only
53
 
54
- // ── Listen for token refresh events from ApiClient ──
55
- // When the API client auto-refreshes tokens, it dispatches custom events
56
- // so this context stays in sync without prop drilling.
57
  useEffect(() => {
58
  const handleTokensRefreshed = (e: Event) => {
59
- const detail = (e as CustomEvent).detail;
60
- if (detail?.accessToken) {
61
- setToken(detail.accessToken);
62
- }
63
- if (detail?.user) {
64
- setUser(detail.user);
65
- }
66
  };
67
 
68
  const handleLoggedOut = () => {
69
- setToken(null);
70
- setUser(null);
71
  };
72
 
73
  window.addEventListener("auth:tokens-refreshed", handleTokensRefreshed);
@@ -77,46 +28,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
77
  window.removeEventListener("auth:tokens-refreshed", handleTokensRefreshed);
78
  window.removeEventListener("auth:logged-out", handleLoggedOut);
79
  };
80
- }, []);
81
-
82
- const login = useCallback(async (email: string, password: string) => {
83
- const data = await api.post<{ access_token: string; refresh_token: string; user: User }>(
84
- "/api/v1/auth/login",
85
- { email, password }
86
- );
87
- localStorage.setItem("token", data.access_token);
88
- localStorage.setItem("refresh_token", data.refresh_token);
89
- setToken(data.access_token);
90
- setUser(data.user);
91
- }, []);
92
-
93
- const register = useCallback(async (username: string, email: string, password: string) => {
94
- const data = await api.post<{ access_token: string; refresh_token: string; user: User }>(
95
- "/api/v1/auth/register",
96
- { username, email, password }
97
- );
98
- localStorage.setItem("token", data.access_token);
99
- localStorage.setItem("refresh_token", data.refresh_token);
100
- setToken(data.access_token);
101
- setUser(data.user);
102
- }, []);
103
-
104
- const logout = useCallback(() => {
105
- localStorage.removeItem("token");
106
- localStorage.removeItem("refresh_token");
107
- setToken(null);
108
- setUser(null);
109
- }, []);
110
 
111
- return (
112
- <AuthContext.Provider value={{ user, token, loading, login, register, logout }}>
113
- {children}
114
- </AuthContext.Provider>
115
- );
116
  }
117
 
118
  export function useAuth() {
119
- const ctx = useContext(AuthContext);
120
- if (!ctx) throw new Error("useAuth must be used within AuthProvider");
121
- return ctx;
122
  }
 
1
  "use client";
2
 
3
+ import React, { useEffect } from "react";
4
+ import { useAuthStore } from "@/store/auth-store";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  export function AuthProvider({ children }: { children: React.ReactNode }) {
7
+ const initializeAuth = useAuthStore((state) => state.initializeAuth);
8
+ const syncTokensRefreshed = useAuthStore((state) => state.syncTokensRefreshed);
9
+ const syncLoggedOut = useAuthStore((state) => state.syncLoggedOut);
 
 
 
 
 
 
 
10
 
 
 
 
11
  useEffect(() => {
12
+ void initializeAuth();
13
+ }, [initializeAuth]);
 
 
 
 
 
 
 
 
 
 
14
 
 
 
 
15
  useEffect(() => {
16
  const handleTokensRefreshed = (e: Event) => {
17
+ syncTokensRefreshed((e as CustomEvent).detail);
 
 
 
 
 
 
18
  };
19
 
20
  const handleLoggedOut = () => {
21
+ syncLoggedOut();
 
22
  };
23
 
24
  window.addEventListener("auth:tokens-refreshed", handleTokensRefreshed);
 
28
  window.removeEventListener("auth:tokens-refreshed", handleTokensRefreshed);
29
  window.removeEventListener("auth:logged-out", handleLoggedOut);
30
  };
31
+ }, [syncLoggedOut, syncTokensRefreshed]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
+ return <>{children}</>;
 
 
 
 
34
  }
35
 
36
  export function useAuth() {
37
+ return useAuthStore();
 
 
38
  }
frontend/src/store/auth-store.ts ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { create } from "zustand";
4
+ import { api } from "@/lib/api";
5
+
6
+ export interface AuthUser {
7
+ id: string;
8
+ username: string;
9
+ email: string;
10
+ is_admin: boolean;
11
+ created_at: string;
12
+ }
13
+
14
+ interface AuthStore {
15
+ user: AuthUser | null;
16
+ token: string | null;
17
+ loading: boolean;
18
+ initialized: boolean;
19
+ login: (email: string, password: string) => Promise<void>;
20
+ register: (username: string, email: string, password: string) => Promise<void>;
21
+ logout: () => void;
22
+ initializeAuth: () => Promise<void>;
23
+ syncTokensRefreshed: (detail?: { accessToken?: string; user?: AuthUser | null }) => void;
24
+ syncLoggedOut: () => void;
25
+ }
26
+
27
+ const getStoredToken = () =>
28
+ typeof window !== "undefined" ? localStorage.getItem("token") : null;
29
+
30
+ const clearStoredTokens = () => {
31
+ if (typeof window === "undefined") return;
32
+ localStorage.removeItem("token");
33
+ localStorage.removeItem("refresh_token");
34
+ };
35
+
36
+ export const useAuthStore = create<AuthStore>((set, get) => ({
37
+ user: null,
38
+ token: getStoredToken(),
39
+ loading: !!getStoredToken(),
40
+ initialized: false,
41
+
42
+ async login(email, password) {
43
+ const data = await api.post<{ access_token: string; refresh_token: string; user: AuthUser }>(
44
+ "/api/v1/auth/login",
45
+ { email, password }
46
+ );
47
+
48
+ localStorage.setItem("token", data.access_token);
49
+ localStorage.setItem("refresh_token", data.refresh_token);
50
+ set({
51
+ token: data.access_token,
52
+ user: data.user,
53
+ loading: false,
54
+ initialized: true,
55
+ });
56
+ },
57
+
58
+ async register(username, email, password) {
59
+ const data = await api.post<{ access_token: string; refresh_token: string; user: AuthUser }>(
60
+ "/api/v1/auth/register",
61
+ { username, email, password }
62
+ );
63
+
64
+ localStorage.setItem("token", data.access_token);
65
+ localStorage.setItem("refresh_token", data.refresh_token);
66
+ set({
67
+ token: data.access_token,
68
+ user: data.user,
69
+ loading: false,
70
+ initialized: true,
71
+ });
72
+ },
73
+
74
+ logout() {
75
+ clearStoredTokens();
76
+ set({
77
+ token: null,
78
+ user: null,
79
+ loading: false,
80
+ initialized: true,
81
+ });
82
+ },
83
+
84
+ async initializeAuth() {
85
+ const { initialized, token } = get();
86
+ if (initialized) return;
87
+
88
+ const storedToken = token ?? getStoredToken();
89
+ if (!storedToken) {
90
+ set({ token: null, user: null, loading: false, initialized: true });
91
+ return;
92
+ }
93
+
94
+ set({ token: storedToken, loading: true });
95
+
96
+ try {
97
+ const user = await api.get<AuthUser>("/api/v1/auth/me", { token: storedToken });
98
+ set({ user, token: storedToken, loading: false, initialized: true });
99
+ } catch {
100
+ clearStoredTokens();
101
+ set({ user: null, token: null, loading: false, initialized: true });
102
+ }
103
+ },
104
+
105
+ syncTokensRefreshed(detail) {
106
+ if (!detail) return;
107
+
108
+ set((state) => ({
109
+ token: detail.accessToken ?? state.token,
110
+ user: detail.user ?? state.user,
111
+ loading: false,
112
+ initialized: true,
113
+ }));
114
+ },
115
+
116
+ syncLoggedOut() {
117
+ set({
118
+ token: null,
119
+ user: null,
120
+ loading: false,
121
+ initialized: true,
122
+ });
123
+ },
124
+ }));
frontend/src/store/chat-store.ts ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { create } from "zustand";
4
+
5
+ export interface SourceChunk {
6
+ text: string;
7
+ filename: string;
8
+ page: number;
9
+ score: number;
10
+ confidence: number;
11
+ }
12
+
13
+ export interface ChatMsg {
14
+ id: string;
15
+ role: "user" | "assistant";
16
+ content: string;
17
+ sources: SourceChunk[];
18
+ isStreaming?: boolean;
19
+ }
20
+
21
+ type Setter<T> = T | ((prev: T) => T);
22
+
23
+ interface ChatStore {
24
+ messages: ChatMsg[];
25
+ input: string;
26
+ streaming: boolean;
27
+ isTyping: boolean;
28
+ setMessages: (value: Setter<ChatMsg[]>) => void;
29
+ setInput: (value: Setter<string>) => void;
30
+ setStreaming: (value: Setter<boolean>) => void;
31
+ setIsTyping: (value: Setter<boolean>) => void;
32
+ resetChat: () => void;
33
+ }
34
+
35
+ const resolveValue = <T,>(value: Setter<T>, current: T): T =>
36
+ typeof value === "function" ? (value as (prev: T) => T)(current) : value;
37
+
38
+ export const useChatStore = create<ChatStore>((set) => ({
39
+ messages: [],
40
+ input: "",
41
+ streaming: false,
42
+ isTyping: false,
43
+
44
+ setMessages(value) {
45
+ set((state) => ({ messages: resolveValue(value, state.messages) }));
46
+ },
47
+
48
+ setInput(value) {
49
+ set((state) => ({ input: resolveValue(value, state.input) }));
50
+ },
51
+
52
+ setStreaming(value) {
53
+ set((state) => ({ streaming: resolveValue(value, state.streaming) }));
54
+ },
55
+
56
+ setIsTyping(value) {
57
+ set((state) => ({ isTyping: resolveValue(value, state.isTyping) }));
58
+ },
59
+
60
+ resetChat() {
61
+ set({
62
+ messages: [],
63
+ input: "",
64
+ streaming: false,
65
+ isTyping: false,
66
+ });
67
+ },
68
+ }));