Spaces:
Sleeping
Sleeping
feat: completed initial dev
Browse files- .dockerignore +12 -0
- .env.example +24 -0
- .gitignore +25 -0
- Dockerfile +61 -0
- README.md +65 -1
- backend/auth.py +51 -0
- backend/hf_embedding.py +188 -0
- backend/hf_sync.py +261 -0
- backend/main.py +506 -0
- backend/personas.json +26 -0
- backend/personas_store.py +110 -0
- backend/pyproject.toml +25 -0
- backend/rag.py +174 -0
- backend/uv.lock +0 -0
- frontend/.eslintrc.js +4 -0
- frontend/app/admin/login/page.tsx +68 -0
- frontend/app/admin/page.tsx +82 -0
- frontend/app/api/chat/route.ts +39 -0
- frontend/app/api/upload/route.ts +37 -0
- frontend/app/globals.css +47 -0
- frontend/app/layout.tsx +15 -0
- frontend/app/page.tsx +26 -0
- frontend/components/chat-interface.tsx +243 -0
- frontend/components/file-list.tsx +85 -0
- frontend/components/file-uploader.tsx +87 -0
- frontend/components/persona-editor.tsx +297 -0
- frontend/components/persona-selector.tsx +43 -0
- frontend/components/temperature-slider.tsx +32 -0
- frontend/components/ui/button.tsx +48 -0
- frontend/components/ui/card.tsx +65 -0
- frontend/components/ui/dialog.tsx +109 -0
- frontend/components/ui/input.tsx +19 -0
- frontend/components/ui/label.tsx +19 -0
- frontend/components/ui/select.tsx +81 -0
- frontend/components/ui/slider.tsx +24 -0
- frontend/lib/personas.ts +63 -0
- frontend/lib/utils.ts +6 -0
- frontend/next-env.d.ts +6 -0
- frontend/next.config.js +22 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +38 -0
- frontend/postcss.config.mjs +6 -0
- frontend/tailwind.config.ts +46 -0
- frontend/tsconfig.json +43 -0
- frontend/tsconfig.tsbuildinfo +0 -0
- start.sh +32 -0
- test_stream.py +6 -0
- test_uvicorn_log.py +14 -0
.dockerignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
**/node_modules
|
| 2 |
+
**/.next
|
| 3 |
+
**/.venv
|
| 4 |
+
**/__pycache__
|
| 5 |
+
**/.lancedb
|
| 6 |
+
**/.tmp_uploads
|
| 7 |
+
**/.cache
|
| 8 |
+
.git
|
| 9 |
+
.env
|
| 10 |
+
.env.local
|
| 11 |
+
*.md
|
| 12 |
+
!README.md
|
.env.example
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ---- Hugging Face ----
|
| 2 |
+
# A read+write token from https://huggingface.co/settings/tokens
|
| 3 |
+
HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
| 4 |
+
|
| 5 |
+
# The dataset repo that acts as your persistent "cloud drive" for PDFs.
|
| 6 |
+
# Format: "username/dataset-name" — must already exist (create as a private dataset).
|
| 7 |
+
DATASET_ID=your-username/iam-earth-dev-corpus
|
| 8 |
+
|
| 9 |
+
# ---- LLM / Embeddings (Hugging Face Serverless Inference) ----
|
| 10 |
+
# Any chat-completions-capable model on the HF Inference API.
|
| 11 |
+
LLM_MODEL=Qwen/Qwen2.5-7B-Instruct
|
| 12 |
+
EMBED_MODEL=BAAI/bge-small-en-v1.5
|
| 13 |
+
|
| 14 |
+
# Leave reasoning hidden in the student UI. Set true only when debugging models
|
| 15 |
+
# that expose a separate thinking/reasoning stream.
|
| 16 |
+
SHOW_LLM_REASONING=false
|
| 17 |
+
|
| 18 |
+
# ---- Admin auth ----
|
| 19 |
+
# Educators set this as a Space secret; the admin dashboard requires it.
|
| 20 |
+
ADMIN_PASSCODE=password
|
| 21 |
+
|
| 22 |
+
# ---- Internal (defaults are fine for local dev; Docker sets these via Dockerfile ENV) ----
|
| 23 |
+
BACKEND_PORT=8000
|
| 24 |
+
FRONTEND_PORT=7860
|
.gitignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.venv/
|
| 5 |
+
backend/.venv/
|
| 6 |
+
.uv/
|
| 7 |
+
|
| 8 |
+
# Node
|
| 9 |
+
node_modules/
|
| 10 |
+
frontend/.next/
|
| 11 |
+
frontend/out/
|
| 12 |
+
.next/
|
| 13 |
+
|
| 14 |
+
# Runtime data (must NOT be committed — these are ephemeral on HF Spaces)
|
| 15 |
+
.lancedb/
|
| 16 |
+
.tmp_uploads/
|
| 17 |
+
.cache/
|
| 18 |
+
|
| 19 |
+
# Env
|
| 20 |
+
.env
|
| 21 |
+
.env.local
|
| 22 |
+
|
| 23 |
+
# OS
|
| 24 |
+
.DS_Store
|
| 25 |
+
Thumbs.db
|
Dockerfile
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1.7
|
| 2 |
+
# ---------- Stage 1: Build the Next.js frontend ----------
|
| 3 |
+
FROM node:20-bookworm-slim AS frontend-build
|
| 4 |
+
|
| 5 |
+
WORKDIR /app/frontend
|
| 6 |
+
COPY frontend/package.json frontend/package-lock.json* ./
|
| 7 |
+
RUN npm install --no-audit --no-fund
|
| 8 |
+
|
| 9 |
+
COPY frontend/ ./
|
| 10 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 11 |
+
RUN npm run build
|
| 12 |
+
|
| 13 |
+
# ---------- Stage 2: Runtime (Python 3.13 + Node 20) ----------
|
| 14 |
+
FROM python:3.13-slim-bookworm AS runtime
|
| 15 |
+
|
| 16 |
+
# Install Node 20 (needed to run `next start`) and minimal system libs
|
| 17 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 18 |
+
curl ca-certificates gnupg build-essential \
|
| 19 |
+
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
| 20 |
+
&& apt-get install -y --no-install-recommends nodejs \
|
| 21 |
+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
| 22 |
+
|
| 23 |
+
# Install uv (fast Python package manager)
|
| 24 |
+
RUN pip install --no-cache-dir uv
|
| 25 |
+
|
| 26 |
+
# HF Spaces requires the working dir to be writable by an arbitrary uid.
|
| 27 |
+
# We create /app and chmod it 777 so cache + lancedb dirs work at runtime.
|
| 28 |
+
WORKDIR /app
|
| 29 |
+
|
| 30 |
+
# ---- Backend ----
|
| 31 |
+
COPY backend/pyproject.toml backend/uv.lock* ./backend/
|
| 32 |
+
RUN cd backend && uv sync --no-dev --frozen 2>/dev/null || (cd backend && uv sync --no-dev)
|
| 33 |
+
|
| 34 |
+
COPY backend/ ./backend/
|
| 35 |
+
|
| 36 |
+
# ---- Frontend (built artifacts only) ----
|
| 37 |
+
COPY --from=frontend-build /app/frontend/.next ./frontend/.next
|
| 38 |
+
COPY --from=frontend-build /app/frontend/public ./frontend/public
|
| 39 |
+
COPY --from=frontend-build /app/frontend/package.json ./frontend/package.json
|
| 40 |
+
COPY --from=frontend-build /app/frontend/next.config.js ./frontend/next.config.js
|
| 41 |
+
COPY --from=frontend-build /app/frontend/node_modules ./frontend/node_modules
|
| 42 |
+
|
| 43 |
+
# ---- Entry script ----
|
| 44 |
+
COPY start.sh /app/start.sh
|
| 45 |
+
RUN chmod +x /app/start.sh
|
| 46 |
+
|
| 47 |
+
# Cache + data dirs (HF Spaces runs as random uid; make them world-writable)
|
| 48 |
+
RUN mkdir -p /app/.cache /app/.lancedb /app/.tmp_uploads \
|
| 49 |
+
&& chmod -R 777 /app
|
| 50 |
+
|
| 51 |
+
ENV HF_HOME=/app/.cache \
|
| 52 |
+
LANCEDB_PATH=/app/.lancedb \
|
| 53 |
+
TMP_UPLOAD_DIR=/app/.tmp_uploads \
|
| 54 |
+
BACKEND_PORT=8000 \
|
| 55 |
+
FRONTEND_PORT=7860 \
|
| 56 |
+
NEXT_TELEMETRY_DISABLED=1 \
|
| 57 |
+
PYTHONUNBUFFERED=1
|
| 58 |
+
|
| 59 |
+
EXPOSE 7860
|
| 60 |
+
|
| 61 |
+
CMD ["/app/start.sh"]
|
README.md
CHANGED
|
@@ -4,7 +4,71 @@ emoji: 🌖
|
|
| 4 |
colorFrom: purple
|
| 5 |
colorTo: green
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
colorFrom: purple
|
| 5 |
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
## Quick start (clone for your own course)
|
| 12 |
+
|
| 13 |
+
1. **Duplicate this Space** on Hugging Face.
|
| 14 |
+
2. **Create a private Dataset** on HF (e.g. `your-username/my-course-corpus`) — leave it empty.
|
| 15 |
+
3. In your Space → **Settings → Variables and secrets**, add:
|
| 16 |
+
- `HF_TOKEN` — a token with **write** access to the dataset above.
|
| 17 |
+
- `DATASET_ID` — `your-username/my-course-corpus`
|
| 18 |
+
- `ADMIN_PASSCODE` — any string; this protects `/admin`.
|
| 19 |
+
- *(optional)* `LLM_MODEL` — defaults to `Qwen/Qwen2.5-7B-Instruct`.
|
| 20 |
+
- *(optional)* `EMBED_MODEL` — defaults to `BAAI/bge-small-en-v1.5`.
|
| 21 |
+
4. Restart the Space. Open `/admin`, sign in with the passcode, drag in PDFs.
|
| 22 |
+
5. Share the root URL with your students — they land on the chat UI.
|
| 23 |
+
|
| 24 |
+
## Local development
|
| 25 |
+
|
| 26 |
+
Requires Python 3.13, Node 20+, and [`uv`](https://docs.astral.sh/uv/).
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
cp .env.example .env # fill in HF_TOKEN, DATASET_ID, ADMIN_PASSCODE
|
| 30 |
+
|
| 31 |
+
# Backend
|
| 32 |
+
cd backend && uv sync && uv run uvicorn main:app --port 8000 --reload
|
| 33 |
+
|
| 34 |
+
# Frontend (in another shell)
|
| 35 |
+
cd frontend && npm install && npm run dev
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
Visit <http://localhost:7860>. Next.js proxies `/api/*` to `127.0.0.1:8000`.
|
| 39 |
+
|
| 40 |
+
### Or with Docker (matches the HF Space environment exactly)
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
docker build -t iamearthdev .
|
| 44 |
+
docker run --rm -p 7860:7860 --env-file .env iamearthdev
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
## Customizing personas
|
| 48 |
+
|
| 49 |
+
Edit [`frontend/lib/personas.ts`](frontend/lib/personas.ts) — each persona is just an
|
| 50 |
+
`id`, `name`, `description`, and a `prompt` that gets prepended to the system message.
|
| 51 |
+
Rebuild the Space to deploy.
|
| 52 |
+
|
| 53 |
+
## Repo layout
|
| 54 |
+
|
| 55 |
+
```
|
| 56 |
+
/
|
| 57 |
+
├── Dockerfile # multi-stage: Next build → Python+Node runtime
|
| 58 |
+
├── start.sh # launches FastAPI (:8000) and Next.js (:7860)
|
| 59 |
+
├── .env.example
|
| 60 |
+
├── backend/
|
| 61 |
+
│ ├── pyproject.toml # uv-managed deps
|
| 62 |
+
│ ├── main.py # FastAPI app, /chat streams NDJSON
|
| 63 |
+
│ ├── rag.py # PyMuPDF4LLM → LlamaIndex → LanceDB
|
| 64 |
+
│ ├── hf_sync.py # Dataset upload/download/list/delete
|
| 65 |
+
│ └── auth.py # passcode cookie middleware
|
| 66 |
+
└── frontend/
|
| 67 |
+
├── next.config.js # /api/* → http://127.0.0.1:8000/*
|
| 68 |
+
├── package.json
|
| 69 |
+
├── app/
|
| 70 |
+
│ ├── page.tsx # chat UI
|
| 71 |
+
│ ├── admin/page.tsx # upload + file list
|
| 72 |
+
│ └── admin/login/page.tsx
|
| 73 |
+
└── components/ # chat-interface, persona-selector, file-uploader, …
|
| 74 |
+
```
|
backend/auth.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Trivial passcode auth for the educator dashboard.
|
| 2 |
+
|
| 3 |
+
The frontend stores the passcode in an httpOnly cookie set by /api/admin/login.
|
| 4 |
+
Protected endpoints depend on `require_admin` to validate the cookie.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import secrets
|
| 10 |
+
|
| 11 |
+
from fastapi import Cookie, HTTPException, status
|
| 12 |
+
|
| 13 |
+
ADMIN_PASSCODE = os.environ.get("ADMIN_PASSCODE", "")
|
| 14 |
+
COOKIE_NAME = "admin_session"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _expected_token() -> str:
|
| 18 |
+
"""A deterministic-but-opaque token derived from the passcode.
|
| 19 |
+
|
| 20 |
+
Avoids storing the raw passcode in the cookie. We don't need full session
|
| 21 |
+
management here — the passcode itself is the credential.
|
| 22 |
+
"""
|
| 23 |
+
if not ADMIN_PASSCODE:
|
| 24 |
+
return ""
|
| 25 |
+
# 32-char hex digest is plenty for a single-tenant educator template.
|
| 26 |
+
import hashlib
|
| 27 |
+
|
| 28 |
+
return hashlib.sha256(ADMIN_PASSCODE.encode("utf-8")).hexdigest()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def verify_passcode(passcode: str) -> bool:
|
| 32 |
+
if not ADMIN_PASSCODE:
|
| 33 |
+
return False
|
| 34 |
+
return secrets.compare_digest(passcode, ADMIN_PASSCODE)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def issue_token() -> str:
|
| 38 |
+
return _expected_token()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def require_admin(admin_session: str | None = Cookie(default=None)) -> None:
|
| 42 |
+
if not ADMIN_PASSCODE:
|
| 43 |
+
raise HTTPException(
|
| 44 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 45 |
+
detail="ADMIN_PASSCODE is not configured on the server.",
|
| 46 |
+
)
|
| 47 |
+
if not admin_session or not secrets.compare_digest(admin_session, _expected_token()):
|
| 48 |
+
raise HTTPException(
|
| 49 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 50 |
+
detail="Admin authentication required.",
|
| 51 |
+
)
|
backend/hf_embedding.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
import time
|
| 6 |
+
from typing import Any, Dict, List, Optional, Union
|
| 7 |
+
|
| 8 |
+
from huggingface_hub import InferenceClient
|
| 9 |
+
from huggingface_hub.errors import HfHubHTTPError, InferenceTimeoutError
|
| 10 |
+
from llama_index.core.base.embeddings.base import BaseEmbedding, Embedding
|
| 11 |
+
from llama_index.core.bridge.pydantic import Field, PrivateAttr
|
| 12 |
+
from llama_index.embeddings.huggingface_api.pooling import Pooling
|
| 13 |
+
from llama_index.utils.huggingface import format_query, format_text
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
DEFAULT_MAX_CHARS = int(os.environ.get("HF_EMBED_MAX_CHARS", "3000"))
|
| 19 |
+
DEFAULT_MIN_CHARS = int(os.environ.get("HF_EMBED_MIN_CHARS", "750"))
|
| 20 |
+
DEFAULT_MAX_RETRIES = int(os.environ.get("HF_EMBED_RETRIES", "3"))
|
| 21 |
+
DEFAULT_BACKOFF_SECONDS = float(os.environ.get("HF_EMBED_RETRY_BACKOFF", "1.0"))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class SyncHuggingFaceInferenceEmbedding(BaseEmbedding):
|
| 25 |
+
"""Sync-only embedding adapter for HF Inference API.
|
| 26 |
+
|
| 27 |
+
The upstream LlamaIndex HF wrapper uses AsyncInferenceClient internally even
|
| 28 |
+
for sync calls, which is brittle under uvicorn/asyncio. This adapter uses
|
| 29 |
+
only the regular InferenceClient, so indexing and retrieval can run safely
|
| 30 |
+
inside a worker thread.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
pooling: Optional[Pooling] = Field(default=Pooling.CLS)
|
| 34 |
+
query_instruction: Optional[str] = Field(default=None)
|
| 35 |
+
text_instruction: Optional[str] = Field(default=None)
|
| 36 |
+
model_name: str = Field(default="BAAI/bge-small-en-v1.5")
|
| 37 |
+
token: Union[str, bool, None] = Field(default=None)
|
| 38 |
+
timeout: Optional[float] = Field(default=None)
|
| 39 |
+
headers: Optional[Dict[str, str]] = Field(default=None)
|
| 40 |
+
cookies: Optional[Dict[str, str]] = Field(default=None)
|
| 41 |
+
max_chars_per_request: int = Field(default=DEFAULT_MAX_CHARS, gt=0)
|
| 42 |
+
min_chars_per_request: int = Field(default=DEFAULT_MIN_CHARS, gt=0)
|
| 43 |
+
max_retries: int = Field(default=DEFAULT_MAX_RETRIES, ge=1)
|
| 44 |
+
retry_backoff_seconds: float = Field(default=DEFAULT_BACKOFF_SECONDS, ge=0.0)
|
| 45 |
+
|
| 46 |
+
_client: InferenceClient = PrivateAttr()
|
| 47 |
+
|
| 48 |
+
def __init__(self, **kwargs: Any) -> None:
|
| 49 |
+
super().__init__(**kwargs)
|
| 50 |
+
self._client = InferenceClient(
|
| 51 |
+
model=self.model_name,
|
| 52 |
+
token=self.token,
|
| 53 |
+
timeout=self.timeout,
|
| 54 |
+
headers=self.headers,
|
| 55 |
+
cookies=self.cookies,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
@classmethod
|
| 59 |
+
def class_name(cls) -> str:
|
| 60 |
+
return "SyncHuggingFaceInferenceEmbedding"
|
| 61 |
+
|
| 62 |
+
@staticmethod
|
| 63 |
+
def _mean_pool_vectors(vectors: List[Embedding]) -> Embedding:
|
| 64 |
+
if not vectors:
|
| 65 |
+
raise ValueError("Cannot average an empty list of embeddings.")
|
| 66 |
+
if len(vectors) == 1:
|
| 67 |
+
return vectors[0]
|
| 68 |
+
return [sum(values) / len(values) for values in zip(*vectors)]
|
| 69 |
+
|
| 70 |
+
@staticmethod
|
| 71 |
+
def _split_text(text: str, max_chars: int) -> List[str]:
|
| 72 |
+
stripped = text.strip()
|
| 73 |
+
if len(stripped) <= max_chars:
|
| 74 |
+
return [stripped] if stripped else [" "]
|
| 75 |
+
|
| 76 |
+
paragraphs = [part.strip() for part in stripped.split("\n\n") if part.strip()]
|
| 77 |
+
if not paragraphs:
|
| 78 |
+
paragraphs = [stripped]
|
| 79 |
+
|
| 80 |
+
segments: List[str] = []
|
| 81 |
+
current = ""
|
| 82 |
+
|
| 83 |
+
for paragraph in paragraphs:
|
| 84 |
+
pieces = [paragraph[i : i + max_chars] for i in range(0, len(paragraph), max_chars)]
|
| 85 |
+
for piece in pieces:
|
| 86 |
+
candidate = piece if not current else f"{current}\n\n{piece}"
|
| 87 |
+
if len(candidate) <= max_chars:
|
| 88 |
+
current = candidate
|
| 89 |
+
else:
|
| 90 |
+
if current:
|
| 91 |
+
segments.append(current)
|
| 92 |
+
current = piece
|
| 93 |
+
|
| 94 |
+
if current:
|
| 95 |
+
segments.append(current)
|
| 96 |
+
|
| 97 |
+
return segments or [" "]
|
| 98 |
+
|
| 99 |
+
def _embed_request(self, text: str) -> Embedding:
|
| 100 |
+
embedding = self._client.feature_extraction(
|
| 101 |
+
text,
|
| 102 |
+
truncate=True,
|
| 103 |
+
truncation_direction="right",
|
| 104 |
+
)
|
| 105 |
+
if len(embedding.shape) == 1:
|
| 106 |
+
return embedding.tolist()
|
| 107 |
+
|
| 108 |
+
embedding = embedding.squeeze(axis=0)
|
| 109 |
+
if len(embedding.shape) == 1:
|
| 110 |
+
return embedding.tolist()
|
| 111 |
+
|
| 112 |
+
if self.pooling is None:
|
| 113 |
+
raise ValueError(
|
| 114 |
+
f"Pooling is required for {self.model_name} because it returned "
|
| 115 |
+
"a > 1-D value."
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
return self.pooling(embedding).tolist()
|
| 119 |
+
|
| 120 |
+
def _embed_with_retry(self, text: str) -> Embedding:
|
| 121 |
+
last_error: Exception | None = None
|
| 122 |
+
|
| 123 |
+
for attempt in range(1, self.max_retries + 1):
|
| 124 |
+
try:
|
| 125 |
+
return self._embed_request(text)
|
| 126 |
+
except (HfHubHTTPError, InferenceTimeoutError) as exc:
|
| 127 |
+
last_error = exc
|
| 128 |
+
status_code = getattr(getattr(exc, "response", None), "status_code", None)
|
| 129 |
+
retryable = isinstance(exc, InferenceTimeoutError) or status_code in (429, 500, 502, 503, 504)
|
| 130 |
+
if not retryable or attempt == self.max_retries:
|
| 131 |
+
break
|
| 132 |
+
delay = self.retry_backoff_seconds * (2 ** (attempt - 1))
|
| 133 |
+
logger.warning(
|
| 134 |
+
"HF embedding request failed for %s (status=%s, attempt %d/%d). Retrying in %.1fs.",
|
| 135 |
+
self.model_name,
|
| 136 |
+
status_code,
|
| 137 |
+
attempt,
|
| 138 |
+
self.max_retries,
|
| 139 |
+
delay,
|
| 140 |
+
)
|
| 141 |
+
time.sleep(delay)
|
| 142 |
+
|
| 143 |
+
if last_error is not None:
|
| 144 |
+
raise last_error
|
| 145 |
+
raise RuntimeError("Embedding request failed without an exception.")
|
| 146 |
+
|
| 147 |
+
def _embed_single(self, text: str, *, max_chars: Optional[int] = None) -> Embedding:
|
| 148 |
+
stripped = text.strip() or " "
|
| 149 |
+
current_max_chars = max_chars or self.max_chars_per_request
|
| 150 |
+
segments = self._split_text(stripped, current_max_chars)
|
| 151 |
+
|
| 152 |
+
if len(segments) > 1:
|
| 153 |
+
embeddings = [self._embed_single(segment, max_chars=current_max_chars) for segment in segments]
|
| 154 |
+
return self._mean_pool_vectors(embeddings)
|
| 155 |
+
|
| 156 |
+
try:
|
| 157 |
+
return self._embed_with_retry(segments[0])
|
| 158 |
+
except (HfHubHTTPError, InferenceTimeoutError) as exc:
|
| 159 |
+
if current_max_chars <= self.min_chars_per_request or len(stripped) <= self.min_chars_per_request:
|
| 160 |
+
raise exc
|
| 161 |
+
|
| 162 |
+
smaller_max_chars = max(self.min_chars_per_request, current_max_chars // 2)
|
| 163 |
+
if smaller_max_chars >= current_max_chars:
|
| 164 |
+
raise exc
|
| 165 |
+
|
| 166 |
+
logger.warning(
|
| 167 |
+
"HF embedding request for %s failed after retries. Falling back to smaller text segments (%d -> %d chars).",
|
| 168 |
+
self.model_name,
|
| 169 |
+
current_max_chars,
|
| 170 |
+
smaller_max_chars,
|
| 171 |
+
)
|
| 172 |
+
return self._embed_single(stripped, max_chars=smaller_max_chars)
|
| 173 |
+
|
| 174 |
+
def _get_query_embedding(self, query: str) -> Embedding:
|
| 175 |
+
return self._embed_single(
|
| 176 |
+
format_query(query, self.model_name, self.query_instruction)
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
async def _aget_query_embedding(self, query: str) -> Embedding:
|
| 180 |
+
return self._get_query_embedding(query)
|
| 181 |
+
|
| 182 |
+
def _get_text_embedding(self, text: str) -> Embedding:
|
| 183 |
+
return self._embed_single(
|
| 184 |
+
format_text(text, self.model_name, self.text_instruction)
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
async def _aget_text_embedding(self, text: str) -> Embedding:
|
| 188 |
+
return self._get_text_embedding(text)
|
backend/hf_sync.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Face Dataset acts as the persistent 'cloud drive' for uploaded PDFs.
|
| 2 |
+
|
| 3 |
+
Layout inside the dataset repo:
|
| 4 |
+
pdfs/<filename>.pdf <- raw PDFs (source of truth)
|
| 5 |
+
vector_cache/manifest.json <- vector cache metadata
|
| 6 |
+
vector_cache/index/** <- persisted LanceDB files
|
| 7 |
+
|
| 8 |
+
Local disk on HF Spaces is ephemeral, so we try to restore the vector cache
|
| 9 |
+
from the dataset first and only rebuild from PDFs when the cache is missing or
|
| 10 |
+
stale.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import logging
|
| 16 |
+
import os
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
import shutil
|
| 19 |
+
from tempfile import TemporaryDirectory
|
| 20 |
+
from typing import List
|
| 21 |
+
|
| 22 |
+
from huggingface_hub import HfApi, hf_hub_download
|
| 23 |
+
from huggingface_hub.utils import EntryNotFoundError, RepositoryNotFoundError
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
HF_TOKEN = os.environ.get("HF_TOKEN")
|
| 28 |
+
DATASET_ID = os.environ.get("DATASET_ID")
|
| 29 |
+
PDF_PREFIX = "pdfs/"
|
| 30 |
+
VECTOR_PREFIX = "vector_cache/"
|
| 31 |
+
VECTOR_INDEX_PREFIX = f"{VECTOR_PREFIX}index/"
|
| 32 |
+
VECTOR_MANIFEST_PATH = f"{VECTOR_PREFIX}manifest.json"
|
| 33 |
+
VECTOR_CACHE_SCHEMA_VERSION = 1
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def is_configured() -> bool:
|
| 37 |
+
return bool(HF_TOKEN and DATASET_ID)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _vector_manifest(pdf_filenames: List[str], embed_model: str) -> dict:
|
| 41 |
+
return {
|
| 42 |
+
"schema_version": VECTOR_CACHE_SCHEMA_VERSION,
|
| 43 |
+
"embed_model": embed_model,
|
| 44 |
+
"pdf_files": sorted(pdf_filenames),
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _api() -> HfApi:
|
| 49 |
+
if not HF_TOKEN or not DATASET_ID:
|
| 50 |
+
raise RuntimeError(
|
| 51 |
+
"HF_TOKEN and DATASET_ID must be set as environment variables / Space secrets."
|
| 52 |
+
)
|
| 53 |
+
return HfApi(token=HF_TOKEN)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _ensure_dataset_exists(api: HfApi) -> None:
|
| 57 |
+
try:
|
| 58 |
+
api.repo_info(repo_id=DATASET_ID, repo_type="dataset")
|
| 59 |
+
except RepositoryNotFoundError:
|
| 60 |
+
try:
|
| 61 |
+
api.create_repo(
|
| 62 |
+
repo_id=DATASET_ID,
|
| 63 |
+
repo_type="dataset",
|
| 64 |
+
private=True,
|
| 65 |
+
exist_ok=True,
|
| 66 |
+
)
|
| 67 |
+
logger.info("Created dataset repo %s", DATASET_ID)
|
| 68 |
+
except Exception as exc: # noqa: BLE001
|
| 69 |
+
raise RuntimeError(
|
| 70 |
+
f"Dataset {DATASET_ID!r} does not exist and could not be created automatically. "
|
| 71 |
+
"Create it on Hugging Face or update DATASET_ID / HF_TOKEN permissions."
|
| 72 |
+
) from exc
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def list_remote_pdfs() -> List[str]:
|
| 76 |
+
"""Return PDF filenames (basename only) currently stored in the dataset."""
|
| 77 |
+
api = _api()
|
| 78 |
+
try:
|
| 79 |
+
files = api.list_repo_files(repo_id=DATASET_ID, repo_type="dataset")
|
| 80 |
+
except RepositoryNotFoundError:
|
| 81 |
+
return []
|
| 82 |
+
return [
|
| 83 |
+
f[len(PDF_PREFIX):]
|
| 84 |
+
for f in files
|
| 85 |
+
if f.startswith(PDF_PREFIX) and f.lower().endswith(".pdf")
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def download_vector_store(
|
| 90 |
+
target_dir: Path,
|
| 91 |
+
expected_pdf_filenames: List[str],
|
| 92 |
+
embed_model: str,
|
| 93 |
+
) -> bool:
|
| 94 |
+
"""Restore the persisted LanceDB folder when its manifest matches the dataset PDFs."""
|
| 95 |
+
if not is_configured():
|
| 96 |
+
return False
|
| 97 |
+
|
| 98 |
+
api = _api()
|
| 99 |
+
try:
|
| 100 |
+
manifest_path = hf_hub_download(
|
| 101 |
+
repo_id=DATASET_ID,
|
| 102 |
+
repo_type="dataset",
|
| 103 |
+
filename=VECTOR_MANIFEST_PATH,
|
| 104 |
+
token=HF_TOKEN,
|
| 105 |
+
)
|
| 106 |
+
except (EntryNotFoundError, RepositoryNotFoundError):
|
| 107 |
+
logger.info("No vector cache manifest in dataset yet")
|
| 108 |
+
return False
|
| 109 |
+
except Exception as exc: # noqa: BLE001
|
| 110 |
+
logger.warning("Could not download vector cache manifest: %s", exc)
|
| 111 |
+
return False
|
| 112 |
+
|
| 113 |
+
try:
|
| 114 |
+
manifest = json.loads(Path(manifest_path).read_text("utf-8"))
|
| 115 |
+
except Exception as exc: # noqa: BLE001
|
| 116 |
+
logger.warning("Could not parse vector cache manifest: %s", exc)
|
| 117 |
+
return False
|
| 118 |
+
|
| 119 |
+
expected_manifest = _vector_manifest(expected_pdf_filenames, embed_model)
|
| 120 |
+
if manifest != expected_manifest:
|
| 121 |
+
logger.info("Vector cache manifest is stale; rebuilding from PDFs")
|
| 122 |
+
return False
|
| 123 |
+
|
| 124 |
+
try:
|
| 125 |
+
files = api.list_repo_files(repo_id=DATASET_ID, repo_type="dataset")
|
| 126 |
+
except RepositoryNotFoundError:
|
| 127 |
+
return False
|
| 128 |
+
|
| 129 |
+
vector_files = [f for f in files if f.startswith(VECTOR_INDEX_PREFIX)]
|
| 130 |
+
if not vector_files:
|
| 131 |
+
logger.info("Vector cache manifest exists but no cache files were found")
|
| 132 |
+
return False
|
| 133 |
+
|
| 134 |
+
shutil.rmtree(target_dir, ignore_errors=True)
|
| 135 |
+
target_dir.mkdir(parents=True, exist_ok=True)
|
| 136 |
+
|
| 137 |
+
for repo_path in vector_files:
|
| 138 |
+
relative_path = repo_path[len(VECTOR_INDEX_PREFIX):]
|
| 139 |
+
if not relative_path:
|
| 140 |
+
continue
|
| 141 |
+
downloaded = hf_hub_download(
|
| 142 |
+
repo_id=DATASET_ID,
|
| 143 |
+
repo_type="dataset",
|
| 144 |
+
filename=repo_path,
|
| 145 |
+
token=HF_TOKEN,
|
| 146 |
+
)
|
| 147 |
+
destination = target_dir / relative_path
|
| 148 |
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
| 149 |
+
shutil.copy2(downloaded, destination)
|
| 150 |
+
|
| 151 |
+
logger.info("Vector cache restored from dataset (%d files)", len(vector_files))
|
| 152 |
+
return True
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def delete_vector_store() -> bool:
|
| 156 |
+
"""Remove the persisted vector cache folder from the dataset."""
|
| 157 |
+
if not is_configured():
|
| 158 |
+
return False
|
| 159 |
+
|
| 160 |
+
api = _api()
|
| 161 |
+
try:
|
| 162 |
+
api.delete_folder(
|
| 163 |
+
path_in_repo=VECTOR_PREFIX.rstrip("/"),
|
| 164 |
+
repo_id=DATASET_ID,
|
| 165 |
+
repo_type="dataset",
|
| 166 |
+
commit_message="Delete vector cache",
|
| 167 |
+
)
|
| 168 |
+
logger.info("Vector cache deleted from HF Dataset")
|
| 169 |
+
return True
|
| 170 |
+
except (EntryNotFoundError, RepositoryNotFoundError):
|
| 171 |
+
return False
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def upload_vector_store(local_dir: Path, pdf_filenames: List[str], embed_model: str) -> bool:
|
| 175 |
+
"""Persist the local LanceDB folder into the dataset along with a manifest."""
|
| 176 |
+
if not is_configured():
|
| 177 |
+
return False
|
| 178 |
+
|
| 179 |
+
api = _api()
|
| 180 |
+
_ensure_dataset_exists(api)
|
| 181 |
+
|
| 182 |
+
local_files = [p for p in local_dir.rglob("*") if p.is_file()]
|
| 183 |
+
if not pdf_filenames or not local_files:
|
| 184 |
+
delete_vector_store()
|
| 185 |
+
return bool(not pdf_filenames)
|
| 186 |
+
|
| 187 |
+
with TemporaryDirectory(prefix="iam-earth-vector-cache-") as tmp_dir:
|
| 188 |
+
stage_dir = Path(tmp_dir)
|
| 189 |
+
index_dir = stage_dir / "index"
|
| 190 |
+
shutil.copytree(local_dir, index_dir, dirs_exist_ok=True)
|
| 191 |
+
(stage_dir / "manifest.json").write_text(
|
| 192 |
+
json.dumps(_vector_manifest(pdf_filenames, embed_model), indent=2, ensure_ascii=False),
|
| 193 |
+
"utf-8",
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
api.upload_folder(
|
| 197 |
+
repo_id=DATASET_ID,
|
| 198 |
+
repo_type="dataset",
|
| 199 |
+
folder_path=stage_dir,
|
| 200 |
+
path_in_repo=VECTOR_PREFIX.rstrip("/"),
|
| 201 |
+
commit_message="Sync vector cache",
|
| 202 |
+
delete_patterns="**",
|
| 203 |
+
ignore_patterns=["**/.DS_Store"],
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
logger.info("Vector cache synced to HF Dataset")
|
| 207 |
+
return True
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def download_all_pdfs(target_dir: Path) -> List[Path]:
|
| 211 |
+
"""Download every PDF in the dataset into target_dir. Returns local paths."""
|
| 212 |
+
target_dir.mkdir(parents=True, exist_ok=True)
|
| 213 |
+
paths: List[Path] = []
|
| 214 |
+
for name in list_remote_pdfs():
|
| 215 |
+
try:
|
| 216 |
+
local = hf_hub_download(
|
| 217 |
+
repo_id=DATASET_ID,
|
| 218 |
+
repo_type="dataset",
|
| 219 |
+
filename=f"{PDF_PREFIX}{name}",
|
| 220 |
+
local_dir=str(target_dir),
|
| 221 |
+
token=HF_TOKEN,
|
| 222 |
+
)
|
| 223 |
+
paths.append(Path(local))
|
| 224 |
+
except EntryNotFoundError:
|
| 225 |
+
continue
|
| 226 |
+
return paths
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def upload_pdf(local_path: Path, filename: str) -> None:
|
| 230 |
+
"""Push a single PDF into the dataset under pdfs/<filename>."""
|
| 231 |
+
api = _api()
|
| 232 |
+
try:
|
| 233 |
+
_ensure_dataset_exists(api)
|
| 234 |
+
api.upload_file(
|
| 235 |
+
path_or_fileobj=str(local_path),
|
| 236 |
+
path_in_repo=f"{PDF_PREFIX}{filename}",
|
| 237 |
+
repo_id=DATASET_ID,
|
| 238 |
+
repo_type="dataset",
|
| 239 |
+
commit_message=f"Add {filename}",
|
| 240 |
+
)
|
| 241 |
+
except RuntimeError:
|
| 242 |
+
raise
|
| 243 |
+
except Exception as exc: # noqa: BLE001
|
| 244 |
+
raise RuntimeError(
|
| 245 |
+
f"Failed to upload {filename} to dataset {DATASET_ID!r}: {exc}"
|
| 246 |
+
) from exc
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def delete_pdf(filename: str) -> bool:
|
| 250 |
+
"""Delete pdfs/<filename> from the dataset. Returns True if removed."""
|
| 251 |
+
api = _api()
|
| 252 |
+
try:
|
| 253 |
+
api.delete_file(
|
| 254 |
+
path_in_repo=f"{PDF_PREFIX}{filename}",
|
| 255 |
+
repo_id=DATASET_ID,
|
| 256 |
+
repo_type="dataset",
|
| 257 |
+
commit_message=f"Delete {filename}",
|
| 258 |
+
)
|
| 259 |
+
return True
|
| 260 |
+
except EntryNotFoundError:
|
| 261 |
+
return False
|
backend/main.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI entrypoint — runs internally on :8000, fronted by Next.js on :7860."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
# ── Load .env FIRST so all module-level os.environ.get() calls in local
|
| 5 |
+
# modules (auth, rag, hf_sync) pick up the values when they are imported.
|
| 6 |
+
import asyncio
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
|
| 12 |
+
_env_file = Path(__file__).parent / ".env"
|
| 13 |
+
if not _env_file.exists():
|
| 14 |
+
_env_file = Path(__file__).parent.parent / ".env"
|
| 15 |
+
load_dotenv(_env_file)
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
import shutil
|
| 19 |
+
import uuid
|
| 20 |
+
from contextlib import asynccontextmanager
|
| 21 |
+
from typing import Any, AsyncIterator, List, Literal, Optional
|
| 22 |
+
|
| 23 |
+
from fastapi import Depends, FastAPI, File, Form, HTTPException, Response, UploadFile
|
| 24 |
+
from fastapi.responses import StreamingResponse
|
| 25 |
+
from huggingface_hub import AsyncInferenceClient
|
| 26 |
+
from pydantic import BaseModel, Field
|
| 27 |
+
|
| 28 |
+
# Local (imported AFTER load_dotenv so their module-level env reads are correct)
|
| 29 |
+
import auth
|
| 30 |
+
import hf_sync
|
| 31 |
+
import personas_store
|
| 32 |
+
from rag import RagEngine
|
| 33 |
+
|
| 34 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
| 35 |
+
logger = logging.getLogger("backend")
|
| 36 |
+
|
| 37 |
+
_REPO_ROOT = Path(__file__).parent.parent
|
| 38 |
+
TMP_UPLOAD_DIR = Path(os.environ.get("TMP_UPLOAD_DIR", str(_REPO_ROOT / ".tmp_uploads")))
|
| 39 |
+
LANCEDB_DIR = Path(os.environ.get("LANCEDB_PATH", str(_REPO_ROOT / ".lancedb")))
|
| 40 |
+
LLM_MODEL = os.environ.get("LLM_MODEL", "Qwen/Qwen2.5-7B-Instruct")
|
| 41 |
+
LLM_MODEL_LOWER = LLM_MODEL.lower()
|
| 42 |
+
EMBED_MODEL = os.environ.get("EMBED_MODEL", "BAAI/bge-small-en-v1.5")
|
| 43 |
+
HF_TOKEN = os.environ.get("HF_TOKEN")
|
| 44 |
+
LLM_EXTRA_BODY_JSON = os.environ.get("LLM_EXTRA_BODY_JSON", "").strip()
|
| 45 |
+
LLM_FINAL_ANSWER_EXTRA_BODY_JSON = os.environ.get("LLM_FINAL_ANSWER_EXTRA_BODY_JSON", "").strip()
|
| 46 |
+
SHOW_LLM_REASONING = os.environ.get("SHOW_LLM_REASONING", "").lower() in {"1", "true", "yes", "on"}
|
| 47 |
+
LLM_MAX_TOKENS = int(
|
| 48 |
+
os.environ.get(
|
| 49 |
+
"LLM_MAX_TOKENS",
|
| 50 |
+
"2048" if "qwen/qwen3" in LLM_MODEL_LOWER or "qwen3" in LLM_MODEL_LOWER else "1024",
|
| 51 |
+
)
|
| 52 |
+
)
|
| 53 |
+
LLM_FINAL_ANSWER_MAX_TOKENS = int(os.environ.get("LLM_FINAL_ANSWER_MAX_TOKENS", "768"))
|
| 54 |
+
|
| 55 |
+
# Globals populated in lifespan.
|
| 56 |
+
rag: Optional[RagEngine] = None
|
| 57 |
+
llm_client: Optional[AsyncInferenceClient] = None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _load_json_object_env(raw: str, env_name: str) -> Optional[dict[str, Any]]:
|
| 61 |
+
if not raw:
|
| 62 |
+
return None
|
| 63 |
+
try:
|
| 64 |
+
loaded = json.loads(raw)
|
| 65 |
+
except json.JSONDecodeError as exc:
|
| 66 |
+
logger.warning("Ignoring invalid %s: %s", env_name, exc)
|
| 67 |
+
return None
|
| 68 |
+
if not isinstance(loaded, dict):
|
| 69 |
+
logger.warning("Ignoring %s because it is not a JSON object", env_name)
|
| 70 |
+
return None
|
| 71 |
+
return loaded
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _is_qwen_thinking_model() -> bool:
|
| 75 |
+
return "qwen/qwen3" in LLM_MODEL_LOWER or "qwen3" in LLM_MODEL_LOWER
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _non_thinking_extra_body() -> dict[str, Any]:
|
| 79 |
+
return {
|
| 80 |
+
"chat_template_kwargs": {"enable_thinking": False},
|
| 81 |
+
# Some HF router/provider combinations look for this at the top level.
|
| 82 |
+
"enable_thinking": False,
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _chat_extra_body() -> Optional[dict[str, Any]]:
|
| 87 |
+
configured = _load_json_object_env(LLM_EXTRA_BODY_JSON, "LLM_EXTRA_BODY_JSON")
|
| 88 |
+
if configured is not None:
|
| 89 |
+
return configured
|
| 90 |
+
|
| 91 |
+
if _is_qwen_thinking_model():
|
| 92 |
+
return _non_thinking_extra_body()
|
| 93 |
+
|
| 94 |
+
return None
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _final_answer_extra_body() -> Optional[dict[str, Any]]:
|
| 98 |
+
configured = _load_json_object_env(
|
| 99 |
+
LLM_FINAL_ANSWER_EXTRA_BODY_JSON,
|
| 100 |
+
"LLM_FINAL_ANSWER_EXTRA_BODY_JSON",
|
| 101 |
+
)
|
| 102 |
+
if configured is not None:
|
| 103 |
+
return configured
|
| 104 |
+
|
| 105 |
+
if _is_qwen_thinking_model():
|
| 106 |
+
return _non_thinking_extra_body()
|
| 107 |
+
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
CHAT_EXTRA_BODY = _chat_extra_body()
|
| 112 |
+
FINAL_ANSWER_EXTRA_BODY = _final_answer_extra_body()
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _sync_vector_cache_sync() -> bool:
|
| 116 |
+
if not hf_sync.is_configured():
|
| 117 |
+
return False
|
| 118 |
+
try:
|
| 119 |
+
return hf_sync.upload_vector_store(LANCEDB_DIR, hf_sync.list_remote_pdfs(), EMBED_MODEL)
|
| 120 |
+
except Exception as exc: # noqa: BLE001
|
| 121 |
+
logger.warning("Vector cache sync failed: %s", exc)
|
| 122 |
+
return False
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _prepare_local_vector_cache_sync() -> tuple[bool, List[str]]:
|
| 126 |
+
"""Restore the local LanceDB folder from the dataset when a valid cache exists."""
|
| 127 |
+
if not hf_sync.is_configured():
|
| 128 |
+
return False, []
|
| 129 |
+
|
| 130 |
+
remote_pdfs = hf_sync.list_remote_pdfs()
|
| 131 |
+
cache_restored = hf_sync.download_vector_store(LANCEDB_DIR, remote_pdfs, EMBED_MODEL)
|
| 132 |
+
if not cache_restored:
|
| 133 |
+
shutil.rmtree(LANCEDB_DIR, ignore_errors=True)
|
| 134 |
+
return cache_restored, remote_pdfs
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _cold_start_index_sync() -> None:
|
| 138 |
+
if rag is None:
|
| 139 |
+
return
|
| 140 |
+
|
| 141 |
+
sync_dir = TMP_UPLOAD_DIR / "_sync"
|
| 142 |
+
sync_dir.mkdir(parents=True, exist_ok=True)
|
| 143 |
+
paths = hf_sync.download_all_pdfs(sync_dir)
|
| 144 |
+
if paths:
|
| 145 |
+
logger.info("Cold-start: indexing %d PDF(s) from dataset", len(paths))
|
| 146 |
+
added = rag.index_many((p, p.name) for p in paths)
|
| 147 |
+
logger.info("Cold-start: %d nodes indexed", added)
|
| 148 |
+
_sync_vector_cache_sync()
|
| 149 |
+
else:
|
| 150 |
+
logger.info("Cold-start: dataset has no PDFs yet")
|
| 151 |
+
_sync_vector_cache_sync()
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _upload_and_index_sync(tmp_path: Path, safe_name: str) -> tuple[int, bool]:
|
| 155 |
+
if rag is None:
|
| 156 |
+
return 0, False
|
| 157 |
+
|
| 158 |
+
hf_sync.upload_pdf(tmp_path, safe_name)
|
| 159 |
+
nodes_added = rag.index_pdf(tmp_path, safe_name)
|
| 160 |
+
return nodes_added, _sync_vector_cache_sync()
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _delete_and_sync_sync(filename: str) -> tuple[bool, int, bool]:
|
| 164 |
+
removed = hf_sync.delete_pdf(filename)
|
| 165 |
+
nodes_removed = rag.delete_by_filename(filename) if rag else 0
|
| 166 |
+
cache_synced = _sync_vector_cache_sync() if removed or nodes_removed > 0 else False
|
| 167 |
+
return removed, nodes_removed, cache_synced
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@asynccontextmanager
|
| 171 |
+
async def lifespan(app: FastAPI): # noqa: ARG001
|
| 172 |
+
global rag, llm_client
|
| 173 |
+
TMP_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
| 174 |
+
|
| 175 |
+
if not HF_TOKEN:
|
| 176 |
+
logger.warning("HF_TOKEN is not set — chat & embeddings will fail until configured.")
|
| 177 |
+
|
| 178 |
+
cache_restored = False
|
| 179 |
+
remote_pdfs: List[str] = []
|
| 180 |
+
if hf_sync.is_configured():
|
| 181 |
+
try:
|
| 182 |
+
cache_restored, remote_pdfs = await asyncio.to_thread(_prepare_local_vector_cache_sync)
|
| 183 |
+
except Exception as exc: # noqa: BLE001
|
| 184 |
+
logger.exception("Vector cache restore failed: %s", exc)
|
| 185 |
+
|
| 186 |
+
rag = RagEngine()
|
| 187 |
+
llm_client = AsyncInferenceClient(model=LLM_MODEL, token=HF_TOKEN)
|
| 188 |
+
|
| 189 |
+
# Load personas (HF Dataset → local file → bundled defaults).
|
| 190 |
+
personas_store.load()
|
| 191 |
+
|
| 192 |
+
# Cold-start sync: pull all PDFs from the linked dataset and rebuild the index.
|
| 193 |
+
try:
|
| 194 |
+
if cache_restored:
|
| 195 |
+
logger.info("Cold-start: restored vector cache from dataset for %d PDF(s)", len(remote_pdfs))
|
| 196 |
+
elif hf_sync.is_configured():
|
| 197 |
+
await asyncio.to_thread(_cold_start_index_sync)
|
| 198 |
+
except Exception as exc: # noqa: BLE001
|
| 199 |
+
logger.exception("Cold-start sync failed: %s", exc)
|
| 200 |
+
|
| 201 |
+
yield
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
app = FastAPI(title="IamEarthDev RAG", lifespan=lifespan)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# ---------------- Models ----------------
|
| 208 |
+
class ChatMessage(BaseModel):
|
| 209 |
+
role: Literal["system", "user", "assistant"]
|
| 210 |
+
content: str
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
class ChatRequest(BaseModel):
|
| 214 |
+
messages: List[ChatMessage]
|
| 215 |
+
persona_prompt: str = Field(default="", description="Persona-specific system prompt")
|
| 216 |
+
temperature: float = Field(default=0.4, ge=0.0, le=1.5)
|
| 217 |
+
top_k: int = Field(default=4, ge=1, le=10)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
class LoginRequest(BaseModel):
|
| 221 |
+
passcode: str
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
class DeleteRequest(BaseModel):
|
| 225 |
+
filename: str
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
# ---------------- Health & files ----------------
|
| 229 |
+
@app.get("/health")
|
| 230 |
+
async def health() -> dict:
|
| 231 |
+
return {
|
| 232 |
+
"ok": True,
|
| 233 |
+
"model": LLM_MODEL,
|
| 234 |
+
"chat_extra_body": CHAT_EXTRA_BODY,
|
| 235 |
+
"final_answer_extra_body": FINAL_ANSWER_EXTRA_BODY,
|
| 236 |
+
"show_reasoning": SHOW_LLM_REASONING,
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
@app.get("/files")
|
| 241 |
+
async def list_files(_: None = Depends(auth.require_admin)) -> dict:
|
| 242 |
+
return {"files": hf_sync.list_remote_pdfs()}
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
# ---------------- Admin auth ----------------
|
| 246 |
+
@app.post("/admin/login")
|
| 247 |
+
async def admin_login(body: LoginRequest, response: Response) -> dict:
|
| 248 |
+
if not auth.verify_passcode(body.passcode):
|
| 249 |
+
raise HTTPException(status_code=401, detail="Invalid passcode")
|
| 250 |
+
response.set_cookie(
|
| 251 |
+
key=auth.COOKIE_NAME,
|
| 252 |
+
value=auth.issue_token(),
|
| 253 |
+
httponly=True,
|
| 254 |
+
samesite="lax",
|
| 255 |
+
secure=False, # HF Spaces serves over https at the edge; cookie still works.
|
| 256 |
+
max_age=60 * 60 * 12,
|
| 257 |
+
path="/",
|
| 258 |
+
)
|
| 259 |
+
return {"ok": True}
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
@app.post("/admin/logout")
|
| 263 |
+
async def admin_logout(response: Response) -> dict:
|
| 264 |
+
response.delete_cookie(auth.COOKIE_NAME, path="/")
|
| 265 |
+
return {"ok": True}
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
@app.get("/admin/me")
|
| 269 |
+
async def admin_me(_: None = Depends(auth.require_admin)) -> dict:
|
| 270 |
+
return {"authenticated": True}
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# ---------------- Personas ----------------
|
| 274 |
+
class PersonaItem(BaseModel):
|
| 275 |
+
id: str
|
| 276 |
+
name: str
|
| 277 |
+
description: str
|
| 278 |
+
prompt: str
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
@app.get("/personas")
|
| 282 |
+
async def get_personas() -> dict:
|
| 283 |
+
"""Public — students and the chat UI need this."""
|
| 284 |
+
return {"personas": personas_store.get_all()}
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
@app.put("/personas")
|
| 288 |
+
async def put_personas(
|
| 289 |
+
personas: List[PersonaItem],
|
| 290 |
+
_: None = Depends(auth.require_admin),
|
| 291 |
+
) -> dict:
|
| 292 |
+
saved = personas_store.save([p.model_dump() for p in personas])
|
| 293 |
+
return {"personas": saved}
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
@app.post("/personas/reset")
|
| 297 |
+
async def reset_personas(_: None = Depends(auth.require_admin)) -> dict:
|
| 298 |
+
restored = personas_store.reset()
|
| 299 |
+
return {"personas": restored}
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
# ---------------- Upload / Delete ----------------
|
| 303 |
+
@app.post("/upload")
|
| 304 |
+
async def upload(
|
| 305 |
+
file: UploadFile = File(...),
|
| 306 |
+
_: None = Depends(auth.require_admin),
|
| 307 |
+
) -> dict:
|
| 308 |
+
if not file.filename or not file.filename.lower().endswith(".pdf"):
|
| 309 |
+
raise HTTPException(status_code=400, detail="Only .pdf files are accepted.")
|
| 310 |
+
|
| 311 |
+
safe_name = Path(file.filename).name # strip any path components
|
| 312 |
+
tmp_path = TMP_UPLOAD_DIR / f"{uuid.uuid4().hex}_{safe_name}"
|
| 313 |
+
try:
|
| 314 |
+
with tmp_path.open("wb") as out:
|
| 315 |
+
shutil.copyfileobj(file.file, out)
|
| 316 |
+
|
| 317 |
+
try:
|
| 318 |
+
nodes_added, vector_cache_synced = await asyncio.to_thread(
|
| 319 |
+
_upload_and_index_sync, tmp_path, safe_name
|
| 320 |
+
)
|
| 321 |
+
except RuntimeError as exc:
|
| 322 |
+
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
| 323 |
+
except Exception as exc: # noqa: BLE001
|
| 324 |
+
logger.exception("Indexing failed for %s", safe_name)
|
| 325 |
+
raise HTTPException(
|
| 326 |
+
status_code=500,
|
| 327 |
+
detail=f"Uploaded {safe_name} to the dataset, but indexing failed: {exc}",
|
| 328 |
+
) from exc
|
| 329 |
+
finally:
|
| 330 |
+
tmp_path.unlink(missing_ok=True)
|
| 331 |
+
|
| 332 |
+
return {
|
| 333 |
+
"ok": True,
|
| 334 |
+
"filename": safe_name,
|
| 335 |
+
"nodes_added": nodes_added,
|
| 336 |
+
"vector_cache_synced": vector_cache_synced,
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
@app.post("/delete")
|
| 341 |
+
async def delete(body: DeleteRequest, _: None = Depends(auth.require_admin)) -> dict:
|
| 342 |
+
safe_name = Path(body.filename).name
|
| 343 |
+
try:
|
| 344 |
+
removed, nodes_removed, vector_cache_synced = await asyncio.to_thread(
|
| 345 |
+
_delete_and_sync_sync, safe_name
|
| 346 |
+
)
|
| 347 |
+
except RuntimeError as exc:
|
| 348 |
+
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
| 349 |
+
if not removed and nodes_removed == 0:
|
| 350 |
+
raise HTTPException(status_code=404, detail=f"{safe_name} not found")
|
| 351 |
+
return {
|
| 352 |
+
"ok": True,
|
| 353 |
+
"filename": safe_name,
|
| 354 |
+
"nodes_removed": nodes_removed,
|
| 355 |
+
"vector_cache_synced": vector_cache_synced,
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
# ---------------- Chat (streaming) ----------------
|
| 360 |
+
SYSTEM_BASE = (
|
| 361 |
+
"You are a helpful course assistant for students. Use the selected persona "
|
| 362 |
+
"instructions to decide the tone, teaching style, level of directness, and "
|
| 363 |
+
"structure of your reply. Use the provided course material excerpts as your "
|
| 364 |
+
"primary source of truth. If the material does not contain the answer, say so "
|
| 365 |
+
"honestly. Always cite source filenames in square brackets when you use them."
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def _build_messages(req: ChatRequest, context: str) -> List[dict]:
|
| 370 |
+
persona = req.persona_prompt.strip()
|
| 371 |
+
sys_parts = [SYSTEM_BASE]
|
| 372 |
+
if persona:
|
| 373 |
+
sys_parts.append(
|
| 374 |
+
"Selected persona instructions. Follow these for the response style; "
|
| 375 |
+
"only the grounding and citation rules above take priority:\n"
|
| 376 |
+
+ persona
|
| 377 |
+
)
|
| 378 |
+
if context:
|
| 379 |
+
sys_parts.append(
|
| 380 |
+
"Course material excerpts (use these as your primary source of truth):\n\n"
|
| 381 |
+
+ context
|
| 382 |
+
)
|
| 383 |
+
msgs: List[dict] = [{"role": "system", "content": "\n\n".join(sys_parts)}]
|
| 384 |
+
for m in req.messages:
|
| 385 |
+
if m.role == "system":
|
| 386 |
+
continue # we control the system prompt server-side
|
| 387 |
+
msgs.append({"role": m.role, "content": m.content})
|
| 388 |
+
return msgs
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
@app.post("/chat")
|
| 392 |
+
async def chat(req: ChatRequest) -> StreamingResponse:
|
| 393 |
+
if rag is None or llm_client is None:
|
| 394 |
+
raise HTTPException(status_code=503, detail="Backend not ready.")
|
| 395 |
+
if not req.messages:
|
| 396 |
+
raise HTTPException(status_code=400, detail="messages must not be empty.")
|
| 397 |
+
|
| 398 |
+
last_user = next((m.content for m in reversed(req.messages) if m.role == "user"), "")
|
| 399 |
+
nodes = await asyncio.to_thread(rag.retrieve, last_user, req.top_k) if last_user else []
|
| 400 |
+
context = rag.format_context(nodes)
|
| 401 |
+
sources = sorted({n.node.metadata.get("source_filename", "unknown") for n in nodes})
|
| 402 |
+
messages = _build_messages(req, context)
|
| 403 |
+
final_answer_messages = [
|
| 404 |
+
*messages,
|
| 405 |
+
{
|
| 406 |
+
"role": "user",
|
| 407 |
+
"content": (
|
| 408 |
+
"You have already reasoned about this request. Now produce the "
|
| 409 |
+
"student-facing reply in the selected persona style, based on the "
|
| 410 |
+
"same course material excerpts. Do not repeat your thinking process. "
|
| 411 |
+
"Do not produce a thinking process, analysis section, or hidden reasoning. "
|
| 412 |
+
"Answer immediately and cite source filenames in square brackets."
|
| 413 |
+
),
|
| 414 |
+
},
|
| 415 |
+
]
|
| 416 |
+
|
| 417 |
+
async def stream() -> AsyncIterator[bytes]:
|
| 418 |
+
# Emit sources first as a single SSE-style event so the UI can render them.
|
| 419 |
+
yield (json.dumps({"type": "sources", "sources": sources}) + "\n").encode()
|
| 420 |
+
saw_content = False
|
| 421 |
+
saw_reasoning = False
|
| 422 |
+
finish_reason = None
|
| 423 |
+
try:
|
| 424 |
+
primary_stream = await llm_client.chat_completion(
|
| 425 |
+
messages=messages,
|
| 426 |
+
max_tokens=LLM_MAX_TOKENS,
|
| 427 |
+
temperature=req.temperature,
|
| 428 |
+
stream=True,
|
| 429 |
+
extra_body=CHAT_EXTRA_BODY,
|
| 430 |
+
)
|
| 431 |
+
async for chunk in primary_stream:
|
| 432 |
+
delta = ""
|
| 433 |
+
reasoning = ""
|
| 434 |
+
try:
|
| 435 |
+
finish_reason = chunk.choices[0].finish_reason or finish_reason
|
| 436 |
+
delta = chunk.choices[0].delta.content or ""
|
| 437 |
+
reasoning = getattr(chunk.choices[0].delta, "reasoning", "") or ""
|
| 438 |
+
except (AttributeError, IndexError):
|
| 439 |
+
pass
|
| 440 |
+
if delta:
|
| 441 |
+
saw_content = True
|
| 442 |
+
yield (json.dumps({"type": "delta", "text": delta}) + "\n").encode()
|
| 443 |
+
if reasoning:
|
| 444 |
+
saw_reasoning = True
|
| 445 |
+
if SHOW_LLM_REASONING:
|
| 446 |
+
yield (json.dumps({"type": "reasoning", "text": reasoning}) + "\n").encode()
|
| 447 |
+
elif not saw_content:
|
| 448 |
+
logger.info(
|
| 449 |
+
"Model emitted hidden reasoning before visible content; "
|
| 450 |
+
"switching to final-answer pass early."
|
| 451 |
+
)
|
| 452 |
+
close_stream = getattr(primary_stream, "close", None)
|
| 453 |
+
if callable(close_stream):
|
| 454 |
+
close_stream()
|
| 455 |
+
break
|
| 456 |
+
|
| 457 |
+
if not saw_content and saw_reasoning:
|
| 458 |
+
yield (
|
| 459 |
+
json.dumps(
|
| 460 |
+
{
|
| 461 |
+
"type": "phase",
|
| 462 |
+
"value": "finalizing",
|
| 463 |
+
"message": "Thinking completed. Generating the final answer.",
|
| 464 |
+
}
|
| 465 |
+
)
|
| 466 |
+
+ "\n"
|
| 467 |
+
).encode()
|
| 468 |
+
final_stream = await llm_client.chat_completion(
|
| 469 |
+
messages=final_answer_messages,
|
| 470 |
+
max_tokens=LLM_FINAL_ANSWER_MAX_TOKENS,
|
| 471 |
+
temperature=req.temperature,
|
| 472 |
+
stream=True,
|
| 473 |
+
extra_body=FINAL_ANSWER_EXTRA_BODY,
|
| 474 |
+
)
|
| 475 |
+
async for chunk in final_stream:
|
| 476 |
+
delta = ""
|
| 477 |
+
try:
|
| 478 |
+
delta = chunk.choices[0].delta.content or ""
|
| 479 |
+
except (AttributeError, IndexError):
|
| 480 |
+
pass
|
| 481 |
+
if delta:
|
| 482 |
+
saw_content = True
|
| 483 |
+
yield (json.dumps({"type": "delta", "text": delta}) + "\n").encode()
|
| 484 |
+
|
| 485 |
+
if not saw_content and saw_reasoning:
|
| 486 |
+
detail = "The model finished its reasoning but did not emit a final answer."
|
| 487 |
+
if finish_reason == "length":
|
| 488 |
+
detail += (
|
| 489 |
+
" It likely exhausted the completion budget while thinking. "
|
| 490 |
+
"Increase LLM_MAX_TOKENS if you want a longer first-pass reasoning budget."
|
| 491 |
+
)
|
| 492 |
+
yield (json.dumps({"type": "error", "message": detail}) + "\n").encode()
|
| 493 |
+
yield (json.dumps({"type": "done"}) + "\n").encode()
|
| 494 |
+
except Exception as exc: # noqa: BLE001
|
| 495 |
+
logger.exception("LLM stream failed")
|
| 496 |
+
yield (json.dumps({"type": "error", "message": str(exc)}) + "\n").encode()
|
| 497 |
+
|
| 498 |
+
return StreamingResponse(
|
| 499 |
+
stream(),
|
| 500 |
+
media_type="application/x-ndjson",
|
| 501 |
+
headers={
|
| 502 |
+
"X-Accel-Buffering": "no",
|
| 503 |
+
"Cache-Control": "no-cache",
|
| 504 |
+
"Connection": "keep-alive",
|
| 505 |
+
},
|
| 506 |
+
)
|
backend/personas.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "socratic",
|
| 4 |
+
"name": "Socratic Tutor",
|
| 5 |
+
"description": "Asks guiding questions instead of giving answers directly.",
|
| 6 |
+
"prompt": "Adopt the Socratic method. Rarely give a final answer outright; instead, ask one focused, open-ended question at a time that nudges the student toward the insight. Praise good reasoning, gently surface flawed assumptions."
|
| 7 |
+
},
|
| 8 |
+
{
|
| 9 |
+
"id": "strict_grader",
|
| 10 |
+
"name": "Strict Grader",
|
| 11 |
+
"description": "Rigorous, terse, and demanding of evidence.",
|
| 12 |
+
"prompt": "You are a strict grader. Be concise and uncompromising. Demand evidence from the course material for every claim. Point out logical gaps, missing citations, and imprecise terminology. Award no partial credit silently."
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"id": "explainer",
|
| 16 |
+
"name": "Direct Explainer",
|
| 17 |
+
"description": "Plain, structured explanations with examples.",
|
| 18 |
+
"prompt": "Explain directly and clearly. Use short paragraphs, concrete examples, and analogies when helpful. End with a one-line summary. Never withhold the answer behind a question."
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"id": "study_buddy",
|
| 22 |
+
"name": "Study Buddy",
|
| 23 |
+
"description": "Casual, encouraging, learns alongside the student.",
|
| 24 |
+
"prompt": "Speak as a friendly peer who is also studying this material. Be warm and encouraging, share confusion when it is genuine, and propose next steps the student could try together with you."
|
| 25 |
+
}
|
| 26 |
+
]
|
backend/personas_store.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""In-process persona store.
|
| 2 |
+
|
| 3 |
+
Load priority on startup:
|
| 4 |
+
1. HF Dataset (personas.json) — when HF_TOKEN + DATASET_ID are set
|
| 5 |
+
2. backend/personas.json — bundled defaults / local edits
|
| 6 |
+
|
| 7 |
+
Saves go to the local file and, when configured, to the HF Dataset.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
import os
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from threading import Lock
|
| 16 |
+
from typing import List
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
_BUNDLED = Path(__file__).parent / "personas.json"
|
| 21 |
+
_DATASET_FILENAME = "personas.json"
|
| 22 |
+
_lock = Lock()
|
| 23 |
+
_personas: List[dict] = []
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _read_bundled() -> List[dict]:
|
| 27 |
+
try:
|
| 28 |
+
return json.loads(_BUNDLED.read_text("utf-8"))
|
| 29 |
+
except Exception as exc: # noqa: BLE001
|
| 30 |
+
logger.warning("Could not read bundled personas.json: %s", exc)
|
| 31 |
+
return []
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def load() -> None:
|
| 35 |
+
"""Called once at startup. Tries HF Dataset first, falls back to local file."""
|
| 36 |
+
global _personas
|
| 37 |
+
|
| 38 |
+
# Try HF Dataset
|
| 39 |
+
hf_token = os.environ.get("HF_TOKEN")
|
| 40 |
+
dataset_id = os.environ.get("DATASET_ID")
|
| 41 |
+
if hf_token and dataset_id:
|
| 42 |
+
try:
|
| 43 |
+
from huggingface_hub import hf_hub_download
|
| 44 |
+
from huggingface_hub.utils import EntryNotFoundError
|
| 45 |
+
|
| 46 |
+
local = hf_hub_download(
|
| 47 |
+
repo_id=dataset_id,
|
| 48 |
+
repo_type="dataset",
|
| 49 |
+
filename=_DATASET_FILENAME,
|
| 50 |
+
token=hf_token,
|
| 51 |
+
)
|
| 52 |
+
data = json.loads(Path(local).read_text("utf-8"))
|
| 53 |
+
with _lock:
|
| 54 |
+
_personas = data
|
| 55 |
+
logger.info("Personas loaded from HF Dataset (%d items)", len(data))
|
| 56 |
+
return
|
| 57 |
+
except Exception as exc: # noqa: BLE001
|
| 58 |
+
logger.info("No personas.json in dataset yet (%s), using bundled.", exc)
|
| 59 |
+
|
| 60 |
+
# Fall back to bundled file
|
| 61 |
+
data = _read_bundled()
|
| 62 |
+
with _lock:
|
| 63 |
+
_personas = data
|
| 64 |
+
logger.info("Personas loaded from bundled file (%d items)", len(data))
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def get_all() -> List[dict]:
|
| 68 |
+
with _lock:
|
| 69 |
+
return list(_personas)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def save(new_personas: List[dict]) -> List[dict]:
|
| 73 |
+
"""Persist a new list of personas. Returns the saved list."""
|
| 74 |
+
with _lock:
|
| 75 |
+
global _personas
|
| 76 |
+
_personas = new_personas
|
| 77 |
+
|
| 78 |
+
# Write local file
|
| 79 |
+
try:
|
| 80 |
+
_BUNDLED.write_text(json.dumps(new_personas, indent=2, ensure_ascii=False), "utf-8")
|
| 81 |
+
logger.info("Personas saved to local file")
|
| 82 |
+
except Exception as exc: # noqa: BLE001
|
| 83 |
+
logger.warning("Could not write local personas.json: %s", exc)
|
| 84 |
+
|
| 85 |
+
# Sync to HF Dataset
|
| 86 |
+
hf_token = os.environ.get("HF_TOKEN")
|
| 87 |
+
dataset_id = os.environ.get("DATASET_ID")
|
| 88 |
+
if hf_token and dataset_id:
|
| 89 |
+
try:
|
| 90 |
+
from huggingface_hub import HfApi
|
| 91 |
+
|
| 92 |
+
api = HfApi(token=hf_token)
|
| 93 |
+
api.upload_file(
|
| 94 |
+
path_or_fileobj=json.dumps(new_personas, indent=2, ensure_ascii=False).encode(),
|
| 95 |
+
path_in_repo=_DATASET_FILENAME,
|
| 96 |
+
repo_id=dataset_id,
|
| 97 |
+
repo_type="dataset",
|
| 98 |
+
commit_message="Update personas",
|
| 99 |
+
)
|
| 100 |
+
logger.info("Personas synced to HF Dataset")
|
| 101 |
+
except Exception as exc: # noqa: BLE001
|
| 102 |
+
logger.warning("Could not sync personas to HF Dataset: %s", exc)
|
| 103 |
+
|
| 104 |
+
return new_personas
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def reset() -> List[dict]:
|
| 108 |
+
"""Reset to the bundled defaults and persist."""
|
| 109 |
+
defaults = _read_bundled()
|
| 110 |
+
return save(defaults)
|
backend/pyproject.toml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "iam-earth-dev-backend"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "FastAPI + LlamaIndex RAG backend for the IamEarthDev cloneable HF Space template"
|
| 5 |
+
requires-python = ">=3.13,<3.14"
|
| 6 |
+
dependencies = [
|
| 7 |
+
"fastapi>=0.115.0",
|
| 8 |
+
"uvicorn[standard]>=0.32.0",
|
| 9 |
+
"python-multipart>=0.0.12",
|
| 10 |
+
"pydantic>=2.9.0",
|
| 11 |
+
"python-dotenv>=1.0.1",
|
| 12 |
+
# RAG
|
| 13 |
+
"llama-index-core>=0.12.0",
|
| 14 |
+
"llama-index-vector-stores-lancedb>=0.3.0",
|
| 15 |
+
"llama-index-embeddings-huggingface-api>=0.3.0",
|
| 16 |
+
"lancedb>=0.16.0",
|
| 17 |
+
"pyarrow>=17.0.0",
|
| 18 |
+
"pandas>=2.2.0",
|
| 19 |
+
"pymupdf4llm>=0.0.17",
|
| 20 |
+
# HF
|
| 21 |
+
"huggingface-hub>=0.26.0",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
[tool.uv]
|
| 25 |
+
package = false
|
backend/rag.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RAG engine: PyMuPDF4LLM -> chunks -> HF-API embeddings -> LanceDB.
|
| 2 |
+
|
| 3 |
+
Designed for HF Spaces Free CPU Basic:
|
| 4 |
+
* No local embedding/LLM weights are downloaded.
|
| 5 |
+
* LanceDB lives on ephemeral disk locally, but its files are mirrored into
|
| 6 |
+
the linked HF Dataset so cold starts can restore the vector cache instead
|
| 7 |
+
of rebuilding from PDFs every time.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
import os
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from threading import Lock
|
| 15 |
+
from typing import Iterable, List, Optional
|
| 16 |
+
|
| 17 |
+
import pymupdf4llm
|
| 18 |
+
from llama_index.core import Document, StorageContext, VectorStoreIndex, Settings
|
| 19 |
+
from llama_index.core.node_parser import MarkdownNodeParser
|
| 20 |
+
from llama_index.core.schema import MetadataMode, NodeWithScore, TextNode
|
| 21 |
+
from llama_index.vector_stores.lancedb import LanceDBVectorStore
|
| 22 |
+
|
| 23 |
+
from hf_embedding import SyncHuggingFaceInferenceEmbedding
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
_REPO_ROOT = Path(__file__).parent.parent
|
| 28 |
+
LANCEDB_PATH = os.environ.get("LANCEDB_PATH", str(_REPO_ROOT / ".lancedb"))
|
| 29 |
+
TABLE_NAME = "documents"
|
| 30 |
+
EMBED_MODEL = os.environ.get("EMBED_MODEL", "BAAI/bge-small-en-v1.5")
|
| 31 |
+
HF_TOKEN = os.environ.get("HF_TOKEN")
|
| 32 |
+
|
| 33 |
+
# Filename is stored on every node so we can delete by source.
|
| 34 |
+
FILENAME_KEY = "source_filename"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class RagEngine:
|
| 38 |
+
"""Thread-safe singleton wrapper around the LanceDB-backed index."""
|
| 39 |
+
|
| 40 |
+
def __init__(self) -> None:
|
| 41 |
+
self._lock = Lock()
|
| 42 |
+
self._index: Optional[VectorStoreIndex] = None
|
| 43 |
+
self._vector_store: Optional[LanceDBVectorStore] = None
|
| 44 |
+
|
| 45 |
+
if not HF_TOKEN:
|
| 46 |
+
logger.warning(
|
| 47 |
+
"HF_TOKEN is not set — embedding calls will fail. "
|
| 48 |
+
"Set it in .env before uploading or querying documents."
|
| 49 |
+
)
|
| 50 |
+
Settings.embed_model = None # type: ignore[assignment]
|
| 51 |
+
else:
|
| 52 |
+
Settings.embed_model = SyncHuggingFaceInferenceEmbedding(
|
| 53 |
+
model_name=EMBED_MODEL,
|
| 54 |
+
token=HF_TOKEN,
|
| 55 |
+
)
|
| 56 |
+
Settings.llm = None # We call the LLM ourselves in main.py for streaming.
|
| 57 |
+
Settings.node_parser = MarkdownNodeParser()
|
| 58 |
+
|
| 59 |
+
Path(LANCEDB_PATH).mkdir(parents=True, exist_ok=True)
|
| 60 |
+
self._vector_store = LanceDBVectorStore(
|
| 61 |
+
uri=LANCEDB_PATH,
|
| 62 |
+
table_name=TABLE_NAME,
|
| 63 |
+
mode="overwrite" if not self._table_exists() else "append",
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# ---------- internals ----------
|
| 67 |
+
def _table_exists(self) -> bool:
|
| 68 |
+
import lancedb
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
db = lancedb.connect(LANCEDB_PATH)
|
| 72 |
+
return TABLE_NAME in db.table_names()
|
| 73 |
+
except Exception: # noqa: BLE001
|
| 74 |
+
return False
|
| 75 |
+
|
| 76 |
+
def _ensure_index(self) -> VectorStoreIndex:
|
| 77 |
+
if self._index is None:
|
| 78 |
+
storage = StorageContext.from_defaults(vector_store=self._vector_store)
|
| 79 |
+
if self._table_exists():
|
| 80 |
+
self._index = VectorStoreIndex.from_vector_store(
|
| 81 |
+
vector_store=self._vector_store,
|
| 82 |
+
storage_context=storage,
|
| 83 |
+
)
|
| 84 |
+
else:
|
| 85 |
+
self._index = VectorStoreIndex.from_documents(
|
| 86 |
+
[], storage_context=storage
|
| 87 |
+
)
|
| 88 |
+
return self._index
|
| 89 |
+
|
| 90 |
+
@staticmethod
|
| 91 |
+
def _pdf_to_documents(pdf_path: Path, filename: str) -> List[Document]:
|
| 92 |
+
try:
|
| 93 |
+
md_text = pymupdf4llm.to_markdown(str(pdf_path))
|
| 94 |
+
except Exception as exc: # noqa: BLE001
|
| 95 |
+
logger.warning("Failed to parse %s: %s", filename, exc)
|
| 96 |
+
return []
|
| 97 |
+
if not md_text.strip():
|
| 98 |
+
return []
|
| 99 |
+
return [
|
| 100 |
+
Document(
|
| 101 |
+
text=md_text,
|
| 102 |
+
metadata={FILENAME_KEY: filename},
|
| 103 |
+
excluded_llm_metadata_keys=[FILENAME_KEY],
|
| 104 |
+
excluded_embed_metadata_keys=[FILENAME_KEY],
|
| 105 |
+
)
|
| 106 |
+
]
|
| 107 |
+
|
| 108 |
+
# ---------- public API ----------
|
| 109 |
+
def index_pdf(self, pdf_path: Path, filename: str) -> int:
|
| 110 |
+
"""Parse a single PDF and insert its chunks. Returns # nodes added."""
|
| 111 |
+
with self._lock:
|
| 112 |
+
# First, drop any existing nodes for this filename (re-uploads).
|
| 113 |
+
self.delete_by_filename(filename, _locked=True)
|
| 114 |
+
docs = self._pdf_to_documents(pdf_path, filename)
|
| 115 |
+
if not docs:
|
| 116 |
+
return 0
|
| 117 |
+
index = self._ensure_index()
|
| 118 |
+
nodes = Settings.node_parser.get_nodes_from_documents(docs)
|
| 119 |
+
for n in nodes:
|
| 120 |
+
n.metadata[FILENAME_KEY] = filename
|
| 121 |
+
index.insert_nodes(nodes)
|
| 122 |
+
return len(nodes)
|
| 123 |
+
|
| 124 |
+
def index_many(self, items: Iterable[tuple[Path, str]]) -> int:
|
| 125 |
+
total = 0
|
| 126 |
+
for path, name in items:
|
| 127 |
+
total += self.index_pdf(path, name)
|
| 128 |
+
return total
|
| 129 |
+
|
| 130 |
+
def delete_by_filename(self, filename: str, *, _locked: bool = False) -> int:
|
| 131 |
+
"""Remove all nodes with metadata.source_filename == filename."""
|
| 132 |
+
def _do() -> int:
|
| 133 |
+
import lancedb
|
| 134 |
+
|
| 135 |
+
if not self._table_exists():
|
| 136 |
+
return 0
|
| 137 |
+
db = lancedb.connect(LANCEDB_PATH)
|
| 138 |
+
tbl = db.open_table(TABLE_NAME)
|
| 139 |
+
# LanceDB stores metadata as a struct column called "metadata".
|
| 140 |
+
# Filter accesses nested fields with dot syntax.
|
| 141 |
+
try:
|
| 142 |
+
before = tbl.count_rows()
|
| 143 |
+
tbl.delete(f"metadata.{FILENAME_KEY} = '{filename}'")
|
| 144 |
+
removed = before - tbl.count_rows()
|
| 145 |
+
except Exception as exc: # noqa: BLE001
|
| 146 |
+
logger.warning("delete_by_filename failed: %s", exc)
|
| 147 |
+
removed = 0
|
| 148 |
+
# Force the index to be rebuilt next query.
|
| 149 |
+
self._index = None
|
| 150 |
+
return removed
|
| 151 |
+
|
| 152 |
+
if _locked:
|
| 153 |
+
return _do()
|
| 154 |
+
with self._lock:
|
| 155 |
+
return _do()
|
| 156 |
+
|
| 157 |
+
def retrieve(self, query: str, top_k: int = 4) -> List[NodeWithScore]:
|
| 158 |
+
with self._lock:
|
| 159 |
+
if not self._table_exists():
|
| 160 |
+
return []
|
| 161 |
+
index = self._ensure_index()
|
| 162 |
+
retriever = index.as_retriever(similarity_top_k=top_k)
|
| 163 |
+
return retriever.retrieve(query)
|
| 164 |
+
|
| 165 |
+
@staticmethod
|
| 166 |
+
def format_context(nodes: List[NodeWithScore]) -> str:
|
| 167 |
+
if not nodes:
|
| 168 |
+
return ""
|
| 169 |
+
chunks = []
|
| 170 |
+
for i, n in enumerate(nodes, 1):
|
| 171 |
+
src = n.node.metadata.get(FILENAME_KEY, "unknown")
|
| 172 |
+
text = n.node.get_content(metadata_mode=MetadataMode.NONE).strip()
|
| 173 |
+
chunks.append(f"[{i}] (source: {src})\n{text}")
|
| 174 |
+
return "\n\n---\n\n".join(chunks)
|
backend/uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/.eslintrc.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
module.exports = {
|
| 3 |
+
extends: ["next/core-web-vitals"],
|
| 4 |
+
};
|
frontend/app/admin/login/page.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useState } from "react";
|
| 3 |
+
import { useRouter } from "next/navigation";
|
| 4 |
+
import { Lock, Loader2 } from "lucide-react";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { Input } from "@/components/ui/input";
|
| 7 |
+
import { Label } from "@/components/ui/label";
|
| 8 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 9 |
+
|
| 10 |
+
export default function LoginPage() {
|
| 11 |
+
const router = useRouter();
|
| 12 |
+
const [passcode, setPasscode] = useState("");
|
| 13 |
+
const [busy, setBusy] = useState(false);
|
| 14 |
+
const [error, setError] = useState<string | null>(null);
|
| 15 |
+
|
| 16 |
+
async function submit(e: React.FormEvent) {
|
| 17 |
+
e.preventDefault();
|
| 18 |
+
setBusy(true);
|
| 19 |
+
setError(null);
|
| 20 |
+
try {
|
| 21 |
+
const r = await fetch("/api/admin/login", {
|
| 22 |
+
method: "POST",
|
| 23 |
+
headers: { "Content-Type": "application/json" },
|
| 24 |
+
body: JSON.stringify({ passcode }),
|
| 25 |
+
});
|
| 26 |
+
if (!r.ok) {
|
| 27 |
+
setError(r.status === 401 ? "Incorrect passcode." : await r.text());
|
| 28 |
+
return;
|
| 29 |
+
}
|
| 30 |
+
router.replace("/admin");
|
| 31 |
+
} catch (e) {
|
| 32 |
+
setError((e as Error).message);
|
| 33 |
+
} finally {
|
| 34 |
+
setBusy(false);
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<main className="flex h-full items-center justify-center p-6">
|
| 40 |
+
<Card className="w-full max-w-sm">
|
| 41 |
+
<CardHeader className="items-center text-center">
|
| 42 |
+
<div className="mx-auto mb-2 rounded-full bg-muted p-3">
|
| 43 |
+
<Lock className="h-5 w-5" />
|
| 44 |
+
</div>
|
| 45 |
+
<CardTitle>Educator login</CardTitle>
|
| 46 |
+
</CardHeader>
|
| 47 |
+
<CardContent>
|
| 48 |
+
<form onSubmit={submit} className="space-y-4">
|
| 49 |
+
<div className="space-y-2">
|
| 50 |
+
<Label htmlFor="passcode">Passcode</Label>
|
| 51 |
+
<Input
|
| 52 |
+
id="passcode"
|
| 53 |
+
type="password"
|
| 54 |
+
value={passcode}
|
| 55 |
+
onChange={(e) => setPasscode(e.target.value)}
|
| 56 |
+
autoFocus
|
| 57 |
+
/>
|
| 58 |
+
</div>
|
| 59 |
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
| 60 |
+
<Button type="submit" className="w-full" disabled={busy || !passcode}>
|
| 61 |
+
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : "Sign in"}
|
| 62 |
+
</Button>
|
| 63 |
+
</form>
|
| 64 |
+
</CardContent>
|
| 65 |
+
</Card>
|
| 66 |
+
</main>
|
| 67 |
+
);
|
| 68 |
+
}
|
frontend/app/admin/page.tsx
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useEffect, useState } from "react";
|
| 3 |
+
import { useRouter } from "next/navigation";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import { Loader2, LogOut } from "lucide-react";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 8 |
+
import { FileUploader } from "@/components/file-uploader";
|
| 9 |
+
import { FileList } from "@/components/file-list";
|
| 10 |
+
import { PersonaEditor } from "@/components/persona-editor";
|
| 11 |
+
|
| 12 |
+
export default function AdminPage() {
|
| 13 |
+
const router = useRouter();
|
| 14 |
+
const [checking, setChecking] = useState(true);
|
| 15 |
+
const [refreshKey, setRefreshKey] = useState(0);
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
(async () => {
|
| 19 |
+
const r = await fetch("/api/admin/me");
|
| 20 |
+
if (!r.ok) router.replace("/admin/login");
|
| 21 |
+
else setChecking(false);
|
| 22 |
+
})();
|
| 23 |
+
}, [router]);
|
| 24 |
+
|
| 25 |
+
async function logout() {
|
| 26 |
+
await fetch("/api/admin/logout", { method: "POST" });
|
| 27 |
+
router.replace("/admin/login");
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
if (checking) {
|
| 31 |
+
return (
|
| 32 |
+
<div className="flex h-full items-center justify-center">
|
| 33 |
+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
| 34 |
+
</div>
|
| 35 |
+
);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<main className="mx-auto max-w-3xl space-y-6 p-4 sm:p-6">
|
| 40 |
+
<header className="flex items-center justify-between">
|
| 41 |
+
<div>
|
| 42 |
+
<h1 className="text-xl font-semibold tracking-tight">Educator Dashboard</h1>
|
| 43 |
+
<p className="text-sm text-muted-foreground">
|
| 44 |
+
Upload PDFs to your linked Hugging Face Dataset.
|
| 45 |
+
</p>
|
| 46 |
+
</div>
|
| 47 |
+
<div className="flex items-center gap-2">
|
| 48 |
+
<Link
|
| 49 |
+
href="/"
|
| 50 |
+
className="text-sm text-muted-foreground underline-offset-4 hover:underline"
|
| 51 |
+
>
|
| 52 |
+
← Chat
|
| 53 |
+
</Link>
|
| 54 |
+
<Button variant="ghost" size="sm" onClick={logout}>
|
| 55 |
+
<LogOut className="mr-1 h-4 w-4" /> Logout
|
| 56 |
+
</Button>
|
| 57 |
+
</div>
|
| 58 |
+
</header>
|
| 59 |
+
|
| 60 |
+
<Card>
|
| 61 |
+
<CardHeader>
|
| 62 |
+
<CardTitle>Upload PDFs</CardTitle>
|
| 63 |
+
</CardHeader>
|
| 64 |
+
<CardContent>
|
| 65 |
+
<FileUploader onUploaded={() => setRefreshKey((k) => k + 1)} />
|
| 66 |
+
</CardContent>
|
| 67 |
+
</Card>
|
| 68 |
+
|
| 69 |
+
<Card>
|
| 70 |
+
<CardContent className="pt-6">
|
| 71 |
+
<FileList refreshKey={refreshKey} />
|
| 72 |
+
</CardContent>
|
| 73 |
+
</Card>
|
| 74 |
+
|
| 75 |
+
<Card>
|
| 76 |
+
<CardContent className="pt-6">
|
| 77 |
+
<PersonaEditor />
|
| 78 |
+
</CardContent>
|
| 79 |
+
</Card>
|
| 80 |
+
</main>
|
| 81 |
+
);
|
| 82 |
+
}
|
frontend/app/api/chat/route.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type NextRequest, NextResponse } from "next/server";
|
| 2 |
+
|
| 3 |
+
export const dynamic = "force-dynamic";
|
| 4 |
+
export const runtime = "nodejs";
|
| 5 |
+
|
| 6 |
+
export async function POST(request: NextRequest) {
|
| 7 |
+
const backend =
|
| 8 |
+
process.env.BACKEND_INTERNAL_URL ?? "http://127.0.0.1:8000";
|
| 9 |
+
|
| 10 |
+
let response: Response;
|
| 11 |
+
try {
|
| 12 |
+
response = await fetch(`${backend}/chat`, {
|
| 13 |
+
method: "POST",
|
| 14 |
+
body: request.body,
|
| 15 |
+
duplex: "half",
|
| 16 |
+
headers: {
|
| 17 |
+
"content-type": request.headers.get("content-type") ?? "application/json",
|
| 18 |
+
},
|
| 19 |
+
} as RequestInit & { duplex: "half" });
|
| 20 |
+
} catch {
|
| 21 |
+
return NextResponse.json(
|
| 22 |
+
{ detail: "Could not reach the backend chat service." },
|
| 23 |
+
{ status: 502 }
|
| 24 |
+
);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const headers = new Headers();
|
| 28 |
+
headers.set(
|
| 29 |
+
"content-type",
|
| 30 |
+
response.headers.get("content-type") ?? "application/x-ndjson"
|
| 31 |
+
);
|
| 32 |
+
headers.set("cache-control", "no-cache, no-transform");
|
| 33 |
+
|
| 34 |
+
return new Response(response.body, {
|
| 35 |
+
status: response.status,
|
| 36 |
+
statusText: response.statusText,
|
| 37 |
+
headers,
|
| 38 |
+
});
|
| 39 |
+
}
|
frontend/app/api/upload/route.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type NextRequest, NextResponse } from "next/server";
|
| 2 |
+
|
| 3 |
+
// This route handler replaces the rewrite proxy for /api/upload so that
|
| 4 |
+
// large PDF files (>10 MB) are forwarded to FastAPI without Next.js buffering
|
| 5 |
+
// the entire body before the 10 MB rewrite middleware limit kicks in.
|
| 6 |
+
|
| 7 |
+
export const dynamic = "force-dynamic";
|
| 8 |
+
|
| 9 |
+
export async function POST(request: NextRequest) {
|
| 10 |
+
const backend =
|
| 11 |
+
process.env.BACKEND_INTERNAL_URL ?? "http://127.0.0.1:8000";
|
| 12 |
+
|
| 13 |
+
const formData = await request.formData();
|
| 14 |
+
const cookie = request.headers.get("cookie") ?? "";
|
| 15 |
+
|
| 16 |
+
let response: Response;
|
| 17 |
+
try {
|
| 18 |
+
response = await fetch(`${backend}/upload`, {
|
| 19 |
+
method: "POST",
|
| 20 |
+
body: formData,
|
| 21 |
+
headers: { cookie },
|
| 22 |
+
});
|
| 23 |
+
} catch {
|
| 24 |
+
return NextResponse.json(
|
| 25 |
+
{ detail: "Could not reach the backend upload service." },
|
| 26 |
+
{ status: 502 }
|
| 27 |
+
);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const contentType = response.headers.get("content-type") ?? "text/plain; charset=utf-8";
|
| 31 |
+
const body = await response.text();
|
| 32 |
+
|
| 33 |
+
return new NextResponse(body, {
|
| 34 |
+
status: response.status,
|
| 35 |
+
headers: { "content-type": contentType },
|
| 36 |
+
});
|
| 37 |
+
}
|
frontend/app/globals.css
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
@layer base {
|
| 6 |
+
:root {
|
| 7 |
+
--background: 0 0% 100%;
|
| 8 |
+
--foreground: 222 47% 11%;
|
| 9 |
+
--card: 0 0% 100%;
|
| 10 |
+
--card-foreground: 222 47% 11%;
|
| 11 |
+
--primary: 222 47% 11%;
|
| 12 |
+
--primary-foreground: 210 40% 98%;
|
| 13 |
+
--muted: 210 40% 96%;
|
| 14 |
+
--muted-foreground: 215 16% 47%;
|
| 15 |
+
--accent: 210 40% 96%;
|
| 16 |
+
--accent-foreground: 222 47% 11%;
|
| 17 |
+
--destructive: 0 84% 60%;
|
| 18 |
+
--destructive-foreground: 210 40% 98%;
|
| 19 |
+
--border: 214 32% 91%;
|
| 20 |
+
--input: 214 32% 91%;
|
| 21 |
+
--ring: 222 84% 4%;
|
| 22 |
+
--radius: 0.6rem;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.dark {
|
| 26 |
+
--background: 222 47% 6%;
|
| 27 |
+
--foreground: 210 40% 98%;
|
| 28 |
+
--card: 222 47% 8%;
|
| 29 |
+
--card-foreground: 210 40% 98%;
|
| 30 |
+
--primary: 210 40% 98%;
|
| 31 |
+
--primary-foreground: 222 47% 11%;
|
| 32 |
+
--muted: 217 33% 17%;
|
| 33 |
+
--muted-foreground: 215 20% 65%;
|
| 34 |
+
--accent: 217 33% 17%;
|
| 35 |
+
--accent-foreground: 210 40% 98%;
|
| 36 |
+
--destructive: 0 63% 31%;
|
| 37 |
+
--destructive-foreground: 210 40% 98%;
|
| 38 |
+
--border: 217 33% 17%;
|
| 39 |
+
--input: 217 33% 17%;
|
| 40 |
+
--ring: 212 27% 84%;
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
@layer base {
|
| 45 |
+
* { @apply border-border; }
|
| 46 |
+
body { @apply bg-background text-foreground; }
|
| 47 |
+
}
|
frontend/app/layout.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "./globals.css";
|
| 2 |
+
import type { Metadata } from "next";
|
| 3 |
+
|
| 4 |
+
export const metadata: Metadata = {
|
| 5 |
+
title: "IamEarthDev — Course Assistant",
|
| 6 |
+
description: "RAG chatbot for course materials, hosted on Hugging Face Spaces.",
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
| 10 |
+
return (
|
| 11 |
+
<html lang="en" className="h-full">
|
| 12 |
+
<body className="h-full antialiased">{children}</body>
|
| 13 |
+
</html>
|
| 14 |
+
);
|
| 15 |
+
}
|
frontend/app/page.tsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Link from "next/link";
|
| 2 |
+
import { ChatInterface } from "@/components/chat-interface";
|
| 3 |
+
|
| 4 |
+
export default function Page() {
|
| 5 |
+
return (
|
| 6 |
+
<main className="mx-auto flex h-full max-w-6xl flex-col gap-4 p-4 sm:p-6">
|
| 7 |
+
<header className="flex items-center justify-between">
|
| 8 |
+
<div>
|
| 9 |
+
<h1 className="text-xl font-semibold tracking-tight">Course Assistant</h1>
|
| 10 |
+
<p className="text-sm text-muted-foreground">
|
| 11 |
+
Ask questions grounded in the uploaded course materials.
|
| 12 |
+
</p>
|
| 13 |
+
</div>
|
| 14 |
+
<Link
|
| 15 |
+
href="/admin"
|
| 16 |
+
className="text-sm text-muted-foreground underline-offset-4 hover:underline"
|
| 17 |
+
>
|
| 18 |
+
Educator dashboard →
|
| 19 |
+
</Link>
|
| 20 |
+
</header>
|
| 21 |
+
<div className="min-h-0 flex-1">
|
| 22 |
+
<ChatInterface />
|
| 23 |
+
</div>
|
| 24 |
+
</main>
|
| 25 |
+
);
|
| 26 |
+
}
|
frontend/components/chat-interface.tsx
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useState, useRef, useEffect } from "react";
|
| 3 |
+
import { Send, Loader2, BookOpen } from "lucide-react";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
import { Input } from "@/components/ui/input";
|
| 6 |
+
import { Card, CardContent } from "@/components/ui/card";
|
| 7 |
+
import { PersonaSelector } from "@/components/persona-selector";
|
| 8 |
+
import { TemperatureSlider } from "@/components/temperature-slider";
|
| 9 |
+
import { DEFAULT_PERSONAS, DEFAULT_PERSONA_ID, fetchPersonas, type Persona } from "@/lib/personas";
|
| 10 |
+
|
| 11 |
+
type Msg = {
|
| 12 |
+
role: "user" | "assistant";
|
| 13 |
+
content: string;
|
| 14 |
+
reasoning?: string;
|
| 15 |
+
sources?: string[];
|
| 16 |
+
status?: string;
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
function latestThinkingPreview(reasoning: string) {
|
| 20 |
+
const text = reasoning.trim();
|
| 21 |
+
if (!text) return "";
|
| 22 |
+
return text.length > 420 ? `...${text.slice(-420)}` : text;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export function ChatInterface() {
|
| 26 |
+
const [personas, setPersonas] = useState<Persona[]>(DEFAULT_PERSONAS);
|
| 27 |
+
const [persona, setPersona] = useState<Persona>(
|
| 28 |
+
DEFAULT_PERSONAS.find((p) => p.id === DEFAULT_PERSONA_ID) ?? DEFAULT_PERSONAS[0]
|
| 29 |
+
);
|
| 30 |
+
const [temperature, setTemperature] = useState(0.4);
|
| 31 |
+
const [messages, setMessages] = useState<Msg[]>([]);
|
| 32 |
+
const [input, setInput] = useState("");
|
| 33 |
+
const [busy, setBusy] = useState(false);
|
| 34 |
+
const scrollRef = useRef<HTMLDivElement>(null);
|
| 35 |
+
|
| 36 |
+
// Load live personas on mount
|
| 37 |
+
useEffect(() => {
|
| 38 |
+
fetchPersonas().then((list) => {
|
| 39 |
+
setPersonas(list);
|
| 40 |
+
// Re-select same id if it still exists, otherwise pick default
|
| 41 |
+
setPersona((prev) => list.find((p) => p.id === prev.id) ?? list[0] ?? prev);
|
| 42 |
+
});
|
| 43 |
+
}, []);
|
| 44 |
+
|
| 45 |
+
useEffect(() => {
|
| 46 |
+
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
| 47 |
+
}, [messages, busy]);
|
| 48 |
+
|
| 49 |
+
async function send() {
|
| 50 |
+
const text = input.trim();
|
| 51 |
+
if (!text || busy) return;
|
| 52 |
+
setInput("");
|
| 53 |
+
const next: Msg[] = [
|
| 54 |
+
...messages,
|
| 55 |
+
{ role: "user", content: text },
|
| 56 |
+
{ role: "assistant", content: "" },
|
| 57 |
+
];
|
| 58 |
+
setMessages(next);
|
| 59 |
+
setBusy(true);
|
| 60 |
+
|
| 61 |
+
try {
|
| 62 |
+
const resp = await fetch("/api/chat", {
|
| 63 |
+
method: "POST",
|
| 64 |
+
headers: { "Content-Type": "application/json" },
|
| 65 |
+
body: JSON.stringify({
|
| 66 |
+
messages: next
|
| 67 |
+
.slice(0, -1)
|
| 68 |
+
.map(({ role, content }) => ({ role, content })),
|
| 69 |
+
persona_prompt: persona.prompt,
|
| 70 |
+
temperature,
|
| 71 |
+
}),
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
if (!resp.ok) {
|
| 75 |
+
const message = (await resp.text()).trim() || `HTTP ${resp.status}`;
|
| 76 |
+
throw new Error(message);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
if (!resp.body) throw new Error("No response stream");
|
| 80 |
+
const reader = resp.body.getReader();
|
| 81 |
+
const decoder = new TextDecoder();
|
| 82 |
+
let buf = "";
|
| 83 |
+
let acc = "";
|
| 84 |
+
let reasoning = "";
|
| 85 |
+
let sources: string[] | undefined;
|
| 86 |
+
let status: string | undefined;
|
| 87 |
+
|
| 88 |
+
while (true) {
|
| 89 |
+
const { done, value } = await reader.read();
|
| 90 |
+
if (done) break;
|
| 91 |
+
buf += decoder.decode(value, { stream: true });
|
| 92 |
+
const lines = buf.split("\n");
|
| 93 |
+
buf = lines.pop() ?? "";
|
| 94 |
+
for (const line of lines) {
|
| 95 |
+
if (!line.trim()) continue;
|
| 96 |
+
try {
|
| 97 |
+
const evt = JSON.parse(line);
|
| 98 |
+
if (evt.type === "delta") {
|
| 99 |
+
acc += evt.text;
|
| 100 |
+
status = undefined;
|
| 101 |
+
} else if (evt.type === "reasoning") {
|
| 102 |
+
reasoning += evt.text;
|
| 103 |
+
} else if (evt.type === "phase") {
|
| 104 |
+
status = evt.message || evt.value;
|
| 105 |
+
} else if (evt.type === "sources") {
|
| 106 |
+
sources = evt.sources;
|
| 107 |
+
} else if (evt.type === "error") {
|
| 108 |
+
acc += `\n\n*[error: ${evt.message}]*`;
|
| 109 |
+
}
|
| 110 |
+
setMessages((prev) => {
|
| 111 |
+
const copy = [...prev];
|
| 112 |
+
copy[copy.length - 1] = {
|
| 113 |
+
role: "assistant",
|
| 114 |
+
content: acc,
|
| 115 |
+
reasoning,
|
| 116 |
+
sources,
|
| 117 |
+
status,
|
| 118 |
+
};
|
| 119 |
+
return copy;
|
| 120 |
+
});
|
| 121 |
+
} catch {
|
| 122 |
+
// partial JSON, ignore
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
} catch (e) {
|
| 127 |
+
setMessages((prev) => {
|
| 128 |
+
const copy = [...prev];
|
| 129 |
+
copy[copy.length - 1] = {
|
| 130 |
+
role: "assistant",
|
| 131 |
+
content: `*Failed to reach the model: ${(e as Error).message}*`,
|
| 132 |
+
};
|
| 133 |
+
return copy;
|
| 134 |
+
});
|
| 135 |
+
} finally {
|
| 136 |
+
setBusy(false);
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
return (
|
| 141 |
+
<div className="grid h-full grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">
|
| 142 |
+
<Card className="flex flex-col overflow-hidden">
|
| 143 |
+
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-6">
|
| 144 |
+
{messages.length === 0 && (
|
| 145 |
+
<div className="flex h-full flex-col items-center justify-center text-center text-muted-foreground">
|
| 146 |
+
<BookOpen className="mb-3 h-10 w-10 opacity-50" />
|
| 147 |
+
<p className="text-sm">
|
| 148 |
+
Ask a question about your course material to get started.
|
| 149 |
+
</p>
|
| 150 |
+
</div>
|
| 151 |
+
)}
|
| 152 |
+
{messages.map((m, i) => {
|
| 153 |
+
const isStreamingAssistant =
|
| 154 |
+
busy && i === messages.length - 1 && m.role === "assistant";
|
| 155 |
+
const thinkingPreview =
|
| 156 |
+
isStreamingAssistant && !m.content && m.reasoning
|
| 157 |
+
? latestThinkingPreview(m.reasoning)
|
| 158 |
+
: "";
|
| 159 |
+
|
| 160 |
+
return (
|
| 161 |
+
<div
|
| 162 |
+
key={i}
|
| 163 |
+
className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}
|
| 164 |
+
>
|
| 165 |
+
<div
|
| 166 |
+
className={`max-w-[85%] rounded-lg px-4 py-2.5 text-sm leading-relaxed ${
|
| 167 |
+
m.role === "user"
|
| 168 |
+
? "bg-primary text-primary-foreground"
|
| 169 |
+
: "bg-muted text-foreground"
|
| 170 |
+
}`}
|
| 171 |
+
>
|
| 172 |
+
<div className="whitespace-pre-wrap">
|
| 173 |
+
{m.content ||
|
| 174 |
+
thinkingPreview ||
|
| 175 |
+
(isStreamingAssistant ? "Thinking…" : "")}
|
| 176 |
+
</div>
|
| 177 |
+
{thinkingPreview && (
|
| 178 |
+
<div className="mt-2 text-xs italic opacity-70">
|
| 179 |
+
Thinking stream
|
| 180 |
+
</div>
|
| 181 |
+
)}
|
| 182 |
+
{m.reasoning && (
|
| 183 |
+
<details className="mt-3 rounded-md border border-border/60 bg-background/60 p-3 text-xs text-muted-foreground">
|
| 184 |
+
<summary className="cursor-pointer select-none font-medium text-foreground">
|
| 185 |
+
Thinking
|
| 186 |
+
</summary>
|
| 187 |
+
<div className="mt-2 whitespace-pre-wrap leading-relaxed">
|
| 188 |
+
{m.reasoning}
|
| 189 |
+
</div>
|
| 190 |
+
</details>
|
| 191 |
+
)}
|
| 192 |
+
{m.status && (
|
| 193 |
+
<div className="mt-2 text-xs italic opacity-80">{m.status}</div>
|
| 194 |
+
)}
|
| 195 |
+
{m.sources && m.sources.length > 0 && (
|
| 196 |
+
<div className="mt-2 border-t border-border/50 pt-2 text-xs opacity-80">
|
| 197 |
+
<span className="font-medium">Sources: </span>
|
| 198 |
+
{m.sources.join(", ")}
|
| 199 |
+
</div>
|
| 200 |
+
)}
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
);
|
| 204 |
+
})}
|
| 205 |
+
</div>
|
| 206 |
+
<div className="border-t p-4">
|
| 207 |
+
<form
|
| 208 |
+
className="flex gap-2"
|
| 209 |
+
onSubmit={(e) => {
|
| 210 |
+
e.preventDefault();
|
| 211 |
+
send();
|
| 212 |
+
}}
|
| 213 |
+
>
|
| 214 |
+
<Input
|
| 215 |
+
placeholder="Ask about the course material…"
|
| 216 |
+
value={input}
|
| 217 |
+
onChange={(e) => setInput(e.target.value)}
|
| 218 |
+
disabled={busy}
|
| 219 |
+
/>
|
| 220 |
+
<Button type="submit" disabled={busy || !input.trim()} size="icon">
|
| 221 |
+
{busy ? (
|
| 222 |
+
<Loader2 className="h-4 w-4 animate-spin" />
|
| 223 |
+
) : (
|
| 224 |
+
<Send className="h-4 w-4" />
|
| 225 |
+
)}
|
| 226 |
+
</Button>
|
| 227 |
+
</form>
|
| 228 |
+
</div>
|
| 229 |
+
</Card>
|
| 230 |
+
|
| 231 |
+
<Card>
|
| 232 |
+
<CardContent className="space-y-6 p-6">
|
| 233 |
+
<div className="space-y-2">
|
| 234 |
+
<div className="text-sm font-medium">Persona</div>
|
| 235 |
+
<PersonaSelector personas={personas} value={persona.id} onChange={setPersona} />
|
| 236 |
+
<p className="text-xs text-muted-foreground">{persona.description}</p>
|
| 237 |
+
</div>
|
| 238 |
+
<TemperatureSlider value={temperature} onChange={setTemperature} />
|
| 239 |
+
</CardContent>
|
| 240 |
+
</Card>
|
| 241 |
+
</div>
|
| 242 |
+
);
|
| 243 |
+
}
|
frontend/components/file-list.tsx
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useEffect, useState, useCallback } from "react";
|
| 3 |
+
import { Trash2, FileText, RefreshCw } from "lucide-react";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
|
| 6 |
+
export function FileList({ refreshKey }: { refreshKey: number }) {
|
| 7 |
+
const [files, setFiles] = useState<string[] | null>(null);
|
| 8 |
+
const [error, setError] = useState<string | null>(null);
|
| 9 |
+
const [deleting, setDeleting] = useState<string | null>(null);
|
| 10 |
+
|
| 11 |
+
const load = useCallback(async () => {
|
| 12 |
+
setError(null);
|
| 13 |
+
try {
|
| 14 |
+
const r = await fetch("/api/files");
|
| 15 |
+
if (!r.ok) throw new Error(await r.text());
|
| 16 |
+
const data = await r.json();
|
| 17 |
+
setFiles(data.files ?? []);
|
| 18 |
+
} catch (e) {
|
| 19 |
+
setError((e as Error).message);
|
| 20 |
+
}
|
| 21 |
+
}, []);
|
| 22 |
+
|
| 23 |
+
useEffect(() => {
|
| 24 |
+
load();
|
| 25 |
+
}, [load, refreshKey]);
|
| 26 |
+
|
| 27 |
+
async function remove(name: string) {
|
| 28 |
+
if (!confirm(`Delete ${name}? This removes it from the dataset and the index.`))
|
| 29 |
+
return;
|
| 30 |
+
setDeleting(name);
|
| 31 |
+
try {
|
| 32 |
+
const r = await fetch("/api/delete", {
|
| 33 |
+
method: "POST",
|
| 34 |
+
headers: { "Content-Type": "application/json" },
|
| 35 |
+
body: JSON.stringify({ filename: name }),
|
| 36 |
+
});
|
| 37 |
+
if (!r.ok) throw new Error(await r.text());
|
| 38 |
+
await load();
|
| 39 |
+
} catch (e) {
|
| 40 |
+
setError((e as Error).message);
|
| 41 |
+
} finally {
|
| 42 |
+
setDeleting(null);
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<div className="space-y-3">
|
| 48 |
+
<div className="flex items-center justify-between">
|
| 49 |
+
<h2 className="text-sm font-semibold">Course materials</h2>
|
| 50 |
+
<Button variant="ghost" size="sm" onClick={load}>
|
| 51 |
+
<RefreshCw className="mr-1 h-3.5 w-3.5" /> Refresh
|
| 52 |
+
</Button>
|
| 53 |
+
</div>
|
| 54 |
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
| 55 |
+
{files === null ? (
|
| 56 |
+
<p className="text-sm text-muted-foreground">Loading…</p>
|
| 57 |
+
) : files.length === 0 ? (
|
| 58 |
+
<p className="text-sm text-muted-foreground">No files uploaded yet.</p>
|
| 59 |
+
) : (
|
| 60 |
+
<ul className="divide-y rounded-lg border">
|
| 61 |
+
{files.map((name) => (
|
| 62 |
+
<li
|
| 63 |
+
key={name}
|
| 64 |
+
className="flex items-center justify-between gap-3 px-4 py-2.5"
|
| 65 |
+
>
|
| 66 |
+
<div className="flex min-w-0 items-center gap-2">
|
| 67 |
+
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
| 68 |
+
<span className="truncate text-sm">{name}</span>
|
| 69 |
+
</div>
|
| 70 |
+
<Button
|
| 71 |
+
variant="ghost"
|
| 72 |
+
size="sm"
|
| 73 |
+
onClick={() => remove(name)}
|
| 74 |
+
disabled={deleting === name}
|
| 75 |
+
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
| 76 |
+
>
|
| 77 |
+
<Trash2 className="h-4 w-4" />
|
| 78 |
+
</Button>
|
| 79 |
+
</li>
|
| 80 |
+
))}
|
| 81 |
+
</ul>
|
| 82 |
+
)}
|
| 83 |
+
</div>
|
| 84 |
+
);
|
| 85 |
+
}
|
frontend/components/file-uploader.tsx
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useCallback, useState } from "react";
|
| 3 |
+
import { useDropzone } from "react-dropzone";
|
| 4 |
+
import { UploadCloud, Loader2 } from "lucide-react";
|
| 5 |
+
import { cn } from "@/lib/utils";
|
| 6 |
+
|
| 7 |
+
export function FileUploader({ onUploaded }: { onUploaded: () => void }) {
|
| 8 |
+
const [busy, setBusy] = useState(false);
|
| 9 |
+
const [error, setError] = useState<string | null>(null);
|
| 10 |
+
const [progress, setProgress] = useState<string | null>(null);
|
| 11 |
+
|
| 12 |
+
const upload = useCallback(
|
| 13 |
+
async (files: File[]) => {
|
| 14 |
+
setError(null);
|
| 15 |
+
setBusy(true);
|
| 16 |
+
try {
|
| 17 |
+
for (let i = 0; i < files.length; i++) {
|
| 18 |
+
const f = files[i];
|
| 19 |
+
setProgress(`Uploading ${i + 1}/${files.length}: ${f.name}`);
|
| 20 |
+
const fd = new FormData();
|
| 21 |
+
fd.append("file", f);
|
| 22 |
+
const r = await fetch("/api/upload", { method: "POST", body: fd });
|
| 23 |
+
if (!r.ok) {
|
| 24 |
+
const contentType = r.headers.get("content-type") ?? "";
|
| 25 |
+
let message = "Upload failed";
|
| 26 |
+
|
| 27 |
+
if (contentType.includes("application/json")) {
|
| 28 |
+
const payload = (await r.json().catch(() => null)) as
|
| 29 |
+
| { detail?: string; error?: string }
|
| 30 |
+
| null;
|
| 31 |
+
message = payload?.detail ?? payload?.error ?? message;
|
| 32 |
+
} else {
|
| 33 |
+
message = (await r.text()).trim() || message;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
throw new Error(`${f.name}: ${message}`);
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
onUploaded();
|
| 40 |
+
} catch (e) {
|
| 41 |
+
setError((e as Error).message);
|
| 42 |
+
} finally {
|
| 43 |
+
setBusy(false);
|
| 44 |
+
setProgress(null);
|
| 45 |
+
}
|
| 46 |
+
},
|
| 47 |
+
[onUploaded]
|
| 48 |
+
);
|
| 49 |
+
|
| 50 |
+
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
| 51 |
+
accept: { "application/pdf": [".pdf"] },
|
| 52 |
+
multiple: true,
|
| 53 |
+
disabled: busy,
|
| 54 |
+
onDrop: upload,
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
return (
|
| 58 |
+
<div className="space-y-2">
|
| 59 |
+
<div
|
| 60 |
+
{...getRootProps()}
|
| 61 |
+
className={cn(
|
| 62 |
+
"flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-10 text-center transition-colors",
|
| 63 |
+
isDragActive ? "border-primary bg-accent" : "border-border hover:bg-accent/50",
|
| 64 |
+
busy && "pointer-events-none opacity-60"
|
| 65 |
+
)}
|
| 66 |
+
>
|
| 67 |
+
<input {...getInputProps()} />
|
| 68 |
+
{busy ? (
|
| 69 |
+
<Loader2 className="mb-2 h-8 w-8 animate-spin text-muted-foreground" />
|
| 70 |
+
) : (
|
| 71 |
+
<UploadCloud className="mb-2 h-8 w-8 text-muted-foreground" />
|
| 72 |
+
)}
|
| 73 |
+
<p className="text-sm font-medium">
|
| 74 |
+
{busy
|
| 75 |
+
? progress ?? "Uploading…"
|
| 76 |
+
: isDragActive
|
| 77 |
+
? "Drop the PDFs here"
|
| 78 |
+
: "Drag & drop PDFs here, or click to select"}
|
| 79 |
+
</p>
|
| 80 |
+
<p className="mt-1 text-xs text-muted-foreground">
|
| 81 |
+
Files are pushed to your linked Hugging Face Dataset and indexed automatically.
|
| 82 |
+
</p>
|
| 83 |
+
</div>
|
| 84 |
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
| 85 |
+
</div>
|
| 86 |
+
);
|
| 87 |
+
}
|
frontend/components/persona-editor.tsx
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useEffect, useState, useRef } from "react";
|
| 3 |
+
import { Plus, Trash2, RotateCcw, Save, ChevronDown, ChevronUp, Pencil } from "lucide-react";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
import { Input } from "@/components/ui/input";
|
| 6 |
+
import { Label } from "@/components/ui/label";
|
| 7 |
+
import {
|
| 8 |
+
Dialog,
|
| 9 |
+
DialogContent,
|
| 10 |
+
DialogDescription,
|
| 11 |
+
DialogFooter,
|
| 12 |
+
DialogHeader,
|
| 13 |
+
DialogTitle,
|
| 14 |
+
DialogTrigger,
|
| 15 |
+
} from "@/components/ui/dialog";
|
| 16 |
+
import { type Persona } from "@/lib/personas";
|
| 17 |
+
|
| 18 |
+
function uid() {
|
| 19 |
+
return Math.random().toString(36).slice(2, 10);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
// ── Confirm dialog ────────────────────────────────────────────────────────────
|
| 23 |
+
function ConfirmDialog({
|
| 24 |
+
trigger,
|
| 25 |
+
title,
|
| 26 |
+
description,
|
| 27 |
+
confirmLabel = "Confirm",
|
| 28 |
+
destructive = false,
|
| 29 |
+
onConfirm,
|
| 30 |
+
}: {
|
| 31 |
+
trigger: React.ReactNode;
|
| 32 |
+
title: string;
|
| 33 |
+
description: string;
|
| 34 |
+
confirmLabel?: string;
|
| 35 |
+
destructive?: boolean;
|
| 36 |
+
onConfirm: () => void;
|
| 37 |
+
}) {
|
| 38 |
+
const [open, setOpen] = useState(false);
|
| 39 |
+
return (
|
| 40 |
+
<Dialog open={open} onOpenChange={setOpen}>
|
| 41 |
+
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
| 42 |
+
<DialogContent className="max-w-sm">
|
| 43 |
+
<DialogHeader>
|
| 44 |
+
<DialogTitle>{title}</DialogTitle>
|
| 45 |
+
<DialogDescription>{description}</DialogDescription>
|
| 46 |
+
</DialogHeader>
|
| 47 |
+
<DialogFooter className="gap-2">
|
| 48 |
+
<Button variant="outline" size="sm" onClick={() => setOpen(false)}>
|
| 49 |
+
Cancel
|
| 50 |
+
</Button>
|
| 51 |
+
<Button
|
| 52 |
+
variant={destructive ? "destructive" : "default"}
|
| 53 |
+
size="sm"
|
| 54 |
+
onClick={() => {
|
| 55 |
+
setOpen(false);
|
| 56 |
+
onConfirm();
|
| 57 |
+
}}
|
| 58 |
+
>
|
| 59 |
+
{confirmLabel}
|
| 60 |
+
</Button>
|
| 61 |
+
</DialogFooter>
|
| 62 |
+
</DialogContent>
|
| 63 |
+
</Dialog>
|
| 64 |
+
);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// ── Per-persona card ──────────────────────────────────────────────────────────
|
| 68 |
+
type SaveState = "idle" | "editing" | "saving" | "saved" | "error";
|
| 69 |
+
|
| 70 |
+
function PersonaCard({
|
| 71 |
+
persona,
|
| 72 |
+
onSave,
|
| 73 |
+
onDelete,
|
| 74 |
+
}: {
|
| 75 |
+
persona: Persona;
|
| 76 |
+
onSave: (p: Persona) => Promise<void>;
|
| 77 |
+
onDelete: () => void;
|
| 78 |
+
}) {
|
| 79 |
+
const [open, setOpen] = useState(false);
|
| 80 |
+
const [draft, setDraft] = useState<Persona>(persona);
|
| 81 |
+
const [state, setState] = useState<SaveState>("idle");
|
| 82 |
+
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
| 83 |
+
|
| 84 |
+
// Sync when parent updates (e.g. after reset)
|
| 85 |
+
const prevId = useRef(persona.id);
|
| 86 |
+
useEffect(() => {
|
| 87 |
+
if (persona.id !== prevId.current || state === "idle") {
|
| 88 |
+
setDraft(persona);
|
| 89 |
+
prevId.current = persona.id;
|
| 90 |
+
}
|
| 91 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 92 |
+
}, [persona]);
|
| 93 |
+
|
| 94 |
+
const isDirty = JSON.stringify(draft) !== JSON.stringify(persona);
|
| 95 |
+
|
| 96 |
+
function change(field: keyof Persona, value: string) {
|
| 97 |
+
setDraft((d) => ({ ...d, [field]: value }));
|
| 98 |
+
setState("editing");
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
async function save() {
|
| 102 |
+
setState("saving");
|
| 103 |
+
setErrorMsg(null);
|
| 104 |
+
try {
|
| 105 |
+
await onSave(draft);
|
| 106 |
+
setState("saved");
|
| 107 |
+
setTimeout(() => setState("idle"), 2500);
|
| 108 |
+
} catch (e) {
|
| 109 |
+
setErrorMsg((e as Error).message);
|
| 110 |
+
setState("error");
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
return (
|
| 115 |
+
<div className="rounded-lg border bg-card">
|
| 116 |
+
{/* Header row */}
|
| 117 |
+
<div className="flex items-center gap-2 px-4 py-3">
|
| 118 |
+
<button
|
| 119 |
+
type="button"
|
| 120 |
+
className="flex flex-1 items-center justify-between text-left text-sm font-medium"
|
| 121 |
+
onClick={() => setOpen((o) => !o)}
|
| 122 |
+
>
|
| 123 |
+
<span className={draft.name ? "" : "italic text-muted-foreground"}>
|
| 124 |
+
{draft.name || "Unnamed"}
|
| 125 |
+
</span>
|
| 126 |
+
{open ? (
|
| 127 |
+
<ChevronUp className="h-4 w-4 shrink-0" />
|
| 128 |
+
) : (
|
| 129 |
+
<ChevronDown className="h-4 w-4 shrink-0" />
|
| 130 |
+
)}
|
| 131 |
+
</button>
|
| 132 |
+
|
| 133 |
+
<ConfirmDialog
|
| 134 |
+
trigger={
|
| 135 |
+
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive">
|
| 136 |
+
<Trash2 className="h-3.5 w-3.5" />
|
| 137 |
+
</Button>
|
| 138 |
+
}
|
| 139 |
+
title="Delete persona"
|
| 140 |
+
description={`Remove "${draft.name || "this persona"}" permanently? Students will no longer see it.`}
|
| 141 |
+
confirmLabel="Delete"
|
| 142 |
+
destructive
|
| 143 |
+
onConfirm={onDelete}
|
| 144 |
+
/>
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
+
{/* Expandable fields */}
|
| 148 |
+
{open && (
|
| 149 |
+
<div className="space-y-3 border-t px-4 py-4">
|
| 150 |
+
<div className="space-y-1">
|
| 151 |
+
<Label>Name</Label>
|
| 152 |
+
<Input
|
| 153 |
+
value={draft.name}
|
| 154 |
+
onChange={(e) => change("name", e.target.value)}
|
| 155 |
+
placeholder="e.g. Socratic Tutor"
|
| 156 |
+
/>
|
| 157 |
+
</div>
|
| 158 |
+
<div className="space-y-1">
|
| 159 |
+
<Label>Short description</Label>
|
| 160 |
+
<Input
|
| 161 |
+
value={draft.description}
|
| 162 |
+
onChange={(e) => change("description", e.target.value)}
|
| 163 |
+
placeholder="One sentence shown in the dropdown"
|
| 164 |
+
/>
|
| 165 |
+
</div>
|
| 166 |
+
<div className="space-y-1">
|
| 167 |
+
<Label>System prompt</Label>
|
| 168 |
+
<textarea
|
| 169 |
+
className="min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
| 170 |
+
value={draft.prompt}
|
| 171 |
+
onChange={(e) => change("prompt", e.target.value)}
|
| 172 |
+
placeholder="Instructions appended to the system message…"
|
| 173 |
+
/>
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
{errorMsg && <p className="text-xs text-destructive">{errorMsg}</p>}
|
| 177 |
+
|
| 178 |
+
<Button
|
| 179 |
+
size="sm"
|
| 180 |
+
className="w-full"
|
| 181 |
+
onClick={save}
|
| 182 |
+
disabled={!isDirty || state === "saving"}
|
| 183 |
+
>
|
| 184 |
+
<Save className="mr-1 h-3.5 w-3.5" />
|
| 185 |
+
{state === "saving"
|
| 186 |
+
? "Saving…"
|
| 187 |
+
: state === "saved"
|
| 188 |
+
? "Saved!"
|
| 189 |
+
: isDirty
|
| 190 |
+
? "Save changes"
|
| 191 |
+
: "No changes"}
|
| 192 |
+
</Button>
|
| 193 |
+
</div>
|
| 194 |
+
)}
|
| 195 |
+
</div>
|
| 196 |
+
);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
// ── Main editor ───────────────────────────────────────────────────────────────
|
| 200 |
+
export function PersonaEditor() {
|
| 201 |
+
const [personas, setPersonas] = useState<Persona[]>([]);
|
| 202 |
+
|
| 203 |
+
async function load() {
|
| 204 |
+
try {
|
| 205 |
+
const r = await fetch("/api/personas");
|
| 206 |
+
const d = await r.json();
|
| 207 |
+
setPersonas(d.personas ?? []);
|
| 208 |
+
} catch {}
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
useEffect(() => { load(); }, []);
|
| 212 |
+
|
| 213 |
+
// Save a single updated persona
|
| 214 |
+
async function saveOne(updated: Persona) {
|
| 215 |
+
const next = personas.map((p) => (p.id === updated.id ? updated : p));
|
| 216 |
+
const r = await fetch("/api/personas", {
|
| 217 |
+
method: "PUT",
|
| 218 |
+
headers: { "Content-Type": "application/json" },
|
| 219 |
+
body: JSON.stringify(next),
|
| 220 |
+
});
|
| 221 |
+
if (!r.ok) throw new Error(await r.text());
|
| 222 |
+
const data = await r.json();
|
| 223 |
+
setPersonas(data.personas);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
function remove(id: string) {
|
| 227 |
+
const next = personas.filter((p) => p.id !== id);
|
| 228 |
+
fetch("/api/personas", {
|
| 229 |
+
method: "PUT",
|
| 230 |
+
headers: { "Content-Type": "application/json" },
|
| 231 |
+
body: JSON.stringify(next),
|
| 232 |
+
})
|
| 233 |
+
.then((r) => r.json())
|
| 234 |
+
.then((d) => setPersonas(d.personas ?? next))
|
| 235 |
+
.catch(() => setPersonas(next));
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
function addNew() {
|
| 239 |
+
setPersonas((prev) => [
|
| 240 |
+
...prev,
|
| 241 |
+
{ id: uid(), name: "", description: "", prompt: "" },
|
| 242 |
+
]);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
async function reset() {
|
| 246 |
+
const r = await fetch("/api/personas/reset", { method: "POST" });
|
| 247 |
+
if (!r.ok) throw new Error(await r.text());
|
| 248 |
+
const data = await r.json();
|
| 249 |
+
setPersonas(data.personas);
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
return (
|
| 253 |
+
<div className="space-y-3">
|
| 254 |
+
<div className="flex items-center justify-between">
|
| 255 |
+
<h2 className="text-sm font-semibold">Personas</h2>
|
| 256 |
+
<ConfirmDialog
|
| 257 |
+
trigger={
|
| 258 |
+
<Button variant="ghost" size="sm" type="button">
|
| 259 |
+
<RotateCcw className="mr-1 h-3.5 w-3.5" /> Reset to defaults
|
| 260 |
+
</Button>
|
| 261 |
+
}
|
| 262 |
+
title="Reset personas?"
|
| 263 |
+
description="All custom personas will be replaced with the bundled defaults. This cannot be undone."
|
| 264 |
+
confirmLabel="Reset"
|
| 265 |
+
destructive
|
| 266 |
+
onConfirm={reset}
|
| 267 |
+
/>
|
| 268 |
+
</div>
|
| 269 |
+
<p className="text-xs text-muted-foreground">
|
| 270 |
+
Each card saves individually. Changes are written to{" "}
|
| 271 |
+
<code className="rounded bg-muted px-1">personas.json</code> and synced
|
| 272 |
+
to your HF Dataset.
|
| 273 |
+
</p>
|
| 274 |
+
|
| 275 |
+
<div className="space-y-2">
|
| 276 |
+
{personas.map((p) => (
|
| 277 |
+
<PersonaCard
|
| 278 |
+
key={p.id}
|
| 279 |
+
persona={p}
|
| 280 |
+
onSave={saveOne}
|
| 281 |
+
onDelete={() => remove(p.id)}
|
| 282 |
+
/>
|
| 283 |
+
))}
|
| 284 |
+
</div>
|
| 285 |
+
|
| 286 |
+
<Button
|
| 287 |
+
variant="outline"
|
| 288 |
+
size="sm"
|
| 289 |
+
type="button"
|
| 290 |
+
className="w-full"
|
| 291 |
+
onClick={addNew}
|
| 292 |
+
>
|
| 293 |
+
<Plus className="mr-1 h-4 w-4" /> Add persona
|
| 294 |
+
</Button>
|
| 295 |
+
</div>
|
| 296 |
+
);
|
| 297 |
+
}
|
frontend/components/persona-selector.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import {
|
| 3 |
+
Select,
|
| 4 |
+
SelectContent,
|
| 5 |
+
SelectItem,
|
| 6 |
+
SelectTrigger,
|
| 7 |
+
SelectValue,
|
| 8 |
+
} from "@/components/ui/select";
|
| 9 |
+
import { type Persona } from "@/lib/personas";
|
| 10 |
+
|
| 11 |
+
export function PersonaSelector({
|
| 12 |
+
personas,
|
| 13 |
+
value,
|
| 14 |
+
onChange,
|
| 15 |
+
}: {
|
| 16 |
+
personas: Persona[];
|
| 17 |
+
value: string;
|
| 18 |
+
onChange: (p: Persona) => void;
|
| 19 |
+
}) {
|
| 20 |
+
return (
|
| 21 |
+
<Select
|
| 22 |
+
value={value}
|
| 23 |
+
onValueChange={(id) => {
|
| 24 |
+
const p = personas.find((x) => x.id === id);
|
| 25 |
+
if (p) onChange(p);
|
| 26 |
+
}}
|
| 27 |
+
>
|
| 28 |
+
<SelectTrigger className="w-full">
|
| 29 |
+
<SelectValue placeholder="Choose a persona" />
|
| 30 |
+
</SelectTrigger>
|
| 31 |
+
<SelectContent>
|
| 32 |
+
{personas.map((p) => (
|
| 33 |
+
<SelectItem key={p.id} value={p.id}>
|
| 34 |
+
<div className="flex flex-col">
|
| 35 |
+
<span className="font-medium">{p.name}</span>
|
| 36 |
+
<span className="text-xs text-muted-foreground">{p.description}</span>
|
| 37 |
+
</div>
|
| 38 |
+
</SelectItem>
|
| 39 |
+
))}
|
| 40 |
+
</SelectContent>
|
| 41 |
+
</Select>
|
| 42 |
+
);
|
| 43 |
+
}
|
frontend/components/temperature-slider.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { Slider } from "@/components/ui/slider";
|
| 3 |
+
import { Label } from "@/components/ui/label";
|
| 4 |
+
|
| 5 |
+
export function TemperatureSlider({
|
| 6 |
+
value,
|
| 7 |
+
onChange,
|
| 8 |
+
}: {
|
| 9 |
+
value: number;
|
| 10 |
+
onChange: (n: number) => void;
|
| 11 |
+
}) {
|
| 12 |
+
return (
|
| 13 |
+
<div className="space-y-2">
|
| 14 |
+
<div className="flex items-center justify-between">
|
| 15 |
+
<Label>Creativity</Label>
|
| 16 |
+
<span className="text-sm tabular-nums text-muted-foreground">
|
| 17 |
+
{value.toFixed(2)}
|
| 18 |
+
</span>
|
| 19 |
+
</div>
|
| 20 |
+
<Slider
|
| 21 |
+
min={0}
|
| 22 |
+
max={1.2}
|
| 23 |
+
step={0.05}
|
| 24 |
+
value={[value]}
|
| 25 |
+
onValueChange={(v) => onChange(v[0] ?? 0)}
|
| 26 |
+
/>
|
| 27 |
+
<p className="text-xs text-muted-foreground">
|
| 28 |
+
Lower = more focused on the source material. Higher = more creative.
|
| 29 |
+
</p>
|
| 30 |
+
</div>
|
| 31 |
+
);
|
| 32 |
+
}
|
frontend/components/ui/button.tsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot";
|
| 3 |
+
import { cva, type VariantProps } from "class-variance-authority";
|
| 4 |
+
import { cn } from "@/lib/utils";
|
| 5 |
+
|
| 6 |
+
const buttonVariants = cva(
|
| 7 |
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
| 8 |
+
{
|
| 9 |
+
variants: {
|
| 10 |
+
variant: {
|
| 11 |
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
| 12 |
+
destructive:
|
| 13 |
+
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
| 14 |
+
outline:
|
| 15 |
+
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
| 16 |
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
| 17 |
+
link: "text-primary underline-offset-4 hover:underline",
|
| 18 |
+
},
|
| 19 |
+
size: {
|
| 20 |
+
default: "h-10 px-4 py-2",
|
| 21 |
+
sm: "h-9 rounded-md px-3",
|
| 22 |
+
lg: "h-11 rounded-md px-6",
|
| 23 |
+
icon: "h-10 w-10",
|
| 24 |
+
},
|
| 25 |
+
},
|
| 26 |
+
defaultVariants: { variant: "default", size: "default" },
|
| 27 |
+
}
|
| 28 |
+
);
|
| 29 |
+
|
| 30 |
+
export interface ButtonProps
|
| 31 |
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
| 32 |
+
VariantProps<typeof buttonVariants> {
|
| 33 |
+
asChild?: boolean;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
| 37 |
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
| 38 |
+
const Comp = asChild ? Slot : "button";
|
| 39 |
+
return (
|
| 40 |
+
<Comp
|
| 41 |
+
className={cn(buttonVariants({ variant, size, className }))}
|
| 42 |
+
ref={ref}
|
| 43 |
+
{...props}
|
| 44 |
+
/>
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
);
|
| 48 |
+
Button.displayName = "Button";
|
frontend/components/ui/card.tsx
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cn } from "@/lib/utils";
|
| 3 |
+
|
| 4 |
+
export const Card = React.forwardRef<
|
| 5 |
+
HTMLDivElement,
|
| 6 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 7 |
+
>(({ className, ...props }, ref) => (
|
| 8 |
+
<div
|
| 9 |
+
ref={ref}
|
| 10 |
+
className={cn(
|
| 11 |
+
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
| 12 |
+
className
|
| 13 |
+
)}
|
| 14 |
+
{...props}
|
| 15 |
+
/>
|
| 16 |
+
));
|
| 17 |
+
Card.displayName = "Card";
|
| 18 |
+
|
| 19 |
+
export const CardHeader = React.forwardRef<
|
| 20 |
+
HTMLDivElement,
|
| 21 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 22 |
+
>(({ className, ...props }, ref) => (
|
| 23 |
+
<div ref={ref} className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />
|
| 24 |
+
));
|
| 25 |
+
CardHeader.displayName = "CardHeader";
|
| 26 |
+
|
| 27 |
+
export const CardTitle = React.forwardRef<
|
| 28 |
+
HTMLHeadingElement,
|
| 29 |
+
React.HTMLAttributes<HTMLHeadingElement>
|
| 30 |
+
>(({ className, ...props }, ref) => (
|
| 31 |
+
<h3
|
| 32 |
+
ref={ref}
|
| 33 |
+
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
| 34 |
+
{...props}
|
| 35 |
+
/>
|
| 36 |
+
));
|
| 37 |
+
CardTitle.displayName = "CardTitle";
|
| 38 |
+
|
| 39 |
+
export const CardDescription = React.forwardRef<
|
| 40 |
+
HTMLParagraphElement,
|
| 41 |
+
React.HTMLAttributes<HTMLParagraphElement>
|
| 42 |
+
>(({ className, ...props }, ref) => (
|
| 43 |
+
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
| 44 |
+
));
|
| 45 |
+
CardDescription.displayName = "CardDescription";
|
| 46 |
+
|
| 47 |
+
export const CardContent = React.forwardRef<
|
| 48 |
+
HTMLDivElement,
|
| 49 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 50 |
+
>(({ className, ...props }, ref) => (
|
| 51 |
+
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
| 52 |
+
));
|
| 53 |
+
CardContent.displayName = "CardContent";
|
| 54 |
+
|
| 55 |
+
export const CardFooter = React.forwardRef<
|
| 56 |
+
HTMLDivElement,
|
| 57 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 58 |
+
>(({ className, ...props }, ref) => (
|
| 59 |
+
<div
|
| 60 |
+
ref={ref}
|
| 61 |
+
className={cn("flex items-center p-6 pt-0", className)}
|
| 62 |
+
{...props}
|
| 63 |
+
/>
|
| 64 |
+
));
|
| 65 |
+
CardFooter.displayName = "CardFooter";
|
frontend/components/ui/dialog.tsx
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
| 5 |
+
import { X } from "lucide-react";
|
| 6 |
+
import { cn } from "@/lib/utils";
|
| 7 |
+
|
| 8 |
+
const Dialog = DialogPrimitive.Root;
|
| 9 |
+
const DialogTrigger = DialogPrimitive.Trigger;
|
| 10 |
+
const DialogPortal = DialogPrimitive.Portal;
|
| 11 |
+
const DialogClose = DialogPrimitive.Close;
|
| 12 |
+
|
| 13 |
+
const DialogOverlay = React.forwardRef<
|
| 14 |
+
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
| 15 |
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
| 16 |
+
>(({ className, ...props }, ref) => (
|
| 17 |
+
<DialogPrimitive.Overlay
|
| 18 |
+
ref={ref}
|
| 19 |
+
className={cn(
|
| 20 |
+
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
| 21 |
+
className
|
| 22 |
+
)}
|
| 23 |
+
{...props}
|
| 24 |
+
/>
|
| 25 |
+
));
|
| 26 |
+
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
| 27 |
+
|
| 28 |
+
const DialogContent = React.forwardRef<
|
| 29 |
+
React.ElementRef<typeof DialogPrimitive.Content>,
|
| 30 |
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
| 31 |
+
>(({ className, children, ...props }, ref) => (
|
| 32 |
+
<DialogPortal>
|
| 33 |
+
<DialogOverlay />
|
| 34 |
+
<DialogPrimitive.Content
|
| 35 |
+
ref={ref}
|
| 36 |
+
className={cn(
|
| 37 |
+
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
| 38 |
+
className
|
| 39 |
+
)}
|
| 40 |
+
{...props}
|
| 41 |
+
>
|
| 42 |
+
{children}
|
| 43 |
+
<DialogClose className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
| 44 |
+
<X className="h-4 w-4" />
|
| 45 |
+
<span className="sr-only">Close</span>
|
| 46 |
+
</DialogClose>
|
| 47 |
+
</DialogPrimitive.Content>
|
| 48 |
+
</DialogPortal>
|
| 49 |
+
));
|
| 50 |
+
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
| 51 |
+
|
| 52 |
+
const DialogHeader = ({
|
| 53 |
+
className,
|
| 54 |
+
...props
|
| 55 |
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
| 56 |
+
<div
|
| 57 |
+
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
| 58 |
+
{...props}
|
| 59 |
+
/>
|
| 60 |
+
);
|
| 61 |
+
DialogHeader.displayName = "DialogHeader";
|
| 62 |
+
|
| 63 |
+
const DialogFooter = ({
|
| 64 |
+
className,
|
| 65 |
+
...props
|
| 66 |
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
| 67 |
+
<div
|
| 68 |
+
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
| 69 |
+
{...props}
|
| 70 |
+
/>
|
| 71 |
+
);
|
| 72 |
+
DialogFooter.displayName = "DialogFooter";
|
| 73 |
+
|
| 74 |
+
const DialogTitle = React.forwardRef<
|
| 75 |
+
React.ElementRef<typeof DialogPrimitive.Title>,
|
| 76 |
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
| 77 |
+
>(({ className, ...props }, ref) => (
|
| 78 |
+
<DialogPrimitive.Title
|
| 79 |
+
ref={ref}
|
| 80 |
+
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
| 81 |
+
{...props}
|
| 82 |
+
/>
|
| 83 |
+
));
|
| 84 |
+
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
| 85 |
+
|
| 86 |
+
const DialogDescription = React.forwardRef<
|
| 87 |
+
React.ElementRef<typeof DialogPrimitive.Description>,
|
| 88 |
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
| 89 |
+
>(({ className, ...props }, ref) => (
|
| 90 |
+
<DialogPrimitive.Description
|
| 91 |
+
ref={ref}
|
| 92 |
+
className={cn("text-sm text-muted-foreground", className)}
|
| 93 |
+
{...props}
|
| 94 |
+
/>
|
| 95 |
+
));
|
| 96 |
+
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
| 97 |
+
|
| 98 |
+
export {
|
| 99 |
+
Dialog,
|
| 100 |
+
DialogPortal,
|
| 101 |
+
DialogOverlay,
|
| 102 |
+
DialogClose,
|
| 103 |
+
DialogTrigger,
|
| 104 |
+
DialogContent,
|
| 105 |
+
DialogHeader,
|
| 106 |
+
DialogFooter,
|
| 107 |
+
DialogTitle,
|
| 108 |
+
DialogDescription,
|
| 109 |
+
};
|
frontend/components/ui/input.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cn } from "@/lib/utils";
|
| 3 |
+
|
| 4 |
+
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
| 5 |
+
|
| 6 |
+
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
| 7 |
+
({ className, type, ...props }, ref) => (
|
| 8 |
+
<input
|
| 9 |
+
type={type}
|
| 10 |
+
ref={ref}
|
| 11 |
+
className={cn(
|
| 12 |
+
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
| 13 |
+
className
|
| 14 |
+
)}
|
| 15 |
+
{...props}
|
| 16 |
+
/>
|
| 17 |
+
)
|
| 18 |
+
);
|
| 19 |
+
Input.displayName = "Input";
|
frontend/components/ui/label.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import * as React from "react";
|
| 3 |
+
import * as LabelPrimitive from "@radix-ui/react-label";
|
| 4 |
+
import { cn } from "@/lib/utils";
|
| 5 |
+
|
| 6 |
+
export const Label = React.forwardRef<
|
| 7 |
+
React.ElementRef<typeof LabelPrimitive.Root>,
|
| 8 |
+
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
| 9 |
+
>(({ className, ...props }, ref) => (
|
| 10 |
+
<LabelPrimitive.Root
|
| 11 |
+
ref={ref}
|
| 12 |
+
className={cn(
|
| 13 |
+
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
| 14 |
+
className
|
| 15 |
+
)}
|
| 16 |
+
{...props}
|
| 17 |
+
/>
|
| 18 |
+
));
|
| 19 |
+
Label.displayName = "Label";
|
frontend/components/ui/select.tsx
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import * as React from "react";
|
| 3 |
+
import * as SelectPrimitive from "@radix-ui/react-select";
|
| 4 |
+
import { Check, ChevronDown } from "lucide-react";
|
| 5 |
+
import { cn } from "@/lib/utils";
|
| 6 |
+
|
| 7 |
+
export const Select = SelectPrimitive.Root;
|
| 8 |
+
export const SelectValue = SelectPrimitive.Value;
|
| 9 |
+
export const SelectGroup = SelectPrimitive.Group;
|
| 10 |
+
|
| 11 |
+
export const SelectTrigger = React.forwardRef<
|
| 12 |
+
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
| 13 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
| 14 |
+
>(({ className, children, ...props }, ref) => (
|
| 15 |
+
<SelectPrimitive.Trigger
|
| 16 |
+
ref={ref}
|
| 17 |
+
className={cn(
|
| 18 |
+
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
| 19 |
+
className
|
| 20 |
+
)}
|
| 21 |
+
{...props}
|
| 22 |
+
>
|
| 23 |
+
{children}
|
| 24 |
+
<SelectPrimitive.Icon asChild>
|
| 25 |
+
<ChevronDown className="h-4 w-4 opacity-50" />
|
| 26 |
+
</SelectPrimitive.Icon>
|
| 27 |
+
</SelectPrimitive.Trigger>
|
| 28 |
+
));
|
| 29 |
+
SelectTrigger.displayName = "SelectTrigger";
|
| 30 |
+
|
| 31 |
+
export const SelectContent = React.forwardRef<
|
| 32 |
+
React.ElementRef<typeof SelectPrimitive.Content>,
|
| 33 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
| 34 |
+
>(({ className, children, position = "popper", ...props }, ref) => (
|
| 35 |
+
<SelectPrimitive.Portal>
|
| 36 |
+
<SelectPrimitive.Content
|
| 37 |
+
ref={ref}
|
| 38 |
+
position={position}
|
| 39 |
+
className={cn(
|
| 40 |
+
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-card text-card-foreground shadow-md",
|
| 41 |
+
position === "popper" &&
|
| 42 |
+
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
| 43 |
+
className
|
| 44 |
+
)}
|
| 45 |
+
{...props}
|
| 46 |
+
>
|
| 47 |
+
<SelectPrimitive.Viewport
|
| 48 |
+
className={cn(
|
| 49 |
+
"p-1",
|
| 50 |
+
position === "popper" &&
|
| 51 |
+
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
| 52 |
+
)}
|
| 53 |
+
>
|
| 54 |
+
{children}
|
| 55 |
+
</SelectPrimitive.Viewport>
|
| 56 |
+
</SelectPrimitive.Content>
|
| 57 |
+
</SelectPrimitive.Portal>
|
| 58 |
+
));
|
| 59 |
+
SelectContent.displayName = "SelectContent";
|
| 60 |
+
|
| 61 |
+
export const SelectItem = React.forwardRef<
|
| 62 |
+
React.ElementRef<typeof SelectPrimitive.Item>,
|
| 63 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
| 64 |
+
>(({ className, children, ...props }, ref) => (
|
| 65 |
+
<SelectPrimitive.Item
|
| 66 |
+
ref={ref}
|
| 67 |
+
className={cn(
|
| 68 |
+
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
| 69 |
+
className
|
| 70 |
+
)}
|
| 71 |
+
{...props}
|
| 72 |
+
>
|
| 73 |
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
| 74 |
+
<SelectPrimitive.ItemIndicator>
|
| 75 |
+
<Check className="h-4 w-4" />
|
| 76 |
+
</SelectPrimitive.ItemIndicator>
|
| 77 |
+
</span>
|
| 78 |
+
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
| 79 |
+
</SelectPrimitive.Item>
|
| 80 |
+
));
|
| 81 |
+
SelectItem.displayName = "SelectItem";
|
frontend/components/ui/slider.tsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import * as React from "react";
|
| 3 |
+
import * as SliderPrimitive from "@radix-ui/react-slider";
|
| 4 |
+
import { cn } from "@/lib/utils";
|
| 5 |
+
|
| 6 |
+
export const Slider = React.forwardRef<
|
| 7 |
+
React.ElementRef<typeof SliderPrimitive.Root>,
|
| 8 |
+
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
| 9 |
+
>(({ className, ...props }, ref) => (
|
| 10 |
+
<SliderPrimitive.Root
|
| 11 |
+
ref={ref}
|
| 12 |
+
className={cn(
|
| 13 |
+
"relative flex w-full touch-none select-none items-center",
|
| 14 |
+
className
|
| 15 |
+
)}
|
| 16 |
+
{...props}
|
| 17 |
+
>
|
| 18 |
+
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-muted">
|
| 19 |
+
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
| 20 |
+
</SliderPrimitive.Track>
|
| 21 |
+
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
| 22 |
+
</SliderPrimitive.Root>
|
| 23 |
+
));
|
| 24 |
+
Slider.displayName = "Slider";
|
frontend/lib/personas.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type Persona = {
|
| 2 |
+
id: string;
|
| 3 |
+
name: string;
|
| 4 |
+
description: string;
|
| 5 |
+
prompt: string;
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
// Bundled fallback — shown before the API responds.
|
| 9 |
+
// The canonical list lives on the server (backend/personas.json → HF Dataset).
|
| 10 |
+
export const DEFAULT_PERSONAS: Persona[] = [
|
| 11 |
+
{
|
| 12 |
+
id: "socratic",
|
| 13 |
+
name: "Socratic Tutor",
|
| 14 |
+
description: "Asks guiding questions instead of giving answers directly.",
|
| 15 |
+
prompt:
|
| 16 |
+
"Adopt the Socratic method. Rarely give a final answer outright; instead, " +
|
| 17 |
+
"ask one focused, open-ended question at a time that nudges the student " +
|
| 18 |
+
"toward the insight. Praise good reasoning, gently surface flawed assumptions.",
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
id: "strict_grader",
|
| 22 |
+
name: "Strict Grader",
|
| 23 |
+
description: "Rigorous, terse, and demanding of evidence.",
|
| 24 |
+
prompt:
|
| 25 |
+
"You are a strict grader. Be concise and uncompromising. Demand evidence " +
|
| 26 |
+
"from the course material for every claim. Point out logical gaps, missing " +
|
| 27 |
+
"citations, and imprecise terminology. Award no partial credit silently.",
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
id: "explainer",
|
| 31 |
+
name: "Direct Explainer",
|
| 32 |
+
description: "Plain, structured explanations with examples.",
|
| 33 |
+
prompt:
|
| 34 |
+
"Explain directly and clearly. Use short paragraphs, concrete examples, " +
|
| 35 |
+
"and analogies when helpful. End with a one-line summary. Never withhold " +
|
| 36 |
+
"the answer behind a question.",
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
id: "study_buddy",
|
| 40 |
+
name: "Study Buddy",
|
| 41 |
+
description: "Casual, encouraging, learns alongside the student.",
|
| 42 |
+
prompt:
|
| 43 |
+
"Speak as a friendly peer who is also studying this material. Be warm and " +
|
| 44 |
+
"encouraging, share confusion when it's genuine, and propose next steps " +
|
| 45 |
+
"the student could try together with you.",
|
| 46 |
+
},
|
| 47 |
+
];
|
| 48 |
+
|
| 49 |
+
export const DEFAULT_PERSONA_ID = "explainer";
|
| 50 |
+
|
| 51 |
+
/** Fetch the live persona list from the backend. Falls back to DEFAULT_PERSONAS. */
|
| 52 |
+
export async function fetchPersonas(): Promise<Persona[]> {
|
| 53 |
+
try {
|
| 54 |
+
const r = await fetch("/api/personas");
|
| 55 |
+
if (!r.ok) throw new Error("non-ok");
|
| 56 |
+
const data = await r.json();
|
| 57 |
+
if (Array.isArray(data.personas) && data.personas.length > 0) return data.personas;
|
| 58 |
+
} catch {
|
| 59 |
+
// swallow — use bundled fallback
|
| 60 |
+
}
|
| 61 |
+
return DEFAULT_PERSONAS;
|
| 62 |
+
}
|
| 63 |
+
|
frontend/lib/utils.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type ClassValue, clsx } from "clsx";
|
| 2 |
+
import { twMerge } from "tailwind-merge";
|
| 3 |
+
|
| 4 |
+
export function cn(...inputs: ClassValue[]) {
|
| 5 |
+
return twMerge(clsx(inputs));
|
| 6 |
+
}
|
frontend/next-env.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="next" />
|
| 2 |
+
/// <reference types="next/image-types/global" />
|
| 3 |
+
import "./.next/dev/types/routes.d.ts";
|
| 4 |
+
|
| 5 |
+
// NOTE: This file should not be edited
|
| 6 |
+
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
frontend/next.config.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
// All /api/* requests from the browser are transparently proxied to the
|
| 3 |
+
// FastAPI process on 127.0.0.1:8000. Only port 7860 is exposed by the HF Space.
|
| 4 |
+
const nextConfig = {
|
| 5 |
+
reactStrictMode: true,
|
| 6 |
+
output: "standalone" === process.env.NEXT_OUTPUT ? "standalone" : undefined,
|
| 7 |
+
// Allow large PDF uploads through the upload route handler
|
| 8 |
+
experimental: {
|
| 9 |
+
serverActions: { bodySizeLimit: "100mb" },
|
| 10 |
+
},
|
| 11 |
+
async rewrites() {
|
| 12 |
+
const backend = process.env.BACKEND_INTERNAL_URL || "http://127.0.0.1:8000";
|
| 13 |
+
return [
|
| 14 |
+
{
|
| 15 |
+
source: "/api/:path*",
|
| 16 |
+
destination: `${backend}/:path*`,
|
| 17 |
+
},
|
| 18 |
+
];
|
| 19 |
+
},
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
module.exports = nextConfig;
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "iam-earth-dev-frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev -p 7860 -H 0.0.0.0",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start -p 7860 -H 0.0.0.0",
|
| 9 |
+
"lint": "next lint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@radix-ui/react-dialog": "^1.1.15",
|
| 13 |
+
"@radix-ui/react-label": "^2.1.0",
|
| 14 |
+
"@radix-ui/react-select": "^2.1.2",
|
| 15 |
+
"@radix-ui/react-slider": "^1.2.1",
|
| 16 |
+
"@radix-ui/react-slot": "^1.1.0",
|
| 17 |
+
"class-variance-authority": "^0.7.0",
|
| 18 |
+
"clsx": "^2.1.1",
|
| 19 |
+
"lucide-react": "^0.460.0",
|
| 20 |
+
"next": "latest",
|
| 21 |
+
"react": "latest",
|
| 22 |
+
"react-dom": "latest",
|
| 23 |
+
"react-dropzone": "^14.3.5",
|
| 24 |
+
"tailwind-merge": "^2.5.4"
|
| 25 |
+
},
|
| 26 |
+
"devDependencies": {
|
| 27 |
+
"@types/node": "^22.9.0",
|
| 28 |
+
"@types/react": "^18.3.12",
|
| 29 |
+
"@types/react-dom": "^18.3.1",
|
| 30 |
+
"autoprefixer": "^10.4.20",
|
| 31 |
+
"eslint": "^9.14.0",
|
| 32 |
+
"eslint-config-next": "latest",
|
| 33 |
+
"postcss": "^8.4.49",
|
| 34 |
+
"tailwindcss": "^3.4.14",
|
| 35 |
+
"tailwindcss-animate": "^1.0.7",
|
| 36 |
+
"typescript": "^5.6.3"
|
| 37 |
+
}
|
| 38 |
+
}
|
frontend/postcss.config.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
};
|
frontend/tailwind.config.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Config } from "tailwindcss";
|
| 2 |
+
|
| 3 |
+
const config: Config = {
|
| 4 |
+
darkMode: ["class"],
|
| 5 |
+
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
| 6 |
+
theme: {
|
| 7 |
+
container: { center: true, padding: "1.5rem" },
|
| 8 |
+
extend: {
|
| 9 |
+
colors: {
|
| 10 |
+
border: "hsl(var(--border))",
|
| 11 |
+
input: "hsl(var(--input))",
|
| 12 |
+
ring: "hsl(var(--ring))",
|
| 13 |
+
background: "hsl(var(--background))",
|
| 14 |
+
foreground: "hsl(var(--foreground))",
|
| 15 |
+
primary: {
|
| 16 |
+
DEFAULT: "hsl(var(--primary))",
|
| 17 |
+
foreground: "hsl(var(--primary-foreground))",
|
| 18 |
+
},
|
| 19 |
+
muted: {
|
| 20 |
+
DEFAULT: "hsl(var(--muted))",
|
| 21 |
+
foreground: "hsl(var(--muted-foreground))",
|
| 22 |
+
},
|
| 23 |
+
accent: {
|
| 24 |
+
DEFAULT: "hsl(var(--accent))",
|
| 25 |
+
foreground: "hsl(var(--accent-foreground))",
|
| 26 |
+
},
|
| 27 |
+
destructive: {
|
| 28 |
+
DEFAULT: "hsl(var(--destructive))",
|
| 29 |
+
foreground: "hsl(var(--destructive-foreground))",
|
| 30 |
+
},
|
| 31 |
+
card: {
|
| 32 |
+
DEFAULT: "hsl(var(--card))",
|
| 33 |
+
foreground: "hsl(var(--card-foreground))",
|
| 34 |
+
},
|
| 35 |
+
},
|
| 36 |
+
borderRadius: {
|
| 37 |
+
lg: "var(--radius)",
|
| 38 |
+
md: "calc(var(--radius) - 2px)",
|
| 39 |
+
sm: "calc(var(--radius) - 4px)",
|
| 40 |
+
},
|
| 41 |
+
},
|
| 42 |
+
},
|
| 43 |
+
plugins: [require("tailwindcss-animate")],
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
export default config;
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"lib": [
|
| 5 |
+
"dom",
|
| 6 |
+
"dom.iterable",
|
| 7 |
+
"esnext"
|
| 8 |
+
],
|
| 9 |
+
"allowJs": true,
|
| 10 |
+
"skipLibCheck": true,
|
| 11 |
+
"strict": true,
|
| 12 |
+
"noEmit": true,
|
| 13 |
+
"esModuleInterop": true,
|
| 14 |
+
"module": "esnext",
|
| 15 |
+
"moduleResolution": "bundler",
|
| 16 |
+
"resolveJsonModule": true,
|
| 17 |
+
"isolatedModules": true,
|
| 18 |
+
"jsx": "react-jsx",
|
| 19 |
+
"incremental": true,
|
| 20 |
+
"ignoreDeprecations": "5.0",
|
| 21 |
+
"plugins": [
|
| 22 |
+
{
|
| 23 |
+
"name": "next"
|
| 24 |
+
}
|
| 25 |
+
],
|
| 26 |
+
"baseUrl": ".",
|
| 27 |
+
"paths": {
|
| 28 |
+
"@/*": [
|
| 29 |
+
"./*"
|
| 30 |
+
]
|
| 31 |
+
}
|
| 32 |
+
},
|
| 33 |
+
"include": [
|
| 34 |
+
"next-env.d.ts",
|
| 35 |
+
"**/*.ts",
|
| 36 |
+
"**/*.tsx",
|
| 37 |
+
".next/types/**/*.ts",
|
| 38 |
+
".next/dev/types/**/*.ts"
|
| 39 |
+
],
|
| 40 |
+
"exclude": [
|
| 41 |
+
"node_modules"
|
| 42 |
+
]
|
| 43 |
+
}
|
frontend/tsconfig.tsbuildinfo
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
start.sh
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Launch FastAPI (internal :8000) and Next.js (public :7860) together.
|
| 3 |
+
# HF Spaces sends SIGTERM on shutdown; we trap it and forward to children.
|
| 4 |
+
set -euo pipefail
|
| 5 |
+
|
| 6 |
+
BACKEND_PORT="${BACKEND_PORT:-8000}"
|
| 7 |
+
FRONTEND_PORT="${FRONTEND_PORT:-7860}"
|
| 8 |
+
|
| 9 |
+
cleanup() {
|
| 10 |
+
echo "[start.sh] caught signal, shutting down…"
|
| 11 |
+
[[ -n "${BACKEND_PID:-}" ]] && kill -TERM "$BACKEND_PID" 2>/dev/null || true
|
| 12 |
+
[[ -n "${FRONTEND_PID:-}" ]] && kill -TERM "$FRONTEND_PID" 2>/dev/null || true
|
| 13 |
+
wait || true
|
| 14 |
+
exit 0
|
| 15 |
+
}
|
| 16 |
+
trap cleanup SIGINT SIGTERM
|
| 17 |
+
|
| 18 |
+
echo "[start.sh] launching FastAPI on :$BACKEND_PORT"
|
| 19 |
+
cd /app/backend
|
| 20 |
+
uv run uvicorn main:app --host 127.0.0.1 --port "$BACKEND_PORT" --workers 1 &
|
| 21 |
+
BACKEND_PID=$!
|
| 22 |
+
|
| 23 |
+
echo "[start.sh] launching Next.js on :$FRONTEND_PORT"
|
| 24 |
+
cd /app/frontend
|
| 25 |
+
HOSTNAME=0.0.0.0 PORT="$FRONTEND_PORT" npx next start -p "$FRONTEND_PORT" -H 0.0.0.0 &
|
| 26 |
+
FRONTEND_PID=$!
|
| 27 |
+
|
| 28 |
+
# If either process dies, exit so HF restarts the Space.
|
| 29 |
+
wait -n "$BACKEND_PID" "$FRONTEND_PID"
|
| 30 |
+
EXIT_CODE=$?
|
| 31 |
+
echo "[start.sh] a child exited (code=$EXIT_CODE), shutting down sibling"
|
| 32 |
+
cleanup
|
test_stream.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
import time
|
| 3 |
+
|
| 4 |
+
for _ in range(3):
|
| 5 |
+
print("tick")
|
| 6 |
+
time.sleep(1)
|
test_uvicorn_log.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.responses import StreamingResponse
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
app = FastAPI()
|
| 6 |
+
|
| 7 |
+
def stream():
|
| 8 |
+
for i in range(3):
|
| 9 |
+
time.sleep(1)
|
| 10 |
+
yield f"chunk {i}\n"
|
| 11 |
+
|
| 12 |
+
@app.get("/")
|
| 13 |
+
def get_stream():
|
| 14 |
+
return StreamingResponse(stream())
|