diff --git a/.env.example b/.env.example index c8da31d0d5a3f29d943f1f91e8b825a828ca0cf5..53ed20aab5f0b06d286b577879b7d8d623e43cf7 100644 --- a/.env.example +++ b/.env.example @@ -91,6 +91,24 @@ HF_TOKEN=your_huggingface_token_here # Optional — defaults to 1024 # LLM_MAX_NEW_TOKENS=1024 +# ── LangSmith Tracing (Optional) ──────────────────────── + +# Enable LangSmith tracing for the backend RAG pipeline. +# Optional — defaults to False +# LANGSMITH_TRACING=False + +# LangSmith API key. +# Optional — only needed when LANGSMITH_TRACING=True +# LANGSMITH_API_KEY= + +# LangSmith API endpoint. +# Optional — defaults to "https://api.smith.langchain.com" +# LANGSMITH_ENDPOINT=https://api.smith.langchain.com + +# LangSmith project name used for traced runs. +# Optional — defaults to "pdf-assistant-rag" +# LANGSMITH_PROJECT=pdf-assistant-rag + # ── Embeddings (Optional — defaults shown)────────────────────────────────────────────── # SentenceTransformer model ID for generating document embeddings. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9d30e712e0027f24591993e6d739ed57cd8997e0..63568be475ad2ccfe563e8cce1889f38a725d458 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,84 +1,65 @@ -name: 🐛 Bug Report -description: Report a bug or unexpected behavior -labels: ["bug", "needs-triage"] -assignees: [] - +name: "\U0001f41b Bug Report" +description: "Create a report to help us improve the project by fixing a bug." +title: "[BUG] " +labels: ["bug"] +assignees: + - "param20h" body: - type: markdown attributes: value: | - Thanks for taking the time to file a bug report! Please fill this out as completely as possible. + Thanks for taking the time to fill out this bug report! + Before you submit, please search the issue tracker to see if this has already been reported. - type: textarea id: description attributes: - label: Describe the Bug - description: A clear description of what the bug is. - placeholder: "When I do X, Y happens instead of Z." + label: "Description of the Bug" + description: "A clear and concise description of what the bug is." + placeholder: "When I click on X, nothing happens..." validations: required: true - type: textarea id: reproduction attributes: - label: Steps to Reproduce - description: How do we reproduce this bug? - placeholder: | + label: "Steps to Reproduce" + description: "How can we reproduce this issue?" + value: | 1. Go to '...' - 2. Click on '...' - 3. See error + 2. Click on '....' + 3. Scroll down to '....' + 4. See error validations: required: true - type: textarea id: expected attributes: - label: Expected Behavior - description: What should have happened? + label: "Expected Behavior" + description: "A clear and concise description of what you expected to happen." validations: required: true - type: textarea id: screenshots attributes: - label: Screenshots / Logs - description: Paste any relevant error output or screenshots here. + label: "Screenshots / Logs" + description: "If applicable, add screenshots or error logs to help explain your problem." - - type: dropdown - id: area + - type: input + id: environment attributes: - label: Area Affected - multiple: true - options: - - Backend (FastAPI) - - Frontend (Next.js) - - RAG / Embeddings - - Authentication - - File Upload - - Chat / Streaming - - Docker / Deployment - - Documentation + label: "Environment" + description: "What OS, browser, or environment were you using?" + placeholder: "e.g., macOS Sequoia, Chrome 120, Node.js v20" validations: required: true - - type: input - id: python-version - attributes: - label: Python Version (if backend issue) - placeholder: "e.g. 3.11" - - - type: input - id: node-version - attributes: - label: Node.js Version (if frontend issue) - placeholder: "e.g. 20.x" - - type: checkboxes - id: checklist + id: gssoc attributes: - label: Checklist + label: "GSSoC '24" + description: "Are you a GSSoC contributor?" options: - - label: I have searched existing issues and this is not a duplicate. - required: true - - label: I am targeting the `dev` branch, not `main`. - required: true + - label: "Yes, I am participating in GirlScript Summer of Code and would like to fix this." diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 9aaed7ef985377c016c5c67611c76175c2413ea5..aab3a33e2b8dfba51eda4b68130efae8ea86e00f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,69 +1,48 @@ -name: ✨ Feature Request -description: Suggest a new feature or enhancement -labels: ["enhancement", "needs-triage"] -assignees: [] - +name: "\U0001f680 Feature Request" +description: "Suggest an idea for this project." +title: "[FEAT] " +labels: ["enhancement"] +assignees: + - "param20h" body: - type: markdown attributes: value: | - Got an idea? Great! Please describe it clearly so we can discuss and prioritize it. + Thanks for taking the time to suggest a new feature! + Please provide as much context as possible so we can properly evaluate your idea. - type: textarea id: problem attributes: - label: Problem / Motivation - description: What problem does this solve? What's the current limitation? - placeholder: "I find it frustrating when..." + label: "Is your feature request related to a problem? Please describe." + description: "A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]" validations: required: true - type: textarea id: solution attributes: - label: Proposed Solution - description: What do you want to happen? + label: "Describe the solution you'd like" + description: "A clear and concise description of what you want to happen." validations: required: true - type: textarea id: alternatives attributes: - label: Alternatives Considered - description: Any other approaches you considered? - - - type: dropdown - id: area - attributes: - label: Which area does this affect? - multiple: true - options: - - Backend (FastAPI) - - Frontend (Next.js) - - RAG / Embeddings - - Authentication - - File Upload - - Chat / Streaming - - Docker / Deployment - - Documentation - - New Area - validations: - required: true + label: "Describe alternatives you've considered" + description: "A clear and concise description of any alternative solutions or features you've considered." - - type: dropdown - id: difficulty + - type: textarea + id: additional_context attributes: - label: Estimated Difficulty - options: - - "🟢 Easy (good first issue)" - - "🟡 Medium" - - "🔴 Hard / Needs discussion" + label: "Additional Context" + description: "Add any other context, screenshots, or mockups about the feature request here." - type: checkboxes - id: checklist + id: gssoc attributes: - label: Checklist + label: "GSSoC '24" + description: "Are you a GSSoC contributor?" options: - - label: I have searched existing issues and this is not a duplicate. - required: true - - label: I am willing to work on this myself (optional but appreciated!). + - label: "Yes, I am participating in GirlScript Summer of Code and would like to build this." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43c42c6211fc9e2f9c1c71e17b27aa445b23f1db..60eb7b49983a57361a3e90394c655cd378a49d35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,16 +48,18 @@ jobs: env: SECRET_KEY: ci-dummy-secret DATABASE_URL: sqlite:///./ci_test.db + DEBUG: "false" HF_TOKEN: ci-dummy-token UPLOAD_DIR: /tmp/uploads CHROMA_PERSIST_DIR: /tmp/chroma run: | - python -c "import sys; sys.path.insert(0, 'backend'); from app.config import settings; print('✅ Config imports OK')" || true + python -c "import sys; sys.path.insert(0, 'backend'); from app.config import get_settings; get_settings(); print('Config imports OK')" - name: Run backend pytest suite env: SECRET_KEY: ci-dummy-secret DATABASE_URL: sqlite:///./ci_test.db + DEBUG: "false" HF_TOKEN: ci-dummy-token UPLOAD_DIR: /tmp/uploads CHROMA_PERSIST_DIR: /tmp/chroma diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000000000000000000000000000000000..0f588680fed0966aa58074c0a382e59b010a211f --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,85 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available +at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000000000000000000000000000000000..2b5f16c84ecae3a47a7253ef5a47b02d79ce61c2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,26 @@ +# Security Policy + +## Supported Versions + +Currently, the following branches and versions of PDF-Assistant-RAG are supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| `dev` | :white_check_mark: | +| `main` | :white_check_mark: | +| < 1.0 | :x: | + +## Reporting a Vulnerability + +We take the security of our users and their data very seriously. If you discover a security vulnerability in this project, please **do not** report it by creating a public GitHub issue. + +Instead, please privately report it by emailing the repository owner directly. + +When reporting a vulnerability, please include: +* A detailed description of the vulnerability. +* The steps required to reproduce the vulnerability. +* Any potential impact or risk to users. + +We will acknowledge your email within 48 hours and work with you to understand and resolve the issue. We aim to fix critical security issues as fast as possible and will credit you in the release notes if you wish. + +Thank you for helping keep this project secure! diff --git a/backend/app/auth.py b/backend/app/auth.py index 47d462e5f54ee14b1ab12e6a0a445ade47a1513e..7cbdaa8fe9a20ee83a53388f7cd80b0ba1c71bae 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -67,12 +67,39 @@ def decode_token(token: str, token_type: str = "access") -> Optional[str]: # ── FastAPI Dependencies ───────────────────────────── +import hashlib + def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db), ) -> User: - """Dependency: extract and validate user from JWT bearer token.""" + """Dependency: extract and validate user from JWT bearer token or API key.""" token = credentials.credentials + + # Check if token is an API key + if token.startswith("rag_"): + hashed = hashlib.sha256(token.encode("utf-8")).hexdigest() + from app.models import ApiKey + api_key = db.query(ApiKey).filter(ApiKey.hashed_key == hashed).first() + if not api_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key", + headers={"WWW-Authenticate": "Bearer"}, + ) + + api_key.last_used = datetime.now(timezone.utc) + db.commit() + + user = api_key.user + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found for this API key", + ) + return user + + # Otherwise, process as JWT user_id = decode_token(token) if not user_id: diff --git a/backend/app/config.py b/backend/app/config.py index 4c61763f9560c2868b166f0c9add9af44e2fa448..99be5b4a9a04eb77339ec17d064c9bd15ba4e3ca 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -56,8 +56,18 @@ class Settings(BaseSettings): LLM_TEMPERATURE: float = 0.3 SUMMARY_MAX_TOKENS: int = 512 + # ── LangSmith Tracing (optional) ───────────────────── + LANGSMITH_TRACING: bool = False + LANGSMITH_API_KEY: str = "" + LANGSMITH_ENDPOINT: str = "https://api.smith.langchain.com" + LANGSMITH_PROJECT: str = "pdf-assistant-rag" + # ── Reranker ───────────────────────────────────────── RERANKER_MODEL: str = "cross-encoder/ms-marco-MiniLM-L-6-v2" + # ── Vision / Image captioning ───────────────────── + VISION_PROVIDER: str | None = None # e.g. 'openai' + VISION_MODEL: str | None = None + OPENAI_API_KEY: str = "" @property diff --git a/backend/app/database.py b/backend/app/database.py index 8109a5b1b25beea3f2bbfa3e96adf7bc4a5d4595..3e5a170ac0f7e4d51631bebbba250cc7d003574d 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -3,11 +3,13 @@ SQLAlchemy database setup with SQLite. Uses synchronous SQLAlchemy for simplicity and compatibility. """ import os -from sqlalchemy import create_engine +import logging +from sqlalchemy import create_engine, inspect, text from sqlalchemy.orm import sessionmaker, declarative_base from app.config import get_settings settings = get_settings() +logger = logging.getLogger(__name__) # ── Ensure data directory exists ───────────────────── db_path = settings.DATABASE_URL.replace("sqlite:///", "") @@ -34,7 +36,34 @@ def get_db(): db.close() +def _migrate_schema(): + """Apply schema migrations for existing databases (SQLite-compatible). + + SQLAlchemy's ``create_all`` only creates new tables and does **not** + add missing columns to existing tables. This helper fills that gap + for non-destructive changes such as new nullable columns. + """ + inspector = inspect(engine) + existing_columns = {c["name"] for c in inspector.get_columns("users")} + + migrations = [ + ("users", "hf_token", "ALTER TABLE users ADD COLUMN hf_token VARCHAR(255)"), + ] + + for table, column, ddl in migrations: + if column not in existing_columns: + try: + with engine.begin() as conn: + conn.execute(text(ddl)) + logger.info("Migration: added column %s.%s", table, column) + except Exception: + logger.warning( + "Migration skipped (may already exist): %s.%s", table, column + ) + + def init_db(): - """Create all tables on startup.""" + """Create all tables on startup and apply schema migrations.""" from app import models # noqa: F401 — import to register models Base.metadata.create_all(bind=engine) + _migrate_schema() diff --git a/backend/app/main.py b/backend/app/main.py index 71bb34bcf75ff187186ce2ee15062b7c66a6dab3..8703d26860f9b8716e28857fbea29e2f0bc2020c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -92,11 +92,13 @@ from app.routes.auth import router as auth_router from app.routes.documents import router as documents_router from app.routes.chat import router as chat_router from app.routes.github import router as github_router +from app.routes.admin import router as admin_router app.include_router(auth_router, prefix="/api/v1") app.include_router(documents_router, prefix="/api/v1") app.include_router(chat_router, prefix="/api/v1") app.include_router(github_router, prefix="/api/v1") +app.include_router(admin_router, prefix="/api/v1") # ── Health Check ───────────────────────────────────── diff --git a/backend/app/metrics.py b/backend/app/metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..339f41db92402b4b605a196e50ec48d40acbd516 --- /dev/null +++ b/backend/app/metrics.py @@ -0,0 +1,33 @@ +""" +Runtime metrics helpers for lightweight operational statistics. +""" +from threading import Lock + + +_metrics_lock = Lock() +_query_count = 0 +_query_response_time_total_ms = 0.0 + + +def record_query_response_time(duration_seconds: float) -> None: + """Record one completed query response duration.""" + global _query_count, _query_response_time_total_ms + + duration_ms = max(duration_seconds, 0) * 1000 + with _metrics_lock: + _query_count += 1 + _query_response_time_total_ms += duration_ms + + +def get_query_metrics() -> dict[str, float | int]: + """Return aggregate query metrics for the current process lifetime.""" + with _metrics_lock: + average_ms = ( + _query_response_time_total_ms / _query_count + if _query_count + else 0.0 + ) + return { + "query_count": _query_count, + "average_query_response_time_ms": round(average_ms, 2), + } diff --git a/backend/app/models.py b/backend/app/models.py index 2f15ab6fa48b81b085d9a7d948ef7a8a0735125c..7330caf20a74331d2495599c7e5a282e599a99e2 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -22,10 +22,26 @@ class User(Base): is_admin = Column(Boolean, default=False) created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) last_login = Column(DateTime, nullable=True, index=True) + hf_token = Column(String(255), nullable=True) # Relationships documents = relationship("Document", back_populates="owner", cascade="all, delete-orphan") messages = relationship("ChatMessage", back_populates="user", cascade="all, delete-orphan") + api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan") + + +class ApiKey(Base): + __tablename__ = "api_keys" + + id = Column(String, primary_key=True, default=generate_uuid) + user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True) + key_prefix = Column(String(10), nullable=False) + hashed_key = Column(String(255), nullable=False, unique=True, index=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + last_used = Column(DateTime, nullable=True) + + # Relationships + user = relationship("User", back_populates="api_keys") class Document(Base): @@ -62,3 +78,15 @@ class ChatMessage(Base): # Relationships user = relationship("User", back_populates="messages") document = relationship("Document", back_populates="messages") + shared_message = relationship("SharedMessage", back_populates="message", uselist=False, cascade="all, delete-orphan") + + +class SharedMessage(Base): + __tablename__ = "shared_messages" + + id = Column(String, primary_key=True, default=generate_uuid) + message_id = Column(String, ForeignKey("chat_messages.id"), nullable=False, unique=True, index=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + # Relationships + message = relationship("ChatMessage", back_populates="shared_message") diff --git a/backend/app/rag/agent.py b/backend/app/rag/agent.py index e62e817dae664559ab31b9c3c4408def239acadd..badf99e27ae48ba7af1bf305aba55c1196b98e86 100644 --- a/backend/app/rag/agent.py +++ b/backend/app/rag/agent.py @@ -10,6 +10,7 @@ from huggingface_hub import InferenceClient from app.config import get_settings from app.rag.retriever import retrieve from app.rag.prompts import SYSTEM_PROMPT, RAG_PROMPT_TEMPLATE, GREETING_PROMPT +from app.rag.tracing import trace_function logger = logging.getLogger(__name__) settings = get_settings() @@ -65,6 +66,14 @@ def _chat_messages(system: str, user_content: str) -> list: ] +@trace_function( + "generate_answer", + metadata_factory=lambda question, user_id, document_id=None: { + "user_id": user_id, + "document_id": document_id, + "llm_model": settings.LLM_MODEL, + }, +) def generate_answer( question: str, user_id: str, @@ -145,6 +154,14 @@ def generate_answer( return {"answer": answer, "sources": sources} +@trace_function( + "generate_answer_stream", + metadata_factory=lambda question, user_id, document_id=None: { + "user_id": user_id, + "document_id": document_id, + "llm_model": settings.LLM_MODEL, + }, +) def generate_answer_stream( question: str, user_id: str, diff --git a/backend/app/rag/chunker.py b/backend/app/rag/chunker.py index 9da560bd3a735b618a890eba7aea7bdaf29d72d3..fc171d83442bc099512f82be161d046cd99b5c20 100644 --- a/backend/app/rag/chunker.py +++ b/backend/app/rag/chunker.py @@ -28,6 +28,34 @@ def extract_pdf(filepath: str) -> List[Dict[str, Any]]: return pages +def extract_pdf_images(filepath: str) -> List[Dict[str, Any]]: + """Extract images from a PDF and return list of dicts with image bytes and page number. + + Each entry: {"image_bytes": b"...", "page": int} + """ + images = [] + doc = fitz.open(filepath) + + for page_num, page in enumerate(doc): + # get_images returns a list of tuples where first item is xref + for img in page.get_images(full=True): + xref = img[0] + try: + pix = fitz.Pixmap(doc, xref) + # Convert to RGB if it's CMYK or has alpha + if pix.n >= 4: + pix = fitz.Pixmap(fitz.csRGB, pix) + + img_bytes = pix.tobytes("png") + images.append({"image_bytes": img_bytes, "page": page_num + 1}) + except Exception: + # ignore extracting this image + continue + + doc.close() + return images + + def extract_docx(filepath: str) -> List[Dict[str, Any]]: """Extract text from DOCX files.""" doc = docx.Document(filepath) @@ -50,10 +78,13 @@ def chunk_document(filepath: str) -> List[Dict[str, Any]]: Returns list of dicts with 'text', 'page', and 'chunk_index'. """ ext = filepath.rsplit(".", 1)[-1].lower() + images = [] # ── Extract text by file type ──────────────────── if ext == "pdf": pages = extract_pdf(filepath) + # also extract images for later captioning/embedding + images = extract_pdf_images(filepath) elif ext == "docx": pages = extract_docx(filepath) elif ext in ("txt", "md"): @@ -91,6 +122,16 @@ def chunk_document(filepath: str) -> List[Dict[str, Any]]: }) chunk_index += 1 + # Attach any images that belong to this page after text chunks for the page + for img in [i for i in images if i["page"] == page_num]: + all_chunks.append({ + "text": "", + "page": page_num, + "chunk_index": chunk_index, + "image_bytes": img["image_bytes"], + }) + chunk_index += 1 + return all_chunks diff --git a/backend/app/rag/embeddings.py b/backend/app/rag/embeddings.py index 16a80e39b3098b27b6dd4c8e270b8e10af5080a9..219741c903b556ca0b355a446ec2cc2b93a5c26e 100644 --- a/backend/app/rag/embeddings.py +++ b/backend/app/rag/embeddings.py @@ -6,6 +6,7 @@ import logging from typing import List from langchain_huggingface import HuggingFaceEmbeddings from app.config import get_settings +from app.rag.tracing import trace_call logger = logging.getLogger(__name__) settings = get_settings() @@ -36,10 +37,26 @@ def get_embedding_model() -> HuggingFaceEmbeddings: def embed_texts(texts: List[str]) -> List[List[float]]: """Embed a batch of texts into vectors.""" model = get_embedding_model() - return model.embed_documents(texts) + return trace_call( + "embed_texts", + lambda: model.embed_documents(texts), + run_type="embedding", + metadata={ + "embedding_model": settings.EMBEDDING_MODEL, + "text_count": len(texts), + }, + ) def embed_query(query: str) -> List[float]: """Embed a single query string.""" model = get_embedding_model() - return model.embed_query(query) + return trace_call( + "embed_query", + lambda: model.embed_query(query), + run_type="embedding", + metadata={ + "embedding_model": settings.EMBEDDING_MODEL, + "query_length": len(query), + }, + ) diff --git a/backend/app/rag/retriever.py b/backend/app/rag/retriever.py index 57053c9a31a6ec3dd637f393d5e6178d278fcd94..21f9e9e8ef6722c8ffbeab0b1083ea6eac52f1bc 100644 --- a/backend/app/rag/retriever.py +++ b/backend/app/rag/retriever.py @@ -5,6 +5,7 @@ import logging from typing import List, Dict, Any, Optional from app.config import get_settings from app.rag.embeddings import embed_query +from app.rag.tracing import trace_function from app.rag.vectorstore import query_chunks logger = logging.getLogger(__name__) @@ -31,6 +32,17 @@ def get_reranker(): return _reranker if _reranker != "disabled" else None +@trace_function( + "retrieve", + metadata_factory=lambda query, user_id, document_id=None: { + "user_id": user_id, + "document_id": document_id, + "embedding_model": settings.EMBEDDING_MODEL, + "reranker_model": settings.RERANKER_MODEL, + "top_k_retrieval": settings.TOP_K_RETRIEVAL, + "top_k_rerank": settings.TOP_K_RERANK, + }, +) def retrieve( query: str, user_id: str, diff --git a/backend/app/rag/tracing.py b/backend/app/rag/tracing.py new file mode 100644 index 0000000000000000000000000000000000000000..f95e8b18d1d626f4e9988fe84e4926f0ba550b0f --- /dev/null +++ b/backend/app/rag/tracing.py @@ -0,0 +1,102 @@ +""" +Optional LangSmith tracing helpers for the RAG pipeline. +Safe to import even when LangSmith is not installed or configured. +""" +import logging +import os +from functools import wraps +from typing import Any, Callable, Optional + +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +try: + from langsmith import traceable as _langsmith_traceable +except Exception: # pragma: no cover - optional dependency safety + _langsmith_traceable = None + + +def configure_langsmith() -> bool: + """Configure LangSmith environment variables when tracing is enabled.""" + if not settings.LANGSMITH_TRACING: + return False + + if not settings.LANGSMITH_API_KEY: + logger.warning("LangSmith tracing enabled but LANGSMITH_API_KEY is not set; tracing disabled.") + return False + + os.environ["LANGSMITH_TRACING"] = "true" + os.environ["LANGSMITH_API_KEY"] = settings.LANGSMITH_API_KEY + os.environ["LANGSMITH_ENDPOINT"] = settings.LANGSMITH_ENDPOINT + os.environ["LANGSMITH_PROJECT"] = settings.LANGSMITH_PROJECT + return _langsmith_traceable is not None + + +LANGSMITH_ENABLED = configure_langsmith() + + +def _sanitize_metadata(metadata: Optional[dict[str, Any]]) -> dict[str, Any]: + return {key: value for key, value in (metadata or {}).items() if value is not None} + + +def _build_traceable(name: str, run_type: str, metadata: Optional[dict[str, Any]] = None): + """Build a LangSmith traceable decorator safely across versions.""" + if _langsmith_traceable is None: + return None + + sanitized = _sanitize_metadata(metadata) + try: + return _langsmith_traceable( + name=name, + run_type=run_type, + metadata=sanitized or None, + ) + except TypeError: + return _langsmith_traceable(name=name, run_type=run_type) + + +def trace_call( + name: str, + fn: Callable[..., Any], + *args: Any, + run_type: str = "chain", + metadata: Optional[dict[str, Any]] = None, + **kwargs: Any, +) -> Any: + """Execute a callable with LangSmith tracing when available.""" + if not LANGSMITH_ENABLED: + return fn(*args, **kwargs) + + decorator = _build_traceable(name, run_type, metadata) + if decorator is None: + return fn(*args, **kwargs) + + traced_fn = decorator(fn) + return traced_fn(*args, **kwargs) + + +def trace_function( + name: str, + *, + run_type: str = "chain", + metadata_factory: Optional[Callable[..., dict[str, Any]]] = None, +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Decorator wrapper that becomes a no-op when LangSmith is disabled.""" + def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: + @wraps(fn) + def wrapped(*args: Any, **kwargs: Any) -> Any: + metadata = metadata_factory(*args, **kwargs) if metadata_factory else None + return trace_call( + name, + fn, + *args, + run_type=run_type, + metadata=metadata, + **kwargs, + ) + + return wrapped + + return decorator diff --git a/backend/app/rag/vectorstore.py b/backend/app/rag/vectorstore.py index aca299eaac348d9e33c0c8c531858b739455ea1c..aa6e6159d5d10af2405a588e1315ed20672bcafa 100644 --- a/backend/app/rag/vectorstore.py +++ b/backend/app/rag/vectorstore.py @@ -4,10 +4,7 @@ Per-user collections for data isolation. """ import logging from typing import List, Dict, Any, Optional -import chromadb -from chromadb.config import Settings as ChromaSettings from app.config import get_settings -from app.rag.embeddings import get_embedding_model logger = logging.getLogger(__name__) settings = get_settings() @@ -16,12 +13,15 @@ settings = get_settings() _chroma_client = None -def get_chroma_client() -> chromadb.ClientAPI: +def get_chroma_client(): """Get or create persistent ChromaDB client.""" global _chroma_client if _chroma_client is None: import os + import chromadb + from chromadb.config import Settings as ChromaSettings + os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True) _chroma_client = chromadb.PersistentClient( @@ -55,7 +55,17 @@ def store_chunks( if not chunks: return 0 + # Generate captions for any extracted images before embedding + try: + from app.rag.vision import generate_captions_for_chunks + + generate_captions_for_chunks(chunks) + except Exception as e: + logger.warning(f"Could not generate image captions: {e}") + client = get_chroma_client() + from app.rag.embeddings import get_embedding_model + embedding_model = get_embedding_model() collection_name = get_collection_name(user_id) @@ -74,6 +84,9 @@ def store_chunks( "document_id": document_id, "page": chunk["page"], "chunk_index": chunk["chunk_index"], + # Indicate whether this chunk was originally an image and include a short caption + **({"is_image": True, "image_caption": chunk.get("image_caption", "")} + if chunk.get("is_image") else {}), } for chunk in chunks ] diff --git a/backend/app/rag/vision.py b/backend/app/rag/vision.py new file mode 100644 index 0000000000000000000000000000000000000000..a84390d513b98707cd17e4bfc83d7f043cdd04e3 --- /dev/null +++ b/backend/app/rag/vision.py @@ -0,0 +1,99 @@ +"""Image captioning / vision helpers for RAG pipeline. + +Provides a simple, pluggable interface to generate textual descriptions +for images extracted from PDFs. By default it uses local OCR (pytesseract) +when available as a robust fallback. An external VLM provider (OpenAI) +can be integrated by setting `VISION_PROVIDER` and appropriate API keys +in settings; the provider hook is intentionally small and optional. +""" +import logging +from typing import List, Dict, Any +from io import BytesIO + +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + + +def _ocr_caption(image_bytes: bytes) -> str: + """Try to produce a caption using pytesseract OCR; returns empty string if not available.""" + try: + from PIL import Image + import pytesseract + except Exception: + return "" + + try: + img = Image.open(BytesIO(image_bytes)).convert("RGB") + text = pytesseract.image_to_string(img) + text = text.strip() + return text + except Exception as e: + logger.debug(f"OCR failed: {e}") + return "" + + +def caption_image(image_bytes: bytes, page: int | None = None) -> str: + """Generate a caption for a single image. + + Order of operations: + - If an external VLM provider is configured, attempt to call it (not implemented as mandatory). + - Fall back to local OCR (pytesseract) if available. + - Otherwise return a simple placeholder caption including the page number. + """ + # Placeholder for provider-based captioning (e.g., OpenAI / LLaVA hooks) + provider = getattr(settings, "VISION_PROVIDER", None) + if provider == "openai": + try: + import openai + # Minimal integration: attempt a text-only caption via responses if available. + # This is a best-effort hook; users should adapt to their provider's API. + api_key = getattr(settings, "OPENAI_API_KEY", None) + if api_key: + openai.api_key = api_key + # Use a generic prompt: "Describe the following image" + # Note: concrete multimodal API usage may vary across SDK versions. + resp = openai.Image.create( + prompt="Describe this image in one concise sentence.", + n=1, + # We do not re-upload image bytes here; this is a placeholder to show + # where provider code would be invoked. For production, follow + # provider docs for sending image data. + ) + # openai.Image.create returns generated images, not captions — so skip. + except Exception: + # If provider integration fails, fall back to OCR below + logger.debug("OpenAI vision provider failed, falling back to OCR") + + # Try OCR caption + ocr = _ocr_caption(image_bytes) + if ocr: + # Keep it short if very long + return (ocr[:500] + "...") if len(ocr) > 500 else ocr + + # Last-resort caption + if page: + return f"Image on page {page}." + return "Image." + + +def generate_captions_for_chunks(chunks: List[Dict[str, Any]]) -> None: + """Mutate chunks in-place: for any chunk containing `image_bytes` but empty `text`, + generate a caption and set `text`. + """ + for chunk in chunks: + if chunk.get("image_bytes") and not chunk.get("text"): + try: + caption = caption_image(chunk["image_bytes"], page=chunk.get("page")) + chunk["text"] = caption + # Remove raw bytes to avoid accidentally serializing them later + chunk.pop("image_bytes", None) + chunk["is_image"] = True + chunk["image_caption"] = caption + except Exception as e: + logger.debug(f"Failed to caption image chunk: {e}") + # ensure we still mark it as image to avoid losing it + chunk.pop("image_bytes", None) + chunk["is_image"] = True + chunk.setdefault("text", f"Image on page {chunk.get('page')}") diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..aa57f65e3b0a164a224cb87fa1f2ec48f656fcbd --- /dev/null +++ b/backend/app/routes/admin.py @@ -0,0 +1,73 @@ +""" +Admin-only operational statistics routes. +""" +import shutil +from pathlib import Path + +from fastapi import APIRouter, Depends +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.auth import get_admin_user +from app.config import get_settings +from app.database import get_db +from app.metrics import get_query_metrics +from app.models import Document, User +from app.schemas import AdminStatsResponse, DiskUsageResponse + +router = APIRouter(prefix="/admin", tags=["Admin"]) +settings = get_settings() + + +def _directory_size(path: Path) -> int: + if not path.exists(): + return 0 + + total = 0 + for item in path.rglob("*"): + if item.is_file(): + try: + total += item.stat().st_size + except OSError: + continue + return total + + +@router.get("/stats", response_model=AdminStatsResponse) +def get_admin_stats( + _admin: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + """Return aggregate system statistics for administrators.""" + upload_dir = Path(settings.UPLOAD_DIR).resolve() + upload_dir.mkdir(parents=True, exist_ok=True) + + disk_usage = shutil.disk_usage(upload_dir) + used_percent = ( + round((disk_usage.used / disk_usage.total) * 100, 2) + if disk_usage.total + else 0.0 + ) + query_metrics = get_query_metrics() + + total_pdfs_uploaded = ( + db.query(Document) + .filter(func.lower(Document.original_name).like("%.pdf")) + .count() + ) + + return AdminStatsResponse( + total_users=db.query(User).count(), + total_pdfs_uploaded=total_pdfs_uploaded, + average_query_response_time_ms=float( + query_metrics["average_query_response_time_ms"] + ), + query_count=int(query_metrics["query_count"]), + disk_space_usage=DiskUsageResponse( + total_bytes=disk_usage.total, + used_bytes=disk_usage.used, + free_bytes=disk_usage.free, + usage_percent=used_percent, + upload_dir_bytes=_directory_size(upload_dir), + ), + ) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index e30d3b3337722cf5c3cefda38fe64c0d2d71de7c..09d7f8c8738608a4c59eed293adc891ff4428647 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -11,9 +11,10 @@ from sqlalchemy.orm import Session from sqlalchemy import select from app.config import get_settings from app.database import get_db -from app.models import User +from app.models import User, ApiKey from app.schemas import ( GoogleLoginRequest, + HFTokenUpdate, RefreshRequest, TokenResponse, UpdatePassword, @@ -23,6 +24,8 @@ from app.schemas import ( UserResponse, UserUpdate, UserUpdateResponse, + ApiKeyResponse, + ApiKeyCreateResponse, ) from app.auth import hash_password, verify_password, create_access_token, create_refresh_token, get_current_user, decode_token @@ -277,6 +280,34 @@ def get_me(user: User = Depends(get_current_user)): """ return UserResponse.model_validate(user) +@router.put("/hf-token", response_model=UserResponse) +def update_hf_token( + payload: HFTokenUpdate, + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Update the HuggingFace token for the authenticated user. + + Stores the provided HF token in the user's profile so it can be used + for HuggingFace API calls (e.g. InferenceClient) in place of the + globally configured ``HF_TOKEN`` environment variable. + + Args: + payload: HFTokenUpdate object containing the new ``hf_token`` value. + user: The currently authenticated user, obtained from the + ``get_current_user`` dependency. + db: SQLAlchemy database session, obtained from the dependency. + + Returns: + UserResponse: The updated user profile including the new ``hf_token`` + field. + """ + user.hf_token = payload.hf_token + db.commit() + db.refresh(user) + return UserResponse.model_validate(user) + + @router.put("/update") def update_user_info(payload:UserUpdate, user: User = Depends(get_current_user), @@ -383,6 +414,42 @@ def update_password(payload:UpdatePassword, db.rollback() raise HTTPException(status_code=400, detail="Database error") +from typing import List +import hashlib + +@router.post("/api-keys", response_model=ApiKeyCreateResponse, status_code=status.HTTP_201_CREATED) +def create_api_key(user: User = Depends(get_current_user), db: Session = Depends(get_db)): + """Create a new API key for the authenticated user.""" + raw_key = "rag_" + secrets.token_urlsafe(32) + hashed_key = hashlib.sha256(raw_key.encode("utf-8")).hexdigest() + + api_key = ApiKey( + user_id=user.id, + key_prefix=raw_key[:10], + hashed_key=hashed_key, + ) + db.add(api_key) + db.commit() + db.refresh(api_key) + + return {"key": raw_key, "api_key": api_key} + +@router.get("/api-keys", response_model=List[ApiKeyResponse]) +def list_api_keys(user: User = Depends(get_current_user), db: Session = Depends(get_db)): + """List all API keys for the authenticated user.""" + return db.query(ApiKey).filter(ApiKey.user_id == user.id).all() + +@router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_api_key(key_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)): + """Revoke an API key.""" + api_key = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first() + if not api_key: + raise HTTPException(status_code=404, detail="API key not found") + + db.delete(api_key) + db.commit() + return None + @router.get("/config") def get_auth_config(): """Return public configuration for auth providers""" diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py index a560d77dd35cb72a9d50a6f5316a193973c06a23..46a1c9ac8750b63d7b580bb75e2ba40ded88c7e8 100644 --- a/backend/app/routes/chat.py +++ b/backend/app/routes/chat.py @@ -3,6 +3,7 @@ Chat routes — ask questions with RAG, stream responses via SSE, manage history """ import html import json +import time from datetime import datetime from io import BytesIO import logging @@ -16,18 +17,83 @@ from reportlab.lib.units import inch from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer from sqlalchemy.orm import Session -from app.database import get_db -from app.models import User, ChatMessage, Document -from app.schemas import ChatRequest, ChatResponse, ChatMessageResponse, ChatHistoryResponse, SourceChunk from app.auth import get_current_user -from app.rag.agent import generate_answer, generate_answer_stream +from app.database import get_db +from app.metrics import record_query_response_time +from app.models import User, ChatMessage, Document, SharedMessage from app.rate_limit import limiter +from app.schemas import ( + ChatRequest, + ChatResponse, + ChatMessageResponse, + ChatHistoryResponse, + ShareAnswerResponse, + ShareLinkResponse, + SourceChunk, +) logger = logging.getLogger(__name__) router = APIRouter(prefix="/chat", tags=["Chat"]) +@router.get("/share/{message_id}", response_model=ShareAnswerResponse) +def get_shared_answer( + message_id: str, + db: Session = Depends(get_db), +): + message = db.query(ChatMessage).filter( + ChatMessage.id == message_id, + ChatMessage.role == "assistant", + ).first() + + if not message or not db.query(SharedMessage).filter(SharedMessage.message_id == message.id).first(): + raise HTTPException(status_code=404, detail="Shared answer not found") + + return _share_answer_response(message) + + +@router.post("/share/{message_id}", response_model=ShareLinkResponse) +def create_share_link( + message_id: str, + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + message = db.query(ChatMessage).filter( + ChatMessage.id == message_id, + ChatMessage.user_id == user.id, + ).first() + + if not message: + raise HTTPException(status_code=404, detail="Message not found") + + if message.role != "assistant": + raise HTTPException(status_code=400, detail="Only assistant messages can be shared") + + shared_message = db.query(SharedMessage).filter(SharedMessage.message_id == message.id).first() + if not shared_message: + shared_message = SharedMessage(message_id=message.id) + db.add(shared_message) + db.commit() + + return ShareLinkResponse( + message_id=message.id, + share_url=f"/share?message_id={message.id}", + ) + + +def generate_answer(question: str, user_id: str, document_id: Optional[str] = None): + from app.rag.agent import generate_answer as _generate_answer + + return _generate_answer(question=question, user_id=user_id, document_id=document_id) + + +def generate_answer_stream(question: str, user_id: str, document_id: Optional[str] = None): + from app.rag.agent import generate_answer_stream as _generate_answer_stream + + return _generate_answer_stream(question=question, user_id=user_id, document_id=document_id) + + @router.post("/ask", response_model=ChatResponse) @limiter.limit("10/minute") def ask_question( @@ -63,38 +129,41 @@ def ask_question( HTTPException: 400 if the document exists but its status is not "ready" (e.g., still processing or failed). """ - # Validate document exists if specified - if payload.document_id: - doc = db.query(Document).filter( - Document.id == payload.document_id, - Document.user_id == user.id, - ).first() - - if not doc: - raise HTTPException(status_code=404, detail="Document not found") - - if doc.status != "ready": - raise HTTPException( - status_code=400, - detail=f"Document is still {doc.status}. Please wait for processing to complete.", - ) - - # Generate answer - result = generate_answer( - question=payload.question, - user_id=user.id, - document_id=payload.document_id, - ) + started_at = time.perf_counter() + try: + # Validate document exists if specified + if payload.document_id: + doc = db.query(Document).filter( + Document.id == payload.document_id, + Document.user_id == user.id, + ).first() + + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + + if doc.status != "ready": + raise HTTPException( + status_code=400, + detail=f"Document is still {doc.status}. Please wait for processing to complete.", + ) + + result = generate_answer( + question=payload.question, + user_id=user.id, + document_id=payload.document_id, + ) - # Save to chat history - _save_message(db, user.id, payload.document_id, "user", payload.question) - _save_message(db, user.id, payload.document_id, "assistant", result["answer"], result["sources"]) + # Save to chat history + _save_message(db, user.id, payload.document_id, "user", payload.question) + _save_message(db, user.id, payload.document_id, "assistant", result["answer"], result["sources"]) - return ChatResponse( - answer=result["answer"], - sources=[SourceChunk(**s) for s in result["sources"]], - document_id=payload.document_id, - ) + return ChatResponse( + answer=result["answer"], + sources=[SourceChunk(**s) for s in result["sources"]], + document_id=payload.document_id, + ) + finally: + record_query_response_time(time.perf_counter() - started_at) @router.post("/ask/stream") @@ -156,6 +225,8 @@ def ask_question_stream( detail=f"Document is still {doc.status}. Please wait for processing to complete.", ) + started_at = time.perf_counter() + # Save user message immediately _save_message(db, user.id, payload.document_id, "user", payload.question) @@ -164,31 +235,34 @@ def ask_question_stream( full_answer = "" sources = [] - for chunk in generate_answer_stream( - question=payload.question, - user_id=user.id, - document_id=payload.document_id, - ): - yield chunk - - # Parse to accumulate full answer for history - try: - if chunk.startswith("data: "): - data = json.loads(chunk[6:].strip()) - if data.get("type") == "token": - full_answer += data.get("data", "") - elif data.get("type") == "sources": - sources = data.get("data", []) - except Exception: - pass - - # Save assistant response to history - from app.database import SessionLocal - save_db = SessionLocal() try: - _save_message(save_db, user.id, payload.document_id, "assistant", full_answer, sources) + for chunk in generate_answer_stream( + question=payload.question, + user_id=user.id, + document_id=payload.document_id, + ): + yield chunk + + # Parse to accumulate full answer for history + try: + if chunk.startswith("data: "): + data = json.loads(chunk[6:].strip()) + if data.get("type") == "token": + full_answer += data.get("data", "") + elif data.get("type") == "sources": + sources = data.get("data", []) + except Exception: + pass + + # Save assistant response to history + from app.database import SessionLocal + save_db = SessionLocal() + try: + _save_message(save_db, user.id, payload.document_id, "assistant", full_answer, sources) + finally: + save_db.close() finally: - save_db.close() + record_query_response_time(time.perf_counter() - started_at) return StreamingResponse( event_stream(), @@ -425,6 +499,23 @@ def _save_message( db.commit() +def _share_answer_response(message: ChatMessage) -> ShareAnswerResponse: + """Format a shared assistant message with only safe public fields.""" + sources = [] + if message.sources_json: + try: + sources = [SourceChunk(**item) for item in json.loads(message.sources_json)] + except Exception: + sources = [] + + return ShareAnswerResponse( + id=message.id, + content=message.content, + created_at=message.created_at, + sources=sources, + ) + + def _format_markdown(doc, messages) -> str: """Format chat history as a Markdown document. diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e7e19501d1ebe70b8f452dfc311adc0619ed89c9..d76b171809f8f7a578f3f5cdd62d005ee8aafa0b 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -53,11 +53,30 @@ class RefreshRequest(BaseModel): refresh_token: str +class HFTokenUpdate(BaseModel): + """Request schema for updating the user's HuggingFace token.""" + hf_token: str + + +class ApiKeyResponse(BaseModel): + id: str + key_preview: str + created_at: datetime + + class Config: + from_attributes = True + + +class ApiKeyCreateResponse(ApiKeyResponse): + raw_key: str + + class UserResponse(BaseModel): id: str username: str email: str is_admin: bool + hf_token: Optional[str] = None created_at: datetime class Config: @@ -99,6 +118,24 @@ class DocumentListResponse(BaseModel): pages: int +# Admin + +class DiskUsageResponse(BaseModel): + total_bytes: int + used_bytes: int + free_bytes: int + usage_percent: float + upload_dir_bytes: int + + +class AdminStatsResponse(BaseModel): + total_users: int + total_pdfs_uploaded: int + average_query_response_time_ms: float + query_count: int + disk_space_usage: DiskUsageResponse + + # ── Chat ───────────────────────────────────────────── class ChatRequest(BaseModel): @@ -136,5 +173,17 @@ class ChatHistoryResponse(BaseModel): document_id: Optional[str] = None +class ShareAnswerResponse(BaseModel): + id: str + content: str + sources: List[SourceChunk] = [] + created_at: datetime + + +class ShareLinkResponse(BaseModel): + message_id: str + share_url: str + + # Rebuild models for forward references TokenResponse.model_rebuild() diff --git a/backend/requirements.txt b/backend/requirements.txt index 85e3186926ac0d088a4fbd9735de6ef3189fe284..a426c5380015d3d274a9c7023c5cfa5a81229d06 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -31,6 +31,7 @@ langchain langchain-community langchain-huggingface langchain-text-splitters +langsmith # Embeddings & ML sentence-transformers diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f9f33385e1a55d8fdf95651e1e5faf8cfb57fb6e..d83fdccd7aaf9303e152f0a4464a344fc70aef44 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -16,11 +16,12 @@ BACKEND_DIR = ROOT / "backend" if str(BACKEND_DIR) not in sys.path: sys.path.insert(0, str(BACKEND_DIR)) -os.environ.setdefault("SECRET_KEY", "test-secret-key") -os.environ.setdefault("DATABASE_URL", "sqlite:///./test_bootstrap.db") -os.environ.setdefault("HF_TOKEN", "test-hf-token") -os.environ.setdefault("UPLOAD_DIR", str(ROOT / "backend" / "test_uploads")) -os.environ.setdefault("CHROMA_PERSIST_DIR", str(ROOT / "backend" / "test_chroma")) +os.environ["SECRET_KEY"] = "test-secret-key-that-is-long-enough" +os.environ["DATABASE_URL"] = "sqlite:///./test_bootstrap.db" +os.environ["DEBUG"] = "false" +os.environ["HF_TOKEN"] = "test-hf-token" +os.environ["UPLOAD_DIR"] = str(ROOT / "backend" / "test_uploads") +os.environ["CHROMA_PERSIST_DIR"] = str(ROOT / "backend" / "test_chroma") fake_embeddings = types.ModuleType("app.rag.embeddings") @@ -83,7 +84,7 @@ sys.modules.setdefault("slowapi.util", slowapi_util) from app.auth import create_access_token, create_refresh_token, hash_password from app.database import Base, get_db from app.main import app -from app.models import Document, User +from app.models import ChatMessage, Document, User @pytest.fixture() @@ -140,6 +141,19 @@ def user(db_session): return instance +@pytest.fixture() +def other_user(db_session): + instance = User( + username="other", + email="other@example.com", + hashed_password=hash_password("password123"), + ) + db_session.add(instance) + db_session.commit() + db_session.refresh(instance) + return instance + + @pytest.fixture() def auth_headers(user): token = create_access_token(user.id) @@ -181,3 +195,43 @@ def pending_document(db_session, user): db_session.commit() db_session.refresh(instance) return instance + + +@pytest.fixture() +def assistant_message(db_session, user): + instance = ChatMessage( + user_id=user.id, + role="assistant", + content="Shared assistant answer", + sources_json='[{"text":"Source text","filename":"file.txt","page":1,"score":0.9,"confidence":95.0}]', + ) + db_session.add(instance) + db_session.commit() + db_session.refresh(instance) + return instance + + +@pytest.fixture() +def user_message(db_session, user): + instance = ChatMessage( + user_id=user.id, + role="user", + content="Private user prompt", + ) + db_session.add(instance) + db_session.commit() + db_session.refresh(instance) + return instance + + +@pytest.fixture() +def other_user_assistant_message(db_session, other_user): + instance = ChatMessage( + user_id=other_user.id, + role="assistant", + content="Other user's answer", + ) + db_session.add(instance) + db_session.commit() + db_session.refresh(instance) + return instance diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py new file mode 100644 index 0000000000000000000000000000000000000000..e0297391c196ce181ae0cc8572dbdf2977008e23 --- /dev/null +++ b/backend/tests/test_admin.py @@ -0,0 +1,65 @@ +from app.auth import create_access_token, hash_password +from app.metrics import record_query_response_time +from app.models import Document, User + + +def test_admin_stats_requires_admin(client, auth_headers): + response = client.get("/api/v1/admin/stats", headers=auth_headers) + + assert response.status_code == 403 + assert response.json()["detail"] == "Admin access required" + + +def test_admin_stats_returns_aggregate_metrics(client, db_session): + admin = User( + username="admin", + email="admin@example.com", + hashed_password=hash_password("password123"), + is_admin=True, + ) + regular = User( + username="regular", + email="regular@example.com", + hashed_password=hash_password("password123"), + ) + db_session.add_all([admin, regular]) + db_session.commit() + db_session.refresh(admin) + db_session.refresh(regular) + + db_session.add_all( + [ + Document( + user_id=regular.id, + filename="first.pdf", + original_name="first.pdf", + file_size=100, + status="ready", + ), + Document( + user_id=regular.id, + filename="notes.txt", + original_name="notes.txt", + file_size=50, + status="ready", + ), + ] + ) + db_session.commit() + + record_query_response_time(0.25) + + token = create_access_token(admin.id) + response = client.get( + "/api/v1/admin/stats", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["total_users"] == 2 + assert payload["total_pdfs_uploaded"] == 1 + assert payload["average_query_response_time_ms"] > 0 + assert payload["query_count"] >= 1 + assert payload["disk_space_usage"]["total_bytes"] > 0 + assert payload["disk_space_usage"]["usage_percent"] >= 0 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 0cf27508ddfa98567f55a0a664a9a6e5fb6e30ab..73f0e7708e7730f97783bae537c8d8e2a5602af6 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -79,3 +79,39 @@ def test_refresh_token_success(client, refresh_token): assert payload["access_token"] assert payload["refresh_token"] assert payload["token_type"] == "bearer" + + +def test_update_hf_token_success(client, auth_headers): + response = client.put( + "/api/v1/auth/hf-token", + json={"hf_token": "hf_new_token_value"}, + headers=auth_headers, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["hf_token"] == "hf_new_token_value" + + +def test_update_hf_token_requires_auth(client): + response = client.put( + "/api/v1/auth/hf-token", + json={"hf_token": "hf_unauth"}, + ) + + assert response.status_code in (401, 403) + + +def test_hf_token_appears_in_user_response(client, auth_headers, user, db_session): + # First update the token + put_resp = client.put( + "/api/v1/auth/hf-token", + json={"hf_token": "hf_persist_token"}, + headers=auth_headers, + ) + assert put_resp.status_code == 200 + + # Then verify it shows up in GET /me + me_resp = client.get("/api/v1/auth/me", headers=auth_headers) + assert me_resp.status_code == 200 + assert me_resp.json()["hf_token"] == "hf_persist_token" diff --git a/backend/tests/test_share.py b/backend/tests/test_share.py new file mode 100644 index 0000000000000000000000000000000000000000..f906c9b46f374f3cabd813d985b6f9a71e31c71f --- /dev/null +++ b/backend/tests/test_share.py @@ -0,0 +1,61 @@ +def test_share_link_creation_success(client, auth_headers, assistant_message): + response = client.post( + f"/api/v1/chat/share/{assistant_message.id}", + headers=auth_headers, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["message_id"] == assistant_message.id + assert payload["share_url"] == f"/share?message_id={assistant_message.id}" + + +def test_share_link_unauthorized_for_other_users_message(client, auth_headers, other_user_assistant_message): + response = client.post( + f"/api/v1/chat/share/{other_user_assistant_message.id}", + headers=auth_headers, + ) + + assert response.status_code == 404 + assert response.json()["detail"] == "Message not found" + + +def test_cannot_share_user_message(client, auth_headers, user_message): + response = client.post( + f"/api/v1/chat/share/{user_message.id}", + headers=auth_headers, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Only assistant messages can be shared" + + +def test_public_fetch_fails_before_share(client, assistant_message): + response = client.get(f"/api/v1/chat/share/{assistant_message.id}") + + assert response.status_code == 404 + assert response.json()["detail"] == "Shared answer not found" + + +def test_public_fetch_shared_answer_success_after_share(client, auth_headers, assistant_message): + share_response = client.post( + f"/api/v1/chat/share/{assistant_message.id}", + headers=auth_headers, + ) + assert share_response.status_code == 200 + + response = client.get(f"/api/v1/chat/share/{assistant_message.id}") + + assert response.status_code == 200 + payload = response.json() + assert payload["id"] == assistant_message.id + assert payload["content"] == "Shared assistant answer" + assert len(payload["sources"]) == 1 + assert payload["sources"][0]["filename"] == "file.txt" + + +def test_missing_message_returns_404(client): + response = client.get("/api/v1/chat/share/missing-message-id") + + assert response.status_code == 404 + assert response.json()["detail"] == "Shared answer not found" diff --git a/bots/discord/README.md b/bots/discord/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e4a1f71592a5a9a7b3f10877eb8acb83a19bffb5 --- /dev/null +++ b/bots/discord/README.md @@ -0,0 +1,37 @@ +# Discord RAG Bot + +This bot connects to the PDF-Assistant-RAG backend to answer questions based on your uploaded documents, directly from Discord. + +## Setup + +1. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +2. Create a Discord Bot on the [Discord Developer Portal](https://discord.com/developers/applications): + - Go to "Bot" tab and enable **Message Content Intent**. + - Copy the bot token. + - Invite the bot to your server via the OAuth2 URL Generator (check `bot` scope and `Send Messages` permission). + +3. Generate an API Key from your PDF-Assistant-RAG profile dashboard. + +4. Set the environment variables and run: + ```bash + export DISCORD_TOKEN="your-discord-bot-token" + export RAG_API_KEY="rag_your-api-key" + + # Optional: set API_URL if backend is not running on localhost:8000 + # export API_URL="http://localhost:8000/api/v1" + + python bot.py + ``` + +## Usage +In a Discord channel where the bot is present, simply use the `!ask` command: + +``` +!ask Summarize the latest uploaded report for me +``` + +The bot will query the backend API using your personal API key and reply with the generated answer. diff --git a/bots/discord/bot.py b/bots/discord/bot.py new file mode 100644 index 0000000000000000000000000000000000000000..d7a47bf991be36427e85623aa44575ea6815b4b7 --- /dev/null +++ b/bots/discord/bot.py @@ -0,0 +1,68 @@ +import os +import discord +import requests +from discord.ext import commands + +DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") +API_URL = os.getenv("API_URL", "http://localhost:8000/api/v1") +RAG_API_KEY = os.getenv("RAG_API_KEY") + +if not DISCORD_TOKEN or not RAG_API_KEY: + print("Error: DISCORD_TOKEN and RAG_API_KEY must be set in environment variables.") + exit(1) + +intents = discord.Intents.default() +intents.message_content = True +bot = commands.Bot(command_prefix="!", intents=intents) + +@bot.event +async def on_ready(): + print(f"Logged in as {bot.user.name} ({bot.user.id})") + print("Ready to answer questions via '!ask '") + +@bot.command(name="ask") +async def ask_rag(ctx, *, question: str): + """Ask the RAG Assistant a question. Example: !ask What is in my documents?""" + loading_msg = await ctx.send("🤔 Thinking...") + + try: + headers = { + "Authorization": f"Bearer {RAG_API_KEY}", + "Content-Type": "application/json" + } + + # We can also support document_id if we want, but for now we do global ask. + payload = {"question": question} + + response = requests.post( + f"{API_URL}/chat/ask", + json=payload, + headers=headers, + timeout=30 # Give the RAG backend some time to process + ) + + if response.status_code == 200: + data = response.json() + answer = data.get("answer", "No answer provided.") + + if len(answer) > 2000: + # Discord has a 2000 character limit per message + chunks = [answer[i:i+2000] for i in range(0, len(answer), 2000)] + await loading_msg.edit(content=chunks[0]) + for chunk in chunks[1:]: + await ctx.send(chunk) + else: + await loading_msg.edit(content=answer) + else: + await loading_msg.edit(content=f"⚠️ Error from RAG API: `{response.status_code}`") + print(f"API Error: {response.text}") + + except requests.exceptions.RequestException as e: + await loading_msg.edit(content=f"❌ Failed to connect to backend API.") + print(f"Request Error: {e}") + except Exception as e: + await loading_msg.edit(content=f"❌ An unexpected error occurred.") + print(f"Error: {e}") + +if __name__ == "__main__": + bot.run(DISCORD_TOKEN) diff --git a/bots/discord/requirements.txt b/bots/discord/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..21915f3b5ed5eff2bc9051d068fe4641d1390d02 --- /dev/null +++ b/bots/discord/requirements.txt @@ -0,0 +1,2 @@ +discord.py==2.3.2 +requests==2.31.0 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f1f957eb26b62e7eafc6776f388dc0bc250bc33e..48ed472a6d154aaccaafafad0cd7bcf2e77371bb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,12 +11,16 @@ "@base-ui/react": "^1.4.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "i18next": "^26.3.0", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.8.0", "next": "16.2.4", + "next-themes": "^0.4.6", "pdfjs-dist": "^5.6.205", "react": "19.2.4", "react-dom": "19.2.4", "react-dropzone": "^15.0.0", + "react-i18next": "^17.0.8", "react-markdown": "^10.1.0", "react-pdf": "^10.4.1", "rehype-highlight": "^7.0.2", @@ -79,6 +83,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -672,6 +677,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2106,6 +2112,7 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -2213,6 +2220,7 @@ "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright": "1.60.0" }, @@ -2681,6 +2689,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2690,6 +2699,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2776,6 +2786,7 @@ "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", @@ -3320,6 +3331,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3774,6 +3786,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4825,6 +4838,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5010,6 +5024,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5309,6 +5324,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5668,6 +5684,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6132,10 +6149,20 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -6188,6 +6215,44 @@ "node": ">=18.18.0" } }, + "node_modules/i18next": { + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz", + "integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peer": true, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -8691,6 +8756,16 @@ } } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -9516,6 +9591,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9525,6 +9601,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9549,6 +9626,33 @@ "react": ">= 16.8 || 18.0.0" } }, + "node_modules/react-i18next": { + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz", + "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -10822,6 +10926,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11090,6 +11195,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11423,6 +11529,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", @@ -11763,6 +11878,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index fd63b5f652b3fd21328102b62ab3d6f70be4e876..30c541f33d82503e4641716adff3289cb04ff2b6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,12 +14,16 @@ "@base-ui/react": "^1.4.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "i18next": "^26.3.0", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.8.0", "next": "16.2.4", + "next-themes": "^0.4.6", "pdfjs-dist": "^5.6.205", "react": "19.2.4", "react-dom": "19.2.4", "react-dropzone": "^15.0.0", + "react-i18next": "^17.0.8", "react-markdown": "^10.1.0", "react-pdf": "^10.4.1", "rehype-highlight": "^7.0.2", diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0309eb58a54cb37f7a717c5abdb062cf5a7d1dd3 --- /dev/null +++ b/frontend/src/app/admin/page.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + ArrowLeft, + Clock3, + Database, + FileText, + HardDrive, + RefreshCw, + Users, +} from "lucide-react"; + +import { api, CONNECTION_ERROR_MESSAGE } from "@/lib/api"; +import { useAuth } from "@/lib/auth"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface AdminStats { + total_users: number; + total_pdfs_uploaded: number; + average_query_response_time_ms: number; + query_count: number; + disk_space_usage: { + total_bytes: number; + used_bytes: number; + free_bytes: number; + usage_percent: number; + upload_dir_bytes: number; + }; +} + +const formatBytes = (bytes: number) => { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + + const units = ["B", "KB", "MB", "GB", "TB"]; + const index = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + units.length - 1 + ); + + return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`; +}; + +const formatTime = (milliseconds: number) => { + if (milliseconds >= 1000) return `${(milliseconds / 1000).toFixed(2)} s`; + return `${Math.round(milliseconds)} ms`; +}; + +function MetricCard({ + icon: Icon, + label, + value, + detail, +}: { + icon: typeof Users; + label: string; + value: string; + detail: string; +}) { + return ( + + +
+ {label} + {value} +
+
+ +
+
+ +

