Param20h commited on
Commit
a91f56d
·
unverified ·
2 Parent(s): 23eb242886eba9

feat: Hall of Fame, Open Source badge + merge all GSSOC contributor PRs

Browse files

- Hall of Fame modal (ContributorsPanel) with GitHub API live contributor data
- Floating Open Source corner badge with star count and fork/contribute links
- Trophy button in header to open Hall of Fame from anywhere
- Contributor work: textarea auto-resize, document switch fix, RAG comments,
Makefile, CHANGELOG, .env.example docs, auth improvements

.env.example CHANGED
@@ -1,24 +1,135 @@
1
- # ── App Config ───────────────────────────────────────
 
 
 
 
 
 
 
 
 
2
  SECRET_KEY=change-me-in-production
3
- DATABASE_URL=sqlite:///./data/app.db
4
 
5
- # ── HuggingFace (Required for LLM) ──────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  HF_TOKEN=your_huggingface_token_here
7
 
8
- # ── LLM Model (Optional — defaults shown) ───────────
 
 
 
 
9
  # LLM_MODEL=mistralai/Mistral-7B-Instruct-v0.3
 
 
 
10
  # LLM_TEMPERATURE=0.3
 
 
 
11
  # LLM_MAX_NEW_TOKENS=1024
12
 
13
- # ── Embeddings (Optional — defaults shown) ───────────
 
 
 
 
14
  # EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
15
 
 
 
 
 
16
  # ── RAG Config (Optional — defaults shown) ───────────
 
 
 
 
 
 
 
 
 
 
 
 
17
  # CHUNK_SIZE=1000
 
 
 
18
  # CHUNK_OVERLAP=200
 
 
 
 
 
19
  # TOP_K_RETRIEVAL=10
 
 
 
 
20
  # TOP_K_RERANK=5
21
 
22
- # ── Upload (Optional) ───────────────────────────────
23
- # UPLOAD_DIR=./data/uploads
24
- # MAX_FILE_SIZE_MB=50
 
 
 
 
 
 
 
 
 
1
+ # Document AI Analyst Environment Configuration
2
+ # Copy this file to backend/.env and fill in your values:
3
+ # cp .env.example backend/.env
4
+
5
+
6
+ # ── Application Config ──────────────────────────────────────────────
7
+
8
+ # Secret key for signing JWT tokens and Flask sessions.
9
+ # Generate one: python -c "import secrets; print(secrets.token_urlsafe(32))"
10
+ # Required
11
  SECRET_KEY=change-me-in-production
 
12
 
13
+ # ── Environment & CORS ──────────────────────────────
14
+
15
+ # Runtime environment. Set to "production" in production.
16
+ # In production, ALLOWED_ORIGINS must be set explicitly (CORS will reject all others).
17
+ # Optional — defaults to "development"
18
+ ENVIRONMENT=development
19
+
20
+ # Debug mode. Enables detailed error pages and auto-reload.
21
+ # Do NOT enable in production.
22
+ # Optional — defaults to False
23
+ # DEBUG=False
24
+
25
+ # Comma-separated list of allowed CORS origins.
26
+ # Only used when ENVIRONMENT=production. When empty or during development, all origins are allowed.
27
+ # Optional — defaults to "http://localhost:3000,http://localhost:7860"
28
+ ALLOWED_ORIGINS=http://localhost:3000,http://localhost:7860
29
+
30
+ # ── Database ─────────────────────────────────────────────────
31
+
32
+ # SQLAlchemy database connection string.
33
+ # Default: SQLite stored at ./data/app.db
34
+ # For Postgres: postgresql+asyncpg://user:pass@host:5432/dbname
35
+ # Optional — defaults to sqlite:///./data/app.db
36
+ # DATABASE_URL=sqlite:///./data/app.db
37
+
38
+ # ── Authentication ──────────────────────────────────────────
39
+
40
+ # JWT signing algorithm. Leave as default unless you know what you're doing.
41
+ # Optional — defaults to "HS256"
42
+ # JWT_ALGORITHM=HS256
43
+
44
+ # JWT token expiry in hours. After this period, users must re-login.
45
+ # Optional — defaults to 72
46
+ # JWT_EXPIRY_HOURS=72
47
+
48
+ # ── File Upload ─────────────────────────────────────────────
49
+
50
+ # Directory where uploaded documents (PDFs, DOCXs, etc.) are stored.
51
+ # Optional — defaults to "./data/uploads"
52
+ # UPLOAD_DIR=./data/uploads
53
+
54
+ # Maximum upload file size in megabytes.
55
+ # Optional — defaults to 50
56
+ # MAX_FILE_SIZE_MB=50
57
+
58
+ # Comma-separated list of allowed file extensions for upload.
59
+ # Optional — defaults to "pdf,docx,txt,md"
60
+ # ALLOWED_EXTENSIONS=pdf,docx,txt,md
61
+
62
+ # ── HuggingFace (Required for LLM inference) ────────────────
63
+
64
+ # HuggingFace API token. Used to call the Inference API for LLM responses.
65
+ # Get yours: https://huggingface.co/settings/tokens (free tier available)
66
+ # Required (app won't generate answers without it)
67
  HF_TOKEN=your_huggingface_token_here
68
 
69
+ # ── LLM Configuration ───────────────────────────────────────
70
+
71
+ # HuggingFace model ID used for answer generation.
72
+ # Check available models: https://huggingface.co/models?inference=warm&sort=trending
73
+ # Optional — defaults to "mistralai/Mistral-7B-Instruct-v0.3"
74
  # LLM_MODEL=mistralai/Mistral-7B-Instruct-v0.3
75
+
76
+ # Sampling temperature (0.0 = deterministic, 1.0 = very creative).
77
+ # Optional — defaults to 0.3
78
  # LLM_TEMPERATURE=0.3
79
+
80
+ # Maximum number of tokens the LLM can generate per response.
81
+ # Optional — defaults to 1024
82
  # LLM_MAX_NEW_TOKENS=1024
83
 
84
+ # ── Embeddings (Optional — defaults shown)──────────────────────────────────────────────
85
+
86
+ # SentenceTransformer model ID for generating document embeddings.
87
+ # Model is downloaded once and cached locally. No external API call.
88
+ # Optional — defaults to "sentence-transformers/all-MiniLM-L6-v2"
89
  # EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
90
 
91
+ # Dimension of the embedding vectors (must match the model output).
92
+ # Optional — defaults to 384
93
+ # EMBEDDING_DIMENSION=384
94
+
95
  # ── RAG Config (Optional — defaults shown) ───────────
96
+
97
+ # ── ChromaDB (Vector Store) ─────────────────────────────────
98
+
99
+ # Directory where ChromaDB persists its vector index to disk.
100
+ # Optional — defaults to "./data/chroma_db"
101
+ # CHROMA_PERSIST_DIR=./data/chroma_db
102
+
103
+ # ── Document Chunking ───��───────────────────────────────────
104
+
105
+ # Number of characters per document chunk.
106
+ # Larger chunks give more context; smaller chunks improve retrieval precision.
107
+ # Optional — defaults to 1000
108
  # CHUNK_SIZE=1000
109
+
110
+ # Character overlap between consecutive chunks. Helps maintain context at boundaries.
111
+ # Optional — defaults to 200
112
  # CHUNK_OVERLAP=200
113
+
114
+ # ── Retrieval ───────────────────────────────────────────────
115
+
116
+ # Number of candidate chunks retrieved from the vector store during semantic search.
117
+ # Optional — defaults to 10
118
  # TOP_K_RETRIEVAL=10
119
+
120
+ # Number of top chunks passed to the LLM after cross-encoder reranking.
121
+ # Must be ≤ TOP_K_RETRIEVAL.
122
+ # Optional — defaults to 5
123
  # TOP_K_RERANK=5
124
 
