NeonClary commited on
Commit
cb6dcc3
·
0 Parent(s):

Initial CU Student AI Project Helper: FastAPI, Gemini, Mongo, React SPA

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +25 -0
  2. .gitattributes +6 -0
  3. .gitignore +12 -0
  4. Dockerfile +19 -0
  5. README.md +78 -0
  6. backend/app/__init__.py +1 -0
  7. backend/app/auth_jwt.py +74 -0
  8. backend/app/config.py +47 -0
  9. backend/app/db.py +16 -0
  10. backend/app/main.py +59 -0
  11. backend/app/routers/__init__.py +1 -0
  12. backend/app/routers/advisor_chat.py +35 -0
  13. backend/app/routers/auth.py +73 -0
  14. backend/app/routers/persona_chat.py +35 -0
  15. backend/app/routers/profile.py +44 -0
  16. backend/app/routers/submit.py +101 -0
  17. backend/app/routers/transcribe.py +105 -0
  18. backend/app/schemas.py +63 -0
  19. backend/app/services/gemini.py +101 -0
  20. backend/requirements.txt +12 -0
  21. docker-compose.yml +23 -0
  22. frontend/.env.development +2 -0
  23. frontend/.gitignore +24 -0
  24. frontend/README.md +73 -0
  25. frontend/eslint.config.js +23 -0
  26. frontend/index.html +12 -0
  27. frontend/package-lock.json +0 -0
  28. frontend/package.json +31 -0
  29. frontend/public/favicon.svg +1 -0
  30. frontend/public/icons.svg +24 -0
  31. frontend/src/App.css +184 -0
  32. frontend/src/App.tsx +28 -0
  33. frontend/src/api.ts +26 -0
  34. frontend/src/assets/hero.png +3 -0
  35. frontend/src/assets/react.svg +1 -0
  36. frontend/src/assets/vite.svg +1 -0
  37. frontend/src/components/AccountModal.tsx +83 -0
  38. frontend/src/components/AppLayout.tsx +38 -0
  39. frontend/src/components/ClearDataModal.tsx +46 -0
  40. frontend/src/components/ProfileModal.tsx +83 -0
  41. frontend/src/components/SaveWarningBar.tsx +13 -0
  42. frontend/src/components/UserMenu.tsx +73 -0
  43. frontend/src/context/AuthContext.tsx +110 -0
  44. frontend/src/index.css +2 -0
  45. frontend/src/lib/drafts.ts +54 -0
  46. frontend/src/main.tsx +10 -0
  47. frontend/src/pages/AdvisorChatPage.tsx +185 -0
  48. frontend/src/pages/AdvisorFormWizard.tsx +151 -0
  49. frontend/src/pages/AuthPage.tsx +107 -0
  50. 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

  • SHA256: 72a860570eddf1dd9988f26c7106c67be286bc9f2fd3303c465ce87edb1ae6cd
  • Pointer size: 130 Bytes
  • Size of remote file: 44.9 kB
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&amp;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. &quot;Skeptical reviewer&quot; vs &quot;Encouraging coach&quot;.
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 &amp; 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 &amp; 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 &amp; 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. &quot;CU Biology peer mentors&quot; or &quot;First-gen student success.&quot;
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&amp;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 &quot;strict planner&quot; and a
73
+ &quot;encouraging coach.&quot; You define roles so the panel feels balanced and useful.
74
+ </p>
75
+ </section>
76
+ <section className="section" id="stage-launch">
77
+ <h3>Test &amp; 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&amp;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&amp;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 &amp; 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 — &quot;I helped define an AI advisory panel for [audience] focusing on [topic].&quot;
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&apos;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
+ }