{detail}

+
+
+ ); +} + +function AdminSkeleton() { + return ( +
+ {[1, 2, 3, 4].map((item) => ( + + + + + + + + + + ))} +
+ ); +} + +export default function AdminPage() { + const { user, loading } = useAuth(); + const router = useRouter(); + const [stats, setStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(true); + const [error, setError] = useState(""); + const [lastUpdated, setLastUpdated] = useState(null); + + useEffect(() => { + if (loading) return; + if (!user) router.replace("/login"); + else if (!user.is_admin) router.replace("/dashboard"); + }, [loading, router, user]); + + const loadStats = useCallback(async () => { + try { + setStatsLoading(true); + const data = await api.get("/api/v1/admin/stats"); + setStats(data); + setLastUpdated(new Date()); + setError(""); + } catch (err) { + const message = + err instanceof Error ? err.message : CONNECTION_ERROR_MESSAGE; + setError(message); + } finally { + setStatsLoading(false); + } + }, []); + + useEffect(() => { + if (!user?.is_admin) return; + + const initialLoad = window.setTimeout(() => void loadStats(), 0); + const interval = window.setInterval(() => void loadStats(), 10000); + + return () => { + window.clearTimeout(initialLoad); + window.clearInterval(interval); + }; + }, [loadStats, user?.is_admin]); + + const diskDetail = useMemo(() => { + if (!stats) return ""; + const disk = stats.disk_space_usage; + return `${formatBytes(disk.used_bytes)} used of ${formatBytes(disk.total_bytes)}`; + }, [stats]); + + if (loading || !user || !user.is_admin) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+ +
+
+ +
+ Admin Metrics +
+
+ + +
+ +
+
+

System overview

+

+ {lastUpdated + ? `Last updated ${lastUpdated.toLocaleTimeString()}` + : "Waiting for live metrics"} +

+
+ + {error && ( +
+ {error} +
+ )} + + {statsLoading && !stats ? ( + + ) : stats ? ( + <> +
+ + + + +
+ + + + Disk space usage + {diskDetail} + + + +
+
+

Used

+

+ {formatBytes(stats.disk_space_usage.used_bytes)} +

+
+
+

Free

+

+ {formatBytes(stats.disk_space_usage.free_bytes)} +

+
+
+

Usage

+

+ {stats.disk_space_usage.usage_percent.toFixed(2)}% +

+
+
+
+
+ + ) : null} +
+
+ ); +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 42145161b09617f458961d7aa74313f4b130a5d3..92f455e459299734d00cd1b2b9d313f6df917327 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -63,11 +63,23 @@ export default function DashboardPage() { const [viewerOpen, setViewerOpen] = useState(true); const [connectionError, setConnectionError] = useState(""); - // Auth guard + // Auth guard useEffect(() => { if (!loading && !user) router.replace("/login"); }, [user, loading, router]); + // Intercept dashboard if Hugging Face token configuration is missing + useEffect(() => { + if (user) { + const existingHfToken = localStorage.getItem("hf_token"); + + if (!existingHfToken) { + console.warn("Hugging Face API configuration key missing."); + } + } + }, [user]); + + // Load documents const loadDocuments = useCallback(async () => { try { diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index e3836df71e40311981cec6ee3a5da19c19191a0c..1fbeac92be11bcb9996139ac83898b52a97457a3 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -83,6 +83,35 @@ --sidebar-ring: oklch(0.65 0.2 265); } +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.178 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.178 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.65 0.2 265); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.22 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.22 0 0); + --muted-foreground: oklch(0.6 0 0); + --accent: oklch(0.55 0.18 265); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 12%); + --ring: oklch(0.65 0.2 265); + --sidebar: oklch(0.12 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.65 0.2 265); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.22 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 8%); + --sidebar-ring: oklch(0.65 0.2 265); +} + .light { --background: oklch(0.985 0 0); --foreground: oklch(0.145 0 0); diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 4617d5ae5391741e4e2c31903c6a324632fd1001..a9d432e7d20c28fe6ad5ef4178f35bc5a5e7c3e8 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -3,6 +3,8 @@ import { Inter } from "next/font/google"; import "./globals.css"; import { AuthProvider } from "@/lib/auth"; import { TooltipProvider } from "@/components/ui/tooltip"; +import I18nProvider from "@/components/providers/I18nProvider"; +import { ThemeProvider } from "@/components/layout/ThemeProvider"; const inter = Inter({ variable: "--font-sans", @@ -23,13 +25,20 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - - - {children} - - + + + + {children} + + + ); diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index d6057d5dae22d866a01fd9e07012dafc69261c17..552a0feff5b2aba149ea6381f7fa9ac2df7a7023 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/auth"; +import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; @@ -12,6 +13,7 @@ import GoogleSignInButton from "@/components/auth/GoogleSignInButton"; export default function LoginPage() { const { login } = useAuth(); + const { t } = useTranslation(); const router = useRouter(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -32,7 +34,7 @@ export default function LoginPage() { await login(email, password); router.replace("/dashboard"); } catch (err: unknown) { - const message = err instanceof Error ? err.message : "Login failed"; + const message = err instanceof Error ? err.message : t("login.fallbackError"); setError(message); } finally { setLoading(false); @@ -51,8 +53,8 @@ export default function LoginPage() {
- Welcome back - Sign in to your Document AI Analyst account + {t("login.title")} + {t("login.description")} @@ -71,7 +73,7 @@ export default function LoginPage() { )}
- +
- +
- Signing in... + {t("login.submitting")} ) : ( - "Sign In" + t("login.submit") )}