125
+ # Cross-encoder model used for reranking retrieved chunks by relevance.
126
+ # Optional — defaults to "cross-encoder/ms-marco-MiniLM-L-6-v2"
127
+ # RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
128
+
129
+ # ── (Legacy) Flask-Only Variables ───────────────────────────
130
+ # These are only used if you run the old Flask app (app.py) instead of FastAPI.
131
+ # They are ignored by the new FastAPI backend.
132
+
133
+ # MONGO_URI=mongodb://localhost:27017/pdf_assistant
134
+ # GOOGLE_CLIENT_ID=your_google_client_id
135
+ # GOOGLE_CLIENT_SECRET=your_google_client_secret
CHANGELOG.MD ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.4.0] - 2026-05-16
11
+ ### Added
12
+ - Configured GSSOC contributor workflow on the `dev` branch.
13
+
14
+ ### Fixed
15
+ - Resolved React Hook linting errors (`react-hooks/set-state-in-effect`) in CI pipelines by lazy-initializing the loading state to prevent `setLoading(false)` execution inside the effect body.
16
+ - Fixed chat scroll component tracking using a `bottomRef` sentinel and `scrollIntoView` mechanism to replace the broken `scrollRef` on the ScrollArea wrapper.
17
+
18
+ ### Documentation
19
+ - Extensively overhauled `README.md` to add full project documentation, an explicit RAG pipeline architectural diagram, comprehensive API reference, and GSSOC contribution guides.
20
+
21
+ ## [0.3.0] - 2026-04-15
22
+ ### Added
23
+ - Implemented a brand new UI and upgraded internal RAG model architectures.
24
+ - Configured native Hugging Face Spaces Docker deployment handling non-root user execution, model pre-download caching, and custom keep-alive timeouts.
25
+
26
+ ### Changed
27
+ - Switched default open-source inference engine to `Qwen2.5-72B-Instruct` to leverage Hugging Face free-tier hardware.
28
+
29
+ ### Fixed
30
+ - Patched critical `list index out of range` runtime crash by explicitly handling empty choice selections from LLM responses.
31
+ - Adjusted production API routing to enforce same-origin API calls and added native `HEAD` method support to satisfy Next.js route prefetching rules.
32
+ - Upgraded text chunking modules to use `langchain_text_splitters` to ensure compatibility with LangChain v0.3+.
33
+ - Removed bulky compiled binary assets before pushing to Hugging Face Spaces storage layers.
34
+
35
+ ## [0.2.0] - 2026-02-26
36
+ ### Added
37
+ - Implemented an alternative lightweight RAG pipeline utilizing a Pinecone vector database index, Gemini embeddings, and Render hosting deployment profiles.
38
+ - Integrated automated Google Cloud Run continuous deployment (CD) workflows via Google Cloud Platform (GCP).
39
+ - Added `ProxyFix` middleware support to securely preserve OAuth authentication headers behind Render's reverse proxy structure.
40
+
41
+ ### Fixed
42
+ - Cleaned hardcoded testing credentials and placeholder URIs flagged during automated GitHub secret scanning routines.
43
+
44
+ ## [0.1.0] - 2024-06-25
45
+ ### Added
46
+ - Initialized core repository, licensing infrastructure, and baseline documentation assets.
47
+ - Built initial RAG application architecture featuring file ingestion systems parsing raw `.txt`, `.docx`, and `.md` formats.
48
+ - Implemented native Google Authentication security layers.
49
+
50
+ [unreleased]: https://github.com/param20h/PDF-Assistant-RAG/compare/v0.4.0...HEAD
51
+ [0.4.0]: https://github.com/param20h/PDF-Assistant-RAG/compare/v0.3.0...v0.4.0
52
+ [0.3.0]: https://github.com/param20h/PDF-Assistant-RAG/compare/v0.2.0...v0.3.0
53
+ [0.2.0]: https://github.com/param20h/PDF-Assistant-RAG/compare/v0.1.0...v0.2.0
54
+ [0.1.0]: https://github.com/param20h/PDF-Assistant-RAG/commits/dev
Makefile ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .PHONY: dev-backend dev-frontend dev test lint format install install-backend install-frontend build clean docker-up docker-down docker-logs help
2
+
3
+ BACKEND_DIR = backend
4
+ FRONTEND_DIR = frontend
5
+ BACKEND_PORT ?= 7860
6
+
7
+ help:
8
+ @echo "Usage:"
9
+ @echo " make dev-backend Start FastAPI (uvicorn) on port $(BACKEND_PORT)"
10
+ @echo " make dev-frontend Start Next.js dev server on port 3000"
11
+ @echo " make dev Start both backend and frontend concurrently"
12
+ @echo " make test Run pytest"
13
+ @echo " make lint Run flake8 (backend) + eslint (frontend)"
14
+ @echo " make format Auto-format Python with black (backend)"
15
+ @echo " make install Install all dependencies (backend + frontend)"
16
+ @echo " make install-backend Install Python dependencies"
17
+ @echo " make install-frontend Install Node.js dependencies"
18
+ @echo " make build Build frontend for production"
19
+ @echo " make clean Remove __pycache__, .next, build artifacts"
20
+ @echo " make docker-up Start all Docker services"
21
+ @echo " make docker-down Stop all Docker services"
22
+ @echo " make docker-logs Tail Docker logs"
23
+
24
+ dev-backend:
25
+ cd $(BACKEND_DIR) && uvicorn app.main:app --host 0.0.0.0 --port $(BACKEND_PORT) --reload
26
+
27
+ dev-frontend:
28
+ cd $(FRONTEND_DIR) && npm run dev
29
+
30
+ dev:
31
+ @echo "Starting backend (port $(BACKEND_PORT)) and frontend (port 3000)..."
32
+ npx concurrently --kill-others --names "BACKEND,FRONTEND" --prefix-colors "blue,green" \
33
+ "$(MAKE) dev-backend" \
34
+ "$(MAKE) dev-frontend"
35
+
36
+ test:
37
+ cd $(BACKEND_DIR) && python -m pytest -v
38
+
39
+ lint:
40
+ cd $(BACKEND_DIR) && flake8 .
41
+ cd $(FRONTEND_DIR) && npm run lint
42
+
43
+ format:
44
+ cd $(BACKEND_DIR) && black .
45
+
46
+ install: install-backend install-frontend
47
+
48
+ install-backend:
49
+ pip install -r $(BACKEND_DIR)/requirements.txt
50
+
51
+ install-frontend:
52
+ cd $(FRONTEND_DIR) && npm install
53
+
54
+ build:
55
+ cd $(FRONTEND_DIR) && npm run build
56
+
57
+ clean:
58
+ rm -rf $(BACKEND_DIR)/__pycache__
59
+ find $(BACKEND_DIR) -type d -name __pycache__ -exec rm -rf {} +
60
+ rm -rf $(FRONTEND_DIR)/.next
61
+ rm -rf $(FRONTEND_DIR)/out
62
+ rm -rf $(FRONTEND_DIR)/build
63
+ rm -rf .pytest_cache
64
+
65
+ docker-up:
66
+ docker compose up -d
67
+
68
+ docker-down:
69
+ docker compose down
70
+
71
+ docker-logs:
72
+ docker compose logs -f
README.md CHANGED
@@ -69,8 +69,6 @@ Thanks to all the amazing people who have contributed to **PDF-Assistant-RAG**!
69
 
70
  <br/>
71
 
