NeonClary commited on
Commit ·
cb6dcc3
0
Parent(s):
Initial CU Student AI Project Helper: FastAPI, Gemini, Mongo, React SPA
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +25 -0
- .gitattributes +6 -0
- .gitignore +12 -0
- Dockerfile +19 -0
- README.md +78 -0
- backend/app/__init__.py +1 -0
- backend/app/auth_jwt.py +74 -0
- backend/app/config.py +47 -0
- backend/app/db.py +16 -0
- backend/app/main.py +59 -0
- backend/app/routers/__init__.py +1 -0
- backend/app/routers/advisor_chat.py +35 -0
- backend/app/routers/auth.py +73 -0
- backend/app/routers/persona_chat.py +35 -0
- backend/app/routers/profile.py +44 -0
- backend/app/routers/submit.py +101 -0
- backend/app/routers/transcribe.py +105 -0
- backend/app/schemas.py +63 -0
- backend/app/services/gemini.py +101 -0
- backend/requirements.txt +12 -0
- docker-compose.yml +23 -0
- frontend/.env.development +2 -0
- frontend/.gitignore +24 -0
- frontend/README.md +73 -0
- frontend/eslint.config.js +23 -0
- frontend/index.html +12 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +31 -0
- frontend/public/favicon.svg +1 -0
- frontend/public/icons.svg +24 -0
- frontend/src/App.css +184 -0
- frontend/src/App.tsx +28 -0
- frontend/src/api.ts +26 -0
- frontend/src/assets/hero.png +3 -0
- frontend/src/assets/react.svg +1 -0
- frontend/src/assets/vite.svg +1 -0
- frontend/src/components/AccountModal.tsx +83 -0
- frontend/src/components/AppLayout.tsx +38 -0
- frontend/src/components/ClearDataModal.tsx +46 -0
- frontend/src/components/ProfileModal.tsx +83 -0
- frontend/src/components/SaveWarningBar.tsx +13 -0
- frontend/src/components/UserMenu.tsx +73 -0
- frontend/src/context/AuthContext.tsx +110 -0
- frontend/src/index.css +2 -0
- frontend/src/lib/drafts.ts +54 -0
- frontend/src/main.tsx +10 -0
- frontend/src/pages/AdvisorChatPage.tsx +185 -0
- frontend/src/pages/AdvisorFormWizard.tsx +151 -0
- frontend/src/pages/AuthPage.tsx +107 -0
- frontend/src/pages/LandingPage.tsx +146 -0
.env.example
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
GEMINI_API_KEY=
|
| 2 |
+
GEMINI_MODEL=gemini-2.0-flash
|
| 3 |
+
|
| 4 |
+
MONGODB_URL=mongodb://localhost:27017
|
| 5 |
+
MONGODB_DB=cu_student_helper
|
| 6 |
+
|
| 7 |
+
JWT_SECRET_KEY=change-me-to-a-long-random-string
|
| 8 |
+
JWT_ALGORITHM=HS256
|
| 9 |
+
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
| 10 |
+
|
| 11 |
+
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
| 12 |
+
|
| 13 |
+
# Optional Google Sheets (service account JSON path or raw JSON in env)
|
| 14 |
+
# GOOGLE_SERVICE_ACCOUNT_FILE=
|
| 15 |
+
# GOOGLE_SERVICE_ACCOUNT_JSON=
|
| 16 |
+
# GOOGLE_SPREADSHEET_ID=
|
| 17 |
+
# GOOGLE_SHEET_NAME=Submissions
|
| 18 |
+
|
| 19 |
+
# Optional email on submit
|
| 20 |
+
# ADMIN_NOTIFY_EMAIL=
|
| 21 |
+
# SMTP_HOST=
|
| 22 |
+
# SMTP_PORT=587
|
| 23 |
+
# SMTP_USER=
|
| 24 |
+
# SMTP_PASSWORD=
|
| 25 |
+
# SMTP_FROM=
|
.gitattributes
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.ico filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.gif filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.webp filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.venv/
|
| 4 |
+
venv/
|
| 5 |
+
.env
|
| 6 |
+
*.egg-info/
|
| 7 |
+
node_modules/
|
| 8 |
+
frontend/dist/
|
| 9 |
+
.idea/
|
| 10 |
+
.vscode/
|
| 11 |
+
.DS_Store
|
| 12 |
+
Thumbs.db
|
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1
|
| 2 |
+
FROM node:20-bookworm AS frontend-build
|
| 3 |
+
WORKDIR /app/frontend
|
| 4 |
+
COPY frontend/package.json frontend/package-lock.json* ./
|
| 5 |
+
RUN npm ci
|
| 6 |
+
COPY frontend/ ./
|
| 7 |
+
RUN npm run build
|
| 8 |
+
|
| 9 |
+
FROM python:3.12-slim-bookworm
|
| 10 |
+
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
WORKDIR /app/backend
|
| 13 |
+
COPY backend/requirements.txt .
|
| 14 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 15 |
+
COPY backend/ /app/backend/
|
| 16 |
+
COPY --from=frontend-build /app/frontend/dist /app/frontend/dist
|
| 17 |
+
ENV PYTHONPATH=/app/backend
|
| 18 |
+
EXPOSE 7860
|
| 19 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CU-Student-AIProject-Helper
|
| 2 |
+
|
| 3 |
+
Recruitment and tooling site for **non-technical** CU students joining a Collaborative Conversational AI (CCAI) advisory-panel project: long-form landing page, optional accounts, persona/advisor **forms** and **interactive Gemini chats** (with mic input and read-aloud), and submission hooks for Google Sheets / email.
|
| 4 |
+
|
| 5 |
+
## Stack
|
| 6 |
+
|
| 7 |
+
- **Frontend:** React (Vite + TypeScript), React Router
|
| 8 |
+
- **Backend:** FastAPI, Motor (MongoDB), Google Gemini API (`GEMINI_API_KEY`)
|
| 9 |
+
- **Deploy:** Docker (single image: API + static SPA); suitable for Hugging Face Spaces or any container host
|
| 10 |
+
|
| 11 |
+
## Local development
|
| 12 |
+
|
| 13 |
+
1. **MongoDB** — install locally or use Docker only for DB:
|
| 14 |
+
|
| 15 |
+
```bash
|
| 16 |
+
docker compose up mongo
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
2. **Backend** — from repo root:
|
| 20 |
+
|
| 21 |
+
```bash
|
| 22 |
+
cd backend
|
| 23 |
+
python -m venv .venv
|
| 24 |
+
.venv\Scripts\activate # Windows
|
| 25 |
+
pip install -r requirements.txt
|
| 26 |
+
copy ..\.env.example .env # or copy to repo root; config loads either
|
| 27 |
+
# Set GEMINI_API_KEY, JWT_SECRET_KEY, MONGODB_URL=mongodb://localhost:27017
|
| 28 |
+
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
3. **Frontend** — separate terminal:
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
cd frontend
|
| 35 |
+
npm install
|
| 36 |
+
npm run dev
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
Vite proxies `/api` to `http://127.0.0.1:8000`. Open `http://localhost:5173`.
|
| 40 |
+
|
| 41 |
+
Without a built `frontend/dist`, the API still serves OpenAPI at `/docs`. After `npm run build`, run uvicorn from repo root context so `frontend/dist` exists — or use Docker below.
|
| 42 |
+
|
| 43 |
+
## Configuration
|
| 44 |
+
|
| 45 |
+
See [.env.example](.env.example). Important variables:
|
| 46 |
+
|
| 47 |
+
| Variable | Purpose |
|
| 48 |
+
|----------|---------|
|
| 49 |
+
| `GEMINI_API_KEY` | Required for chat and transcription |
|
| 50 |
+
| `GEMINI_MODEL` | Default `gemini-2.0-flash` |
|
| 51 |
+
| `MONGODB_URL` / `MONGODB_DB` | User accounts and saved drafts |
|
| 52 |
+
| `JWT_SECRET_KEY` | Sign auth tokens |
|
| 53 |
+
| `CORS_ORIGINS` | Frontend origins (dev: `http://localhost:5173`) |
|
| 54 |
+
| `GOOGLE_*` / SMTP | Optional submission pipeline (see `app/routers/submit.py`) |
|
| 55 |
+
|
| 56 |
+
**Speech:** Mic input is sent to `POST /api/transcribe` (Gemini multimodal). “Read aloud” uses the browser **Web Speech API** (no extra key).
|
| 57 |
+
|
| 58 |
+
## Docker (full stack)
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
cp .env.example .env
|
| 62 |
+
# Fill GEMINI_API_KEY, JWT_SECRET_KEY, etc.
|
| 63 |
+
docker compose up --build
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
App: `http://localhost:7860` (API + static UI).
|
| 67 |
+
|
| 68 |
+
## GitHub
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
git remote add origin https://github.com/YOUR_ORG/CU-Student-AIProject-Helper.git
|
| 72 |
+
git add -A && git commit -m "Initial import"
|
| 73 |
+
git push -u origin main
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
## License
|
| 77 |
+
|
| 78 |
+
Project scaffold for educational use; adapt as needed for your program.
|
backend/app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# CU Student AI Project Helper — backend
|
backend/app/auth_jwt.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta, timezone
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from bson import ObjectId
|
| 5 |
+
from fastapi import Depends, HTTPException, status
|
| 6 |
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 7 |
+
from jose import JWTError, jwt
|
| 8 |
+
from passlib.context import CryptContext
|
| 9 |
+
|
| 10 |
+
from app.config import settings
|
| 11 |
+
from app.db import get_database
|
| 12 |
+
from app.schemas import UserPublic
|
| 13 |
+
|
| 14 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 15 |
+
security = HTTPBearer(auto_error=False)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def hash_password(password: str) -> str:
|
| 19 |
+
return pwd_context.hash(password)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def verify_password(plain: str, hashed: str) -> bool:
|
| 23 |
+
return pwd_context.verify(plain, hashed)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def create_access_token(subject: str) -> str:
|
| 27 |
+
expire = datetime.now(timezone.utc) + timedelta(
|
| 28 |
+
minutes=settings.access_token_expire_minutes
|
| 29 |
+
)
|
| 30 |
+
return jwt.encode(
|
| 31 |
+
{"sub": subject, "exp": expire},
|
| 32 |
+
settings.jwt_secret_key,
|
| 33 |
+
algorithm=settings.jwt_algorithm,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
async def get_current_user(
|
| 38 |
+
creds: HTTPAuthorizationCredentials | None = Depends(security),
|
| 39 |
+
) -> dict[str, Any]:
|
| 40 |
+
if creds is None or creds.scheme.lower() != "bearer":
|
| 41 |
+
raise HTTPException(
|
| 42 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 43 |
+
detail="Not authenticated",
|
| 44 |
+
)
|
| 45 |
+
try:
|
| 46 |
+
payload = jwt.decode(
|
| 47 |
+
creds.credentials,
|
| 48 |
+
settings.jwt_secret_key,
|
| 49 |
+
algorithms=[settings.jwt_algorithm],
|
| 50 |
+
)
|
| 51 |
+
uid = payload.get("sub")
|
| 52 |
+
if not uid:
|
| 53 |
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
| 54 |
+
except JWTError:
|
| 55 |
+
raise HTTPException(
|
| 56 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 57 |
+
detail="Invalid token",
|
| 58 |
+
) from None
|
| 59 |
+
|
| 60 |
+
db = get_database()
|
| 61 |
+
user = await db.users.find_one({"_id": ObjectId(uid)})
|
| 62 |
+
if not user:
|
| 63 |
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
| 64 |
+
user["id"] = str(user["_id"])
|
| 65 |
+
return user
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def user_public(doc: dict[str, Any]) -> UserPublic:
|
| 69 |
+
return UserPublic(
|
| 70 |
+
id=str(doc["_id"]),
|
| 71 |
+
email=doc["email"],
|
| 72 |
+
firstName=doc.get("firstName", ""),
|
| 73 |
+
lastName=doc.get("lastName", ""),
|
| 74 |
+
)
|
backend/app/config.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 4 |
+
|
| 5 |
+
_ROOT = Path(__file__).resolve().parent.parent
|
| 6 |
+
_ENV = _ROOT / ".env"
|
| 7 |
+
if not _ENV.is_file():
|
| 8 |
+
_ENV = _ROOT.parent / ".env"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Settings(BaseSettings):
|
| 12 |
+
model_config = SettingsConfigDict(
|
| 13 |
+
env_file=str(_ENV) if _ENV.is_file() else None,
|
| 14 |
+
env_file_encoding="utf-8",
|
| 15 |
+
extra="ignore",
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
gemini_api_key: str = ""
|
| 19 |
+
gemini_model: str = "gemini-2.0-flash"
|
| 20 |
+
|
| 21 |
+
mongodb_url: str = "mongodb://localhost:27017"
|
| 22 |
+
mongodb_db: str = "cu_student_helper"
|
| 23 |
+
|
| 24 |
+
jwt_secret_key: str = "change-me"
|
| 25 |
+
jwt_algorithm: str = "HS256"
|
| 26 |
+
access_token_expire_minutes: int = 60 * 24 * 7
|
| 27 |
+
|
| 28 |
+
cors_origins: str = "http://localhost:5173,http://localhost:3000"
|
| 29 |
+
|
| 30 |
+
google_service_account_file: str | None = None
|
| 31 |
+
google_service_account_json: str | None = None
|
| 32 |
+
google_spreadsheet_id: str | None = None
|
| 33 |
+
google_sheet_name: str = "Submissions"
|
| 34 |
+
|
| 35 |
+
admin_notify_email: str | None = None
|
| 36 |
+
smtp_host: str | None = None
|
| 37 |
+
smtp_port: int = 587
|
| 38 |
+
smtp_user: str | None = None
|
| 39 |
+
smtp_password: str | None = None
|
| 40 |
+
smtp_from: str | None = None
|
| 41 |
+
|
| 42 |
+
@property
|
| 43 |
+
def cors_origin_list(self) -> list[str]:
|
| 44 |
+
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
settings = Settings()
|
backend/app/db.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
| 2 |
+
|
| 3 |
+
from app.config import settings
|
| 4 |
+
|
| 5 |
+
_client: AsyncIOMotorClient | None = None
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def get_client() -> AsyncIOMotorClient:
|
| 9 |
+
global _client
|
| 10 |
+
if _client is None:
|
| 11 |
+
_client = AsyncIOMotorClient(settings.mongodb_url)
|
| 12 |
+
return _client
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def get_database() -> AsyncIOMotorDatabase:
|
| 16 |
+
return get_client()[settings.mongodb_db]
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from contextlib import asynccontextmanager
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from fastapi import FastAPI
|
| 6 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
+
from fastapi.staticfiles import StaticFiles
|
| 8 |
+
|
| 9 |
+
from app.config import settings
|
| 10 |
+
from app.db import get_client, get_database
|
| 11 |
+
from app.routers import advisor_chat, auth, persona_chat, profile, submit, transcribe
|
| 12 |
+
|
| 13 |
+
LOG = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@asynccontextmanager
|
| 19 |
+
async def lifespan(app: FastAPI):
|
| 20 |
+
db = get_database()
|
| 21 |
+
await db.users.create_index("email", unique=True)
|
| 22 |
+
LOG.info("Mongo indexes ensured")
|
| 23 |
+
yield
|
| 24 |
+
get_client().close()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
_docs = None if FRONTEND_DIST.is_dir() else "/docs"
|
| 28 |
+
_redoc = None if FRONTEND_DIST.is_dir() else "/redoc"
|
| 29 |
+
|
| 30 |
+
app = FastAPI(
|
| 31 |
+
title="CU Student AI Project Helper",
|
| 32 |
+
lifespan=lifespan,
|
| 33 |
+
docs_url=_docs,
|
| 34 |
+
redoc_url=_redoc,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
app.add_middleware(
|
| 38 |
+
CORSMiddleware,
|
| 39 |
+
allow_origins=settings.cors_origin_list,
|
| 40 |
+
allow_credentials=True,
|
| 41 |
+
allow_methods=["*"],
|
| 42 |
+
allow_headers=["*"],
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
app.include_router(auth.router, prefix="/api")
|
| 46 |
+
app.include_router(profile.router, prefix="/api")
|
| 47 |
+
app.include_router(persona_chat.router, prefix="/api/persona-chat")
|
| 48 |
+
app.include_router(advisor_chat.router, prefix="/api/advisor-chat")
|
| 49 |
+
app.include_router(transcribe.router, prefix="/api")
|
| 50 |
+
app.include_router(submit.router, prefix="/api")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@app.get("/api/health")
|
| 54 |
+
async def health():
|
| 55 |
+
return {"status": "ok"}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
if FRONTEND_DIST.is_dir():
|
| 59 |
+
app.mount("/", StaticFiles(directory=str(FRONTEND_DIST), html=True), name="static")
|
backend/app/routers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Routers package
|
backend/app/routers/advisor_chat.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
|
| 3 |
+
from app.schemas import ChatMessage, ChatResponse
|
| 4 |
+
from app.services.gemini import generate_structured_reply
|
| 5 |
+
|
| 6 |
+
router = APIRouter(tags=["advisor-chat"])
|
| 7 |
+
|
| 8 |
+
SYSTEM = """You are helping a student design a small **advisor panel** (multiple AI advisors with different angles) for a Collaborative Conversational AI project.
|
| 9 |
+
|
| 10 |
+
Collect in plain language:
|
| 11 |
+
- Panel name / theme
|
| 12 |
+
- How many advisors (2–5), each with a short role
|
| 13 |
+
- What decisions or questions the panel will help with
|
| 14 |
+
- Tone (formal/casual) and any content boundaries
|
| 15 |
+
|
| 16 |
+
Keep replies short (2–5 sentences). No code.
|
| 17 |
+
|
| 18 |
+
Every response MUST be a single JSON object only, no markdown, shape:
|
| 19 |
+
{"reply":"<message>","progress":<0-100>,"complete":<true|false>}
|
| 20 |
+
complete=true when the student could implement the panel from your notes."""
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.post("/message", response_model=ChatResponse)
|
| 24 |
+
async def advisor_message(msg: ChatMessage):
|
| 25 |
+
try:
|
| 26 |
+
out = await generate_structured_reply(
|
| 27 |
+
SYSTEM,
|
| 28 |
+
msg.history,
|
| 29 |
+
msg.user_input,
|
| 30 |
+
)
|
| 31 |
+
return ChatResponse(**out)
|
| 32 |
+
except ValueError as e:
|
| 33 |
+
raise HTTPException(503, str(e)) from e
|
| 34 |
+
except Exception as e:
|
| 35 |
+
raise HTTPException(500, f"Chat error: {e!s}") from e
|
backend/app/routers/auth.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from pymongo.errors import DuplicateKeyError
|
| 3 |
+
|
| 4 |
+
from app.auth_jwt import (
|
| 5 |
+
create_access_token,
|
| 6 |
+
get_current_user,
|
| 7 |
+
hash_password,
|
| 8 |
+
user_public,
|
| 9 |
+
verify_password,
|
| 10 |
+
)
|
| 11 |
+
from app.db import get_database
|
| 12 |
+
from app.schemas import TokenResponse, UserCreate, UserLogin, UserPublic, UserUpdate
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@router.post("/register", response_model=TokenResponse)
|
| 18 |
+
async def register(body: UserCreate):
|
| 19 |
+
db = get_database()
|
| 20 |
+
doc = {
|
| 21 |
+
"email": body.email.lower(),
|
| 22 |
+
"password_hash": hash_password(body.password),
|
| 23 |
+
"firstName": body.firstName,
|
| 24 |
+
"lastName": body.lastName,
|
| 25 |
+
}
|
| 26 |
+
try:
|
| 27 |
+
res = await db.users.insert_one(doc)
|
| 28 |
+
except DuplicateKeyError:
|
| 29 |
+
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Email already registered")
|
| 30 |
+
token = create_access_token(str(res.inserted_id))
|
| 31 |
+
return TokenResponse(access_token=token)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@router.post("/login", response_model=TokenResponse)
|
| 35 |
+
async def login(body: UserLogin):
|
| 36 |
+
db = get_database()
|
| 37 |
+
user = await db.users.find_one({"email": body.email.lower()})
|
| 38 |
+
if not user or not verify_password(body.password, user["password_hash"]):
|
| 39 |
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid email or password")
|
| 40 |
+
token = create_access_token(str(user["_id"]))
|
| 41 |
+
return TokenResponse(access_token=token)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@router.get("/me", response_model=UserPublic)
|
| 45 |
+
async def me(user=Depends(get_current_user)):
|
| 46 |
+
return user_public(user)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.patch("/me", response_model=UserPublic)
|
| 50 |
+
async def update_me(body: UserUpdate, user=Depends(get_current_user)):
|
| 51 |
+
db = get_database()
|
| 52 |
+
updates: dict = {}
|
| 53 |
+
if body.firstName is not None:
|
| 54 |
+
updates["firstName"] = body.firstName
|
| 55 |
+
if body.lastName is not None:
|
| 56 |
+
updates["lastName"] = body.lastName
|
| 57 |
+
if body.email is not None:
|
| 58 |
+
updates["email"] = body.email.lower()
|
| 59 |
+
if body.password is not None:
|
| 60 |
+
updates["password_hash"] = hash_password(body.password)
|
| 61 |
+
if updates:
|
| 62 |
+
await db.users.update_one({"_id": user["_id"]}, {"$set": updates})
|
| 63 |
+
fresh = await db.users.find_one({"_id": user["_id"]})
|
| 64 |
+
return user_public(fresh)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.delete("/me")
|
| 68 |
+
async def delete_me(user=Depends(get_current_user)):
|
| 69 |
+
db = get_database()
|
| 70 |
+
uid = user["_id"]
|
| 71 |
+
await db.user_profiles.delete_many({"user_id": uid})
|
| 72 |
+
await db.users.delete_one({"_id": uid})
|
| 73 |
+
return {"ok": True}
|
backend/app/routers/persona_chat.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
|
| 3 |
+
from app.schemas import ChatMessage, ChatResponse
|
| 4 |
+
from app.services.gemini import generate_structured_reply
|
| 5 |
+
|
| 6 |
+
router = APIRouter(tags=["persona-chat"])
|
| 7 |
+
|
| 8 |
+
SYSTEM = """You are a friendly assistant helping a university student design ONE AI advisor **persona** for a Collaborative Conversational AI (CCAI) class project with Neon.ai / CU.
|
| 9 |
+
|
| 10 |
+
Goals:
|
| 11 |
+
- Learn their subject area, tone, name/label, what the persona should help with, and any boundaries.
|
| 12 |
+
- Keep messages short (2–5 sentences). No jargon unless they use it first.
|
| 13 |
+
- You are NOT writing code; you are helping them describe the persona in plain language.
|
| 14 |
+
|
| 15 |
+
IMPORTANT: Every response MUST be a single JSON object only, no markdown fences, with this shape:
|
| 16 |
+
{"reply":"<your next message to the student>","progress":<0-100>,"complete":<true|false>}
|
| 17 |
+
- progress: rough estimate of how much you still need (100 = ready to copy into their project).
|
| 18 |
+
- complete: true only when you have enough to fill a persona sheet (name, role, audience, tone, topics, guardrails).
|
| 19 |
+
|
| 20 |
+
Start by greeting them and asking what subject or community they want their AI persona to represent."""
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.post("/message", response_model=ChatResponse)
|
| 24 |
+
async def persona_message(msg: ChatMessage):
|
| 25 |
+
try:
|
| 26 |
+
out = await generate_structured_reply(
|
| 27 |
+
SYSTEM,
|
| 28 |
+
msg.history,
|
| 29 |
+
msg.user_input,
|
| 30 |
+
)
|
| 31 |
+
return ChatResponse(**out)
|
| 32 |
+
except ValueError as e:
|
| 33 |
+
raise HTTPException(503, str(e)) from e
|
| 34 |
+
except Exception as e:
|
| 35 |
+
raise HTTPException(500, f"Chat error: {e!s}") from e
|
backend/app/routers/profile.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends
|
| 2 |
+
|
| 3 |
+
from app.auth_jwt import get_current_user
|
| 4 |
+
from app.db import get_database
|
| 5 |
+
from app.schemas import ProfilePayload
|
| 6 |
+
|
| 7 |
+
router = APIRouter(prefix="/users", tags=["profile"])
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@router.get("/me/profile")
|
| 11 |
+
async def get_profile(user=Depends(get_current_user)):
|
| 12 |
+
db = get_database()
|
| 13 |
+
doc = await db.user_profiles.find_one({"user_id": user["_id"]})
|
| 14 |
+
if not doc:
|
| 15 |
+
return {"persona_draft": {}, "advisor_draft": {}}
|
| 16 |
+
return {
|
| 17 |
+
"persona_draft": doc.get("persona_draft") or {},
|
| 18 |
+
"advisor_draft": doc.get("advisor_draft") or {},
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.put("/me/profile")
|
| 23 |
+
async def put_profile(body: ProfilePayload, user=Depends(get_current_user)):
|
| 24 |
+
db = get_database()
|
| 25 |
+
update: dict = {}
|
| 26 |
+
if body.persona_draft is not None:
|
| 27 |
+
update["persona_draft"] = body.persona_draft
|
| 28 |
+
if body.advisor_draft is not None:
|
| 29 |
+
update["advisor_draft"] = body.advisor_draft
|
| 30 |
+
if not update:
|
| 31 |
+
return {"ok": True}
|
| 32 |
+
await db.user_profiles.update_one(
|
| 33 |
+
{"user_id": user["_id"]},
|
| 34 |
+
{"$set": update},
|
| 35 |
+
upsert=True,
|
| 36 |
+
)
|
| 37 |
+
return {"ok": True}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.post("/me/clear-data")
|
| 41 |
+
async def clear_data(user=Depends(get_current_user)):
|
| 42 |
+
db = get_database()
|
| 43 |
+
await db.user_profiles.delete_one({"user_id": user["_id"]})
|
| 44 |
+
return {"ok": True}
|
backend/app/routers/submit.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
import smtplib
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from email.message import EmailMessage
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter
|
| 9 |
+
|
| 10 |
+
from app.config import settings
|
| 11 |
+
from app.schemas import SubmitPayload, SubmitResponse
|
| 12 |
+
|
| 13 |
+
LOG = logging.getLogger(__name__)
|
| 14 |
+
router = APIRouter(tags=["submit"])
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _append_sheet(row: list[Any]) -> bool:
|
| 18 |
+
if not settings.google_spreadsheet_id:
|
| 19 |
+
return False
|
| 20 |
+
try:
|
| 21 |
+
import gspread
|
| 22 |
+
except ImportError:
|
| 23 |
+
LOG.warning("gspread not installed; skipping sheet append")
|
| 24 |
+
return False
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
if settings.google_service_account_file:
|
| 28 |
+
gc = gspread.service_account(filename=settings.google_service_account_file)
|
| 29 |
+
elif settings.google_service_account_json:
|
| 30 |
+
info = json.loads(settings.google_service_account_json)
|
| 31 |
+
gc = gspread.service_account_from_dict(info)
|
| 32 |
+
else:
|
| 33 |
+
return False
|
| 34 |
+
except Exception as e:
|
| 35 |
+
LOG.error("Google auth failed: %s", e)
|
| 36 |
+
return False
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
sh = gc.open_by_key(settings.google_spreadsheet_id)
|
| 40 |
+
ws = sh.worksheet(settings.google_sheet_name)
|
| 41 |
+
ws.append_row(row, value_input_option="USER_ENTERED")
|
| 42 |
+
return True
|
| 43 |
+
except Exception as e:
|
| 44 |
+
LOG.error("Sheet append failed: %s", e)
|
| 45 |
+
return False
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _send_email(subject: str, body: str) -> bool:
|
| 49 |
+
if not settings.admin_notify_email or not settings.smtp_host:
|
| 50 |
+
return False
|
| 51 |
+
msg = EmailMessage()
|
| 52 |
+
msg["Subject"] = subject
|
| 53 |
+
msg["From"] = settings.smtp_from or settings.smtp_user or "noreply@localhost"
|
| 54 |
+
msg["To"] = settings.admin_notify_email
|
| 55 |
+
msg.set_content(body)
|
| 56 |
+
try:
|
| 57 |
+
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as smtp:
|
| 58 |
+
smtp.starttls()
|
| 59 |
+
if settings.smtp_user and settings.smtp_password:
|
| 60 |
+
smtp.login(settings.smtp_user, settings.smtp_password)
|
| 61 |
+
smtp.send_message(msg)
|
| 62 |
+
return True
|
| 63 |
+
except Exception as e:
|
| 64 |
+
LOG.error("SMTP failed: %s", e)
|
| 65 |
+
return False
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@router.post("/submit", response_model=SubmitResponse)
|
| 69 |
+
async def submit(payload: SubmitPayload):
|
| 70 |
+
ts = datetime.now(timezone.utc).isoformat()
|
| 71 |
+
row = [
|
| 72 |
+
ts,
|
| 73 |
+
payload.kind,
|
| 74 |
+
payload.email or "",
|
| 75 |
+
json.dumps(payload.data, ensure_ascii=False)[:32000],
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
sheet_ok = False
|
| 79 |
+
try:
|
| 80 |
+
sheet_ok = _append_sheet(row)
|
| 81 |
+
except Exception as e:
|
| 82 |
+
LOG.error("Sheet append error: %s", e)
|
| 83 |
+
|
| 84 |
+
subject = f"[CU Student Helper] {payload.kind} — {ts}"
|
| 85 |
+
body = f"Kind: {payload.kind}\nEmail: {payload.email}\n\n{json.dumps(payload.data, indent=2, ensure_ascii=False)[:50000]}"
|
| 86 |
+
email_ok = _send_email(subject, body)
|
| 87 |
+
|
| 88 |
+
if not sheet_ok and not email_ok:
|
| 89 |
+
return SubmitResponse(
|
| 90 |
+
ok=True,
|
| 91 |
+
message="Received. Configure Google Sheets or SMTP in .env to persist server-side.",
|
| 92 |
+
row_appended=False,
|
| 93 |
+
email_sent=False,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
return SubmitResponse(
|
| 97 |
+
ok=True,
|
| 98 |
+
message="Saved.",
|
| 99 |
+
row_appended=sheet_ok,
|
| 100 |
+
email_sent=email_ok,
|
| 101 |
+
)
|
backend/app/routers/transcribe.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Transcribe audio via Gemini multimodal (WAV preferred)."""
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import logging
|
| 5 |
+
import subprocess
|
| 6 |
+
import tempfile
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
from fastapi import APIRouter, File, HTTPException, UploadFile
|
| 11 |
+
|
| 12 |
+
from app.config import settings
|
| 13 |
+
|
| 14 |
+
LOG = logging.getLogger(__name__)
|
| 15 |
+
router = APIRouter(tags=["transcribe"])
|
| 16 |
+
|
| 17 |
+
GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _to_wav(audio_bytes: bytes, mime: str) -> tuple[bytes, str]:
|
| 21 |
+
if "wav" in (mime or "").lower():
|
| 22 |
+
return audio_bytes, "audio/wav"
|
| 23 |
+
ext = "webm" if "webm" in (mime or "").lower() else "ogg"
|
| 24 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 25 |
+
src = Path(tmp) / f"in.{ext}"
|
| 26 |
+
dst = Path(tmp) / "out.wav"
|
| 27 |
+
src.write_bytes(audio_bytes)
|
| 28 |
+
r = subprocess.run(
|
| 29 |
+
[
|
| 30 |
+
"ffmpeg",
|
| 31 |
+
"-y",
|
| 32 |
+
"-i",
|
| 33 |
+
str(src),
|
| 34 |
+
"-ar",
|
| 35 |
+
"16000",
|
| 36 |
+
"-ac",
|
| 37 |
+
"1",
|
| 38 |
+
"-f",
|
| 39 |
+
"wav",
|
| 40 |
+
str(dst),
|
| 41 |
+
],
|
| 42 |
+
capture_output=True,
|
| 43 |
+
timeout=60,
|
| 44 |
+
)
|
| 45 |
+
if r.returncode != 0:
|
| 46 |
+
LOG.warning("ffmpeg failed: %s", r.stderr.decode(errors="replace")[-400:])
|
| 47 |
+
raise RuntimeError("ffmpeg conversion failed")
|
| 48 |
+
return dst.read_bytes(), "audio/wav"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@router.post("/transcribe")
|
| 52 |
+
async def transcribe(audio: UploadFile = File(...)):
|
| 53 |
+
key = settings.gemini_api_key
|
| 54 |
+
if not key:
|
| 55 |
+
raise HTTPException(503, "GEMINI_API_KEY not configured")
|
| 56 |
+
|
| 57 |
+
raw = await audio.read()
|
| 58 |
+
if not raw:
|
| 59 |
+
return {"text": ""}
|
| 60 |
+
|
| 61 |
+
mime = audio.content_type or "audio/webm"
|
| 62 |
+
try:
|
| 63 |
+
wav_bytes, wav_mime = _to_wav(raw, mime)
|
| 64 |
+
except RuntimeError:
|
| 65 |
+
raise HTTPException(500, "Could not convert audio (install ffmpeg?)")
|
| 66 |
+
|
| 67 |
+
b64 = base64.standard_b64encode(wav_bytes).decode("ascii")
|
| 68 |
+
model = settings.gemini_model
|
| 69 |
+
url = f"{GEMINI_URL}/{model}:generateContent?key={key}"
|
| 70 |
+
|
| 71 |
+
payload = {
|
| 72 |
+
"contents": [
|
| 73 |
+
{
|
| 74 |
+
"role": "user",
|
| 75 |
+
"parts": [
|
| 76 |
+
{
|
| 77 |
+
"inline_data": {
|
| 78 |
+
"mime_type": wav_mime,
|
| 79 |
+
"data": b64,
|
| 80 |
+
}
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"text": "Transcribe the spoken words in this audio. "
|
| 84 |
+
"Output only the transcript text, no quotes or labels."
|
| 85 |
+
},
|
| 86 |
+
],
|
| 87 |
+
}
|
| 88 |
+
],
|
| 89 |
+
"generationConfig": {"temperature": 0.2, "maxOutputTokens": 1024},
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 93 |
+
resp = await client.post(url, json=payload)
|
| 94 |
+
if resp.status_code >= 400:
|
| 95 |
+
LOG.error("Gemini transcribe error: %s", resp.text[:500])
|
| 96 |
+
raise HTTPException(502, "Transcription service error")
|
| 97 |
+
data = resp.json()
|
| 98 |
+
|
| 99 |
+
try:
|
| 100 |
+
parts = data["candidates"][0]["content"]["parts"]
|
| 101 |
+
text = "".join(p.get("text", "") for p in parts).strip()
|
| 102 |
+
except (KeyError, IndexError):
|
| 103 |
+
raise HTTPException(502, "Bad transcription response")
|
| 104 |
+
|
| 105 |
+
return {"text": text}
|
backend/app/schemas.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, EmailStr, Field
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class UserCreate(BaseModel):
|
| 7 |
+
email: EmailStr
|
| 8 |
+
password: str = Field(min_length=8)
|
| 9 |
+
firstName: str = Field(min_length=1, max_length=80)
|
| 10 |
+
lastName: str = Field(min_length=1, max_length=80)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class UserLogin(BaseModel):
|
| 14 |
+
email: EmailStr
|
| 15 |
+
password: str
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class TokenResponse(BaseModel):
|
| 19 |
+
access_token: str
|
| 20 |
+
token_type: str = "bearer"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class UserPublic(BaseModel):
|
| 24 |
+
id: str
|
| 25 |
+
email: str
|
| 26 |
+
firstName: str
|
| 27 |
+
lastName: str
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class UserUpdate(BaseModel):
|
| 31 |
+
firstName: str | None = None
|
| 32 |
+
lastName: str | None = None
|
| 33 |
+
email: EmailStr | None = None
|
| 34 |
+
password: str | None = Field(default=None, min_length=8)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ProfilePayload(BaseModel):
|
| 38 |
+
persona_draft: dict[str, Any] | None = None
|
| 39 |
+
advisor_draft: dict[str, Any] | None = None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class ChatMessage(BaseModel):
|
| 43 |
+
history: list[dict[str, str]] = []
|
| 44 |
+
user_input: str
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class ChatResponse(BaseModel):
|
| 48 |
+
reply: str
|
| 49 |
+
progress: int
|
| 50 |
+
complete: bool
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class SubmitPayload(BaseModel):
|
| 54 |
+
kind: str # persona_form | advisor_form | persona_chat | advisor_chat
|
| 55 |
+
data: dict[str, Any]
|
| 56 |
+
email: str | None = None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class SubmitResponse(BaseModel):
|
| 60 |
+
ok: bool
|
| 61 |
+
message: str
|
| 62 |
+
row_appended: bool = False
|
| 63 |
+
email_sent: bool = False
|
backend/app/services/gemini.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
import re
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
|
| 8 |
+
from app.config import settings
|
| 9 |
+
|
| 10 |
+
LOG = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _extract_json_object(text: str) -> dict[str, Any] | None:
|
| 16 |
+
text = text.strip()
|
| 17 |
+
m = re.search(r"\{[\s\S]*\}", text)
|
| 18 |
+
if not m:
|
| 19 |
+
return None
|
| 20 |
+
try:
|
| 21 |
+
return json.loads(m.group())
|
| 22 |
+
except json.JSONDecodeError:
|
| 23 |
+
return None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
async def generate_structured_reply(
|
| 27 |
+
system_instruction: str,
|
| 28 |
+
history: list[dict[str, str]],
|
| 29 |
+
user_message: str,
|
| 30 |
+
) -> dict[str, Any]:
|
| 31 |
+
"""Call Gemini; expect JSON with reply, progress, complete."""
|
| 32 |
+
key = settings.gemini_api_key
|
| 33 |
+
if not key:
|
| 34 |
+
raise ValueError("GEMINI_API_KEY is not set")
|
| 35 |
+
|
| 36 |
+
model = settings.gemini_model
|
| 37 |
+
url = f"{GEMINI_URL}/{model}:generateContent?key={key}"
|
| 38 |
+
|
| 39 |
+
# Gemini turns should start with a user message; drop leading assistant-only context
|
| 40 |
+
h = list(history)
|
| 41 |
+
while h and h[0].get("role") == "agent":
|
| 42 |
+
h.pop(0)
|
| 43 |
+
|
| 44 |
+
contents: list[dict[str, Any]] = []
|
| 45 |
+
for turn in h:
|
| 46 |
+
role = turn.get("role", "user")
|
| 47 |
+
text = turn.get("text", "")
|
| 48 |
+
g_role = "user" if role == "user" else "model"
|
| 49 |
+
contents.append({"role": g_role, "parts": [{"text": text}]})
|
| 50 |
+
|
| 51 |
+
contents.append({"role": "user", "parts": [{"text": user_message}]})
|
| 52 |
+
|
| 53 |
+
payload = {
|
| 54 |
+
"systemInstruction": {"parts": [{"text": system_instruction}]},
|
| 55 |
+
"contents": contents,
|
| 56 |
+
"generationConfig": {
|
| 57 |
+
"temperature": 0.6,
|
| 58 |
+
"maxOutputTokens": 2048,
|
| 59 |
+
},
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 63 |
+
resp = await client.post(url, json=payload)
|
| 64 |
+
resp.raise_for_status()
|
| 65 |
+
data = resp.json()
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
parts = data["candidates"][0]["content"]["parts"]
|
| 69 |
+
raw = "".join(p.get("text", "") for p in parts)
|
| 70 |
+
except (KeyError, IndexError) as e:
|
| 71 |
+
LOG.error("Unexpected Gemini response: %s", data)
|
| 72 |
+
raise ValueError("Invalid Gemini response") from e
|
| 73 |
+
|
| 74 |
+
parsed = _extract_json_object(raw)
|
| 75 |
+
if parsed and "reply" in parsed:
|
| 76 |
+
return {
|
| 77 |
+
"reply": str(parsed["reply"]),
|
| 78 |
+
"progress": int(parsed.get("progress", 0)),
|
| 79 |
+
"complete": bool(parsed.get("complete", False)),
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
return {"reply": raw, "progress": 50, "complete": False}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
async def generate_simple_text(system_instruction: str, user_prompt: str) -> str:
|
| 86 |
+
key = settings.gemini_api_key
|
| 87 |
+
if not key:
|
| 88 |
+
raise ValueError("GEMINI_API_KEY is not set")
|
| 89 |
+
model = settings.gemini_model
|
| 90 |
+
url = f"{GEMINI_URL}/{model}:generateContent?key={key}"
|
| 91 |
+
payload = {
|
| 92 |
+
"systemInstruction": {"parts": [{"text": system_instruction}]},
|
| 93 |
+
"contents": [{"role": "user", "parts": [{"text": user_prompt}]}],
|
| 94 |
+
"generationConfig": {"temperature": 0.4, "maxOutputTokens": 1024},
|
| 95 |
+
}
|
| 96 |
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 97 |
+
resp = await client.post(url, json=payload)
|
| 98 |
+
resp.raise_for_status()
|
| 99 |
+
data = resp.json()
|
| 100 |
+
parts = data["candidates"][0]["content"]["parts"]
|
| 101 |
+
return "".join(p.get("text", "") for p in parts)
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115.0
|
| 2 |
+
uvicorn[standard]>=0.32.0
|
| 3 |
+
motor>=3.6.0
|
| 4 |
+
pydantic-settings>=2.6.0
|
| 5 |
+
python-jose[cryptography]>=3.3.0
|
| 6 |
+
passlib[bcrypt]>=1.7.4
|
| 7 |
+
bcrypt==4.0.1
|
| 8 |
+
httpx>=0.27.0
|
| 9 |
+
python-multipart>=0.0.12
|
| 10 |
+
email-validator>=2.2.0
|
| 11 |
+
gspread>=6.0.0
|
| 12 |
+
google-auth>=2.29.0
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
mongo:
|
| 3 |
+
image: mongo:7
|
| 4 |
+
ports:
|
| 5 |
+
- "27017:27017"
|
| 6 |
+
volumes:
|
| 7 |
+
- cu_student_mongo:/data/db
|
| 8 |
+
|
| 9 |
+
api:
|
| 10 |
+
build:
|
| 11 |
+
context: .
|
| 12 |
+
dockerfile: Dockerfile
|
| 13 |
+
ports:
|
| 14 |
+
- "7860:7860"
|
| 15 |
+
env_file:
|
| 16 |
+
- .env
|
| 17 |
+
environment:
|
| 18 |
+
MONGODB_URL: mongodb://mongo:27017
|
| 19 |
+
depends_on:
|
| 20 |
+
- mongo
|
| 21 |
+
|
| 22 |
+
volumes:
|
| 23 |
+
cu_student_mongo:
|
frontend/.env.development
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Leave empty to use Vite proxy to backend; or set http://127.0.0.1:8000
|
| 2 |
+
VITE_API_URL=
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + TypeScript + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
| 17 |
+
|
| 18 |
+
```js
|
| 19 |
+
export default defineConfig([
|
| 20 |
+
globalIgnores(['dist']),
|
| 21 |
+
{
|
| 22 |
+
files: ['**/*.{ts,tsx}'],
|
| 23 |
+
extends: [
|
| 24 |
+
// Other configs...
|
| 25 |
+
|
| 26 |
+
// Remove tseslint.configs.recommended and replace with this
|
| 27 |
+
tseslint.configs.recommendedTypeChecked,
|
| 28 |
+
// Alternatively, use this for stricter rules
|
| 29 |
+
tseslint.configs.strictTypeChecked,
|
| 30 |
+
// Optionally, add this for stylistic rules
|
| 31 |
+
tseslint.configs.stylisticTypeChecked,
|
| 32 |
+
|
| 33 |
+
// Other configs...
|
| 34 |
+
],
|
| 35 |
+
languageOptions: {
|
| 36 |
+
parserOptions: {
|
| 37 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 38 |
+
tsconfigRootDir: import.meta.dirname,
|
| 39 |
+
},
|
| 40 |
+
// other options...
|
| 41 |
+
},
|
| 42 |
+
},
|
| 43 |
+
])
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
| 47 |
+
|
| 48 |
+
```js
|
| 49 |
+
// eslint.config.js
|
| 50 |
+
import reactX from 'eslint-plugin-react-x'
|
| 51 |
+
import reactDom from 'eslint-plugin-react-dom'
|
| 52 |
+
|
| 53 |
+
export default defineConfig([
|
| 54 |
+
globalIgnores(['dist']),
|
| 55 |
+
{
|
| 56 |
+
files: ['**/*.{ts,tsx}'],
|
| 57 |
+
extends: [
|
| 58 |
+
// Other configs...
|
| 59 |
+
// Enable lint rules for React
|
| 60 |
+
reactX.configs['recommended-typescript'],
|
| 61 |
+
// Enable lint rules for React DOM
|
| 62 |
+
reactDom.configs.recommended,
|
| 63 |
+
],
|
| 64 |
+
languageOptions: {
|
| 65 |
+
parserOptions: {
|
| 66 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 67 |
+
tsconfigRootDir: import.meta.dirname,
|
| 68 |
+
},
|
| 69 |
+
// other options...
|
| 70 |
+
},
|
| 71 |
+
},
|
| 72 |
+
])
|
| 73 |
+
```
|
frontend/eslint.config.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import tseslint from 'typescript-eslint'
|
| 6 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 7 |
+
|
| 8 |
+
export default defineConfig([
|
| 9 |
+
globalIgnores(['dist']),
|
| 10 |
+
{
|
| 11 |
+
files: ['**/*.{ts,tsx}'],
|
| 12 |
+
extends: [
|
| 13 |
+
js.configs.recommended,
|
| 14 |
+
tseslint.configs.recommended,
|
| 15 |
+
reactHooks.configs.flat.recommended,
|
| 16 |
+
reactRefresh.configs.vite,
|
| 17 |
+
],
|
| 18 |
+
languageOptions: {
|
| 19 |
+
ecmaVersion: 2020,
|
| 20 |
+
globals: globals.browser,
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
])
|
frontend/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>CU Student AI Project Helper</title>
|
| 7 |
+
</head>
|
| 8 |
+
<body>
|
| 9 |
+
<div id="root"></div>
|
| 10 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 11 |
+
</body>
|
| 12 |
+
</html>
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"react": "^18.3.1",
|
| 14 |
+
"react-dom": "^18.3.1",
|
| 15 |
+
"react-router-dom": "^6.28.0"
|
| 16 |
+
},
|
| 17 |
+
"devDependencies": {
|
| 18 |
+
"@types/node": "^22.9.0",
|
| 19 |
+
"@eslint/js": "^9.15.0",
|
| 20 |
+
"@types/react": "^18.3.12",
|
| 21 |
+
"@types/react-dom": "^18.3.1",
|
| 22 |
+
"@vitejs/plugin-react": "^4.3.4",
|
| 23 |
+
"eslint": "^9.15.0",
|
| 24 |
+
"eslint-plugin-react-hooks": "^5.0.0",
|
| 25 |
+
"eslint-plugin-react-refresh": "^0.4.14",
|
| 26 |
+
"globals": "^15.12.0",
|
| 27 |
+
"typescript": "~5.6.3",
|
| 28 |
+
"typescript-eslint": "^8.15.0",
|
| 29 |
+
"vite": "^5.4.11"
|
| 30 |
+
}
|
| 31 |
+
}
|
frontend/public/favicon.svg
ADDED
|
|
frontend/public/icons.svg
ADDED
|
|
frontend/src/App.css
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.counter {
|
| 2 |
+
font-size: 16px;
|
| 3 |
+
padding: 5px 10px;
|
| 4 |
+
border-radius: 5px;
|
| 5 |
+
color: var(--accent);
|
| 6 |
+
background: var(--accent-bg);
|
| 7 |
+
border: 2px solid transparent;
|
| 8 |
+
transition: border-color 0.3s;
|
| 9 |
+
margin-bottom: 24px;
|
| 10 |
+
|
| 11 |
+
&:hover {
|
| 12 |
+
border-color: var(--accent-border);
|
| 13 |
+
}
|
| 14 |
+
&:focus-visible {
|
| 15 |
+
outline: 2px solid var(--accent);
|
| 16 |
+
outline-offset: 2px;
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
.hero {
|
| 21 |
+
position: relative;
|
| 22 |
+
|
| 23 |
+
.base,
|
| 24 |
+
.framework,
|
| 25 |
+
.vite {
|
| 26 |
+
inset-inline: 0;
|
| 27 |
+
margin: 0 auto;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.base {
|
| 31 |
+
width: 170px;
|
| 32 |
+
position: relative;
|
| 33 |
+
z-index: 0;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.framework,
|
| 37 |
+
.vite {
|
| 38 |
+
position: absolute;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.framework {
|
| 42 |
+
z-index: 1;
|
| 43 |
+
top: 34px;
|
| 44 |
+
height: 28px;
|
| 45 |
+
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
| 46 |
+
scale(1.4);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.vite {
|
| 50 |
+
z-index: 0;
|
| 51 |
+
top: 107px;
|
| 52 |
+
height: 26px;
|
| 53 |
+
width: auto;
|
| 54 |
+
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
| 55 |
+
scale(0.8);
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
#center {
|
| 60 |
+
display: flex;
|
| 61 |
+
flex-direction: column;
|
| 62 |
+
gap: 25px;
|
| 63 |
+
place-content: center;
|
| 64 |
+
place-items: center;
|
| 65 |
+
flex-grow: 1;
|
| 66 |
+
|
| 67 |
+
@media (max-width: 1024px) {
|
| 68 |
+
padding: 32px 20px 24px;
|
| 69 |
+
gap: 18px;
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
#next-steps {
|
| 74 |
+
display: flex;
|
| 75 |
+
border-top: 1px solid var(--border);
|
| 76 |
+
text-align: left;
|
| 77 |
+
|
| 78 |
+
& > div {
|
| 79 |
+
flex: 1 1 0;
|
| 80 |
+
padding: 32px;
|
| 81 |
+
@media (max-width: 1024px) {
|
| 82 |
+
padding: 24px 20px;
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.icon {
|
| 87 |
+
margin-bottom: 16px;
|
| 88 |
+
width: 22px;
|
| 89 |
+
height: 22px;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
@media (max-width: 1024px) {
|
| 93 |
+
flex-direction: column;
|
| 94 |
+
text-align: center;
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
#docs {
|
| 99 |
+
border-right: 1px solid var(--border);
|
| 100 |
+
|
| 101 |
+
@media (max-width: 1024px) {
|
| 102 |
+
border-right: none;
|
| 103 |
+
border-bottom: 1px solid var(--border);
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
#next-steps ul {
|
| 108 |
+
list-style: none;
|
| 109 |
+
padding: 0;
|
| 110 |
+
display: flex;
|
| 111 |
+
gap: 8px;
|
| 112 |
+
margin: 32px 0 0;
|
| 113 |
+
|
| 114 |
+
.logo {
|
| 115 |
+
height: 18px;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
a {
|
| 119 |
+
color: var(--text-h);
|
| 120 |
+
font-size: 16px;
|
| 121 |
+
border-radius: 6px;
|
| 122 |
+
background: var(--social-bg);
|
| 123 |
+
display: flex;
|
| 124 |
+
padding: 6px 12px;
|
| 125 |
+
align-items: center;
|
| 126 |
+
gap: 8px;
|
| 127 |
+
text-decoration: none;
|
| 128 |
+
transition: box-shadow 0.3s;
|
| 129 |
+
|
| 130 |
+
&:hover {
|
| 131 |
+
box-shadow: var(--shadow);
|
| 132 |
+
}
|
| 133 |
+
.button-icon {
|
| 134 |
+
height: 18px;
|
| 135 |
+
width: 18px;
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
@media (max-width: 1024px) {
|
| 140 |
+
margin-top: 20px;
|
| 141 |
+
flex-wrap: wrap;
|
| 142 |
+
justify-content: center;
|
| 143 |
+
|
| 144 |
+
li {
|
| 145 |
+
flex: 1 1 calc(50% - 8px);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
a {
|
| 149 |
+
width: 100%;
|
| 150 |
+
justify-content: center;
|
| 151 |
+
box-sizing: border-box;
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
#spacer {
|
| 157 |
+
height: 88px;
|
| 158 |
+
border-top: 1px solid var(--border);
|
| 159 |
+
@media (max-width: 1024px) {
|
| 160 |
+
height: 48px;
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.ticks {
|
| 165 |
+
position: relative;
|
| 166 |
+
width: 100%;
|
| 167 |
+
|
| 168 |
+
&::before,
|
| 169 |
+
&::after {
|
| 170 |
+
content: '';
|
| 171 |
+
position: absolute;
|
| 172 |
+
top: -4.5px;
|
| 173 |
+
border: 5px solid transparent;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
&::before {
|
| 177 |
+
left: 0;
|
| 178 |
+
border-left-color: var(--border);
|
| 179 |
+
}
|
| 180 |
+
&::after {
|
| 181 |
+
right: 0;
|
| 182 |
+
border-right-color: var(--border);
|
| 183 |
+
}
|
| 184 |
+
}
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
| 2 |
+
import { AuthProvider } from './context/AuthContext'
|
| 3 |
+
import { AppLayout } from './components/AppLayout'
|
| 4 |
+
import { LandingPage } from './pages/LandingPage'
|
| 5 |
+
import { AuthPage } from './pages/AuthPage'
|
| 6 |
+
import { PersonaFormWizard } from './pages/PersonaFormWizard'
|
| 7 |
+
import { PersonaChatPage } from './pages/PersonaChatPage'
|
| 8 |
+
import { AdvisorFormWizard } from './pages/AdvisorFormWizard'
|
| 9 |
+
import { AdvisorChatPage } from './pages/AdvisorChatPage'
|
| 10 |
+
|
| 11 |
+
export default function App() {
|
| 12 |
+
return (
|
| 13 |
+
<AuthProvider>
|
| 14 |
+
<BrowserRouter>
|
| 15 |
+
<Routes>
|
| 16 |
+
<Route element={<AppLayout />}>
|
| 17 |
+
<Route path="/" element={<LandingPage />} />
|
| 18 |
+
<Route path="/auth" element={<AuthPage />} />
|
| 19 |
+
<Route path="/create-persona/form" element={<PersonaFormWizard />} />
|
| 20 |
+
<Route path="/create-persona/chat" element={<PersonaChatPage />} />
|
| 21 |
+
<Route path="/create-advisor/form" element={<AdvisorFormWizard />} />
|
| 22 |
+
<Route path="/create-advisor/chat" element={<AdvisorChatPage />} />
|
| 23 |
+
</Route>
|
| 24 |
+
</Routes>
|
| 25 |
+
</BrowserRouter>
|
| 26 |
+
</AuthProvider>
|
| 27 |
+
)
|
| 28 |
+
}
|
frontend/src/api.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const base = import.meta.env.VITE_API_URL || ''
|
| 2 |
+
|
| 3 |
+
export function apiUrl(path: string): string {
|
| 4 |
+
if (path.startsWith('http')) return path
|
| 5 |
+
const p = path.startsWith('/') ? path : `/${path}`
|
| 6 |
+
return base ? `${base.replace(/\/$/, '')}${p}` : p
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export function getToken(): string | null {
|
| 10 |
+
return localStorage.getItem('authToken')
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export async function apiFetch(
|
| 14 |
+
path: string,
|
| 15 |
+
options: RequestInit & { auth?: boolean } = {},
|
| 16 |
+
): Promise<Response> {
|
| 17 |
+
const headers = new Headers(options.headers)
|
| 18 |
+
if (!headers.has('Content-Type') && options.body && typeof options.body === 'string') {
|
| 19 |
+
headers.set('Content-Type', 'application/json')
|
| 20 |
+
}
|
| 21 |
+
if (options.auth !== false) {
|
| 22 |
+
const t = getToken()
|
| 23 |
+
if (t) headers.set('Authorization', `Bearer ${t}`)
|
| 24 |
+
}
|
| 25 |
+
return fetch(apiUrl(path), { ...options, headers })
|
| 26 |
+
}
|
frontend/src/assets/hero.png
ADDED
|
Git LFS Details
|
frontend/src/assets/react.svg
ADDED
|
|
frontend/src/assets/vite.svg
ADDED
|
|
frontend/src/components/AccountModal.tsx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react'
|
| 2 |
+
import { apiFetch } from '../api'
|
| 3 |
+
import { useAuth } from '../context/AuthContext'
|
| 4 |
+
|
| 5 |
+
type Props = { onClose: () => void }
|
| 6 |
+
|
| 7 |
+
export function AccountModal({ onClose }: Props) {
|
| 8 |
+
const { user, refreshUser, signOut } = useAuth()
|
| 9 |
+
const [firstName, setFirstName] = useState(user?.firstName || '')
|
| 10 |
+
const [lastName, setLastName] = useState(user?.lastName || '')
|
| 11 |
+
const [email, setEmail] = useState(user?.email || '')
|
| 12 |
+
const [password, setPassword] = useState('')
|
| 13 |
+
const [err, setErr] = useState('')
|
| 14 |
+
|
| 15 |
+
const save = async () => {
|
| 16 |
+
setErr('')
|
| 17 |
+
const body: Record<string, string> = { firstName, lastName, email }
|
| 18 |
+
if (password.trim()) body.password = password
|
| 19 |
+
const r = await apiFetch('/api/auth/me', {
|
| 20 |
+
method: 'PATCH',
|
| 21 |
+
body: JSON.stringify(body),
|
| 22 |
+
})
|
| 23 |
+
if (!r.ok) {
|
| 24 |
+
setErr((await r.json().catch(() => ({}))).detail || 'Update failed')
|
| 25 |
+
return
|
| 26 |
+
}
|
| 27 |
+
await refreshUser()
|
| 28 |
+
onClose()
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const deleteAccount = async () => {
|
| 32 |
+
if (!confirm('Delete your account and server-stored profile? This cannot be undone.')) return
|
| 33 |
+
const r = await apiFetch('/api/auth/me', { method: 'DELETE' })
|
| 34 |
+
if (r.ok) {
|
| 35 |
+
signOut()
|
| 36 |
+
onClose()
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<div className="modal-overlay" role="dialog" aria-modal>
|
| 42 |
+
<div className="modal-box">
|
| 43 |
+
<h2>Account</h2>
|
| 44 |
+
{err && <p style={{ color: '#b91c1c', fontSize: '0.9rem' }}>{err}</p>}
|
| 45 |
+
<div className="field">
|
| 46 |
+
<label>First name</label>
|
| 47 |
+
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
| 48 |
+
</div>
|
| 49 |
+
<div className="field">
|
| 50 |
+
<label>Last name</label>
|
| 51 |
+
<input value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
| 52 |
+
</div>
|
| 53 |
+
<div className="field">
|
| 54 |
+
<label>Email</label>
|
| 55 |
+
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
| 56 |
+
</div>
|
| 57 |
+
<div className="field">
|
| 58 |
+
<label>New password (optional)</label>
|
| 59 |
+
<input
|
| 60 |
+
type="password"
|
| 61 |
+
autoComplete="new-password"
|
| 62 |
+
value={password}
|
| 63 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 64 |
+
placeholder="Leave blank to keep current"
|
| 65 |
+
/>
|
| 66 |
+
</div>
|
| 67 |
+
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
| 68 |
+
<button type="button" className="btn btn-secondary" onClick={deleteAccount}>
|
| 69 |
+
Delete account
|
| 70 |
+
</button>
|
| 71 |
+
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
| 72 |
+
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
| 73 |
+
Cancel
|
| 74 |
+
</button>
|
| 75 |
+
<button type="button" className="btn btn-primary" onClick={save}>
|
| 76 |
+
Save
|
| 77 |
+
</button>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
)
|
| 83 |
+
}
|
frontend/src/components/AppLayout.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Outlet, Link } from 'react-router-dom'
|
| 2 |
+
import { useState } from 'react'
|
| 3 |
+
import { UserMenu } from './UserMenu'
|
| 4 |
+
import { SaveWarningBar } from './SaveWarningBar'
|
| 5 |
+
import { ProfileModal } from './ProfileModal'
|
| 6 |
+
import { AccountModal } from './AccountModal'
|
| 7 |
+
import { ClearDataModal } from './ClearDataModal'
|
| 8 |
+
|
| 9 |
+
export function AppLayout() {
|
| 10 |
+
const [profile, setProfile] = useState(false)
|
| 11 |
+
const [account, setAccount] = useState(false)
|
| 12 |
+
const [clear, setClear] = useState(false)
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<div className="layout-main">
|
| 16 |
+
<header className="header-bar">
|
| 17 |
+
<Link to="/" style={{ fontWeight: 700, textDecoration: 'none', color: 'var(--text-primary)' }}>
|
| 18 |
+
CU Student AI Helper
|
| 19 |
+
</Link>
|
| 20 |
+
<nav>
|
| 21 |
+
<Link to="/">Home</Link>
|
| 22 |
+
<Link to="/create-persona/form">Persona</Link>
|
| 23 |
+
<Link to="/create-advisor/form">Advisor panel</Link>
|
| 24 |
+
</nav>
|
| 25 |
+
<UserMenu
|
| 26 |
+
onProfile={() => setProfile(true)}
|
| 27 |
+
onAccount={() => setAccount(true)}
|
| 28 |
+
onClearData={() => setClear(true)}
|
| 29 |
+
/>
|
| 30 |
+
</header>
|
| 31 |
+
<Outlet />
|
| 32 |
+
<SaveWarningBar />
|
| 33 |
+
{profile && <ProfileModal onClose={() => setProfile(false)} />}
|
| 34 |
+
{account && <AccountModal onClose={() => setAccount(false)} />}
|
| 35 |
+
{clear && <ClearDataModal onClose={() => setClear(false)} />}
|
| 36 |
+
</div>
|
| 37 |
+
)
|
| 38 |
+
}
|
frontend/src/components/ClearDataModal.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react'
|
| 2 |
+
import { apiFetch } from '../api'
|
| 3 |
+
|
| 4 |
+
const LS_KEYS = ['persona_draft_v1', 'advisor_draft_v1', 'persona_chat_v1', 'advisor_chat_v1']
|
| 5 |
+
|
| 6 |
+
type Props = { onClose: () => void }
|
| 7 |
+
|
| 8 |
+
export function ClearDataModal({ onClose }: Props) {
|
| 9 |
+
const [clearProfile, setClearProfile] = useState(true)
|
| 10 |
+
const [clearLocal, setClearLocal] = useState(true)
|
| 11 |
+
|
| 12 |
+
const run = async () => {
|
| 13 |
+
if (clearLocal) {
|
| 14 |
+
for (const k of LS_KEYS) localStorage.removeItem(k)
|
| 15 |
+
}
|
| 16 |
+
if (clearProfile) {
|
| 17 |
+
await apiFetch('/api/users/me/clear-data?profile=true', { method: 'POST' })
|
| 18 |
+
}
|
| 19 |
+
onClose()
|
| 20 |
+
window.location.reload()
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<div className="modal-overlay" role="dialog" aria-modal>
|
| 25 |
+
<div className="modal-box">
|
| 26 |
+
<h2>Clear User Data</h2>
|
| 27 |
+
<label style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem' }}>
|
| 28 |
+
<input type="checkbox" checked={clearLocal} onChange={(e) => setClearLocal(e.target.checked)} />
|
| 29 |
+
Clear local drafts (browser storage)
|
| 30 |
+
</label>
|
| 31 |
+
<label style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '1rem' }}>
|
| 32 |
+
<input type="checkbox" checked={clearProfile} onChange={(e) => setClearProfile(e.target.checked)} />
|
| 33 |
+
Clear saved profile on server (signed-in)
|
| 34 |
+
</label>
|
| 35 |
+
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
| 36 |
+
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
| 37 |
+
Cancel
|
| 38 |
+
</button>
|
| 39 |
+
<button type="button" className="btn btn-primary" onClick={run}>
|
| 40 |
+
Clear
|
| 41 |
+
</button>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
)
|
| 46 |
+
}
|
frontend/src/components/ProfileModal.tsx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react'
|
| 2 |
+
import { apiFetch } from '../api'
|
| 3 |
+
|
| 4 |
+
type Props = { onClose: () => void }
|
| 5 |
+
|
| 6 |
+
export function ProfileModal({ onClose }: Props) {
|
| 7 |
+
const [persona, setPersona] = useState('')
|
| 8 |
+
const [advisor, setAdvisor] = useState('')
|
| 9 |
+
const [loading, setLoading] = useState(true)
|
| 10 |
+
|
| 11 |
+
useEffect(() => {
|
| 12 |
+
let cancelled = false
|
| 13 |
+
;(async () => {
|
| 14 |
+
const r = await apiFetch('/api/users/me/profile')
|
| 15 |
+
if (!r.ok || cancelled) {
|
| 16 |
+
setLoading(false)
|
| 17 |
+
return
|
| 18 |
+
}
|
| 19 |
+
const d = await r.json()
|
| 20 |
+
setPersona(JSON.stringify(d.persona_draft || {}, null, 2))
|
| 21 |
+
setAdvisor(JSON.stringify(d.advisor_draft || {}, null, 2))
|
| 22 |
+
setLoading(false)
|
| 23 |
+
})()
|
| 24 |
+
return () => {
|
| 25 |
+
cancelled = true
|
| 26 |
+
}
|
| 27 |
+
}, [])
|
| 28 |
+
|
| 29 |
+
const save = async () => {
|
| 30 |
+
let persona_draft: object = {}
|
| 31 |
+
let advisor_draft: object = {}
|
| 32 |
+
try {
|
| 33 |
+
persona_draft = JSON.parse(persona || '{}')
|
| 34 |
+
} catch {
|
| 35 |
+
alert('Persona draft must be valid JSON')
|
| 36 |
+
return
|
| 37 |
+
}
|
| 38 |
+
try {
|
| 39 |
+
advisor_draft = JSON.parse(advisor || '{}')
|
| 40 |
+
} catch {
|
| 41 |
+
alert('Advisor draft must be valid JSON')
|
| 42 |
+
return
|
| 43 |
+
}
|
| 44 |
+
await apiFetch('/api/users/me/profile', {
|
| 45 |
+
method: 'PUT',
|
| 46 |
+
body: JSON.stringify({ persona_draft, advisor_draft }),
|
| 47 |
+
})
|
| 48 |
+
onClose()
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<div className="modal-overlay" role="dialog" aria-modal>
|
| 53 |
+
<div className="modal-box" style={{ maxWidth: 560 }}>
|
| 54 |
+
<h2>Profile</h2>
|
| 55 |
+
{loading ? (
|
| 56 |
+
<p>Loading…</p>
|
| 57 |
+
) : (
|
| 58 |
+
<>
|
| 59 |
+
<p style={{ fontSize: '0.88rem', color: 'var(--text-secondary)' }}>
|
| 60 |
+
Your saved wizard data (JSON). You can paste from the export/download tools.
|
| 61 |
+
</p>
|
| 62 |
+
<div className="field">
|
| 63 |
+
<label>Persona draft</label>
|
| 64 |
+
<textarea value={persona} onChange={(e) => setPersona(e.target.value)} rows={8} />
|
| 65 |
+
</div>
|
| 66 |
+
<div className="field">
|
| 67 |
+
<label>Advisor draft</label>
|
| 68 |
+
<textarea value={advisor} onChange={(e) => setAdvisor(e.target.value)} rows={8} />
|
| 69 |
+
</div>
|
| 70 |
+
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
| 71 |
+
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
| 72 |
+
Cancel
|
| 73 |
+
</button>
|
| 74 |
+
<button type="button" className="btn btn-primary" onClick={save}>
|
| 75 |
+
Save
|
| 76 |
+
</button>
|
| 77 |
+
</div>
|
| 78 |
+
</>
|
| 79 |
+
)}
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
)
|
| 83 |
+
}
|
frontend/src/components/SaveWarningBar.tsx
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Link } from 'react-router-dom'
|
| 2 |
+
import { useAuth } from '../context/AuthContext'
|
| 3 |
+
|
| 4 |
+
export function SaveWarningBar() {
|
| 5 |
+
const { user, loading } = useAuth()
|
| 6 |
+
if (loading || user) return null
|
| 7 |
+
return (
|
| 8 |
+
<div className="save-warning">
|
| 9 |
+
Create an account to more thoroughly save your work —{' '}
|
| 10 |
+
<Link to="/auth">Create an account</Link>
|
| 11 |
+
</div>
|
| 12 |
+
)
|
| 13 |
+
}
|
frontend/src/components/UserMenu.tsx
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useEffect } from 'react'
|
| 2 |
+
import { Link } from 'react-router-dom'
|
| 3 |
+
import { useAuth } from '../context/AuthContext'
|
| 4 |
+
|
| 5 |
+
type Props = {
|
| 6 |
+
onProfile: () => void
|
| 7 |
+
onAccount: () => void
|
| 8 |
+
onClearData: () => void
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function UserMenu({ onProfile, onAccount, onClearData }: Props) {
|
| 12 |
+
const { user, signOut } = useAuth()
|
| 13 |
+
const [open, setOpen] = useState(false)
|
| 14 |
+
const ref = useRef<HTMLDivElement>(null)
|
| 15 |
+
|
| 16 |
+
useEffect(() => {
|
| 17 |
+
function close(e: MouseEvent) {
|
| 18 |
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
| 19 |
+
}
|
| 20 |
+
document.addEventListener('mousedown', close)
|
| 21 |
+
return () => document.removeEventListener('mousedown', close)
|
| 22 |
+
}, [])
|
| 23 |
+
|
| 24 |
+
if (!user) {
|
| 25 |
+
return (
|
| 26 |
+
<Link className="btn btn-primary" to="/auth">
|
| 27 |
+
Sign in
|
| 28 |
+
</Link>
|
| 29 |
+
)
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<div className="user-menu-wrap" ref={ref}>
|
| 34 |
+
<button type="button" className="user-menu-trigger" onClick={() => setOpen((o) => !o)}>
|
| 35 |
+
{user.firstName || user.email}
|
| 36 |
+
</button>
|
| 37 |
+
{open && (
|
| 38 |
+
<div className="user-dropdown">
|
| 39 |
+
<button
|
| 40 |
+
type="button"
|
| 41 |
+
onClick={() => {
|
| 42 |
+
setOpen(false)
|
| 43 |
+
onProfile()
|
| 44 |
+
}}
|
| 45 |
+
>
|
| 46 |
+
Profile
|
| 47 |
+
</button>
|
| 48 |
+
<button
|
| 49 |
+
type="button"
|
| 50 |
+
onClick={() => {
|
| 51 |
+
setOpen(false)
|
| 52 |
+
onAccount()
|
| 53 |
+
}}
|
| 54 |
+
>
|
| 55 |
+
Account
|
| 56 |
+
</button>
|
| 57 |
+
<button
|
| 58 |
+
type="button"
|
| 59 |
+
onClick={() => {
|
| 60 |
+
setOpen(false)
|
| 61 |
+
onClearData()
|
| 62 |
+
}}
|
| 63 |
+
>
|
| 64 |
+
Clear User Data
|
| 65 |
+
</button>
|
| 66 |
+
<button type="button" className="danger" onClick={() => signOut()}>
|
| 67 |
+
Sign out
|
| 68 |
+
</button>
|
| 69 |
+
</div>
|
| 70 |
+
)}
|
| 71 |
+
</div>
|
| 72 |
+
)
|
| 73 |
+
}
|
frontend/src/context/AuthContext.tsx
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
createContext,
|
| 3 |
+
useCallback,
|
| 4 |
+
useContext,
|
| 5 |
+
useEffect,
|
| 6 |
+
useMemo,
|
| 7 |
+
useState,
|
| 8 |
+
} from 'react'
|
| 9 |
+
import type { ReactNode } from 'react'
|
| 10 |
+
import { apiFetch } from '../api'
|
| 11 |
+
|
| 12 |
+
export type User = {
|
| 13 |
+
id: string
|
| 14 |
+
email: string
|
| 15 |
+
firstName: string
|
| 16 |
+
lastName: string
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
type AuthCtx = {
|
| 20 |
+
user: User | null
|
| 21 |
+
token: string | null
|
| 22 |
+
loading: boolean
|
| 23 |
+
setAuth: (user: User, token: string) => void
|
| 24 |
+
signOut: () => void
|
| 25 |
+
refreshUser: () => Promise<void>
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const Ctx = createContext<AuthCtx | null>(null)
|
| 29 |
+
|
| 30 |
+
export function AuthProvider({ children }: { children: ReactNode }) {
|
| 31 |
+
const [user, setUser] = useState<User | null>(null)
|
| 32 |
+
const [token, setToken] = useState<string | null>(() => localStorage.getItem('authToken'))
|
| 33 |
+
const [loading, setLoading] = useState(true)
|
| 34 |
+
|
| 35 |
+
const refreshUser = useCallback(async () => {
|
| 36 |
+
const t = localStorage.getItem('authToken')
|
| 37 |
+
if (!t) {
|
| 38 |
+
setUser(null)
|
| 39 |
+
setToken(null)
|
| 40 |
+
return
|
| 41 |
+
}
|
| 42 |
+
const r = await apiFetch('/api/auth/me')
|
| 43 |
+
if (r.ok) {
|
| 44 |
+
const u = (await r.json()) as User
|
| 45 |
+
setUser(u)
|
| 46 |
+
setToken(t)
|
| 47 |
+
localStorage.setItem('user', JSON.stringify(u))
|
| 48 |
+
} else {
|
| 49 |
+
localStorage.removeItem('authToken')
|
| 50 |
+
localStorage.removeItem('user')
|
| 51 |
+
setUser(null)
|
| 52 |
+
setToken(null)
|
| 53 |
+
}
|
| 54 |
+
}, [])
|
| 55 |
+
|
| 56 |
+
useEffect(() => {
|
| 57 |
+
let cancelled = false
|
| 58 |
+
;(async () => {
|
| 59 |
+
const t = localStorage.getItem('authToken')
|
| 60 |
+
if (!t) {
|
| 61 |
+
if (!cancelled) setLoading(false)
|
| 62 |
+
return
|
| 63 |
+
}
|
| 64 |
+
const r = await apiFetch('/api/auth/me')
|
| 65 |
+
if (cancelled) return
|
| 66 |
+
if (r.ok) {
|
| 67 |
+
const u = (await r.json()) as User
|
| 68 |
+
setUser(u)
|
| 69 |
+
setToken(t)
|
| 70 |
+
localStorage.setItem('user', JSON.stringify(u))
|
| 71 |
+
} else {
|
| 72 |
+
localStorage.removeItem('authToken')
|
| 73 |
+
localStorage.removeItem('user')
|
| 74 |
+
setUser(null)
|
| 75 |
+
setToken(null)
|
| 76 |
+
}
|
| 77 |
+
setLoading(false)
|
| 78 |
+
})()
|
| 79 |
+
return () => {
|
| 80 |
+
cancelled = true
|
| 81 |
+
}
|
| 82 |
+
}, [])
|
| 83 |
+
|
| 84 |
+
const setAuth = useCallback((u: User, tok: string) => {
|
| 85 |
+
localStorage.setItem('authToken', tok)
|
| 86 |
+
localStorage.setItem('user', JSON.stringify(u))
|
| 87 |
+
setUser(u)
|
| 88 |
+
setToken(tok)
|
| 89 |
+
}, [])
|
| 90 |
+
|
| 91 |
+
const signOut = useCallback(() => {
|
| 92 |
+
localStorage.removeItem('authToken')
|
| 93 |
+
localStorage.removeItem('user')
|
| 94 |
+
setUser(null)
|
| 95 |
+
setToken(null)
|
| 96 |
+
}, [])
|
| 97 |
+
|
| 98 |
+
const value = useMemo(
|
| 99 |
+
() => ({ user, token, loading, setAuth, signOut, refreshUser }),
|
| 100 |
+
[user, token, loading, setAuth, signOut, refreshUser],
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
export function useAuth() {
|
| 107 |
+
const v = useContext(Ctx)
|
| 108 |
+
if (!v) throw new Error('useAuth outside AuthProvider')
|
| 109 |
+
return v
|
| 110 |
+
}
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
| 2 |
+
@import './styles/theme.css';
|
frontend/src/lib/drafts.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { apiFetch } from '../api'
|
| 2 |
+
|
| 3 |
+
const PERSONA_KEY = 'persona_draft_v1'
|
| 4 |
+
const ADVISOR_KEY = 'advisor_draft_v1'
|
| 5 |
+
|
| 6 |
+
export function loadPersonaDraft(): Record<string, unknown> {
|
| 7 |
+
try {
|
| 8 |
+
const s = localStorage.getItem(PERSONA_KEY)
|
| 9 |
+
return s ? JSON.parse(s) : {}
|
| 10 |
+
} catch {
|
| 11 |
+
return {}
|
| 12 |
+
}
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function savePersonaDraft(data: Record<string, unknown>) {
|
| 16 |
+
localStorage.setItem(PERSONA_KEY, JSON.stringify(data))
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function loadAdvisorDraft(): Record<string, unknown> {
|
| 20 |
+
try {
|
| 21 |
+
const s = localStorage.getItem(ADVISOR_KEY)
|
| 22 |
+
return s ? JSON.parse(s) : {}
|
| 23 |
+
} catch {
|
| 24 |
+
return {}
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export function saveAdvisorDraft(data: Record<string, unknown>) {
|
| 29 |
+
localStorage.setItem(ADVISOR_KEY, JSON.stringify(data))
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export async function syncPersonaToServerIfAuthed() {
|
| 33 |
+
const r = await apiFetch('/api/users/me/profile')
|
| 34 |
+
if (!r.ok) return
|
| 35 |
+
const cur = await r.json()
|
| 36 |
+
await apiFetch('/api/users/me/profile', {
|
| 37 |
+
method: 'PUT',
|
| 38 |
+
body: JSON.stringify({
|
| 39 |
+
persona_draft: { ...cur.persona_draft, ...loadPersonaDraft() },
|
| 40 |
+
}),
|
| 41 |
+
})
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export async function syncAdvisorToServerIfAuthed() {
|
| 45 |
+
const r = await apiFetch('/api/users/me/profile')
|
| 46 |
+
if (!r.ok) return
|
| 47 |
+
const cur = await r.json()
|
| 48 |
+
await apiFetch('/api/users/me/profile', {
|
| 49 |
+
method: 'PUT',
|
| 50 |
+
body: JSON.stringify({
|
| 51 |
+
advisor_draft: { ...cur.advisor_draft, ...loadAdvisorDraft() },
|
| 52 |
+
}),
|
| 53 |
+
})
|
| 54 |
+
}
|
frontend/src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react'
|
| 2 |
+
import { createRoot } from 'react-dom/client'
|
| 3 |
+
import './index.css'
|
| 4 |
+
import App from './App.tsx'
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')!).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
)
|
frontend/src/pages/AdvisorChatPage.tsx
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
| 2 |
+
import { Link } from 'react-router-dom'
|
| 3 |
+
import { apiFetch, apiUrl } from '../api'
|
| 4 |
+
|
| 5 |
+
type Msg = { role: 'user' | 'agent'; text: string }
|
| 6 |
+
|
| 7 |
+
const LS = 'advisor_chat_v1'
|
| 8 |
+
|
| 9 |
+
export function AdvisorChatPage() {
|
| 10 |
+
const [messages, setMessages] = useState<Msg[]>([])
|
| 11 |
+
const [input, setInput] = useState('')
|
| 12 |
+
const [loading, setLoading] = useState(false)
|
| 13 |
+
const [progress, setProgress] = useState(0)
|
| 14 |
+
const endRef = useRef<HTMLDivElement>(null)
|
| 15 |
+
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
| 16 |
+
const chunksRef = useRef<Blob[]>([])
|
| 17 |
+
const [recording, setRecording] = useState(false)
|
| 18 |
+
const [transcribing, setTranscribing] = useState(false)
|
| 19 |
+
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
try {
|
| 22 |
+
const raw = localStorage.getItem(LS)
|
| 23 |
+
if (raw) {
|
| 24 |
+
const j = JSON.parse(raw)
|
| 25 |
+
if (j.messages?.length) {
|
| 26 |
+
setMessages(j.messages)
|
| 27 |
+
return
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
} catch {
|
| 31 |
+
/* ignore */
|
| 32 |
+
}
|
| 33 |
+
setMessages([
|
| 34 |
+
{
|
| 35 |
+
role: 'agent',
|
| 36 |
+
text: "Let's design your advisor panel — a small group of AI advisors with different roles. What theme or domain should this panel focus on?",
|
| 37 |
+
},
|
| 38 |
+
])
|
| 39 |
+
}, [])
|
| 40 |
+
|
| 41 |
+
useEffect(() => {
|
| 42 |
+
localStorage.setItem(LS, JSON.stringify({ messages }))
|
| 43 |
+
endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
| 44 |
+
}, [messages])
|
| 45 |
+
|
| 46 |
+
const speak = (text: string) => {
|
| 47 |
+
if (!window.speechSynthesis) return
|
| 48 |
+
const u = new SpeechSynthesisUtterance(text)
|
| 49 |
+
window.speechSynthesis.cancel()
|
| 50 |
+
window.speechSynthesis.speak(u)
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const sendForTranscription = useCallback(async (blob: Blob) => {
|
| 54 |
+
if (blob.size < 80) return
|
| 55 |
+
setTranscribing(true)
|
| 56 |
+
try {
|
| 57 |
+
const form = new FormData()
|
| 58 |
+
form.append('audio', blob, 'rec.webm')
|
| 59 |
+
const r = await fetch(apiUrl('/api/transcribe'), { method: 'POST', body: form })
|
| 60 |
+
if (r.ok) {
|
| 61 |
+
const { text } = await r.json()
|
| 62 |
+
if (text?.trim()) setInput((prev) => (prev ? `${prev} ${text.trim()}` : text.trim()))
|
| 63 |
+
}
|
| 64 |
+
} finally {
|
| 65 |
+
setTranscribing(false)
|
| 66 |
+
}
|
| 67 |
+
}, [])
|
| 68 |
+
|
| 69 |
+
const toggleRec = async () => {
|
| 70 |
+
if (recording) {
|
| 71 |
+
mediaRecorderRef.current?.stop()
|
| 72 |
+
setRecording(false)
|
| 73 |
+
return
|
| 74 |
+
}
|
| 75 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
| 76 |
+
const mimeType = ['audio/webm;codecs=opus', 'audio/webm', ''].find(
|
| 77 |
+
(m) => !m || MediaRecorder.isTypeSupported(m),
|
| 78 |
+
)
|
| 79 |
+
const rec = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
|
| 80 |
+
chunksRef.current = []
|
| 81 |
+
rec.ondataavailable = (e) => {
|
| 82 |
+
if (e.data.size) chunksRef.current.push(e.data)
|
| 83 |
+
}
|
| 84 |
+
rec.onstop = () => {
|
| 85 |
+
stream.getTracks().forEach((t) => t.stop())
|
| 86 |
+
const blob = new Blob(chunksRef.current, { type: rec.mimeType || 'audio/webm' })
|
| 87 |
+
sendForTranscription(blob)
|
| 88 |
+
}
|
| 89 |
+
rec.start(400)
|
| 90 |
+
mediaRecorderRef.current = rec
|
| 91 |
+
setRecording(true)
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
const send = async () => {
|
| 95 |
+
if (!input.trim() || loading) return
|
| 96 |
+
const userText = input.trim()
|
| 97 |
+
setInput('')
|
| 98 |
+
setMessages((prev) => [...prev, { role: 'user', text: userText }])
|
| 99 |
+
setLoading(true)
|
| 100 |
+
try {
|
| 101 |
+
const history = messages.map((m) => ({ role: m.role, text: m.text }))
|
| 102 |
+
const r = await apiFetch('/api/advisor-chat/message', {
|
| 103 |
+
method: 'POST',
|
| 104 |
+
body: JSON.stringify({ history, user_input: userText }),
|
| 105 |
+
auth: false,
|
| 106 |
+
})
|
| 107 |
+
if (!r.ok) throw new Error(await r.text())
|
| 108 |
+
const data = await r.json()
|
| 109 |
+
setMessages((prev) => [...prev, { role: 'agent', text: data.reply }])
|
| 110 |
+
setProgress(data.progress ?? 0)
|
| 111 |
+
} catch {
|
| 112 |
+
setMessages((prev) => [
|
| 113 |
+
...prev,
|
| 114 |
+
{ role: 'agent', text: 'Something went wrong. Check the API key and try again.' },
|
| 115 |
+
])
|
| 116 |
+
} finally {
|
| 117 |
+
setLoading(false)
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
return (
|
| 122 |
+
<div className="wizard-layout theme-advisor">
|
| 123 |
+
<aside className="wizard-sidebar">
|
| 124 |
+
<h2>Advisor chat</h2>
|
| 125 |
+
<p style={{ fontSize: '0.8rem', opacity: 0.75, padding: '0 0.75rem' }}>
|
| 126 |
+
Progress: {progress}%
|
| 127 |
+
</p>
|
| 128 |
+
<p style={{ fontSize: '0.75rem', opacity: 0.6, padding: '0 0.75rem', marginTop: '1rem' }}>
|
| 129 |
+
<Link to="/" style={{ color: '#c4b5fd' }}>
|
| 130 |
+
← Home
|
| 131 |
+
</Link>
|
| 132 |
+
</p>
|
| 133 |
+
</aside>
|
| 134 |
+
<div className="wizard-main">
|
| 135 |
+
<h2 style={{ marginTop: 0 }}>Interactive Q&A</h2>
|
| 136 |
+
<div className="chat-panel">
|
| 137 |
+
<div className="chat-messages">
|
| 138 |
+
{messages.map((m, i) => (
|
| 139 |
+
<div key={i} className={`chat-bubble ${m.role}`}>
|
| 140 |
+
{m.text}
|
| 141 |
+
{m.role === 'agent' && (
|
| 142 |
+
<button
|
| 143 |
+
type="button"
|
| 144 |
+
className="icon-btn"
|
| 145 |
+
style={{ marginLeft: '0.5rem', fontSize: '0.75rem' }}
|
| 146 |
+
onClick={() => speak(m.text)}
|
| 147 |
+
>
|
| 148 |
+
Read aloud
|
| 149 |
+
</button>
|
| 150 |
+
)}
|
| 151 |
+
</div>
|
| 152 |
+
))}
|
| 153 |
+
{loading && <div className="chat-bubble agent">…</div>}
|
| 154 |
+
<div ref={endRef} />
|
| 155 |
+
</div>
|
| 156 |
+
<div className="chat-input-row">
|
| 157 |
+
<textarea
|
| 158 |
+
value={input}
|
| 159 |
+
onChange={(e) => setInput(e.target.value)}
|
| 160 |
+
placeholder={transcribing ? 'Transcribing…' : 'Type a message…'}
|
| 161 |
+
onKeyDown={(e) => {
|
| 162 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 163 |
+
e.preventDefault()
|
| 164 |
+
send()
|
| 165 |
+
}
|
| 166 |
+
}}
|
| 167 |
+
/>
|
| 168 |
+
<button
|
| 169 |
+
type="button"
|
| 170 |
+
className="icon-btn"
|
| 171 |
+
title="Voice input"
|
| 172 |
+
onClick={toggleRec}
|
| 173 |
+
disabled={transcribing}
|
| 174 |
+
>
|
| 175 |
+
{recording ? '■' : '🎤'}
|
| 176 |
+
</button>
|
| 177 |
+
<button type="button" className="btn btn-primary" onClick={send} disabled={loading}>
|
| 178 |
+
Send
|
| 179 |
+
</button>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
)
|
| 185 |
+
}
|
frontend/src/pages/AdvisorFormWizard.tsx
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react'
|
| 2 |
+
import { Link } from 'react-router-dom'
|
| 3 |
+
import { apiFetch } from '../api'
|
| 4 |
+
import { loadAdvisorDraft, saveAdvisorDraft, syncAdvisorToServerIfAuthed } from '../lib/drafts'
|
| 5 |
+
|
| 6 |
+
type AdvisorRow = { name: string; role: string }
|
| 7 |
+
|
| 8 |
+
const STEPS = ['Panel overview', 'Advisors', 'Review']
|
| 9 |
+
|
| 10 |
+
export function AdvisorFormWizard() {
|
| 11 |
+
const [step, setStep] = useState(0)
|
| 12 |
+
const [panelName, setPanelName] = useState('')
|
| 13 |
+
const [purpose, setPurpose] = useState('')
|
| 14 |
+
const [advisors, setAdvisors] = useState<AdvisorRow[]>([{ name: '', role: '' }])
|
| 15 |
+
const [submitMsg, setSubmitMsg] = useState('')
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
const d = loadAdvisorDraft() as Record<string, unknown>
|
| 19 |
+
if (d.panelName) setPanelName(String(d.panelName))
|
| 20 |
+
if (d.purpose) setPurpose(String(d.purpose))
|
| 21 |
+
if (Array.isArray(d.advisors)) setAdvisors(d.advisors as AdvisorRow[])
|
| 22 |
+
}, [])
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
saveAdvisorDraft({ panelName, purpose, advisors })
|
| 26 |
+
}, [panelName, purpose, advisors])
|
| 27 |
+
|
| 28 |
+
const addRow = () => setAdvisors((a) => [...a, { name: '', role: '' }])
|
| 29 |
+
const updateRow = (i: number, field: keyof AdvisorRow, v: string) => {
|
| 30 |
+
setAdvisors((rows) => rows.map((r, j) => (j === i ? { ...r, [field]: v } : r)))
|
| 31 |
+
}
|
| 32 |
+
const removeRow = (i: number) => setAdvisors((rows) => rows.filter((_, j) => j !== i))
|
| 33 |
+
|
| 34 |
+
const payload = () => ({ panelName, purpose, advisors })
|
| 35 |
+
|
| 36 |
+
const submit = async () => {
|
| 37 |
+
setSubmitMsg('')
|
| 38 |
+
const r = await apiFetch('/api/submit', {
|
| 39 |
+
method: 'POST',
|
| 40 |
+
body: JSON.stringify({ kind: 'advisor_form', data: payload(), email: null }),
|
| 41 |
+
})
|
| 42 |
+
const j = await r.json()
|
| 43 |
+
setSubmitMsg(j.message || 'Sent.')
|
| 44 |
+
await syncAdvisorToServerIfAuthed()
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const downloadJson = () => {
|
| 48 |
+
const blob = new Blob([JSON.stringify(payload(), null, 2)], { type: 'application/json' })
|
| 49 |
+
const a = document.createElement('a')
|
| 50 |
+
a.href = URL.createObjectURL(blob)
|
| 51 |
+
a.download = 'advisor-panel-draft.json'
|
| 52 |
+
a.click()
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
<div className="wizard-layout theme-advisor">
|
| 57 |
+
<aside className="wizard-sidebar">
|
| 58 |
+
<h2>Advisor panel</h2>
|
| 59 |
+
{STEPS.map((label, i) => (
|
| 60 |
+
<div
|
| 61 |
+
key={label}
|
| 62 |
+
className={`wizard-nav-item ${step === i ? 'active' : ''}`}
|
| 63 |
+
onClick={() => setStep(i)}
|
| 64 |
+
role="presentation"
|
| 65 |
+
>
|
| 66 |
+
{i + 1}. {label}
|
| 67 |
+
</div>
|
| 68 |
+
))}
|
| 69 |
+
<p style={{ fontSize: '0.75rem', opacity: 0.6, padding: '0 0.75rem', marginTop: '1.5rem' }}>
|
| 70 |
+
<Link to="/" style={{ color: '#c4b5fd' }}>
|
| 71 |
+
← Home
|
| 72 |
+
</Link>
|
| 73 |
+
</p>
|
| 74 |
+
</aside>
|
| 75 |
+
<div className="wizard-main">
|
| 76 |
+
{step === 0 && (
|
| 77 |
+
<>
|
| 78 |
+
<h2>Panel overview</h2>
|
| 79 |
+
<div className="field">
|
| 80 |
+
<label>Panel name</label>
|
| 81 |
+
<input value={panelName} onChange={(e) => setPanelName(e.target.value)} />
|
| 82 |
+
</div>
|
| 83 |
+
<div className="field">
|
| 84 |
+
<label>What decisions or questions should this panel help with?</label>
|
| 85 |
+
<textarea value={purpose} onChange={(e) => setPurpose(e.target.value)} />
|
| 86 |
+
</div>
|
| 87 |
+
</>
|
| 88 |
+
)}
|
| 89 |
+
{step === 1 && (
|
| 90 |
+
<>
|
| 91 |
+
<h2>Advisors</h2>
|
| 92 |
+
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
|
| 93 |
+
Add one row per advisor — e.g. "Skeptical reviewer" vs "Encouraging coach".
|
| 94 |
+
</p>
|
| 95 |
+
{advisors.map((row, i) => (
|
| 96 |
+
<div key={i} className="field" style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end' }}>
|
| 97 |
+
<div style={{ flex: 1 }}>
|
| 98 |
+
<label>Name / label</label>
|
| 99 |
+
<input value={row.name} onChange={(e) => updateRow(i, 'name', e.target.value)} />
|
| 100 |
+
</div>
|
| 101 |
+
<div style={{ flex: 1 }}>
|
| 102 |
+
<label>Role</label>
|
| 103 |
+
<input value={row.role} onChange={(e) => updateRow(i, 'role', e.target.value)} />
|
| 104 |
+
</div>
|
| 105 |
+
<button type="button" className="btn btn-secondary" onClick={() => removeRow(i)}>
|
| 106 |
+
Remove
|
| 107 |
+
</button>
|
| 108 |
+
</div>
|
| 109 |
+
))}
|
| 110 |
+
<button type="button" className="btn btn-secondary" onClick={addRow}>
|
| 111 |
+
+ Add advisor
|
| 112 |
+
</button>
|
| 113 |
+
</>
|
| 114 |
+
)}
|
| 115 |
+
{step === 2 && (
|
| 116 |
+
<>
|
| 117 |
+
<h2>Review</h2>
|
| 118 |
+
<pre className="card" style={{ whiteSpace: 'pre-wrap', fontSize: '0.85rem' }}>
|
| 119 |
+
{JSON.stringify(payload(), null, 2)}
|
| 120 |
+
</pre>
|
| 121 |
+
{submitMsg && <p>{submitMsg}</p>}
|
| 122 |
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '1rem' }}>
|
| 123 |
+
<button type="button" className="btn btn-primary" onClick={submit}>
|
| 124 |
+
Submit to project lead
|
| 125 |
+
</button>
|
| 126 |
+
<button type="button" className="btn btn-secondary" onClick={downloadJson}>
|
| 127 |
+
Download JSON backup
|
| 128 |
+
</button>
|
| 129 |
+
<a
|
| 130 |
+
className="btn btn-secondary"
|
| 131 |
+
href={`mailto:?subject=${encodeURIComponent('Advisor panel draft')}&body=${encodeURIComponent(JSON.stringify(payload(), null, 2))}`}
|
| 132 |
+
>
|
| 133 |
+
Email backup
|
| 134 |
+
</a>
|
| 135 |
+
</div>
|
| 136 |
+
</>
|
| 137 |
+
)}
|
| 138 |
+
<div style={{ marginTop: '1.5rem', display: 'flex', justifyContent: 'space-between' }}>
|
| 139 |
+
<button type="button" className="btn btn-secondary" disabled={step === 0} onClick={() => setStep((s) => s - 1)}>
|
| 140 |
+
Back
|
| 141 |
+
</button>
|
| 142 |
+
{step < STEPS.length - 1 ? (
|
| 143 |
+
<button type="button" className="btn btn-primary" onClick={() => setStep((s) => s + 1)}>
|
| 144 |
+
Next
|
| 145 |
+
</button>
|
| 146 |
+
) : null}
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
)
|
| 151 |
+
}
|
frontend/src/pages/AuthPage.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react'
|
| 2 |
+
import { Link, useNavigate } from 'react-router-dom'
|
| 3 |
+
import { apiFetch } from '../api'
|
| 4 |
+
import { useAuth } from '../context/AuthContext'
|
| 5 |
+
|
| 6 |
+
export function AuthPage() {
|
| 7 |
+
const { setAuth } = useAuth()
|
| 8 |
+
const nav = useNavigate()
|
| 9 |
+
const [mode, setMode] = useState<'login' | 'register'>('login')
|
| 10 |
+
const [email, setEmail] = useState('')
|
| 11 |
+
const [password, setPassword] = useState('')
|
| 12 |
+
const [firstName, setFirstName] = useState('')
|
| 13 |
+
const [lastName, setLastName] = useState('')
|
| 14 |
+
const [err, setErr] = useState('')
|
| 15 |
+
|
| 16 |
+
const submit = async (e: React.FormEvent) => {
|
| 17 |
+
e.preventDefault()
|
| 18 |
+
setErr('')
|
| 19 |
+
const path = mode === 'login' ? '/api/auth/login' : '/api/auth/register'
|
| 20 |
+
const body =
|
| 21 |
+
mode === 'login'
|
| 22 |
+
? { email, password }
|
| 23 |
+
: { email, password, firstName, lastName }
|
| 24 |
+
const r = await apiFetch(path, {
|
| 25 |
+
method: 'POST',
|
| 26 |
+
body: JSON.stringify(body),
|
| 27 |
+
auth: false,
|
| 28 |
+
})
|
| 29 |
+
if (!r.ok) {
|
| 30 |
+
const j = await r.json().catch(() => ({}))
|
| 31 |
+
setErr(j.detail || 'Request failed')
|
| 32 |
+
return
|
| 33 |
+
}
|
| 34 |
+
const { access_token } = await r.json()
|
| 35 |
+
const me = await apiFetch('/api/auth/me', {
|
| 36 |
+
headers: { Authorization: `Bearer ${access_token}` },
|
| 37 |
+
})
|
| 38 |
+
if (!me.ok) {
|
| 39 |
+
setErr('Could not load profile')
|
| 40 |
+
return
|
| 41 |
+
}
|
| 42 |
+
const user = await me.json()
|
| 43 |
+
setAuth(user, access_token)
|
| 44 |
+
nav('/')
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<main className="section" style={{ maxWidth: 420 }}>
|
| 49 |
+
<h1>{mode === 'login' ? 'Sign in' : 'Create an account'}</h1>
|
| 50 |
+
<p style={{ color: 'var(--text-secondary)' }}>
|
| 51 |
+
Optional — an account saves drafts on the server. You can still explore without one.
|
| 52 |
+
</p>
|
| 53 |
+
{err && <p style={{ color: '#b91c1c' }}>{err}</p>}
|
| 54 |
+
<form onSubmit={submit}>
|
| 55 |
+
{mode === 'register' && (
|
| 56 |
+
<>
|
| 57 |
+
<div className="field">
|
| 58 |
+
<label>First name</label>
|
| 59 |
+
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} required />
|
| 60 |
+
</div>
|
| 61 |
+
<div className="field">
|
| 62 |
+
<label>Last name</label>
|
| 63 |
+
<input value={lastName} onChange={(e) => setLastName(e.target.value)} required />
|
| 64 |
+
</div>
|
| 65 |
+
</>
|
| 66 |
+
)}
|
| 67 |
+
<div className="field">
|
| 68 |
+
<label>Email</label>
|
| 69 |
+
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
| 70 |
+
</div>
|
| 71 |
+
<div className="field">
|
| 72 |
+
<label>Password (min 8 characters)</label>
|
| 73 |
+
<input
|
| 74 |
+
type="password"
|
| 75 |
+
value={password}
|
| 76 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 77 |
+
minLength={8}
|
| 78 |
+
required
|
| 79 |
+
/>
|
| 80 |
+
</div>
|
| 81 |
+
<button type="submit" className="btn btn-primary" style={{ width: '100%' }}>
|
| 82 |
+
{mode === 'login' ? 'Sign in' : 'Register'}
|
| 83 |
+
</button>
|
| 84 |
+
</form>
|
| 85 |
+
<p style={{ marginTop: '1rem' }}>
|
| 86 |
+
{mode === 'login' ? (
|
| 87 |
+
<>
|
| 88 |
+
No account?{' '}
|
| 89 |
+
<button type="button" className="btn btn-ghost" onClick={() => setMode('register')}>
|
| 90 |
+
Register
|
| 91 |
+
</button>
|
| 92 |
+
</>
|
| 93 |
+
) : (
|
| 94 |
+
<>
|
| 95 |
+
Already have an account?{' '}
|
| 96 |
+
<button type="button" className="btn btn-ghost" onClick={() => setMode('login')}>
|
| 97 |
+
Sign in
|
| 98 |
+
</button>
|
| 99 |
+
</>
|
| 100 |
+
)}
|
| 101 |
+
</p>
|
| 102 |
+
<p>
|
| 103 |
+
<Link to="/">← Back home</Link>
|
| 104 |
+
</p>
|
| 105 |
+
</main>
|
| 106 |
+
)
|
| 107 |
+
}
|
frontend/src/pages/LandingPage.tsx
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const CU_DEMO = 'https://undergraddemo.neonaiservices2.com/'
|
| 2 |
+
|
| 3 |
+
export function LandingPage() {
|
| 4 |
+
return (
|
| 5 |
+
<main>
|
| 6 |
+
<section className="landing-hero">
|
| 7 |
+
<h1>Looking for students to develop AI advisory panels</h1>
|
| 8 |
+
<p className="lead">
|
| 9 |
+
Join a <strong>non-coding</strong> Collaborative Conversational AI (CCAI) project with Neon.ai and CU:
|
| 10 |
+
you bring subject-matter passion — we help you shape it into a real AI experience you can list on your
|
| 11 |
+
resume and portfolio. No traditional programming required; you will name your panel, design personas and
|
| 12 |
+
advisors, and launch a working demo you can show employers.
|
| 13 |
+
</p>
|
| 14 |
+
</section>
|
| 15 |
+
|
| 16 |
+
<div className="benefit-grid">
|
| 17 |
+
<a className="benefit-card" href="#get-paid">
|
| 18 |
+
<h3>Get paid</h3>
|
| 19 |
+
<p>Paid project opportunities while you learn how modern AI assistants are built.</p>
|
| 20 |
+
</a>
|
| 21 |
+
<a className="benefit-card" href="#show-knowledge">
|
| 22 |
+
<h3>Show off your subject knowledge</h3>
|
| 23 |
+
<p>Your major and interests become the brain of an AI people can actually talk to.</p>
|
| 24 |
+
</a>
|
| 25 |
+
<a className="benefit-card" href="#portfolio">
|
| 26 |
+
<h3>Build your AI portfolio & resume</h3>
|
| 27 |
+
<p>Ship a tangible demo: a panel or persona you can link to in applications and interviews.</p>
|
| 28 |
+
</a>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<section className="section">
|
| 32 |
+
<h2>See the CU Undergraduate Advisory Panel at work</h2>
|
| 33 |
+
<p>
|
| 34 |
+
<a href={CU_DEMO} target="_blank" rel="noreferrer">
|
| 35 |
+
Click here to open the live demo
|
| 36 |
+
</a>{' '}
|
| 37 |
+
— the course advisor brings professor ratings together with course schedule and availability information
|
| 38 |
+
from CU so you can plan your next semester in one conversational place.
|
| 39 |
+
</p>
|
| 40 |
+
<div className="placeholder-shot">Screenshot placeholder — advisor panel in action</div>
|
| 41 |
+
<div className="placeholder-shot">Screenshot placeholder — schedule & ratings together</div>
|
| 42 |
+
</section>
|
| 43 |
+
|
| 44 |
+
<section className="section" id="stages">
|
| 45 |
+
<h2>How the project flows</h2>
|
| 46 |
+
<p>Each step links to more detail below. You will iterate with mentors and use the tools on this site.</p>
|
| 47 |
+
<div className="steps-row">
|
| 48 |
+
<a href="#stage-name">Name your panel</a>
|
| 49 |
+
<a href="#stage-personas">Design your personas</a>
|
| 50 |
+
<a href="#stage-advisors">Configure advisors</a>
|
| 51 |
+
<a href="#stage-launch">Test & launch</a>
|
| 52 |
+
</div>
|
| 53 |
+
</section>
|
| 54 |
+
|
| 55 |
+
<section className="section" id="stage-name">
|
| 56 |
+
<h3>Name your panel</h3>
|
| 57 |
+
<p>
|
| 58 |
+
Pick a clear theme — e.g. "CU Biology peer mentors" or "First-gen student success."
|
| 59 |
+
The name anchors everything else: who the AI speaks as, and who it helps.
|
| 60 |
+
</p>
|
| 61 |
+
</section>
|
| 62 |
+
<section className="section" id="stage-personas">
|
| 63 |
+
<h3>Design your personas</h3>
|
| 64 |
+
<p>
|
| 65 |
+
Personas define voice, tone, and boundaries: what the AI should sound like, what topics it covers, and
|
| 66 |
+
what it should defer to a human for. Use our guided form or conversational Q&A.
|
| 67 |
+
</p>
|
| 68 |
+
</section>
|
| 69 |
+
<section className="section" id="stage-advisors">
|
| 70 |
+
<h3>Configure advisors</h3>
|
| 71 |
+
<p>
|
| 72 |
+
An advisor panel combines multiple viewpoints — for example a "strict planner" and a
|
| 73 |
+
"encouraging coach." You define roles so the panel feels balanced and useful.
|
| 74 |
+
</p>
|
| 75 |
+
</section>
|
| 76 |
+
<section className="section" id="stage-launch">
|
| 77 |
+
<h3>Test & press go</h3>
|
| 78 |
+
<p>
|
| 79 |
+
Try real questions, refine prompts with feedback, then publish your demo link for class and recruiters.
|
| 80 |
+
</p>
|
| 81 |
+
</section>
|
| 82 |
+
|
| 83 |
+
<section className="cta-block theme-persona" id="create-persona">
|
| 84 |
+
<h2>Create a Persona</h2>
|
| 85 |
+
<p>Structured form or a friendly chat that gathers the same information — your choice.</p>
|
| 86 |
+
<div className="cta-buttons">
|
| 87 |
+
<a className="btn btn-primary" href="/create-persona/form">
|
| 88 |
+
Fill out the form
|
| 89 |
+
</a>
|
| 90 |
+
<a className="btn btn-secondary" href="/create-persona/chat">
|
| 91 |
+
Interactive Q&A
|
| 92 |
+
</a>
|
| 93 |
+
</div>
|
| 94 |
+
</section>
|
| 95 |
+
|
| 96 |
+
<section className="cta-block theme-advisor" id="create-advisor">
|
| 97 |
+
<h2>Create an Advisor Panel</h2>
|
| 98 |
+
<p>Describe your multi-advisor setup — form or guided conversation.</p>
|
| 99 |
+
<div className="cta-buttons">
|
| 100 |
+
<a className="btn btn-primary" href="/create-advisor/form">
|
| 101 |
+
Fill out the form
|
| 102 |
+
</a>
|
| 103 |
+
<a className="btn btn-secondary" href="/create-advisor/chat">
|
| 104 |
+
Interactive Q&A
|
| 105 |
+
</a>
|
| 106 |
+
</div>
|
| 107 |
+
</section>
|
| 108 |
+
|
| 109 |
+
<section className="section" id="get-paid">
|
| 110 |
+
<h2>Get paid</h2>
|
| 111 |
+
<p>
|
| 112 |
+
Selected student contributors may receive compensation for milestones (e.g. completed persona, working
|
| 113 |
+
demo, final presentation). Details depend on the semester program — ask your faculty lead. The important
|
| 114 |
+
part: you are treated as a collaborator, not free labor for a black-box tool you did not help shape.
|
| 115 |
+
</p>
|
| 116 |
+
</section>
|
| 117 |
+
<section className="section" id="show-knowledge">
|
| 118 |
+
<h2>Show off your subject knowledge</h2>
|
| 119 |
+
<p>
|
| 120 |
+
Employers care that you can explain ideas clearly and care about users. Here, your expertise becomes the
|
| 121 |
+
curriculum the AI studies: readings you care about, jargon your community uses, and pitfalls only someone
|
| 122 |
+
in your field would flag.
|
| 123 |
+
</p>
|
| 124 |
+
</section>
|
| 125 |
+
<section className="section" id="portfolio">
|
| 126 |
+
<h2>Build your AI portfolio & resume</h2>
|
| 127 |
+
<p>
|
| 128 |
+
You leave with artifacts you can show: a live or recorded demo, a short write-up of design choices, and a
|
| 129 |
+
story for interviews — "I helped define an AI advisory panel for [audience] focusing on [topic]."
|
| 130 |
+
That is legitimate AI product experience without requiring you to maintain Kubernetes.
|
| 131 |
+
</p>
|
| 132 |
+
</section>
|
| 133 |
+
|
| 134 |
+
<footer className="footer-site">
|
| 135 |
+
<p>
|
| 136 |
+
Built with Neon.ai's collaborative conversational AI ideas — private, customizable AI personalities
|
| 137 |
+
you can own. Learn more at{' '}
|
| 138 |
+
<a href="https://www.neon.ai/" target="_blank" rel="noreferrer">
|
| 139 |
+
neon.ai
|
| 140 |
+
</a>
|
| 141 |
+
.
|
| 142 |
+
</p>
|
| 143 |
+
</footer>
|
| 144 |
+
</main>
|
| 145 |
+
)
|
| 146 |
+
}
|