Spaces:
Running
Running
Merge branch 'dev' into docs/edit-.env.example
Browse files- README.md +60 -3
- backend/app/auth.py +18 -4
- backend/app/config.py +3 -2
- backend/app/routes/auth.py +37 -6
- backend/app/routes/documents.py +6 -2
- backend/app/schemas.py +5 -0
- frontend/src/app/dashboard/page.tsx +19 -3
- frontend/src/components/document/DocumentSidebar.tsx +28 -17
- frontend/src/lib/api.ts +62 -16
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">
|
|
@@ -503,4 +560,4 @@ Distributed under the **MIT License**. See [`LICENSE`](license) for more informa
|
|
| 503 |
|
| 504 |
**[⬆ Back to top](#)**
|
| 505 |
|
| 506 |
-
</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">
|
|
|
|
| 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
|
| 34 |
-
"""Create a JWT token with user_id as the subject."""
|
| 35 |
payload = {
|
| 36 |
"sub": user_id,
|
| 37 |
-
"
|
|
|
|
| 38 |
"iat": datetime.now(timezone.utc),
|
| 39 |
}
|
| 40 |
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
| 41 |
|
| 42 |
|
| 43 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
@@ -20,11 +20,12 @@ class Settings(BaseSettings):
|
|
| 20 |
|
| 21 |
# ── Auth ─────────────────────────────────────────────
|
| 22 |
JWT_ALGORITHM: str = "HS256"
|
| 23 |
-
|
|
|
|
| 24 |
|
| 25 |
# ── File Upload ──────────────────────────────────────
|
| 26 |
UPLOAD_DIR: str = "./data/uploads"
|
| 27 |
-
|
| 28 |
ALLOWED_EXTENSIONS: set = {"pdf", "docx", "txt", "md"}
|
| 29 |
|
| 30 |
# ── RAG Pipeline ─────────────────────────────────────
|
|
|
|
| 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 ─────────────────────────────────────
|
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,
|
| 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 |
-
|
|
|
|
| 44 |
|
| 45 |
return TokenResponse(
|
| 46 |
-
access_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 |
-
|
|
|
|
| 63 |
|
| 64 |
return TokenResponse(
|
| 65 |
-
access_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.
|
|
|
|
| 112 |
raise HTTPException(
|
| 113 |
status_code=400,
|
| 114 |
-
detail=
|
|
|
|
|
|
|
|
|
|
| 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
|
frontend/src/app/dashboard/page.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 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";
|
|
@@ -29,6 +29,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 +41,14 @@ export default function DashboardPage() {
|
|
| 40 |
try {
|
| 41 |
const data = await api.get<{ documents: DocInfo[] }>("/api/v1/documents/");
|
| 42 |
setDocuments(data.documents);
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
}
|
| 46 |
}, []);
|
| 47 |
|
|
@@ -81,6 +88,15 @@ export default function DashboardPage() {
|
|
| 81 |
onToggleViewer={() => setViewerOpen(!viewerOpen)}
|
| 82 |
/>
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
<div className="flex-1 flex overflow-hidden">
|
| 85 |
{/* ── Left: Document Sidebar ──────────────── */}
|
| 86 |
{sidebarOpen && (
|
|
|
|
| 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";
|
|
|
|
| 29 |
const [pdfPage, setPdfPage] = useState(1);
|
| 30 |
const [sidebarOpen, setSidebarOpen] = useState(true);
|
| 31 |
const [viewerOpen, setViewerOpen] = useState(true);
|
| 32 |
+
const [connectionError, setConnectionError] = useState("");
|
| 33 |
|
| 34 |
// Auth guard
|
| 35 |
useEffect(() => {
|
|
|
|
| 41 |
try {
|
| 42 |
const data = await api.get<{ documents: DocInfo[] }>("/api/v1/documents/");
|
| 43 |
setDocuments(data.documents);
|
| 44 |
+
setConnectionError("");
|
| 45 |
+
} catch (err) {
|
| 46 |
+
const message = err instanceof Error ? err.message : CONNECTION_ERROR_MESSAGE;
|
| 47 |
+
setConnectionError(
|
| 48 |
+
message === CONNECTION_ERROR_MESSAGE
|
| 49 |
+
? CONNECTION_ERROR_BANNER_MESSAGE
|
| 50 |
+
: `⚠️ ${message}`
|
| 51 |
+
);
|
| 52 |
}
|
| 53 |
}, []);
|
| 54 |
|
|
|
|
| 88 |
onToggleViewer={() => setViewerOpen(!viewerOpen)}
|
| 89 |
/>
|
| 90 |
|
| 91 |
+
{connectionError && (
|
| 92 |
+
<div
|
| 93 |
+
role="alert"
|
| 94 |
+
className="border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive"
|
| 95 |
+
>
|
| 96 |
+
{connectionError}
|
| 97 |
+
</div>
|
| 98 |
+
)}
|
| 99 |
+
|
| 100 |
<div className="flex-1 flex overflow-hidden">
|
| 101 |
{/* ── Left: Document Sidebar ──────────────── */}
|
| 102 |
{sidebarOpen && (
|
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 |
-
|
| 29 |
if (acceptedFiles.length === 0) return;
|
| 30 |
-
setUploading(true);
|
| 31 |
-
setUploadProgress(0);
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 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/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
|
| 39 |
method: "GET",
|
| 40 |
headers: this.getHeaders(options?.token),
|
| 41 |
...options,
|
| 42 |
});
|
| 43 |
|
| 44 |
if (!res.ok) {
|
| 45 |
-
|
| 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
|
| 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 |
-
|
| 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
|
| 77 |
method: "POST",
|
| 78 |
headers,
|
| 79 |
body: formData,
|
|
@@ -81,23 +130,21 @@ class ApiClient {
|
|
| 81 |
});
|
| 82 |
|
| 83 |
if (!res.ok) {
|
| 84 |
-
|
| 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
|
| 93 |
method: "DELETE",
|
| 94 |
headers: this.getHeaders(options?.token),
|
| 95 |
...options,
|
| 96 |
});
|
| 97 |
|
| 98 |
if (!res.ok) {
|
| 99 |
-
|
| 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
|
| 112 |
method: "POST",
|
| 113 |
headers: this.getHeaders(),
|
| 114 |
body: JSON.stringify(body),
|
| 115 |
});
|
| 116 |
|
| 117 |
if (!res.ok) {
|
| 118 |
-
|
| 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 };
|