Spaces:
Sleeping
Sleeping
Commit ·
2db8ee1
0
Parent(s):
feat: add frontend and backend code for multimodal RAG knowledge assistant
Browse files- .gitignore +29 -0
- backend/auth.py +46 -0
- backend/ingest/audio_ingest.py +7 -0
- backend/ingest/image_ingest.py +15 -0
- backend/ingest/pdf_ingest.py +60 -0
- backend/main.py +288 -0
- backend/rag/generator.py +57 -0
- backend/rag/reranker.py +17 -0
- backend/test-rag.py +138 -0
- backend/test.py +5 -0
- backend/test_standalone_image.py +13 -0
- backend/testv.py +20 -0
- backend/utils/text_utils.py +23 -0
- backend/vectorstore/chunk_index.py +80 -0
- backend/vectorstore/document_index.py +46 -0
- backend/vectorstore/image_store.py +52 -0
- backend/vectorstore/index_manager.py +10 -0
- backend/vectorstore/text_store.py +29 -0
- frontend/index.html +17 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +23 -0
- frontend/public/vite.svg +16 -0
- frontend/src/App.jsx +245 -0
- frontend/src/components/ChatArea.css +332 -0
- frontend/src/components/ChatArea.jsx +246 -0
- frontend/src/components/MessageBubble.css +288 -0
- frontend/src/components/MessageBubble.jsx +162 -0
- frontend/src/components/Sidebar.css +259 -0
- frontend/src/components/Sidebar.jsx +195 -0
- frontend/src/components/TopNav.css +202 -0
- frontend/src/components/TopNav.jsx +196 -0
- frontend/src/index.css +479 -0
- frontend/src/main.jsx +10 -0
- frontend/vite.config.js +17 -0
- requirements.txt +39 -0
.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
|