Ash-211 commited on
Commit
2db8ee1
·
0 Parent(s):

feat: add frontend and backend code for multimodal RAG knowledge assistant

Browse files
.gitignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ *.sqlite3
7
+ .env
8
+ .venv/
9
+
10
+ # OS
11
+ .DS_Store
12
+ Thumbs.db
13
+
14
+ # Data (don't upload raw large files)
15
+ data/raw/*
16
+ data/processed/images/*
17
+ data/processed/text/*
18
+ data/processed/audio/*
19
+ data/users/
20
+ data/*.db
21
+
22
+ # Frontend
23
+ frontend/node_modules/
24
+ frontend/dist/
25
+ frontend/.vite/
26
+
27
+ # Model caches
28
+ *.pt
29
+ *.bin
backend/auth.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from datetime import datetime, timedelta
3
+ from typing import Optional
4
+ from jose import JWTError, jwt
5
+ from passlib.context import CryptContext
6
+ from fastapi import Depends, HTTPException, status
7
+ from fastapi.security import OAuth2PasswordBearer
8
+ from dotenv import load_dotenv
9
+ load_dotenv()
10
+
11
+ SECRET_KEY = os.getenv("ENCRYPTION_KEY")
12
+ ALGORITHM = "HS256"
13
+ ACCESS_TOKEN_EXPIRE_MINUTES = 30
14
+
15
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
16
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
17
+
18
+ def verify_password(plain_password, hashed_password):
19
+ return pwd_context.verify(plain_password, hashed_password)
20
+
21
+ def get_password_hash(password):
22
+ return pwd_context.hash(password)
23
+
24
+ def create_access_token(data: dict):
25
+ to_encode = data.copy()
26
+ expire = datetime.utcnow() + timedelta(minutes = ACCESS_TOKEN_EXPIRE_MINUTES)
27
+ to_encode.update({"exp": expire})
28
+
29
+ encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
30
+ return encoded_jwt
31
+
32
+ async def get_current_user(token: str = Depends(oauth2_scheme)):
33
+ credentials_exception = HTTPException(
34
+ status_code = status.HTTP_401_UNAUTHORIZED,
35
+ detail = "Could not validate credentials",
36
+ headers={"WWW-Authenticate": "Bearer"},
37
+ )
38
+ try:
39
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
40
+ username: str = payload.get("sub")
41
+ if username is None:
42
+ raise credentials_exception
43
+ except JWTError:
44
+ raise credentials_exception
45
+
46
+ return username
backend/ingest/audio_ingest.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import whisper
2
+
3
+ model = whisper.load_model("base")
4
+
5
+ def ingest_audio(audio_path):
6
+ result = model.transcribe(audio_path)
7
+ return result["text"]
backend/ingest/image_ingest.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image
2
+ import os
3
+ import shutil
4
+
5
+ def ingest_image(image_path, output_dir):
6
+ os.makedirs(output_dir, exist_ok=True)
7
+
8
+ img = Image.open(image_path).convert("RGB")
9
+
10
+ filename = os.path.basename(image_path)
11
+ save_path = os.path.join(output_dir, filename)
12
+
13
+ shutil.copy(image_path, save_path)
14
+
15
+ return save_path
backend/ingest/pdf_ingest.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fitz
2
+ import os
3
+
4
+ def ingest_pdf(pdf_paths, output_dir):
5
+
6
+ # 🔹 Allow both single path (string) and list of paths
7
+ if isinstance(pdf_paths, str):
8
+ pdf_paths = [pdf_paths]
9
+
10
+ all_text_chunks = []
11
+ all_extracted_images = []
12
+
13
+ os.makedirs(output_dir, exist_ok=True)
14
+
15
+ for pdf_path in pdf_paths:
16
+ try:
17
+ print("Processing PDF:", pdf_path)
18
+ doc = fitz.open(pdf_path)
19
+
20
+ image_count = 0
21
+ source_name = os.path.basename(pdf_path)
22
+
23
+ for page_num, page in enumerate(doc):
24
+
25
+ # ------------------
26
+ # Extract Text
27
+ # ------------------
28
+ text = page.get_text()
29
+
30
+ if text.strip():
31
+ all_text_chunks.append({
32
+ "page": page_num,
33
+ "text": text,
34
+ "source": source_name # important for multi-doc retrieval
35
+ })
36
+
37
+ # ------------------
38
+ # Extract Images
39
+ # ------------------
40
+ for img in page.get_images(full=True):
41
+ xref = img[0]
42
+ base_image = doc.extract_image(xref)
43
+ image_bytes = base_image["image"]
44
+ image_ext = base_image["ext"]
45
+
46
+ img_path = os.path.join(
47
+ output_dir,
48
+ f"{source_name}_page_{page_num}_img_{image_count}.{image_ext}"
49
+ )
50
+
51
+ with open(img_path, "wb") as f:
52
+ f.write(image_bytes)
53
+
54
+ all_extracted_images.append(img_path)
55
+ image_count += 1
56
+
57
+ except Exception as e:
58
+ print(f"Error processing {pdf_path}: {e}")
59
+
60
+ return all_text_chunks, all_extracted_images
backend/main.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import shutil
3
+ import glob
4
+ from fastapi import FastAPI, UploadFile, File, HTTPException, Depends
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from fastapi.staticfiles import StaticFiles
7
+ from pydantic import BaseModel
8
+ from typing import List
9
+ import sqlite3
10
+ from fastapi.security import OAuth2PasswordRequestForm
11
+ from auth import get_password_hash, verify_password, create_access_token, get_current_user
12
+ # Import our RAG components
13
+ # Ensure these imports match your actual file structure
14
+ from ingest.pdf_ingest import ingest_pdf
15
+ from vectorstore.document_index import DocumentIndex
16
+ from vectorstore.chunk_index import ChunkIndex
17
+ from vectorstore.image_store import ImageVectorStore
18
+ from vectorstore.index_manager import IndexManager
19
+ from rag.generator import generate_answer
20
+ from rag.reranker import rerank
21
+
22
+ DB_PATH = "../data/users.db"
23
+
24
+ def init_db():
25
+ os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
26
+ conn = sqlite3.connect(DB_PATH)
27
+ cursor = conn.cursor()
28
+ cursor.execute("""
29
+ CREATE TABLE IF NOT EXISTS users(
30
+ username TEXT PRIMARY KEY,
31
+ hashed_password TEXT NOT NULL
32
+ )
33
+ """)
34
+ conn.commit()
35
+ conn.close()
36
+ # --- Paths ---
37
+ # Assuming run from 'backend/' directory
38
+ DATA_DIR = "../data"
39
+ RAW_DIR = os.path.join(DATA_DIR, "raw")
40
+ PROCESSED_DIR = os.path.join(DATA_DIR, "processed", "images")
41
+ INDICES_DIR = os.path.join(DATA_DIR, "indices")
42
+
43
+ CHUNK_INDEX_PATH = os.path.join(INDICES_DIR, "chunks")
44
+ DOC_INDEX_PATH = os.path.join(INDICES_DIR, "docs")
45
+ IMAGE_INDEX_PATH = os.path.join(INDICES_DIR, "images")
46
+
47
+ # --- Global State ---
48
+ # Initialize generic stores
49
+ doc_index = DocumentIndex()
50
+ chunk_index = ChunkIndex()
51
+ image_store = ImageVectorStore()
52
+ # Manager will be bound on startup
53
+ index_manager = IndexManager(doc_index, chunk_index)
54
+
55
+ app = FastAPI()
56
+
57
+ # Enable CORS for Frontend (React default port is 5173)
58
+ app.add_middleware(
59
+ CORSMiddleware,
60
+ allow_origins=["http://localhost:5173"],
61
+ allow_credentials=True,
62
+ allow_methods=["*"],
63
+ allow_headers=["*"],
64
+ )
65
+
66
+ # Serve static images so frontend can display them via URL
67
+ os.makedirs(PROCESSED_DIR, exist_ok=True)
68
+ @app.get("/images/{username}/{filename}")
69
+ async def save_user_image(username: str, filename: str):
70
+ from fastapi.responses import FileResponse
71
+ image_path = os.path.join(DATA_DIR, "users", username, "processed", "images", filename)
72
+ if not os.path.exists(image_path):
73
+ raise HTTPException(status_code=404, detail="Image not found")
74
+
75
+ return FileResponse(image_path)
76
+
77
+ @app.on_event("startup")
78
+ async def startup_event():
79
+ init_db()
80
+ """Load indices from disk on startup if they exist."""
81
+ print("Checking for existing indices...")
82
+ if os.path.exists(CHUNK_INDEX_PATH) and os.path.exists(DOC_INDEX_PATH) and os.path.exists(IMAGE_INDEX_PATH):
83
+ try:
84
+ chunk_index.load_local(CHUNK_INDEX_PATH)
85
+ doc_index.load_local(DOC_INDEX_PATH)
86
+ image_store.load_local(IMAGE_INDEX_PATH)
87
+
88
+ # Re-bind manager with loaded indices
89
+ global index_manager
90
+ index_manager = IndexManager(doc_index, chunk_index)
91
+ print("indices loaded successfully!")
92
+ except Exception as e:
93
+ print(f"Failed to load indices: {e}")
94
+ else:
95
+ print("No indices found. System execution will rely on /ingest endpoint.")
96
+
97
+ @app.get("/")
98
+ def health_check():
99
+ return {"status": "ok", "message": "Multimodal RAG Backend Ready"}
100
+
101
+ class UserRegister(BaseModel):
102
+ username: str
103
+ password: str
104
+
105
+ @app.post("/register")
106
+ async def register(user: UserRegister):
107
+ conn = sqlite3.connect(DB_PATH)
108
+ cursor = conn.cursor()
109
+
110
+ cursor.execute("SELECT username FROM users WHERE username = ?", (user.username,))
111
+ if cursor.fetchone():
112
+ raise HTTPException(status_code=400, detail="Username already exists")
113
+
114
+ hashed = get_password_hash(user.password)
115
+ cursor.execute("INSERT INTO users (username, hashed_password) VALUES (?, ?)", (user.username, hashed))
116
+ conn.commit()
117
+ conn.close()
118
+ return {"message": "User registered!"}
119
+
120
+ @app.post("/token")
121
+ async def login(form_data: OAuth2PasswordRequestForm = Depends()):
122
+
123
+ conn = sqlite3.connect(DB_PATH)
124
+ cursor = conn.cursor()
125
+ cursor.execute("SELECT hashed_password FROM users WHERE username = ?", (form_data.username,))
126
+ result = cursor.fetchone()
127
+ conn.close()
128
+
129
+ if not result or not verify_password(form_data.password, result[0]):
130
+ raise HTTPException(status_code=401, detail="Invalid credentials")
131
+
132
+ access_token = create_access_token(data={"sub": form_data.username})
133
+ return {"access_token": access_token, "token_type": "bearer"}
134
+ @app.post("/ingest")
135
+ async def ingest_endpoint(file: UploadFile = File(...),
136
+ username: str = Depends(get_current_user)
137
+ ):
138
+ USER_DIR = os.path.join(DATA_DIR, "users", username)
139
+ USER_RAW_DIR = os.path.join(USER_DIR, "raw")
140
+ USER_PROCESSED_DIR = os.path.join(USER_DIR, "processed", "images")
141
+ USER_INDICES_DIR = os.path.join(USER_DIR, "indices")
142
+
143
+ USER_CHUNK_INDEX_PATH = os.path.join(USER_INDICES_DIR, "chunks")
144
+ USER_DOC_INDEX_PATH = os.path.join(USER_INDICES_DIR, "docs")
145
+ USER_IMAGE_INDEX_PATH = os.path.join(USER_INDICES_DIR, "images")
146
+
147
+ user_doc_index = DocumentIndex()
148
+ user_chunk_index = ChunkIndex()
149
+ user_image_store = ImageVectorStore()
150
+
151
+ # Load existing user data if available
152
+ if os.path.exists(USER_CHUNK_INDEX_PATH):
153
+ user_chunk_index.load_local(USER_CHUNK_INDEX_PATH)
154
+ user_doc_index.load_local(USER_DOC_INDEX_PATH)
155
+ user_image_store.load_local(USER_IMAGE_INDEX_PATH)
156
+
157
+ filename = file.filename
158
+ save_path = os.path.join(USER_RAW_DIR, filename)
159
+ os.makedirs(USER_RAW_DIR, exist_ok=True)
160
+
161
+ # Save Uploaded File
162
+ with open(save_path, "wb") as buffer:
163
+ shutil.copyfileobj(file.file, buffer)
164
+
165
+ print(f"[{username}] Ingesting {filename}...")
166
+ text_chunks, _ = ingest_pdf(save_path, USER_PROCESSED_DIR)
167
+
168
+ # --- Index Text ---
169
+ from collections import defaultdict
170
+ chunks_by_source = defaultdict(list)
171
+ for chunk in text_chunks:
172
+ chunks_by_source[chunk["source"]].append(chunk["text"])
173
+ for source, chunks in chunks_by_source.items():
174
+ slide_chunks = []
175
+ full_text = ""
176
+ for text in chunks:
177
+ text = text.strip()
178
+ if len(text) > 50:
179
+ full_text += text + "\n"
180
+ slide_chunks.append(text)
181
+
182
+ if slide_chunks:
183
+ user_doc_index.add_document(full_text, source)
184
+ user_chunk_index.add_chunks(source, slide_chunks)
185
+
186
+ # --- Index Images ---
187
+ all_images = glob.glob(os.path.join(USER_PROCESSED_DIR, "*.png"))
188
+ pdf_basename = os.path.basename(save_path)
189
+ new_images = [img for img in all_images if pdf_basename in os.path.basename(img)]
190
+
191
+ image_metadata = []
192
+ for p in new_images:
193
+ try:
194
+ parts = p.split("_page_")
195
+ page_num = int(parts[1].split("_img_")[0]) if len(parts) > 1 else 0
196
+ except:
197
+ page_num = 0
198
+ image_metadata.append({"image_path": p, "page": page_num})
199
+
200
+ if new_images:
201
+ user_image_store.add_images(new_images, image_metadata)
202
+ # --- Save Updates ---
203
+ user_chunk_index.save_local(USER_CHUNK_INDEX_PATH)
204
+ user_doc_index.save_local(USER_DOC_INDEX_PATH)
205
+ user_image_store.save_local(USER_IMAGE_INDEX_PATH)
206
+
207
+ return {
208
+ "message": f"Successfully ingested {filename}",
209
+ "chunks": len(text_chunks),
210
+ "images": len(new_images)
211
+ }
212
+
213
+ class QueryRequest(BaseModel):
214
+ query: str
215
+
216
+ @app.post("/chat")
217
+ async def chat_endpoint(
218
+ request: QueryRequest,
219
+ username: str = Depends(get_current_user) # <-- ADD THIS
220
+ ):
221
+ query = request.query
222
+
223
+ # User-specific directories
224
+ USER_DIR = os.path.join(DATA_DIR, "users", username)
225
+ USER_INDICES_DIR = os.path.join(USER_DIR, "indices")
226
+ USER_PROCESSED_DIR = os.path.join(USER_DIR, "processed", "images")
227
+
228
+ USER_CHUNK_INDEX_PATH = os.path.join(USER_INDICES_DIR, "chunks")
229
+ USER_DOC_INDEX_PATH = os.path.join(USER_INDICES_DIR, "docs")
230
+ USER_IMAGE_INDEX_PATH = os.path.join(USER_INDICES_DIR, "images")
231
+
232
+ # Load user's indices
233
+ user_doc_index = DocumentIndex()
234
+ user_chunk_index = ChunkIndex()
235
+ user_image_store = ImageVectorStore()
236
+
237
+ if not os.path.exists(USER_CHUNK_INDEX_PATH):
238
+ return {
239
+ "answer": "You haven't uploaded any documents yet. Please upload a PDF first.",
240
+ "sources": [],
241
+ "images": []
242
+ }
243
+
244
+ user_chunk_index.load_local(USER_CHUNK_INDEX_PATH)
245
+ user_doc_index.load_local(USER_DOC_INDEX_PATH)
246
+ user_image_store.load_local(USER_IMAGE_INDEX_PATH)
247
+
248
+ user_index_manager = IndexManager(user_doc_index, user_chunk_index)
249
+
250
+ # 1. Retrieve Text
251
+ retrieved_chunks = user_index_manager.retrieve(query)
252
+
253
+ # Deduplicate
254
+ unique_chunks = []
255
+ seen = set()
256
+ for r in retrieved_chunks:
257
+ if r["content"] not in seen:
258
+ unique_chunks.append(r)
259
+ seen.add(r["content"])
260
+
261
+ # 2. Rerank
262
+ ranked_chunks = rerank(query, unique_chunks, top_k=5)
263
+
264
+ # 3. Retrieve Images
265
+ image_results = user_image_store.search(query, k=4)
266
+
267
+ # 4. Generate Answer
268
+ try:
269
+ answer = generate_answer(query, ranked_chunks, image_results)
270
+ except Exception as e:
271
+ answer = f"Error generating answer: {e}"
272
+
273
+ # 5. Format Response for Frontend
274
+ # Convert local image paths to URLs
275
+ base_url = f"http://localhost:8000/images/{username}/"
276
+ frontend_images = []
277
+ for img in image_results:
278
+ fname = os.path.basename(img["image_path"])
279
+ frontend_images.append({
280
+ "url": base_url + fname,
281
+ "page": img.get("page", 0)
282
+ })
283
+
284
+ return {
285
+ "answer": answer,
286
+ "sources": ranked_chunks,
287
+ "images": frontend_images
288
+ }
backend/rag/generator.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dotenv import load_dotenv
3
+ import google.genai as genai
4
+ load_dotenv()
5
+
6
+ client = genai.Client(api_key=os.getenv("GEMINI_API_KEY"))
7
+ def decompose_query(user_query):
8
+ prompt = f"""
9
+ Break the following user question into smaller, independent search queries.
10
+
11
+ Question:
12
+ {user_query}
13
+
14
+ Return 2–4 focused sub-queries related to the input documents, if the query can be broken down, else return a singular query.
15
+ Do NOT answer the question.
16
+ Only return the search queries.
17
+ """
18
+
19
+ response = client.models.generate_content(
20
+ model="gemini-2.5-flash",
21
+ contents=prompt
22
+ )
23
+
24
+ return [q.strip("- ").strip() for q in response.text.split("\n") if q.strip()]
25
+
26
+ def generate_answer(query, text_contexts, image_contexts):
27
+ text_block = "\n\n".join(
28
+ [f"[Text Source {i+1}]\n{ctx['content']}"
29
+ for i, ctx in enumerate(text_contexts)]
30
+ )
31
+
32
+ image_block = "\n\n".join(
33
+ [f"[Image Source {i+1}] Page {ctx['page']} -> {ctx['image_path']}"
34
+ for i, ctx in enumerate(image_contexts)]
35
+ )
36
+
37
+ prompt = f"""
38
+ You are a multimodal AI Knowledge assistant. Use ONLY the provided context to answer the question. If the answer is not in the context, say you don't know.
39
+ Provide detailed, informative answers to the user to the best of your ability.
40
+
41
+ TEXT CONTEXT:
42
+ {text_block}
43
+
44
+ IMAGE CONTEXT:
45
+ {image_block}
46
+
47
+ QUESTION:
48
+ {query}
49
+
50
+ Provide a clear answer with references like:
51
+ (Text Source 1), (Image Source 2)
52
+ """
53
+ response = client.models.generate_content(
54
+ model = "gemini-2.5-flash",
55
+ contents=prompt
56
+ )
57
+ return response.text
backend/rag/reranker.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sentence_transformers import CrossEncoder
2
+
3
+ reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
4
+
5
+ def rerank(query, docs, top_k=5):
6
+ if not docs:
7
+ return []
8
+ pairs = [(query, doc['content']) for doc in docs]
9
+ scores = reranker.predict(pairs)
10
+
11
+ ranked = sorted(
12
+ zip(docs, scores),
13
+ key=lambda x: x[1],
14
+ reverse=True
15
+ )
16
+
17
+ return [doc for doc, score in ranked[:top_k]]
backend/test-rag.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from vectorstore.document_index import DocumentIndex
2
+ from vectorstore.chunk_index import ChunkIndex
3
+ from vectorstore.image_store import ImageVectorStore
4
+ from vectorstore.index_manager import IndexManager
5
+
6
+ from rag.generator import generate_answer, decompose_query
7
+ from rag.reranker import rerank
8
+
9
+ from ingest.pdf_ingest import ingest_pdf
10
+ from utils.text_utils import chunk_by_slide
11
+
12
+ import os
13
+ import glob
14
+
15
+
16
+
17
+
18
+ doc_index = DocumentIndex()
19
+ chunk_index = ChunkIndex()
20
+ image_store = ImageVectorStore()
21
+
22
+ index_manager = IndexManager(doc_index, chunk_index)
23
+
24
+
25
+ DATA_DIR = "../data"
26
+ INDICES_DIR = os.path.join(DATA_DIR, "indices")
27
+
28
+ CHUNK_INDEX_PATH = os.path.join(INDICES_DIR, "chunks")
29
+ DOC_INDEX_PATH = os.path.join(INDICES_DIR, "docs")
30
+ IMAGE_INDEX_PATH = os.path.join(INDICES_DIR, "images")
31
+
32
+ if os.path.exists(CHUNK_INDEX_PATH) and os.path.exists(DOC_INDEX_PATH) and os.path.exists(IMAGE_INDEX_PATH):
33
+ print("Loading indices from disk (skipping ingestion)...")
34
+
35
+ chunk_index.load_local(CHUNK_INDEX_PATH)
36
+ doc_index.load_local(DOC_INDEX_PATH)
37
+ image_store.load_local(IMAGE_INDEX_PATH)
38
+
39
+ index_manager = IndexManager(doc_index, chunk_index)
40
+ else:
41
+ print("Indices not found. Starting fresh ingesetion...")
42
+
43
+ pdf_list = [
44
+ "../data/raw/os.pdf",
45
+ "../data/raw/DEVOPS.pdf",
46
+ "../data/raw/DBMS_Notes.pdf"
47
+ ]
48
+
49
+ text_chunks, _ = ingest_pdf(pdf_list, "../data/processed/images")
50
+
51
+ from collections import defaultdict
52
+ chunks_by_source = defaultdict(list)
53
+
54
+ for chunk in text_chunks:
55
+ chunks_by_source[chunk["source"]].append(chunk["text"])
56
+
57
+ for source, chunks in chunks_by_source.items():
58
+ slide_chunks = []
59
+ full_text = ""
60
+ for text in chunks:
61
+ text = text.strip()
62
+ if len(text) > 50:
63
+ full_text += text + "\n"
64
+ slide_chunks.append(text)
65
+
66
+ if(slide_chunks):
67
+ doc_index.add_document(full_text, source)
68
+ chunk_index.add_chunks(source, slide_chunks)
69
+
70
+ image_paths = glob.glob("../data/processed/images/*.png")
71
+ image_metadata = []
72
+
73
+ for p in image_paths:
74
+ try:
75
+ parts = p.split("_page_")
76
+ page_num = int(parts[1].split("_img_")[0]) if len(parts) > 1 else 0
77
+ except:
78
+ page_num = 0
79
+ image_metadata.append({"image_path": p, "page": page_num})
80
+
81
+ image_store.add_images(image_paths[:20], image_metadata[:20])
82
+
83
+ print("Saving indices to disk...")
84
+ chunk_index.save_local(CHUNK_INDEX_PATH)
85
+ doc_index.save_local(DOC_INDEX_PATH)
86
+ image_store.save_local(IMAGE_INDEX_PATH)
87
+
88
+ print("System ready!")
89
+
90
+
91
+
92
+
93
+
94
+
95
+
96
+
97
+ query = input("Enter your question: ")
98
+
99
+ # Optional: decompose
100
+ # Optional: decompose
101
+ # Skip decomposition for speed
102
+ subqueries = [query]
103
+
104
+ all_chunks = []
105
+
106
+ for sq in subqueries:
107
+ retrieved_chunks = index_manager.retrieve(sq)
108
+ all_chunks.extend(retrieved_chunks)
109
+
110
+ # Deduplicate
111
+ # Deduplicate
112
+ unique = []
113
+ seen = set()
114
+
115
+ for r in all_chunks:
116
+ content_text = r["content"] # this should be string
117
+ if content_text not in seen:
118
+ unique.append(r)
119
+ seen.add(content_text)
120
+
121
+ print(f"\nFound {len(unique)} unique chunks.")
122
+ for i, r in enumerate(unique[:3]):
123
+ print(f"Chunk {i}: {r['content'][:100]}...")
124
+
125
+ text_results = rerank(query, unique, top_k=6)
126
+ print(f"Reranked to {len(text_results)} chunks.")
127
+
128
+ # Image retrieval stays global (can improve later)
129
+ image_results = image_store.search(query, k=6)
130
+
131
+ try:
132
+ answer = generate_answer(query, text_results, image_results)
133
+ print("\n=== FINAL ANSWER ===\n")
134
+ print(answer)
135
+ except Exception as e:
136
+ print(f"\nGeneration failed: {e}")
137
+ # Don't fail the script, just report error
138
+
backend/test.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from ingest.audio_ingest import ingest_audio
2
+ from ingest.pdf_ingest import ingest_pdf
3
+
4
+ print(ingest_pdf("../data/raw/os.pdf", "../data/processed/images"))
5
+ print(ingest_audio("../data/raw/Recording.mp3"))
backend/test_standalone_image.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from ingest.image_ingest import ingest_image
2
+ from PIL import Image
3
+
4
+ img_path = ingest_image(
5
+ "../data/raw/devops.png",
6
+ "../data/processed/images"
7
+ )
8
+
9
+ img = Image.open(img_path)
10
+ img.verify()
11
+
12
+ print(" Standalone image ingestion works")
13
+
backend/testv.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from vectorstore.text_store import TextVectorStore
2
+ from vectorstore.image_store import ImageVectorStore
3
+ import glob
4
+ image_paths = glob.glob("../data/processed/images/*.png")
5
+ # TEXT
6
+ text_store = TextVectorStore()
7
+ text_store.add_texts(
8
+ ["Git is a version control system", "Docker uses containers"],
9
+ [{"src": "pdf"}, {"src": "pdf"}]
10
+ )
11
+
12
+ print(text_store.search("What is git?"))
13
+
14
+ # IMAGE
15
+ image_store = ImageVectorStore()
16
+ image_store.add_images(
17
+ image_paths,
18
+ [{"page": i} for i in range(len(image_paths))]
19
+ )
20
+ print(image_store.search("types of operating systems"))
backend/utils/text_utils.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def chunk_by_slide(text):
2
+ # Split by newline and group logically
3
+ lines = text.split("\n")
4
+ chunks = []
5
+ current_chunk = ""
6
+
7
+ for line in lines:
8
+ line = line.strip()
9
+ if not line:
10
+ continue
11
+
12
+ # If line looks like a heading, start new chunk
13
+ if line.endswith("Structure") or line.endswith("Generation"):
14
+ if current_chunk:
15
+ chunks.append(current_chunk.strip())
16
+ current_chunk = line + "\n"
17
+ else:
18
+ current_chunk += line + " "
19
+
20
+ if current_chunk:
21
+ chunks.append(current_chunk.strip())
22
+
23
+ return chunks
backend/vectorstore/chunk_index.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import faiss
2
+ import numpy as np
3
+ from sentence_transformers import SentenceTransformer
4
+ import pickle
5
+ import os
6
+
7
+ class ChunkIndex:
8
+ def __init__(self):
9
+ self.model = SentenceTransformer("all-MiniLM-L6-v2")
10
+ # Global index for ALL chunks
11
+ self.index = None
12
+ self.chunks = [] # List of dicts: {"source": str, "text": str}
13
+
14
+ def add_chunks(self, source, chunks):
15
+ """
16
+ chunks: list of strings (the text segments)
17
+ source: filename or identifier
18
+ """
19
+ if not chunks:
20
+ return
21
+
22
+ # 1. Store metadata
23
+ for text in chunks:
24
+ self.chunks.append({
25
+ "source": source,
26
+ "text": text
27
+ })
28
+
29
+ # 2. Embed
30
+ embeddings = self.model.encode(chunks)
31
+ embeddings = np.array(embeddings)
32
+
33
+ # Ensure 2D
34
+ if embeddings.ndim == 1:
35
+ embeddings = embeddings.reshape(1, -1)
36
+
37
+ embeddings = embeddings.astype("float32")
38
+ faiss.normalize_L2(embeddings)
39
+
40
+ # 3. Add to FAISS index
41
+ if self.index is None:
42
+ self.index = faiss.IndexFlatIP(embeddings.shape[1])
43
+
44
+ self.index.add(embeddings)
45
+
46
+ def search(self, query, k=6):
47
+ if self.index is None or self.index.ntotal == 0:
48
+ return []
49
+
50
+ q_emb = self.model.encode([query])
51
+ q_emb = np.array(q_emb).astype("float32")
52
+ faiss.normalize_L2(q_emb)
53
+
54
+ k = min(k, self.index.ntotal)
55
+ scores, idxs = self.index.search(q_emb, k)
56
+
57
+ results = []
58
+ for i in idxs[0]:
59
+ if i < len(self.chunks):
60
+ item = self.chunks[i]
61
+ results.append({
62
+ "source": item["source"],
63
+ "content": item["text"]
64
+ })
65
+
66
+ return results
67
+
68
+ def save_local(self, folder_path):
69
+ os.makedirs(folder_path, exist_ok=True)
70
+
71
+ faiss.write_index(self.index, os.path.join(folder_path, "index.faiss"))
72
+
73
+ with open(os.path.join(folder_path, "chunks.pkl"), "wb") as f:
74
+ pickle.dump(self.chunks, f)
75
+
76
+ def load_local(self, folder_path):
77
+ self.index = faiss.read_index(os.path.join(folder_path, "index.faiss"))
78
+
79
+ with open(os.path.join(folder_path, "chunks.pkl"), "rb") as f:
80
+ self.chunks = pickle.load(f)
backend/vectorstore/document_index.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import faiss
2
+ import numpy as np
3
+ from sentence_transformers import SentenceTransformer
4
+ import os
5
+ import pickle
6
+
7
+ class DocumentIndex:
8
+ def __init__(self):
9
+ self.model = SentenceTransformer("all-MiniLM-L6-v2")
10
+ self.index = faiss.IndexFlatIP(384)
11
+ self.metadata = []
12
+
13
+ def add_document(self, full_text, source):
14
+ embedding = self.model.encode([full_text])
15
+ embedding = np.array(embedding).astype("float32")
16
+ faiss.normalize_L2(embedding)
17
+
18
+ self.index.add(embedding)
19
+
20
+ self.metadata.append({
21
+ "source": source,
22
+ "full_text": full_text
23
+ })
24
+
25
+ def search(self, query, k=2):
26
+ q_emb = self.model.encode([query])
27
+ q_emb = np.array(q_emb).astype("float32")
28
+ faiss.normalize_L2(q_emb)
29
+
30
+ scores, idxs = self.index.search(q_emb, k)
31
+
32
+ return [self.metadata[i] for i in idxs[0]]
33
+
34
+ def save_local(self, folder_path):
35
+ os.makedirs(folder_path, exist_ok=True)
36
+
37
+ faiss.write_index(self.index, os.path.join(folder_path, "index.faiss"))
38
+
39
+ with open(os.path.join(folder_path, "metadata.pkl"), "wb") as f:
40
+ pickle.dump(self.metadata, f)
41
+
42
+ def load_local(self, folder_path):
43
+ self.index = faiss.read_index(os.path.join(folder_path, "index.faiss"))
44
+
45
+ with open(os.path.join(folder_path, "metadata.pkl"), "rb") as f:
46
+ self.metadata = pickle.load(f)
backend/vectorstore/image_store.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import clip
2
+ import torch
3
+ import faiss
4
+ import numpy as np
5
+ from PIL import Image
6
+ import os
7
+ import pickle
8
+ class ImageVectorStore:
9
+ def __init__(self):
10
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
11
+ self.model, self.preprocess = clip.load("ViT-B/32", device=self.device)
12
+ self.index = faiss.IndexFlatL2(512) # 512 is the dimension of the embeddings from the model
13
+ self.metadata = []
14
+
15
+ def add_images(self, image_paths, metadatas):
16
+ images = [
17
+ self.preprocess(Image.open(p)).unsqueeze(0)
18
+ for p in image_paths
19
+ ]
20
+ images = torch.cat(images).to(self.device)
21
+
22
+ with torch.no_grad():
23
+ emb = self.model.encode_image(images)
24
+
25
+ self.index.add(emb.cpu().numpy().astype("float32"))
26
+
27
+ for path, meta in zip(image_paths, metadatas):
28
+ enriched_meta = meta.copy()
29
+ enriched_meta["image_path"] = path
30
+ self.metadata.append(enriched_meta)
31
+
32
+ def search(self, query_text, k=5):
33
+ text_tokens = clip.tokenize([query_text]).to(self.device)
34
+ with torch.no_grad():
35
+ q_emb = self.model.encode_text(text_tokens)
36
+
37
+ _, idxs = self.index.search(q_emb.cpu().numpy().astype("float32"), k)
38
+ return [self.metadata[i] for i in idxs[0]]
39
+
40
+ def save_local(self, folder_path):
41
+ os.makedirs(folder_path, exist_ok=True)
42
+
43
+ faiss.write_index(self.index, os.path.join(folder_path, "index.faiss"))
44
+
45
+ with open(os.path.join(folder_path, "metadata.pkl"), "wb") as f:
46
+ pickle.dump(self.metadata, f)
47
+
48
+ def load_local(self, folder_path):
49
+ self.index = faiss.read_index(os.path.join(folder_path, "index.faiss"))
50
+
51
+ with open(os.path.join(folder_path, "metadata.pkl"), "rb") as f:
52
+ self.metadata = pickle.load(f)
backend/vectorstore/index_manager.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ class IndexManager:
2
+ def __init__(self, doc_index, chunk_index):
3
+ self.doc_index = doc_index
4
+ self.chunk_index = chunk_index
5
+
6
+ def retrieve(self, query):
7
+
8
+ # Search global chunk index directly
9
+ all_chunks = self.chunk_index.search(query, k=10)
10
+ return all_chunks
backend/vectorstore/text_store.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sentence_transformers import SentenceTransformer
2
+ import faiss
3
+ import numpy as np
4
+
5
+ class TextVectorStore:
6
+ def __init__(self):
7
+ self.model = SentenceTransformer('all-MiniLM-L6-v2')
8
+ self.index = faiss.IndexFlatIP(384) # 384 is the dimension of the embeddings from the model
9
+ self.metadata = []
10
+
11
+ def add_texts(self, texts, metadatas=None):
12
+ embeddings = self.model.encode(texts)
13
+ embeddings = np.array(embeddings).astype("float32")
14
+ faiss.normalize_L2(embeddings)
15
+ self.index.add(embeddings)
16
+
17
+ for text, meta in zip(texts, metadatas):
18
+ enriched_meta = meta.copy()
19
+ enriched_meta["content"] = text
20
+ self.metadata.append(enriched_meta)
21
+
22
+ def search(self, query, k=5):
23
+ q_emb = self.model.encode([query])
24
+ q_emb = np.array(q_emb).astype("float32")
25
+ faiss.normalize_L2(q_emb)
26
+ _, idxs = self.index.search(q_emb.astype("float32"), k)
27
+ return [self.metadata[i] for i in idxs[0]]
28
+
29
+
frontend/index.html ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <meta name="description" content="RAG Knowledge Assistant - AI-powered document Q&A with source citations" />
8
+ <title>RAG Knowledge Assistant</title>
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
12
+ </head>
13
+ <body>
14
+ <div id="root"></div>
15
+ <script type="module" src="/src/main.jsx"></script>
16
+ </body>
17
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "rag-frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "react": "^18.3.1",
14
+ "react-dom": "^18.3.1",
15
+ "react-markdown": "^9.0.1"
16
+ },
17
+ "devDependencies": {
18
+ "@types/react": "^18.3.12",
19
+ "@types/react-dom": "^18.3.1",
20
+ "@vitejs/plugin-react": "^4.3.3",
21
+ "vite": "^5.4.10"
22
+ }
23
+ }
frontend/public/vite.svg ADDED
frontend/src/App.jsx ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef } from 'react'
2
+ import TopNav from './components/TopNav'
3
+ import Sidebar from './components/Sidebar'
4
+ import ChatArea from './components/ChatArea'
5
+
6
+ const API_BASE = 'http://localhost:8000'
7
+
8
+ function App() {
9
+ const [sidebarOpen, setSidebarOpen] = useState(true)
10
+ const [documents, setDocuments] = useState([])
11
+ const [messages, setMessages] = useState([])
12
+ const [isLoading, setIsLoading] = useState(false)
13
+ const [error, setError] = useState(null)
14
+ const [token, setToken] = useState(() => localStorage.getItem('token'))
15
+ const [user, setUser] = useState(null)
16
+
17
+ // Auth state
18
+ const isAuthenticated = !!token
19
+
20
+ // Fetch documents on mount
21
+ useEffect(() => {
22
+ if (isAuthenticated) {
23
+ // Load messages from localStorage if available
24
+ const saved = localStorage.getItem('chat_messages')
25
+ if (saved) {
26
+ try {
27
+ setMessages(JSON.parse(saved))
28
+ } catch (e) {
29
+ console.error('Failed to parse saved messages')
30
+ }
31
+ }
32
+ }
33
+ }, [isAuthenticated])
34
+
35
+ // Save messages to localStorage
36
+ useEffect(() => {
37
+ if (messages.length > 0) {
38
+ localStorage.setItem('chat_messages', JSON.stringify(messages))
39
+ }
40
+ }, [messages])
41
+
42
+ const handleLogin = async (username, password) => {
43
+ try {
44
+ setError(null)
45
+ const formData = new FormData()
46
+ formData.append('username', username)
47
+ formData.append('password', password)
48
+
49
+ const res = await fetch(`${API_BASE}/token`, {
50
+ method: 'POST',
51
+ body: formData,
52
+ })
53
+
54
+ if (!res.ok) {
55
+ const data = await res.json()
56
+ throw new Error(data.detail || 'Login failed')
57
+ }
58
+
59
+ const data = await res.json()
60
+ localStorage.setItem('token', data.access_token)
61
+ setToken(data.access_token)
62
+ setUser(username)
63
+ } catch (err) {
64
+ setError(err.message)
65
+ throw err
66
+ }
67
+ }
68
+
69
+ const handleRegister = async (username, password) => {
70
+ try {
71
+ setError(null)
72
+ const res = await fetch(`${API_BASE}/register`, {
73
+ method: 'POST',
74
+ headers: { 'Content-Type': 'application/json' },
75
+ body: JSON.stringify({ username, password }),
76
+ })
77
+
78
+ if (!res.ok) {
79
+ const data = await res.json()
80
+ throw new Error(data.detail || 'Registration failed')
81
+ }
82
+
83
+ // Auto-login after registration
84
+ await handleLogin(username, password)
85
+ } catch (err) {
86
+ setError(err.message)
87
+ throw err
88
+ }
89
+ }
90
+
91
+ const handleLogout = () => {
92
+ localStorage.removeItem('token')
93
+ localStorage.removeItem('chat_messages')
94
+ setToken(null)
95
+ setUser(null)
96
+ setMessages([])
97
+ setDocuments([])
98
+ }
99
+
100
+ const handleUpload = async (file) => {
101
+ try {
102
+ setError(null)
103
+ const formData = new FormData()
104
+ formData.append('file', file)
105
+
106
+ // Add optimistic document
107
+ const tempDoc = {
108
+ id: Date.now(),
109
+ name: file.name,
110
+ status: 'uploading',
111
+ progress: 0,
112
+ }
113
+ setDocuments(prev => [...prev, tempDoc])
114
+
115
+ const res = await fetch(`${API_BASE}/ingest`, {
116
+ method: 'POST',
117
+ headers: {
118
+ Authorization: `Bearer ${token}`,
119
+ },
120
+ body: formData,
121
+ })
122
+
123
+ if (!res.ok) {
124
+ throw new Error('Upload failed')
125
+ }
126
+
127
+ const data = await res.json()
128
+
129
+ // Update document status
130
+ setDocuments(prev =>
131
+ prev.map(doc =>
132
+ doc.id === tempDoc.id
133
+ ? { ...doc, status: 'ready', chunks: data.chunks, images: data.images }
134
+ : doc
135
+ )
136
+ )
137
+
138
+ return data
139
+ } catch (err) {
140
+ setError(err.message)
141
+ // Update document status to error
142
+ setDocuments(prev =>
143
+ prev.map(doc =>
144
+ doc.status === 'uploading' ? { ...doc, status: 'error' } : doc
145
+ )
146
+ )
147
+ throw err
148
+ }
149
+ }
150
+
151
+ const handleSendMessage = async (content) => {
152
+ if (!content.trim() || isLoading) return
153
+
154
+ const userMessage = {
155
+ id: Date.now(),
156
+ role: 'user',
157
+ content: content.trim(),
158
+ timestamp: new Date().toISOString(),
159
+ }
160
+
161
+ setMessages(prev => [...prev, userMessage])
162
+ setIsLoading(true)
163
+ setError(null)
164
+
165
+ try {
166
+ const res = await fetch(`${API_BASE}/chat`, {
167
+ method: 'POST',
168
+ headers: {
169
+ 'Content-Type': 'application/json',
170
+ Authorization: `Bearer ${token}`,
171
+ },
172
+ body: JSON.stringify({ query: content.trim() }),
173
+ })
174
+
175
+ if (!res.ok) {
176
+ throw new Error('Failed to get response')
177
+ }
178
+
179
+ const data = await res.json()
180
+
181
+ const aiMessage = {
182
+ id: Date.now() + 1,
183
+ role: 'assistant',
184
+ content: data.answer,
185
+ sources: data.sources || [],
186
+ images: data.images || [],
187
+ timestamp: new Date().toISOString(),
188
+ }
189
+
190
+ setMessages(prev => [...prev, aiMessage])
191
+ } catch (err) {
192
+ setError(err.message)
193
+ // Add error message
194
+ setMessages(prev => [
195
+ ...prev,
196
+ {
197
+ id: Date.now() + 1,
198
+ role: 'error',
199
+ content: 'Failed to get response. Please try again.',
200
+ timestamp: new Date().toISOString(),
201
+ },
202
+ ])
203
+ } finally {
204
+ setIsLoading(false)
205
+ }
206
+ }
207
+
208
+ const clearChat = () => {
209
+ setMessages([])
210
+ localStorage.removeItem('chat_messages')
211
+ }
212
+
213
+ return (
214
+ <div className={`app-layout ${!sidebarOpen ? 'sidebar-collapsed' : ''}`}>
215
+ <TopNav
216
+ user={user}
217
+ isAuthenticated={isAuthenticated}
218
+ onLogin={handleLogin}
219
+ onRegister={handleRegister}
220
+ onLogout={handleLogout}
221
+ onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
222
+ sidebarOpen={sidebarOpen}
223
+ />
224
+
225
+ <Sidebar
226
+ isOpen={sidebarOpen}
227
+ documents={documents}
228
+ onUpload={handleUpload}
229
+ onDeleteDocument={(id) => setDocuments(prev => prev.filter(d => d.id !== id))}
230
+ />
231
+
232
+ <ChatArea
233
+ messages={messages}
234
+ isLoading={isLoading}
235
+ error={error}
236
+ isAuthenticated={isAuthenticated}
237
+ onSendMessage={handleSendMessage}
238
+ onClearChat={clearChat}
239
+ onDismissError={() => setError(null)}
240
+ />
241
+ </div>
242
+ )
243
+ }
244
+
245
+ export default App
frontend/src/components/ChatArea.css ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .chat-area {
2
+ grid-row: 2;
3
+ display: flex;
4
+ flex-direction: column;
5
+ height: calc(100vh - var(--topnav-height));
6
+ overflow: hidden;
7
+ position: relative;
8
+ }
9
+
10
+ /* Welcome Screen (Unauthenticated) */
11
+ .chat-welcome {
12
+ display: flex;
13
+ flex-direction: column;
14
+ align-items: center;
15
+ justify-content: center;
16
+ text-align: center;
17
+ padding: var(--space-8);
18
+ height: 100%;
19
+ max-width: 800px;
20
+ margin: 0 auto;
21
+ }
22
+
23
+ .welcome-icon {
24
+ width: 100px;
25
+ height: 100px;
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: center;
29
+ background: var(--bg-glass);
30
+ border-radius: var(--radius-2xl);
31
+ margin-bottom: var(--space-6);
32
+ border: 1px solid var(--border-subtle);
33
+ }
34
+
35
+ .chat-welcome h1 {
36
+ font-size: var(--text-3xl);
37
+ font-weight: 700;
38
+ letter-spacing: -0.03em;
39
+ margin-bottom: var(--space-3);
40
+ background: var(--accent-gradient);
41
+ -webkit-background-clip: text;
42
+ -webkit-text-fill-color: transparent;
43
+ background-clip: text;
44
+ }
45
+
46
+ .chat-welcome>p {
47
+ font-size: var(--text-lg);
48
+ color: var(--text-secondary);
49
+ max-width: 500px;
50
+ margin-bottom: var(--space-8);
51
+ }
52
+
53
+ .welcome-features {
54
+ display: grid;
55
+ grid-template-columns: repeat(3, 1fr);
56
+ gap: var(--space-4);
57
+ width: 100%;
58
+ max-width: 600px;
59
+ margin-bottom: var(--space-8);
60
+ }
61
+
62
+ .feature-card {
63
+ padding: var(--space-5);
64
+ background: var(--bg-glass);
65
+ border: 1px solid var(--border-subtle);
66
+ border-radius: var(--radius-lg);
67
+ text-align: center;
68
+ transition: all var(--transition-base);
69
+ }
70
+
71
+ .feature-card:hover {
72
+ background: var(--bg-glass-hover);
73
+ border-color: var(--border-medium);
74
+ transform: translateY(-2px);
75
+ }
76
+
77
+ .feature-card svg {
78
+ color: var(--accent-primary);
79
+ margin-bottom: var(--space-3);
80
+ }
81
+
82
+ .feature-card h3 {
83
+ font-size: var(--text-sm);
84
+ font-weight: 600;
85
+ margin-bottom: var(--space-1);
86
+ }
87
+
88
+ .feature-card p {
89
+ font-size: var(--text-xs);
90
+ color: var(--text-tertiary);
91
+ }
92
+
93
+ .welcome-cta {
94
+ font-size: var(--text-sm);
95
+ color: var(--text-tertiary);
96
+ }
97
+
98
+ /* Error Toast */
99
+ .error-toast {
100
+ position: absolute;
101
+ top: var(--space-4);
102
+ left: 50%;
103
+ transform: translateX(-50%);
104
+ display: flex;
105
+ align-items: center;
106
+ gap: var(--space-3);
107
+ padding: var(--space-3) var(--space-4);
108
+ background: var(--error-bg);
109
+ border: 1px solid rgba(239, 68, 68, 0.3);
110
+ border-radius: var(--radius-lg);
111
+ color: var(--error);
112
+ z-index: 10;
113
+ max-width: 90%;
114
+ }
115
+
116
+ .error-toast span {
117
+ font-size: var(--text-sm);
118
+ }
119
+
120
+ /* Messages Container */
121
+ .messages-container {
122
+ flex: 1;
123
+ overflow-y: auto;
124
+ padding: var(--space-6) var(--space-4);
125
+ }
126
+
127
+ .messages-wrapper {
128
+ max-width: var(--chat-max-width);
129
+ margin: 0 auto;
130
+ display: flex;
131
+ flex-direction: column;
132
+ gap: var(--space-6);
133
+ }
134
+
135
+ /* Empty State */
136
+ .chat-empty {
137
+ display: flex;
138
+ flex-direction: column;
139
+ align-items: center;
140
+ justify-content: center;
141
+ text-align: center;
142
+ padding: var(--space-16) var(--space-4);
143
+ min-height: 50vh;
144
+ }
145
+
146
+ .empty-icon {
147
+ width: 80px;
148
+ height: 80px;
149
+ display: flex;
150
+ align-items: center;
151
+ justify-content: center;
152
+ background: var(--bg-glass);
153
+ border-radius: var(--radius-xl);
154
+ margin-bottom: var(--space-6);
155
+ color: var(--text-tertiary);
156
+ }
157
+
158
+ .chat-empty h2 {
159
+ font-size: var(--text-xl);
160
+ font-weight: 600;
161
+ margin-bottom: var(--space-2);
162
+ }
163
+
164
+ .chat-empty>p {
165
+ color: var(--text-secondary);
166
+ margin-bottom: var(--space-8);
167
+ }
168
+
169
+ /* Suggested Questions */
170
+ .suggested-questions {
171
+ width: 100%;
172
+ max-width: 600px;
173
+ }
174
+
175
+ .suggestions-label {
176
+ font-size: var(--text-sm);
177
+ color: var(--text-tertiary);
178
+ margin-bottom: var(--space-3);
179
+ }
180
+
181
+ .suggestions-grid {
182
+ display: grid;
183
+ grid-template-columns: repeat(2, 1fr);
184
+ gap: var(--space-2);
185
+ }
186
+
187
+ .suggestion-chip {
188
+ padding: var(--space-3) var(--space-4);
189
+ background: var(--bg-tertiary);
190
+ border: 1px solid var(--border-subtle);
191
+ border-radius: var(--radius-lg);
192
+ color: var(--text-secondary);
193
+ font-size: var(--text-sm);
194
+ text-align: left;
195
+ cursor: pointer;
196
+ transition: all var(--transition-base);
197
+ }
198
+
199
+ .suggestion-chip:hover {
200
+ background: var(--bg-elevated);
201
+ border-color: var(--accent-primary);
202
+ color: var(--text-primary);
203
+ }
204
+
205
+ /* Loading Bubble */
206
+ .message-bubble.loading {
207
+ animation: fadeInUp var(--transition-slow) ease;
208
+ }
209
+
210
+ .typing-indicator {
211
+ display: flex;
212
+ align-items: center;
213
+ gap: 4px;
214
+ padding: var(--space-3) 0;
215
+ }
216
+
217
+ .typing-indicator span {
218
+ width: 8px;
219
+ height: 8px;
220
+ background: var(--text-tertiary);
221
+ border-radius: 50%;
222
+ animation: bounce 1.4s ease-in-out infinite;
223
+ }
224
+
225
+ .typing-indicator span:nth-child(1) {
226
+ animation-delay: 0s;
227
+ }
228
+
229
+ .typing-indicator span:nth-child(2) {
230
+ animation-delay: 0.16s;
231
+ }
232
+
233
+ .typing-indicator span:nth-child(3) {
234
+ animation-delay: 0.32s;
235
+ }
236
+
237
+ /* Input Area */
238
+ .input-area {
239
+ padding: var(--space-4) var(--space-4) var(--space-6);
240
+ background: linear-gradient(to top, var(--bg-primary) 80%, transparent);
241
+ }
242
+
243
+ .input-wrapper {
244
+ max-width: var(--chat-max-width);
245
+ margin: 0 auto;
246
+ display: flex;
247
+ align-items: flex-end;
248
+ gap: var(--space-2);
249
+ }
250
+
251
+ .clear-btn {
252
+ flex-shrink: 0;
253
+ margin-bottom: var(--space-1);
254
+ }
255
+
256
+ .chat-form {
257
+ flex: 1;
258
+ display: flex;
259
+ align-items: flex-end;
260
+ gap: var(--space-2);
261
+ padding: var(--space-3);
262
+ background: var(--bg-tertiary);
263
+ border: 1px solid var(--border-subtle);
264
+ border-radius: var(--radius-xl);
265
+ transition: all var(--transition-base);
266
+ }
267
+
268
+ .chat-form:focus-within {
269
+ border-color: var(--accent-primary);
270
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
271
+ }
272
+
273
+ .chat-input {
274
+ flex: 1;
275
+ background: transparent;
276
+ border: none;
277
+ color: var(--text-primary);
278
+ font-family: var(--font-sans);
279
+ font-size: var(--text-base);
280
+ line-height: 1.5;
281
+ resize: none;
282
+ max-height: 200px;
283
+ padding: var(--space-1) var(--space-2);
284
+ }
285
+
286
+ .chat-input::placeholder {
287
+ color: var(--text-tertiary);
288
+ }
289
+
290
+ .chat-input:focus {
291
+ outline: none;
292
+ }
293
+
294
+ .send-btn {
295
+ flex-shrink: 0;
296
+ padding: var(--space-2) var(--space-3);
297
+ border-radius: var(--radius-lg);
298
+ }
299
+
300
+ .send-btn .loading-dots {
301
+ display: flex;
302
+ gap: 2px;
303
+ }
304
+
305
+ .send-btn .loading-dots span {
306
+ width: 4px;
307
+ height: 4px;
308
+ background: currentColor;
309
+ border-radius: 50%;
310
+ animation: bounce 1.4s ease-in-out infinite;
311
+ }
312
+
313
+ .input-hint {
314
+ text-align: center;
315
+ font-size: var(--text-xs);
316
+ color: var(--text-tertiary);
317
+ margin-top: var(--space-2);
318
+ }
319
+
320
+ @media (max-width: 768px) {
321
+ .welcome-features {
322
+ grid-template-columns: 1fr;
323
+ }
324
+
325
+ .suggestions-grid {
326
+ grid-template-columns: 1fr;
327
+ }
328
+
329
+ .chat-welcome h1 {
330
+ font-size: var(--text-2xl);
331
+ }
332
+ }
frontend/src/components/ChatArea.jsx ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect } from 'react'
2
+ import MessageBubble from './MessageBubble'
3
+ import './ChatArea.css'
4
+
5
+ function ChatArea({
6
+ messages,
7
+ isLoading,
8
+ error,
9
+ isAuthenticated,
10
+ onSendMessage,
11
+ onClearChat,
12
+ onDismissError
13
+ }) {
14
+ const [input, setInput] = useState('')
15
+ const messagesEndRef = useRef(null)
16
+ const textareaRef = useRef(null)
17
+
18
+ const scrollToBottom = () => {
19
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
20
+ }
21
+
22
+ useEffect(() => {
23
+ scrollToBottom()
24
+ }, [messages])
25
+
26
+ const handleSubmit = (e) => {
27
+ e.preventDefault()
28
+ if (input.trim() && !isLoading) {
29
+ onSendMessage(input)
30
+ setInput('')
31
+ if (textareaRef.current) {
32
+ textareaRef.current.style.height = 'auto'
33
+ }
34
+ }
35
+ }
36
+
37
+ const handleKeyDown = (e) => {
38
+ if (e.key === 'Enter' && !e.shiftKey) {
39
+ e.preventDefault()
40
+ handleSubmit(e)
41
+ }
42
+ }
43
+
44
+ const handleTextareaChange = (e) => {
45
+ setInput(e.target.value)
46
+ // Auto-resize textarea
47
+ e.target.style.height = 'auto'
48
+ e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px'
49
+ }
50
+
51
+ const suggestedQuestions = [
52
+ 'What are the key concepts in my documents?',
53
+ 'Summarize the main ideas',
54
+ 'Find information about...',
55
+ 'Compare topics across documents'
56
+ ]
57
+
58
+ if (!isAuthenticated) {
59
+ return (
60
+ <main className="chat-area">
61
+ <div className="chat-welcome">
62
+ <div className="welcome-icon">
63
+ <svg width="64" height="64" viewBox="0 0 24 24" fill="none">
64
+ <path
65
+ d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"
66
+ stroke="url(#welcome-gradient)"
67
+ strokeWidth="1.5"
68
+ strokeLinecap="round"
69
+ strokeLinejoin="round"
70
+ />
71
+ <defs>
72
+ <linearGradient id="welcome-gradient" x1="2" y1="2" x2="22" y2="22">
73
+ <stop stopColor="#6366f1" />
74
+ <stop offset="1" stopColor="#a855f7" />
75
+ </linearGradient>
76
+ </defs>
77
+ </svg>
78
+ </div>
79
+ <h1>RAG Knowledge Assistant</h1>
80
+ <p>Upload documents and ask questions to get AI-powered answers with source citations</p>
81
+ <div className="welcome-features">
82
+ <div className="feature-card">
83
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
84
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
85
+ <path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" />
86
+ </svg>
87
+ <h3>Upload Documents</h3>
88
+ <p>PDF, TXT, DOCX supported</p>
89
+ </div>
90
+ <div className="feature-card">
91
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
92
+ <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
93
+ </svg>
94
+ <h3>Ask Questions</h3>
95
+ <p>Natural language queries</p>
96
+ </div>
97
+ <div className="feature-card">
98
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
99
+ <circle cx="11" cy="11" r="8" />
100
+ <path d="M21 21l-4.35-4.35" />
101
+ </svg>
102
+ <h3>Get Citations</h3>
103
+ <p>Answers with sources</p>
104
+ </div>
105
+ </div>
106
+ <p className="welcome-cta">Sign in to get started</p>
107
+ </div>
108
+ </main>
109
+ )
110
+ }
111
+
112
+ return (
113
+ <main className="chat-area">
114
+ {/* Error Toast */}
115
+ {error && (
116
+ <div className="error-toast animate-fade-in-up">
117
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
118
+ <circle cx="12" cy="12" r="10" />
119
+ <path d="M12 8v4M12 16h.01" />
120
+ </svg>
121
+ <span>{error}</span>
122
+ <button
123
+ className="btn btn-ghost btn-icon"
124
+ onClick={onDismissError}
125
+ >
126
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
127
+ <path d="M18 6L6 18M6 6l12 12" />
128
+ </svg>
129
+ </button>
130
+ </div>
131
+ )}
132
+
133
+ {/* Messages Container */}
134
+ <div className="messages-container">
135
+ <div className="messages-wrapper">
136
+ {messages.length === 0 ? (
137
+ <div className="chat-empty">
138
+ <div className="empty-icon">
139
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1">
140
+ <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
141
+ </svg>
142
+ </div>
143
+ <h2>Start a Conversation</h2>
144
+ <p>Upload documents and ask questions about their content</p>
145
+
146
+ <div className="suggested-questions">
147
+ <p className="suggestions-label">Try asking:</p>
148
+ <div className="suggestions-grid">
149
+ {suggestedQuestions.map((q, i) => (
150
+ <button
151
+ key={i}
152
+ className="suggestion-chip"
153
+ onClick={() => setInput(q)}
154
+ >
155
+ {q}
156
+ </button>
157
+ ))}
158
+ </div>
159
+ </div>
160
+ </div>
161
+ ) : (
162
+ <>
163
+ {messages.map((msg, index) => (
164
+ <MessageBubble
165
+ key={msg.id}
166
+ message={msg}
167
+ isLast={index === messages.length - 1}
168
+ />
169
+ ))}
170
+
171
+ {isLoading && (
172
+ <div className="message-bubble assistant loading">
173
+ <div className="assistant-avatar">
174
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
175
+ <path
176
+ d="M12 2L2 7l10 5 10-5-10-5z"
177
+ stroke="currentColor"
178
+ strokeWidth="1.5"
179
+ />
180
+ </svg>
181
+ </div>
182
+ <div className="message-content">
183
+ <div className="typing-indicator">
184
+ <span></span>
185
+ <span></span>
186
+ <span></span>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ )}
191
+ </>
192
+ )}
193
+ <div ref={messagesEndRef} />
194
+ </div>
195
+ </div>
196
+
197
+ {/* Input Area */}
198
+ <div className="input-area">
199
+ <div className="input-wrapper">
200
+ {messages.length > 0 && (
201
+ <button
202
+ className="btn btn-ghost btn-icon clear-btn"
203
+ onClick={onClearChat}
204
+ title="Clear chat"
205
+ >
206
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
207
+ <path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" />
208
+ </svg>
209
+ </button>
210
+ )}
211
+
212
+ <form onSubmit={handleSubmit} className="chat-form">
213
+ <textarea
214
+ ref={textareaRef}
215
+ value={input}
216
+ onChange={handleTextareaChange}
217
+ onKeyDown={handleKeyDown}
218
+ placeholder="Ask about your documents..."
219
+ rows={1}
220
+ disabled={isLoading}
221
+ className="chat-input"
222
+ />
223
+ <button
224
+ type="submit"
225
+ className="btn btn-primary send-btn"
226
+ disabled={!input.trim() || isLoading}
227
+ >
228
+ {isLoading ? (
229
+ <div className="loading-dots">
230
+ <span></span><span></span><span></span>
231
+ </div>
232
+ ) : (
233
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
234
+ <path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
235
+ </svg>
236
+ )}
237
+ </button>
238
+ </form>
239
+ </div>
240
+ <p className="input-hint">Press Enter to send, Shift+Enter for new line</p>
241
+ </div>
242
+ </main>
243
+ )
244
+ }
245
+
246
+ export default ChatArea
frontend/src/components/MessageBubble.css ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .message-bubble {
2
+ display: flex;
3
+ gap: var(--space-3);
4
+ animation: fadeInUp var(--transition-slow) ease;
5
+ }
6
+
7
+ .message-bubble.user {
8
+ justify-content: flex-end;
9
+ }
10
+
11
+ .message-bubble.error {
12
+ justify-content: flex-end;
13
+ }
14
+
15
+ /* Avatars */
16
+ .assistant-avatar {
17
+ width: 36px;
18
+ height: 36px;
19
+ flex-shrink: 0;
20
+ display: flex;
21
+ align-items: center;
22
+ justify-content: center;
23
+ background: var(--bg-glass);
24
+ border: 1px solid var(--border-subtle);
25
+ border-radius: var(--radius-lg);
26
+ color: var(--accent-primary);
27
+ }
28
+
29
+ .user-avatar {
30
+ width: 36px;
31
+ height: 36px;
32
+ flex-shrink: 0;
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: center;
36
+ background: var(--accent-gradient);
37
+ border-radius: var(--radius-lg);
38
+ color: white;
39
+ }
40
+
41
+ .error-icon {
42
+ width: 36px;
43
+ height: 36px;
44
+ flex-shrink: 0;
45
+ display: flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ background: var(--error-bg);
49
+ border: 1px solid rgba(239, 68, 68, 0.2);
50
+ border-radius: var(--radius-lg);
51
+ color: var(--error);
52
+ }
53
+
54
+ /* Message Content */
55
+ .message-content {
56
+ max-width: 75%;
57
+ display: flex;
58
+ flex-direction: column;
59
+ gap: var(--space-3);
60
+ }
61
+
62
+ .message-bubble.user .message-content {
63
+ align-items: flex-end;
64
+ }
65
+
66
+ .message-text {
67
+ padding: var(--space-4);
68
+ border-radius: var(--radius-xl);
69
+ font-size: var(--text-base);
70
+ line-height: 1.7;
71
+ white-space: pre-wrap;
72
+ word-break: break-word;
73
+ }
74
+
75
+ .message-bubble.assistant .message-text {
76
+ background: var(--bg-tertiary);
77
+ border: 1px solid var(--border-subtle);
78
+ border-top-left-radius: var(--radius-sm);
79
+ }
80
+
81
+ .message-bubble.user .message-text {
82
+ background: var(--accent-gradient);
83
+ color: white;
84
+ border-top-right-radius: var(--radius-sm);
85
+ }
86
+
87
+ .message-bubble.error .message-text {
88
+ background: var(--error-bg);
89
+ border: 1px solid rgba(239, 68, 68, 0.2);
90
+ color: var(--error);
91
+ border-top-right-radius: var(--radius-sm);
92
+ }
93
+
94
+ /* Images */
95
+ .message-images {
96
+ display: flex;
97
+ flex-wrap: wrap;
98
+ gap: var(--space-2);
99
+ padding-left: var(--space-1);
100
+ }
101
+
102
+ .image-thumb {
103
+ position: relative;
104
+ width: 100px;
105
+ height: 100px;
106
+ border-radius: var(--radius-md);
107
+ overflow: hidden;
108
+ border: 1px solid var(--border-subtle);
109
+ cursor: pointer;
110
+ background: var(--bg-tertiary);
111
+ padding: 0;
112
+ transition: all var(--transition-base);
113
+ }
114
+
115
+ .image-thumb:hover {
116
+ border-color: var(--accent-primary);
117
+ transform: scale(1.05);
118
+ }
119
+
120
+ .image-thumb img {
121
+ width: 100%;
122
+ height: 100%;
123
+ object-fit: cover;
124
+ }
125
+
126
+ .image-page {
127
+ position: absolute;
128
+ bottom: var(--space-1);
129
+ right: var(--space-1);
130
+ padding: var(--space-1) var(--space-2);
131
+ background: rgba(0, 0, 0, 0.7);
132
+ border-radius: var(--radius-sm);
133
+ font-size: var(--text-xs);
134
+ color: white;
135
+ }
136
+
137
+ /* Sources */
138
+ .message-sources {
139
+ padding-left: var(--space-1);
140
+ }
141
+
142
+ .sources-toggle {
143
+ display: flex;
144
+ align-items: center;
145
+ gap: var(--space-2);
146
+ padding: var(--space-2) var(--space-3);
147
+ background: var(--bg-glass);
148
+ border: 1px solid var(--border-subtle);
149
+ border-radius: var(--radius-md);
150
+ color: var(--text-secondary);
151
+ font-size: var(--text-sm);
152
+ cursor: pointer;
153
+ transition: all var(--transition-base);
154
+ }
155
+
156
+ .sources-toggle:hover {
157
+ background: var(--bg-glass-hover);
158
+ color: var(--text-primary);
159
+ }
160
+
161
+ .sources-toggle .chevron {
162
+ transition: transform var(--transition-base);
163
+ }
164
+
165
+ .sources-toggle .chevron.open {
166
+ transform: rotate(180deg);
167
+ }
168
+
169
+ .sources-list {
170
+ margin-top: var(--space-2);
171
+ display: flex;
172
+ flex-direction: column;
173
+ gap: var(--space-2);
174
+ animation: fadeIn var(--transition-base) ease;
175
+ }
176
+
177
+ .source-item {
178
+ padding: var(--space-3);
179
+ background: var(--bg-tertiary);
180
+ border: 1px solid var(--border-subtle);
181
+ border-radius: var(--radius-md);
182
+ }
183
+
184
+ .source-header {
185
+ display: flex;
186
+ align-items: center;
187
+ gap: var(--space-2);
188
+ margin-bottom: var(--space-2);
189
+ }
190
+
191
+ .source-num {
192
+ width: 20px;
193
+ height: 20px;
194
+ display: flex;
195
+ align-items: center;
196
+ justify-content: center;
197
+ background: var(--accent-primary);
198
+ color: white;
199
+ border-radius: var(--radius-full);
200
+ font-size: var(--text-xs);
201
+ font-weight: 600;
202
+ }
203
+
204
+ .source-file {
205
+ font-size: var(--text-sm);
206
+ font-weight: 500;
207
+ color: var(--text-primary);
208
+ flex: 1;
209
+ overflow: hidden;
210
+ text-overflow: ellipsis;
211
+ white-space: nowrap;
212
+ }
213
+
214
+ .source-score {
215
+ font-size: var(--text-xs);
216
+ color: var(--success);
217
+ padding: var(--space-1) var(--space-2);
218
+ background: var(--success-bg);
219
+ border-radius: var(--radius-full);
220
+ }
221
+
222
+ .source-content {
223
+ font-size: var(--text-sm);
224
+ color: var(--text-secondary);
225
+ line-height: 1.6;
226
+ display: -webkit-box;
227
+ -webkit-line-clamp: 3;
228
+ -webkit-box-orient: vertical;
229
+ overflow: hidden;
230
+ }
231
+
232
+ /* Timestamp */
233
+ .message-time {
234
+ font-size: var(--text-xs);
235
+ color: var(--text-tertiary);
236
+ padding: 0 var(--space-2);
237
+ }
238
+
239
+ /* Image Modal */
240
+ .image-modal {
241
+ position: fixed;
242
+ inset: 0;
243
+ background: rgba(0, 0, 0, 0.9);
244
+ display: flex;
245
+ align-items: center;
246
+ justify-content: center;
247
+ z-index: 1000;
248
+ animation: fadeIn var(--transition-fast) ease;
249
+ }
250
+
251
+ .image-modal-content {
252
+ position: relative;
253
+ max-width: 90vw;
254
+ max-height: 90vh;
255
+ animation: fadeInUp var(--transition-base) ease;
256
+ }
257
+
258
+ .modal-close {
259
+ position: absolute;
260
+ top: calc(-1 * var(--space-10));
261
+ right: 0;
262
+ color: white;
263
+ }
264
+
265
+ .image-modal-content img {
266
+ max-width: 100%;
267
+ max-height: 80vh;
268
+ border-radius: var(--radius-lg);
269
+ box-shadow: var(--shadow-xl);
270
+ }
271
+
272
+ .image-caption {
273
+ text-align: center;
274
+ color: var(--text-secondary);
275
+ margin-top: var(--space-3);
276
+ font-size: var(--text-sm);
277
+ }
278
+
279
+ @media (max-width: 768px) {
280
+ .message-content {
281
+ max-width: 85%;
282
+ }
283
+
284
+ .image-thumb {
285
+ width: 80px;
286
+ height: 80px;
287
+ }
288
+ }
frontend/src/components/MessageBubble.jsx ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import './MessageBubble.css'
3
+
4
+ function MessageBubble({ message, isLast }) {
5
+ const [showSources, setShowSources] = useState(false)
6
+ const [selectedImage, setSelectedImage] = useState(null)
7
+
8
+ const isUser = message.role === 'user'
9
+ const isError = message.role === 'error'
10
+ const isAssistant = message.role === 'assistant'
11
+
12
+ const formatTime = (timestamp) => {
13
+ return new Date(timestamp).toLocaleTimeString([], {
14
+ hour: '2-digit',
15
+ minute: '2-digit'
16
+ })
17
+ }
18
+
19
+ return (
20
+ <div className={`message-bubble ${message.role} ${isLast ? 'is-last' : ''}`}>
21
+ {/* Avatar */}
22
+ {isAssistant && (
23
+ <div className="assistant-avatar">
24
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
25
+ <path
26
+ d="M12 2L2 7l10 5 10-5-10-5z"
27
+ stroke="currentColor"
28
+ strokeWidth="1.5"
29
+ strokeLinecap="round"
30
+ strokeLinejoin="round"
31
+ />
32
+ <path
33
+ d="M2 17l10 5 10-5M2 12l10 5 10-5"
34
+ stroke="currentColor"
35
+ strokeWidth="1.5"
36
+ strokeLinecap="round"
37
+ strokeLinejoin="round"
38
+ />
39
+ </svg>
40
+ </div>
41
+ )}
42
+
43
+ <div className="message-content">
44
+ {/* Message Text */}
45
+ <div className="message-text">
46
+ {message.content}
47
+ </div>
48
+
49
+ {/* Images */}
50
+ {isAssistant && message.images && message.images.length > 0 && (
51
+ <div className="message-images">
52
+ {message.images.map((img, i) => (
53
+ <button
54
+ key={i}
55
+ className="image-thumb"
56
+ onClick={() => setSelectedImage(img)}
57
+ >
58
+ <img
59
+ src={img.url}
60
+ alt={`Reference image from page ${img.page}`}
61
+ loading="lazy"
62
+ />
63
+ <span className="image-page">p.{img.page}</span>
64
+ </button>
65
+ ))}
66
+ </div>
67
+ )}
68
+
69
+ {/* Sources */}
70
+ {isAssistant && message.sources && message.sources.length > 0 && (
71
+ <div className="message-sources">
72
+ <button
73
+ className="sources-toggle"
74
+ onClick={() => setShowSources(!showSources)}
75
+ >
76
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
77
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
78
+ <path d="M14 2v6h6" />
79
+ </svg>
80
+ <span>{message.sources.length} source{message.sources.length !== 1 ? 's' : ''}</span>
81
+ <svg
82
+ className={`chevron ${showSources ? 'open' : ''}`}
83
+ width="16"
84
+ height="16"
85
+ viewBox="0 0 24 24"
86
+ fill="none"
87
+ stroke="currentColor"
88
+ strokeWidth="2"
89
+ >
90
+ <path d="M6 9l6 6 6-6" />
91
+ </svg>
92
+ </button>
93
+
94
+ {showSources && (
95
+ <div className="sources-list">
96
+ {message.sources.map((source, i) => (
97
+ <div key={i} className="source-item">
98
+ <div className="source-header">
99
+ <span className="source-num">{i + 1}</span>
100
+ <span className="source-file">{source.source || 'Document'}</span>
101
+ {source.score && (
102
+ <span className="source-score">
103
+ {(source.score * 100).toFixed(0)}% match
104
+ </span>
105
+ )}
106
+ </div>
107
+ <p className="source-content">{source.content}</p>
108
+ </div>
109
+ ))}
110
+ </div>
111
+ )}
112
+ </div>
113
+ )}
114
+
115
+ {/* Timestamp */}
116
+ <div className="message-time">
117
+ {formatTime(message.timestamp)}
118
+ </div>
119
+ </div>
120
+
121
+ {/* User Avatar */}
122
+ {isUser && (
123
+ <div className="user-avatar">
124
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
125
+ <path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
126
+ <circle cx="12" cy="7" r="4" />
127
+ </svg>
128
+ </div>
129
+ )}
130
+
131
+ {/* Error Icon */}
132
+ {isError && (
133
+ <div className="error-icon">
134
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
135
+ <circle cx="12" cy="12" r="10" />
136
+ <path d="M12 8v4M12 16h.01" />
137
+ </svg>
138
+ </div>
139
+ )}
140
+
141
+ {/* Image Modal */}
142
+ {selectedImage && (
143
+ <div className="image-modal" onClick={() => setSelectedImage(null)}>
144
+ <div className="image-modal-content" onClick={e => e.stopPropagation()}>
145
+ <button
146
+ className="modal-close btn btn-ghost btn-icon"
147
+ onClick={() => setSelectedImage(null)}
148
+ >
149
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
150
+ <path d="M18 6L6 18M6 6l12 12" />
151
+ </svg>
152
+ </button>
153
+ <img src={selectedImage.url} alt="Full size reference" />
154
+ <p className="image-caption">Page {selectedImage.page}</p>
155
+ </div>
156
+ </div>
157
+ )}
158
+ </div>
159
+ )
160
+ }
161
+
162
+ export default MessageBubble
frontend/src/components/Sidebar.css ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .sidebar {
2
+ grid-row: 2;
3
+ background: var(--bg-secondary);
4
+ border-right: 1px solid var(--border-subtle);
5
+ display: flex;
6
+ flex-direction: column;
7
+ transition: width var(--transition-base), opacity var(--transition-base);
8
+ overflow: hidden;
9
+ }
10
+
11
+ .sidebar.open {
12
+ width: var(--sidebar-width);
13
+ }
14
+
15
+ .sidebar.closed {
16
+ width: 0;
17
+ border-right: none;
18
+ }
19
+
20
+ .sidebar-content {
21
+ display: flex;
22
+ flex-direction: column;
23
+ height: 100%;
24
+ padding: var(--space-4);
25
+ min-width: var(--sidebar-width);
26
+ overflow-y: auto;
27
+ }
28
+
29
+ .sidebar-header {
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: space-between;
33
+ margin-bottom: var(--space-4);
34
+ }
35
+
36
+ .sidebar-header h3 {
37
+ font-size: var(--text-sm);
38
+ font-weight: 600;
39
+ color: var(--text-secondary);
40
+ text-transform: uppercase;
41
+ letter-spacing: 0.05em;
42
+ }
43
+
44
+ .doc-count {
45
+ font-size: var(--text-xs);
46
+ font-weight: 500;
47
+ color: var(--text-tertiary);
48
+ background: var(--bg-tertiary);
49
+ padding: var(--space-1) var(--space-2);
50
+ border-radius: var(--radius-full);
51
+ }
52
+
53
+ /* Upload Zone */
54
+ .upload-zone {
55
+ display: flex;
56
+ flex-direction: column;
57
+ align-items: center;
58
+ justify-content: center;
59
+ padding: var(--space-6) var(--space-4);
60
+ border: 2px dashed var(--border-subtle);
61
+ border-radius: var(--radius-lg);
62
+ cursor: pointer;
63
+ transition: all var(--transition-base);
64
+ margin-bottom: var(--space-4);
65
+ }
66
+
67
+ .upload-zone:hover {
68
+ border-color: var(--accent-primary);
69
+ background: rgba(99, 102, 241, 0.05);
70
+ }
71
+
72
+ .upload-zone.dragging {
73
+ border-color: var(--accent-primary);
74
+ background: rgba(99, 102, 241, 0.1);
75
+ border-style: solid;
76
+ }
77
+
78
+ .upload-icon {
79
+ width: 48px;
80
+ height: 48px;
81
+ display: flex;
82
+ align-items: center;
83
+ justify-content: center;
84
+ background: var(--bg-glass);
85
+ border-radius: var(--radius-lg);
86
+ color: var(--accent-primary);
87
+ margin-bottom: var(--space-3);
88
+ }
89
+
90
+ .upload-text {
91
+ font-size: var(--text-sm);
92
+ font-weight: 500;
93
+ color: var(--text-primary);
94
+ margin-bottom: var(--space-1);
95
+ }
96
+
97
+ .upload-hint {
98
+ font-size: var(--text-xs);
99
+ color: var(--text-tertiary);
100
+ }
101
+
102
+ /* Upload Progress */
103
+ .upload-progress {
104
+ padding: var(--space-3);
105
+ background: var(--bg-tertiary);
106
+ border-radius: var(--radius-md);
107
+ margin-bottom: var(--space-4);
108
+ }
109
+
110
+ .progress-info {
111
+ display: flex;
112
+ justify-content: space-between;
113
+ margin-bottom: var(--space-2);
114
+ }
115
+
116
+ .progress-name {
117
+ font-size: var(--text-xs);
118
+ color: var(--text-primary);
119
+ max-width: 150px;
120
+ overflow: hidden;
121
+ text-overflow: ellipsis;
122
+ white-space: nowrap;
123
+ }
124
+
125
+ .progress-status {
126
+ font-size: var(--text-xs);
127
+ color: var(--accent-primary);
128
+ }
129
+
130
+ .progress-bar {
131
+ height: 4px;
132
+ background: var(--bg-elevated);
133
+ border-radius: var(--radius-full);
134
+ overflow: hidden;
135
+ }
136
+
137
+ .progress-fill {
138
+ height: 100%;
139
+ background: var(--accent-gradient);
140
+ border-radius: var(--radius-full);
141
+ transition: width var(--transition-base);
142
+ }
143
+
144
+ /* Document List */
145
+ .document-list {
146
+ flex: 1;
147
+ display: flex;
148
+ flex-direction: column;
149
+ gap: var(--space-2);
150
+ overflow-y: auto;
151
+ }
152
+
153
+ .empty-docs {
154
+ display: flex;
155
+ flex-direction: column;
156
+ align-items: center;
157
+ justify-content: center;
158
+ padding: var(--space-8) var(--space-4);
159
+ text-align: center;
160
+ color: var(--text-tertiary);
161
+ }
162
+
163
+ .empty-docs svg {
164
+ margin-bottom: var(--space-4);
165
+ opacity: 0.5;
166
+ }
167
+
168
+ .empty-docs p {
169
+ font-size: var(--text-sm);
170
+ color: var(--text-secondary);
171
+ margin-bottom: var(--space-1);
172
+ }
173
+
174
+ .empty-docs span {
175
+ font-size: var(--text-xs);
176
+ }
177
+
178
+ /* Document Item */
179
+ .document-item {
180
+ display: flex;
181
+ align-items: center;
182
+ gap: var(--space-3);
183
+ padding: var(--space-3);
184
+ background: var(--bg-tertiary);
185
+ border-radius: var(--radius-md);
186
+ transition: all var(--transition-fast);
187
+ animation: fadeInUp var(--transition-base) ease;
188
+ }
189
+
190
+ .document-item:hover {
191
+ background: var(--bg-elevated);
192
+ }
193
+
194
+ .doc-icon {
195
+ color: var(--text-tertiary);
196
+ flex-shrink: 0;
197
+ }
198
+
199
+ .doc-info {
200
+ flex: 1;
201
+ min-width: 0;
202
+ display: flex;
203
+ flex-direction: column;
204
+ }
205
+
206
+ .doc-name {
207
+ font-size: var(--text-sm);
208
+ color: var(--text-primary);
209
+ overflow: hidden;
210
+ text-overflow: ellipsis;
211
+ white-space: nowrap;
212
+ }
213
+
214
+ .doc-meta {
215
+ font-size: var(--text-xs);
216
+ color: var(--text-tertiary);
217
+ }
218
+
219
+ .status-icon {
220
+ flex-shrink: 0;
221
+ }
222
+
223
+ .status-icon.uploading {
224
+ color: var(--accent-primary);
225
+ }
226
+
227
+ .status-icon.ready {
228
+ color: var(--success);
229
+ }
230
+
231
+ .status-icon.error {
232
+ color: var(--error);
233
+ }
234
+
235
+ .doc-delete {
236
+ opacity: 0;
237
+ transition: opacity var(--transition-fast);
238
+ }
239
+
240
+ .document-item:hover .doc-delete {
241
+ opacity: 1;
242
+ }
243
+
244
+ @media (max-width: 768px) {
245
+ .sidebar {
246
+ position: fixed;
247
+ top: var(--topnav-height);
248
+ left: 0;
249
+ bottom: 0;
250
+ z-index: 50;
251
+ width: 280px !important;
252
+ transform: translateX(-100%);
253
+ }
254
+
255
+ .sidebar.open {
256
+ transform: translateX(0);
257
+ box-shadow: var(--shadow-xl);
258
+ }
259
+ }
frontend/src/components/Sidebar.jsx ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef } from 'react'
2
+ import './Sidebar.css'
3
+
4
+ function Sidebar({ isOpen, documents, onUpload, onDeleteDocument }) {
5
+ const [isDragging, setIsDragging] = useState(false)
6
+ const [uploadProgress, setUploadProgress] = useState(null)
7
+ const fileInputRef = useRef(null)
8
+
9
+ const handleDragOver = (e) => {
10
+ e.preventDefault()
11
+ setIsDragging(true)
12
+ }
13
+
14
+ const handleDragLeave = (e) => {
15
+ e.preventDefault()
16
+ setIsDragging(false)
17
+ }
18
+
19
+ const handleDrop = async (e) => {
20
+ e.preventDefault()
21
+ setIsDragging(false)
22
+
23
+ const files = Array.from(e.dataTransfer.files)
24
+ const validFiles = files.filter(f =>
25
+ f.type === 'application/pdf' ||
26
+ f.type === 'text/plain' ||
27
+ f.name.endsWith('.docx')
28
+ )
29
+
30
+ for (const file of validFiles) {
31
+ try {
32
+ setUploadProgress({ name: file.name, percent: 0 })
33
+ await onUpload(file)
34
+ setUploadProgress(null)
35
+ } catch (err) {
36
+ console.error('Upload failed:', err)
37
+ setUploadProgress(null)
38
+ }
39
+ }
40
+ }
41
+
42
+ const handleFileSelect = async (e) => {
43
+ const files = Array.from(e.target.files)
44
+ for (const file of files) {
45
+ try {
46
+ setUploadProgress({ name: file.name, percent: 0 })
47
+ await onUpload(file)
48
+ setUploadProgress(null)
49
+ } catch (err) {
50
+ console.error('Upload failed:', err)
51
+ setUploadProgress(null)
52
+ }
53
+ }
54
+ e.target.value = ''
55
+ }
56
+
57
+ const getStatusIcon = (status) => {
58
+ switch (status) {
59
+ case 'uploading':
60
+ return (
61
+ <div className="status-icon uploading">
62
+ <svg className="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
63
+ <path d="M21 12a9 9 0 11-6.219-8.56" />
64
+ </svg>
65
+ </div>
66
+ )
67
+ case 'ready':
68
+ return (
69
+ <div className="status-icon ready">
70
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
71
+ <path d="M20 6L9 17l-5-5" />
72
+ </svg>
73
+ </div>
74
+ )
75
+ case 'error':
76
+ return (
77
+ <div className="status-icon error">
78
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
79
+ <circle cx="12" cy="12" r="10" />
80
+ <path d="M12 8v4M12 16h.01" />
81
+ </svg>
82
+ </div>
83
+ )
84
+ default:
85
+ return null
86
+ }
87
+ }
88
+
89
+ const getFileIcon = (name) => {
90
+ if (name.endsWith('.pdf')) {
91
+ return (
92
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
93
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
94
+ <path d="M14 2v6h6M9 15h6M9 11h6" />
95
+ </svg>
96
+ )
97
+ }
98
+ return (
99
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
100
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
101
+ <path d="M14 2v6h6" />
102
+ </svg>
103
+ )
104
+ }
105
+
106
+ return (
107
+ <aside className={`sidebar ${isOpen ? 'open' : 'closed'}`}>
108
+ <div className="sidebar-content">
109
+ <div className="sidebar-header">
110
+ <h3>Documents</h3>
111
+ <span className="doc-count">{documents.length}</span>
112
+ </div>
113
+
114
+ {/* Upload Zone */}
115
+ <div
116
+ className={`upload-zone ${isDragging ? 'dragging' : ''}`}
117
+ onDragOver={handleDragOver}
118
+ onDragLeave={handleDragLeave}
119
+ onDrop={handleDrop}
120
+ onClick={() => fileInputRef.current?.click()}
121
+ >
122
+ <input
123
+ ref={fileInputRef}
124
+ type="file"
125
+ accept=".pdf,.txt,.docx"
126
+ multiple
127
+ onChange={handleFileSelect}
128
+ hidden
129
+ />
130
+
131
+ <div className="upload-icon">
132
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
133
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" />
134
+ </svg>
135
+ </div>
136
+ <p className="upload-text">
137
+ {isDragging ? 'Drop files here' : 'Upload documents'}
138
+ </p>
139
+ <p className="upload-hint">PDF, TXT, DOCX</p>
140
+ </div>
141
+
142
+ {/* Upload Progress */}
143
+ {uploadProgress && (
144
+ <div className="upload-progress">
145
+ <div className="progress-info">
146
+ <span className="progress-name">{uploadProgress.name}</span>
147
+ <span className="progress-status">Uploading...</span>
148
+ </div>
149
+ <div className="progress-bar">
150
+ <div className="progress-fill animate-pulse" style={{ width: '100%' }} />
151
+ </div>
152
+ </div>
153
+ )}
154
+
155
+ {/* Document List */}
156
+ <div className="document-list">
157
+ {documents.length === 0 ? (
158
+ <div className="empty-docs">
159
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1">
160
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
161
+ <path d="M14 2v6h6M12 11v6M9 14h6" />
162
+ </svg>
163
+ <p>No documents yet</p>
164
+ <span>Upload files to get started</span>
165
+ </div>
166
+ ) : (
167
+ documents.map((doc) => (
168
+ <div key={doc.id} className={`document-item ${doc.status}`}>
169
+ <div className="doc-icon">{getFileIcon(doc.name)}</div>
170
+ <div className="doc-info">
171
+ <span className="doc-name">{doc.name}</span>
172
+ {doc.chunks && (
173
+ <span className="doc-meta">{doc.chunks} chunks</span>
174
+ )}
175
+ </div>
176
+ {getStatusIcon(doc.status)}
177
+ <button
178
+ className="doc-delete btn btn-ghost btn-icon"
179
+ onClick={() => onDeleteDocument(doc.id)}
180
+ aria-label="Delete document"
181
+ >
182
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
183
+ <path d="M18 6L6 18M6 6l12 12" />
184
+ </svg>
185
+ </button>
186
+ </div>
187
+ ))
188
+ )}
189
+ </div>
190
+ </div>
191
+ </aside>
192
+ )
193
+ }
194
+
195
+ export default Sidebar
frontend/src/components/TopNav.css ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .topnav {
2
+ grid-column: 1 / -1;
3
+ display: flex;
4
+ align-items: center;
5
+ justify-content: space-between;
6
+ padding: 0 var(--space-6);
7
+ background: var(--bg-secondary);
8
+ border-bottom: 1px solid var(--border-subtle);
9
+ position: sticky;
10
+ top: 0;
11
+ z-index: 100;
12
+ }
13
+
14
+ .topnav-left {
15
+ display: flex;
16
+ align-items: center;
17
+ gap: var(--space-4);
18
+ }
19
+
20
+ .sidebar-toggle {
21
+ display: flex;
22
+ }
23
+
24
+ .topnav-brand {
25
+ display: flex;
26
+ align-items: center;
27
+ gap: var(--space-3);
28
+ }
29
+
30
+ .brand-icon {
31
+ width: 36px;
32
+ height: 36px;
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: center;
36
+ background: var(--bg-glass);
37
+ border-radius: var(--radius-lg);
38
+ border: 1px solid var(--border-subtle);
39
+ }
40
+
41
+ .brand-name {
42
+ font-size: var(--text-lg);
43
+ font-weight: 600;
44
+ letter-spacing: -0.02em;
45
+ background: var(--accent-gradient);
46
+ -webkit-background-clip: text;
47
+ -webkit-text-fill-color: transparent;
48
+ background-clip: text;
49
+ }
50
+
51
+ .topnav-right {
52
+ display: flex;
53
+ align-items: center;
54
+ gap: var(--space-4);
55
+ }
56
+
57
+ .user-menu {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: var(--space-3);
61
+ }
62
+
63
+ .user-avatar {
64
+ width: 32px;
65
+ height: 32px;
66
+ border-radius: var(--radius-full);
67
+ background: var(--accent-gradient);
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+ font-size: var(--text-sm);
72
+ font-weight: 600;
73
+ color: white;
74
+ }
75
+
76
+ .user-name {
77
+ font-size: var(--text-sm);
78
+ color: var(--text-secondary);
79
+ }
80
+
81
+ /* Modal Styles */
82
+ .modal-overlay {
83
+ position: fixed;
84
+ inset: 0;
85
+ background: rgba(0, 0, 0, 0.7);
86
+ backdrop-filter: blur(4px);
87
+ display: flex;
88
+ align-items: center;
89
+ justify-content: center;
90
+ z-index: 1000;
91
+ animation: fadeIn var(--transition-fast) ease;
92
+ }
93
+
94
+ .modal {
95
+ width: 100%;
96
+ max-width: 400px;
97
+ margin: var(--space-4);
98
+ animation: fadeInUp var(--transition-base) ease;
99
+ }
100
+
101
+ .modal-header {
102
+ display: flex;
103
+ align-items: center;
104
+ justify-content: space-between;
105
+ margin-bottom: var(--space-6);
106
+ }
107
+
108
+ .modal-header h2 {
109
+ font-size: var(--text-xl);
110
+ font-weight: 600;
111
+ letter-spacing: -0.02em;
112
+ }
113
+
114
+ .auth-form {
115
+ display: flex;
116
+ flex-direction: column;
117
+ gap: var(--space-5);
118
+ }
119
+
120
+ .form-group {
121
+ display: flex;
122
+ flex-direction: column;
123
+ gap: var(--space-2);
124
+ }
125
+
126
+ .form-group label {
127
+ font-size: var(--text-sm);
128
+ font-weight: 500;
129
+ color: var(--text-secondary);
130
+ }
131
+
132
+ .auth-error {
133
+ display: flex;
134
+ align-items: center;
135
+ gap: var(--space-2);
136
+ padding: var(--space-3) var(--space-4);
137
+ background: var(--error-bg);
138
+ border: 1px solid rgba(239, 68, 68, 0.2);
139
+ border-radius: var(--radius-md);
140
+ color: var(--error);
141
+ font-size: var(--text-sm);
142
+ }
143
+
144
+ .auth-switch {
145
+ text-align: center;
146
+ font-size: var(--text-sm);
147
+ color: var(--text-secondary);
148
+ }
149
+
150
+ .auth-switch button {
151
+ background: none;
152
+ border: none;
153
+ color: var(--accent-primary);
154
+ cursor: pointer;
155
+ font-size: inherit;
156
+ padding: 0;
157
+ }
158
+
159
+ .auth-switch button:hover {
160
+ text-decoration: underline;
161
+ }
162
+
163
+ /* Loading Dots */
164
+ .loading-dots {
165
+ display: flex;
166
+ align-items: center;
167
+ gap: 4px;
168
+ }
169
+
170
+ .loading-dots span {
171
+ width: 6px;
172
+ height: 6px;
173
+ background: currentColor;
174
+ border-radius: 50%;
175
+ animation: bounce 1.4s ease-in-out infinite;
176
+ }
177
+
178
+ .loading-dots span:nth-child(1) {
179
+ animation-delay: 0s;
180
+ }
181
+
182
+ .loading-dots span:nth-child(2) {
183
+ animation-delay: 0.16s;
184
+ }
185
+
186
+ .loading-dots span:nth-child(3) {
187
+ animation-delay: 0.32s;
188
+ }
189
+
190
+ @media (max-width: 768px) {
191
+ .topnav {
192
+ padding: 0 var(--space-4);
193
+ }
194
+
195
+ .brand-name {
196
+ display: none;
197
+ }
198
+
199
+ .user-name {
200
+ display: none;
201
+ }
202
+ }
frontend/src/components/TopNav.jsx ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import './TopNav.css'
3
+
4
+ function TopNav({
5
+ user,
6
+ isAuthenticated,
7
+ onLogin,
8
+ onRegister,
9
+ onLogout,
10
+ onToggleSidebar,
11
+ sidebarOpen
12
+ }) {
13
+ const [showAuthModal, setShowAuthModal] = useState(false)
14
+ const [authMode, setAuthMode] = useState('login')
15
+ const [username, setUsername] = useState('')
16
+ const [password, setPassword] = useState('')
17
+ const [loading, setLoading] = useState(false)
18
+ const [error, setError] = useState('')
19
+
20
+ const handleSubmit = async (e) => {
21
+ e.preventDefault()
22
+ setLoading(true)
23
+ setError('')
24
+
25
+ try {
26
+ if (authMode === 'login') {
27
+ await onLogin(username, password)
28
+ } else {
29
+ await onRegister(username, password)
30
+ }
31
+ setShowAuthModal(false)
32
+ setUsername('')
33
+ setPassword('')
34
+ } catch (err) {
35
+ setError(err.message)
36
+ } finally {
37
+ setLoading(false)
38
+ }
39
+ }
40
+
41
+ return (
42
+ <>
43
+ <nav className="topnav">
44
+ <div className="topnav-left">
45
+ <button
46
+ className="btn btn-ghost btn-icon sidebar-toggle"
47
+ onClick={onToggleSidebar}
48
+ aria-label="Toggle sidebar"
49
+ >
50
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
51
+ {sidebarOpen ? (
52
+ <path d="M11 19l-7-7 7-7M18 19l-7-7 7-7" />
53
+ ) : (
54
+ <path d="M3 12h18M3 6h18M3 18h18" />
55
+ )}
56
+ </svg>
57
+ </button>
58
+
59
+ <div className="topnav-brand">
60
+ <div className="brand-icon">
61
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
62
+ <path
63
+ d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"
64
+ stroke="url(#brand-gradient)"
65
+ strokeWidth="2"
66
+ strokeLinecap="round"
67
+ strokeLinejoin="round"
68
+ />
69
+ <defs>
70
+ <linearGradient id="brand-gradient" x1="2" y1="2" x2="22" y2="22">
71
+ <stop stopColor="#6366f1" />
72
+ <stop offset="1" stopColor="#a855f7" />
73
+ </linearGradient>
74
+ </defs>
75
+ </svg>
76
+ </div>
77
+ <span className="brand-name">RAG Assistant</span>
78
+ </div>
79
+ </div>
80
+
81
+ <div className="topnav-right">
82
+ {isAuthenticated ? (
83
+ <div className="user-menu">
84
+ <div className="user-avatar">
85
+ {user?.[0]?.toUpperCase() || 'U'}
86
+ </div>
87
+ <span className="user-name">{user}</span>
88
+ <button className="btn btn-ghost btn-sm" onClick={onLogout}>
89
+ Logout
90
+ </button>
91
+ </div>
92
+ ) : (
93
+ <button
94
+ className="btn btn-primary btn-sm"
95
+ onClick={() => setShowAuthModal(true)}
96
+ >
97
+ Sign In
98
+ </button>
99
+ )}
100
+ </div>
101
+ </nav>
102
+
103
+ {/* Auth Modal */}
104
+ {showAuthModal && (
105
+ <div className="modal-overlay" onClick={() => setShowAuthModal(false)}>
106
+ <div className="modal card-glass" onClick={e => e.stopPropagation()}>
107
+ <div className="modal-header">
108
+ <h2>{authMode === 'login' ? 'Welcome Back' : 'Create Account'}</h2>
109
+ <button
110
+ className="btn btn-ghost btn-icon"
111
+ onClick={() => setShowAuthModal(false)}
112
+ >
113
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
114
+ <path d="M18 6L6 18M6 6l12 12" />
115
+ </svg>
116
+ </button>
117
+ </div>
118
+
119
+ <form onSubmit={handleSubmit} className="auth-form">
120
+ {error && (
121
+ <div className="auth-error">
122
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
123
+ <circle cx="12" cy="12" r="10" />
124
+ <path d="M12 8v4M12 16h.01" />
125
+ </svg>
126
+ {error}
127
+ </div>
128
+ )}
129
+
130
+ <div className="form-group">
131
+ <label htmlFor="username">Username</label>
132
+ <input
133
+ id="username"
134
+ type="text"
135
+ className="input"
136
+ value={username}
137
+ onChange={e => setUsername(e.target.value)}
138
+ placeholder="Enter username"
139
+ required
140
+ autoComplete="username"
141
+ />
142
+ </div>
143
+
144
+ <div className="form-group">
145
+ <label htmlFor="password">Password</label>
146
+ <input
147
+ id="password"
148
+ type="password"
149
+ className="input"
150
+ value={password}
151
+ onChange={e => setPassword(e.target.value)}
152
+ placeholder="Enter password"
153
+ required
154
+ autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
155
+ />
156
+ </div>
157
+
158
+ <button
159
+ type="submit"
160
+ className="btn btn-primary btn-lg"
161
+ disabled={loading}
162
+ style={{ width: '100%' }}
163
+ >
164
+ {loading ? (
165
+ <span className="loading-dots">
166
+ <span></span><span></span><span></span>
167
+ </span>
168
+ ) : authMode === 'login' ? 'Sign In' : 'Create Account'}
169
+ </button>
170
+
171
+ <div className="auth-switch">
172
+ {authMode === 'login' ? (
173
+ <>
174
+ Don't have an account?{' '}
175
+ <button type="button" onClick={() => setAuthMode('register')}>
176
+ Sign up
177
+ </button>
178
+ </>
179
+ ) : (
180
+ <>
181
+ Already have an account?{' '}
182
+ <button type="button" onClick={() => setAuthMode('login')}>
183
+ Sign in
184
+ </button>
185
+ </>
186
+ )}
187
+ </div>
188
+ </form>
189
+ </div>
190
+ </div>
191
+ )}
192
+ </>
193
+ )
194
+ }
195
+
196
+ export default TopNav
frontend/src/index.css ADDED
@@ -0,0 +1,479 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================
2
+ RAG Knowledge Assistant - Design System
3
+ ============================================ */
4
+
5
+ /* CSS Reset & Base */
6
+ *, *::before, *::after {
7
+ box-sizing: border-box;
8
+ margin: 0;
9
+ padding: 0;
10
+ }
11
+
12
+ :root {
13
+ /* Colors - Dark Theme */
14
+ --bg-primary: #0a0a0f;
15
+ --bg-secondary: #12121a;
16
+ --bg-tertiary: #1a1a24;
17
+ --bg-elevated: #22222e;
18
+ --bg-glass: rgba(255, 255, 255, 0.03);
19
+ --bg-glass-hover: rgba(255, 255, 255, 0.06);
20
+
21
+ /* Accent Colors */
22
+ --accent-primary: #6366f1;
23
+ --accent-secondary: #818cf8;
24
+ --accent-tertiary: #a5b4fc;
25
+ --accent-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a855f7 100%);
26
+ --accent-glow: 0 0 20px rgba(99, 102, 241, 0.4);
27
+
28
+ /* Text Colors */
29
+ --text-primary: #f4f4f5;
30
+ --text-secondary: #a1a1aa;
31
+ --text-tertiary: #71717a;
32
+ --text-inverse: #09090b;
33
+
34
+ /* Border Colors */
35
+ --border-subtle: rgba(255, 255, 255, 0.08);
36
+ --border-medium: rgba(255, 255, 255, 0.12);
37
+ --border-accent: rgba(99, 102, 241, 0.4);
38
+
39
+ /* Status Colors */
40
+ --success: #22c55e;
41
+ --success-bg: rgba(34, 197, 94, 0.1);
42
+ --error: #ef4444;
43
+ --error-bg: rgba(239, 68, 68, 0.1);
44
+ --warning: #f59e0b;
45
+ --warning-bg: rgba(245, 158, 11, 0.1);
46
+ --info: #3b82f6;
47
+ --info-bg: rgba(59, 130, 246, 0.1);
48
+
49
+ /* Typography */
50
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
51
+ --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
52
+
53
+ /* Font Sizes */
54
+ --text-xs: 0.75rem;
55
+ --text-sm: 0.875rem;
56
+ --text-base: 1rem;
57
+ --text-lg: 1.125rem;
58
+ --text-xl: 1.25rem;
59
+ --text-2xl: 1.5rem;
60
+ --text-3xl: 1.875rem;
61
+
62
+ /* Spacing */
63
+ --space-1: 0.25rem;
64
+ --space-2: 0.5rem;
65
+ --space-3: 0.75rem;
66
+ --space-4: 1rem;
67
+ --space-5: 1.25rem;
68
+ --space-6: 1.5rem;
69
+ --space-8: 2rem;
70
+ --space-10: 2.5rem;
71
+ --space-12: 3rem;
72
+ --space-16: 4rem;
73
+
74
+ /* Border Radius */
75
+ --radius-sm: 6px;
76
+ --radius-md: 8px;
77
+ --radius-lg: 12px;
78
+ --radius-xl: 16px;
79
+ --radius-2xl: 24px;
80
+ --radius-full: 9999px;
81
+
82
+ /* Shadows */
83
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
84
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
85
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
86
+ --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 8px 10px -6px rgba(0, 0, 0, 0.4);
87
+ --shadow-glow: 0 0 40px rgba(99, 102, 241, 0.15);
88
+
89
+ /* Transitions */
90
+ --transition-fast: 150ms ease;
91
+ --transition-base: 200ms ease;
92
+ --transition-slow: 300ms ease;
93
+ --transition-spring: 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
94
+
95
+ /* Layout */
96
+ --sidebar-width: 280px;
97
+ --topnav-height: 64px;
98
+ --chat-max-width: 800px;
99
+ }
100
+
101
+ /* Base Styles */
102
+ html {
103
+ font-size: 16px;
104
+ -webkit-font-smoothing: antialiased;
105
+ -moz-osx-font-smoothing: grayscale;
106
+ }
107
+
108
+ body {
109
+ font-family: var(--font-sans);
110
+ background: var(--bg-primary);
111
+ color: var(--text-primary);
112
+ line-height: 1.6;
113
+ min-height: 100vh;
114
+ overflow: hidden;
115
+ }
116
+
117
+ #root {
118
+ min-height: 100vh;
119
+ display: flex;
120
+ flex-direction: column;
121
+ }
122
+
123
+ /* Scrollbar Styling */
124
+ ::-webkit-scrollbar {
125
+ width: 6px;
126
+ height: 6px;
127
+ }
128
+
129
+ ::-webkit-scrollbar-track {
130
+ background: transparent;
131
+ }
132
+
133
+ ::-webkit-scrollbar-thumb {
134
+ background: var(--border-medium);
135
+ border-radius: var(--radius-full);
136
+ }
137
+
138
+ ::-webkit-scrollbar-thumb:hover {
139
+ background: var(--text-tertiary);
140
+ }
141
+
142
+ /* Focus Styles */
143
+ :focus-visible {
144
+ outline: 2px solid var(--accent-primary);
145
+ outline-offset: 2px;
146
+ }
147
+
148
+ /* Selection */
149
+ ::selection {
150
+ background: rgba(99, 102, 241, 0.3);
151
+ color: var(--text-primary);
152
+ }
153
+
154
+ /* ============================================
155
+ Utility Classes
156
+ ============================================ */
157
+
158
+ .glass {
159
+ background: var(--bg-glass);
160
+ backdrop-filter: blur(12px);
161
+ -webkit-backdrop-filter: blur(12px);
162
+ border: 1px solid var(--border-subtle);
163
+ }
164
+
165
+ .glass-strong {
166
+ background: rgba(18, 18, 26, 0.8);
167
+ backdrop-filter: blur(20px);
168
+ -webkit-backdrop-filter: blur(20px);
169
+ border: 1px solid var(--border-subtle);
170
+ }
171
+
172
+ /* ============================================
173
+ Button Styles
174
+ ============================================ */
175
+
176
+ .btn {
177
+ display: inline-flex;
178
+ align-items: center;
179
+ justify-content: center;
180
+ gap: var(--space-2);
181
+ padding: var(--space-3) var(--space-5);
182
+ font-family: var(--font-sans);
183
+ font-size: var(--text-sm);
184
+ font-weight: 500;
185
+ border-radius: var(--radius-lg);
186
+ border: none;
187
+ cursor: pointer;
188
+ transition: all var(--transition-base);
189
+ text-decoration: none;
190
+ white-space: nowrap;
191
+ }
192
+
193
+ .btn:disabled {
194
+ opacity: 0.5;
195
+ cursor: not-allowed;
196
+ }
197
+
198
+ .btn-primary {
199
+ background: var(--accent-gradient);
200
+ color: white;
201
+ box-shadow: var(--shadow-md), 0 0 20px rgba(99, 102, 241, 0.3);
202
+ }
203
+
204
+ .btn-primary:hover:not(:disabled) {
205
+ transform: translateY(-1px);
206
+ box-shadow: var(--shadow-lg), 0 0 30px rgba(99, 102, 241, 0.4);
207
+ }
208
+
209
+ .btn-primary:active:not(:disabled) {
210
+ transform: translateY(0);
211
+ }
212
+
213
+ .btn-secondary {
214
+ background: var(--bg-glass);
215
+ color: var(--text-primary);
216
+ border: 1px solid var(--border-subtle);
217
+ }
218
+
219
+ .btn-secondary:hover:not(:disabled) {
220
+ background: var(--bg-glass-hover);
221
+ border-color: var(--border-medium);
222
+ }
223
+
224
+ .btn-ghost {
225
+ background: transparent;
226
+ color: var(--text-secondary);
227
+ }
228
+
229
+ .btn-ghost:hover:not(:disabled) {
230
+ background: var(--bg-glass);
231
+ color: var(--text-primary);
232
+ }
233
+
234
+ .btn-icon {
235
+ padding: var(--space-2);
236
+ border-radius: var(--radius-md);
237
+ }
238
+
239
+ .btn-sm {
240
+ padding: var(--space-2) var(--space-3);
241
+ font-size: var(--text-xs);
242
+ }
243
+
244
+ .btn-lg {
245
+ padding: var(--space-4) var(--space-6);
246
+ font-size: var(--text-base);
247
+ }
248
+
249
+ /* ============================================
250
+ Input Styles
251
+ ============================================ */
252
+
253
+ .input {
254
+ width: 100%;
255
+ padding: var(--space-3) var(--space-4);
256
+ font-family: var(--font-sans);
257
+ font-size: var(--text-base);
258
+ color: var(--text-primary);
259
+ background: var(--bg-tertiary);
260
+ border: 1px solid var(--border-subtle);
261
+ border-radius: var(--radius-lg);
262
+ transition: all var(--transition-base);
263
+ }
264
+
265
+ .input::placeholder {
266
+ color: var(--text-tertiary);
267
+ }
268
+
269
+ .input:hover {
270
+ border-color: var(--border-medium);
271
+ }
272
+
273
+ .input:focus {
274
+ outline: none;
275
+ border-color: var(--accent-primary);
276
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
277
+ }
278
+
279
+ /* ============================================
280
+ Card Styles
281
+ ============================================ */
282
+
283
+ .card {
284
+ background: var(--bg-secondary);
285
+ border: 1px solid var(--border-subtle);
286
+ border-radius: var(--radius-xl);
287
+ padding: var(--space-6);
288
+ }
289
+
290
+ .card-glass {
291
+ background: var(--bg-glass);
292
+ backdrop-filter: blur(12px);
293
+ -webkit-backdrop-filter: blur(12px);
294
+ border: 1px solid var(--border-subtle);
295
+ border-radius: var(--radius-xl);
296
+ padding: var(--space-6);
297
+ }
298
+
299
+ /* ============================================
300
+ Animation Keyframes
301
+ ============================================ */
302
+
303
+ @keyframes fadeIn {
304
+ from {
305
+ opacity: 0;
306
+ }
307
+ to {
308
+ opacity: 1;
309
+ }
310
+ }
311
+
312
+ @keyframes fadeInUp {
313
+ from {
314
+ opacity: 0;
315
+ transform: translateY(10px);
316
+ }
317
+ to {
318
+ opacity: 1;
319
+ transform: translateY(0);
320
+ }
321
+ }
322
+
323
+ @keyframes slideInRight {
324
+ from {
325
+ opacity: 0;
326
+ transform: translateX(-10px);
327
+ }
328
+ to {
329
+ opacity: 1;
330
+ transform: translateX(0);
331
+ }
332
+ }
333
+
334
+ @keyframes pulse {
335
+ 0%, 100% {
336
+ opacity: 1;
337
+ }
338
+ 50% {
339
+ opacity: 0.5;
340
+ }
341
+ }
342
+
343
+ @keyframes bounce {
344
+ 0%, 80%, 100% {
345
+ transform: translateY(0);
346
+ }
347
+ 40% {
348
+ transform: translateY(-6px);
349
+ }
350
+ }
351
+
352
+ @keyframes shimmer {
353
+ 0% {
354
+ background-position: -200% 0;
355
+ }
356
+ 100% {
357
+ background-position: 200% 0;
358
+ }
359
+ }
360
+
361
+ @keyframes spin {
362
+ from {
363
+ transform: rotate(0deg);
364
+ }
365
+ to {
366
+ transform: rotate(360deg);
367
+ }
368
+ }
369
+
370
+ /* Animation Classes */
371
+ .animate-fade-in {
372
+ animation: fadeIn var(--transition-base) ease forwards;
373
+ }
374
+
375
+ .animate-fade-in-up {
376
+ animation: fadeInUp var(--transition-slow) ease forwards;
377
+ }
378
+
379
+ .animate-slide-in-right {
380
+ animation: slideInRight var(--transition-slow) ease forwards;
381
+ }
382
+
383
+ .animate-pulse {
384
+ animation: pulse 2s ease-in-out infinite;
385
+ }
386
+
387
+ .animate-spin {
388
+ animation: spin 1s linear infinite;
389
+ }
390
+
391
+ /* Skeleton Loading */
392
+ .skeleton {
393
+ background: linear-gradient(
394
+ 90deg,
395
+ var(--bg-tertiary) 0%,
396
+ var(--bg-elevated) 50%,
397
+ var(--bg-tertiary) 100%
398
+ );
399
+ background-size: 200% 100%;
400
+ animation: shimmer 1.5s ease-in-out infinite;
401
+ border-radius: var(--radius-md);
402
+ }
403
+
404
+ /* ============================================
405
+ Layout Components
406
+ ============================================ */
407
+
408
+ .app-layout {
409
+ display: grid;
410
+ grid-template-rows: var(--topnav-height) 1fr;
411
+ grid-template-columns: var(--sidebar-width) 1fr;
412
+ height: 100vh;
413
+ overflow: hidden;
414
+ }
415
+
416
+ .app-layout.sidebar-collapsed {
417
+ grid-template-columns: 0 1fr;
418
+ }
419
+
420
+ /* ============================================
421
+ Badge Styles
422
+ ============================================ */
423
+
424
+ .badge {
425
+ display: inline-flex;
426
+ align-items: center;
427
+ padding: var(--space-1) var(--space-2);
428
+ font-size: var(--text-xs);
429
+ font-weight: 500;
430
+ border-radius: var(--radius-full);
431
+ background: var(--bg-glass);
432
+ color: var(--text-secondary);
433
+ border: 1px solid var(--border-subtle);
434
+ }
435
+
436
+ .badge-success {
437
+ background: var(--success-bg);
438
+ color: var(--success);
439
+ border-color: rgba(34, 197, 94, 0.2);
440
+ }
441
+
442
+ .badge-error {
443
+ background: var(--error-bg);
444
+ color: var(--error);
445
+ border-color: rgba(239, 68, 68, 0.2);
446
+ }
447
+
448
+ .badge-warning {
449
+ background: var(--warning-bg);
450
+ color: var(--warning);
451
+ border-color: rgba(245, 158, 11, 0.2);
452
+ }
453
+
454
+ .badge-info {
455
+ background: var(--info-bg);
456
+ color: var(--info);
457
+ border-color: rgba(59, 130, 246, 0.2);
458
+ }
459
+
460
+ /* ============================================
461
+ Responsive Breakpoints
462
+ ============================================ */
463
+
464
+ @media (max-width: 1024px) {
465
+ :root {
466
+ --sidebar-width: 240px;
467
+ }
468
+ }
469
+
470
+ @media (max-width: 768px) {
471
+ :root {
472
+ --sidebar-width: 0;
473
+ --topnav-height: 56px;
474
+ }
475
+
476
+ .app-layout {
477
+ grid-template-columns: 1fr;
478
+ }
479
+ }
frontend/src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.jsx'
5
+
6
+ createRoot(document.getElementById('root')).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
frontend/vite.config.js ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // https://vitejs.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ server: {
8
+ port: 5173,
9
+ proxy: {
10
+ '/api': {
11
+ target: 'http://localhost:8000',
12
+ changeOrigin: true,
13
+ rewrite: (path) => path.replace(/^\/api/, '')
14
+ }
15
+ }
16
+ }
17
+ })
requirements.txt ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastAPI & Server
2
+ fastapi
3
+ uvicorn[standard]
4
+ python-multipart
5
+
6
+ protobuf==5.28.3
7
+ timm==0.9.16
8
+
9
+ # Authentication
10
+ python-jose[cryptography]
11
+ passlib[bcrypt]
12
+ python-dotenv
13
+
14
+ # PDF Processing
15
+ pymupdf
16
+
17
+ # Image Processing
18
+ pillow
19
+ clip @ git+https://github.com/openai/CLIP.git
20
+
21
+ # Machine Learning & Embeddings
22
+ torch
23
+ transformers
24
+ sentence-transformers
25
+ faiss-cpu
26
+
27
+ # RAG Components
28
+ langchain
29
+ tqdm
30
+
31
+ # Audio Processing (optional)
32
+ openai-whisper
33
+
34
+ # Google Generative AI
35
+ google-genai
36
+
37
+ # Utilities
38
+ numpy
39
+ pydantic