Kumari Vaishnavi commited on
Commit
bdff89f
·
unverified ·
2 Parent(s): 4ea829da40172d

Merge branch 'dev' into fix/mobile-responsiveness-130

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +18 -0
  2. .github/ISSUE_TEMPLATE/bug_report.yml +30 -49
  3. .github/ISSUE_TEMPLATE/feature_request.yml +22 -43
  4. .github/workflows/ci.yml +3 -1
  5. CODE_OF_CONDUCT.md +85 -0
  6. SECURITY.md +26 -0
  7. backend/app/auth.py +28 -1
  8. backend/app/config.py +10 -0
  9. backend/app/database.py +31 -2
  10. backend/app/main.py +2 -0
  11. backend/app/metrics.py +33 -0
  12. backend/app/models.py +28 -0
  13. backend/app/rag/agent.py +17 -0
  14. backend/app/rag/chunker.py +41 -0
  15. backend/app/rag/embeddings.py +19 -2
  16. backend/app/rag/retriever.py +12 -0
  17. backend/app/rag/tracing.py +102 -0
  18. backend/app/rag/vectorstore.py +17 -4
  19. backend/app/rag/vision.py +99 -0
  20. backend/app/routes/admin.py +73 -0
  21. backend/app/routes/auth.py +68 -1
  22. backend/app/routes/chat.py +148 -57
  23. backend/app/schemas.py +49 -0
  24. backend/requirements.txt +1 -0
  25. backend/tests/conftest.py +60 -6
  26. backend/tests/test_admin.py +65 -0
  27. backend/tests/test_auth.py +36 -0
  28. backend/tests/test_share.py +61 -0
  29. bots/discord/README.md +37 -0
  30. bots/discord/bot.py +68 -0
  31. bots/discord/requirements.txt +2 -0
  32. frontend/package-lock.json +116 -0
  33. frontend/package.json +4 -0
  34. frontend/src/app/admin/page.tsx +277 -0
  35. frontend/src/app/dashboard/page.tsx +13 -1
  36. frontend/src/app/globals.css +29 -0
  37. frontend/src/app/layout.tsx +15 -6
  38. frontend/src/app/login/page.tsx +11 -9
  39. frontend/src/app/register/page.tsx +13 -11
  40. frontend/src/app/share/page.tsx +188 -0
  41. frontend/src/components/auth/ApiKeyManager.tsx +158 -0
  42. frontend/src/components/chat/ChatPanel.tsx +15 -11
  43. frontend/src/components/chat/MessageBubble.tsx +72 -19
  44. frontend/src/components/chat/SourceCard.tsx +114 -71
  45. frontend/src/components/document/DocumentSidebar.tsx +15 -13
  46. frontend/src/components/layout/Header.tsx +17 -1
  47. frontend/src/components/layout/ThemeProvider.tsx +8 -0
  48. frontend/src/components/layout/ThemeToggle.tsx +31 -0
  49. frontend/src/components/providers/I18nProvider.tsx +22 -0
  50. frontend/src/lib/api.ts +23 -0
.env.example CHANGED
@@ -91,6 +91,24 @@ HF_TOKEN=your_huggingface_token_here
91
  # Optional — defaults to 1024
92
  # LLM_MAX_NEW_TOKENS=1024
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  # ── Embeddings (Optional — defaults shown)──────────────────────────────────────────────
95
 
96
  # SentenceTransformer model ID for generating document embeddings.
 
91
  # Optional — defaults to 1024
92
  # LLM_MAX_NEW_TOKENS=1024
93
 
94
+ # ── LangSmith Tracing (Optional) ────────────────────────
95
+
96
+ # Enable LangSmith tracing for the backend RAG pipeline.
97
+ # Optional — defaults to False
98
+ # LANGSMITH_TRACING=False
99
+
100
+ # LangSmith API key.
101
+ # Optional — only needed when LANGSMITH_TRACING=True
102
+ # LANGSMITH_API_KEY=
103
+
104
+ # LangSmith API endpoint.
105
+ # Optional — defaults to "https://api.smith.langchain.com"
106
+ # LANGSMITH_ENDPOINT=https://api.smith.langchain.com
107
+
108
+ # LangSmith project name used for traced runs.
109
+ # Optional — defaults to "pdf-assistant-rag"
110
+ # LANGSMITH_PROJECT=pdf-assistant-rag
111
+
112
  # ── Embeddings (Optional — defaults shown)──────────────────────────────────────────────
113
 
114
  # SentenceTransformer model ID for generating document embeddings.
.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
CODE_OF_CONDUCT.md ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, religion, or sexual identity
10
+ and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the
26
+ overall community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or
31
+ advances of any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email
35
+ address, without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official e-mail address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement.
63
+ All complaints will be reviewed and investigated promptly and fairly.
64
+
65
+ All community leaders are obligated to respect the privacy and security of the
66
+ reporter of any incident.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
71
+ version 2.1, available at
72
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
73
+
74
+ Community Impact Guidelines were inspired by
75
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
76
+
77
+ For answers to common questions about this code of conduct, see the FAQ at
78
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available
79
+ at [https://www.contributor-covenant.org/translations][translations].
80
+
81
+ [homepage]: https://www.contributor-covenant.org
82
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
83
+ [Mozilla CoC]: https://github.com/mozilla/diversity
84
+ [FAQ]: https://www.contributor-covenant.org/faq
85
+ [translations]: https://www.contributor-covenant.org/translations
SECURITY.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ Currently, the following branches and versions of PDF-Assistant-RAG are supported with security updates.
6
+
7
+ | Version | Supported |
8
+ | ------- | ------------------ |
9
+ | `dev` | :white_check_mark: |
10
+ | `main` | :white_check_mark: |
11
+ | < 1.0 | :x: |
12
+
13
+ ## Reporting a Vulnerability
14
+
15
+ We take the security of our users and their data very seriously. If you discover a security vulnerability in this project, please **do not** report it by creating a public GitHub issue.
16
+
17
+ Instead, please privately report it by emailing the repository owner directly.
18
+
19
+ When reporting a vulnerability, please include:
20
+ * A detailed description of the vulnerability.
21
+ * The steps required to reproduce the vulnerability.
22
+ * Any potential impact or risk to users.
23
+
24
+ We will acknowledge your email within 48 hours and work with you to understand and resolve the issue. We aim to fix critical security issues as fast as possible and will credit you in the release notes if you wish.
25
+
26
+ Thank you for helping keep this project secure!
backend/app/auth.py CHANGED
@@ -67,12 +67,39 @@ def decode_token(token: str, token_type: str = "access") -> Optional[str]:
67
 
68
  # ── FastAPI Dependencies ─────────────────────────────
69
 
 
 
70
  def get_current_user(
71
  credentials: HTTPAuthorizationCredentials = Depends(security),
72
  db: Session = Depends(get_db),
73
  ) -> User:
74
- """Dependency: extract and validate user from JWT bearer token."""
75
  token = credentials.credentials
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  user_id = decode_token(token)
77
 
78
  if not user_id:
 
67
 
68
  # ── FastAPI Dependencies ─────────────────────────────
69
 
70
+ import hashlib
71
+
72
  def get_current_user(
73
  credentials: HTTPAuthorizationCredentials = Depends(security),
74
  db: Session = Depends(get_db),
75
  ) -> User:
76
+ """Dependency: extract and validate user from JWT bearer token or API key."""
77
  token = credentials.credentials
78
+
79
+ # Check if token is an API key
80
+ if token.startswith("rag_"):
81
+ hashed = hashlib.sha256(token.encode("utf-8")).hexdigest()
82
+ from app.models import ApiKey
83
+ api_key = db.query(ApiKey).filter(ApiKey.hashed_key == hashed).first()
84
+ if not api_key:
85
+ raise HTTPException(
86
+ status_code=status.HTTP_401_UNAUTHORIZED,
87
+ detail="Invalid API key",
88
+ headers={"WWW-Authenticate": "Bearer"},
89
+ )
90
+
91
+ api_key.last_used = datetime.now(timezone.utc)
92
+ db.commit()
93
+
94
+ user = api_key.user
95
+ if not user:
96
+ raise HTTPException(
97
+ status_code=status.HTTP_401_UNAUTHORIZED,
98
+ detail="User not found for this API key",
99
+ )
100
+ return user
101
+
102
+ # Otherwise, process as JWT
103
  user_id = decode_token(token)
104
 
105
  if not user_id:
backend/app/config.py CHANGED
@@ -56,8 +56,18 @@ class Settings(BaseSettings):
56
  LLM_TEMPERATURE: float = 0.3
57
  SUMMARY_MAX_TOKENS: int = 512
58
 
 
 
 
 
 
 
59
  # ── Reranker ─────────────────────────────────────────
60
  RERANKER_MODEL: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
 
 
 
 
61
 
62
 
63
  @property
 
56
  LLM_TEMPERATURE: float = 0.3
57
  SUMMARY_MAX_TOKENS: int = 512
58
 
59
+ # ── LangSmith Tracing (optional) ─────────────────────
60
+ LANGSMITH_TRACING: bool = False
61
+ LANGSMITH_API_KEY: str = ""
62
+ LANGSMITH_ENDPOINT: str = "https://api.smith.langchain.com"
63
+ LANGSMITH_PROJECT: str = "pdf-assistant-rag"
64
+
65
  # ── Reranker ─────────────────────────────────────────
66
  RERANKER_MODEL: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
67
+ # ── Vision / Image captioning ─────────────────────
68
+ VISION_PROVIDER: str | None = None # e.g. 'openai'
69
+ VISION_MODEL: str | None = None
70
+ OPENAI_API_KEY: str = ""
71
 
72
 
73
  @property
backend/app/database.py CHANGED
@@ -3,11 +3,13 @@ SQLAlchemy database setup with SQLite.
3
  Uses synchronous SQLAlchemy for simplicity and compatibility.
4
  """
5
  import os
6
- from sqlalchemy import create_engine
 
7
  from sqlalchemy.orm import sessionmaker, declarative_base
8
  from app.config import get_settings
9
 
10
  settings = get_settings()
 
11
 
12
  # ── Ensure data directory exists ─────────────────────
13
  db_path = settings.DATABASE_URL.replace("sqlite:///", "")
@@ -34,7 +36,34 @@ def get_db():
34
  db.close()
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  def init_db():
38
- """Create all tables on startup."""
39
  from app import models # noqa: F401 — import to register models
40
  Base.metadata.create_all(bind=engine)
 
 
3
  Uses synchronous SQLAlchemy for simplicity and compatibility.
4
  """
5
  import os
6
+ import logging
7
+ from sqlalchemy import create_engine, inspect, text
8
  from sqlalchemy.orm import sessionmaker, declarative_base
9
  from app.config import get_settings
10
 
11
  settings = get_settings()
12
+ logger = logging.getLogger(__name__)
13
 
14
  # ── Ensure data directory exists ─────────────────────
15
  db_path = settings.DATABASE_URL.replace("sqlite:///", "")
 
36
  db.close()
37
 
38
 
39
+ def _migrate_schema():
40
+ """Apply schema migrations for existing databases (SQLite-compatible).
41
+
42
+ SQLAlchemy's ``create_all`` only creates new tables and does **not**
43
+ add missing columns to existing tables. This helper fills that gap
44
+ for non-destructive changes such as new nullable columns.
45
+ """
46
+ inspector = inspect(engine)
47
+ existing_columns = {c["name"] for c in inspector.get_columns("users")}
48
+
49
+ migrations = [
50
+ ("users", "hf_token", "ALTER TABLE users ADD COLUMN hf_token VARCHAR(255)"),
51
+ ]
52
+
53
+ for table, column, ddl in migrations:
54
+ if column not in existing_columns:
55
+ try:
56
+ with engine.begin() as conn:
57
+ conn.execute(text(ddl))
58
+ logger.info("Migration: added column %s.%s", table, column)
59
+ except Exception:
60
+ logger.warning(
61
+ "Migration skipped (may already exist): %s.%s", table, column
62
+ )
63
+
64
+
65
  def init_db():
66
+ """Create all tables on startup and apply schema migrations."""
67
  from app import models # noqa: F401 — import to register models
68
  Base.metadata.create_all(bind=engine)
69
+ _migrate_schema()
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/models.py CHANGED
@@ -22,10 +22,26 @@ class User(Base):
22
  is_admin = Column(Boolean, default=False)
23
  created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
24
  last_login = Column(DateTime, nullable=True, index=True)
 
25
 
26
  # Relationships
27
  documents = relationship("Document", back_populates="owner", cascade="all, delete-orphan")
28
  messages = relationship("ChatMessage", back_populates="user", cascade="all, delete-orphan")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
 
31
  class Document(Base):
@@ -62,3 +78,15 @@ class ChatMessage(Base):
62
  # Relationships
63
  user = relationship("User", back_populates="messages")
64
  document = relationship("Document", back_populates="messages")
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  is_admin = Column(Boolean, default=False)
23
  created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
24
  last_login = Column(DateTime, nullable=True, index=True)
25
+ hf_token = Column(String(255), nullable=True)
26
 
27
  # Relationships
28
  documents = relationship("Document", back_populates="owner", cascade="all, delete-orphan")
29
  messages = relationship("ChatMessage", back_populates="user", cascade="all, delete-orphan")
30
+ api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan")
31
+
32
+
33
+ class ApiKey(Base):
34
+ __tablename__ = "api_keys"
35
+
36
+ id = Column(String, primary_key=True, default=generate_uuid)
37
+ user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
38
+ key_prefix = Column(String(10), nullable=False)
39
+ hashed_key = Column(String(255), nullable=False, unique=True, index=True)
40
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
41
+ last_used = Column(DateTime, nullable=True)
42
+
43
+ # Relationships
44
+ user = relationship("User", back_populates="api_keys")
45
 
46
 
47
  class Document(Base):
 
78
  # Relationships
79
  user = relationship("User", back_populates="messages")
80
  document = relationship("Document", back_populates="messages")
81
+ shared_message = relationship("SharedMessage", back_populates="message", uselist=False, cascade="all, delete-orphan")
82
+
83
+
84
+ class SharedMessage(Base):
85
+ __tablename__ = "shared_messages"
86
+
87
+ id = Column(String, primary_key=True, default=generate_uuid)
88
+ message_id = Column(String, ForeignKey("chat_messages.id"), nullable=False, unique=True, index=True)
89
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
90
+
91
+ # Relationships
92
+ message = relationship("ChatMessage", back_populates="shared_message")
backend/app/rag/agent.py CHANGED
@@ -10,6 +10,7 @@ from huggingface_hub import InferenceClient
10
  from app.config import get_settings
11
  from app.rag.retriever import retrieve
12
  from app.rag.prompts import SYSTEM_PROMPT, RAG_PROMPT_TEMPLATE, GREETING_PROMPT
 
13
 
14
  logger = logging.getLogger(__name__)
15
  settings = get_settings()
@@ -65,6 +66,14 @@ def _chat_messages(system: str, user_content: str) -> list:
65
  ]