72
- > 🌟 **GSSOC Contributors** — This project is open for [GirlScript Summer of Code](https://gssoc.girlscript.tech/). Check out our [CONTRIBUTING.md](CONTRIBUTING.md) to get started and browse [open issues](https://github.com/param20h/PDF-Assistant-RAG/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) tagged `good first issue`.
73
-
74
  ---
75
 
76
  <br/>
@@ -83,6 +81,65 @@ The system uses **semantic search + cross-encoder reranking** to find the most r
83
 
84
  <br/>
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  ## 🛠 Tech Stack
87
 
88
  <div align="center">
@@ -235,7 +292,7 @@ PDF-Assistant-RAG/
235
 
236
  ├── Dockerfile # Multi-stage: Node build → Python serve
237
  ├── docker-compose.yml # Local Docker stack
238
- ├── CONTRIBUTING.md # GSSOC contributor guide
239
  └── .env.example # Template for environment variables
240
  ```
241
 
@@ -378,22 +435,30 @@ docker compose up --build
378
 
379
  ## 📦 Environment Variables
380
 
381
- | Variable | Required | Default | Description |
382
- |---|---|---|---|
383
- | `HF_TOKEN` | ✅ | — | HuggingFace API token for LLM inference |
384
- | `SECRET_KEY` | ✅ | — | JWT signing secret (use a strong random string) |
385
- | `DATABASE_URL` | ❌ | `sqlite:///./data/app.db` | SQLAlchemy database URL |
386
- | `UPLOAD_DIR` | ❌ | `./data/uploads` | Directory for uploaded files |
387
- | `CHROMA_PERSIST_DIR` | ❌ | `./data/chroma_db` | ChromaDB persistence path |
388
- | `LLM_MODEL` | ❌ | `Qwen/Qwen2.5-72B-Instruct` | HuggingFace model ID |
389
- | `LLM_TEMPERATURE` | ❌ | `0.3` | LLM sampling temperature |
390
- | `LLM_MAX_NEW_TOKENS` | ❌ | `1024` | Max tokens per response |
391
- | `EMBEDDING_MODEL` | ❌ | `all-MiniLM-L6-v2` | SentenceTransformer model |
392
- | `CHUNK_SIZE` | ❌ | `1000` | Document chunk size (characters) |
393
- | `CHUNK_OVERLAP` | ❌ | `200` | Overlap between chunks |
394
- | `TOP_K_RETRIEVAL` | ❌ | `10` | Candidates retrieved from vector store |
395
- | `TOP_K_RERANK` | ❌ | `5` | Final chunks passed to LLM after reranking |
396
- | `MAX_FILE_SIZE_MB` | ❌ | `50` | Maximum upload file size |
 
 
 
 
 
 
 
 
397
 
398
  <br/>
399
 
@@ -449,7 +514,7 @@ docker compose up -d --build
449
 
450
  <br/>
451
 
452
- ## 🤝 Contributing — GSSOC
453
 
454
  This project is participating in **GirlScript Summer of Code**! We welcome contributors of all skill levels.
455
 
@@ -485,7 +550,7 @@ Distributed under the **MIT License**. See [`LICENSE`](license) for more informa
485
 
486
  **Built with 💙 as a flagship AI engineering project**
487
 
488
- *If you found this project helpful, please give it a ⭐ — it helps GSSOC contributors discover it!*
489
 
490
  <br/>
491
 
@@ -495,4 +560,4 @@ Distributed under the **MIT License**. See [`LICENSE`](license) for more informa
495
 
496
  **[⬆ Back to top](#)**
497
 
498
- </div>
 
69
 
70
  <br/>
71
 
 
 
72
  ---
73
 
74
  <br/>
 
81
 
82
  <br/>
83
 
84
+ ## 🏗️ Architecture
85
+
86
+ ```mermaid
87
+ graph TD
88
+ subgraph Frontend["Frontend (Next.js 16)"]
89
+ UI["Dashboard UI (React)"]
90
+ Chat["Chat Panel (SSE)"]
91
+ Viewer["PDF Viewer (iframe)"]
92
+ end
93
+
94
+ subgraph Backend["Backend (FastAPI 0.115+)"]
95
+ API["API Router (/api/v1)"]
96
+ Auth["Auth (JWT/bcrypt)"]
97
+ DB[(SQLite Metadata)]
98
+
99
+ subgraph RAG["RAG Pipeline"]
100
+ Upload["Ingestion Task (Chunking)"]
101
+ Embed["Local Embeddings (all-MiniLM-L6-v2)"]
102
+ Retriever["Two-Stage Retriever"]
103
+ Rerank["Cross-Encoder Reranker"]
104
+ Agent["Agent/Generator"]
105
+ end
106
+ end
107
+
108
+ subgraph Storage["Vector Storage"]
109
+ Chroma[(ChromaDB)]
110
+ end
111
+
112
+ subgraph External["External Services"]
113
+ HF["HuggingFace Inference API (Qwen 72B)"]
114
+ end
115
+
116
+ %% Frontend to Backend Connections
117
+ UI <-->|REST / Auth| API
118
+ Chat <-->|SSE Streaming| API
119
+ Viewer -->|Fetch PDF| API
120
+
121
+ %% Backend Internals
122
+ API <--> Auth
123
+ API <--> DB
124
+ API --> Upload
125
+ API <--> Retriever
126
+ API <--> Agent
127
+
128
+ %% RAG Ingestion Flow
129
+ Upload --> Embed
130
+ Embed -->|Store Vectors| Chroma
131
+
132
+ %% RAG Query Flow
133
+ Retriever -->|1. Semantic Search| Chroma
134
+ Retriever -->|2. Score & Sort| Rerank
135
+ Retriever -->|Context| Agent
136
+
137
+ %% External LLM Flow
138
+ Agent <-->|LLM Generation| HF
139
+ ```
140
+
141
+ <br/>
142
+
143
  ## 🛠 Tech Stack
144
 
145
  <div align="center">
 
292
 
293
  ├── Dockerfile # Multi-stage: Node build → Python serve
294
  ├── docker-compose.yml # Local Docker stack
295
+ ├── CONTRIBUTING.md # contributor guide
296
  └── .env.example # Template for environment variables
297
  ```
298
 
 
435
 
436
  ## 📦 Environment Variables
437
 
438
+ | Variable | Required | Default | Description | Where to Get It |
439
+ |---|---|---|---|---|
440
+ | `SECRET_KEY` | ✅ | — | JWT signing & session secret. Use a strong random string. | Generate: `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
441
+ | `HF_TOKEN` | ✅ | — | HuggingFace API token for LLM inference via Inference API. | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) (free) |
442
+ | `ENVIRONMENT` | ❌ | `development` | Runtime mode. Set to `production` for deployment to lock CORS. | — |
443
+ | `DEBUG` | ❌ | `False` | Enable debug mode with detailed error pages. Never enable in production. | — |
444
+ | `ALLOWED_ORIGINS` | ❌ | `http://localhost:3000,http://localhost:7860` | Comma-separated CORS origins (only enforced in production). | Your deployed domain(s) |
445
+ | `DATABASE_URL` | ❌ | `sqlite:///./data/app.db` | SQLAlchemy database connection string. | SQLite (default), or your Postgres/MySQL connection string |
446
+ | `JWT_ALGORITHM` | ❌ | `HS256` | JWT signing algorithm. | — |
447
+ | `JWT_EXPIRY_HOURS` | ❌ | `72` | JWT token lifetime in hours before re-login is required. | — |
448
+ | `UPLOAD_DIR` | ❌ | `./data/uploads` | Local directory for storing uploaded documents. | — |
449
+ | `MAX_FILE_SIZE_MB` | ❌ | `50` | Maximum allowed upload file size in MB. | — |
450
+ | `ALLOWED_EXTENSIONS` | ❌ | `pdf,docx,txt,md` | Comma-separated list of permitted file extensions. | — |
451
+ | `CHROMA_PERSIST_DIR` | ❌ | `./data/chroma_db` | Directory where ChromaDB persists its vector index. | — |
452
+ | `LLM_MODEL` | ❌ | `Qwen/Qwen2.5-72B-Instruct` | HuggingFace model ID for answer generation. | [huggingface.co/models](https://huggingface.co/models?inference=warm&sort=trending) |
453
+ | `LLM_TEMPERATURE` | ❌ | `0.3` | LLM sampling temperature (0 = deterministic, 1 = creative). | — |
454
+ | `LLM_MAX_NEW_TOKENS` | ❌ | `1024` | Maximum tokens per LLM response. | — |
455
+ | `EMBEDDING_MODEL` | ❌ | `sentence-transformers/all-MiniLM-L6-v2` | SentenceTransformer model for local embeddings (no external API). | [huggingface.co/sentence-transformers](https://huggingface.co/sentence-transformers) |
456
+ | `EMBEDDING_DIMENSION` | ❌ | `384` | Embedding vector dimension (must match the model). | — |
457
+ | `RERANKER_MODEL` | ❌ | `cross-encoder/ms-marco-MiniLM-L-6-v2` | Cross-encoder model for reranking retrieved chunks by relevance. | [huggingface.co/cross-encoder](https://huggingface.co/cross-encoder) |
458
+ | `CHUNK_SIZE` | ❌ | `1000` | Characters per document chunk. Larger = more context, smaller = better precision. | — |
459
+ | `CHUNK_OVERLAP` | ❌ | `200` | Overlap between consecutive chunks to maintain boundary context. | — |
460
+ | `TOP_K_RETRIEVAL` | ❌ | `10` | Candidate chunks retrieved from vector store during semantic search. | — |
461
+ | `TOP_K_RERANK` | ❌ | `5` | Final chunks passed to the LLM after reranking (must be ≤ `TOP_K_RETRIEVAL`). | — |
462
 
463
  <br/>
464
 
 
514
 
515
  <br/>
516
 
517
+ ## 🤝 Contributing
518
 
519
  This project is participating in **GirlScript Summer of Code**! We welcome contributors of all skill levels.
520
 
 
550
 
551
  **Built with 💙 as a flagship AI engineering project**
552
 
553
+ *If you found this project helpful, please give it a ⭐ — it helps contributors discover it!*
554
 
555
  <br/>
556
 
 
560
 
561
  **[⬆ Back to top](#)**
562
 
563
+ </div>
backend/app/auth.py CHANGED
@@ -30,20 +30,34 @@ def verify_password(plain: str, hashed: str) -> bool:
30
 
31
  # ── JWT Token ────────────────────────────────────────
32
 
33
- def create_token(user_id: str) -> str:
34
- """Create a JWT token with user_id as the subject."""
35
  payload = {
36
  "sub": user_id,
37
- "exp": datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRY_HOURS),
 
38
  "iat": datetime.now(timezone.utc),
39
  }
40
  return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
41
 
42
 
43
- def decode_token(token: str) -> Optional[str]:
 
 
 
 
 
 
 
 
 
 
 
44
  """Decode JWT and return user_id, or None if invalid."""
45
  try:
46
  payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
 
 
47
  return payload.get("sub")
48
  except jwt.ExpiredSignatureError:
49
  return None
 
30
 
31
  # ── JWT Token ────────────────────────────────────────
32
 
33
+ def create_access_token(user_id: str) -> str:
34
+ """Create a JWT access token with user_id as the subject."""
35
  payload = {
36
  "sub": user_id,
37
+ "type": "access",
38
+ "exp": datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_ACCESS_EXPIRY_MINUTES),
39
  "iat": datetime.now(timezone.utc),
40
  }
41
  return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
42
 
43
 
44
+ def create_refresh_token(user_id: str) -> str:
45
+ """Create a JWT refresh token with user_id as the subject."""
46
+ payload = {
47
+ "sub": user_id,
48
+ "type": "refresh",
49
+ "exp": datetime.now(timezone.utc) + timedelta(days=settings.JWT_REFRESH_EXPIRY_DAYS),
50
+ "iat": datetime.now(timezone.utc),
51
+ }
52
+ return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
53
+
54
+
55
+ def decode_token(token: str, token_type: str = "access") -> Optional[str]:
56
  """Decode JWT and return user_id, or None if invalid."""
57
  try:
58
  payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
59
+ if payload.get("type") != token_type:
60
+ return None
61
  return payload.get("sub")
62
  except jwt.ExpiredSignatureError:
63
  return None
backend/app/config.py CHANGED
@@ -12,17 +12,20 @@ class Settings(BaseSettings):
12
  APP_NAME: str = "Document AI Analyst"
13
  SECRET_KEY: str = "change-me-in-production-please"
14
  DEBUG: bool = False
 
 
15
 
16
  # ── Database ─────────────────────────────────────────
17
  DATABASE_URL: str = "sqlite:///./data/app.db"
18
 
19
  # ── Auth ─────────────────────────────────────────────
20
  JWT_ALGORITHM: str = "HS256"
21
- JWT_EXPIRY_HOURS: int = 72
 
22
 
23
  # ── File Upload ──────────────────────────────────────
24
  UPLOAD_DIR: str = "./data/uploads"
25
- MAX_FILE_SIZE_MB: int = 50
26
  ALLOWED_EXTENSIONS: set = {"pdf", "docx", "txt", "md"}
27
 
28
  # ── RAG Pipeline ─────────────────────────────────────
@@ -47,6 +50,13 @@ class Settings(BaseSettings):
47
  # ── Reranker ─────────────────────────────────────────
48
  RERANKER_MODEL: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
49
 
 
 
 
 
 
 
 
50
  class Config:
51
  env_file = ".env"
52
  env_file_encoding = "utf-8"
 
12
  APP_NAME: str = "Document AI Analyst"
13
  SECRET_KEY: str = "change-me-in-production-please"
14
  DEBUG: bool = False
15
+ ENVIRONMENT: str = "development"
16
+ ALLOWED_ORIGINS: str = "http://localhost:3000,http://localhost:7860"
17
 
18
  # ── Database ─────────────────────────────────────────
19
  DATABASE_URL: str = "sqlite:///./data/app.db"
20
 
21
  # ── Auth ─────────────────────────────────────────────
22
  JWT_ALGORITHM: str = "HS256"
23
+ JWT_ACCESS_EXPIRY_MINUTES: int = 15
24
+ JWT_REFRESH_EXPIRY_DAYS: int = 7
25
 
26
  # ── File Upload ──────────────────────────────────────
27
  UPLOAD_DIR: str = "./data/uploads"
28
+ MAX_UPLOAD_SIZE_MB: int = 20
29
  ALLOWED_EXTENSIONS: set = {"pdf", "docx", "txt", "md"}
30
 
31
  # ── RAG Pipeline ─────────────────────────────────────
 
50
  # ── Reranker ─────────────────────────────────────────
51
  RERANKER_MODEL: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
52
 
53
+
54
+ @property
55
+ def cors_origins(self) -> list[str]:
56
+ if self.ENVIRONMENT == "production":
57
+ return [o.strip() for o in self.ALLOWED_ORIGINS.split(",")]
58
+ return ["*"]
59
+
60
  class Config:
61
  env_file = ".env"
62
  env_file_encoding = "utf-8"
backend/app/main.py CHANGED
@@ -63,11 +63,12 @@ app = FastAPI(
63
  # ── CORS (allow frontend dev server) ─────────────────
64
  app.add_middleware(
65
  CORSMiddleware,
66
- allow_origins=["http://localhost:3000", "http://localhost:7860", "*"],
67
  allow_credentials=True,
68
  allow_methods=["*"],
69
  allow_headers=["*"],
70
  )
 
71
 
72
  # ── Mount API Routes ─────────────────────────────────
73
  from app.routes.auth import router as auth_router
 
63
  # ── CORS (allow frontend dev server) ─────────────────
64
  app.add_middleware(
65
  CORSMiddleware,
66
+ allow_origins=settings.cors_origins,
67
  allow_credentials=True,
68
  allow_methods=["*"],
69
  allow_headers=["*"],
70
  )
71
+ logger.info(f"CORS origins: {settings.cors_origins}")
72
 
73
  # ── Mount API Routes ─────────────────────────────────
74
  from app.routes.auth import router as auth_router
backend/app/rag/agent.py CHANGED
@@ -74,11 +74,14 @@ def generate_answer(
74
  Full RAG pipeline: retrieve → build context → generate answer.
75
  Returns dict with 'answer' and 'sources'.
76
  """
 
77
  client = get_llm_client()
78
 
79
  # ── Handle greetings ─────────────────────────────
 
80
  if is_greeting(question):
81
  try:
 
82
  messages = _chat_messages(
83
  "You are Document AI Analyst, a friendly AI assistant for document analysis.",
84
  question,
@@ -96,6 +99,7 @@ def generate_answer(
96
  return {"answer": answer, "sources": []}
97
 
98
  # ── Retrieve relevant chunks ─────────────────────
 
99
  chunks = retrieve(
100
  query=question,
101
  user_id=user_id,
@@ -103,11 +107,13 @@ def generate_answer(
103
  )
104
 
105
  # ── Build prompt ─────────────────────────────────
 
106
  context = build_context(chunks)
107
  user_content = RAG_PROMPT_TEMPLATE.format(context=context, question=question)
108
  messages = _chat_messages(SYSTEM_PROMPT, user_content)
109
 
110
  # ── Generate answer ──────────────────────────────
 
111
  try:
112
  response = client.chat_completion(
113
  messages=messages,
@@ -124,6 +130,7 @@ def generate_answer(
124
  answer = f"I encountered an error generating a response. Please try again. Error: {str(e)}"
125
 
126
  # ── Format sources ───────────────────────────────
 
127
  sources = [
128
  {
129
  "text": chunk["text"][:300] + ("..." if len(chunk["text"]) > 300 else ""),
@@ -147,17 +154,22 @@ def generate_answer_stream(
147
  Streaming RAG pipeline — yields SSE-formatted chunks.
148
  First yields sources, then streams answer tokens.
149
  """
 
150
  client = get_llm_client()
151
 
152
  # ── Handle greetings ─────────────────────────────
 
153
  if is_greeting(question):
 
154
  yield f"data: {json.dumps({'type': 'sources', 'data': []})}\n\n"
155
 
156
  try:
 
157
  messages = _chat_messages(
158
  "You are Document AI Analyst, a friendly AI assistant for document analysis.",
159
  question,
160
  )
 
161
  stream = client.chat_completion(
162
  messages=messages,
163
  model=settings.LLM_MODEL,
@@ -173,10 +185,12 @@ def generate_answer_stream(
173
  except Exception as e:
174
  yield f"data: {json.dumps({'type': 'error', 'data': str(e)})}\n\n"
175
 
 
176
  yield f"data: {json.dumps({'type': 'done'})}\n\n"
177
  return
178
 
179
  # ── Retrieve relevant chunks ─────────────────────
 
180
  chunks = retrieve(
181
  query=question,
182
  user_id=user_id,
@@ -184,6 +198,7 @@ def generate_answer_stream(
184
  )
185
 
186
  # ── Yield sources first ──────────────────────────
 
187
  sources = [
188
  {
189
  "text": chunk["text"][:300] + ("..." if len(chunk["text"]) > 300 else ""),
@@ -197,11 +212,13 @@ def generate_answer_stream(
197
  yield f"data: {json.dumps({'type': 'sources', 'data': sources})}\n\n"
198
 
199
  # ── Build prompt ─────────────────────────────────
 
200
  context = build_context(chunks)
201
  user_content = RAG_PROMPT_TEMPLATE.format(context=context, question=question)
202
  messages = _chat_messages(SYSTEM_PROMPT, user_content)
203
 
204
  # ── Stream answer tokens ─────────────────────────
 
205
  try:
206
  stream = client.chat_completion(
207
  messages=messages,
@@ -216,8 +233,10 @@ def generate_answer_stream(
216
  if delta:
217
  yield f"data: {json.dumps({'type': 'token', 'data': delta})}\n\n"
218
 
 
219
  except Exception as e:
220
  logger.error(f"LLM streaming error: {e}")
221
  yield f"data: {json.dumps({'type': 'error', 'data': str(e)})}\n\n"
222
 
 
223
  yield f"data: {json.dumps({'type': 'done'})}\n\n"
 
74
  Full RAG pipeline: retrieve → build context → generate answer.
75
  Returns dict with 'answer' and 'sources'.
76
  """
77
+ # Get HuggingFace InferenceClient singleton (created once, reused)
78
  client = get_llm_client()
79
 
80
  # ── Handle greetings ─────────────────────────────
81
+ # Short-circuit: if user just says "hello", skip RAG entirely
82
  if is_greeting(question):
83
  try:
84
+ # Send greeting to LLM with a friendly system prompt (no document context)
85
  messages = _chat_messages(
86
  "You are Document AI Analyst, a friendly AI assistant for document analysis.",
87
  question,
 
99
  return {"answer": answer, "sources": []}
100
 
101
  # ── Retrieve relevant chunks ─────────────────────
102
+ # STAGE 1+2: Semantic search (ChromaDB) + cross-encoder reranking → top 5 chunks
103
  chunks = retrieve(
104
  query=question,
105
  user_id=user_id,
 
107
  )
108
 
109
  # ── Build prompt ─────────────────────────────────
110
+ # Format retrieved chunks into a readable context block, then inject into the RAG prompt template
111
  context = build_context(chunks)
112
  user_content = RAG_PROMPT_TEMPLATE.format(context=context, question=question)
113
  messages = _chat_messages(SYSTEM_PROMPT, user_content)
114
 
115
  # ── Generate answer ──────────────────────────────
116
+ # STAGE 3: Send prompt to HuggingFace Inference API and get the generated answer
117
  try:
118
  response = client.chat_completion(
119
  messages=messages,
 
130
  answer = f"I encountered an error generating a response. Please try again. Error: {str(e)}"
131
 
132
  # ── Format sources ───────────────────────────────
133
+ # Truncate chunk text to 300 chars and attach metadata (filename, page, score, confidence) for frontend citation display
134
  sources = [
135
  {
136
  "text": chunk["text"][:300] + ("..." if len(chunk["text"]) > 300 else ""),
 
154
  Streaming RAG pipeline — yields SSE-formatted chunks.
155
  First yields sources, then streams answer tokens.
156
  """
157
+ # Get HuggingFace InferenceClient singleton (created once, reused)
158
  client = get_llm_client()
159
 
160
  # ── Handle greetings ─────────────────────────────
161
+ # Short-circuit: if user just says "hello", skip RAG entirely
162
  if is_greeting(question):
163
+ # Yield empty sources array first so frontend resets its citation display
164
  yield f"data: {json.dumps({'type': 'sources', 'data': []})}\n\n"
165
 
166
  try:
167
+ # Send greeting to LLM with a friendly system prompt (no document context)
168
  messages = _chat_messages(
169
  "You are Document AI Analyst, a friendly AI assistant for document analysis.",
170
  question,
171
  )
172
+ # Stream greeting response token-by-token via SSE
173
  stream = client.chat_completion(
174
  messages=messages,
175
  model=settings.LLM_MODEL,
 
185
  except Exception as e:
186
  yield f"data: {json.dumps({'type': 'error', 'data': str(e)})}\n\n"
187
 
188
+ # Signal end of stream, then exit early (no RAG)
189
  yield f"data: {json.dumps({'type': 'done'})}\n\n"
190
  return
191
 
192
  # ── Retrieve relevant chunks ─────────────────────
193
+ # STAGE 1+2: Semantic search (ChromaDB) + cross-encoder reranking → top 5 chunks
194
  chunks = retrieve(
195
  query=question,
196
  user_id=user_id,
 
198
  )
199
 
200
  # ── Yield sources first ──────────────────────────
201
+ # Yield all sources first — frontend needs them to render citation cards before the answer starts appearing
202
  sources = [
203
  {
204
  "text": chunk["text"][:300] + ("..." if len(chunk["text"]) > 300 else ""),
 
212
  yield f"data: {json.dumps({'type': 'sources', 'data': sources})}\n\n"
213
 
214
  # ── Build prompt ─────────────────────────────────
215
+ # Format retrieved chunks into a readable context block, then inject into the RAG prompt template
216
  context = build_context(chunks)
217
  user_content = RAG_PROMPT_TEMPLATE.format(context=context, question=question)
218
  messages = _chat_messages(SYSTEM_PROMPT, user_content)
219
 
220
  # ── Stream answer tokens ─────────────────────────
221
+ # STAGE 3: Stream tokens from HuggingFace Inference API → forward each as an SSE 'token' event
222
  try:
223
  stream = client.chat_completion(
224
  messages=messages,
 
233
  if delta:
234
  yield f"data: {json.dumps({'type': 'token', 'data': delta})}\n\n"
235
 
236
+ # If LLM fails mid-stream, yield an error event so frontend can display the message
237
  except Exception as e:
238
  logger.error(f"LLM streaming error: {e}")
239
  yield f"data: {json.dumps({'type': 'error', 'data': str(e)})}\n\n"
240
 
241
+ # Signal end of stream to frontend (stops the streaming indicator)
242
  yield f"data: {json.dumps({'type': 'done'})}\n\n"
backend/app/routes/auth.py CHANGED
@@ -6,8 +6,8 @@ from sqlalchemy.orm import Session
6
 
7
  from app.database import get_db
8
  from app.models import User
9
- from app.schemas import UserRegister, UserLogin, TokenResponse, UserResponse
10
- from app.auth import hash_password, verify_password, create_token, get_current_user
11
 
12
  router = APIRouter(prefix="/auth", tags=["Authentication"])
13
 
@@ -40,10 +40,12 @@ def register(payload: UserRegister, db: Session = Depends(get_db)):
40
  db.refresh(user)
41
 
42
  # Generate token
43
- token = create_token(user.id)
 
44
 
45
  return TokenResponse(
46
- access_token=token,
 
47
  user=UserResponse.model_validate(user),
48
  )
49
 
@@ -59,10 +61,39 @@ def login(payload: UserLogin, db: Session = Depends(get_db)):
59
  detail="Invalid email or password",
60
  )
61
 
62
- token = create_token(user.id)
 
63
 
64
  return TokenResponse(
65
- access_token=token,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  user=UserResponse.model_validate(user),
67
  )
68
 
 
6
 
7
  from app.database import get_db
8
  from app.models import User
9
+ from app.schemas import UserRegister, UserLogin, TokenResponse, UserResponse, RefreshRequest
10
+ from app.auth import hash_password, verify_password, create_access_token, create_refresh_token, get_current_user, decode_token
11
 
12
  router = APIRouter(prefix="/auth", tags=["Authentication"])
13
 
 
40
  db.refresh(user)
41
 
42
  # Generate token
43
+ access_token = create_access_token(user.id)
44
+ refresh_token = create_refresh_token(user.id)
45
 
46
  return TokenResponse(
47
+ access_token=access_token,
48
+ refresh_token=refresh_token,
49
  user=UserResponse.model_validate(user),
50
  )
51
 
 
61
  detail="Invalid email or password",
62
  )
63
 
64
+ access_token = create_access_token(user.id)
65
+ refresh_token = create_refresh_token(user.id)
66
 
67
  return TokenResponse(
68
+ access_token=access_token,
69
+ refresh_token=refresh_token,
70
+ user=UserResponse.model_validate(user),
71
+ )
72
+
73
+
74
+ @router.post("/refresh", response_model=TokenResponse)
75
+ def refresh_token(payload: RefreshRequest, db: Session = Depends(get_db)):
76
+ """Refresh access token."""
77
+ user_id = decode_token(payload.refresh_token, token_type="refresh")
78
+ if not user_id:
79
+ raise HTTPException(
80
+ status_code=status.HTTP_401_UNAUTHORIZED,
81
+ detail="Invalid or expired refresh token",
82
+ )
83
+
84
+ user = db.query(User).filter(User.id == user_id).first()
85
+ if not user:
86
+ raise HTTPException(
87
+ status_code=status.HTTP_401_UNAUTHORIZED,
88
+ detail="User not found",
89
+ )
90
+
91
+ new_access_token = create_access_token(user.id)
92
+ new_refresh_token = create_refresh_token(user.id)
93
+
94
+ return TokenResponse(
95
+ access_token=new_access_token,
96
+ refresh_token=new_refresh_token,
97
  user=UserResponse.model_validate(user),
98
  )
99
 
backend/app/routes/documents.py CHANGED
@@ -108,10 +108,14 @@ async def upload_document(
108
  content = await file.read()
109
  file_size = len(content)
110
 
111
- if file_size > settings.MAX_FILE_SIZE_MB * 1024 * 1024:
 
112
  raise HTTPException(
113
  status_code=400,
114
- detail=f"File too large. Maximum size: {settings.MAX_FILE_SIZE_MB}MB",
 
 
 
115
  )
116
 
117
  # ── Save file to disk ────────────────────────────
 
108
  content = await file.read()
109
  file_size = len(content)
110
 
111
+ if file_size > settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024:
112
+ size_mb = file_size / (1024 * 1024)
113
  raise HTTPException(
114
  status_code=400,
115
+ detail=(
116
+ f"Upload rejected: file size ({size_mb:.1f} MB) exceeds the maximum "
117
+ f"allowed size of {settings.MAX_UPLOAD_SIZE_MB} MB."
118
+ ),
119
  )
120
 
121
  # ── Save file to disk ────────────────────────────
backend/app/schemas.py CHANGED
@@ -21,10 +21,15 @@ class UserLogin(BaseModel):
21
 
22
  class TokenResponse(BaseModel):
23
  access_token: str
 
24
  token_type: str = "bearer"
25
  user: "UserResponse"
26
 
27
 
 
 
 
 
28
  class UserResponse(BaseModel):
29
  id: str
30
  username: str
 
21
 
22
  class TokenResponse(BaseModel):
23
  access_token: str
24
+ refresh_token: str
25
  token_type: str = "bearer"
26
  user: "UserResponse"
27
 
28
 
29
+ class RefreshRequest(BaseModel):
30
+ refresh_token: str
31
+
32
+
33
  class UserResponse(BaseModel):
34
  id: str
35
  username: str
docker-compose.yml CHANGED
@@ -14,6 +14,12 @@ services:
14
  - UPLOAD_DIR=./data/uploads
15
  - CHROMA_PERSIST_DIR=./data/chroma_db
16
  restart: unless-stopped
 
 
 
 
 
 
17
 
18
  volumes:
19
  app_data:
 
14
  - UPLOAD_DIR=./data/uploads
15
  - CHROMA_PERSIST_DIR=./data/chroma_db
16
  restart: unless-stopped
17
+ healthcheck:
18
+ test: ["CMD", "curl", "-f", "http://localhost:7860/api/health"]
19
+ interval: 30s
20
+ timeout: 10s
21
+ retries: 3
22
+ start_period: 60s
23
 
24
  volumes:
25
  app_data:
frontend/src/app/dashboard/page.tsx CHANGED
@@ -3,11 +3,13 @@
3
  import { useEffect, useState, useCallback } from "react";
4
  import { useRouter } from "next/navigation";
5
  import { useAuth } from "@/lib/auth";
6
- import { api } from "@/lib/api";
7
  import Header from "@/components/layout/Header";
8
  import DocumentSidebar from "@/components/document/DocumentSidebar";
9
  import ChatPanel from "@/components/chat/ChatPanel";
10
  import PDFViewer from "@/components/document/PDFViewer";
 
 
11
 
12
  export interface DocInfo {
13
  id: string;
@@ -29,6 +31,7 @@ export default function DashboardPage() {
29
  const [pdfPage, setPdfPage] = useState(1);
30
  const [sidebarOpen, setSidebarOpen] = useState(true);
31
  const [viewerOpen, setViewerOpen] = useState(true);
 
32
 
33
  // Auth guard
34
  useEffect(() => {
@@ -40,8 +43,14 @@ export default function DashboardPage() {
40
  try {
41
  const data = await api.get<{ documents: DocInfo[] }>("/api/v1/documents/");
42
  setDocuments(data.documents);
43
- } catch {
44
- // silently fail
 
 
 
 
 
 
45
  }
46
  }, []);
47
 
@@ -74,13 +83,28 @@ export default function DashboardPage() {
74
 
75
  return (
76
  <div className="h-screen flex flex-col overflow-hidden">
 
 
 
 
 
77
  <Header
78
  sidebarOpen={sidebarOpen}
79
  onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
80
  viewerOpen={viewerOpen}
81
  onToggleViewer={() => setViewerOpen(!viewerOpen)}
 
82
  />
83
 
 
 
 
 
 
 
 
 
 
84
  <div className="flex-1 flex overflow-hidden">
85
  {/* ── Left: Document Sidebar ──────────────── */}
86
  {sidebarOpen && (
@@ -120,6 +144,9 @@ export default function DashboardPage() {
120
  </div>
121
  )}
122
  </div>
 
 
 
123
  </div>
124
  );
125
  }
 
3
  import { useEffect, useState, useCallback } from "react";
4
  import { useRouter } from "next/navigation";
5
  import { useAuth } from "@/lib/auth";
6
+ import { api, CONNECTION_ERROR_BANNER_MESSAGE, CONNECTION_ERROR_MESSAGE } from "@/lib/api";
7
  import Header from "@/components/layout/Header";
8
  import DocumentSidebar from "@/components/document/DocumentSidebar";
9
  import ChatPanel from "@/components/chat/ChatPanel";
10
  import PDFViewer from "@/components/document/PDFViewer";
11
+ import ContributorsPanel from "@/components/layout/ContributorsPanel";
12
+ import OpenSourceBadge from "@/components/layout/OpenSourceBadge";
13
 
14
  export interface DocInfo {
15
  id: string;
 
31
  const [pdfPage, setPdfPage] = useState(1);
32
  const [sidebarOpen, setSidebarOpen] = useState(true);
33
  const [viewerOpen, setViewerOpen] = useState(true);
34
+ const [hallOfFameOpen, setHallOfFameOpen] = useState(false);
35
 
36
  // Auth guard
37
  useEffect(() => {
 
43
  try {
44
  const data = await api.get<{ documents: DocInfo[] }>("/api/v1/documents/");
45
  setDocuments(data.documents);
46
+ setConnectionError("");
47
+ } catch (err) {
48
+ const message = err instanceof Error ? err.message : CONNECTION_ERROR_MESSAGE;
49
+ setConnectionError(
50
+ message === CONNECTION_ERROR_MESSAGE
51
+ ? CONNECTION_ERROR_BANNER_MESSAGE
52
+ : `⚠️ ${message}`
53
+ );
54
  }
55
  }, []);
56
 
 
83
 
84
  return (
85
  <div className="h-screen flex flex-col overflow-hidden">
86
+ {/* Hall of Fame Modal */}
87
+ {hallOfFameOpen && (
88
+ <ContributorsPanel onClose={() => setHallOfFameOpen(false)} />
89
+ )}
90
+
91
  <Header
92
  sidebarOpen={sidebarOpen}
93
  onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
94
  viewerOpen={viewerOpen}
95
  onToggleViewer={() => setViewerOpen(!viewerOpen)}
96
+ onOpenContributors={() => setHallOfFameOpen(true)}
97
  />
98
 
99
+ {connectionError && (
100
+ <div
101
+ role="alert"
102
+ className="border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive"
103
+ >
104
+ {connectionError}
105
+ </div>
106
+ )}
107
+
108
  <div className="flex-1 flex overflow-hidden">
109
  {/* ── Left: Document Sidebar ──────────────── */}
110
  {sidebarOpen && (
 
144
  </div>
145
  )}
146
  </div>
147
+
148
+ {/* Open Source floating badge */}
149
+ <OpenSourceBadge onOpenHallOfFame={() => setHallOfFameOpen(true)} />
150
  </div>
151
  );
152
  }
frontend/src/components/chat/ChatPanel.tsx CHANGED
@@ -35,9 +35,27 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
35
  const [messages, setMessages] = useState<ChatMsg[]>([]);
36
  const [input, setInput] = useState("");
37
  const [streaming, setStreaming] = useState(false);
 
38
  const bottomRef = useRef<HTMLDivElement>(null);
39
  const prevDocId = useRef<string | null>(null);
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  // Auto-scroll to bottom whenever messages change
42
  useEffect(() => {
43
  bottomRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -45,14 +63,26 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
45
 
46
  // Load history on doc change
47
  useEffect(() => {
48
- if (!activeDoc || activeDoc.id === prevDocId.current) return;
49
- prevDocId.current = activeDoc.id;
 
 
 
 
 
 
 
 
 
 
50
 
51
  api
52
  .get<{ messages: Array<{ id: string; role: string; content: string; sources?: SourceChunk[] }> }>(
53
- `/api/v1/chat/history/${activeDoc.id}`
54
  )
55
  .then((data) => {
 
 
56
  setMessages(
57
  data.messages.map((m) => ({
58
  id: m.id,
@@ -62,7 +92,14 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
62
  }))
63
  );
64
  })
65
- .catch(() => setMessages([]));
 
 
 
 
 
 
 
66
  }, [activeDoc]);
67
 
68
  const handleSend = async () => {
@@ -205,6 +242,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
205
  <div className="border-t border-border/50 p-4 bg-card/30 backdrop-blur-sm">
206
  <div className="max-w-3xl mx-auto flex gap-2 items-end">
207
  <Textarea
 
208
  id="chat-input"
209
  value={input}
210
  onChange={(e) => setInput(e.target.value)}
 
35
  const [messages, setMessages] = useState<ChatMsg[]>([]);
36
  const [input, setInput] = useState("");
37
  const [streaming, setStreaming] = useState(false);
38
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
39
  const bottomRef = useRef<HTMLDivElement>(null);
40
  const prevDocId = useRef<string | null>(null);
41
 
42
+ useEffect(() => {
43
+ const textarea = textareaRef.current;
44
+ if (!textarea) return;
45
+
46
+ textarea.style.height = "auto";
47
+ const computedMaxHeight = Number.parseFloat(
48
+ window.getComputedStyle(textarea).maxHeight
49
+ );
50
+ const maxHeight = Number.isFinite(computedMaxHeight)
51
+ ? computedMaxHeight
52
+ : textarea.scrollHeight;
53
+ const nextHeight = Math.min(textarea.scrollHeight, maxHeight);
54
+ textarea.style.height = `${nextHeight}px`;
55
+ textarea.style.overflowY =
56
+ textarea.scrollHeight > maxHeight ? "auto" : "hidden";
57
+ }, [input]);
58
+
59
  // Auto-scroll to bottom whenever messages change
60
  useEffect(() => {
61
  bottomRef.current?.scrollIntoView({ behavior: "smooth" });
 
63
 
64
  // Load history on doc change
65
  useEffect(() => {
66
+ if (!activeDoc) {
67
+ prevDocId.current = null;
68
+ setMessages([]);
69
+ return;
70
+ }
71
+
72
+ if (activeDoc.id === prevDocId.current) return;
73
+
74
+ const documentId = activeDoc.id;
75
+ prevDocId.current = documentId;
76
+ setMessages([]);
77
+ let cancelled = false;
78
 
79
  api
80
  .get<{ messages: Array<{ id: string; role: string; content: string; sources?: SourceChunk[] }> }>(
81
+ `/api/v1/chat/history/${documentId}`
82
  )
83
  .then((data) => {
84
+ if (cancelled || prevDocId.current !== documentId) return;
85
+
86
  setMessages(
87
  data.messages.map((m) => ({
88
  id: m.id,
 
92
  }))
93
  );
94
  })
95
+ .catch(() => {
96
+ if (cancelled || prevDocId.current !== documentId) return;
97
+ setMessages([]);
98
+ });
99
+
100
+ return () => {
101
+ cancelled = true;
102
+ };
103
  }, [activeDoc]);
104
 
105
  const handleSend = async () => {
 
242
  <div className="border-t border-border/50 p-4 bg-card/30 backdrop-blur-sm">
243
  <div className="max-w-3xl mx-auto flex gap-2 items-end">
244
  <Textarea
245
+ ref={textareaRef}
246
  id="chat-input"
247
  value={input}
248
  onChange={(e) => setInput(e.target.value)}
frontend/src/components/chat/SourceCard.tsx CHANGED
@@ -42,9 +42,9 @@ export default function SourceCard({ sources, onPageClick }: Props) {
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)}
46
  >
47
- p.{src.page} • {src.confidence}%
48
  </Badge>
49
  ))}
50
  </div>
@@ -64,7 +64,7 @@ export default function SourceCard({ sources, onPageClick }: Props) {
64
  {src.filename}
65
  </span>
66
  <Badge variant="outline" className="text-[9px] h-4 px-1.5">
67
- Page {src.page}
68
  </Badge>
69
  <Badge
70
  variant="secondary"
@@ -83,7 +83,7 @@ export default function SourceCard({ sources, onPageClick }: Props) {
83
  variant="ghost"
84
  size="sm"
85
  className="h-6 px-2 text-[10px]"
86
- onClick={() => onPageClick(src.page)}
87
  >
88
  <Eye className="w-3 h-3 mr-1" />
89
  View
 
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>
 
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"
 
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
frontend/src/components/document/DocumentSidebar.tsx CHANGED
@@ -22,28 +22,34 @@ interface Props {
22
  export default function DocumentSidebar({ documents, activeDoc, onSelectDoc, onDocumentsChange }: Props) {
23
  const [uploading, setUploading] = useState(false);
24
  const [uploadProgress, setUploadProgress] = useState(0);
 
25
  const [deleting, setDeleting] = useState<string | null>(null);
26
 
27
  const onDrop = useCallback(
28
- async (acceptedFiles: File[]) => {
29
  if (acceptedFiles.length === 0) return;
30
- setUploading(true);
31
- setUploadProgress(0);
32
 
33
- try {
34
- for (let i = 0; i < acceptedFiles.length; i++) {
35
- const formData = new FormData();
36
- formData.append("file", acceptedFiles[i]);
37
- await api.postForm("/api/v1/documents/upload", formData);
38
- setUploadProgress(((i + 1) / acceptedFiles.length) * 100);
39
- }
40
- onDocumentsChange();
41
- } catch (err) {
42
- console.error("Upload failed:", err);
43
- } finally {
44
- setUploading(false);
45
  setUploadProgress(0);
46
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  },
48
  [onDocumentsChange]
49
  );
@@ -97,7 +103,12 @@ export default function DocumentSidebar({ documents, activeDoc, onSelectDoc, onD
97
  return (
98
  <div className="h-full flex flex-col bg-sidebar">
99
  {/* ── Upload Zone ─────────────────────────────── */}
100
- <div className="p-3 border-b border-sidebar-border">
 
 
 
 
 
101
  <div
102
  {...getRootProps()}
103
  className={`relative rounded-lg border-2 border-dashed p-4 text-center cursor-pointer transition-all duration-200
 
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("");
26
  const [deleting, setDeleting] = useState<string | null>(null);
27
 
28
  const onDrop = useCallback(
29
+ (acceptedFiles: File[]) => {
30
  if (acceptedFiles.length === 0) return;
 
 
31
 
32
+ void (async () => {
33
+ setUploadError("");
34
+ setUploading(true);
 
 
 
 
 
 
 
 
 
35
  setUploadProgress(0);
36
+
37
+ try {
38
+ for (let i = 0; i < acceptedFiles.length; i++) {
39
+ const formData = new FormData();
40
+ formData.append("file", acceptedFiles[i]);
41
+ await api.postForm("/api/v1/documents/upload", formData);
42
+ setUploadProgress(((i + 1) / acceptedFiles.length) * 100);
43
+ }
44
+ onDocumentsChange();
45
+ } catch (err) {
46
+ const message = err instanceof Error ? err.message : "Upload failed";
47
+ setUploadError(message);
48
+ } finally {
49
+ setUploading(false);
50
+ setUploadProgress(0);
51
+ }
52
+ })();
53
  },
54
  [onDocumentsChange]
55
  );
 
103
  return (
104
  <div className="h-full flex flex-col bg-sidebar">
105
  {/* ── Upload Zone ─────────────────────────────── */}
106
+ <div className="p-3 border-b border-sidebar-border space-y-2">
107
+ {uploadError && (
108
+ <div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-sm text-destructive">
109
+ {uploadError}
110
+ </div>
111
+ )}
112
  <div
113
  {...getRootProps()}
114
  className={`relative rounded-lg border-2 border-dashed p-4 text-center cursor-pointer transition-all duration-200
frontend/src/components/layout/ContributorsPanel.tsx ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { Github, Star, GitPullRequest, Users, X, Trophy, ExternalLink } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+
7
+ interface Contributor {
8
+ login: string;
9
+ avatar_url: string;
10
+ html_url: string;
11
+ contributions: number;
12
+ }
13
+
14
+ interface RepoStats {
15
+ stargazers_count: number;
16
+ forks_count: number;
17
+ open_issues_count: number;
18
+ }
19
+
20
+ export default function ContributorsPanel({ onClose }: { onClose: () => void }) {
21
+ const [contributors, setContributors] = useState<Contributor[]>([]);
22
+ const [stats, setStats] = useState<RepoStats | null>(null);
23
+ const [loading, setLoading] = useState(true);
24
+
25
+ const REPO = "param20h/PDF-Assistant-RAG";
26
+
27
+ useEffect(() => {
28
+ Promise.all([
29
+ fetch(`https://api.github.com/repos/${REPO}/contributors?per_page=30`).then((r) => r.json()),
30
+ fetch(`https://api.github.com/repos/${REPO}`).then((r) => r.json()),
31
+ ])
32
+ .then(([contribs, repo]) => {
33
+ setContributors(Array.isArray(contribs) ? contribs : []);
34
+ setStats(repo);
35
+ })
36
+ .catch(() => {})
37
+ .finally(() => setLoading(false));
38
+ }, []);
39
+
40
+ const medals = ["🥇", "🥈", "🥉"];
41
+
42
+ return (
43
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
44
+ {/* Backdrop */}
45
+ <div
46
+ className="absolute inset-0 bg-background/80 backdrop-blur-sm"
47
+ onClick={onClose}
48
+ />
49
+
50
+ {/* Panel */}
51
+ <div className="relative w-full max-w-2xl max-h-[85vh] flex flex-col rounded-2xl border border-border/60 bg-card shadow-2xl overflow-hidden animate-fade-in-up">
52
+
53
+ {/* Header */}
54
+ <div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-gradient-to-r from-primary/10 to-transparent flex-shrink-0">
55
+ <div className="flex items-center gap-3">
56
+ <div className="w-9 h-9 rounded-xl bg-primary/15 flex items-center justify-center">
57
+ <Trophy className="w-5 h-5 text-primary" />
58
+ </div>
59
+ <div>
60
+ <h2 className="font-bold text-base">Hall of Fame</h2>
61
+ <p className="text-xs text-muted-foreground">GSSOC Contributors — thank you! 🎉</p>
62
+ </div>
63
+ </div>
64
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
65
+ <X className="w-4 h-4" />
66
+ </Button>
67
+ </div>
68
+
69
+ {/* Stats bar */}
70
+ {stats && (
71
+ <div className="flex items-center gap-6 px-6 py-3 bg-muted/30 border-b border-border/30 flex-shrink-0">
72
+ <a
73
+ href={`https://github.com/${REPO}`}
74
+ target="_blank"
75
+ rel="noopener noreferrer"
76
+ className="flex items-center gap-1.5 text-sm hover:text-primary transition-colors"
77
+ >
78
+ <Star className="w-4 h-4 text-yellow-500" />
79
+ <span className="font-semibold">{stats.stargazers_count}</span>
80
+ <span className="text-muted-foreground">stars</span>
81
+ </a>
82
+ <div className="flex items-center gap-1.5 text-sm">
83
+ <GitPullRequest className="w-4 h-4 text-green-500" />
84
+ <span className="font-semibold">{stats.forks_count}</span>
85
+ <span className="text-muted-foreground">forks</span>
86
+ </div>
87
+ <div className="flex items-center gap-1.5 text-sm">
88
+ <Users className="w-4 h-4 text-primary" />
89
+ <span className="font-semibold">{contributors.length}</span>
90
+ <span className="text-muted-foreground">contributors</span>
91
+ </div>
92
+ </div>
93
+ )}
94
+
95
+ {/* Contributors grid */}
96
+ <div className="flex-1 overflow-y-auto p-6">
97
+ {loading ? (
98
+ <div className="grid grid-cols-3 sm:grid-cols-4 gap-4">
99
+ {Array.from({ length: 8 }).map((_, i) => (
100
+ <div key={i} className="flex flex-col items-center gap-2 animate-pulse">
101
+ <div className="w-14 h-14 rounded-full bg-muted" />
102
+ <div className="h-3 w-16 rounded bg-muted" />
103
+ <div className="h-2 w-10 rounded bg-muted" />
104
+ </div>
105
+ ))}
106
+ </div>
107
+ ) : contributors.length === 0 ? (
108
+ <div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
109
+ <Users className="w-12 h-12 mb-3 opacity-30" />
110
+ <p className="text-sm">No contributors yet — be the first!</p>
111
+ </div>
112
+ ) : (
113
+ <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-5">
114
+ {contributors.map((c, i) => (
115
+ <a
116
+ key={c.login}
117
+ href={c.html_url}
118
+ target="_blank"
119
+ rel="noopener noreferrer"
120
+ className="group flex flex-col items-center gap-2 rounded-xl p-3 hover:bg-accent/50 transition-all duration-200"
121
+ >
122
+ <div className="relative">
123
+ {/* Medal for top 3 */}
124
+ {i < 3 && (
125
+ <span className="absolute -top-1.5 -right-1.5 text-base leading-none">
126
+ {medals[i]}
127
+ </span>
128
+ )}
129
+ <img
130
+ src={c.avatar_url}
131
+ alt={c.login}
132
+ width={56}
133
+ height={56}
134
+ className="w-14 h-14 rounded-full border-2 border-border/50 group-hover:border-primary/50 transition-colors object-cover"
135
+ />
136
+ </div>
137
+ <span className="text-xs font-medium text-center leading-tight truncate w-full text-center group-hover:text-primary transition-colors">
138
+ {c.login}
139
+ </span>
140
+ <span className="text-[10px] text-muted-foreground">
141
+ {c.contributions} commit{c.contributions !== 1 ? "s" : ""}
142
+ </span>
143
+ </a>
144
+ ))}
145
+ </div>
146
+ )}
147
+ </div>
148
+
149
+ {/* Footer CTA */}
150
+ <div className="flex items-center justify-between px-6 py-4 border-t border-border/50 bg-muted/20 flex-shrink-0">
151
+ <p className="text-xs text-muted-foreground">
152
+ Want to see your name here?{" "}
153
+ <a
154
+ href={`https://github.com/${REPO}/issues?q=label%3A%22good+first+issue%22`}
155
+ target="_blank"
156
+ rel="noopener noreferrer"
157
+ className="text-primary underline underline-offset-2 hover:no-underline"
158
+ >
159
+ Pick an issue
160
+ </a>{" "}
161
+ and open a PR to <code className="text-[10px] bg-muted px-1 py-0.5 rounded">dev</code>!
162
+ </p>
163
+ <a
164
+ href={`https://github.com/${REPO}`}
165
+ target="_blank"
166
+ rel="noopener noreferrer"
167
+ >
168
+ <Button variant="outline" size="sm" className="h-7 text-xs gap-1.5">
169
+ <Github className="w-3.5 h-3.5" />
170
+ View on GitHub
171
+ <ExternalLink className="w-3 h-3" />
172
+ </Button>
173
+ </a>
174
+ </div>
175
+ </div>
176
+ </div>
177
+ );
178
+ }
frontend/src/components/layout/Header.tsx CHANGED
@@ -20,6 +20,7 @@ import {
20
  LogOut,
21
  Moon,
22
  Sun,
 
23
  } from "lucide-react";
24
  import { useState } from "react";
25
 
@@ -28,9 +29,10 @@ interface HeaderProps {
28
  onToggleSidebar: () => void;
29
  viewerOpen: boolean;
30
  onToggleViewer: () => void;
 
31
  }
32
 
33
- export default function Header({ sidebarOpen, onToggleSidebar, viewerOpen, onToggleViewer }: HeaderProps) {
34
  const { user, logout } = useAuth();
35
  const router = useRouter();
36
  const [isDark, setIsDark] = useState(true);
@@ -70,6 +72,22 @@ export default function Header({ sidebarOpen, onToggleSidebar, viewerOpen, onTog
70
 
71
  {/* Right */}
72
  <div className="flex items-center gap-2">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleViewer} title={viewerOpen ? "Close viewer" : "Open viewer"}>
74
  {viewerOpen ? <PanelRightClose className="w-4 h-4" /> : <PanelRightOpen className="w-4 h-4" />}
75
  </Button>
 
20
  LogOut,
21
  Moon,
22
  Sun,
23
+ Trophy,
24
  } from "lucide-react";
25
  import { useState } from "react";
26
 
 
29
  onToggleSidebar: () => void;
30
  viewerOpen: boolean;
31
  onToggleViewer: () => void;
32
+ onOpenContributors: () => void;
33
  }
34
 
35
+ export default function Header({ sidebarOpen, onToggleSidebar, viewerOpen, onToggleViewer, onOpenContributors }: HeaderProps) {
36
  const { user, logout } = useAuth();
37
  const router = useRouter();
38
  const [isDark, setIsDark] = useState(true);
 
72
 
73
  {/* Right */}
74
  <div className="flex items-center gap-2">
75
+ {/* Hall of Fame */}
76
+ <Button
77
+ variant="ghost"
78
+ size="sm"
79
+ className="h-8 gap-1.5 px-2.5 text-xs hidden sm:flex hover:text-primary"
80
+ onClick={onOpenContributors}
81
+ title="Hall of Fame — Contributors"
82
+ >
83
+ <Trophy className="w-3.5 h-3.5 text-yellow-500" />
84
+ <span>Hall of Fame</span>
85
+ </Button>
86
+ {/* Mobile icon only */}
87
+ <Button variant="ghost" size="icon" className="h-8 w-8 sm:hidden" onClick={onOpenContributors} title="Hall of Fame">
88
+ <Trophy className="w-4 h-4 text-yellow-500" />
89
+ </Button>
90
+
91
  <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleViewer} title={viewerOpen ? "Close viewer" : "Open viewer"}>
92
  {viewerOpen ? <PanelRightClose className="w-4 h-4" /> : <PanelRightOpen className="w-4 h-4" />}
93
  </Button>
frontend/src/components/layout/OpenSourceBadge.tsx ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { Github, Star, GitFork, Heart, Trophy } from "lucide-react";
5
+
6
+ interface Props {
7
+ onOpenHallOfFame: () => void;
8
+ }
9
+
10
+ export default function OpenSourceBadge({ onOpenHallOfFame }: Props) {
11
+ const [stars, setStars] = useState<number | null>(null);
12
+ const [expanded, setExpanded] = useState(false);
13
+ const [hasAnimated, setHasAnimated] = useState(false);
14
+
15
+ useEffect(() => {
16
+ fetch("https://api.github.com/repos/param20h/PDF-Assistant-RAG")
17
+ .then((r) => r.json())
18
+ .then((d) => setStars(d.stargazers_count ?? null))
19
+ .catch(() => {});
20
+
21
+ // Pulse animation once on mount to draw attention
22
+ const t = setTimeout(() => setHasAnimated(true), 2000);
23
+ return () => clearTimeout(t);
24
+ }, []);
25
+
26
+ return (
27
+ <div className="fixed bottom-5 right-5 z-40 flex flex-col items-end gap-2">
28
+
29
+ {/* Expanded card */}
30
+ {expanded && (
31
+ <div className="mb-1 w-64 rounded-2xl border border-border/60 bg-card/95 backdrop-blur-md shadow-2xl overflow-hidden animate-fade-in-up">
32
+ {/* Top section */}
33
+ <div className="px-4 pt-4 pb-3 border-b border-border/40">
34
+ <div className="flex items-center gap-2 mb-1">
35
+ <Github className="w-4 h-4" />
36
+ <span className="text-sm font-semibold">Open Source</span>
37
+ <span className="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 font-medium border border-green-500/20">
38
+ GSSOC
39
+ </span>
40
+ </div>
41
+ <p className="text-[11px] text-muted-foreground leading-relaxed">
42
+ Built in public with ❤️ by the community. Free forever.
43
+ </p>
44
+ </div>
45
+
46
+ {/* Actions */}
47
+ <div className="p-3 flex flex-col gap-2">
48
+ <a
49
+ href="https://github.com/param20h/PDF-Assistant-RAG"
50
+ target="_blank"
51
+ rel="noopener noreferrer"
52
+ className="flex items-center justify-between px-3 py-2 rounded-lg bg-muted/50 hover:bg-accent transition-colors group"
53
+ >
54
+ <div className="flex items-center gap-2 text-xs">
55
+ <Star className="w-3.5 h-3.5 text-yellow-500" />
56
+ <span>Star on GitHub</span>
57
+ </div>
58
+ {stars !== null && (
59
+ <span className="text-xs font-bold text-yellow-500">{stars} ⭐</span>
60
+ )}
61
+ </a>
62
+
63
+ <a
64
+ href="https://github.com/param20h/PDF-Assistant-RAG/fork"
65
+ target="_blank"
66
+ rel="noopener noreferrer"
67
+ className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/50 hover:bg-accent transition-colors text-xs"
68
+ >
69
+ <GitFork className="w-3.5 h-3.5 text-primary" />
70
+ <span>Fork & Contribute</span>
71
+ </a>
72
+
73
+ <button
74
+ onClick={() => { setExpanded(false); onOpenHallOfFame(); }}
75
+ className="flex items-center gap-2 px-3 py-2 rounded-lg bg-primary/10 hover:bg-primary/20 transition-colors text-xs text-primary border border-primary/20 cursor-pointer w-full"
76
+ >
77
+ <Trophy className="w-3.5 h-3.5" />
78
+ <span>Hall of Fame 🏆</span>
79
+ </button>
80
+ </div>
81
+
82
+ {/* Heart footer */}
83
+ <div className="px-4 pb-3 flex items-center gap-1.5 text-[10px] text-muted-foreground">
84
+ <Heart className="w-3 h-3 text-red-400 fill-red-400" />
85
+ <span>Made with contributors worldwide</span>
86
+ </div>
87
+ </div>
88
+ )}
89
+
90
+ {/* FAB trigger button */}
91
+ <button
92
+ onClick={() => setExpanded(!expanded)}
93
+ className={`
94
+ group flex items-center gap-2 px-3 py-2 rounded-full
95
+ border border-border/60 bg-card/90 backdrop-blur-md shadow-lg
96
+ hover:border-primary/50 hover:shadow-primary/10 hover:shadow-xl
97
+ transition-all duration-300 cursor-pointer
98
+ ${!hasAnimated ? "animate-pulse-glow" : ""}
99
+ `}
100
+ title="Open Source — Support this project"
101
+ >
102
+ <Github className="w-4 h-4 group-hover:text-primary transition-colors" />
103
+ <span className="text-xs font-medium hidden sm:inline">Open Source</span>
104
+ {stars !== null && (
105
+ <span className="flex items-center gap-0.5 text-[11px] font-semibold text-yellow-500">
106
+ <Star className="w-3 h-3 fill-yellow-500" />
107
+ {stars >= 1000 ? `${(stars / 1000).toFixed(1)}k` : stars}
108
+ </span>
109
+ )}
110
+ </button>
111
+ </div>
112
+ );
113
+ }
frontend/src/components/ui/textarea.tsx CHANGED
@@ -2,9 +2,13 @@ import * as React from "react"
2
 
3
  import { cn } from "@/lib/utils"
4
 
5
- function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
 
 
 
6
  return (
7
  <textarea
 
8
  data-slot="textarea"
9
  className={cn(
10
  "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
@@ -13,6 +17,8 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
13
  {...props}
14
  />
15
  )
16
- }
 
 
17
 
18
  export { Textarea }
 
2
 
3
  import { cn } from "@/lib/utils"
4
 
5
+ const Textarea = React.forwardRef<
6
+ HTMLTextAreaElement,
7
+ React.ComponentProps<"textarea">
8
+ >(({ className, ...props }, ref) => {
9
  return (
10
  <textarea
11
+ ref={ref}
12
  data-slot="textarea"
13
  className={cn(
14
  "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
 
17
  {...props}
18
  />
19
  )
20
+ })
21
+
22
+ Textarea.displayName = "Textarea"
23
 
24
  export { Textarea }
frontend/src/lib/api.ts CHANGED
@@ -4,6 +4,8 @@
4
  */
5
 
6
  const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
 
 
7
 
8
  interface FetchOptions extends RequestInit {
9
  token?: string;
@@ -34,23 +36,71 @@ class ApiClient {
34
  return headers;
35
  }
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  async get<T>(path: string, options?: FetchOptions): Promise<T> {
38
- const res = await fetch(`${this.baseUrl}${path}`, {
39
  method: "GET",
40
  headers: this.getHeaders(options?.token),
41
  ...options,
42
  });
43
 
44
  if (!res.ok) {
45
- const error = await res.json().catch(() => ({ detail: res.statusText }));
46
- throw new Error(error.detail || "Request failed");
47
  }
48
 
49
  return res.json();
50
  }
51
 
52
  async post<T>(path: string, body?: unknown, options?: FetchOptions): Promise<T> {
53
- const res = await fetch(`${this.baseUrl}${path}`, {
54
  method: "POST",
55
  headers: this.getHeaders(options?.token),
56
  body: body ? JSON.stringify(body) : undefined,
@@ -58,8 +108,7 @@ class ApiClient {
58
  });
59
 
60
  if (!res.ok) {
61
- const error = await res.json().catch(() => ({ detail: res.statusText }));
62
- throw new Error(error.detail || "Request failed");
63
  }
64
 
65
  return res.json();
@@ -73,7 +122,7 @@ class ApiClient {
73
  }
74
  // Don't set Content-Type — browser sets multipart boundary automatically
75
 
76
- const res = await fetch(`${this.baseUrl}${path}`, {
77
  method: "POST",
78
  headers,
79
  body: formData,
@@ -81,23 +130,21 @@ class ApiClient {
81
  });
82
 
83
  if (!res.ok) {
84
- const error = await res.json().catch(() => ({ detail: res.statusText }));
85
- throw new Error(error.detail || "Upload failed");
86
  }
87
 
88
  return res.json();
89
  }
90
 
91
  async delete<T>(path: string, options?: FetchOptions): Promise<T> {
92
- const res = await fetch(`${this.baseUrl}${path}`, {
93
  method: "DELETE",
94
  headers: this.getHeaders(options?.token),
95
  ...options,
96
  });
97
 
98
  if (!res.ok) {
99
- const error = await res.json().catch(() => ({ detail: res.statusText }));
100
- throw new Error(error.detail || "Delete failed");
101
  }
102
 
103
  return res.json();
@@ -108,15 +155,14 @@ class ApiClient {
108
  * Yields parsed SSE data objects.
109
  */
110
  async *streamPost(path: string, body: unknown): AsyncGenerator<{ type: string; data?: unknown }> {
111
- const res = await fetch(`${this.baseUrl}${path}`, {
112
  method: "POST",
113
  headers: this.getHeaders(),
114
  body: JSON.stringify(body),
115
  });
116
 
117
  if (!res.ok) {
118
- const error = await res.json().catch(() => ({ detail: res.statusText }));
119
- throw new Error(error.detail || "Stream request failed");
120
  }
121
 
122
  const reader = res.body?.getReader();
@@ -153,4 +199,4 @@ class ApiClient {
153
  }
154
 
155
  export const api = new ApiClient(API_BASE);
156
- export { API_BASE };
 
4
  */
5
 
6
  const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
7
+ const CONNECTION_ERROR_MESSAGE = "Could not connect to the server. Please try again later.";
8
+ const CONNECTION_ERROR_BANNER_MESSAGE = `⚠️ ${CONNECTION_ERROR_MESSAGE}`;
9
 
10
  interface FetchOptions extends RequestInit {
11
  token?: string;
 
36
  return headers;
37
  }
38
 
39
+ private async fetchWithConnectionError(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
40
+ try {
41
+ return await fetch(input, init);
42
+ } catch (error) {
43
+ if (error instanceof TypeError) {
44
+ throw new Error(CONNECTION_ERROR_MESSAGE);
45
+ }
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ private getPayloadMessage(payload: unknown): string | null {
51
+ if (typeof payload === "string" && payload.trim()) {
52
+ return payload;
53
+ }
54
+
55
+ if (Array.isArray(payload)) {
56
+ const messages = payload
57
+ .map((item) => {
58
+ if (typeof item === "string") return item;
59
+ if (item && typeof item === "object" && "msg" in item && typeof item.msg === "string") {
60
+ return item.msg;
61
+ }
62
+ return null;
63
+ })
64
+ .filter((message): message is string => Boolean(message));
65
+
66
+ return messages.length > 0 ? messages.join(", ") : null;
67
+ }
68
+
69
+ return null;
70
+ }
71
+
72
+ private async getErrorMessage(res: Response, fallback: string): Promise<string> {
73
+ const payload = await res.json().catch(() => null);
74
+
75
+ if (payload && typeof payload === "object") {
76
+ const errorPayload = payload as { detail?: unknown; error?: unknown; message?: unknown };
77
+ return (
78
+ this.getPayloadMessage(errorPayload.detail) ||
79
+ this.getPayloadMessage(errorPayload.message) ||
80
+ this.getPayloadMessage(errorPayload.error) ||
81
+ fallback
82
+ );
83
+ }
84
+
85
+ return this.getPayloadMessage(payload) || fallback;
86
+ }
87
+
88
  async get<T>(path: string, options?: FetchOptions): Promise<T> {
89
+ const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
90
  method: "GET",
91
  headers: this.getHeaders(options?.token),
92
  ...options,
93
  });
94
 
95
  if (!res.ok) {
96
+ throw new Error(await this.getErrorMessage(res, res.statusText || "Request failed"));
 
97
  }
98
 
99
  return res.json();
100
  }
101
 
102
  async post<T>(path: string, body?: unknown, options?: FetchOptions): Promise<T> {
103
+ const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
104
  method: "POST",
105
  headers: this.getHeaders(options?.token),
106
  body: body ? JSON.stringify(body) : undefined,
 
108
  });
109
 
110
  if (!res.ok) {
111
+ throw new Error(await this.getErrorMessage(res, res.statusText || "Request failed"));
 
112
  }
113
 
114
  return res.json();
 
122
  }
123
  // Don't set Content-Type — browser sets multipart boundary automatically
124
 
125
+ const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
126
  method: "POST",
127
  headers,
128
  body: formData,
 
130
  });
131
 
132
  if (!res.ok) {
133
+ throw new Error(await this.getErrorMessage(res, res.statusText || "Upload failed"));
 
134
  }
135
 
136
  return res.json();
137
  }
138
 
139
  async delete<T>(path: string, options?: FetchOptions): Promise<T> {
140
+ const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
141
  method: "DELETE",
142
  headers: this.getHeaders(options?.token),
143
  ...options,
144
  });
145
 
146
  if (!res.ok) {
147
+ throw new Error(await this.getErrorMessage(res, res.statusText || "Delete failed"));
 
148
  }
149
 
150
  return res.json();
 
155
  * Yields parsed SSE data objects.
156
  */
157
  async *streamPost(path: string, body: unknown): AsyncGenerator<{ type: string; data?: unknown }> {
158
+ const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
159
  method: "POST",
160
  headers: this.getHeaders(),
161
  body: JSON.stringify(body),
162
  });
163
 
164
  if (!res.ok) {
165
+ throw new Error(await this.getErrorMessage(res, res.statusText || "Stream request failed"));
 
166
  }
167
 
168
  const reader = res.body?.getReader();
 
199
  }
200
 
201
  export const api = new ApiClient(API_BASE);
202
+ export { API_BASE, CONNECTION_ERROR_BANNER_MESSAGE, CONNECTION_ERROR_MESSAGE };