Jiya3177 commited on
Commit
886fdec
·
2 Parent(s): a752efabd035a4

fix: resolve share branch conflicts

Browse files
.github/ISSUE_TEMPLATE/bug_report.yml CHANGED
@@ -1,84 +1,65 @@
1
- name: 🐛 Bug Report
2
- description: Report a bug or unexpected behavior
3
- labels: ["bug", "needs-triage"]
4
- assignees: []
5
-
 
6
  body:
7
  - type: markdown
8
  attributes:
9
  value: |
10
- Thanks for taking the time to file a bug report! Please fill this out as completely as possible.
 
11
 
12
  - type: textarea
13
  id: description
14
  attributes:
15
- label: Describe the Bug
16
- description: A clear description of what the bug is.
17
- placeholder: "When I do X, Y happens instead of Z."
18
  validations:
19
  required: true
20
 
21
  - type: textarea
22
  id: reproduction
23
  attributes:
24
- label: Steps to Reproduce
25
- description: How do we reproduce this bug?
26
- placeholder: |
27
  1. Go to '...'
28
- 2. Click on '...'
29
- 3. See error
 
30
  validations:
31
  required: true
32
 
33
  - type: textarea
34
  id: expected
35
  attributes:
36
- label: Expected Behavior
37
- description: What should have happened?
38
  validations:
39
  required: true
40
 
41
  - type: textarea
42
  id: screenshots
43
  attributes:
44
- label: Screenshots / Logs
45
- description: Paste any relevant error output or screenshots here.
46
 
47
- - type: dropdown
48
- id: area
49
  attributes:
50
- label: Area Affected
51
- multiple: true
52
- options:
53
- - Backend (FastAPI)
54
- - Frontend (Next.js)
55
- - RAG / Embeddings
56
- - Authentication
57
- - File Upload
58
- - Chat / Streaming
59
- - Docker / Deployment
60
- - Documentation
61
  validations:
62
  required: true
63
 
64
- - type: input
65
- id: python-version
66
- attributes:
67
- label: Python Version (if backend issue)
68
- placeholder: "e.g. 3.11"
69
-
70
- - type: input
71
- id: node-version
72
- attributes:
73
- label: Node.js Version (if frontend issue)
74
- placeholder: "e.g. 20.x"
75
-
76
  - type: checkboxes
77
- id: checklist
78
  attributes:
79
- label: Checklist
 
80
  options:
81
- - label: I have searched existing issues and this is not a duplicate.
82
- required: true
83
- - label: I am targeting the `dev` branch, not `main`.
84
- required: true
 
1
+ name: "\U0001f41b Bug Report"
2
+ description: "Create a report to help us improve the project by fixing a bug."
3
+ title: "[BUG] "
4
+ labels: ["bug"]
5
+ assignees:
6
+ - "param20h"
7
  body:
8
  - type: markdown
9
  attributes:
10
  value: |
11
+ Thanks for taking the time to fill out this bug report!
12
+ Before you submit, please search the issue tracker to see if this has already been reported.
13
 
14
  - type: textarea
15
  id: description
16
  attributes:
17
+ label: "Description of the Bug"
18
+ description: "A clear and concise description of what the bug is."
19
+ placeholder: "When I click on X, nothing happens..."
20
  validations:
21
  required: true
22
 
23
  - type: textarea
24
  id: reproduction
25
  attributes:
26
+ label: "Steps to Reproduce"
27
+ description: "How can we reproduce this issue?"
28
+ value: |
29
  1. Go to '...'
30
+ 2. Click on '....'
31
+ 3. Scroll down to '....'
32
+ 4. See error
33
  validations:
34
  required: true
35
 
36
  - type: textarea
37
  id: expected
38
  attributes:
39
+ label: "Expected Behavior"
40
+ description: "A clear and concise description of what you expected to happen."
41
  validations:
42
  required: true
43
 
44
  - type: textarea
45
  id: screenshots
46
  attributes:
47
+ label: "Screenshots / Logs"
48
+ description: "If applicable, add screenshots or error logs to help explain your problem."
49
 
50
+ - type: input
51
+ id: environment
52
  attributes:
53
+ label: "Environment"
54
+ description: "What OS, browser, or environment were you using?"
55
+ placeholder: "e.g., macOS Sequoia, Chrome 120, Node.js v20"
 
 
 
 
 
 
 
 
56
  validations:
57
  required: true
58
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  - type: checkboxes
60
+ id: gssoc
61
  attributes:
62
+ label: "GSSoC '24"
63
+ description: "Are you a GSSoC contributor?"
64
  options:
65
+ - label: "Yes, I am participating in GirlScript Summer of Code and would like to fix this."
 
 
 
.github/ISSUE_TEMPLATE/feature_request.yml CHANGED
@@ -1,69 +1,48 @@
1
- name: Feature Request
2
- description: Suggest a new feature or enhancement
3
- labels: ["enhancement", "needs-triage"]
4
- assignees: []
5
-
 
6
  body:
7
  - type: markdown
8
  attributes:
9
  value: |
10
- Got an idea? Great! Please describe it clearly so we can discuss and prioritize it.
 
11
 
12
  - type: textarea
13
  id: problem
14
  attributes:
15
- label: Problem / Motivation
16
- description: What problem does this solve? What's the current limitation?
17
- placeholder: "I find it frustrating when..."
18
  validations:
19
  required: true
20
 
21
  - type: textarea
22
  id: solution
23
  attributes:
24
- label: Proposed Solution
25
- description: What do you want to happen?
26
  validations:
27
  required: true
28
 
29
  - type: textarea
30
  id: alternatives
31
  attributes:
32
- label: Alternatives Considered
33
- description: Any other approaches you considered?
34
-
35
- - type: dropdown
36
- id: area
37
- attributes:
38
- label: Which area does this affect?
39
- multiple: true
40
- options:
41
- - Backend (FastAPI)
42
- - Frontend (Next.js)
43
- - RAG / Embeddings
44
- - Authentication
45
- - File Upload
46
- - Chat / Streaming
47
- - Docker / Deployment
48
- - Documentation
49
- - New Area
50
- validations:
51
- required: true
52
 
53
- - type: dropdown
54
- id: difficulty
55
  attributes:
56
- label: Estimated Difficulty
57
- options:
58
- - "🟢 Easy (good first issue)"
59
- - "🟡 Medium"
60
- - "🔴 Hard / Needs discussion"
61
 
62
  - type: checkboxes
63
- id: checklist
64
  attributes:
65
- label: Checklist
 
66
  options:
67
- - label: I have searched existing issues and this is not a duplicate.
68
- required: true
69
- - label: I am willing to work on this myself (optional but appreciated!).
 
1
+ name: "\U0001f680 Feature Request"
2
+ description: "Suggest an idea for this project."
3
+ title: "[FEAT] "
4
+ labels: ["enhancement"]
5
+ assignees:
6
+ - "param20h"
7
  body:
8
  - type: markdown
9
  attributes:
10
  value: |
11
+ Thanks for taking the time to suggest a new feature!
12
+ Please provide as much context as possible so we can properly evaluate your idea.
13
 
14
  - type: textarea
15
  id: problem
16
  attributes:
17
+ label: "Is your feature request related to a problem? Please describe."
18
+ description: "A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]"
 
19
  validations:
20
  required: true
21
 
22
  - type: textarea
23
  id: solution
24
  attributes:
25
+ label: "Describe the solution you'd like"
26
+ description: "A clear and concise description of what you want to happen."
27
  validations:
28
  required: true
29
 
30
  - type: textarea
31
  id: alternatives
32
  attributes:
33
+ label: "Describe alternatives you've considered"
34
+ description: "A clear and concise description of any alternative solutions or features you've considered."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
+ - type: textarea
37
+ id: additional_context
38
  attributes:
39
+ label: "Additional Context"
40
+ description: "Add any other context, screenshots, or mockups about the feature request here."
 
 
 
41
 
42
  - type: checkboxes
43
+ id: gssoc
44
  attributes:
45
+ label: "GSSoC '24"
46
+ description: "Are you a GSSoC contributor?"
47
  options:
48
+ - label: "Yes, I am participating in GirlScript Summer of Code and would like to build this."
 
 
.github/workflows/ci.yml CHANGED
@@ -48,16 +48,18 @@ jobs:
48
  env:
49
  SECRET_KEY: ci-dummy-secret
50
  DATABASE_URL: sqlite:///./ci_test.db
 
51
  HF_TOKEN: ci-dummy-token
52
  UPLOAD_DIR: /tmp/uploads
53
  CHROMA_PERSIST_DIR: /tmp/chroma
54
  run: |
55
- python -c "import sys; sys.path.insert(0, 'backend'); from app.config import settings; print('Config imports OK')" || true
56
 
57
  - name: Run backend pytest suite
58
  env:
59
  SECRET_KEY: ci-dummy-secret
60
  DATABASE_URL: sqlite:///./ci_test.db
 
61
  HF_TOKEN: ci-dummy-token
62
  UPLOAD_DIR: /tmp/uploads
63
  CHROMA_PERSIST_DIR: /tmp/chroma
 
48
  env:
49
  SECRET_KEY: ci-dummy-secret
50
  DATABASE_URL: sqlite:///./ci_test.db
51
+ DEBUG: "false"
52
  HF_TOKEN: ci-dummy-token
53
  UPLOAD_DIR: /tmp/uploads
54
  CHROMA_PERSIST_DIR: /tmp/chroma
55
  run: |
56
+ python -c "import sys; sys.path.insert(0, 'backend'); from app.config import get_settings; get_settings(); print('Config imports OK')"
57
 
58
  - name: Run backend pytest suite
59
  env:
60
  SECRET_KEY: ci-dummy-secret
61
  DATABASE_URL: sqlite:///./ci_test.db
62
+ DEBUG: "false"
63
  HF_TOKEN: ci-dummy-token
64
  UPLOAD_DIR: /tmp/uploads
65
  CHROMA_PERSIST_DIR: /tmp/chroma
backend/app/main.py CHANGED
@@ -92,11 +92,13 @@ from app.routes.auth import router as auth_router
92
  from app.routes.documents import router as documents_router
93
  from app.routes.chat import router as chat_router
94
  from app.routes.github import router as github_router
 
95
 
96
  app.include_router(auth_router, prefix="/api/v1")
97
  app.include_router(documents_router, prefix="/api/v1")
98
  app.include_router(chat_router, prefix="/api/v1")
99
  app.include_router(github_router, prefix="/api/v1")
 
100
 
101
 
102
  # ── Health Check ─────────────────────────────────────
 
92
  from app.routes.documents import router as documents_router
93
  from app.routes.chat import router as chat_router
94
  from app.routes.github import router as github_router
95
+ from app.routes.admin import router as admin_router
96
 
97
  app.include_router(auth_router, prefix="/api/v1")
98
  app.include_router(documents_router, prefix="/api/v1")
99
  app.include_router(chat_router, prefix="/api/v1")
100
  app.include_router(github_router, prefix="/api/v1")
101
+ app.include_router(admin_router, prefix="/api/v1")
102
 
103
 
104
  # ── Health Check ─────────────────────────────────────
backend/app/metrics.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Runtime metrics helpers for lightweight operational statistics.
3
+ """
4
+ from threading import Lock
5
+
6
+
7
+ _metrics_lock = Lock()
8
+ _query_count = 0
9
+ _query_response_time_total_ms = 0.0
10
+
11
+
12
+ def record_query_response_time(duration_seconds: float) -> None:
13
+ """Record one completed query response duration."""
14
+ global _query_count, _query_response_time_total_ms
15
+
16
+ duration_ms = max(duration_seconds, 0) * 1000
17
+ with _metrics_lock:
18
+ _query_count += 1
19
+ _query_response_time_total_ms += duration_ms
20
+
21
+
22
+ def get_query_metrics() -> dict[str, float | int]:
23
+ """Return aggregate query metrics for the current process lifetime."""
24
+ with _metrics_lock:
25
+ average_ms = (
26
+ _query_response_time_total_ms / _query_count
27
+ if _query_count
28
+ else 0.0
29
+ )
30
+ return {
31
+ "query_count": _query_count,
32
+ "average_query_response_time_ms": round(average_ms, 2),
33
+ }
backend/app/rag/vectorstore.py CHANGED
@@ -4,11 +4,7 @@ Per-user collections for data isolation.
4
  """
5
  import logging
6
  from typing import List, Dict, Any, Optional
7
- import chromadb
8
- from chromadb.config import Settings as ChromaSettings
9
  from app.config import get_settings
10
- from app.rag.embeddings import get_embedding_model
11
- from app.rag.vision import generate_captions_for_chunks
12
 
13
  logger = logging.getLogger(__name__)
