Yuvraj Sarathe commited on
Commit
b6cf317
·
unverified ·
2 Parent(s): 1a2f96588d1527

Merge branch 'dev' into docs/edit-.env.example

Browse files
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 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
@@ -20,11 +20,12 @@ class Settings(BaseSettings):
20
 
21
  # ── Auth ─────────────────────────────────────────────
22
  JWT_ALGORITHM: str = "HS256"
23
- JWT_EXPIRY_HOURS: int = 72
 
24
 
25
  # ── File Upload ──────────────────────────────────────
26
  UPLOAD_DIR: str = "./data/uploads"
27
- MAX_FILE_SIZE_MB: int = 50
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, 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
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
- } catch {
44
- // silently fail
 
 
 
 
 
 
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
- 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/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 };