- Don't have an account?{" "} + {t("login.noAccount")}{" "} - Create one + {t("login.createOne")}

diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx index b89efb2b4ffc94da1f65aef2d1d17d5f0419a95b..88ccb88312a280ff10f2ed9198317bdccf79c8b6 100644 --- a/frontend/src/app/register/page.tsx +++ b/frontend/src/app/register/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/auth"; +import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; @@ -12,6 +13,7 @@ import GoogleSignInButton from "@/components/auth/GoogleSignInButton"; export default function RegisterPage() { const { register } = useAuth(); + const { t } = useTranslation(); const router = useRouter(); const [username, setUsername] = useState(""); const [email, setEmail] = useState(""); @@ -33,7 +35,7 @@ export default function RegisterPage() { await register(username, email, password); router.replace("/dashboard"); } catch (err: unknown) { - const message = err instanceof Error ? err.message : "Registration failed"; + const message = err instanceof Error ? err.message : t("register.fallbackError"); setError(message); } finally { setLoading(false); @@ -51,8 +53,8 @@ export default function RegisterPage() {
- Create Account - Start analyzing documents with AI + {t("register.title")} + {t("register.description")} @@ -71,7 +73,7 @@ export default function RegisterPage() { )}
- +
- +
- +
setPassword(e.target.value)} required @@ -124,18 +126,18 @@ export default function RegisterPage() { {loading ? ( - Creating account... + {t("register.submitting")} ) : ( - "Create Account" + t("register.submit") )}

- Already have an account?{" "} + {t("register.hasAccount")}{" "} - Sign in + {t("register.signIn")}

diff --git a/frontend/src/app/share/page.tsx b/frontend/src/app/share/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..20ed4d2dc734f82da8e2f63a3e0d462c8db1e68d --- /dev/null +++ b/frontend/src/app/share/page.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import ReactMarkdown, { type Components } from "react-markdown"; +import rehypeHighlight from "rehype-highlight"; +import remarkGfm from "remark-gfm"; +import { Brain } from "lucide-react"; +import { api } from "@/lib/api"; + +interface SharedSource { + text: string; + filename: string; + page: number; + score: number; + confidence: number; +} + +interface SharedAnswer { + id: string; + content: string; + created_at: string; + sources: SharedSource[]; +} + +const markdownComponents: Components = { + table: ({ children }) => ( +
+ + {children} +
+
+ ), + thead: ({ children }) => ( + {children} + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + pre: ({ children }) => ( +
+      {children}
+    
+ ), + code: ({ className, children, ...props }) => { + const language = /language-(\w+)/.exec(className ?? "")?.[1]; + + return ( + + {children} + + ); + }, +}; + +function ShareAnswerContent() { + const searchParams = useSearchParams(); + const messageId = searchParams.get("message_id"); + const missingMessageId = !messageId; + const [answer, setAnswer] = useState(null); + const [error, setError] = useState(""); + const loading = !error && !answer && !missingMessageId; + + useEffect(() => { + if (missingMessageId) { + return; + } + + let cancelled = false; + + void api + .get(`/api/v1/chat/share/${messageId}`) + .then((data) => { + if (cancelled) return; + setAnswer(data); + setError(""); + }) + .catch((err: unknown) => { + if (cancelled) return; + setAnswer(null); + setError(err instanceof Error ? err.message : "Shared answer not found"); + }); + + return () => { + cancelled = true; + }; + }, [messageId, missingMessageId]); + + if (missingMessageId) { + return ( +
+
+

Shared answer unavailable

+

This shared answer could not be found.

+
+
+ ); + } + + if (loading) { + return ( +
+
Loading shared answer...
+
+ ); + } + + if (error || !answer) { + return ( +
+
+

Shared answer unavailable

+

{error || "This shared answer could not be found."}

+
+
+ ); + } + + return ( +
+
+
+
+
+ +
+
+

Shared AI Answer

+

+ {new Date(answer.created_at).toLocaleString()} +

+
+
+ +
+ + {answer.content} + +
+ + {answer.sources.length > 0 && ( +
+

Sources

+
+ {answer.sources.map((source, index) => ( +
+

+ {source.filename} • Page {source.page} +

+

{source.text}

+
+ ))} +
+
+ )} +
+
+
+ ); +} + +export default function ShareAnswerPage() { + return ( + +
Loading shared answer...
+
+ } + > + + + ); +} diff --git a/frontend/src/components/auth/ApiKeyManager.tsx b/frontend/src/components/auth/ApiKeyManager.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b718b4ff64d27003dcfbdf702a30acb7e1dc3904 --- /dev/null +++ b/frontend/src/components/auth/ApiKeyManager.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { api } from "@/lib/api"; +import { Key, Plus, Trash2, Copy, Check } from "lucide-react"; + +interface ApiKey { + id: string; + key_prefix: string; + created_at: string; + last_used: string | null; +} + +export default function ApiKeyManager() { + const [keys, setKeys] = useState([]); + const [newKey, setNewKey] = useState(null); + const [loading, setLoading] = useState(false); + const [copied, setCopied] = useState(false); + + const fetchKeys = async () => { + try { + setLoading(true); + const data = await api.get("/api/v1/auth/api-keys"); + setKeys(data || []); + } catch (err) { + console.error("Failed to load API keys", err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + const timer = setTimeout(() => { + fetchKeys(); + }, 0); + return () => clearTimeout(timer); + }, []); + + const generateKey = async () => { + try { + setLoading(true); + const data = await api.post<{ key: string; api_key: ApiKey }>("/api/v1/auth/api-keys"); + setNewKey(data.key); + setKeys((prev) => [...prev, data.api_key]); + } catch (err) { + console.error("Failed to generate API key", err); + } finally { + setLoading(false); + } + }; + + const revokeKey = async (id: string) => { + if (!confirm("Are you sure you want to revoke this key? Any integrations using it will immediately break.")) return; + + try { + await api.delete(`/api/v1/auth/api-keys/${id}`); + setKeys((prev) => prev.filter((k) => k.id !== id)); + } catch (err) { + console.error("Failed to revoke API key", err); + } + }; + + const copyToClipboard = () => { + if (newKey) { + navigator.clipboard.writeText(newKey); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + return ( + { if (!open) setNewKey(null); }}> + + + API Keys + + } + /> + + + + API Keys +

+ Manage API keys to access the RAG engine programmatically from your own applications or scripts. +

+
+ + {newKey && ( +
+

+ Save your new API key +

+

+ Please copy this key and store it somewhere safe. For security reasons, you will never be able to view it again. +

+
+ + {newKey} + + +
+
+ )} + +
+
+

Active Keys

+ +
+ +
+ {keys.length === 0 ? ( +
+ + You don't have any API keys yet. +
+ ) : ( +
+ {keys.map((key) => ( +
+
+
+ {key.key_prefix}•••••••••••••••••••••• +
+
+ Created: {new Date(key.created_at).toLocaleDateString()} + Last used: {key.last_used ? new Date(key.last_used).toLocaleDateString() : "Never"} +
+
+ +
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/chat/ChatPanel.tsx b/frontend/src/components/chat/ChatPanel.tsx index cd67b09293a2d0d8123db51201154fbf8659d2d0..ddd47fc87f2a46e4eb6b7d7c1cf781e3582c291d 100644 --- a/frontend/src/components/chat/ChatPanel.tsx +++ b/frontend/src/components/chat/ChatPanel.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useRef, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import type { DocInfo } from "@/app/dashboard/page"; import { api, API_BASE } from "@/lib/api"; import { useChatStore, type ChatMsg, type SourceChunk } from "@/store/chat-store"; @@ -16,6 +17,7 @@ interface Props { } export default function ChatPanel({ activeDoc, onCitationClick }: Props) { + const { t } = useTranslation(); const messages = useChatStore((state) => state.messages); const input = useChatStore((state) => state.input); const streaming = useChatStore((state) => state.streaming); @@ -185,7 +187,9 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { m.id === assistantId ? { ...m, - content: `Failed to get response: ${err instanceof Error ? err.message : "Unknown error"}`, + content: t("chat.fallbackError", { + message: err instanceof Error ? err.message : "Unknown error", + }), isStreaming: false, } : m @@ -198,7 +202,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { }; const handleClear = async () => { - if (!activeDoc || !confirm("Clear all chat history for this document?")) return; + if (!activeDoc || !confirm(t("chat.clearConfirm"))) return; try { await api.delete(`/api/v1/chat/history/${activeDoc.id}`); setMessages([]); @@ -250,12 +254,12 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {

- {activeDoc ? "Ask about your document" : "Select a document"} + {activeDoc ? t("chat.askAboutDocument") : t("chat.selectDocument")}

{activeDoc - ? `"${activeDoc.original_name}" is ready. Ask any question and get cited answers.` - : "Upload and select a document from the sidebar to start chatting."} + ? t("chat.readyPrompt", { name: activeDoc.original_name }) + : t("chat.uploadPrompt")}

) : ( @@ -293,8 +297,8 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { onKeyDown={handleKeyDown} placeholder={ activeDoc - ? `Ask about "${activeDoc.original_name}"...` - : "Select a document first..." + ? t("chat.askPlaceholder", { name: activeDoc.original_name }) + : t("chat.selectPlaceholder") } disabled={streaming} className="min-h-[44px] max-h-32 resize-none bg-background/50 border-border/50" @@ -324,7 +328,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { size="icon" onClick={() => setShowExportMenu((v) => !v)} className="h-[44px] w-[44px] text-muted-foreground hover:text-primary" - title="Export chat history" + title={t("chat.exportTitle")} > @@ -336,7 +340,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { className="w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left" > 📝 - Markdown (.md) + {t("chat.markdown")}
)} diff --git a/frontend/src/components/chat/MessageBubble.tsx b/frontend/src/components/chat/MessageBubble.tsx index 767ecd9d5a508807e0a7bf0be807d3c40666bcf7..ffd0ab616c7d513442c579a9838263a25406cc56 100644 --- a/frontend/src/components/chat/MessageBubble.tsx +++ b/frontend/src/components/chat/MessageBubble.tsx @@ -5,7 +5,8 @@ import ReactMarkdown, { type Components } from "react-markdown"; import rehypeHighlight from "rehype-highlight"; import remarkGfm from "remark-gfm"; import type { ChatMsg } from "@/store/chat-store"; -import { Brain, User, Copy, Check } from "lucide-react"; +import { api } from "@/lib/api"; +import { Brain, User, Copy, Check, Share2, Link2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; interface Props { @@ -52,7 +53,10 @@ const markdownComponents: Components = { export default function MessageBubble({ message }: Props) { const isUser = message.role === "user"; const [copied, setCopied] = useState(false); + const [shared, setShared] = useState(false); + const [shareFailed, setShareFailed] = useState(false); const copiedTimeoutRef = useRef | null>(null); + const sharedTimeoutRef = useRef | null>(null); const handleCopy = async () => { if (!message.content) return; @@ -66,6 +70,31 @@ export default function MessageBubble({ message }: Props) { } }; + const handleShare = async () => { + if (!message.content || message.isStreaming) return; + + try { + const data = await api.post<{ message_id: string; share_url: string }>( + `/api/v1/chat/share/${message.id}` + ); + await navigator.clipboard.writeText(`${window.location.origin}${data.share_url}`); + setShared(true); + setShareFailed(false); + if (sharedTimeoutRef.current) clearTimeout(sharedTimeoutRef.current); + sharedTimeoutRef.current = setTimeout(() => { + setShared(false); + setShareFailed(false); + }, 2000); + } catch { + setShareFailed(true); + setShared(false); + if (sharedTimeoutRef.current) clearTimeout(sharedTimeoutRef.current); + sharedTimeoutRef.current = setTimeout(() => { + setShareFailed(false); + }, 2000); + } + }; + return (
{message.content && ( - )} - + + )} -
+
{message.content ? ( >(new Set()); if (sources.length === 0) return null; + const toggleExcerpt = (i: number) => { + const next = new Set(excerptOpen); + if (next.has(i)) { + next.delete(i); + } else { + next.add(i); + } + setExcerptOpen(next); + }; + return (
- {/* ── Header ──────────────────────────────────── */} - + {/* ── Header ──────────────────────────────────── */} + - {/* ── Collapsed: Mini badges ──────────────────── */} - {!expanded && ( -
- {sources.map((src, i) => ( - onPageClick(src.page + 1)} - > - p.{src.page + 1} • {src.confidence}% - - ))} -
- )} - - {/* ── Expanded: Full source cards ─────────────── */} - {expanded && ( -
- {sources.map((src, i) => ( -
-
-
- - {src.filename} - - - Page {src.page + 1} - + {/* ── Collapsed: Mini badges with hover preview ── */} + {!expanded && ( +
+ {sources.map((src, i) => ( + + = 80 - ? "text-emerald-400 bg-emerald-400/10" - : src.confidence >= 50 - ? "text-yellow-400 bg-yellow-400/10" - : "text-muted-foreground" - }`} + className="text-[10px] h-5 cursor-pointer hover:bg-primary/20 transition-colors" + onClick={() => onPageClick(src.page + 1)} > - {src.confidence}% match + p.{src.page + 1} • {src.confidence}% + + +

+ {src.text} +

+
+
+ ))} +
+ )} + + {/* ── Expanded: Full source cards ─────────────── */} + {expanded && ( +
+ {sources.map((src, i) => ( +
+
+
+ + {src.filename} + + + Page {src.page + 1} + + = 80 + ? "text-emerald-400 bg-emerald-400/10" + : src.confidence >= 50 + ? "text-yellow-400 bg-yellow-400/10" + : "text-muted-foreground" + }`} + > + {src.confidence}% match + +
+
- + {src.text} +

+ {src.text.length > EXCERPT_THRESHOLD && ( + + )}
-

- {src.text} -

-
- ))} -
- )} + ))} +
+ )}
); } diff --git a/frontend/src/components/document/DocumentSidebar.tsx b/frontend/src/components/document/DocumentSidebar.tsx index 451c6800e3394fed8191ffb2cf0b244d570b43d6..37fdff557e3e202573881a0a5a7495f3186b9f91 100644 --- a/frontend/src/components/document/DocumentSidebar.tsx +++ b/frontend/src/components/document/DocumentSidebar.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import type { DocInfo } from "@/app/dashboard/page"; import { api } from "@/lib/api"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -20,6 +21,7 @@ interface Props { } export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc, onDocumentsChange }: Props) { + const { t } = useTranslation(); const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [uploadError, setUploadError] = useState(""); @@ -43,7 +45,7 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc } onDocumentsChange(); } catch (err) { - const message = err instanceof Error ? err.message : "Upload failed"; + const message = err instanceof Error ? err.message : t("documents.uploadFailed"); setUploadError(message); } finally { setUploading(false); @@ -51,7 +53,7 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc } })(); }, - [onDocumentsChange] + [onDocumentsChange, t] ); const { getRootProps, getInputProps, isDragActive } = useDropzone({ @@ -67,7 +69,7 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc const handleDelete = async (docId: string, e: React.MouseEvent) => { e.stopPropagation(); - if (!confirm("Delete this document and all its data?")) return; + if (!confirm(t("documents.deleteConfirm"))) return; setDeleting(docId); try { await api.delete(`/api/v1/documents/${docId}`); @@ -119,17 +121,17 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc {uploading ? (
-

Uploading...

+

{t("documents.uploading")}

) : ( <>

- {isDragActive ? "Drop files here" : "Drop files or click to upload"} + {isDragActive ? t("documents.dropHere") : t("documents.dropOrClick")}

- PDF, DOCX, TXT, MD (max 50MB) + {t("documents.uploadFormats")}

)} @@ -139,7 +141,7 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc {/* ── Documents List ──────────────────────────── */}

- Documents ({documents.length}) + {t("documents.documentsTitle", { count: documents.length })}

@@ -147,8 +149,8 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc {documents.length === 0 ? (
-

No documents yet

-

Upload a file to get started

+

{t("documents.noDocuments")}

+

{t("documents.getStarted")}

) : (
@@ -179,22 +181,22 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc <> - {doc.page_count} pg + {t("documents.pagesShort", { count: doc.page_count })} - {doc.chunk_count} chunks + {t("documents.chunks", { count: doc.chunk_count })} )} {doc.status === "processing" && ( - Processing + {t("documents.processing")} )} {doc.status === "failed" && ( - Failed + {t("documents.failed")} )}
diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 7a22b72c01f0c791f0e0df7ff37101ce8aa280d2..a00543c63738c5edd354a690c995e8ecb9f5b8ef 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useAuth } from "@/lib/auth"; +import { useTranslation } from "react-i18next"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; @@ -20,6 +21,7 @@ import { PanelRightOpen, LogOut, Moon, + Shield, Sun, Menu, X, @@ -48,6 +50,7 @@ export default function Header({ mobileSheetContent, }: HeaderProps) { const { user, logout } = useAuth(); + const { t, i18n } = useTranslation(); const router = useRouter(); const { theme, setTheme } = useTheme(); const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); @@ -61,6 +64,19 @@ export default function Header({ router.replace("/login"); }; + const languageLabel = (language: string) => { + switch (language) { + case "hi": + return t("common.hindi"); + case "es": + return t("common.spanish"); + case "fr": + return t("common.french"); + default: + return t("common.english"); + } + }; + return ( <>
@@ -205,4 +221,4 @@ export default function Header({ ); -} +} \ No newline at end of file diff --git a/frontend/src/components/layout/ThemeProvider.tsx b/frontend/src/components/layout/ThemeProvider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..545a22b99b2d452eedb50ab7ea7c5b0d4b8dc38f --- /dev/null +++ b/frontend/src/components/layout/ThemeProvider.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { ThemeProvider as NextThemesProvider } from "next-themes"; +import { type ThemeProviderProps } from "next-themes"; + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children}; +} \ No newline at end of file diff --git a/frontend/src/components/layout/ThemeToggle.tsx b/frontend/src/components/layout/ThemeToggle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ca8f0cedf99dda21d37c78907c989453b18efc4d --- /dev/null +++ b/frontend/src/components/layout/ThemeToggle.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { useSyncExternalStore } from "react"; +import { Sun, Moon } from "lucide-react"; + +// useSyncExternalStore with identical server/client snapshots = no hydration mismatch +const subscribe = () => () => {}; +const getSnapshot = () => true; +const getServerSnapshot = () => false; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + if (!mounted) return null; + + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/components/providers/I18nProvider.tsx b/frontend/src/components/providers/I18nProvider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b500fd254d6a9586ffbb2b17a054420eab327bba --- /dev/null +++ b/frontend/src/components/providers/I18nProvider.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useEffect } from "react"; +import { I18nextProvider } from "react-i18next"; +import i18n from "@/lib/i18n"; + +export default function I18nProvider({ children }: { children: React.ReactNode }) { + useEffect(() => { + document.documentElement.lang = i18n.resolvedLanguage || "en"; + + const handleLanguageChanged = (language: string) => { + document.documentElement.lang = language; + }; + + i18n.on("languageChanged", handleLanguageChanged); + return () => { + i18n.off("languageChanged", handleLanguageChanged); + }; + }, []); + + return {children}; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 95b1a14f502e45eeda186e58bced124827cfe220..dab01f9ac4c5ffa569d0a8bea61cac5bb1f6111a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -201,6 +201,29 @@ class ApiClient { return res.json(); } + async put(path: string, body?: unknown, options?: FetchOptions): Promise { + const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, { + method: "PUT", + headers: this.getHeaders(options?.token), + body: body ? JSON.stringify(body) : undefined, + ...options, + }); + + // Auto-refresh on 401 + if (res.status === 401 && !options?._skipRefresh) { + const newToken = await this.tryRefreshToken(); + if (newToken) { + return this.put(path, body, { ...options, token: newToken, _skipRefresh: true }); + } + } + + if (!res.ok) { + throw new Error(await this.getErrorMessage(res, res.statusText || "Request failed")); + } + + return res.json(); + } + async postForm(path: string, formData: FormData, options?: FetchOptions): Promise { const token = options?.token || this.getToken(); const headers: HeadersInit = {}; diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts new file mode 100644 index 0000000000000000000000000000000000000000..ecc953471e34072255fcf80919aafbd9c2f347d8 --- /dev/null +++ b/frontend/src/lib/i18n.ts @@ -0,0 +1,320 @@ +"use client"; + +import i18n from "i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import { initReactI18next } from "react-i18next"; + +const resources = { + en: { + translation: { + common: { + appName: "Document AI Analyst", + language: "Language", + english: "English", + hindi: "Hindi", + spanish: "Spanish", + french: "French", + email: "Email", + password: "Password", + username: "Username", + }, + header: { + closeSidebar: "Close sidebar", + openSidebar: "Open sidebar", + closeViewer: "Close viewer", + openViewer: "Open viewer", + lightMode: "Light mode", + darkMode: "Dark mode", + signOut: "Sign out", + }, + login: { + title: "Welcome back", + description: "Sign in to your Document AI Analyst account", + fallbackError: "Login failed", + submit: "Sign In", + submitting: "Signing in...", + noAccount: "Don't have an account?", + createOne: "Create one", + }, + register: { + title: "Create Account", + description: "Start analyzing documents with AI", + fallbackError: "Registration failed", + passwordPlaceholder: "Minimum 6 characters", + submit: "Create Account", + submitting: "Creating account...", + hasAccount: "Already have an account?", + signIn: "Sign in", + }, + chat: { + askAboutDocument: "Ask about your document", + selectDocument: "Select a document", + readyPrompt: "\"{{name}}\" is ready. Ask any question and get cited answers.", + uploadPrompt: "Upload and select a document from the sidebar to start chatting.", + fallbackError: "Failed to get response: {{message}}", + clearConfirm: "Clear all chat history for this document?", + askPlaceholder: "Ask about \"{{name}}\"...", + selectPlaceholder: "Select a document first...", + exportTitle: "Export chat history", + markdown: "Markdown (.md)", + plainText: "Plain Text (.txt)", + pdf: "PDF (.pdf)", + }, + documents: { + uploadFailed: "Upload failed", + deleteConfirm: "Delete this document and all its data?", + uploading: "Uploading...", + dropHere: "Drop files here", + dropOrClick: "Drop files or click to upload", + uploadFormats: "PDF, DOCX, TXT, MD (max 50MB)", + documentsTitle: "Documents ({{count}})", + noDocuments: "No documents yet", + getStarted: "Upload a file to get started", + pagesShort: "{{count}} pg", + chunks: "{{count}} chunks", + processing: "Processing", + failed: "Failed", + }, + }, + }, + hi: { + translation: { + common: { + appName: "डॉक्यूमेंट एआई एनालिस्ट", + language: "भाषा", + english: "अंग्रेज़ी", + hindi: "हिंदी", + spanish: "स्पेनिश", + french: "फ़्रेंच", + email: "ईमेल", + password: "पासवर्ड", + username: "उपयोगकर्ता नाम", + }, + header: { + closeSidebar: "साइडबार बंद करें", + openSidebar: "साइडबार खोलें", + closeViewer: "व्यूअर बंद करें", + openViewer: "व्यूअर खोलें", + lightMode: "लाइट मोड", + darkMode: "डार्क मोड", + signOut: "साइन आउट", + }, + login: { + title: "वापसी पर स्वागत है", + description: "अपने डॉक्यूमेंट एआई एनालिस्ट खाते में साइन इन करें", + fallbackError: "लॉगिन विफल", + submit: "साइन इन करें", + submitting: "साइन इन हो रहा है...", + noAccount: "क्या आपका खाता नहीं है?", + createOne: "एक बनाएं", + }, + register: { + title: "खाता बनाएं", + description: "एआई के साथ दस्तावेज़ों का विश्लेषण शुरू करें", + fallbackError: "पंजीकरण विफल", + passwordPlaceholder: "कम से कम 6 अक्षर", + submit: "खाता बनाएं", + submitting: "खाता बनाया जा रहा है...", + hasAccount: "क्या पहले से खाता है?", + signIn: "साइन इन करें", + }, + chat: { + askAboutDocument: "अपने दस्तावेज़ के बारे में पूछें", + selectDocument: "एक दस्तावेज़ चुनें", + readyPrompt: "\"{{name}}\" तैयार है। कोई भी प्रश्न पूछें और स्रोत सहित उत्तर पाएं।", + uploadPrompt: "चैट शुरू करने के लिए साइडबार से फ़ाइल अपलोड और चुनें।", + fallbackError: "जवाब प्राप्त नहीं हुआ: {{message}}", + clearConfirm: "क्या इस दस्तावेज़ का पूरा चैट इतिहास साफ़ करें?", + askPlaceholder: "\"{{name}}\" के बारे में पूछें...", + selectPlaceholder: "पहले एक दस्तावेज़ चुनें...", + exportTitle: "चैट इतिहास निर्यात करें", + markdown: "मार्कडाउन (.md)", + plainText: "सादा पाठ (.txt)", + pdf: "पीडीएफ (.pdf)", + }, + documents: { + uploadFailed: "अपलोड विफल", + deleteConfirm: "क्या इस दस्तावेज़ और उसके सभी डेटा को हटाएं?", + uploading: "अपलोड हो रहा है...", + dropHere: "फ़ाइलें यहाँ छोड़ें", + dropOrClick: "फ़ाइलें छोड़ें या अपलोड के लिए क्लिक करें", + uploadFormats: "PDF, DOCX, TXT, MD (अधिकतम 50MB)", + documentsTitle: "दस्तावेज़ ({{count}})", + noDocuments: "अभी तक कोई दस्तावेज़ नहीं", + getStarted: "शुरू करने के लिए फ़ाइल अपलोड करें", + pagesShort: "{{count}} पेज", + chunks: "{{count}} खंड", + processing: "प्रोसेस हो रहा है", + failed: "विफल", + }, + }, + }, + es: { + translation: { + common: { + appName: "Analista IA de Documentos", + language: "Idioma", + english: "Inglés", + hindi: "Hindi", + spanish: "Español", + french: "Francés", + email: "Correo electrónico", + password: "Contraseña", + username: "Nombre de usuario", + }, + header: { + closeSidebar: "Cerrar barra lateral", + openSidebar: "Abrir barra lateral", + closeViewer: "Cerrar visor", + openViewer: "Abrir visor", + lightMode: "Modo claro", + darkMode: "Modo oscuro", + signOut: "Cerrar sesión", + }, + login: { + title: "Bienvenido de nuevo", + description: "Inicia sesión en tu cuenta de Analista IA de Documentos", + fallbackError: "Error al iniciar sesión", + submit: "Iniciar sesión", + submitting: "Iniciando sesión...", + noAccount: "¿No tienes una cuenta?", + createOne: "Crear una", + }, + register: { + title: "Crear cuenta", + description: "Empieza a analizar documentos con IA", + fallbackError: "Error de registro", + passwordPlaceholder: "Mínimo 6 caracteres", + submit: "Crear cuenta", + submitting: "Creando cuenta...", + hasAccount: "¿Ya tienes una cuenta?", + signIn: "Inicia sesión", + }, + chat: { + askAboutDocument: "Pregunta sobre tu documento", + selectDocument: "Selecciona un documento", + readyPrompt: "\"{{name}}\" está listo. Haz cualquier pregunta y obtén respuestas con citas.", + uploadPrompt: "Sube y selecciona un documento de la barra lateral para comenzar a chatear.", + fallbackError: "No se pudo obtener respuesta: {{message}}", + clearConfirm: "¿Borrar todo el historial de chat de este documento?", + askPlaceholder: "Pregunta sobre \"{{name}}\"...", + selectPlaceholder: "Primero selecciona un documento...", + exportTitle: "Exportar historial del chat", + markdown: "Markdown (.md)", + plainText: "Texto plano (.txt)", + pdf: "PDF (.pdf)", + }, + documents: { + uploadFailed: "Error de carga", + deleteConfirm: "¿Eliminar este documento y todos sus datos?", + uploading: "Subiendo...", + dropHere: "Suelta archivos aquí", + dropOrClick: "Suelta archivos o haz clic para subir", + uploadFormats: "PDF, DOCX, TXT, MD (máx. 50MB)", + documentsTitle: "Documentos ({{count}})", + noDocuments: "Aún no hay documentos", + getStarted: "Sube un archivo para comenzar", + pagesShort: "{{count}} pág", + chunks: "{{count}} fragmentos", + processing: "Procesando", + failed: "Falló", + }, + }, + }, + fr: { + translation: { + common: { + appName: "Analyste IA de Documents", + language: "Langue", + english: "Anglais", + hindi: "Hindi", + spanish: "Espagnol", + french: "Français", + email: "E-mail", + password: "Mot de passe", + username: "Nom d'utilisateur", + }, + header: { + closeSidebar: "Fermer la barre latérale", + openSidebar: "Ouvrir la barre latérale", + closeViewer: "Fermer le lecteur", + openViewer: "Ouvrir le lecteur", + lightMode: "Mode clair", + darkMode: "Mode sombre", + signOut: "Se déconnecter", + }, + login: { + title: "Bon retour", + description: "Connectez-vous à votre compte Analyste IA de Documents", + fallbackError: "Échec de la connexion", + submit: "Se connecter", + submitting: "Connexion en cours...", + noAccount: "Vous n'avez pas de compte ?", + createOne: "En créer un", + }, + register: { + title: "Créer un compte", + description: "Commencez à analyser des documents avec l'IA", + fallbackError: "Échec de l'inscription", + passwordPlaceholder: "6 caractères minimum", + submit: "Créer un compte", + submitting: "Création du compte...", + hasAccount: "Vous avez déjà un compte ?", + signIn: "Se connecter", + }, + chat: { + askAboutDocument: "Posez une question sur votre document", + selectDocument: "Sélectionnez un document", + readyPrompt: "\"{{name}}\" est prêt. Posez n'importe quelle question et obtenez des réponses sourcées.", + uploadPrompt: "Importez puis sélectionnez un document dans la barre latérale pour commencer à discuter.", + fallbackError: "Impossible d'obtenir une réponse : {{message}}", + clearConfirm: "Effacer tout l'historique de discussion de ce document ?", + askPlaceholder: "Posez une question sur \"{{name}}\"...", + selectPlaceholder: "Sélectionnez d'abord un document...", + exportTitle: "Exporter l'historique du chat", + markdown: "Markdown (.md)", + plainText: "Texte brut (.txt)", + pdf: "PDF (.pdf)", + }, + documents: { + uploadFailed: "Échec de l'envoi", + deleteConfirm: "Supprimer ce document et toutes ses données ?", + uploading: "Envoi en cours...", + dropHere: "Déposez les fichiers ici", + dropOrClick: "Déposez les fichiers ou cliquez pour téléverser", + uploadFormats: "PDF, DOCX, TXT, MD (max 50 Mo)", + documentsTitle: "Documents ({{count}})", + noDocuments: "Aucun document pour le moment", + getStarted: "Importez un fichier pour commencer", + pagesShort: "{{count}} p", + chunks: "{{count}} segments", + processing: "Traitement", + failed: "Échec", + }, + }, + }, +} as const; + +if (!i18n.isInitialized) { + void i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: "en", + supportedLngs: ["en", "hi", "es", "fr"], + interpolation: { + escapeValue: false, + }, + detection: { + order: ["localStorage", "navigator"], + caches: ["localStorage"], + lookupLocalStorage: "i18nextLng", + }, + react: { + useSuspense: false, + }, + }); +} + +export default i18n; diff --git a/frontend/src/store/auth-store.ts b/frontend/src/store/auth-store.ts index fc5eed34a6af902d7d8de5834f21043e9bb0f09e..69ed6d47f8a9c558425afcf6004b725bb63a3e83 100644 --- a/frontend/src/store/auth-store.ts +++ b/frontend/src/store/auth-store.ts @@ -8,6 +8,7 @@ export interface AuthUser { username: string; email: string; is_admin: boolean; + hf_token?: string; created_at: string; } @@ -23,6 +24,7 @@ interface AuthStore { initializeAuth: () => Promise; syncTokensRefreshed: (detail?: { accessToken?: string; user?: AuthUser | null }) => void; syncLoggedOut: () => void; + setHfToken: (hfToken: string) => Promise; } const getStoredToken = () => @@ -138,4 +140,9 @@ export const useAuthStore = create((set, get) => ({ initialized: true, }); }, + + async setHfToken(hfToken: string) { + const response = await api.put("/api/v1/auth/hf-token", { hf_token: hfToken }); + set({ user: response }); + }, }));