66
 
67
 
 
 
 
 
 
 
 
 
68
  def generate_answer(
69
  question: str,
70
  user_id: str,
@@ -145,6 +154,14 @@ def generate_answer(
145
  return {"answer": answer, "sources": sources}
146
 
147
 
 
 
 
 
 
 
 
 
148
  def generate_answer_stream(
149
  question: str,
150
  user_id: str,
 
10
  from app.config import get_settings
11
  from app.rag.retriever import retrieve
12
  from app.rag.prompts import SYSTEM_PROMPT, RAG_PROMPT_TEMPLATE, GREETING_PROMPT
13
+ from app.rag.tracing import trace_function
14
 
15
  logger = logging.getLogger(__name__)
16
  settings = get_settings()
 
66
  ]
67
 
68
 
69
+ @trace_function(
70
+ "generate_answer",
71
+ metadata_factory=lambda question, user_id, document_id=None: {
72
+ "user_id": user_id,
73
+ "document_id": document_id,
74
+ "llm_model": settings.LLM_MODEL,
75
+ },
76
+ )
77
  def generate_answer(
78
  question: str,
79
  user_id: str,
 
154
  return {"answer": answer, "sources": sources}
155
 
156
 
157
+ @trace_function(
158
+ "generate_answer_stream",
159
+ metadata_factory=lambda question, user_id, document_id=None: {
160
+ "user_id": user_id,
161
+ "document_id": document_id,
162
+ "llm_model": settings.LLM_MODEL,
163
+ },
164
+ )
165
  def generate_answer_stream(
166
  question: str,
167
  user_id: str,
backend/app/rag/chunker.py CHANGED
@@ -28,6 +28,34 @@ def extract_pdf(filepath: str) -> List[Dict[str, Any]]:
28
  return pages
29
 
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  def extract_docx(filepath: str) -> List[Dict[str, Any]]:
32
  """Extract text from DOCX files."""
33
  doc = docx.Document(filepath)
@@ -50,10 +78,13 @@ def chunk_document(filepath: str) -> List[Dict[str, Any]]:
50
  Returns list of dicts with 'text', 'page', and 'chunk_index'.
51
  """
52
  ext = filepath.rsplit(".", 1)[-1].lower()
 
53
 
54
  # ── Extract text by file type ────────────────────
55
  if ext == "pdf":
56
  pages = extract_pdf(filepath)
 
 
57
  elif ext == "docx":
58
  pages = extract_docx(filepath)
59
  elif ext in ("txt", "md"):
@@ -91,6 +122,16 @@ def chunk_document(filepath: str) -> List[Dict[str, Any]]:
91
  })
92
  chunk_index += 1
93
 
 
 
 
 
 
 
 
 
 
 
94
  return all_chunks
95
 
96
 
 
28
  return pages
29
 
30
 
31
+ def extract_pdf_images(filepath: str) -> List[Dict[str, Any]]:
32
+ """Extract images from a PDF and return list of dicts with image bytes and page number.
33
+
34
+ Each entry: {"image_bytes": b"...", "page": int}
35
+ """
36
+ images = []
37
+ doc = fitz.open(filepath)
38
+
39
+ for page_num, page in enumerate(doc):
40
+ # get_images returns a list of tuples where first item is xref
41
+ for img in page.get_images(full=True):
42
+ xref = img[0]
43
+ try:
44
+ pix = fitz.Pixmap(doc, xref)
45
+ # Convert to RGB if it's CMYK or has alpha
46
+ if pix.n >= 4:
47
+ pix = fitz.Pixmap(fitz.csRGB, pix)
48
+
49
+ img_bytes = pix.tobytes("png")
50
+ images.append({"image_bytes": img_bytes, "page": page_num + 1})
51
+ except Exception:
52
+ # ignore extracting this image
53
+ continue
54
+
55
+ doc.close()
56
+ return images
57
+
58
+
59
  def extract_docx(filepath: str) -> List[Dict[str, Any]]:
60
  """Extract text from DOCX files."""
61
  doc = docx.Document(filepath)
 
78
  Returns list of dicts with 'text', 'page', and 'chunk_index'.
79
  """
80
  ext = filepath.rsplit(".", 1)[-1].lower()
81
+ images = []
82
 
83
  # ── Extract text by file type ────────────────────
84
  if ext == "pdf":
85
  pages = extract_pdf(filepath)
86
+ # also extract images for later captioning/embedding
87
+ images = extract_pdf_images(filepath)
88
  elif ext == "docx":
89
  pages = extract_docx(filepath)
90
  elif ext in ("txt", "md"):
 
122
  })
123
  chunk_index += 1
124
 
125
+ # Attach any images that belong to this page after text chunks for the page
126
+ for img in [i for i in images if i["page"] == page_num]:
127
+ all_chunks.append({
128
+ "text": "",
129
+ "page": page_num,
130
+ "chunk_index": chunk_index,
131
+ "image_bytes": img["image_bytes"],
132
+ })
133
+ chunk_index += 1
134
+
135
  return all_chunks
136
 
137
 
backend/app/rag/embeddings.py CHANGED
@@ -6,6 +6,7 @@ import logging
6
  from typing import List
7
  from langchain_huggingface import HuggingFaceEmbeddings
8
  from app.config import get_settings
 
9
 
10
  logger = logging.getLogger(__name__)
11
  settings = get_settings()
@@ -36,10 +37,26 @@ def get_embedding_model() -> HuggingFaceEmbeddings:
36
  def embed_texts(texts: List[str]) -> List[List[float]]:
37
  """Embed a batch of texts into vectors."""
38
  model = get_embedding_model()
39
- return model.embed_documents(texts)
 
 
 
 
 
 
 
 
40
 
41
 
42
  def embed_query(query: str) -> List[float]:
43
  """Embed a single query string."""
44
  model = get_embedding_model()
45
- return model.embed_query(query)
 
 
 
 
 
 
 
 
 
6
  from typing import List
7
  from langchain_huggingface import HuggingFaceEmbeddings
8
  from app.config import get_settings
9
+ from app.rag.tracing import trace_call
10
 
11
  logger = logging.getLogger(__name__)
12
  settings = get_settings()
 
37
  def embed_texts(texts: List[str]) -> List[List[float]]:
38
  """Embed a batch of texts into vectors."""
39
  model = get_embedding_model()
40
+ return trace_call(
41
+ "embed_texts",
42
+ lambda: model.embed_documents(texts),
43
+ run_type="embedding",
44
+ metadata={
45
+ "embedding_model": settings.EMBEDDING_MODEL,
46
+ "text_count": len(texts),
47
+ },
48
+ )
49
 
50
 
51
  def embed_query(query: str) -> List[float]:
52
  """Embed a single query string."""
53
  model = get_embedding_model()
54
+ return trace_call(
55
+ "embed_query",
56
+ lambda: model.embed_query(query),
57
+ run_type="embedding",
58
+ metadata={
59
+ "embedding_model": settings.EMBEDDING_MODEL,
60
+ "query_length": len(query),
61
+ },
62
+ )
backend/app/rag/retriever.py CHANGED
@@ -5,6 +5,7 @@ import logging
5
  from typing import List, Dict, Any, Optional
6
  from app.config import get_settings
7
  from app.rag.embeddings import embed_query
 
8
  from app.rag.vectorstore import query_chunks
9
 
10
  logger = logging.getLogger(__name__)
@@ -31,6 +32,17 @@ def get_reranker():
31
  return _reranker if _reranker != "disabled" else None
32
 
33
 
 
 
 
 
 
 
 
 
 
 
 
34
  def retrieve(
35
  query: str,
36
  user_id: str,
 
5
  from typing import List, Dict, Any, Optional
6
  from app.config import get_settings
7
  from app.rag.embeddings import embed_query
8
+ from app.rag.tracing import trace_function
9
  from app.rag.vectorstore import query_chunks
10
 
11
  logger = logging.getLogger(__name__)
 
32
  return _reranker if _reranker != "disabled" else None
33
 
34
 
35
+ @trace_function(
36
+ "retrieve",
37
+ metadata_factory=lambda query, user_id, document_id=None: {
38
+ "user_id": user_id,
39
+ "document_id": document_id,
40
+ "embedding_model": settings.EMBEDDING_MODEL,
41
+ "reranker_model": settings.RERANKER_MODEL,
42
+ "top_k_retrieval": settings.TOP_K_RETRIEVAL,
43
+ "top_k_rerank": settings.TOP_K_RERANK,
44
+ },
45
+ )
46
  def retrieve(
47
  query: str,
48
  user_id: str,
backend/app/rag/tracing.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Optional LangSmith tracing helpers for the RAG pipeline.
3
+ Safe to import even when LangSmith is not installed or configured.
4
+ """
5
+ import logging
6
+ import os
7
+ from functools import wraps
8
+ from typing import Any, Callable, Optional
9
+
10
+ from app.config import get_settings
11
+
12
+ logger = logging.getLogger(__name__)
13
+ settings = get_settings()
14
+
15
+ try:
16
+ from langsmith import traceable as _langsmith_traceable
17
+ except Exception: # pragma: no cover - optional dependency safety
18
+ _langsmith_traceable = None
19
+
20
+
21
+ def configure_langsmith() -> bool:
22
+ """Configure LangSmith environment variables when tracing is enabled."""
23
+ if not settings.LANGSMITH_TRACING:
24
+ return False
25
+
26
+ if not settings.LANGSMITH_API_KEY:
27
+ logger.warning("LangSmith tracing enabled but LANGSMITH_API_KEY is not set; tracing disabled.")
28
+ return False
29
+
30
+ os.environ["LANGSMITH_TRACING"] = "true"
31
+ os.environ["LANGSMITH_API_KEY"] = settings.LANGSMITH_API_KEY
32
+ os.environ["LANGSMITH_ENDPOINT"] = settings.LANGSMITH_ENDPOINT
33
+ os.environ["LANGSMITH_PROJECT"] = settings.LANGSMITH_PROJECT
34
+ return _langsmith_traceable is not None
35
+
36
+
37
+ LANGSMITH_ENABLED = configure_langsmith()
38
+
39
+
40
+ def _sanitize_metadata(metadata: Optional[dict[str, Any]]) -> dict[str, Any]:
41
+ return {key: value for key, value in (metadata or {}).items() if value is not None}
42
+
43
+
44
+ def _build_traceable(name: str, run_type: str, metadata: Optional[dict[str, Any]] = None):
45
+ """Build a LangSmith traceable decorator safely across versions."""
46
+ if _langsmith_traceable is None:
47
+ return None
48
+
49
+ sanitized = _sanitize_metadata(metadata)
50
+ try:
51
+ return _langsmith_traceable(
52
+ name=name,
53
+ run_type=run_type,
54
+ metadata=sanitized or None,
55
+ )
56
+ except TypeError:
57
+ return _langsmith_traceable(name=name, run_type=run_type)
58
+
59
+
60
+ def trace_call(
61
+ name: str,
62
+ fn: Callable[..., Any],
63
+ *args: Any,
64
+ run_type: str = "chain",
65
+ metadata: Optional[dict[str, Any]] = None,
66
+ **kwargs: Any,
67
+ ) -> Any:
68
+ """Execute a callable with LangSmith tracing when available."""
69
+ if not LANGSMITH_ENABLED:
70
+ return fn(*args, **kwargs)
71
+
72
+ decorator = _build_traceable(name, run_type, metadata)
73
+ if decorator is None:
74
+ return fn(*args, **kwargs)
75
+
76
+ traced_fn = decorator(fn)
77
+ return traced_fn(*args, **kwargs)
78
+
79
+
80
+ def trace_function(
81
+ name: str,
82
+ *,
83
+ run_type: str = "chain",
84
+ metadata_factory: Optional[Callable[..., dict[str, Any]]] = None,
85
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
86
+ """Decorator wrapper that becomes a no-op when LangSmith is disabled."""
87
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
88
+ @wraps(fn)
89
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
90
+ metadata = metadata_factory(*args, **kwargs) if metadata_factory else None
91
+ return trace_call(
92
+ name,
93
+ fn,
94
+ *args,
95
+ run_type=run_type,
96
+ metadata=metadata,
97
+ **kwargs,
98
+ )
99
+
100
+ return wrapped
101
+
102
+ return decorator
backend/app/rag/vectorstore.py CHANGED
@@ -4,10 +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
 
12
  logger = logging.getLogger(__name__)
13
  settings = get_settings()
@@ -16,12 +13,15 @@ settings = get_settings()
16
  _chroma_client = None
17
 
18
 
19
- def get_chroma_client() -> chromadb.ClientAPI:
20
  """Get or create persistent ChromaDB client."""
21
  global _chroma_client
22
 
23
  if _chroma_client is None:
24
  import os
 
 
 
25
  os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
26
 
27
  _chroma_client = chromadb.PersistentClient(
@@ -55,7 +55,17 @@ def store_chunks(
55
  if not chunks:
56
  return 0
57
 
 
 
 
 
 
 
 
 
58
  client = get_chroma_client()
 
 
59
  embedding_model = get_embedding_model()
60
 
61
  collection_name = get_collection_name(user_id)
@@ -74,6 +84,9 @@ def store_chunks(
74
  "document_id": document_id,
75
  "page": chunk["page"],
76
  "chunk_index": chunk["chunk_index"],
 
 
 
77
  }
78
  for chunk in chunks
79
  ]
 
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(
 
55
  if not chunks:
56
  return 0
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)
 
84
  "document_id": document_id,
85
  "page": chunk["page"],
86
  "chunk_index": chunk["chunk_index"],
87
+ # Indicate whether this chunk was originally an image and include a short caption
88
+ **({"is_image": True, "image_caption": chunk.get("image_caption", "")}
89
+ if chunk.get("is_image") else {}),
90
  }
91
  for chunk in chunks
92
  ]
backend/app/rag/vision.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Image captioning / vision helpers for RAG pipeline.
2
+
3
+ Provides a simple, pluggable interface to generate textual descriptions
4
+ for images extracted from PDFs. By default it uses local OCR (pytesseract)
5
+ when available as a robust fallback. An external VLM provider (OpenAI)
6
+ can be integrated by setting `VISION_PROVIDER` and appropriate API keys
7
+ in settings; the provider hook is intentionally small and optional.
8
+ """
9
+ import logging
10
+ from typing import List, Dict, Any
11
+ from io import BytesIO
12
+
13
+ from app.config import get_settings
14
+
15
+ logger = logging.getLogger(__name__)
16
+ settings = get_settings()
17
+
18
+
19
+ def _ocr_caption(image_bytes: bytes) -> str:
20
+ """Try to produce a caption using pytesseract OCR; returns empty string if not available."""
21
+ try:
22
+ from PIL import Image
23
+ import pytesseract
24
+ except Exception:
25
+ return ""
26
+
27
+ try:
28
+ img = Image.open(BytesIO(image_bytes)).convert("RGB")
29
+ text = pytesseract.image_to_string(img)
30
+ text = text.strip()
31
+ return text
32
+ except Exception as e:
33
+ logger.debug(f"OCR failed: {e}")
34
+ return ""
35
+
36
+
37
+ def caption_image(image_bytes: bytes, page: int | None = None) -> str:
38
+ """Generate a caption for a single image.
39
+
40
+ Order of operations:
41
+ - If an external VLM provider is configured, attempt to call it (not implemented as mandatory).
42
+ - Fall back to local OCR (pytesseract) if available.
43
+ - Otherwise return a simple placeholder caption including the page number.
44
+ """
45
+ # Placeholder for provider-based captioning (e.g., OpenAI / LLaVA hooks)
46
+ provider = getattr(settings, "VISION_PROVIDER", None)
47
+ if provider == "openai":
48
+ try:
49
+ import openai
50
+ # Minimal integration: attempt a text-only caption via responses if available.
51
+ # This is a best-effort hook; users should adapt to their provider's API.
52
+ api_key = getattr(settings, "OPENAI_API_KEY", None)
53
+ if api_key:
54
+ openai.api_key = api_key
55
+ # Use a generic prompt: "Describe the following image"
56
+ # Note: concrete multimodal API usage may vary across SDK versions.
57
+ resp = openai.Image.create(
58
+ prompt="Describe this image in one concise sentence.",
59
+ n=1,
60
+ # We do not re-upload image bytes here; this is a placeholder to show
61
+ # where provider code would be invoked. For production, follow
62
+ # provider docs for sending image data.
63
+ )
64
+ # openai.Image.create returns generated images, not captions — so skip.
65
+ except Exception:
66
+ # If provider integration fails, fall back to OCR below
67
+ logger.debug("OpenAI vision provider failed, falling back to OCR")
68
+
69
+ # Try OCR caption
70
+ ocr = _ocr_caption(image_bytes)
71
+ if ocr:
72
+ # Keep it short if very long
73
+ return (ocr[:500] + "...") if len(ocr) > 500 else ocr
74
+
75
+ # Last-resort caption
76
+ if page:
77
+ return f"Image on page {page}."
78
+ return "Image."
79
+
80
+
81
+ def generate_captions_for_chunks(chunks: List[Dict[str, Any]]) -> None:
82
+ """Mutate chunks in-place: for any chunk containing `image_bytes` but empty `text`,
83
+ generate a caption and set `text`.
84
+ """
85
+ for chunk in chunks:
86
+ if chunk.get("image_bytes") and not chunk.get("text"):
87
+ try:
88
+ caption = caption_image(chunk["image_bytes"], page=chunk.get("page"))
89
+ chunk["text"] = caption
90
+ # Remove raw bytes to avoid accidentally serializing them later
91
+ chunk.pop("image_bytes", None)
92
+ chunk["is_image"] = True
93
+ chunk["image_caption"] = caption
94
+ except Exception as e:
95
+ logger.debug(f"Failed to caption image chunk: {e}")
96
+ # ensure we still mark it as image to avoid losing it
97
+ chunk.pop("image_bytes", None)
98
+ chunk["is_image"] = True
99
+ chunk.setdefault("text", f"Image on page {chunk.get('page')}")
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/auth.py CHANGED
@@ -11,9 +11,10 @@ from sqlalchemy.orm import Session
11
  from sqlalchemy import select
12
  from app.config import get_settings
13
  from app.database import get_db
14
- from app.models import User
15
  from app.schemas import (
16
  GoogleLoginRequest,
 
17
  RefreshRequest,
18
  TokenResponse,
19
  UpdatePassword,
@@ -23,6 +24,8 @@ from app.schemas import (
23
  UserResponse,
24
  UserUpdate,
25
  UserUpdateResponse,
 
 
26
  )
27
  from app.auth import hash_password, verify_password, create_access_token, create_refresh_token, get_current_user, decode_token
28
 
@@ -277,6 +280,34 @@ def get_me(user: User = Depends(get_current_user)):
277
  """
278
  return UserResponse.model_validate(user)
279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  @router.put("/update")
281
  def update_user_info(payload:UserUpdate,
282
  user: User = Depends(get_current_user),
@@ -383,6 +414,42 @@ def update_password(payload:UpdatePassword,
383
  db.rollback()
384
  raise HTTPException(status_code=400, detail="Database error")
385
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  @router.get("/config")
387
  def get_auth_config():
388
  """Return public configuration for auth providers"""
 
11
  from sqlalchemy import select
12
  from app.config import get_settings
13
  from app.database import get_db
14
+ from app.models import User, ApiKey
15
  from app.schemas import (
16
  GoogleLoginRequest,
17
+ HFTokenUpdate,
18
  RefreshRequest,
19
  TokenResponse,
20
  UpdatePassword,
 
24
  UserResponse,
25
  UserUpdate,
26
  UserUpdateResponse,
27
+ ApiKeyResponse,
28
+ ApiKeyCreateResponse,
29
  )
30
  from app.auth import hash_password, verify_password, create_access_token, create_refresh_token, get_current_user, decode_token
31
 
 
280
  """
281
  return UserResponse.model_validate(user)
282
 
283
+ @router.put("/hf-token", response_model=UserResponse)
284
+ def update_hf_token(
285
+ payload: HFTokenUpdate,
286
+ user: User = Depends(get_current_user),
287
+ db: Session = Depends(get_db),
288
+ ):
289
+ """Update the HuggingFace token for the authenticated user.
290
+
291
+ Stores the provided HF token in the user's profile so it can be used
292
+ for HuggingFace API calls (e.g. InferenceClient) in place of the
293
+ globally configured ``HF_TOKEN`` environment variable.
294
+
295
+ Args:
296
+ payload: HFTokenUpdate object containing the new ``hf_token`` value.
297
+ user: The currently authenticated user, obtained from the
298
+ ``get_current_user`` dependency.
299
+ db: SQLAlchemy database session, obtained from the dependency.
300
+
301
+ Returns:
302
+ UserResponse: The updated user profile including the new ``hf_token``
303
+ field.
304
+ """
305
+ user.hf_token = payload.hf_token
306
+ db.commit()
307
+ db.refresh(user)
308
+ return UserResponse.model_validate(user)
309
+
310
+
311
  @router.put("/update")
312
  def update_user_info(payload:UserUpdate,
313
  user: User = Depends(get_current_user),
 
414
  db.rollback()
415
  raise HTTPException(status_code=400, detail="Database error")
416
 
417
+ from typing import List
418
+ import hashlib
419
+
420
+ @router.post("/api-keys", response_model=ApiKeyCreateResponse, status_code=status.HTTP_201_CREATED)
421
+ def create_api_key(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
422
+ """Create a new API key for the authenticated user."""
423
+ raw_key = "rag_" + secrets.token_urlsafe(32)
424
+ hashed_key = hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
425
+
426
+ api_key = ApiKey(
427
+ user_id=user.id,
428
+ key_prefix=raw_key[:10],
429
+ hashed_key=hashed_key,
430
+ )
431
+ db.add(api_key)
432
+ db.commit()
433
+ db.refresh(api_key)
434
+
435
+ return {"key": raw_key, "api_key": api_key}
436
+
437
+ @router.get("/api-keys", response_model=List[ApiKeyResponse])
438
+ def list_api_keys(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
439
+ """List all API keys for the authenticated user."""
440
+ return db.query(ApiKey).filter(ApiKey.user_id == user.id).all()
441
+
442
+ @router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
443
+ def delete_api_key(key_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
444
+ """Revoke an API key."""
445
+ api_key = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
446
+ if not api_key:
447
+ raise HTTPException(status_code=404, detail="API key not found")
448
+
449
+ db.delete(api_key)
450
+ db.commit()
451
+ return None
452
+
453
  @router.get("/config")
454
  def get_auth_config():
455
  """Return public configuration for auth providers"""
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,18 +17,83 @@ 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
21
- from app.schemas import ChatRequest, ChatResponse, ChatMessageResponse, ChatHistoryResponse, SourceChunk
22
  from app.auth import get_current_user
23
- from app.rag.agent import generate_answer, generate_answer_stream
 
 
24
  from app.rate_limit import limiter
 
 
 
 
 
 
 
 
 
25
 
26
  logger = logging.getLogger(__name__)
27
 
28
  router = APIRouter(prefix="/chat", tags=["Chat"])
29
 
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  @router.post("/ask", response_model=ChatResponse)
32
  @limiter.limit("10/minute")
33
  def ask_question(
@@ -63,38 +129,41 @@ def ask_question(
63
  HTTPException: 400 if the document exists but its status is not
64
  "ready" (e.g., still processing or failed).
65
  """
66
- # Validate document exists if specified
67
- if payload.document_id:
68
- doc = db.query(Document).filter(
69
- Document.id == payload.document_id,
70
- Document.user_id == user.id,
71
- ).first()
72
-
73
- if not doc:
74
- raise HTTPException(status_code=404, detail="Document not found")
75
-
76
- if doc.status != "ready":
77
- raise HTTPException(
78
- status_code=400,
79
- detail=f"Document is still {doc.status}. Please wait for processing to complete.",
80
- )
81
-
82
- # Generate answer
83
- result = generate_answer(
84
- question=payload.question,
85
- user_id=user.id,
86
- document_id=payload.document_id,
87
- )
 
88
 
89
- # Save to chat history
90
- _save_message(db, user.id, payload.document_id, "user", payload.question)
91
- _save_message(db, user.id, payload.document_id, "assistant", result["answer"], result["sources"])
92
 
93
- return ChatResponse(
94
- answer=result["answer"],
95
- sources=[SourceChunk(**s) for s in result["sources"]],
96
- document_id=payload.document_id,
97
- )
 
 
98
 
99
 
100
  @router.post("/ask/stream")
@@ -156,6 +225,8 @@ def ask_question_stream(
156
  detail=f"Document is still {doc.status}. Please wait for processing to complete.",
157
  )
158
 
 
 
159
  # Save user message immediately
160
  _save_message(db, user.id, payload.document_id, "user", payload.question)
161
 
@@ -164,31 +235,34 @@ def ask_question_stream(
164
  full_answer = ""
165
  sources = []
166
 
167
- for chunk in generate_answer_stream(
168
- question=payload.question,
169
- user_id=user.id,
170
- document_id=payload.document_id,
171
- ):
172
- yield chunk
173
-
174
- # Parse to accumulate full answer for history
175
- try:
176
- if chunk.startswith("data: "):
177
- data = json.loads(chunk[6:].strip())
178
- if data.get("type") == "token":
179
- full_answer += data.get("data", "")
180
- elif data.get("type") == "sources":
181
- sources = data.get("data", [])
182
- except Exception:
183
- pass
184
-
185
- # Save assistant response to history
186
- from app.database import SessionLocal
187
- save_db = SessionLocal()
188
  try:
189
- _save_message(save_db, user.id, payload.document_id, "assistant", full_answer, sources)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  finally:
191
- save_db.close()
192
 
193
  return StreamingResponse(
194
  event_stream(),
@@ -425,6 +499,23 @@ def _save_message(
425
  db.commit()
426
 
427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  def _format_markdown(doc, messages) -> str:
429
  """Format chat history as a Markdown document.
430
 
 
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,
28
+ ChatMessageResponse,
29
+ ChatHistoryResponse,
30
+ ShareAnswerResponse,
31
+ ShareLinkResponse,
32
+ SourceChunk,
33
+ )
34
 
35
  logger = logging.getLogger(__name__)
36
 
37
  router = APIRouter(prefix="/chat", tags=["Chat"])
38
 
39
 
40
+ @router.get("/share/{message_id}", response_model=ShareAnswerResponse)
41
+ def get_shared_answer(
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",
48
+ ).first()
49
+
50
+ if not message or not db.query(SharedMessage).filter(SharedMessage.message_id == message.id).first():
51
+ raise HTTPException(status_code=404, detail="Shared answer not found")
52
+
53
+ return _share_answer_response(message)
54
+
55
+
56
+ @router.post("/share/{message_id}", response_model=ShareLinkResponse)
57
+ def create_share_link(
58
+ message_id: str,
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,
65
+ ).first()
66
+
67
+ if not message:
68
+ raise HTTPException(status_code=404, detail="Message not found")
69
+
70
+ if message.role != "assistant":
71
+ raise HTTPException(status_code=400, detail="Only assistant messages can be shared")
72
+
73
+ shared_message = db.query(SharedMessage).filter(SharedMessage.message_id == message.id).first()
74
+ if not shared_message:
75
+ shared_message = SharedMessage(message_id=message.id)
76
+ db.add(shared_message)
77
+ db.commit()
78
+
79
+ return ShareLinkResponse(
80
+ message_id=message.id,
81
+ share_url=f"/share?message_id={message.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(),
 
499
  db.commit()
500
 
501
 
502
+ def _share_answer_response(message: ChatMessage) -> ShareAnswerResponse:
503
+ """Format a shared assistant message with only safe public fields."""
504
+ sources = []
505
+ if message.sources_json:
506
+ try:
507
+ sources = [SourceChunk(**item) for item in json.loads(message.sources_json)]
508
+ except Exception:
509
+ sources = []
510
+
511
+ return ShareAnswerResponse(
512
+ id=message.id,
513
+ content=message.content,
514
+ created_at=message.created_at,
515
+ sources=sources,
516
+ )
517
+
518
+
519
  def _format_markdown(doc, messages) -> str:
520
  """Format chat history as a Markdown document.
521
 
backend/app/schemas.py CHANGED
@@ -53,11 +53,30 @@ class RefreshRequest(BaseModel):
53
  refresh_token: str
54
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  class UserResponse(BaseModel):
57
  id: str
58
  username: str
59
  email: str
60
  is_admin: bool
 
61
  created_at: datetime
62
 
63
  class Config:
@@ -99,6 +118,24 @@ class DocumentListResponse(BaseModel):
99
  pages: int
100
 
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  # ── Chat ─────────────────────────────────────────────
103
 
104
  class ChatRequest(BaseModel):
@@ -136,5 +173,17 @@ class ChatHistoryResponse(BaseModel):
136
  document_id: Optional[str] = None
137
 
138
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  # Rebuild models for forward references
140
  TokenResponse.model_rebuild()
 
53
  refresh_token: str
54
 
55
 
56
+ class HFTokenUpdate(BaseModel):
57
+ """Request schema for updating the user's HuggingFace token."""
58
+ hf_token: str
59
+
60
+
61
+ class ApiKeyResponse(BaseModel):
62
+ id: str
63
+ key_preview: str
64
+ created_at: datetime
65
+
66
+ class Config:
67
+ from_attributes = True
68
+
69
+
70
+ class ApiKeyCreateResponse(ApiKeyResponse):
71
+ raw_key: str
72
+
73
+
74
  class UserResponse(BaseModel):
75
  id: str
76
  username: str
77
  email: str
78
  is_admin: bool
79
+ hf_token: Optional[str] = None
80
  created_at: datetime
81
 
82
  class Config:
 
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):
 
173
  document_id: Optional[str] = None
174
 
175
 
176
+ class ShareAnswerResponse(BaseModel):
177
+ id: str
178
+ content: str
179
+ sources: List[SourceChunk] = []
180
+ created_at: datetime
181
+
182
+
183
+ class ShareLinkResponse(BaseModel):
184
+ message_id: str
185
+ share_url: str
186
+
187
+
188
  # Rebuild models for forward references
189
  TokenResponse.model_rebuild()
backend/requirements.txt CHANGED
@@ -31,6 +31,7 @@ langchain
31
  langchain-community
32
  langchain-huggingface
33
  langchain-text-splitters
 
34
 
35
  # Embeddings & ML
36
  sentence-transformers
 
31
  langchain-community
32
  langchain-huggingface
33
  langchain-text-splitters
34
+ langsmith
35
 
36
  # Embeddings & ML
37
  sentence-transformers
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")
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")
@@ -83,7 +84,7 @@ sys.modules.setdefault("slowapi.util", slowapi_util)
83
  from app.auth import create_access_token, create_refresh_token, hash_password
84
  from app.database import Base, get_db
85
  from app.main import app
86
- from app.models import Document, User
87
 
88
 
89
  @pytest.fixture()
@@ -140,6 +141,19 @@ def user(db_session):
140
  return instance
141
 
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  @pytest.fixture()
144
  def auth_headers(user):
145
  token = create_access_token(user.id)
@@ -181,3 +195,43 @@ def pending_document(db_session, user):
181
  db_session.commit()
182
  db_session.refresh(instance)
183
  return instance
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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")
 
84
  from app.auth import create_access_token, create_refresh_token, hash_password
85
  from app.database import Base, get_db
86
  from app.main import app
87
+ from app.models import ChatMessage, Document, User
88
 
89
 
90
  @pytest.fixture()
 
141
  return instance
142
 
143
 
144
+ @pytest.fixture()
145
+ def other_user(db_session):
146
+ instance = User(
147
+ username="other",
148
+ email="other@example.com",
149
+ hashed_password=hash_password("password123"),
150
+ )
151
+ db_session.add(instance)
152
+ db_session.commit()
153
+ db_session.refresh(instance)
154
+ return instance
155
+
156
+
157
  @pytest.fixture()
158
  def auth_headers(user):
159
  token = create_access_token(user.id)
 
195
  db_session.commit()
196
  db_session.refresh(instance)
197
  return instance
198
+
199
+
200
+ @pytest.fixture()
201
+ def assistant_message(db_session, user):
202
+ instance = ChatMessage(
203
+ user_id=user.id,
204
+ role="assistant",
205
+ content="Shared assistant answer",
206
+ sources_json='[{"text":"Source text","filename":"file.txt","page":1,"score":0.9,"confidence":95.0}]',
207
+ )
208
+ db_session.add(instance)
209
+ db_session.commit()
210
+ db_session.refresh(instance)
211
+ return instance
212
+
213
+
214
+ @pytest.fixture()
215
+ def user_message(db_session, user):
216
+ instance = ChatMessage(
217
+ user_id=user.id,
218
+ role="user",
219
+ content="Private user prompt",
220
+ )
221
+ db_session.add(instance)
222
+ db_session.commit()
223
+ db_session.refresh(instance)
224
+ return instance
225
+
226
+
227
+ @pytest.fixture()
228
+ def other_user_assistant_message(db_session, other_user):
229
+ instance = ChatMessage(
230
+ user_id=other_user.id,
231
+ role="assistant",
232
+ content="Other user's answer",
233
+ )
234
+ db_session.add(instance)
235
+ db_session.commit()
236
+ db_session.refresh(instance)
237
+ return instance
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
backend/tests/test_auth.py CHANGED
@@ -79,3 +79,39 @@ def test_refresh_token_success(client, refresh_token):
79
  assert payload["access_token"]
80
  assert payload["refresh_token"]
81
  assert payload["token_type"] == "bearer"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  assert payload["access_token"]
80
  assert payload["refresh_token"]
81
  assert payload["token_type"] == "bearer"
82
+
83
+
84
+ def test_update_hf_token_success(client, auth_headers):
85
+ response = client.put(
86
+ "/api/v1/auth/hf-token",
87
+ json={"hf_token": "hf_new_token_value"},
88
+ headers=auth_headers,
89
+ )
90
+
91
+ assert response.status_code == 200
92
+ payload = response.json()
93
+ assert payload["hf_token"] == "hf_new_token_value"
94
+
95
+
96
+ def test_update_hf_token_requires_auth(client):
97
+ response = client.put(
98
+ "/api/v1/auth/hf-token",
99
+ json={"hf_token": "hf_unauth"},
100
+ )
101
+
102
+ assert response.status_code in (401, 403)
103
+
104
+
105
+ def test_hf_token_appears_in_user_response(client, auth_headers, user, db_session):
106
+ # First update the token
107
+ put_resp = client.put(
108
+ "/api/v1/auth/hf-token",
109
+ json={"hf_token": "hf_persist_token"},
110
+ headers=auth_headers,
111
+ )
112
+ assert put_resp.status_code == 200
113
+
114
+ # Then verify it shows up in GET /me
115
+ me_resp = client.get("/api/v1/auth/me", headers=auth_headers)
116
+ assert me_resp.status_code == 200
117
+ assert me_resp.json()["hf_token"] == "hf_persist_token"
backend/tests/test_share.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def test_share_link_creation_success(client, auth_headers, assistant_message):
2
+ response = client.post(
3
+ f"/api/v1/chat/share/{assistant_message.id}",
4
+ headers=auth_headers,
5
+ )
6
+
7
+ assert response.status_code == 200
8
+ payload = response.json()
9
+ assert payload["message_id"] == assistant_message.id
10
+ assert payload["share_url"] == f"/share?message_id={assistant_message.id}"
11
+
12
+
13
+ def test_share_link_unauthorized_for_other_users_message(client, auth_headers, other_user_assistant_message):
14
+ response = client.post(
15
+ f"/api/v1/chat/share/{other_user_assistant_message.id}",
16
+ headers=auth_headers,
17
+ )
18
+
19
+ assert response.status_code == 404
20
+ assert response.json()["detail"] == "Message not found"
21
+
22
+
23
+ def test_cannot_share_user_message(client, auth_headers, user_message):
24
+ response = client.post(
25
+ f"/api/v1/chat/share/{user_message.id}",
26
+ headers=auth_headers,
27
+ )
28
+
29
+ assert response.status_code == 400
30
+ assert response.json()["detail"] == "Only assistant messages can be shared"
31
+
32
+
33
+ def test_public_fetch_fails_before_share(client, assistant_message):
34
+ response = client.get(f"/api/v1/chat/share/{assistant_message.id}")
35
+
36
+ assert response.status_code == 404
37
+ assert response.json()["detail"] == "Shared answer not found"
38
+
39
+
40
+ def test_public_fetch_shared_answer_success_after_share(client, auth_headers, assistant_message):
41
+ share_response = client.post(
42
+ f"/api/v1/chat/share/{assistant_message.id}",
43
+ headers=auth_headers,
44
+ )
45
+ assert share_response.status_code == 200
46
+
47
+ response = client.get(f"/api/v1/chat/share/{assistant_message.id}")
48
+
49
+ assert response.status_code == 200
50
+ payload = response.json()
51
+ assert payload["id"] == assistant_message.id
52
+ assert payload["content"] == "Shared assistant answer"
53
+ assert len(payload["sources"]) == 1
54
+ assert payload["sources"][0]["filename"] == "file.txt"
55
+
56
+
57
+ def test_missing_message_returns_404(client):
58
+ response = client.get("/api/v1/chat/share/missing-message-id")
59
+
60
+ assert response.status_code == 404
61
+ assert response.json()["detail"] == "Shared answer not found"
bots/discord/README.md ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Discord RAG Bot
2
+
3
+ This bot connects to the PDF-Assistant-RAG backend to answer questions based on your uploaded documents, directly from Discord.
4
+
5
+ ## Setup
6
+
7
+ 1. Install dependencies:
8
+ ```bash
9
+ pip install -r requirements.txt
10
+ ```
11
+
12
+ 2. Create a Discord Bot on the [Discord Developer Portal](https://discord.com/developers/applications):
13
+ - Go to "Bot" tab and enable **Message Content Intent**.
14
+ - Copy the bot token.
15
+ - Invite the bot to your server via the OAuth2 URL Generator (check `bot` scope and `Send Messages` permission).
16
+
17
+ 3. Generate an API Key from your PDF-Assistant-RAG profile dashboard.
18
+
19
+ 4. Set the environment variables and run:
20
+ ```bash
21
+ export DISCORD_TOKEN="your-discord-bot-token"
22
+ export RAG_API_KEY="rag_your-api-key"
23
+
24
+ # Optional: set API_URL if backend is not running on localhost:8000
25
+ # export API_URL="http://localhost:8000/api/v1"
26
+
27
+ python bot.py
28
+ ```
29
+
30
+ ## Usage
31
+ In a Discord channel where the bot is present, simply use the `!ask` command:
32
+
33
+ ```
34
+ !ask Summarize the latest uploaded report for me
35
+ ```
36
+
37
+ The bot will query the backend API using your personal API key and reply with the generated answer.
bots/discord/bot.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import discord
3
+ import requests
4
+ from discord.ext import commands
5
+
6
+ DISCORD_TOKEN = os.getenv("DISCORD_TOKEN")
7
+ API_URL = os.getenv("API_URL", "http://localhost:8000/api/v1")
8
+ RAG_API_KEY = os.getenv("RAG_API_KEY")
9
+
10
+ if not DISCORD_TOKEN or not RAG_API_KEY:
11
+ print("Error: DISCORD_TOKEN and RAG_API_KEY must be set in environment variables.")
12
+ exit(1)
13
+
14
+ intents = discord.Intents.default()
15
+ intents.message_content = True
16
+ bot = commands.Bot(command_prefix="!", intents=intents)
17
+
18
+ @bot.event
19
+ async def on_ready():
20
+ print(f"Logged in as {bot.user.name} ({bot.user.id})")
21
+ print("Ready to answer questions via '!ask <question>'")
22
+
23
+ @bot.command(name="ask")
24
+ async def ask_rag(ctx, *, question: str):
25
+ """Ask the RAG Assistant a question. Example: !ask What is in my documents?"""
26
+ loading_msg = await ctx.send("🤔 Thinking...")
27
+
28
+ try:
29
+ headers = {
30
+ "Authorization": f"Bearer {RAG_API_KEY}",
31
+ "Content-Type": "application/json"
32
+ }
33
+
34
+ # We can also support document_id if we want, but for now we do global ask.
35
+ payload = {"question": question}
36
+
37
+ response = requests.post(
38
+ f"{API_URL}/chat/ask",
39
+ json=payload,
40
+ headers=headers,
41
+ timeout=30 # Give the RAG backend some time to process
42
+ )
43
+
44
+ if response.status_code == 200:
45
+ data = response.json()
46
+ answer = data.get("answer", "No answer provided.")
47
+
48
+ if len(answer) > 2000:
49
+ # Discord has a 2000 character limit per message
50
+ chunks = [answer[i:i+2000] for i in range(0, len(answer), 2000)]
51
+ await loading_msg.edit(content=chunks[0])
52
+ for chunk in chunks[1:]:
53
+ await ctx.send(chunk)
54
+ else:
55
+ await loading_msg.edit(content=answer)
56
+ else:
57
+ await loading_msg.edit(content=f"⚠️ Error from RAG API: `{response.status_code}`")
58
+ print(f"API Error: {response.text}")
59
+
60
+ except requests.exceptions.RequestException as e:
61
+ await loading_msg.edit(content=f"❌ Failed to connect to backend API.")
62
+ print(f"Request Error: {e}")
63
+ except Exception as e:
64
+ await loading_msg.edit(content=f"❌ An unexpected error occurred.")
65
+ print(f"Error: {e}")
66
+
67
+ if __name__ == "__main__":
68
+ bot.run(DISCORD_TOKEN)
bots/discord/requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ discord.py==2.3.2
2
+ requests==2.31.0
frontend/package-lock.json CHANGED
@@ -11,12 +11,16 @@
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
  "pdfjs-dist": "^5.6.205",
17
  "react": "19.2.4",
18
  "react-dom": "19.2.4",
19
  "react-dropzone": "^15.0.0",
 
20
  "react-markdown": "^10.1.0",
21
  "react-pdf": "^10.4.1",
22
  "rehype-highlight": "^7.0.2",
@@ -79,6 +83,7 @@
79
  "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
80
  "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
81
  "license": "MIT",
 
82
  "dependencies": {
83
  "@babel/code-frame": "^7.29.0",
84
  "@babel/generator": "^7.29.0",
@@ -672,6 +677,7 @@
672
  "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
673
  "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
674
  "license": "MIT",
 
675
  "engines": {
676
  "node": ">=12"
677
  },
@@ -2106,6 +2112,7 @@
2106
  "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
2107
  "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
2108
  "license": "MIT",
 
2109
  "engines": {
2110
  "node": "^14.21.3 || >=16"
2111
  },
@@ -2213,6 +2220,7 @@
2213
  "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
2214
  "devOptional": true,
2215
  "license": "Apache-2.0",
 
2216
  "dependencies": {
2217
  "playwright": "1.60.0"
2218
  },
@@ -2681,6 +2689,7 @@
2681
  "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
2682
  "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
2683
  "license": "MIT",
 
2684
  "dependencies": {
2685
  "undici-types": "~6.21.0"
2686
  }
@@ -2690,6 +2699,7 @@
2690
  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
2691
  "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
2692
  "license": "MIT",
 
2693
  "dependencies": {
2694
  "csstype": "^3.2.2"
2695
  }
@@ -2776,6 +2786,7 @@
2776
  "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
2777
  "dev": true,
2778
  "license": "MIT",
 
2779
  "dependencies": {
2780
  "@typescript-eslint/scope-manager": "8.59.0",
2781
  "@typescript-eslint/types": "8.59.0",
@@ -3320,6 +3331,7 @@
3320
  "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
3321
  "dev": true,
3322
  "license": "MIT",
 
3323
  "bin": {
3324
  "acorn": "bin/acorn"
3325
  },
@@ -3774,6 +3786,7 @@
3774
  }
3775
  ],
3776
  "license": "MIT",
 
3777
  "dependencies": {
3778
  "baseline-browser-mapping": "^2.10.12",
3779
  "caniuse-lite": "^1.0.30001782",
@@ -4825,6 +4838,7 @@
4825
  "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
4826
  "dev": true,
4827
  "license": "MIT",
 
4828
  "dependencies": {
4829
  "@eslint-community/eslint-utils": "^4.8.0",
4830
  "@eslint-community/regexpp": "^4.12.1",
@@ -5010,6 +5024,7 @@
5010
  "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
5011
  "dev": true,
5012
  "license": "MIT",
 
5013
  "dependencies": {
5014
  "@rtsao/scc": "^1.1.0",
5015
  "array-includes": "^3.1.9",
@@ -5309,6 +5324,7 @@
5309
  "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
5310
  "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
5311
  "license": "MIT",
 
5312
  "dependencies": {
5313
  "accepts": "^2.0.0",
5314
  "body-parser": "^2.2.1",
@@ -5668,6 +5684,7 @@
5668
  "version": "2.3.2",
5669
  "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
5670
  "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
 
5671
  "hasInstallScript": true,
5672
  "license": "MIT",
5673
  "optional": true,
@@ -6132,10 +6149,20 @@
6132
  "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
6133
  "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
6134
  "license": "MIT",
 
6135
  "engines": {
6136
  "node": ">=16.9.0"
6137
  }
6138
  },
 
 
 
 
 
 
 
 
 
6139
  "node_modules/html-url-attributes": {
6140
  "version": "3.0.1",
6141
  "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -6188,6 +6215,44 @@
6188
  "node": ">=18.18.0"
6189
  }
6190
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6191
  "node_modules/iconv-lite": {
6192
  "version": "0.7.2",
6193
  "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -8691,6 +8756,16 @@
8691
  }
8692
  }
8693
  },
 
 
 
 
 
 
 
 
 
 
8694
  "node_modules/next/node_modules/postcss": {
8695
  "version": "8.4.31",
8696
  "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -9516,6 +9591,7 @@
9516
  "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
9517
  "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
9518
  "license": "MIT",
 
9519
  "engines": {
9520
  "node": ">=0.10.0"
9521
  }
@@ -9525,6 +9601,7 @@
9525
  "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
9526
  "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
9527
  "license": "MIT",
 
9528
  "dependencies": {
9529
  "scheduler": "^0.27.0"
9530
  },
@@ -9549,6 +9626,33 @@
9549
  "react": ">= 16.8 || 18.0.0"
9550
  }
9551
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9552
  "node_modules/react-is": {
9553
  "version": "16.13.1",
9554
  "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -10822,6 +10926,7 @@
10822
  "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
10823
  "dev": true,
10824
  "license": "MIT",
 
10825
  "engines": {
10826
  "node": ">=12"
10827
  },
@@ -11090,6 +11195,7 @@
11090
  "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
11091
  "devOptional": true,
11092
  "license": "Apache-2.0",
 
11093
  "bin": {
11094
  "tsc": "bin/tsc",
11095
  "tsserver": "bin/tsserver"
@@ -11423,6 +11529,15 @@
11423
  "url": "https://opencollective.com/unified"
11424
  }
11425
  },
 
 
 
 
 
 
 
 
 
11426
  "node_modules/warning": {
11427
  "version": "4.0.3",
11428
  "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
@@ -11763,6 +11878,7 @@
11763
  "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
11764
  "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
11765
  "license": "MIT",
 
11766
  "funding": {
11767
  "url": "https://github.com/sponsors/colinhacks"
11768
  }
 
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",
19
  "pdfjs-dist": "^5.6.205",
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",
 
8756
  }
8757
  }
8758
  },
8759
+ "node_modules/next-themes": {
8760
+ "version": "0.4.6",
8761
+ "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
8762
+ "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
8763
+ "license": "MIT",
8764
+ "peerDependencies": {
8765
+ "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
8766
+ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
8767
+ }
8768
+ },
8769
  "node_modules/next/node_modules/postcss": {
8770
  "version": "8.4.31",
8771
  "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.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,12 +14,16 @@
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
  "pdfjs-dist": "^5.6.205",
20
  "react": "19.2.4",
21
  "react-dom": "19.2.4",
22
  "react-dropzone": "^15.0.0",
 
23
  "react-markdown": "^10.1.0",
24
  "react-pdf": "^10.4.1",
25
  "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",
22
  "pdfjs-dist": "^5.6.205",
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/dashboard/page.tsx CHANGED
@@ -63,11 +63,23 @@ export default function DashboardPage() {
63
  const [viewerOpen, setViewerOpen] = useState(true);
64
  const [connectionError, setConnectionError] = useState("");
65
 
66
- // Auth guard
67
  useEffect(() => {
68
  if (!loading && !user) router.replace("/login");
69
  }, [user, loading, router]);
70
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  // Load documents
72
  const loadDocuments = useCallback(async () => {
73
  try {
 
63
  const [viewerOpen, setViewerOpen] = useState(true);
64
  const [connectionError, setConnectionError] = useState("");
65
 
66
+ // Auth guard
67
  useEffect(() => {
68
  if (!loading && !user) router.replace("/login");
69
  }, [user, loading, router]);
70
 
71
+ // Intercept dashboard if Hugging Face token configuration is missing
72
+ useEffect(() => {
73
+ if (user) {
74
+ const existingHfToken = localStorage.getItem("hf_token");
75
+
76
+ if (!existingHfToken) {
77
+ console.warn("Hugging Face API configuration key missing.");
78
+ }
79
+ }
80
+ }, [user]);
81
+
82
+
83
  // Load documents
84
  const loadDocuments = useCallback(async () => {
85
  try {
frontend/src/app/globals.css CHANGED
@@ -83,6 +83,35 @@
83
  --sidebar-ring: oklch(0.65 0.2 265);
84
  }
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  .light {
87
  --background: oklch(0.985 0 0);
88
  --foreground: oklch(0.145 0 0);
 
83
  --sidebar-ring: oklch(0.65 0.2 265);
84
  }
85
 
86
+ .dark {
87
+ --background: oklch(0.145 0 0);
88
+ --foreground: oklch(0.985 0 0);
89
+ --card: oklch(0.178 0 0);
90
+ --card-foreground: oklch(0.985 0 0);
91
+ --popover: oklch(0.178 0 0);
92
+ --popover-foreground: oklch(0.985 0 0);
93
+ --primary: oklch(0.65 0.2 265);
94
+ --primary-foreground: oklch(0.985 0 0);
95
+ --secondary: oklch(0.22 0 0);
96
+ --secondary-foreground: oklch(0.985 0 0);
97
+ --muted: oklch(0.22 0 0);
98
+ --muted-foreground: oklch(0.6 0 0);
99
+ --accent: oklch(0.55 0.18 265);
100
+ --accent-foreground: oklch(0.985 0 0);
101
+ --destructive: oklch(0.704 0.191 22.216);
102
+ --border: oklch(1 0 0 / 10%);
103
+ --input: oklch(1 0 0 / 12%);
104
+ --ring: oklch(0.65 0.2 265);
105
+ --sidebar: oklch(0.12 0 0);
106
+ --sidebar-foreground: oklch(0.985 0 0);
107
+ --sidebar-primary: oklch(0.65 0.2 265);
108
+ --sidebar-primary-foreground: oklch(0.985 0 0);
109
+ --sidebar-accent: oklch(0.22 0 0);
110
+ --sidebar-accent-foreground: oklch(0.985 0 0);
111
+ --sidebar-border: oklch(1 0 0 / 8%);
112
+ --sidebar-ring: oklch(0.65 0.2 265);
113
+ }
114
+
115
  .light {
116
  --background: oklch(0.985 0 0);
117
  --foreground: oklch(0.145 0 0);
frontend/src/app/layout.tsx CHANGED
@@ -3,6 +3,8 @@ import { Inter } from "next/font/google";
3
  import "./globals.css";
4
  import { AuthProvider } from "@/lib/auth";
5
  import { TooltipProvider } from "@/components/ui/tooltip";
 
 
6
 
7
  const inter = Inter({
8
  variable: "--font-sans",
@@ -23,13 +25,20 @@ export default function RootLayout({
23
  children: React.ReactNode;
24
  }>) {
25
  return (
26
- <html lang="en" className={`${inter.variable} dark h-full antialiased`}>
27
  <body className="min-h-full flex flex-col bg-background text-foreground">
28
- <AuthProvider>
29
- <TooltipProvider>
30
- {children}
31
- </TooltipProvider>
32
- </AuthProvider>
 
 
 
 
 
 
 
33
  </body>
34
  </html>
35
  );
 
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({
10
  variable: "--font-sans",
 
25
  children: React.ReactNode;
26
  }>) {
27
  return (
28
+ <html lang="en" className={`${inter.variable} h-full antialiased`} suppressHydrationWarning>
29
  <body className="min-h-full flex flex-col bg-background text-foreground">
30
+ <ThemeProvider
31
+ attribute="class"
32
+ defaultTheme="dark"
33
+ enableSystem={false}
34
+ disableTransitionOnChange
35
+ >
36
+ <AuthProvider>
37
+ <I18nProvider>
38
+ <TooltipProvider>{children}</TooltipProvider>
39
+ </I18nProvider>
40
+ </AuthProvider>
41
+ </ThemeProvider>
42
  </body>
43
  </html>
44
  );
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/app/share/page.tsx ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { Suspense, useEffect, useState } from "react";
4
+ import { useSearchParams } from "next/navigation";
5
+ import ReactMarkdown, { type Components } from "react-markdown";
6
+ import rehypeHighlight from "rehype-highlight";
7
+ import remarkGfm from "remark-gfm";
8
+ import { Brain } from "lucide-react";
9
+ import { api } from "@/lib/api";
10
+
11
+ interface SharedSource {
12
+ text: string;
13
+ filename: string;
14
+ page: number;
15
+ score: number;
16
+ confidence: number;
17
+ }
18
+
19
+ interface SharedAnswer {
20
+ id: string;
21
+ content: string;
22
+ created_at: string;
23
+ sources: SharedSource[];
24
+ }
25
+
26
+ const markdownComponents: Components = {
27
+ table: ({ children }) => (
28
+ <div className="my-3 overflow-x-auto rounded-lg border border-border/70">
29
+ <table className="min-w-full border-collapse text-left text-sm">
30
+ {children}
31
+ </table>
32
+ </div>
33
+ ),
34
+ thead: ({ children }) => (
35
+ <thead className="bg-muted/60 text-foreground">{children}</thead>
36
+ ),
37
+ th: ({ children }) => (
38
+ <th className="border-b border-border/70 px-3 py-2 font-semibold">
39
+ {children}
40
+ </th>
41
+ ),
42
+ td: ({ children }) => (
43
+ <td className="border-b border-border/50 px-3 py-2 align-top">
44
+ {children}
45
+ </td>
46
+ ),
47
+ pre: ({ children }) => (
48
+ <pre className="not-prose my-3 overflow-x-auto rounded-lg border border-border/70 bg-zinc-950 p-3 text-sm text-zinc-100">
49
+ {children}
50
+ </pre>
51
+ ),
52
+ code: ({ className, children, ...props }) => {
53
+ const language = /language-(\w+)/.exec(className ?? "")?.[1];
54
+
55
+ return (
56
+ <code className={className} data-language={language} {...props}>
57
+ {children}
58
+ </code>
59
+ );
60
+ },
61
+ };
62
+
63
+ function ShareAnswerContent() {
64
+ const searchParams = useSearchParams();
65
+ const messageId = searchParams.get("message_id");
66
+ const missingMessageId = !messageId;
67
+ const [answer, setAnswer] = useState<SharedAnswer | null>(null);
68
+ const [error, setError] = useState("");
69
+ const loading = !error && !answer && !missingMessageId;
70
+
71
+ useEffect(() => {
72
+ if (missingMessageId) {
73
+ return;
74
+ }
75
+
76
+ let cancelled = false;
77
+
78
+ void api
79
+ .get<SharedAnswer>(`/api/v1/chat/share/${messageId}`)
80
+ .then((data) => {
81
+ if (cancelled) return;
82
+ setAnswer(data);
83
+ setError("");
84
+ })
85
+ .catch((err: unknown) => {
86
+ if (cancelled) return;
87
+ setAnswer(null);
88
+ setError(err instanceof Error ? err.message : "Shared answer not found");
89
+ });
90
+
91
+ return () => {
92
+ cancelled = true;
93
+ };
94
+ }, [messageId, missingMessageId]);
95
+
96
+ if (missingMessageId) {
97
+ return (
98
+ <div className="min-h-screen flex items-center justify-center px-4">
99
+ <div className="rounded-xl border border-border/50 bg-card/80 px-6 py-5 text-center">
100
+ <p className="text-lg font-semibold mb-1">Shared answer unavailable</p>
101
+ <p className="text-sm text-muted-foreground">This shared answer could not be found.</p>
102
+ </div>
103
+ </div>
104
+ );
105
+ }
106
+
107
+ if (loading) {
108
+ return (
109
+ <div className="min-h-screen flex items-center justify-center px-4">
110
+ <div className="text-sm text-muted-foreground">Loading shared answer...</div>
111
+ </div>
112
+ );
113
+ }
114
+
115
+ if (error || !answer) {
116
+ return (
117
+ <div className="min-h-screen flex items-center justify-center px-4">
118
+ <div className="rounded-xl border border-border/50 bg-card/80 px-6 py-5 text-center">
119
+ <p className="text-lg font-semibold mb-1">Shared answer unavailable</p>
120
+ <p className="text-sm text-muted-foreground">{error || "This shared answer could not be found."}</p>
121
+ </div>
122
+ </div>
123
+ );
124
+ }
125
+
126
+ return (
127
+ <div className="min-h-screen px-4 py-10 bg-background text-foreground">
128
+ <div className="max-w-3xl mx-auto">
129
+ <div className="rounded-2xl border border-border/50 bg-card/80 backdrop-blur-sm p-6">
130
+ <div className="flex items-center gap-3 mb-5">
131
+ <div className="w-10 h-10 rounded-xl bg-primary/15 flex items-center justify-center">
132
+ <Brain className="w-5 h-5 text-primary" />
133
+ </div>
134
+ <div>
135
+ <h1 className="text-xl font-semibold">Shared AI Answer</h1>
136
+ <p className="text-sm text-muted-foreground">
137
+ {new Date(answer.created_at).toLocaleString()}
138
+ </p>
139
+ </div>
140
+ </div>
141
+
142
+ <div className="prose-chat text-sm">
143
+ <ReactMarkdown
144
+ remarkPlugins={[remarkGfm]}
145
+ rehypePlugins={[rehypeHighlight]}
146
+ components={markdownComponents}
147
+ >
148
+ {answer.content}
149
+ </ReactMarkdown>
150
+ </div>
151
+
152
+ {answer.sources.length > 0 && (
153
+ <div className="mt-6 border-t border-border/50 pt-4">
154
+ <h2 className="text-sm font-semibold mb-3">Sources</h2>
155
+ <div className="space-y-2">
156
+ {answer.sources.map((source, index) => (
157
+ <div
158
+ key={`${answer.id}-${index}`}
159
+ className="rounded-lg border border-border/50 bg-background/60 p-3"
160
+ >
161
+ <p className="text-xs font-medium mb-1">
162
+ {source.filename} • Page {source.page}
163
+ </p>
164
+ <p className="text-xs text-muted-foreground">{source.text}</p>
165
+ </div>
166
+ ))}
167
+ </div>
168
+ </div>
169
+ )}
170
+ </div>
171
+ </div>
172
+ </div>
173
+ );
174
+ }
175
+
176
+ export default function ShareAnswerPage() {
177
+ return (
178
+ <Suspense
179
+ fallback={
180
+ <div className="min-h-screen flex items-center justify-center px-4">
181
+ <div className="text-sm text-muted-foreground">Loading shared answer...</div>
182
+ </div>
183
+ }
184
+ >
185
+ <ShareAnswerContent />
186
+ </Suspense>
187
+ );
188
+ }
frontend/src/components/auth/ApiKeyManager.tsx ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
6
+ import { api } from "@/lib/api";
7
+ import { Key, Plus, Trash2, Copy, Check } from "lucide-react";
8
+
9
+ interface ApiKey {
10
+ id: string;
11
+ key_prefix: string;
12
+ created_at: string;
13
+ last_used: string | null;
14
+ }
15
+
16
+ export default function ApiKeyManager() {
17
+ const [keys, setKeys] = useState<ApiKey[]>([]);
18
+ const [newKey, setNewKey] = useState<string | null>(null);
19
+ const [loading, setLoading] = useState(false);
20
+ const [copied, setCopied] = useState(false);
21
+
22
+ const fetchKeys = async () => {
23
+ try {
24
+ setLoading(true);
25
+ const data = await api.get<ApiKey[]>("/api/v1/auth/api-keys");
26
+ setKeys(data || []);
27
+ } catch (err) {
28
+ console.error("Failed to load API keys", err);
29
+ } finally {
30
+ setLoading(false);
31
+ }
32
+ };
33
+
34
+ useEffect(() => {
35
+ const timer = setTimeout(() => {
36
+ fetchKeys();
37
+ }, 0);
38
+ return () => clearTimeout(timer);
39
+ }, []);
40
+
41
+ const generateKey = async () => {
42
+ try {
43
+ setLoading(true);
44
+ const data = await api.post<{ key: string; api_key: ApiKey }>("/api/v1/auth/api-keys");
45
+ setNewKey(data.key);
46
+ setKeys((prev) => [...prev, data.api_key]);
47
+ } catch (err) {
48
+ console.error("Failed to generate API key", err);
49
+ } finally {
50
+ setLoading(false);
51
+ }
52
+ };
53
+
54
+ const revokeKey = async (id: string) => {
55
+ if (!confirm("Are you sure you want to revoke this key? Any integrations using it will immediately break.")) return;
56
+
57
+ try {
58
+ await api.delete(`/api/v1/auth/api-keys/${id}`);
59
+ setKeys((prev) => prev.filter((k) => k.id !== id));
60
+ } catch (err) {
61
+ console.error("Failed to revoke API key", err);
62
+ }
63
+ };
64
+
65
+ const copyToClipboard = () => {
66
+ if (newKey) {
67
+ navigator.clipboard.writeText(newKey);
68
+ setCopied(true);
69
+ setTimeout(() => setCopied(false), 2000);
70
+ }
71
+ };
72
+
73
+ return (
74
+ <Dialog onOpenChange={(open) => { if (!open) setNewKey(null); }}>
75
+ <DialogTrigger
76
+ render={
77
+ <button className="flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground">
78
+ <Key className="mr-2 h-4 w-4" />
79
+ <span>API Keys</span>
80
+ </button>
81
+ }
82
+ />
83
+ <DialogContent className="max-w-2xl sm:rounded-2xl border-border/40 p-6 md:p-8 bg-background/95 backdrop-blur-xl shadow-2xl">
84
+
85
+ <DialogHeader>
86
+ <DialogTitle className="text-2xl font-bold tracking-tight">API Keys</DialogTitle>
87
+ <p className="text-sm text-muted-foreground mt-1.5">
88
+ Manage API keys to access the RAG engine programmatically from your own applications or scripts.
89
+ </p>
90
+ </DialogHeader>
91
+
92
+ {newKey && (
93
+ <div className="my-6 p-5 border border-primary/20 bg-primary/5 rounded-xl space-y-3 animate-in fade-in zoom-in-95 duration-300">
94
+ <h4 className="font-semibold text-primary flex items-center gap-2">
95
+ <Key className="w-4 h-4" /> Save your new API key
96
+ </h4>
97
+ <p className="text-sm text-muted-foreground">
98
+ Please copy this key and store it somewhere safe. For security reasons, you will <strong>never</strong> be able to view it again.
99
+ </p>
100
+ <div className="flex items-center gap-2 mt-2">
101
+ <code className="flex-1 bg-background/80 border border-border/50 px-4 py-2.5 rounded-lg text-sm font-mono break-all text-foreground shadow-inner">
102
+ {newKey}
103
+ </code>
104
+ <Button onClick={copyToClipboard} variant={copied ? "default" : "secondary"} className="shrink-0 shadow-sm">
105
+ {copied ? <Check className="w-4 h-4 mr-2" /> : <Copy className="w-4 h-4 mr-2" />}
106
+ {copied ? "Copied!" : "Copy"}
107
+ </Button>
108
+ </div>
109
+ </div>
110
+ )}
111
+
112
+ <div className="space-y-4 mt-6">
113
+ <div className="flex items-center justify-between">
114
+ <h3 className="text-sm font-medium text-foreground/80 uppercase tracking-wider">Active Keys</h3>
115
+ <Button onClick={generateKey} disabled={loading} size="sm" className="rounded-full shadow-sm hover:shadow-md transition-shadow">
116
+ <Plus className="w-4 h-4 mr-1.5" />
117
+ Generate New Key
118
+ </Button>
119
+ </div>
120
+
121
+ <div className="rounded-xl border border-border/50 bg-card overflow-hidden shadow-sm">
122
+ {keys.length === 0 ? (
123
+ <div className="p-8 text-center text-sm text-muted-foreground bg-muted/20">
124
+ <Key className="w-8 h-8 mx-auto mb-3 opacity-20" />
125
+ You don&apos;t have any API keys yet.
126
+ </div>
127
+ ) : (
128
+ <div className="divide-y divide-border/50">
129
+ {keys.map((key) => (
130
+ <div key={key.id} className="flex items-center justify-between p-4 hover:bg-muted/30 transition-colors group">
131
+ <div className="space-y-1">
132
+ <div className="font-mono text-sm font-medium tracking-tight">
133
+ {key.key_prefix}••••••••••••••••••••••
134
+ </div>
135
+ <div className="text-xs text-muted-foreground flex gap-4">
136
+ <span>Created: {new Date(key.created_at).toLocaleDateString()}</span>
137
+ <span>Last used: {key.last_used ? new Date(key.last_used).toLocaleDateString() : "Never"}</span>
138
+ </div>
139
+ </div>
140
+ <Button
141
+ variant="ghost"
142
+ size="icon"
143
+ onClick={() => revokeKey(key.id)}
144
+ className="text-destructive/70 hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover:opacity-100 transition-all"
145
+ title="Revoke key"
146
+ >
147
+ <Trash2 className="w-4 h-4" />
148
+ </Button>
149
+ </div>
150
+ ))}
151
+ </div>
152
+ )}
153
+ </div>
154
+ </div>
155
+ </DialogContent>
156
+ </Dialog>
157
+ );
158
+ }
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/chat/MessageBubble.tsx CHANGED
@@ -5,7 +5,8 @@ import ReactMarkdown, { type Components } from "react-markdown";
5
  import rehypeHighlight from "rehype-highlight";
6
  import remarkGfm from "remark-gfm";
7
  import type { ChatMsg } from "@/store/chat-store";
8
- import { Brain, User, Copy, Check } from "lucide-react";
 
9
  import { Button } from "@/components/ui/button";
10
 
11
  interface Props {
@@ -52,7 +53,10 @@ const markdownComponents: Components = {
52
  export default function MessageBubble({ message }: Props) {
53
  const isUser = message.role === "user";
54
  const [copied, setCopied] = useState(false);
 
 
55
  const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
56
 
57
  const handleCopy = async () => {
58
  if (!message.content) return;
@@ -66,6 +70,31 @@ export default function MessageBubble({ message }: Props) {
66
  }
67
  };
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  return (
70
  <div
71
  className={`flex gap-3 py-3 animate-fade-in-up ${isUser ? "justify-end" : "justify-start"}`}
@@ -88,26 +117,50 @@ export default function MessageBubble({ message }: Props) {
88
  ) : (
89
  <>
90
  {message.content && (
91
- <Button
92
- type="button"
93
- variant="ghost"
94
- size="icon-xs"
95
- className={`absolute top-2 right-2 text-muted-foreground hover:text-foreground transition-opacity ${
96
- copied
97
- ? "opacity-100"
98
- : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
99
- }`}
100
- onClick={handleCopy}
101
- aria-label={copied ? "Copied" : "Copy response"}
102
- >
103
- {copied ? (
104
- <Check className="w-3.5 h-3.5 text-emerald-400" />
105
- ) : (
106
- <Copy className="w-3.5 h-3.5" />
 
 
 
 
 
 
107
  )}
108
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  )}
110
- <div className={`prose-chat text-sm ${message.content ? "pr-7" : ""}`}>
111
  {message.content ? (
112
  <ReactMarkdown
113
  remarkPlugins={[remarkGfm]}
 
5
  import rehypeHighlight from "rehype-highlight";
6
  import remarkGfm from "remark-gfm";
7
  import type { ChatMsg } from "@/store/chat-store";
8
+ import { api } from "@/lib/api";
9
+ import { Brain, User, Copy, Check, Share2, Link2, X } from "lucide-react";
10
  import { Button } from "@/components/ui/button";
11
 
12
  interface Props {
 
53
  export default function MessageBubble({ message }: Props) {
54
  const isUser = message.role === "user";
55
  const [copied, setCopied] = useState(false);
56
+ const [shared, setShared] = useState(false);
57
+ const [shareFailed, setShareFailed] = useState(false);
58
  const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
59
+ const sharedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
60
 
61
  const handleCopy = async () => {
62
  if (!message.content) return;
 
70
  }
71
  };
72
 
73
+ const handleShare = async () => {
74
+ if (!message.content || message.isStreaming) return;
75
+
76
+ try {
77
+ const data = await api.post<{ message_id: string; share_url: string }>(
78
+ `/api/v1/chat/share/${message.id}`
79
+ );
80
+ await navigator.clipboard.writeText(`${window.location.origin}${data.share_url}`);
81
+ setShared(true);
82
+ setShareFailed(false);
83
+ if (sharedTimeoutRef.current) clearTimeout(sharedTimeoutRef.current);
84
+ sharedTimeoutRef.current = setTimeout(() => {
85
+ setShared(false);
86
+ setShareFailed(false);
87
+ }, 2000);
88
+ } catch {
89
+ setShareFailed(true);
90
+ setShared(false);
91
+ if (sharedTimeoutRef.current) clearTimeout(sharedTimeoutRef.current);
92
+ sharedTimeoutRef.current = setTimeout(() => {
93
+ setShareFailed(false);
94
+ }, 2000);
95
+ }
96
+ };
97
+
98
  return (
99
  <div
100
  className={`flex gap-3 py-3 animate-fade-in-up ${isUser ? "justify-end" : "justify-start"}`}
 
117
  ) : (
118
  <>
119
  {message.content && (
120
+ <>
121
+ {!message.isStreaming && (
122
+ <Button
123
+ type="button"
124
+ variant="ghost"
125
+ size="icon-xs"
126
+ className={`absolute top-2 right-2 text-muted-foreground hover:text-foreground transition-opacity ${
127
+ shared || shareFailed
128
+ ? "opacity-100"
129
+ : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
130
+ }`}
131
+ onClick={handleShare}
132
+ aria-label={shared ? "Link copied" : shareFailed ? "Share failed" : "Share response"}
133
+ >
134
+ {shared ? (
135
+ <Link2 className="w-3.5 h-3.5 text-emerald-400" />
136
+ ) : shareFailed ? (
137
+ <X className="w-3.5 h-3.5 text-destructive" />
138
+ ) : (
139
+ <Share2 className="w-3.5 h-3.5" />
140
+ )}
141
+ </Button>
142
  )}
143
+ <Button
144
+ type="button"
145
+ variant="ghost"
146
+ size="icon-xs"
147
+ className={`absolute top-2 right-9 text-muted-foreground hover:text-foreground transition-opacity ${
148
+ copied
149
+ ? "opacity-100"
150
+ : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
151
+ }`}
152
+ onClick={handleCopy}
153
+ aria-label={copied ? "Copied" : "Copy response"}
154
+ >
155
+ {copied ? (
156
+ <Check className="w-3.5 h-3.5 text-emerald-400" />
157
+ ) : (
158
+ <Copy className="w-3.5 h-3.5" />
159
+ )}
160
+ </Button>
161
+ </>
162
  )}
163
+ <div className={`prose-chat text-sm ${message.content ? "pr-14" : ""}`}>
164
  {message.content ? (
165
  <ReactMarkdown
166
  remarkPlugins={[remarkGfm]}
frontend/src/components/chat/SourceCard.tsx CHANGED
@@ -4,7 +4,14 @@ import { useState } from "react";
4
  import type { SourceChunk } from "@/store/chat-store";
5
  import { Badge } from "@/components/ui/badge";
6
  import { Button } from "@/components/ui/button";
7
- import { ChevronDown, ChevronUp, FileText, Eye } from "lucide-react";
 
 
 
 
 
 
 
8
 
9
  interface Props {
10
  sources: SourceChunk[];
@@ -13,89 +20,125 @@ interface Props {
13
 
14
  export default function SourceCard({ sources = [], onPageClick }: Props) {
15
  const [expanded, setExpanded] = useState(false);
 
16
 
17
  if (sources.length === 0) return null;
18
 
 
 
 
 
 
 
 
 
 
 
19
  return (
20
  <div className="rounded-lg border border-border/50 bg-card/50 overflow-hidden">
21
- {/* ── Header ──────────────────────────────────── */}
22
- <button
23
- onClick={() => setExpanded(!expanded)}
24
- className="w-full flex items-center justify-between px-3 py-2 text-xs hover:bg-accent/30 transition-colors"
25
- >
26
- <span className="flex items-center gap-1.5 text-muted-foreground">
27
- <FileText className="w-3.5 h-3.5" />
28
- {sources.length} source{sources.length > 1 ? "s" : ""} cited
29
- </span>
30
- {expanded ? (
31
- <ChevronUp className="w-3.5 h-3.5 text-muted-foreground" />
32
- ) : (
33
- <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
34
- )}
35
- </button>
36
 
37
- {/* ── Collapsed: Mini badges ──────────────────── */}
38
- {!expanded && (
39
- <div className="px-3 pb-2 flex flex-wrap gap-1">
40
- {sources.map((src, i) => (
41
- <Badge
42
- key={i}
43
- variant="secondary"
44
- className="text-[10px] h-5 cursor-pointer hover:bg-primary/20 transition-colors"
45
- onClick={() => onPageClick(src.page + 1)}
46
- >
47
- p.{src.page + 1} • {src.confidence}%
48
- </Badge>
49
- ))}
50
- </div>
51
- )}
52
-
53
- {/* ── Expanded: Full source cards ─────────────── */}
54
- {expanded && (
55
- <div className="border-t border-border/30">
56
- {sources.map((src, i) => (
57
- <div
58
- key={i}
59
- className="px-3 py-2.5 border-b border-border/20 last:border-b-0 hover:bg-accent/20 transition-colors"
60
- >
61
- <div className="flex items-center justify-between mb-1.5">
62
- <div className="flex items-center gap-2">
63
- <span className="text-[10px] font-medium text-muted-foreground">
64
- {src.filename}
65
- </span>
66
- <Badge variant="outline" className="text-[9px] h-4 px-1.5">
67
- Page {src.page + 1}
68
- </Badge>
69
  <Badge
70
  variant="secondary"
71
- className={`text-[9px] h-4 px-1.5 ${
72
- src.confidence >= 80
73
- ? "text-emerald-400 bg-emerald-400/10"
74
- : src.confidence >= 50
75
- ? "text-yellow-400 bg-yellow-400/10"
76
- : "text-muted-foreground"
77
- }`}
78
  >
79
- {src.confidence}% match
80
  </Badge>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  </div>
82
- <Button
83
- variant="ghost"
84
- size="sm"
85
- className="h-6 px-2 text-[10px]"
86
- onClick={() => onPageClick(src.page + 1)}
87
  >
88
- <Eye className="w-3 h-3 mr-1" />
89
- View
90
- </Button>
 
 
 
 
 
 
 
 
91
  </div>
92
- <p className="text-[11px] text-muted-foreground leading-relaxed line-clamp-3">
93
- {src.text}
94
- </p>
95
- </div>
96
- ))}
97
- </div>
98
- )}
99
  </div>
100
  );
101
  }
 
4
  import type { SourceChunk } from "@/store/chat-store";
5
  import { Badge } from "@/components/ui/badge";
6
  import { Button } from "@/components/ui/button";
7
+ import {
8
+ Tooltip,
9
+ TooltipContent,
10
+ TooltipTrigger,
11
+ } from "@/components/ui/tooltip";
12
+ import { ChevronDown, ChevronUp, FileText, Eye, TextQuote } from "lucide-react";
13
+
14
+ const EXCERPT_THRESHOLD = 200;
15
 
16
  interface Props {
17
  sources: SourceChunk[];
 
20
 
21
  export default function SourceCard({ sources = [], onPageClick }: Props) {
22
  const [expanded, setExpanded] = useState(false);
23
+ const [excerptOpen, setExcerptOpen] = useState<Set<number>>(new Set());
24
 
25
  if (sources.length === 0) return null;
26
 
27
+ const toggleExcerpt = (i: number) => {
28
+ const next = new Set(excerptOpen);
29
+ if (next.has(i)) {
30
+ next.delete(i);
31
+ } else {
32
+ next.add(i);
33
+ }
34
+ setExcerptOpen(next);
35
+ };
36
+
37
  return (
38
  <div className="rounded-lg border border-border/50 bg-card/50 overflow-hidden">
39
+ {/* ── Header ──────────────────────────────────── */}
40
+ <button
41
+ onClick={() => setExpanded(!expanded)}
42
+ className="w-full flex items-center justify-between px-3 py-2 text-xs hover:bg-accent/30 transition-colors"
43
+ >
44
+ <span className="flex items-center gap-1.5 text-muted-foreground">
45
+ <FileText className="w-3.5 h-3.5" />
46
+ {sources.length} source{sources.length > 1 ? "s" : ""} cited
47
+ </span>
48
+ {expanded ? (
49
+ <ChevronUp className="w-3.5 h-3.5 text-muted-foreground" />
50
+ ) : (
51
+ <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
52
+ )}
53
+ </button>
54
 
55
+ {/* ── Collapsed: Mini badges with hover preview ── */}
56
+ {!expanded && (
57
+ <div className="px-3 pb-2 flex flex-wrap gap-1">
58
+ {sources.map((src, i) => (
59
+ <Tooltip key={i}>
60
+ <TooltipTrigger className="inline-flex">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  <Badge
62
  variant="secondary"
63
+ className="text-[10px] h-5 cursor-pointer hover:bg-primary/20 transition-colors"
64
+ onClick={() => onPageClick(src.page + 1)}
 
 
 
 
 
65
  >
66
+ p.{src.page + 1} • {src.confidence}%
67
  </Badge>
68
+ </TooltipTrigger>
69
+ <TooltipContent
70
+ side="top"
71
+ align="center"
72
+ className="max-w-xs p-2"
73
+ >
74
+ <p className="text-[11px] leading-relaxed line-clamp-6">
75
+ {src.text}
76
+ </p>
77
+ </TooltipContent>
78
+ </Tooltip>
79
+ ))}
80
+ </div>
81
+ )}
82
+
83
+ {/* ── Expanded: Full source cards ─────────────── */}
84
+ {expanded && (
85
+ <div className="border-t border-border/30">
86
+ {sources.map((src, i) => (
87
+ <div
88
+ key={i}
89
+ className="px-3 py-2.5 border-b border-border/20 last:border-b-0 hover:bg-accent/20 transition-colors"
90
+ >
91
+ <div className="flex items-center justify-between mb-1.5">
92
+ <div className="flex items-center gap-2">
93
+ <span className="text-[10px] font-medium text-muted-foreground">
94
+ {src.filename}
95
+ </span>
96
+ <Badge variant="outline" className="text-[9px] h-4 px-1.5">
97
+ Page {src.page + 1}
98
+ </Badge>
99
+ <Badge
100
+ variant="secondary"
101
+ className={`text-[9px] h-4 px-1.5 ${
102
+ src.confidence >= 80
103
+ ? "text-emerald-400 bg-emerald-400/10"
104
+ : src.confidence >= 50
105
+ ? "text-yellow-400 bg-yellow-400/10"
106
+ : "text-muted-foreground"
107
+ }`}
108
+ >
109
+ {src.confidence}% match
110
+ </Badge>
111
+ </div>
112
+ <Button
113
+ variant="ghost"
114
+ size="sm"
115
+ className="h-6 px-2 text-[10px]"
116
+ onClick={() => onPageClick(src.page + 1)}
117
+ >
118
+ <Eye className="w-3 h-3 mr-1" />
119
+ View
120
+ </Button>
121
  </div>
122
+ <p
123
+ className={`text-[11px] text-muted-foreground leading-relaxed ${
124
+ excerptOpen.has(i) ? "" : "line-clamp-3"
125
+ }`}
 
126
  >
127
+ {src.text}
128
+ </p>
129
+ {src.text.length > EXCERPT_THRESHOLD && (
130
+ <button
131
+ onClick={() => toggleExcerpt(i)}
132
+ className="mt-1.5 flex items-center gap-1 text-[10px] text-primary/70 hover:text-primary transition-colors"
133
+ >
134
+ <TextQuote className="w-3 h-3" />
135
+ {excerptOpen.has(i) ? "Hide excerpt" : "Show excerpt"}
136
+ </button>
137
+ )}
138
  </div>
139
+ ))}
140
+ </div>
141
+ )}
 
 
 
 
142
  </div>
143
  );
144
  }
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
@@ -2,6 +2,7 @@
2
 
3
  import { useState } from "react";
4
  import { useAuth } from "@/lib/auth";
 
5
  import { useRouter } from "next/navigation";
6
  import { Button } from "@/components/ui/button";
7
  import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -20,6 +21,7 @@ import {
20
  PanelRightOpen,
21
  LogOut,
22
  Moon,
 
23
  Sun,
24
  Menu,
25
  X,
@@ -48,6 +50,7 @@ export default function Header({
48
  mobileSheetContent,
49
  }: HeaderProps) {
50
  const { user, logout } = useAuth();
 
51
  const router = useRouter();
52
  const { theme, setTheme } = useTheme();
53
  const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
@@ -61,6 +64,19 @@ export default function Header({
61
  router.replace("/login");
62
  };
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  return (
65
  <>
66
  <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">
@@ -205,4 +221,4 @@ export default function Header({
205
  </aside>
206
  </>
207
  );
208
- }
 
2
 
3
  import { useState } from "react";
4
  import { useAuth } from "@/lib/auth";
5
+ import { useTranslation } from "react-i18next";
6
  import { useRouter } from "next/navigation";
7
  import { Button } from "@/components/ui/button";
8
  import { Avatar, AvatarFallback } from "@/components/ui/avatar";
 
21
  PanelRightOpen,
22
  LogOut,
23
  Moon,
24
+ Shield,
25
  Sun,
26
  Menu,
27
  X,
 
50
  mobileSheetContent,
51
  }: HeaderProps) {
52
  const { user, logout } = useAuth();
53
+ const { t, i18n } = useTranslation();
54
  const router = useRouter();
55
  const { theme, setTheme } = useTheme();
56
  const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
 
64
  router.replace("/login");
65
  };
66
 
67
+ const languageLabel = (language: string) => {
68
+ switch (language) {
69
+ case "hi":
70
+ return t("common.hindi");
71
+ case "es":
72
+ return t("common.spanish");
73
+ case "fr":
74
+ return t("common.french");
75
+ default:
76
+ return t("common.english");
77
+ }
78
+ };
79
+
80
  return (
81
  <>
82
  <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">
 
221
  </aside>
222
  </>
223
  );
224
+ }
frontend/src/components/layout/ThemeProvider.tsx ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { ThemeProvider as NextThemesProvider } from "next-themes";
4
+ import { type ThemeProviderProps } from "next-themes";
5
+
6
+ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
7
+ return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
8
+ }
frontend/src/components/layout/ThemeToggle.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useTheme } from "next-themes";
4
+ import { useSyncExternalStore } from "react";
5
+ import { Sun, Moon } from "lucide-react";
6
+
7
+ // useSyncExternalStore with identical server/client snapshots = no hydration mismatch
8
+ const subscribe = () => () => {};
9
+ const getSnapshot = () => true;
10
+ const getServerSnapshot = () => false;
11
+
12
+ export function ThemeToggle() {
13
+ const { theme, setTheme } = useTheme();
14
+ const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
15
+
16
+ if (!mounted) return null;
17
+
18
+ return (
19
+ <button
20
+ onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
21
+ aria-label="Toggle theme"
22
+ className="rounded-md p-2 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
23
+ >
24
+ {theme === "dark" ? (
25
+ <Sun className="h-5 w-5 text-yellow-400" />
26
+ ) : (
27
+ <Moon className="h-5 w-5 text-gray-700" />
28
+ )}
29
+ </button>
30
+ );
31
+ }
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/api.ts CHANGED
@@ -201,6 +201,29 @@ class ApiClient {
201
  return res.json();
202
  }
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  async postForm<T>(path: string, formData: FormData, options?: FetchOptions): Promise<T> {
205
  const token = options?.token || this.getToken();
206
  const headers: HeadersInit = {};
 
201
  return res.json();
202
  }
203
 
204
+ async put<T>(path: string, body?: unknown, options?: FetchOptions): Promise<T> {
205
+ const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
206
+ method: "PUT",
207
+ headers: this.getHeaders(options?.token),
208
+ body: body ? JSON.stringify(body) : undefined,
209
+ ...options,
210
+ });
211
+
212
+ // Auto-refresh on 401
213
+ if (res.status === 401 && !options?._skipRefresh) {
214
+ const newToken = await this.tryRefreshToken();
215
+ if (newToken) {
216
+ return this.put<T>(path, body, { ...options, token: newToken, _skipRefresh: true });
217
+ }
218
+ }
219
+
220
+ if (!res.ok) {
221
+ throw new Error(await this.getErrorMessage(res, res.statusText || "Request failed"));
222
+ }
223
+
224
+ return res.json();
225
+ }
226
+
227
  async postForm<T>(path: string, formData: FormData, options?: FetchOptions): Promise<T> {
228
  const token = options?.token || this.getToken();
229
  const headers: HeadersInit = {};