14
  settings = get_settings()
@@ -17,12 +13,15 @@ settings = get_settings()
17
  _chroma_client = None
18
 
19
 
20
- def get_chroma_client() -> chromadb.ClientAPI:
21
  """Get or create persistent ChromaDB client."""
22
  global _chroma_client
23
 
24
  if _chroma_client is None:
25
  import os
 
 
 
26
  os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
27
 
28
  _chroma_client = chromadb.PersistentClient(
@@ -58,11 +57,15 @@ def store_chunks(
58
 
59
  # Generate captions for any extracted images before embedding
60
  try:
 
 
61
  generate_captions_for_chunks(chunks)
62
  except Exception as e:
63
  logger.warning(f"Could not generate image captions: {e}")
64
 
65
  client = get_chroma_client()
 
 
66
  embedding_model = get_embedding_model()
67
 
68
  collection_name = get_collection_name(user_id)
 
4
  """
5
  import logging
6
  from typing import List, Dict, Any, Optional
 
 
7
  from app.config import get_settings
 
 
8
 
9
  logger = logging.getLogger(__name__)
10
  settings = get_settings()
 
13
  _chroma_client = None
14
 
15
 
16
+ def get_chroma_client():
17
  """Get or create persistent ChromaDB client."""
18
  global _chroma_client
19
 
20
  if _chroma_client is None:
21
  import os
22
+ import chromadb
23
+ from chromadb.config import Settings as ChromaSettings
24
+
25
  os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
26
 
27
  _chroma_client = chromadb.PersistentClient(
 
57
 
58
  # Generate captions for any extracted images before embedding
59
  try:
60
+ from app.rag.vision import generate_captions_for_chunks
61
+
62
  generate_captions_for_chunks(chunks)
63
  except Exception as e:
64
  logger.warning(f"Could not generate image captions: {e}")
65
 
66
  client = get_chroma_client()
67
+ from app.rag.embeddings import get_embedding_model
68
+
69
  embedding_model = get_embedding_model()
70
 
71
  collection_name = get_collection_name(user_id)
backend/app/routes/admin.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Admin-only operational statistics routes.
3
+ """
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ from fastapi import APIRouter, Depends
8
+ from sqlalchemy import func
9
+ from sqlalchemy.orm import Session
10
+
11
+ from app.auth import get_admin_user
12
+ from app.config import get_settings
13
+ from app.database import get_db
14
+ from app.metrics import get_query_metrics
15
+ from app.models import Document, User
16
+ from app.schemas import AdminStatsResponse, DiskUsageResponse
17
+
18
+ router = APIRouter(prefix="/admin", tags=["Admin"])
19
+ settings = get_settings()
20
+
21
+
22
+ def _directory_size(path: Path) -> int:
23
+ if not path.exists():
24
+ return 0
25
+
26
+ total = 0
27
+ for item in path.rglob("*"):
28
+ if item.is_file():
29
+ try:
30
+ total += item.stat().st_size
31
+ except OSError:
32
+ continue
33
+ return total
34
+
35
+
36
+ @router.get("/stats", response_model=AdminStatsResponse)
37
+ def get_admin_stats(
38
+ _admin: User = Depends(get_admin_user),
39
+ db: Session = Depends(get_db),
40
+ ):
41
+ """Return aggregate system statistics for administrators."""
42
+ upload_dir = Path(settings.UPLOAD_DIR).resolve()
43
+ upload_dir.mkdir(parents=True, exist_ok=True)
44
+
45
+ disk_usage = shutil.disk_usage(upload_dir)
46
+ used_percent = (
47
+ round((disk_usage.used / disk_usage.total) * 100, 2)
48
+ if disk_usage.total
49
+ else 0.0
50
+ )
51
+ query_metrics = get_query_metrics()
52
+
53
+ total_pdfs_uploaded = (
54
+ db.query(Document)
55
+ .filter(func.lower(Document.original_name).like("%.pdf"))
56
+ .count()
57
+ )
58
+
59
+ return AdminStatsResponse(
60
+ total_users=db.query(User).count(),
61
+ total_pdfs_uploaded=total_pdfs_uploaded,
62
+ average_query_response_time_ms=float(
63
+ query_metrics["average_query_response_time_ms"]
64
+ ),
65
+ query_count=int(query_metrics["query_count"]),
66
+ disk_space_usage=DiskUsageResponse(
67
+ total_bytes=disk_usage.total,
68
+ used_bytes=disk_usage.used,
69
+ free_bytes=disk_usage.free,
70
+ usage_percent=used_percent,
71
+ upload_dir_bytes=_directory_size(upload_dir),
72
+ ),
73
+ )
backend/app/routes/chat.py CHANGED
@@ -3,6 +3,7 @@ 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
@@ -16,8 +17,11 @@ 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
 
20
  from app.models import User, ChatMessage, Document, SharedMessage
 
21
  from app.schemas import (
22
  ChatRequest,
23
  ChatResponse,
@@ -27,9 +31,6 @@ from app.schemas import (
27
  ShareLinkResponse,
28
  SourceChunk,
29
  )
30
- from app.auth import get_current_user
31
- from app.rag.agent import generate_answer, generate_answer_stream
32
- from app.rate_limit import limiter
33
 
34
  logger = logging.getLogger(__name__)
35
 
@@ -41,7 +42,6 @@ def get_shared_answer(
41
  message_id: str,
42
  db: Session = Depends(get_db),
43
  ):
44
- """Fetch a single assistant answer for public sharing."""
45
  message = db.query(ChatMessage).filter(
46
  ChatMessage.id == message_id,
47
  ChatMessage.role == "assistant",
@@ -59,7 +59,6 @@ def create_share_link(
59
  user: User = Depends(get_current_user),
60
  db: Session = Depends(get_db),
61
  ):
62
- """Create a public share URL for a user's assistant answer."""
63
  message = db.query(ChatMessage).filter(
64
  ChatMessage.id == message_id,
65
  ChatMessage.user_id == user.id,
@@ -83,6 +82,18 @@ def create_share_link(
83
  )
84
 
85
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  @router.post("/ask", response_model=ChatResponse)
87
  @limiter.limit("10/minute")
88
  def ask_question(
@@ -118,38 +129,41 @@ def ask_question(
118
  HTTPException: 400 if the document exists but its status is not
119
  "ready" (e.g., still processing or failed).
120
  """
121
- # Validate document exists if specified
122
- if payload.document_id:
123
- doc = db.query(Document).filter(
124
- Document.id == payload.document_id,
125
- Document.user_id == user.id,
126
- ).first()
127
-
128
- if not doc:
129
- raise HTTPException(status_code=404, detail="Document not found")
130
-
131
- if doc.status != "ready":
132
- raise HTTPException(
133
- status_code=400,
134
- detail=f"Document is still {doc.status}. Please wait for processing to complete.",
135
- )
136
-
137
- # Generate answer
138
- result = generate_answer(
139
- question=payload.question,
140
- user_id=user.id,
141
- document_id=payload.document_id,
142
- )
 
143
 
144
- # Save to chat history
145
- _save_message(db, user.id, payload.document_id, "user", payload.question)
146
- _save_message(db, user.id, payload.document_id, "assistant", result["answer"], result["sources"])
147
 
148
- return ChatResponse(
149
- answer=result["answer"],
150
- sources=[SourceChunk(**s) for s in result["sources"]],
151
- document_id=payload.document_id,
152
- )
 
 
153
 
154
 
155
  @router.post("/ask/stream")
@@ -211,6 +225,8 @@ def ask_question_stream(
211
  detail=f"Document is still {doc.status}. Please wait for processing to complete.",
212
  )
213
 
 
 
214
  # Save user message immediately
215
  _save_message(db, user.id, payload.document_id, "user", payload.question)
216
 
@@ -219,31 +235,34 @@ def ask_question_stream(
219
  full_answer = ""
220
  sources = []
221
 
222
- for chunk in generate_answer_stream(
223
- question=payload.question,
224
- user_id=user.id,
225
- document_id=payload.document_id,
226
- ):
227
- yield chunk
228
-
229
- # Parse to accumulate full answer for history
230
- try:
231
- if chunk.startswith("data: "):
232
- data = json.loads(chunk[6:].strip())
233
- if data.get("type") == "token":
234
- full_answer += data.get("data", "")
235
- elif data.get("type") == "sources":
236
- sources = data.get("data", [])
237
- except Exception:
238
- pass
239
-
240
- # Save assistant response to history
241
- from app.database import SessionLocal
242
- save_db = SessionLocal()
243
  try:
244
- _save_message(save_db, user.id, payload.document_id, "assistant", full_answer, sources)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  finally:
246
- save_db.close()
247
 
248
  return StreamingResponse(
249
  event_stream(),
 
3
  """
4
  import html
5
  import json
6
+ import time
7
  from datetime import datetime
8
  from io import BytesIO
9
  import logging
 
17
  from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
18
  from sqlalchemy.orm import Session
19
 
20
+ from app.auth import get_current_user
21
  from app.database import get_db
22
+ from app.metrics import record_query_response_time
23
  from app.models import User, ChatMessage, Document, SharedMessage
24
+ from app.rate_limit import limiter
25
  from app.schemas import (
26
  ChatRequest,
27
  ChatResponse,
 
31
  ShareLinkResponse,
32
  SourceChunk,
33
  )
 
 
 
34
 
35
  logger = logging.getLogger(__name__)
36
 
 
42
  message_id: str,
43
  db: Session = Depends(get_db),
44
  ):
 
45
  message = db.query(ChatMessage).filter(
46
  ChatMessage.id == message_id,
47
  ChatMessage.role == "assistant",
 
59
  user: User = Depends(get_current_user),
60
  db: Session = Depends(get_db),
61
  ):
 
62
  message = db.query(ChatMessage).filter(
63
  ChatMessage.id == message_id,
64
  ChatMessage.user_id == user.id,
 
82
  )
83
 
84
 
85
+ def generate_answer(question: str, user_id: str, document_id: Optional[str] = None):
86
+ from app.rag.agent import generate_answer as _generate_answer
87
+
88
+ return _generate_answer(question=question, user_id=user_id, document_id=document_id)
89
+
90
+
91
+ def generate_answer_stream(question: str, user_id: str, document_id: Optional[str] = None):
92
+ from app.rag.agent import generate_answer_stream as _generate_answer_stream
93
+
94
+ return _generate_answer_stream(question=question, user_id=user_id, document_id=document_id)
95
+
96
+
97
  @router.post("/ask", response_model=ChatResponse)
98
  @limiter.limit("10/minute")
99
  def ask_question(
 
129
  HTTPException: 400 if the document exists but its status is not
130
  "ready" (e.g., still processing or failed).
131
  """
132
+ started_at = time.perf_counter()
133
+ try:
134
+ # Validate document exists if specified
135
+ if payload.document_id:
136
+ doc = db.query(Document).filter(
137
+ Document.id == payload.document_id,
138
+ Document.user_id == user.id,
139
+ ).first()
140
+
141
+ if not doc:
142
+ raise HTTPException(status_code=404, detail="Document not found")
143
+
144
+ if doc.status != "ready":
145
+ raise HTTPException(
146
+ status_code=400,
147
+ detail=f"Document is still {doc.status}. Please wait for processing to complete.",
148
+ )
149
+
150
+ result = generate_answer(
151
+ question=payload.question,
152
+ user_id=user.id,
153
+ document_id=payload.document_id,
154
+ )
155
 
156
+ # Save to chat history
157
+ _save_message(db, user.id, payload.document_id, "user", payload.question)
158
+ _save_message(db, user.id, payload.document_id, "assistant", result["answer"], result["sources"])
159
 
160
+ return ChatResponse(
161
+ answer=result["answer"],
162
+ sources=[SourceChunk(**s) for s in result["sources"]],
163
+ document_id=payload.document_id,
164
+ )
165
+ finally:
166
+ record_query_response_time(time.perf_counter() - started_at)
167
 
168
 
169
  @router.post("/ask/stream")
 
225
  detail=f"Document is still {doc.status}. Please wait for processing to complete.",
226
  )
227
 
228
+ started_at = time.perf_counter()
229
+
230
  # Save user message immediately
231
  _save_message(db, user.id, payload.document_id, "user", payload.question)
232
 
 
235
  full_answer = ""
236
  sources = []
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  try:
239
+ for chunk in generate_answer_stream(
240
+ question=payload.question,
241
+ user_id=user.id,
242
+ document_id=payload.document_id,
243
+ ):
244
+ yield chunk
245
+
246
+ # Parse to accumulate full answer for history
247
+ try:
248
+ if chunk.startswith("data: "):
249
+ data = json.loads(chunk[6:].strip())
250
+ if data.get("type") == "token":
251
+ full_answer += data.get("data", "")
252
+ elif data.get("type") == "sources":
253
+ sources = data.get("data", [])
254
+ except Exception:
255
+ pass
256
+
257
+ # Save assistant response to history
258
+ from app.database import SessionLocal
259
+ save_db = SessionLocal()
260
+ try:
261
+ _save_message(save_db, user.id, payload.document_id, "assistant", full_answer, sources)
262
+ finally:
263
+ save_db.close()
264
  finally:
265
+ record_query_response_time(time.perf_counter() - started_at)
266
 
267
  return StreamingResponse(
268
  event_stream(),
backend/app/schemas.py CHANGED
@@ -118,6 +118,24 @@ class DocumentListResponse(BaseModel):
118
  pages: int
119
 
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  # ── Chat ─────────────────────────────────────────────
122
 
123
  class ChatRequest(BaseModel):
 
118
  pages: int
119
 
120
 
121
+ # Admin
122
+
123
+ class DiskUsageResponse(BaseModel):
124
+ total_bytes: int
125
+ used_bytes: int
126
+ free_bytes: int
127
+ usage_percent: float
128
+ upload_dir_bytes: int
129
+
130
+
131
+ class AdminStatsResponse(BaseModel):
132
+ total_users: int
133
+ total_pdfs_uploaded: int
134
+ average_query_response_time_ms: float
135
+ query_count: int
136
+ disk_space_usage: DiskUsageResponse
137
+
138
+
139
  # ── Chat ─────────────────────────────────────────────
140
 
141
  class ChatRequest(BaseModel):
backend/tests/conftest.py CHANGED
@@ -16,11 +16,12 @@ BACKEND_DIR = ROOT / "backend"
16
  if str(BACKEND_DIR) not in sys.path:
17
  sys.path.insert(0, str(BACKEND_DIR))
18
 
19
- os.environ.setdefault("SECRET_KEY", "test-secret-key-that-is-long-enough")
20
- os.environ.setdefault("DATABASE_URL", "sqlite:///./test_bootstrap.db")
21
- os.environ.setdefault("HF_TOKEN", "test-hf-token")
22
- os.environ.setdefault("UPLOAD_DIR", str(ROOT / "backend" / "test_uploads"))
23
- os.environ.setdefault("CHROMA_PERSIST_DIR", str(ROOT / "backend" / "test_chroma"))
 
24
 
25
 
26
  fake_embeddings = types.ModuleType("app.rag.embeddings")
 
16
  if str(BACKEND_DIR) not in sys.path:
17
  sys.path.insert(0, str(BACKEND_DIR))
18
 
19
+ os.environ["SECRET_KEY"] = "test-secret-key-that-is-long-enough"
20
+ os.environ["DATABASE_URL"] = "sqlite:///./test_bootstrap.db"
21
+ os.environ["DEBUG"] = "false"
22
+ os.environ["HF_TOKEN"] = "test-hf-token"
23
+ os.environ["UPLOAD_DIR"] = str(ROOT / "backend" / "test_uploads")
24
+ os.environ["CHROMA_PERSIST_DIR"] = str(ROOT / "backend" / "test_chroma")
25
 
26
 
27
  fake_embeddings = types.ModuleType("app.rag.embeddings")
backend/tests/test_admin.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.auth import create_access_token, hash_password
2
+ from app.metrics import record_query_response_time
3
+ from app.models import Document, User
4
+
5
+
6
+ def test_admin_stats_requires_admin(client, auth_headers):
7
+ response = client.get("/api/v1/admin/stats", headers=auth_headers)
8
+
9
+ assert response.status_code == 403
10
+ assert response.json()["detail"] == "Admin access required"
11
+
12
+
13
+ def test_admin_stats_returns_aggregate_metrics(client, db_session):
14
+ admin = User(
15
+ username="admin",
16
+ email="admin@example.com",
17
+ hashed_password=hash_password("password123"),
18
+ is_admin=True,
19
+ )
20
+ regular = User(
21
+ username="regular",
22
+ email="regular@example.com",
23
+ hashed_password=hash_password("password123"),
24
+ )
25
+ db_session.add_all([admin, regular])
26
+ db_session.commit()
27
+ db_session.refresh(admin)
28
+ db_session.refresh(regular)
29
+
30
+ db_session.add_all(
31
+ [
32
+ Document(
33
+ user_id=regular.id,
34
+ filename="first.pdf",
35
+ original_name="first.pdf",
36
+ file_size=100,
37
+ status="ready",
38
+ ),
39
+ Document(
40
+ user_id=regular.id,
41
+ filename="notes.txt",
42
+ original_name="notes.txt",
43
+ file_size=50,
44
+ status="ready",
45
+ ),
46
+ ]
47
+ )
48
+ db_session.commit()
49
+
50
+ record_query_response_time(0.25)
51
+
52
+ token = create_access_token(admin.id)
53
+ response = client.get(
54
+ "/api/v1/admin/stats",
55
+ headers={"Authorization": f"Bearer {token}"},
56
+ )
57
+
58
+ assert response.status_code == 200
59
+ payload = response.json()
60
+ assert payload["total_users"] == 2
61
+ assert payload["total_pdfs_uploaded"] == 1
62
+ assert payload["average_query_response_time_ms"] > 0
63
+ assert payload["query_count"] >= 1
64
+ assert payload["disk_space_usage"]["total_bytes"] > 0
65
+ assert payload["disk_space_usage"]["usage_percent"] >= 0
frontend/package-lock.json CHANGED
@@ -11,6 +11,8 @@
11
  "@base-ui/react": "^1.4.1",
12
  "class-variance-authority": "^0.7.1",
13
  "clsx": "^2.1.1",
 
 
14
  "lucide-react": "^1.8.0",
15
  "next": "16.2.4",
16
  "next-themes": "^0.4.6",
@@ -18,6 +20,7 @@
18
  "react": "19.2.4",
19
  "react-dom": "19.2.4",
20
  "react-dropzone": "^15.0.0",
 
21
  "react-markdown": "^10.1.0",
22
  "react-pdf": "^10.4.1",
23
  "rehype-highlight": "^7.0.2",
@@ -80,6 +83,7 @@
80
  "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
81
  "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
82
  "license": "MIT",
 
83
  "dependencies": {
84
  "@babel/code-frame": "^7.29.0",
85
  "@babel/generator": "^7.29.0",
@@ -673,6 +677,7 @@
673
  "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
674
  "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
675
  "license": "MIT",
 
676
  "engines": {
677
  "node": ">=12"
678
  },
@@ -2107,6 +2112,7 @@
2107
  "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
2108
  "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
2109
  "license": "MIT",
 
2110
  "engines": {
2111
  "node": "^14.21.3 || >=16"
2112
  },
@@ -2214,6 +2220,7 @@
2214
  "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
2215
  "devOptional": true,
2216
  "license": "Apache-2.0",
 
2217
  "dependencies": {
2218
  "playwright": "1.60.0"
2219
  },
@@ -2682,6 +2689,7 @@
2682
  "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
2683
  "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
2684
  "license": "MIT",
 
2685
  "dependencies": {
2686
  "undici-types": "~6.21.0"
2687
  }
@@ -2691,6 +2699,7 @@
2691
  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
2692
  "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
2693
  "license": "MIT",
 
2694
  "dependencies": {
2695
  "csstype": "^3.2.2"
2696
  }
@@ -2777,6 +2786,7 @@
2777
  "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
2778
  "dev": true,
2779
  "license": "MIT",
 
2780
  "dependencies": {
2781
  "@typescript-eslint/scope-manager": "8.59.0",
2782
  "@typescript-eslint/types": "8.59.0",
@@ -3321,6 +3331,7 @@
3321
  "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
3322
  "dev": true,
3323
  "license": "MIT",
 
3324
  "bin": {
3325
  "acorn": "bin/acorn"
3326
  },
@@ -3775,6 +3786,7 @@
3775
  }
3776
  ],
3777
  "license": "MIT",
 
3778
  "dependencies": {
3779
  "baseline-browser-mapping": "^2.10.12",
3780
  "caniuse-lite": "^1.0.30001782",
@@ -4826,6 +4838,7 @@
4826
  "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
4827
  "dev": true,
4828
  "license": "MIT",
 
4829
  "dependencies": {
4830
  "@eslint-community/eslint-utils": "^4.8.0",
4831
  "@eslint-community/regexpp": "^4.12.1",
@@ -5011,6 +5024,7 @@
5011
  "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
5012
  "dev": true,
5013
  "license": "MIT",
 
5014
  "dependencies": {
5015
  "@rtsao/scc": "^1.1.0",
5016
  "array-includes": "^3.1.9",
@@ -5310,6 +5324,7 @@
5310
  "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
5311
  "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
5312
  "license": "MIT",
 
5313
  "dependencies": {
5314
  "accepts": "^2.0.0",
5315
  "body-parser": "^2.2.1",
@@ -5669,6 +5684,7 @@
5669
  "version": "2.3.2",
5670
  "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
5671
  "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
 
5672
  "hasInstallScript": true,
5673
  "license": "MIT",
5674
  "optional": true,
@@ -6133,10 +6149,20 @@
6133
  "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
6134
  "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
6135
  "license": "MIT",
 
6136
  "engines": {
6137
  "node": ">=16.9.0"
6138
  }
6139
  },
 
 
 
 
 
 
 
 
 
6140
  "node_modules/html-url-attributes": {
6141
  "version": "3.0.1",
6142
  "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -6189,6 +6215,44 @@
6189
  "node": ">=18.18.0"
6190
  }
6191
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6192
  "node_modules/iconv-lite": {
6193
  "version": "0.7.2",
6194
  "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -9527,6 +9591,7 @@
9527
  "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
9528
  "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
9529
  "license": "MIT",
 
9530
  "engines": {
9531
  "node": ">=0.10.0"
9532
  }
@@ -9536,6 +9601,7 @@
9536
  "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
9537
  "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
9538
  "license": "MIT",
 
9539
  "dependencies": {
9540
  "scheduler": "^0.27.0"
9541
  },
@@ -9560,6 +9626,33 @@
9560
  "react": ">= 16.8 || 18.0.0"
9561
  }
9562
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9563
  "node_modules/react-is": {
9564
  "version": "16.13.1",
9565
  "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -10833,6 +10926,7 @@
10833
  "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
10834
  "dev": true,
10835
  "license": "MIT",
 
10836
  "engines": {
10837
  "node": ">=12"
10838
  },
@@ -11101,6 +11195,7 @@
11101
  "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
11102
  "devOptional": true,
11103
  "license": "Apache-2.0",
 
11104
  "bin": {
11105
  "tsc": "bin/tsc",
11106
  "tsserver": "bin/tsserver"
@@ -11434,6 +11529,15 @@
11434
  "url": "https://opencollective.com/unified"
11435
  }
11436
  },
 
 
 
 
 
 
 
 
 
11437
  "node_modules/warning": {
11438
  "version": "4.0.3",
11439
  "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
@@ -11774,6 +11878,7 @@
11774
  "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
11775
  "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
11776
  "license": "MIT",
 
11777
  "funding": {
11778
  "url": "https://github.com/sponsors/colinhacks"
11779
  }
 
11
  "@base-ui/react": "^1.4.1",
12
  "class-variance-authority": "^0.7.1",
13
  "clsx": "^2.1.1",
14
+ "i18next": "^26.3.0",
15
+ "i18next-browser-languagedetector": "^8.2.1",
16
  "lucide-react": "^1.8.0",
17
  "next": "16.2.4",
18
  "next-themes": "^0.4.6",
 
20
  "react": "19.2.4",
21
  "react-dom": "19.2.4",
22
  "react-dropzone": "^15.0.0",
23
+ "react-i18next": "^17.0.8",
24
  "react-markdown": "^10.1.0",
25
  "react-pdf": "^10.4.1",
26
  "rehype-highlight": "^7.0.2",
 
83
  "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
84
  "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
85
  "license": "MIT",
86
+ "peer": true,
87
  "dependencies": {
88
  "@babel/code-frame": "^7.29.0",
89
  "@babel/generator": "^7.29.0",
 
677
  "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
678
  "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
679
  "license": "MIT",
680
+ "peer": true,
681
  "engines": {
682
  "node": ">=12"
683
  },
 
2112
  "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
2113
  "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
2114
  "license": "MIT",
2115
+ "peer": true,
2116
  "engines": {
2117
  "node": "^14.21.3 || >=16"
2118
  },
 
2220
  "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
2221
  "devOptional": true,
2222
  "license": "Apache-2.0",
2223
+ "peer": true,
2224
  "dependencies": {
2225
  "playwright": "1.60.0"
2226
  },
 
2689
  "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
2690
  "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
2691
  "license": "MIT",
2692
+ "peer": true,
2693
  "dependencies": {
2694
  "undici-types": "~6.21.0"
2695
  }
 
2699
  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
2700
  "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
2701
  "license": "MIT",
2702
+ "peer": true,
2703
  "dependencies": {
2704
  "csstype": "^3.2.2"
2705
  }
 
2786
  "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
2787
  "dev": true,
2788
  "license": "MIT",
2789
+ "peer": true,
2790
  "dependencies": {
2791
  "@typescript-eslint/scope-manager": "8.59.0",
2792
  "@typescript-eslint/types": "8.59.0",
 
3331
  "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
3332
  "dev": true,
3333
  "license": "MIT",
3334
+ "peer": true,
3335
  "bin": {
3336
  "acorn": "bin/acorn"
3337
  },
 
3786
  }
3787
  ],
3788
  "license": "MIT",
3789
+ "peer": true,
3790
  "dependencies": {
3791
  "baseline-browser-mapping": "^2.10.12",
3792
  "caniuse-lite": "^1.0.30001782",
 
4838
  "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
4839
  "dev": true,
4840
  "license": "MIT",
4841
+ "peer": true,
4842
  "dependencies": {
4843
  "@eslint-community/eslint-utils": "^4.8.0",
4844
  "@eslint-community/regexpp": "^4.12.1",
 
5024
  "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
5025
  "dev": true,
5026
  "license": "MIT",
5027
+ "peer": true,
5028
  "dependencies": {
5029
  "@rtsao/scc": "^1.1.0",
5030
  "array-includes": "^3.1.9",
 
5324
  "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
5325
  "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
5326
  "license": "MIT",
5327
+ "peer": true,
5328
  "dependencies": {
5329
  "accepts": "^2.0.0",
5330
  "body-parser": "^2.2.1",
 
5684
  "version": "2.3.2",
5685
  "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
5686
  "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
5687
+ "dev": true,
5688
  "hasInstallScript": true,
5689
  "license": "MIT",
5690
  "optional": true,
 
6149
  "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
6150
  "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
6151
  "license": "MIT",
6152
+ "peer": true,
6153
  "engines": {
6154
  "node": ">=16.9.0"
6155
  }
6156
  },
6157
+ "node_modules/html-parse-stringify": {
6158
+ "version": "3.0.1",
6159
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
6160
+ "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
6161
+ "license": "MIT",
6162
+ "dependencies": {
6163
+ "void-elements": "3.1.0"
6164
+ }
6165
+ },
6166
  "node_modules/html-url-attributes": {
6167
  "version": "3.0.1",
6168
  "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
 
6215
  "node": ">=18.18.0"
6216
  }
6217
  },
6218
+ "node_modules/i18next": {
6219
+ "version": "26.3.0",
6220
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz",
6221
+ "integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==",
6222
+ "funding": [
6223
+ {
6224
+ "type": "individual",
6225
+ "url": "https://www.locize.com/i18next"
6226
+ },
6227
+ {
6228
+ "type": "individual",
6229
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
6230
+ },
6231
+ {
6232
+ "type": "individual",
6233
+ "url": "https://www.locize.com"
6234
+ }
6235
+ ],
6236
+ "license": "MIT",
6237
+ "peer": true,
6238
+ "peerDependencies": {
6239
+ "typescript": "^5 || ^6"
6240
+ },
6241
+ "peerDependenciesMeta": {
6242
+ "typescript": {
6243
+ "optional": true
6244
+ }
6245
+ }
6246
+ },
6247
+ "node_modules/i18next-browser-languagedetector": {
6248
+ "version": "8.2.1",
6249
+ "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
6250
+ "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
6251
+ "license": "MIT",
6252
+ "dependencies": {
6253
+ "@babel/runtime": "^7.23.2"
6254
+ }
6255
+ },
6256
  "node_modules/iconv-lite": {
6257
  "version": "0.7.2",
6258
  "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
 
9591
  "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
9592
  "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
9593
  "license": "MIT",
9594
+ "peer": true,
9595
  "engines": {
9596
  "node": ">=0.10.0"
9597
  }
 
9601
  "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
9602
  "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
9603
  "license": "MIT",
9604
+ "peer": true,
9605
  "dependencies": {
9606
  "scheduler": "^0.27.0"
9607
  },
 
9626
  "react": ">= 16.8 || 18.0.0"
9627
  }
9628
  },
9629
+ "node_modules/react-i18next": {
9630
+ "version": "17.0.8",
9631
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
9632
+ "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
9633
+ "license": "MIT",
9634
+ "dependencies": {
9635
+ "@babel/runtime": "^7.29.2",
9636
+ "html-parse-stringify": "^3.0.1",
9637
+ "use-sync-external-store": "^1.6.0"
9638
+ },
9639
+ "peerDependencies": {
9640
+ "i18next": ">= 26.2.0",
9641
+ "react": ">= 16.8.0",
9642
+ "typescript": "^5 || ^6"
9643
+ },
9644
+ "peerDependenciesMeta": {
9645
+ "react-dom": {
9646
+ "optional": true
9647
+ },
9648
+ "react-native": {
9649
+ "optional": true
9650
+ },
9651
+ "typescript": {
9652
+ "optional": true
9653
+ }
9654
+ }
9655
+ },
9656
  "node_modules/react-is": {
9657
  "version": "16.13.1",
9658
  "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
 
10926
  "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
10927
  "dev": true,
10928
  "license": "MIT",
10929
+ "peer": true,
10930
  "engines": {
10931
  "node": ">=12"
10932
  },
 
11195
  "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
11196
  "devOptional": true,
11197
  "license": "Apache-2.0",
11198
+ "peer": true,
11199
  "bin": {
11200
  "tsc": "bin/tsc",
11201
  "tsserver": "bin/tsserver"
 
11529
  "url": "https://opencollective.com/unified"
11530
  }
11531
  },
11532
+ "node_modules/void-elements": {
11533
+ "version": "3.1.0",
11534
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
11535
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
11536
+ "license": "MIT",
11537
+ "engines": {
11538
+ "node": ">=0.10.0"
11539
+ }
11540
+ },
11541
  "node_modules/warning": {
11542
  "version": "4.0.3",
11543
  "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
 
11878
  "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
11879
  "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
11880
  "license": "MIT",
11881
+ "peer": true,
11882
  "funding": {
11883
  "url": "https://github.com/sponsors/colinhacks"
11884
  }
frontend/package.json CHANGED
@@ -14,6 +14,8 @@
14
  "@base-ui/react": "^1.4.1",
15
  "class-variance-authority": "^0.7.1",
16
  "clsx": "^2.1.1",
 
 
17
  "lucide-react": "^1.8.0",
18
  "next": "16.2.4",
19
  "next-themes": "^0.4.6",
@@ -21,6 +23,7 @@
21
  "react": "19.2.4",
22
  "react-dom": "19.2.4",
23
  "react-dropzone": "^15.0.0",
 
24
  "react-markdown": "^10.1.0",
25
  "react-pdf": "^10.4.1",
26
  "rehype-highlight": "^7.0.2",
 
14
  "@base-ui/react": "^1.4.1",
15
  "class-variance-authority": "^0.7.1",
16
  "clsx": "^2.1.1",
17
+ "i18next": "^26.3.0",
18
+ "i18next-browser-languagedetector": "^8.2.1",
19
  "lucide-react": "^1.8.0",
20
  "next": "16.2.4",
21
  "next-themes": "^0.4.6",
 
23
  "react": "19.2.4",
24
  "react-dom": "19.2.4",
25
  "react-dropzone": "^15.0.0",
26
+ "react-i18next": "^17.0.8",
27
  "react-markdown": "^10.1.0",
28
  "react-pdf": "^10.4.1",
29
  "rehype-highlight": "^7.0.2",
frontend/src/app/admin/page.tsx ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import {
6
+ ArrowLeft,
7
+ Clock3,
8
+ Database,
9
+ FileText,
10
+ HardDrive,
11
+ RefreshCw,
12
+ Users,
13
+ } from "lucide-react";
14
+
15
+ import { api, CONNECTION_ERROR_MESSAGE } from "@/lib/api";
16
+ import { useAuth } from "@/lib/auth";
17
+ import { Button } from "@/components/ui/button";
18
+ import {
19
+ Card,
20
+ CardContent,
21
+ CardDescription,
22
+ CardHeader,
23
+ CardTitle,
24
+ } from "@/components/ui/card";
25
+ import { Progress } from "@/components/ui/progress";
26
+ import { Skeleton } from "@/components/ui/skeleton";
27
+
28
+ interface AdminStats {
29
+ total_users: number;
30
+ total_pdfs_uploaded: number;
31
+ average_query_response_time_ms: number;
32
+ query_count: number;
33
+ disk_space_usage: {
34
+ total_bytes: number;
35
+ used_bytes: number;
36
+ free_bytes: number;
37
+ usage_percent: number;
38
+ upload_dir_bytes: number;
39
+ };
40
+ }
41
+
42
+ const formatBytes = (bytes: number) => {
43
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
44
+
45
+ const units = ["B", "KB", "MB", "GB", "TB"];
46
+ const index = Math.min(
47
+ Math.floor(Math.log(bytes) / Math.log(1024)),
48
+ units.length - 1
49
+ );
50
+
51
+ return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
52
+ };
53
+
54
+ const formatTime = (milliseconds: number) => {
55
+ if (milliseconds >= 1000) return `${(milliseconds / 1000).toFixed(2)} s`;
56
+ return `${Math.round(milliseconds)} ms`;
57
+ };
58
+
59
+ function MetricCard({
60
+ icon: Icon,
61
+ label,
62
+ value,
63
+ detail,
64
+ }: {
65
+ icon: typeof Users;
66
+ label: string;
67
+ value: string;
68
+ detail: string;
69
+ }) {
70
+ return (
71
+ <Card className="min-h-36">
72
+ <CardHeader className="grid-cols-[1fr_auto]">
73
+ <div>
74
+ <CardDescription>{label}</CardDescription>
75
+ <CardTitle className="mt-2 text-3xl tabular-nums">{value}</CardTitle>
76
+ </div>
77
+ <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/15 text-primary">
78
+ <Icon className="h-4 w-4" />
79
+ </div>
80
+ </CardHeader>
81
+ <CardContent>
82
+ <p className="text-sm text-muted-foreground">{detail}</p>
83
+ </CardContent>
84
+ </Card>
85
+ );
86
+ }
87
+
88
+ function AdminSkeleton() {
89
+ return (
90
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
91
+ {[1, 2, 3, 4].map((item) => (
92
+ <Card key={item} className="min-h-36">
93
+ <CardHeader>
94
+ <Skeleton className="h-4 w-28" />
95
+ <Skeleton className="h-8 w-20" />
96
+ </CardHeader>
97
+ <CardContent>
98
+ <Skeleton className="h-4 w-36" />
99
+ </CardContent>
100
+ </Card>
101
+ ))}
102
+ </div>
103
+ );
104
+ }
105
+
106
+ export default function AdminPage() {
107
+ const { user, loading } = useAuth();
108
+ const router = useRouter();
109
+ const [stats, setStats] = useState<AdminStats | null>(null);
110
+ const [statsLoading, setStatsLoading] = useState(true);
111
+ const [error, setError] = useState("");
112
+ const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
113
+
114
+ useEffect(() => {
115
+ if (loading) return;
116
+ if (!user) router.replace("/login");
117
+ else if (!user.is_admin) router.replace("/dashboard");
118
+ }, [loading, router, user]);
119
+
120
+ const loadStats = useCallback(async () => {
121
+ try {
122
+ setStatsLoading(true);
123
+ const data = await api.get<AdminStats>("/api/v1/admin/stats");
124
+ setStats(data);
125
+ setLastUpdated(new Date());
126
+ setError("");
127
+ } catch (err) {
128
+ const message =
129
+ err instanceof Error ? err.message : CONNECTION_ERROR_MESSAGE;
130
+ setError(message);
131
+ } finally {
132
+ setStatsLoading(false);
133
+ }
134
+ }, []);
135
+
136
+ useEffect(() => {
137
+ if (!user?.is_admin) return;
138
+
139
+ const initialLoad = window.setTimeout(() => void loadStats(), 0);
140
+ const interval = window.setInterval(() => void loadStats(), 10000);
141
+
142
+ return () => {
143
+ window.clearTimeout(initialLoad);
144
+ window.clearInterval(interval);
145
+ };
146
+ }, [loadStats, user?.is_admin]);
147
+
148
+ const diskDetail = useMemo(() => {
149
+ if (!stats) return "";
150
+ const disk = stats.disk_space_usage;
151
+ return `${formatBytes(disk.used_bytes)} used of ${formatBytes(disk.total_bytes)}`;
152
+ }, [stats]);
153
+
154
+ if (loading || !user || !user.is_admin) {
155
+ return (
156
+ <div className="min-h-screen flex items-center justify-center">
157
+ <div className="animate-pulse-glow w-12 h-12 rounded-full bg-primary/20" />
158
+ </div>
159
+ );
160
+ }
161
+
162
+ return (
163
+ <div className="min-h-screen bg-background">
164
+ <header className="sticky top-0 z-40 flex h-14 items-center justify-between border-b border-border/50 bg-card/50 px-4 backdrop-blur-md">
165
+ <div className="flex items-center gap-3">
166
+ <Button
167
+ variant="ghost"
168
+ size="icon"
169
+ onClick={() => router.push("/dashboard")}
170
+ title="Back to dashboard"
171
+ >
172
+ <ArrowLeft className="h-4 w-4" />
173
+ </Button>
174
+ <div className="flex items-center gap-2">
175
+ <div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/15">
176
+ <Database className="h-4 w-4 text-primary" />
177
+ </div>
178
+ <span className="text-sm font-semibold">Admin Metrics</span>
179
+ </div>
180
+ </div>
181
+
182
+ <Button
183
+ variant="outline"
184
+ size="sm"
185
+ onClick={() => void loadStats()}
186
+ disabled={statsLoading}
187
+ >
188
+ <RefreshCw className={statsLoading ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
189
+ Refresh
190
+ </Button>
191
+ </header>
192
+
193
+ <main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-6">
194
+ <div className="flex flex-col gap-1">
195
+ <h1 className="text-2xl font-semibold tracking-normal">System overview</h1>
196
+ <p className="text-sm text-muted-foreground">
197
+ {lastUpdated
198
+ ? `Last updated ${lastUpdated.toLocaleTimeString()}`
199
+ : "Waiting for live metrics"}
200
+ </p>
201
+ </div>
202
+
203
+ {error && (
204
+ <div
205
+ role="alert"
206
+ className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
207
+ >
208
+ {error}
209
+ </div>
210
+ )}
211
+
212
+ {statsLoading && !stats ? (
213
+ <AdminSkeleton />
214
+ ) : stats ? (
215
+ <>
216
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
217
+ <MetricCard
218
+ icon={Users}
219
+ label="Total users"
220
+ value={stats.total_users.toLocaleString()}
221
+ detail="Registered accounts"
222
+ />
223
+ <MetricCard
224
+ icon={FileText}
225
+ label="PDFs uploaded"
226
+ value={stats.total_pdfs_uploaded.toLocaleString()}
227
+ detail="All uploaded PDF records"
228
+ />
229
+ <MetricCard
230
+ icon={Clock3}
231
+ label="Avg response time"
232
+ value={formatTime(stats.average_query_response_time_ms)}
233
+ detail={`${stats.query_count.toLocaleString()} measured queries`}
234
+ />
235
+ <MetricCard
236
+ icon={HardDrive}
237
+ label="Upload storage"
238
+ value={formatBytes(stats.disk_space_usage.upload_dir_bytes)}
239
+ detail="Files in the upload directory"
240
+ />
241
+ </div>
242
+
243
+ <Card>
244
+ <CardHeader>
245
+ <CardTitle>Disk space usage</CardTitle>
246
+ <CardDescription>{diskDetail}</CardDescription>
247
+ </CardHeader>
248
+ <CardContent className="space-y-4">
249
+ <Progress value={stats.disk_space_usage.usage_percent} />
250
+ <div className="grid gap-3 text-sm sm:grid-cols-3">
251
+ <div>
252
+ <p className="text-muted-foreground">Used</p>
253
+ <p className="font-medium tabular-nums">
254
+ {formatBytes(stats.disk_space_usage.used_bytes)}
255
+ </p>
256
+ </div>
257
+ <div>
258
+ <p className="text-muted-foreground">Free</p>
259
+ <p className="font-medium tabular-nums">
260
+ {formatBytes(stats.disk_space_usage.free_bytes)}
261
+ </p>
262
+ </div>
263
+ <div>
264
+ <p className="text-muted-foreground">Usage</p>
265
+ <p className="font-medium tabular-nums">
266
+ {stats.disk_space_usage.usage_percent.toFixed(2)}%
267
+ </p>
268
+ </div>
269
+ </div>
270
+ </CardContent>
271
+ </Card>
272
+ </>
273
+ ) : null}
274
+ </main>
275
+ </div>
276
+ );
277
+ }
frontend/src/app/layout.tsx CHANGED
@@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
3
  import "./globals.css";
4
  import { AuthProvider } from "@/lib/auth";
5
  import { TooltipProvider } from "@/components/ui/tooltip";
 
6
  import { ThemeProvider } from "@/components/layout/ThemeProvider";
7
 
8
  const inter = Inter({
@@ -33,12 +34,12 @@ export default function RootLayout({
33
  disableTransitionOnChange
34
  >
35
  <AuthProvider>
36
- <TooltipProvider>
37
- {children}
38
- </TooltipProvider>
39
  </AuthProvider>
40
  </ThemeProvider>
41
  </body>
42
  </html>
43
  );
44
- }
 
3
  import "./globals.css";
4
  import { AuthProvider } from "@/lib/auth";
5
  import { TooltipProvider } from "@/components/ui/tooltip";
6
+ import I18nProvider from "@/components/providers/I18nProvider";
7
  import { ThemeProvider } from "@/components/layout/ThemeProvider";
8
 
9
  const inter = Inter({
 
34
  disableTransitionOnChange
35
  >
36
  <AuthProvider>
37
+ <I18nProvider>
38
+ <TooltipProvider>{children}</TooltipProvider>
39
+ </I18nProvider>
40
  </AuthProvider>
41
  </ThemeProvider>
42
  </body>
43
  </html>
44
  );
45
+ }
frontend/src/app/login/page.tsx CHANGED
@@ -3,6 +3,7 @@
3
  import { useCallback, useState } from "react";
4
  import { useRouter } from "next/navigation";
5
  import { useAuth } from "@/lib/auth";
 
6
  import { Button } from "@/components/ui/button";
7
  import { Input } from "@/components/ui/input";
8
  import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
@@ -12,6 +13,7 @@ import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
12
 
13
  export default function LoginPage() {
14
  const { login } = useAuth();
 
15
  const router = useRouter();
16
  const [email, setEmail] = useState("");
17
  const [password, setPassword] = useState("");
@@ -32,7 +34,7 @@ export default function LoginPage() {
32
  await login(email, password);
33
  router.replace("/dashboard");
34
  } catch (err: unknown) {
35
- const message = err instanceof Error ? err.message : "Login failed";
36
  setError(message);
37
  } finally {
38
  setLoading(false);
@@ -51,8 +53,8 @@ export default function LoginPage() {
51
  <Brain className="w-6 h-6 text-primary" />
52
  </div>
53
  </div>
54
- <CardTitle className="text-2xl font-bold">Welcome back</CardTitle>
55
- <CardDescription>Sign in to your Document AI Analyst account</CardDescription>
56
  </CardHeader>
57
 
58
  <CardContent>
@@ -71,7 +73,7 @@ export default function LoginPage() {
71
  )}
72
 
73
  <div className="space-y-2">
74
- <label className="text-sm font-medium">Email</label>
75
  <Input
76
  id="login-email"
77
  type="email"
@@ -84,7 +86,7 @@ export default function LoginPage() {
84
  </div>
85
 
86
  <div className="space-y-2">
87
- <label className="text-sm font-medium">Password</label>
88
  <div className="relative">
89
  <Input
90
  id="login-password"
@@ -109,18 +111,18 @@ export default function LoginPage() {
109
  {loading ? (
110
  <span className="flex items-center gap-2">
111
  <span className="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
112
- Signing in...
113
  </span>
114
  ) : (
115
- "Sign In"
116
  )}
117
  </Button>
118
  </form>
119
 
120
  <p className="text-center text-sm text-muted-foreground mt-6">
121
- Don&apos;t have an account?{" "}
122
  <Link href="/register" className="text-primary hover:underline font-medium">
123
- Create one
124
  </Link>
125
  </p>
126
  </CardContent>
 
3
  import { useCallback, useState } from "react";
4
  import { useRouter } from "next/navigation";
5
  import { useAuth } from "@/lib/auth";
6
+ import { useTranslation } from "react-i18next";
7
  import { Button } from "@/components/ui/button";
8
  import { Input } from "@/components/ui/input";
9
  import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
 
13
 
14
  export default function LoginPage() {
15
  const { login } = useAuth();
16
+ const { t } = useTranslation();
17
  const router = useRouter();
18
  const [email, setEmail] = useState("");
19
  const [password, setPassword] = useState("");
 
34
  await login(email, password);
35
  router.replace("/dashboard");
36
  } catch (err: unknown) {
37
+ const message = err instanceof Error ? err.message : t("login.fallbackError");
38
  setError(message);
39
  } finally {
40
  setLoading(false);
 
53
  <Brain className="w-6 h-6 text-primary" />
54
  </div>
55
  </div>
56
+ <CardTitle className="text-2xl font-bold">{t("login.title")}</CardTitle>
57
+ <CardDescription>{t("login.description")}</CardDescription>
58
  </CardHeader>
59
 
60
  <CardContent>
 
73
  )}
74
 
75
  <div className="space-y-2">
76
+ <label className="text-sm font-medium">{t("common.email")}</label>
77
  <Input
78
  id="login-email"
79
  type="email"
 
86
  </div>
87
 
88
  <div className="space-y-2">
89
+ <label className="text-sm font-medium">{t("common.password")}</label>
90
  <div className="relative">
91
  <Input
92
  id="login-password"
 
111
  {loading ? (
112
  <span className="flex items-center gap-2">
113
  <span className="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
114
+ {t("login.submitting")}
115
  </span>
116
  ) : (
117
+ t("login.submit")
118
  )}
119
  </Button>
120
  </form>
121
 
122
  <p className="text-center text-sm text-muted-foreground mt-6">
123
+ {t("login.noAccount")}{" "}
124
  <Link href="/register" className="text-primary hover:underline font-medium">
125
+ {t("login.createOne")}
126
  </Link>
127
  </p>
128
  </CardContent>
frontend/src/app/register/page.tsx CHANGED
@@ -3,6 +3,7 @@
3
  import { useCallback, useState } from "react";
4
  import { useRouter } from "next/navigation";
5
  import { useAuth } from "@/lib/auth";
 
6
  import { Button } from "@/components/ui/button";
7
  import { Input } from "@/components/ui/input";
8
  import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
@@ -12,6 +13,7 @@ import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
12
 
13
  export default function RegisterPage() {
14
  const { register } = useAuth();
 
15
  const router = useRouter();
16
  const [username, setUsername] = useState("");
17
  const [email, setEmail] = useState("");
@@ -33,7 +35,7 @@ export default function RegisterPage() {
33
  await register(username, email, password);
34
  router.replace("/dashboard");
35
  } catch (err: unknown) {
36
- const message = err instanceof Error ? err.message : "Registration failed";
37
  setError(message);
38
  } finally {
39
  setLoading(false);
@@ -51,8 +53,8 @@ export default function RegisterPage() {
51
  <Brain className="w-6 h-6 text-primary" />
52
  </div>
53
  </div>
54
- <CardTitle className="text-2xl font-bold">Create Account</CardTitle>
55
- <CardDescription>Start analyzing documents with AI</CardDescription>
56
  </CardHeader>
57
 
58
  <CardContent>
@@ -71,7 +73,7 @@ export default function RegisterPage() {
71
  )}
72
 
73
  <div className="space-y-2">
74
- <label className="text-sm font-medium">Username</label>
75
  <Input
76
  id="reg-username"
77
  type="text"
@@ -85,7 +87,7 @@ export default function RegisterPage() {
85
  </div>
86
 
87
  <div className="space-y-2">
88
- <label className="text-sm font-medium">Email</label>
89
  <Input
90
  id="reg-email"
91
  type="email"
@@ -98,12 +100,12 @@ export default function RegisterPage() {
98
  </div>
99
 
100
  <div className="space-y-2">
101
- <label className="text-sm font-medium">Password</label>
102
  <div className="relative">
103
  <Input
104
  id="reg-password"
105
  type={showPw ? "text" : "password"}
106
- placeholder="Minimum 6 characters"
107
  value={password}
108
  onChange={(e) => setPassword(e.target.value)}
109
  required
@@ -124,18 +126,18 @@ export default function RegisterPage() {
124
  {loading ? (
125
  <span className="flex items-center gap-2">
126
  <span className="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
127
- Creating account...
128
  </span>
129
  ) : (
130
- "Create Account"
131
  )}
132
  </Button>
133
  </form>
134
 
135
  <p className="text-center text-sm text-muted-foreground mt-6">
136
- Already have an account?{" "}
137
  <Link href="/login" className="text-primary hover:underline font-medium">
138
- Sign in
139
  </Link>
140
  </p>
141
  </CardContent>
 
3
  import { useCallback, useState } from "react";
4
  import { useRouter } from "next/navigation";
5
  import { useAuth } from "@/lib/auth";
6
+ import { useTranslation } from "react-i18next";
7
  import { Button } from "@/components/ui/button";
8
  import { Input } from "@/components/ui/input";
9
  import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
 
13
 
14
  export default function RegisterPage() {
15
  const { register } = useAuth();
16
+ const { t } = useTranslation();
17
  const router = useRouter();
18
  const [username, setUsername] = useState("");
19
  const [email, setEmail] = useState("");
 
35
  await register(username, email, password);
36
  router.replace("/dashboard");
37
  } catch (err: unknown) {
38
+ const message = err instanceof Error ? err.message : t("register.fallbackError");
39
  setError(message);
40
  } finally {
41
  setLoading(false);
 
53
  <Brain className="w-6 h-6 text-primary" />
54
  </div>
55
  </div>
56
+ <CardTitle className="text-2xl font-bold">{t("register.title")}</CardTitle>
57
+ <CardDescription>{t("register.description")}</CardDescription>
58
  </CardHeader>
59
 
60
  <CardContent>
 
73
  )}
74
 
75
  <div className="space-y-2">
76
+ <label className="text-sm font-medium">{t("common.username")}</label>
77
  <Input
78
  id="reg-username"
79
  type="text"
 
87
  </div>
88
 
89
  <div className="space-y-2">
90
+ <label className="text-sm font-medium">{t("common.email")}</label>
91
  <Input
92
  id="reg-email"
93
  type="email"
 
100
  </div>
101
 
102
  <div className="space-y-2">
103
+ <label className="text-sm font-medium">{t("common.password")}</label>
104
  <div className="relative">
105
  <Input
106
  id="reg-password"
107
  type={showPw ? "text" : "password"}
108
+ placeholder={t("register.passwordPlaceholder")}
109
  value={password}
110
  onChange={(e) => setPassword(e.target.value)}
111
  required
 
126
  {loading ? (
127
  <span className="flex items-center gap-2">
128
  <span className="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
129
+ {t("register.submitting")}
130
  </span>
131
  ) : (
132
+ t("register.submit")
133
  )}
134
  </Button>
135
  </form>
136
 
137
  <p className="text-center text-sm text-muted-foreground mt-6">
138
+ {t("register.hasAccount")}{" "}
139
  <Link href="/login" className="text-primary hover:underline font-medium">
140
+ {t("register.signIn")}
141
  </Link>
142
  </p>
143
  </CardContent>
frontend/src/components/chat/ChatPanel.tsx CHANGED
@@ -1,6 +1,7 @@
1
  "use client";
2
 
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";
@@ -16,6 +17,7 @@ interface Props {
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);
@@ -185,7 +187,9 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
185
  m.id === assistantId
186
  ? {
187
  ...m,
188
- content: `Failed to get response: ${err instanceof Error ? err.message : "Unknown error"}`,
 
 
189
  isStreaming: false,
190
  }
191
  : m
@@ -198,7 +202,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
198
  };
199
 
200
  const handleClear = async () => {
201
- if (!activeDoc || !confirm("Clear all chat history for this document?")) return;
202
  try {
203
  await api.delete(`/api/v1/chat/history/${activeDoc.id}`);
204
  setMessages([]);
@@ -250,12 +254,12 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
250
  <MessageSquare className="w-8 h-8 text-primary/60" />
251
  </div>
252
  <h3 className="text-lg font-semibold mb-1">
253
- {activeDoc ? "Ask about your document" : "Select a document"}
254
  </h3>
255
  <p className="text-sm text-muted-foreground text-center max-w-sm">
256
  {activeDoc
257
- ? `"${activeDoc.original_name}" is ready. Ask any question and get cited answers.`
258
- : "Upload and select a document from the sidebar to start chatting."}
259
  </p>
260
  </div>
261
  ) : (
@@ -293,8 +297,8 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
293
  onKeyDown={handleKeyDown}
294
  placeholder={
295
  activeDoc
296
- ? `Ask about "${activeDoc.original_name}"...`
297
- : "Select a document first..."
298
  }
299
  disabled={streaming}
300
  className="min-h-[44px] max-h-32 resize-none bg-background/50 border-border/50"
@@ -324,7 +328,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
324
  size="icon"
325
  onClick={() => setShowExportMenu((v) => !v)}
326
  className="h-[44px] w-[44px] text-muted-foreground hover:text-primary"
327
- title="Export chat history"
328
  >
329
  <Download className="w-4 h-4" />
330
  </Button>
@@ -336,7 +340,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
336
  className="w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
337
  >
338
  <span className="text-base">📝</span>
339
- Markdown (.md)
340
  </button>
341
  <button
342
  id="export-txt-btn"
@@ -344,7 +348,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
344
  className="w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
345
  >
346
  <span className="text-base">📄</span>
347
- Plain Text (.txt)
348
  </button>
349
  <button
350
  id="export-pdf-btn"
@@ -352,7 +356,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
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
  )}
 
1
  "use client";
2
 
3
  import { useState, useRef, useEffect } from "react";
4
+ import { useTranslation } from "react-i18next";
5
  import type { DocInfo } from "@/app/dashboard/page";
6
  import { api, API_BASE } from "@/lib/api";
7
  import { useChatStore, type ChatMsg, type SourceChunk } from "@/store/chat-store";
 
17
  }
18
 
19
  export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
20
+ const { t } = useTranslation();
21
  const messages = useChatStore((state) => state.messages);
22
  const input = useChatStore((state) => state.input);
23
  const streaming = useChatStore((state) => state.streaming);
 
187
  m.id === assistantId
188
  ? {
189
  ...m,
190
+ content: t("chat.fallbackError", {
191
+ message: err instanceof Error ? err.message : "Unknown error",
192
+ }),
193
  isStreaming: false,
194
  }
195
  : m
 
202
  };
203
 
204
  const handleClear = async () => {
205
+ if (!activeDoc || !confirm(t("chat.clearConfirm"))) return;
206
  try {
207
  await api.delete(`/api/v1/chat/history/${activeDoc.id}`);
208
  setMessages([]);
 
254
  <MessageSquare className="w-8 h-8 text-primary/60" />
255
  </div>
256
  <h3 className="text-lg font-semibold mb-1">
257
+ {activeDoc ? t("chat.askAboutDocument") : t("chat.selectDocument")}
258
  </h3>
259
  <p className="text-sm text-muted-foreground text-center max-w-sm">
260
  {activeDoc
261
+ ? t("chat.readyPrompt", { name: activeDoc.original_name })
262
+ : t("chat.uploadPrompt")}
263
  </p>
264
  </div>
265
  ) : (
 
297
  onKeyDown={handleKeyDown}
298
  placeholder={
299
  activeDoc
300
+ ? t("chat.askPlaceholder", { name: activeDoc.original_name })
301
+ : t("chat.selectPlaceholder")
302
  }
303
  disabled={streaming}
304
  className="min-h-[44px] max-h-32 resize-none bg-background/50 border-border/50"
 
328
  size="icon"
329
  onClick={() => setShowExportMenu((v) => !v)}
330
  className="h-[44px] w-[44px] text-muted-foreground hover:text-primary"
331
+ title={t("chat.exportTitle")}
332
  >
333
  <Download className="w-4 h-4" />
334
  </Button>
 
340
  className="w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
341
  >
342
  <span className="text-base">📝</span>
343
+ {t("chat.markdown")}
344
  </button>
345
  <button
346
  id="export-txt-btn"
 
348
  className="w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
349
  >
350
  <span className="text-base">📄</span>
351
+ {t("chat.plainText")}
352
  </button>
353
  <button
354
  id="export-pdf-btn"
 
356
  className="w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
357
  >
358
  <span className="text-base">📕</span>
359
+ {t("chat.pdf")}
360
  </button>
361
  </div>
362
  )}
frontend/src/components/document/DocumentSidebar.tsx CHANGED
@@ -1,6 +1,7 @@
1
  "use client";
2
 
3
  import { useState, useCallback } from "react";
 
4
  import type { DocInfo } from "@/app/dashboard/page";
5
  import { api } from "@/lib/api";
6
  import { ScrollArea } from "@/components/ui/scroll-area";
@@ -20,6 +21,7 @@ interface Props {
20
  }
21
 
22
  export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc, onDocumentsChange }: Props) {
 
23
  const [uploading, setUploading] = useState(false);
24
  const [uploadProgress, setUploadProgress] = useState(0);
25
  const [uploadError, setUploadError] = useState("");
@@ -43,7 +45,7 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc
43
  }
44
  onDocumentsChange();
45
  } catch (err) {
46
- const message = err instanceof Error ? err.message : "Upload failed";
47
  setUploadError(message);
48
  } finally {
49
  setUploading(false);
@@ -51,7 +53,7 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc
51
  }
52
  })();
53
  },
54
- [onDocumentsChange]
55
  );
56
 
57
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
@@ -67,7 +69,7 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc
67
 
68
  const handleDelete = async (docId: string, e: React.MouseEvent) => {
69
  e.stopPropagation();
70
- if (!confirm("Delete this document and all its data?")) return;
71
  setDeleting(docId);
72
  try {
73
  await api.delete(`/api/v1/documents/${docId}`);
@@ -119,17 +121,17 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc
119
  {uploading ? (
120
  <div className="space-y-2">
121
  <Loader2 className="w-5 h-5 mx-auto animate-spin text-primary" />
122
- <p className="text-xs text-muted-foreground">Uploading...</p>
123
  <Progress value={uploadProgress} className="h-1" />
124
  </div>
125
  ) : (
126
  <>
127
  <Upload className="w-5 h-5 mx-auto text-muted-foreground mb-2" />
128
  <p className="text-xs text-muted-foreground">
129
- {isDragActive ? "Drop files here" : "Drop files or click to upload"}
130
  </p>
131
  <p className="text-[10px] text-muted-foreground/60 mt-1">
132
- PDF, DOCX, TXT, MD (max 50MB)
133
  </p>
134
  </>
135
  )}
@@ -139,7 +141,7 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc
139
  {/* ── Documents List ──────────────────────────── */}
140
  <div className="px-3 pt-3 pb-1">
141
  <h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">
142
- Documents ({documents.length})
143
  </h3>
144
  </div>
145
 
@@ -147,8 +149,8 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc
147
  {documents.length === 0 ? (
148
  <div className="text-center py-12">
149
  <FolderOpen className="w-8 h-8 mx-auto text-muted-foreground/40 mb-3" />
150
- <p className="text-sm text-muted-foreground">No documents yet</p>
151
- <p className="text-xs text-muted-foreground/60 mt-1">Upload a file to get started</p>
152
  </div>
153
  ) : (
154
  <div className="space-y-1 pb-3">
@@ -179,22 +181,22 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc
179
  <>
180
  <span className="text-[10px] text-muted-foreground">•</span>
181
  <span className="text-[10px] text-muted-foreground">
182
- {doc.page_count} pg
183
  </span>
184
  <span className="text-[10px] text-muted-foreground">•</span>
185
  <span className="text-[10px] text-muted-foreground">
186
- {doc.chunk_count} chunks
187
  </span>
188
  </>
189
  )}
190
  {doc.status === "processing" && (
191
  <Badge variant="secondary" className="text-[9px] h-4 px-1.5">
192
- Processing
193
  </Badge>
194
  )}
195
  {doc.status === "failed" && (
196
  <Badge variant="destructive" className="text-[9px] h-4 px-1.5">
197
- Failed
198
  </Badge>
199
  )}
200
  </div>
 
1
  "use client";
2
 
3
  import { useState, useCallback } from "react";
4
+ import { useTranslation } from "react-i18next";
5
  import type { DocInfo } from "@/app/dashboard/page";
6
  import { api } from "@/lib/api";
7
  import { ScrollArea } from "@/components/ui/scroll-area";
 
21
  }
22
 
23
  export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc, onDocumentsChange }: Props) {
24
+ const { t } = useTranslation();
25
  const [uploading, setUploading] = useState(false);
26
  const [uploadProgress, setUploadProgress] = useState(0);
27
  const [uploadError, setUploadError] = useState("");
 
45
  }
46
  onDocumentsChange();
47
  } catch (err) {
48
+ const message = err instanceof Error ? err.message : t("documents.uploadFailed");
49
  setUploadError(message);
50
  } finally {
51
  setUploading(false);
 
53
  }
54
  })();
55
  },
56
+ [onDocumentsChange, t]
57
  );
58
 
59
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
 
69
 
70
  const handleDelete = async (docId: string, e: React.MouseEvent) => {
71
  e.stopPropagation();
72
+ if (!confirm(t("documents.deleteConfirm"))) return;
73
  setDeleting(docId);
74
  try {
75
  await api.delete(`/api/v1/documents/${docId}`);
 
121
  {uploading ? (
122
  <div className="space-y-2">
123
  <Loader2 className="w-5 h-5 mx-auto animate-spin text-primary" />
124
+ <p className="text-xs text-muted-foreground">{t("documents.uploading")}</p>
125
  <Progress value={uploadProgress} className="h-1" />
126
  </div>
127
  ) : (
128
  <>
129
  <Upload className="w-5 h-5 mx-auto text-muted-foreground mb-2" />
130
  <p className="text-xs text-muted-foreground">
131
+ {isDragActive ? t("documents.dropHere") : t("documents.dropOrClick")}
132
  </p>
133
  <p className="text-[10px] text-muted-foreground/60 mt-1">
134
+ {t("documents.uploadFormats")}
135
  </p>
136
  </>
137
  )}
 
141
  {/* ── Documents List ──────────────────────────── */}
142
  <div className="px-3 pt-3 pb-1">
143
  <h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">
144
+ {t("documents.documentsTitle", { count: documents.length })}
145
  </h3>
146
  </div>
147
 
 
149
  {documents.length === 0 ? (
150
  <div className="text-center py-12">
151
  <FolderOpen className="w-8 h-8 mx-auto text-muted-foreground/40 mb-3" />
152
+ <p className="text-sm text-muted-foreground">{t("documents.noDocuments")}</p>
153
+ <p className="text-xs text-muted-foreground/60 mt-1">{t("documents.getStarted")}</p>
154
  </div>
155
  ) : (
156
  <div className="space-y-1 pb-3">
 
181
  <>
182
  <span className="text-[10px] text-muted-foreground">•</span>
183
  <span className="text-[10px] text-muted-foreground">
184
+ {t("documents.pagesShort", { count: doc.page_count })}
185
  </span>
186
  <span className="text-[10px] text-muted-foreground">•</span>
187
  <span className="text-[10px] text-muted-foreground">
188
+ {t("documents.chunks", { count: doc.chunk_count })}
189
  </span>
190
  </>
191
  )}
192
  {doc.status === "processing" && (
193
  <Badge variant="secondary" className="text-[9px] h-4 px-1.5">
194
+ {t("documents.processing")}
195
  </Badge>
196
  )}
197
  {doc.status === "failed" && (
198
  <Badge variant="destructive" className="text-[9px] h-4 px-1.5">
199
+ {t("documents.failed")}
200
  </Badge>
201
  )}
202
  </div>
frontend/src/components/layout/Header.tsx CHANGED
@@ -1,6 +1,7 @@
1
  "use client";
2
 
3
  import { useAuth } from "@/lib/auth";
 
4
  import { useRouter } from "next/navigation";
5
  import { Button } from "@/components/ui/button";
6
  import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -19,6 +20,7 @@ import {
19
  PanelRightOpen,
20
  LogOut,
21
  Moon,
 
22
  Sun,
23
  } from "lucide-react";
24
  import { useSyncExternalStore } from "react";
@@ -39,6 +41,7 @@ const getServerSnapshot = () => false;
39
 
40
  export default function Header({ sidebarOpen, onToggleSidebar, viewerOpen, onToggleViewer }: HeaderProps) {
41
  const { user, logout } = useAuth();
 
42
  const router = useRouter();
43
  const { theme, setTheme } = useTheme();
44
  const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); // ← replaces useState + useEffect
@@ -51,11 +54,24 @@ export default function Header({ sidebarOpen, onToggleSidebar, viewerOpen, onTog
51
  router.replace("/login");
52
  };
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  return (
55
  <header className="h-14 flex items-center justify-between px-4 border-b border-border/50 bg-card/50 backdrop-blur-md flex-shrink-0 z-50">
56
  {/* Left */}
57
  <div className="flex items-center gap-3">
58
- <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleSidebar} title={sidebarOpen ? "Close sidebar" : "Open sidebar"}>
59
  {sidebarOpen ? <PanelLeftClose className="w-4 h-4" /> : <PanelLeftOpen className="w-4 h-4" />}
60
  </Button>
61
 
@@ -63,22 +79,34 @@ export default function Header({ sidebarOpen, onToggleSidebar, viewerOpen, onTog
63
  <div className="w-7 h-7 rounded-lg bg-primary/15 flex items-center justify-center">
64
  <Brain className="w-4 h-4 text-primary" />
65
  </div>
66
- <span className="font-semibold text-sm hidden sm:inline">Document AI Analyst</span>
67
  </div>
68
  </div>
69
 
70
  {/* Right */}
71
  <div className="flex items-center gap-2">
72
- <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleViewer} title={viewerOpen ? "Close viewer" : "Open viewer"}>
73
  {viewerOpen ? <PanelRightClose className="w-4 h-4" /> : <PanelRightOpen className="w-4 h-4" />}
74
  </Button>
75
 
76
  {mounted && (
77
- <Button variant="ghost" size="icon" className="h-8 w-8" onClick={toggleTheme} title={isDark ? "Light mode" : "Dark mode"}>
78
  {isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
79
  </Button>
80
  )}
81
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  <DropdownMenu>
83
  <DropdownMenuTrigger
84
  render={
@@ -99,11 +127,16 @@ export default function Header({ sidebarOpen, onToggleSidebar, viewerOpen, onTog
99
  <p className="text-xs text-muted-foreground truncate">{user?.email}</p>
100
  </div>
101
  <DropdownMenuSeparator />
102
- <ApiKeyManager />
103
- <DropdownMenuSeparator />
 
 
 
 
 
104
  <DropdownMenuItem className="text-destructive cursor-pointer" onClick={handleLogout}>
105
  <LogOut className="w-4 h-4 mr-2" />
106
- Sign out
107
  </DropdownMenuItem>
108
  </DropdownMenuContent>
109
  </DropdownMenu>
 
1
  "use client";
2
 
3
  import { useAuth } from "@/lib/auth";
4
+ import { useTranslation } from "react-i18next";
5
  import { useRouter } from "next/navigation";
6
  import { Button } from "@/components/ui/button";
7
  import { Avatar, AvatarFallback } from "@/components/ui/avatar";
 
20
  PanelRightOpen,
21
  LogOut,
22
  Moon,
23
+ Shield,
24
  Sun,
25
  } from "lucide-react";
26
  import { useSyncExternalStore } from "react";
 
41
 
42
  export default function Header({ sidebarOpen, onToggleSidebar, viewerOpen, onToggleViewer }: HeaderProps) {
43
  const { user, logout } = useAuth();
44
+ const { t, i18n } = useTranslation();
45
  const router = useRouter();
46
  const { theme, setTheme } = useTheme();
47
  const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); // ← replaces useState + useEffect
 
54
  router.replace("/login");
55
  };
56
 
57
+ const languageLabel = (language: string) => {
58
+ switch (language) {
59
+ case "hi":
60
+ return t("common.hindi");
61
+ case "es":
62
+ return t("common.spanish");
63
+ case "fr":
64
+ return t("common.french");
65
+ default:
66
+ return t("common.english");
67
+ }
68
+ };
69
+
70
  return (
71
  <header className="h-14 flex items-center justify-between px-4 border-b border-border/50 bg-card/50 backdrop-blur-md flex-shrink-0 z-50">
72
  {/* Left */}
73
  <div className="flex items-center gap-3">
74
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleSidebar} title={sidebarOpen ? t("header.closeSidebar") : t("header.openSidebar")}>
75
  {sidebarOpen ? <PanelLeftClose className="w-4 h-4" /> : <PanelLeftOpen className="w-4 h-4" />}
76
  </Button>
77
 
 
79
  <div className="w-7 h-7 rounded-lg bg-primary/15 flex items-center justify-center">
80
  <Brain className="w-4 h-4 text-primary" />
81
  </div>
82
+ <span className="font-semibold text-sm hidden sm:inline">{t("common.appName")}</span>
83
  </div>
84
  </div>
85
 
86
  {/* Right */}
87
  <div className="flex items-center gap-2">
88
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleViewer} title={viewerOpen ? t("header.closeViewer") : t("header.openViewer")}>
89
  {viewerOpen ? <PanelRightClose className="w-4 h-4" /> : <PanelRightOpen className="w-4 h-4" />}
90
  </Button>
91
 
92
  {mounted && (
93
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={toggleTheme} title={isDark ? t("header.lightMode") : t("header.darkMode")}>
94
  {isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
95
  </Button>
96
  )}
97
 
98
+ <select
99
+ aria-label={t("common.language")}
100
+ value={i18n.resolvedLanguage || "en"}
101
+ onChange={(e) => void i18n.changeLanguage(e.target.value)}
102
+ className="h-8 rounded-md border border-border bg-background px-2 text-xs text-foreground"
103
+ >
104
+ <option value="en">{languageLabel("en")}</option>
105
+ <option value="hi">{languageLabel("hi")}</option>
106
+ <option value="es">{languageLabel("es")}</option>
107
+ <option value="fr">{languageLabel("fr")}</option>
108
+ </select>
109
+
110
  <DropdownMenu>
111
  <DropdownMenuTrigger
112
  render={
 
127
  <p className="text-xs text-muted-foreground truncate">{user?.email}</p>
128
  </div>
129
  <DropdownMenuSeparator />
130
+ {user?.is_admin && (
131
+ <DropdownMenuItem className="cursor-pointer" onClick={() => router.push("/admin")}>
132
+ <Shield className="w-4 h-4 mr-2" />
133
+ Admin metrics
134
+ </DropdownMenuItem>
135
+ )}
136
+ {user?.is_admin && <DropdownMenuSeparator />}
137
  <DropdownMenuItem className="text-destructive cursor-pointer" onClick={handleLogout}>
138
  <LogOut className="w-4 h-4 mr-2" />
139
+ {t("header.signOut")}
140
  </DropdownMenuItem>
141
  </DropdownMenuContent>
142
  </DropdownMenu>
frontend/src/components/providers/I18nProvider.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import { I18nextProvider } from "react-i18next";
5
+ import i18n from "@/lib/i18n";
6
+
7
+ export default function I18nProvider({ children }: { children: React.ReactNode }) {
8
+ useEffect(() => {
9
+ document.documentElement.lang = i18n.resolvedLanguage || "en";
10
+
11
+ const handleLanguageChanged = (language: string) => {
12
+ document.documentElement.lang = language;
13
+ };
14
+
15
+ i18n.on("languageChanged", handleLanguageChanged);
16
+ return () => {
17
+ i18n.off("languageChanged", handleLanguageChanged);
18
+ };
19
+ }, []);
20
+
21
+ return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
22
+ }
frontend/src/lib/i18n.ts ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import i18n from "i18next";
4
+ import LanguageDetector from "i18next-browser-languagedetector";
5
+ import { initReactI18next } from "react-i18next";
6
+
7
+ const resources = {
8
+ en: {
9
+ translation: {
10
+ common: {
11
+ appName: "Document AI Analyst",
12
+ language: "Language",
13
+ english: "English",
14
+ hindi: "Hindi",
15
+ spanish: "Spanish",
16
+ french: "French",
17
+ email: "Email",
18
+ password: "Password",
19
+ username: "Username",
20
+ },
21
+ header: {
22
+ closeSidebar: "Close sidebar",
23
+ openSidebar: "Open sidebar",
24
+ closeViewer: "Close viewer",
25
+ openViewer: "Open viewer",
26
+ lightMode: "Light mode",
27
+ darkMode: "Dark mode",
28
+ signOut: "Sign out",
29
+ },
30
+ login: {
31
+ title: "Welcome back",
32
+ description: "Sign in to your Document AI Analyst account",
33
+ fallbackError: "Login failed",
34
+ submit: "Sign In",
35
+ submitting: "Signing in...",
36
+ noAccount: "Don't have an account?",
37
+ createOne: "Create one",
38
+ },
39
+ register: {
40
+ title: "Create Account",
41
+ description: "Start analyzing documents with AI",
42
+ fallbackError: "Registration failed",
43
+ passwordPlaceholder: "Minimum 6 characters",
44
+ submit: "Create Account",
45
+ submitting: "Creating account...",
46
+ hasAccount: "Already have an account?",
47
+ signIn: "Sign in",
48
+ },
49
+ chat: {
50
+ askAboutDocument: "Ask about your document",
51
+ selectDocument: "Select a document",
52
+ readyPrompt: "\"{{name}}\" is ready. Ask any question and get cited answers.",
53
+ uploadPrompt: "Upload and select a document from the sidebar to start chatting.",
54
+ fallbackError: "Failed to get response: {{message}}",
55
+ clearConfirm: "Clear all chat history for this document?",
56
+ askPlaceholder: "Ask about \"{{name}}\"...",
57
+ selectPlaceholder: "Select a document first...",
58
+ exportTitle: "Export chat history",
59
+ markdown: "Markdown (.md)",
60
+ plainText: "Plain Text (.txt)",
61
+ pdf: "PDF (.pdf)",
62
+ },
63
+ documents: {
64
+ uploadFailed: "Upload failed",
65
+ deleteConfirm: "Delete this document and all its data?",
66
+ uploading: "Uploading...",
67
+ dropHere: "Drop files here",
68
+ dropOrClick: "Drop files or click to upload",
69
+ uploadFormats: "PDF, DOCX, TXT, MD (max 50MB)",
70
+ documentsTitle: "Documents ({{count}})",
71
+ noDocuments: "No documents yet",
72
+ getStarted: "Upload a file to get started",
73
+ pagesShort: "{{count}} pg",
74
+ chunks: "{{count}} chunks",
75
+ processing: "Processing",
76
+ failed: "Failed",
77
+ },
78
+ },
79
+ },
80
+ hi: {
81
+ translation: {
82
+ common: {
83
+ appName: "डॉक्यूमेंट एआई एनालिस्ट",
84
+ language: "भाषा",
85
+ english: "अंग्रेज़ी",
86
+ hindi: "हिंदी",
87
+ spanish: "स्पेनिश",
88
+ french: "फ़्रेंच",
89
+ email: "ईमेल",
90
+ password: "पासवर्ड",
91
+ username: "उपयोगकर्ता नाम",
92
+ },
93
+ header: {
94
+ closeSidebar: "साइडबार बंद करें",
95
+ openSidebar: "साइडबार खोलें",
96
+ closeViewer: "व्यूअर बंद करें",
97
+ openViewer: "व्यूअर खोलें",
98
+ lightMode: "लाइट मोड",
99
+ darkMode: "डार्क मोड",
100
+ signOut: "साइन आउट",
101
+ },
102
+ login: {
103
+ title: "वापसी पर स्वागत है",
104
+ description: "अपने डॉक्यूमेंट एआई एनालिस्ट खाते में साइन इन करें",
105
+ fallbackError: "लॉगिन विफल",
106
+ submit: "साइन इन करें",
107
+ submitting: "साइन इन हो रहा है...",
108
+ noAccount: "क्या आपका खाता नहीं है?",
109
+ createOne: "एक बनाएं",
110
+ },
111
+ register: {
112
+ title: "खाता बनाएं",
113
+ description: "एआई के साथ दस्तावेज़ों का विश्लेषण शुरू करें",
114
+ fallbackError: "पंजीकरण विफल",
115
+ passwordPlaceholder: "कम से कम 6 अक्षर",
116
+ submit: "खाता बनाएं",
117
+ submitting: "खाता बनाया जा रहा है...",
118
+ hasAccount: "क्या पहले से खाता है?",
119
+ signIn: "साइन इन करें",
120
+ },
121
+ chat: {
122
+ askAboutDocument: "अपने दस्तावेज़ के बारे में पूछें",
123
+ selectDocument: "एक दस्तावेज़ चुनें",
124
+ readyPrompt: "\"{{name}}\" तैयार है। कोई भी प्रश्न पूछें और स्रोत सहित उत्तर पाएं।",
125
+ uploadPrompt: "चैट शुरू करने के लिए साइडबार से फ़ाइल अपलोड और चुनें।",
126
+ fallbackError: "जवाब प्राप्त नहीं हुआ: {{message}}",
127
+ clearConfirm: "क्या इस दस्तावेज़ का पूरा चैट इतिहास साफ़ करें?",
128
+ askPlaceholder: "\"{{name}}\" के बारे में पूछें...",
129
+ selectPlaceholder: "पहले एक दस्तावेज़ चुनें...",
130
+ exportTitle: "चैट इतिहास निर्यात करें",
131
+ markdown: "मार्कडाउन (.md)",
132
+ plainText: "सादा पाठ (.txt)",
133
+ pdf: "पीडीएफ (.pdf)",
134
+ },
135
+ documents: {
136
+ uploadFailed: "अपलोड विफल",
137
+ deleteConfirm: "क्या इस दस्तावेज़ और उसके सभी डेटा को हटाएं?",
138
+ uploading: "अपलोड हो रहा है...",
139
+ dropHere: "फ़ाइलें यहाँ छोड़ें",
140
+ dropOrClick: "फ़ाइलें छोड़ें या अपलोड के लिए क्लिक करें",
141
+ uploadFormats: "PDF, DOCX, TXT, MD (अधिकतम 50MB)",
142
+ documentsTitle: "दस्तावेज़ ({{count}})",
143
+ noDocuments: "अभी तक कोई दस्तावेज़ नहीं",
144
+ getStarted: "शुरू करने के लिए फ़ाइल अपलोड करें",
145
+ pagesShort: "{{count}} पेज",
146
+ chunks: "{{count}} खंड",
147
+ processing: "प्रोसेस हो रहा है",
148
+ failed: "विफल",
149
+ },
150
+ },
151
+ },
152
+ es: {
153
+ translation: {
154
+ common: {
155
+ appName: "Analista IA de Documentos",
156
+ language: "Idioma",
157
+ english: "Inglés",
158
+ hindi: "Hindi",
159
+ spanish: "Español",
160
+ french: "Francés",
161
+ email: "Correo electrónico",
162
+ password: "Contraseña",
163
+ username: "Nombre de usuario",
164
+ },
165
+ header: {
166
+ closeSidebar: "Cerrar barra lateral",
167
+ openSidebar: "Abrir barra lateral",
168
+ closeViewer: "Cerrar visor",
169
+ openViewer: "Abrir visor",
170
+ lightMode: "Modo claro",
171
+ darkMode: "Modo oscuro",
172
+ signOut: "Cerrar sesión",
173
+ },
174
+ login: {
175
+ title: "Bienvenido de nuevo",
176
+ description: "Inicia sesión en tu cuenta de Analista IA de Documentos",
177
+ fallbackError: "Error al iniciar sesión",
178
+ submit: "Iniciar sesión",
179
+ submitting: "Iniciando sesión...",
180
+ noAccount: "¿No tienes una cuenta?",
181
+ createOne: "Crear una",
182
+ },
183
+ register: {
184
+ title: "Crear cuenta",
185
+ description: "Empieza a analizar documentos con IA",
186
+ fallbackError: "Error de registro",
187
+ passwordPlaceholder: "Mínimo 6 caracteres",
188
+ submit: "Crear cuenta",
189
+ submitting: "Creando cuenta...",
190
+ hasAccount: "¿Ya tienes una cuenta?",
191
+ signIn: "Inicia sesión",
192
+ },
193
+ chat: {
194
+ askAboutDocument: "Pregunta sobre tu documento",
195
+ selectDocument: "Selecciona un documento",
196
+ readyPrompt: "\"{{name}}\" está listo. Haz cualquier pregunta y obtén respuestas con citas.",
197
+ uploadPrompt: "Sube y selecciona un documento de la barra lateral para comenzar a chatear.",
198
+ fallbackError: "No se pudo obtener respuesta: {{message}}",
199
+ clearConfirm: "¿Borrar todo el historial de chat de este documento?",
200
+ askPlaceholder: "Pregunta sobre \"{{name}}\"...",
201
+ selectPlaceholder: "Primero selecciona un documento...",
202
+ exportTitle: "Exportar historial del chat",
203
+ markdown: "Markdown (.md)",
204
+ plainText: "Texto plano (.txt)",
205
+ pdf: "PDF (.pdf)",
206
+ },
207
+ documents: {
208
+ uploadFailed: "Error de carga",
209
+ deleteConfirm: "¿Eliminar este documento y todos sus datos?",
210
+ uploading: "Subiendo...",
211
+ dropHere: "Suelta archivos aquí",
212
+ dropOrClick: "Suelta archivos o haz clic para subir",
213
+ uploadFormats: "PDF, DOCX, TXT, MD (máx. 50MB)",
214
+ documentsTitle: "Documentos ({{count}})",
215
+ noDocuments: "Aún no hay documentos",
216
+ getStarted: "Sube un archivo para comenzar",
217
+ pagesShort: "{{count}} pág",
218
+ chunks: "{{count}} fragmentos",
219
+ processing: "Procesando",
220
+ failed: "Falló",
221
+ },
222
+ },
223
+ },
224
+ fr: {
225
+ translation: {
226
+ common: {
227
+ appName: "Analyste IA de Documents",
228
+ language: "Langue",
229
+ english: "Anglais",
230
+ hindi: "Hindi",
231
+ spanish: "Espagnol",
232
+ french: "Français",
233
+ email: "E-mail",
234
+ password: "Mot de passe",
235
+ username: "Nom d'utilisateur",
236
+ },
237
+ header: {
238
+ closeSidebar: "Fermer la barre latérale",
239
+ openSidebar: "Ouvrir la barre latérale",
240
+ closeViewer: "Fermer le lecteur",
241
+ openViewer: "Ouvrir le lecteur",
242
+ lightMode: "Mode clair",
243
+ darkMode: "Mode sombre",
244
+ signOut: "Se déconnecter",
245
+ },
246
+ login: {
247
+ title: "Bon retour",
248
+ description: "Connectez-vous à votre compte Analyste IA de Documents",
249
+ fallbackError: "Échec de la connexion",
250
+ submit: "Se connecter",
251
+ submitting: "Connexion en cours...",
252
+ noAccount: "Vous n'avez pas de compte ?",
253
+ createOne: "En créer un",
254
+ },
255
+ register: {
256
+ title: "Créer un compte",
257
+ description: "Commencez à analyser des documents avec l'IA",
258
+ fallbackError: "Échec de l'inscription",
259
+ passwordPlaceholder: "6 caractères minimum",
260
+ submit: "Créer un compte",
261
+ submitting: "Création du compte...",
262
+ hasAccount: "Vous avez déjà un compte ?",
263
+ signIn: "Se connecter",
264
+ },
265
+ chat: {
266
+ askAboutDocument: "Posez une question sur votre document",
267
+ selectDocument: "Sélectionnez un document",
268
+ readyPrompt: "\"{{name}}\" est prêt. Posez n'importe quelle question et obtenez des réponses sourcées.",
269
+ uploadPrompt: "Importez puis sélectionnez un document dans la barre latérale pour commencer à discuter.",
270
+ fallbackError: "Impossible d'obtenir une réponse : {{message}}",
271
+ clearConfirm: "Effacer tout l'historique de discussion de ce document ?",
272
+ askPlaceholder: "Posez une question sur \"{{name}}\"...",
273
+ selectPlaceholder: "Sélectionnez d'abord un document...",
274
+ exportTitle: "Exporter l'historique du chat",
275
+ markdown: "Markdown (.md)",
276
+ plainText: "Texte brut (.txt)",
277
+ pdf: "PDF (.pdf)",
278
+ },
279
+ documents: {
280
+ uploadFailed: "Échec de l'envoi",
281
+ deleteConfirm: "Supprimer ce document et toutes ses données ?",
282
+ uploading: "Envoi en cours...",
283
+ dropHere: "Déposez les fichiers ici",
284
+ dropOrClick: "Déposez les fichiers ou cliquez pour téléverser",
285
+ uploadFormats: "PDF, DOCX, TXT, MD (max 50 Mo)",
286
+ documentsTitle: "Documents ({{count}})",
287
+ noDocuments: "Aucun document pour le moment",
288
+ getStarted: "Importez un fichier pour commencer",
289
+ pagesShort: "{{count}} p",
290
+ chunks: "{{count}} segments",
291
+ processing: "Traitement",
292
+ failed: "Échec",
293
+ },
294
+ },
295
+ },
296
+ } as const;
297
+
298
+ if (!i18n.isInitialized) {
299
+ void i18n
300
+ .use(LanguageDetector)
301
+ .use(initReactI18next)
302
+ .init({
303
+ resources,
304
+ fallbackLng: "en",
305
+ supportedLngs: ["en", "hi", "es", "fr"],
306
+ interpolation: {
307
+ escapeValue: false,
308
+ },
309
+ detection: {
310
+ order: ["localStorage", "navigator"],
311
+ caches: ["localStorage"],
312
+ lookupLocalStorage: "i18nextLng",
313
+ },
314
+ react: {
315
+ useSuspense: false,
316
+ },
317
+ });
318
+ }
319
+
320
+ export default i18n;