TeachChat: model selector, proxy routing fix, titan seed cleanup

- teach.py: use _proxy_model() + pass api_key/api_base from settings (fixes LiteLLM provider error for openrouter/bedrock models)
- teach.py: accept model_id in ChatRequest so frontend can select model
- main.py: remove titan-embed-v2 from general seed, auto-delete legacy entry on startup
- main.py: kill stale idle-in-transaction DB connections at startup to prevent DDL lock hangs
- main.py: set lock_timeout=10s on DDL connection as fast-fail safety net
- TeachChat.jsx: fetch /teach/models, show selector dropdown in header when >1 model available

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-04 00:37:35 +02:00
parent ecc4f64ad5
commit 975a31fb01
17 changed files with 1129 additions and 65 deletions

View file

@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles
from app.config import settings from app.config import settings
from app.database import engine, Base, SessionLocal from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach
from app.utils.auth import get_password_hash from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler from app.utils.scheduler import start_scheduler, stop_scheduler
@ -65,11 +65,19 @@ def seed_default_models():
AIModelConfig(name="Claude Haiku 4.5", model_id="claude-haiku-4.5", task="extraction", is_active=True, is_default=True), AIModelConfig(name="Claude Haiku 4.5", model_id="claude-haiku-4.5", task="extraction", is_active=True, is_default=True),
AIModelConfig(name="Claude Sonnet 4.6", model_id="claude-sonnet-4.6", task="extraction", is_active=True, is_default=False), AIModelConfig(name="Claude Sonnet 4.6", model_id="claude-sonnet-4.6", task="extraction", is_active=True, is_default=False),
AIModelConfig(name="Gemini 2.5 Flash", model_id="gemini-2.5-flash", task="extraction", is_active=True, is_default=False), AIModelConfig(name="Gemini 2.5 Flash", model_id="gemini-2.5-flash", task="extraction", is_active=True, is_default=False),
AIModelConfig(name="Titan Embed v2 (Embedding)", model_id="titan-embed-v2", task="general", is_active=True, is_default=False),
] ]
db.add_all(defaults) db.add_all(defaults)
db.commit() db.commit()
# Clean up legacy titan-embed-v2 "general" entry (was mistakenly seeded)
titan = db.query(AIModelConfig).filter(
AIModelConfig.model_id == "titan-embed-v2",
AIModelConfig.task == "general",
).first()
if titan:
db.delete(titan)
db.commit()
# Always ensure OpenAI TTS voice models exist (idempotent) # Always ensure OpenAI TTS voice models exist (idempotent)
tts_voices = [ tts_voices = [
# OpenAI (work with OPENAI_API_KEY) # OpenAI (work with OPENAI_API_KEY)
@ -123,7 +131,23 @@ def setup_pgvector():
from sqlalchemy import text from sqlalchemy import text
# Import new models so create_all picks them up # Import new models so create_all picks them up
from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa
# Kill stale idle-in-transaction connections from previous killed startups.
# They hold DDL locks and cause ALTER TABLE below to hang indefinitely.
with engine.connect() as conn: with engine.connect() as conn:
conn.execute(text("""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = current_database()
AND state = 'idle in transaction'
AND query_start < NOW() - INTERVAL '30 seconds'
AND pid != pg_backend_pid()
"""))
conn.commit()
with engine.connect() as conn:
# Fail fast instead of hanging if a lock can't be acquired within 10s.
conn.execute(text("SET lock_timeout = '10s'"))
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS embedding vector(1024)")) conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS embedding vector(1024)"))
conn.execute(text(""" conn.execute(text("""
@ -292,6 +316,7 @@ app.include_router(categories.router, prefix="/api/categories", tags=["categorie
app.include_router(questions.router, prefix="/api/questions", tags=["questions"]) app.include_router(questions.router, prefix="/api/questions", tags=["questions"])
app.include_router(question_categories.router, prefix="/api/question-categories", tags=["question-categories"]) app.include_router(question_categories.router, prefix="/api/question-categories", tags=["question-categories"])
app.include_router(favorites.router, prefix="/api/favorites", tags=["favorites"]) app.include_router(favorites.router, prefix="/api/favorites", tags=["favorites"])
app.include_router(teach.router, prefix="/api/teach", tags=["teach"])
@app.get("/api/health") @app.get("/api/health")

View file

@ -1,6 +1,6 @@
from pgvector.sqlalchemy import Vector from pgvector.sqlalchemy import Vector
from sqlalchemy import Column, Integer, String, Text, JSON, ForeignKey from sqlalchemy import Column, Integer, String, Text, JSON, ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship, deferred
from app.config import settings from app.config import settings
from app.database import Base from app.database import Base
@ -23,7 +23,7 @@ class Question(Base):
explanation = Column(Text, nullable=True) explanation = Column(Text, nullable=True)
page_reference = Column(Integer, nullable=True) page_reference = Column(Integer, nullable=True)
image_path = Column(String, nullable=True) image_path = Column(String, nullable=True)
embedding = Column(Vector(1024), nullable=True) # semantic search vector embedding = deferred(Column(Vector(1024), nullable=True)) # semantic search vector — deferred: not loaded in standard queries
question_category = relationship("QuestionCategory", back_populates="questions", question_category = relationship("QuestionCategory", back_populates="questions",
foreign_keys=[question_category_id]) foreign_keys=[question_category_id])

View file

@ -143,8 +143,8 @@ def create_model(
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin: User = Depends(require_admin), admin: User = Depends(require_admin),
): ):
if data.task not in ("extraction", "tts", "general"): if data.task not in ("extraction", "tts", "general", "teach"):
raise HTTPException(status_code=400, detail="Task must be extraction, tts, or general") raise HTTPException(status_code=400, detail="Task must be extraction, tts, general, or teach")
if data.is_default: if data.is_default:
db.query(AIModelConfig).filter( db.query(AIModelConfig).filter(
@ -201,6 +201,124 @@ def delete_model(
db.commit() db.commit()
@router.post("/models/{model_id}/test")
def test_model(
model_id: int,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
"""Send a simple test completion to verify an LLM model is reachable.
TTS models are previewed via /tts/speak instead."""
model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
if not model:
raise HTTPException(status_code=404, detail="Model config not found")
if model.task == "tts":
raise HTTPException(status_code=400, detail="Use the Preview button to test TTS voices — it plays audio directly.")
try:
import litellm
from app.services.ai_service import _proxy_model
use_model = _proxy_model(model.model_id)
kwargs = {
"model": use_model,
"messages": [{"role": "user", "content": "Reply with only the word: OK"}],
"max_tokens": 10,
}
if model.api_key:
kwargs["api_key"] = model.api_key
elif settings.LITELLM_API_KEY:
kwargs["api_key"] = settings.LITELLM_API_KEY
if settings.LITELLM_API_BASE:
kwargs["api_base"] = settings.LITELLM_API_BASE
response = litellm.completion(**kwargs)
reply = response.choices[0].message.content.strip()
return {"message": f"{model.model_id}{reply!r}"}
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/tts/voices")
def search_tts_voices(
provider: str,
api_key: str | None = None,
region: str | None = None,
admin: User = Depends(require_admin),
):
"""Discover available TTS voices from ElevenLabs, AWS Polly, or return OpenAI hardcoded list."""
if provider == "elevenlabs":
key = api_key or settings.ELEVENLABS_API_KEY
if not key:
raise HTTPException(status_code=400, detail="ElevenLabs API key required (set ELEVENLABS_API_KEY in .env or enter it above)")
try:
resp = httpx.get(
"https://api.elevenlabs.io/v1/voices",
headers={"xi-api-key": key},
timeout=15,
)
resp.raise_for_status()
voices = resp.json().get("voices", [])
return {"voices": [
{
"model_id": f"elevenlabs/{v['voice_id']}",
"name": v["name"],
"labels": v.get("labels", {}),
}
for v in sorted(voices, key=lambda x: x["name"])
]}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=f"ElevenLabs API error: {e}")
elif provider == "polly":
access_key = api_key or settings.AWS_ACCESS_KEY_ID
secret_key = settings.AWS_SECRET_ACCESS_KEY
aws_region = region or settings.AWS_REGION or "us-east-1"
if not access_key or not secret_key:
raise HTTPException(
status_code=400,
detail="AWS credentials required — set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in .env",
)
try:
import boto3
polly = boto3.client(
"polly",
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=aws_region,
)
resp = polly.describe_voices(Engine="neural")
voices = resp.get("Voices", [])
return {"voices": [
{
"model_id": f"polly/{v['Id']}",
"name": f"{v['Name']}{v['LanguageName']} ({v.get('Gender', '')})",
"labels": {"gender": v.get("Gender", ""), "language": v.get("LanguageName", "")},
}
for v in sorted(voices, key=lambda x: x["Name"])
]}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=f"AWS Polly error: {e}")
elif provider == "openai":
voices = []
for model_name in ["tts-1", "tts-1-hd"]:
for voice in ["alloy", "ash", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer"]:
voices.append({
"model_id": f"{model_name}:{voice}",
"name": f"{model_name} · {voice}",
"labels": {"model": model_name, "voice": voice},
})
return {"voices": voices}
else:
raise HTTPException(status_code=400, detail=f"Unknown provider '{provider}'. Valid: elevenlabs, polly, openai")
# --- System Settings --- # --- System Settings ---
@router.get("/settings") @router.get("/settings")
@ -262,3 +380,13 @@ def test_embedding(admin: User = Depends(require_admin)):
if result is None: if result is None:
raise HTTPException(status_code=500, detail=f"Embedding failed for model: {model}") raise HTTPException(status_code=500, detail=f"Embedding failed for model: {model}")
return {"model": model, "dimensions": len(result), "status": "ok"} return {"model": model, "dimensions": len(result), "status": "ok"}
@router.post("/embedding/regenerate")
def regenerate_embeddings(admin: User = Depends(require_admin)):
"""Queue a background Celery task to regenerate all question embeddings."""
import uuid
from app.tasks.quiz_tasks import regenerate_embeddings as regen_task
job_id = str(uuid.uuid4())
regen_task.delay(job_id, admin.id)
return {"job_id": job_id, "message": "Regeneration started — progress visible in the Jobs badge."}

View file

@ -404,6 +404,7 @@ def get_quiz_history(
for a in completed: for a in completed:
pct = round((a.score / a.total_questions * 100) if a.total_questions > 0 else 0, 1) pct = round((a.score / a.total_questions * 100) if a.total_questions > 0 else 0, 1)
by_quiz[a.quiz_id].append({ by_quiz[a.quiz_id].append({
"attempt_id": a.id,
"date": a.completed_at.isoformat(), "date": a.completed_at.isoformat(),
"percentage": pct, "percentage": pct,
"score": a.score, "score": a.score,

View file

@ -0,0 +1,169 @@
"""Teach chat endpoint — AI tutor for study mode questions."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.question import Question
from app.models.ai_model_config import AIModelConfig
from app.models.user import User
from app.utils.auth import get_current_user
router = APIRouter()
class ChatMessage(BaseModel):
role: str # "user" | "assistant"
content: str
class ChatRequest(BaseModel):
question_id: int
messages: list[ChatMessage]
model_id: int | None = None # AIModelConfig.id — if None, use default
def _get_teach_model(db: Session, model_config_id: int | None = None):
"""Return (model_id, api_key) for the requested (or default) teach model, or None."""
if model_config_id:
m = db.query(AIModelConfig).filter(
AIModelConfig.id == model_config_id,
AIModelConfig.task == "teach",
AIModelConfig.is_active == True,
).first()
if m:
return (m.model_id, m.api_key or None)
# Fall back to default, then any active
m = db.query(AIModelConfig).filter(
AIModelConfig.task == "teach",
AIModelConfig.is_active == True,
AIModelConfig.is_default == True,
).first()
if not m:
m = db.query(AIModelConfig).filter(
AIModelConfig.task == "teach",
AIModelConfig.is_active == True,
).first()
return (m.model_id, m.api_key or None) if m else None
def _find_similar_questions(db: Session, question: Question, limit: int = 4) -> list[Question]:
"""Return up to `limit` questions with similar embeddings, excluding the current one."""
if question.embedding is None:
return []
try:
from sqlalchemy import text as sa_text
emb = question.embedding
emb_literal = "[" + ",".join(str(x) for x in emb) + "]"
rows = db.execute(sa_text(f"""
SELECT id, 1 - (embedding <=> '{emb_literal}'::vector) AS sim
FROM questions
WHERE embedding IS NOT NULL AND id != {question.id}
ORDER BY embedding <=> '{emb_literal}'::vector
LIMIT {limit}
""")).fetchall()
ids = [r.id for r in rows if float(r.sim) >= 0.35]
if not ids:
return []
return db.query(Question).filter(Question.id.in_(ids)).all()
except Exception:
return []
def _build_system_prompt(question: Question, similar: list[Question]) -> str:
opts = ""
if question.options:
letters = "ABCDE"
opts = "\n".join(f" {letters[i]}) {opt}" for i, opt in enumerate(question.options))
prompt = (
"You are a medical education tutor helping a student understand the following question. "
"Be accurate, educational, and concise. You may reveal and explain the correct answer.\n\n"
f"=== Current Question ===\n{question.question_text}\n"
)
if opts:
prompt += f"Options:\n{opts}\n"
if question.correct_answer:
prompt += f"Correct Answer: {question.correct_answer}\n"
if question.explanation:
prompt += f"Explanation: {question.explanation}\n"
if similar:
prompt += "\n=== Related Questions (for broader context) ===\n"
for i, sq in enumerate(similar, 1):
prompt += f"{i}. {sq.question_text}"
if sq.correct_answer:
prompt += f" → Answer: {sq.correct_answer}"
prompt += "\n"
prompt += (
"\nAnswer the student's questions about this topic. "
"If they ask why an option is wrong, explain the underlying concept. "
"Keep responses focused and under 200 words unless a longer explanation is needed."
)
return prompt
@router.get("/models")
def list_teach_models(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return available teach models for the frontend to display."""
models = db.query(AIModelConfig).filter(
AIModelConfig.task == "teach",
AIModelConfig.is_active == True,
).all()
return [{"id": m.id, "name": m.name, "model_id": m.model_id, "is_default": m.is_default} for m in models]
@router.post("/chat")
def chat(
req: ChatRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Send a message to the teach AI with full question context."""
model_info = _get_teach_model(db, req.model_id)
if not model_info:
raise HTTPException(
status_code=503,
detail="No teaching AI model is configured. Ask an admin to add a model with task 'teach'.",
)
model_id, api_key = model_info
question = db.query(Question).filter(Question.id == req.question_id).first()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
similar = _find_similar_questions(db, question)
system_prompt = _build_system_prompt(question, similar)
messages = [{"role": "system", "content": system_prompt}]
for msg in req.messages:
if msg.role not in ("user", "assistant"):
continue
messages.append({"role": msg.role, "content": msg.content})
try:
import litellm
from app.config import settings
from app.services.ai_service import _proxy_model
use_model = _proxy_model(model_id)
kwargs = {
"model": use_model,
"messages": messages,
"max_tokens": 600,
"temperature": 0.4,
}
if api_key:
kwargs["api_key"] = api_key
elif settings.LITELLM_API_KEY:
kwargs["api_key"] = settings.LITELLM_API_KEY
if settings.LITELLM_API_BASE:
kwargs["api_base"] = settings.LITELLM_API_BASE
response = litellm.completion(**kwargs)
reply = response.choices[0].message.content.strip()
return {"reply": reply}
except Exception as e:
raise HTTPException(status_code=502, detail=f"AI model error: {str(e)}")

View file

@ -273,3 +273,44 @@ def extract_quiz(
raise raise
finally: finally:
db.close() db.close()
@celery_app.task(name="regenerate_embeddings", bind=True)
def regenerate_embeddings(self, job_id: str, user_id: int):
"""Regenerate embeddings for all questions using the current embedding model."""
r = _redis()
r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
r.set(f"extraction:job_title:{job_id}", "Regenerate Embeddings", ex=EXPIRE_SECONDS)
r.lpush(f"extraction:user_jobs:{user_id}", job_id)
r.expire(f"extraction:user_jobs:{user_id}", 86400)
db = SessionLocal()
try:
from app.models.question import Question
from app.services import embedding_service
questions = db.query(Question).all()
total = len(questions)
_push_step(r, job_id, "start", f"Regenerating embeddings for {total} questions…")
ok = 0
for i, q in enumerate(questions):
try:
if embedding_service.embed_question(q):
ok += 1
if (i + 1) % 50 == 0:
db.commit()
_push_step(r, job_id, "progress", f"{i + 1}/{total} processed ({ok} embedded)")
except Exception as e:
logger.warning(f"Embedding failed for question {q.id}: {e}")
db.commit()
_push_step(r, job_id, "done", f"Done — {ok}/{total} questions re-embedded.")
r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
except Exception as e:
logger.exception(f"Embedding regeneration failed for job {job_id}")
_push_step(r, job_id, "error", f"Failed: {e}")
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
raise
finally:
db.close()

View file

@ -4,6 +4,13 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
# Security headers # Security headers
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;

View file

@ -0,0 +1,41 @@
/**
* Dialog styled modal that replaces window.confirm() and window.alert().
* Used via the useDialog() hook. Matches the Suspend Quiz popup style.
*
* Props:
* open boolean
* title string (optional)
* message string
* confirmLabel string (default "OK")
* cancelLabel string | null (null = no cancel button alert mode)
* onConfirm () => void
* onCancel () => void
* danger boolean (makes confirm button red)
*/
export default function Dialog({ open, title, message, confirmLabel = 'OK', cancelLabel = null, onConfirm, onCancel, danger = false }) {
if (!open) return null
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={e => { if (e.target === e.currentTarget && onCancel) onCancel() }}
>
<div style={{ background: 'var(--card-bg)', borderRadius: 14, padding: 28, maxWidth: 420, width: '100%', textAlign: 'center', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
{title && <h2 style={{ marginBottom: 10, fontSize: '1.1rem' }}>{title}</h2>}
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginBottom: 22, lineHeight: 1.5 }}>{message}</p>
<div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
{cancelLabel && (
<button className="btn btn-secondary" onClick={onCancel}>{cancelLabel}</button>
)}
<button
className={`btn ${danger ? 'btn-danger' : 'btn-primary'}`}
onClick={onConfirm}
autoFocus
>
{confirmLabel}
</button>
</div>
</div>
</div>
)
}

View file

@ -3,29 +3,10 @@ import { Link, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../api/client' import api from '../api/client'
function JobsBadge() { function JobsBadge({ jobs }) {
const [activeJobs, setActiveJobs] = useState([])
const [allJobs, setAllJobs] = useState([])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const allJobs = jobs
useEffect(() => { const activeJobs = jobs.filter(j => j.status === 'running' || j.status === 'pending')
let interval
const load = async () => {
try {
const res = await api.get('/quizzes/jobs')
const jobs = res.data || []
setAllJobs(jobs)
const running = jobs.filter(j => j.status === 'running' || j.status === 'pending')
setActiveJobs(running)
// Poll faster when jobs running, slower when idle
clearInterval(interval)
interval = setInterval(load, running.length > 0 ? 3000 : 30000)
} catch { }
}
load()
interval = setInterval(load, 30000)
return () => clearInterval(interval)
}, [])
if (allJobs.length === 0) return null if (allJobs.length === 0) return null
@ -79,12 +60,32 @@ function JobsBadge() {
export default function Navbar() { export default function Navbar() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [jobs, setJobs] = useState([])
const location = useLocation() const location = useLocation()
const isModerator = user?.role === 'admin' || user?.role === 'moderator' const isModerator = user?.role === 'admin' || user?.role === 'moderator'
// Close menu on route change // Close menu on route change
useEffect(() => { setMenuOpen(false) }, [location.pathname]) useEffect(() => { setMenuOpen(false) }, [location.pathname])
// Single job-polling instance for the whole navbar
useEffect(() => {
if (!user) return
let interval
const load = async () => {
try {
const res = await api.get('/quizzes/jobs')
const jobList = res.data || []
setJobs(jobList)
const running = jobList.filter(j => j.status === 'running' || j.status === 'pending')
clearInterval(interval)
interval = setInterval(load, running.length > 0 ? 3000 : 30000)
} catch { }
}
load()
interval = setInterval(load, 30000)
return () => clearInterval(interval)
}, [user])
const navLinks = user ? [ const navLinks = user ? [
{ to: '/', label: 'Dashboard' }, { to: '/', label: 'Dashboard' },
@ -109,13 +110,13 @@ export default function Navbar() {
{l.label} {l.label}
</Link> </Link>
))} ))}
<JobsBadge /> <JobsBadge jobs={jobs} />
<button onClick={logout}>Logout</button> <button onClick={logout}>Logout</button>
</nav> </nav>
{/* Mobile: jobs + hamburger */} {/* Mobile: jobs + hamburger */}
<div className="nav-mobile-controls"> <div className="nav-mobile-controls">
<JobsBadge /> <JobsBadge jobs={jobs} />
<button <button
onClick={() => setMenuOpen(v => !v)} onClick={() => setMenuOpen(v => !v)}
aria-label="Menu" aria-label="Menu"

View file

@ -0,0 +1,276 @@
import { useState, useEffect, useRef } from 'react'
import api from '../api/client'
/**
* TeachChat slide-in AI tutor drawer for study mode.
*
* Props:
* question current question object (id, question_text, options, correct_answer, explanation)
*
* Layout:
* Mobile bottom sheet, 70vh
* Desktop right-side panel inside quiz-layout, 320px wide
*/
export default function TeachChat({ question }) {
const [open, setOpen] = useState(false)
const [messages, setMessages] = useState([]) // {role, content}
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [noModel, setNoModel] = useState(false)
const [models, setModels] = useState([])
const [selectedModelId, setSelectedModelId] = useState(null) // AIModelConfig.id
const bottomRef = useRef(null)
const inputRef = useRef(null)
// Load available teach models once
useEffect(() => {
api.get('/teach/models').then(res => {
setModels(res.data)
const def = res.data.find(m => m.is_default) || res.data[0]
if (def) setSelectedModelId(def.id)
}).catch(() => {})
}, [])
// Clear chat when question changes
useEffect(() => {
setMessages([])
setInput('')
setNoModel(false)
}, [question?.id])
// Scroll to bottom when messages change
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Focus input when drawer opens
useEffect(() => {
if (open) setTimeout(() => inputRef.current?.focus(), 150)
}, [open])
const send = async () => {
const text = input.trim()
if (!text || loading) return
const userMsg = { role: 'user', content: text }
const next = [...messages, userMsg]
setMessages(next)
setInput('')
setLoading(true)
try {
const res = await api.post('/teach/chat', {
question_id: question.id,
messages: next,
model_id: selectedModelId || null,
})
setMessages(m => [...m, { role: 'assistant', content: res.data.reply }])
} catch (err) {
const detail = err.response?.data?.detail || 'AI error'
if (err.response?.status === 503) setNoModel(true)
setMessages(m => [...m, { role: 'assistant', content: `⚠️ ${detail}` }])
} finally {
setLoading(false)
}
}
const onKey = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() }
}
// Floating button (always visible in study mode)
const fabStyle = {
position: 'fixed',
bottom: 80,
right: 18,
zIndex: 400,
width: 48,
height: 48,
borderRadius: '50%',
background: open ? 'var(--primary)' : '#1e3a5f',
color: 'white',
border: 'none',
cursor: 'pointer',
fontSize: '1.3rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 16px rgba(0,0,0,0.25)',
transition: 'background 0.2s, transform 0.15s',
}
// Drawer shell
// Mobile: fixed bottom sheet. Desktop (768px): right panel via CSS class.
const drawerMobile = {
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: '70vh',
background: 'var(--card-bg)',
borderTop: '2px solid var(--primary)',
borderRadius: '16px 16px 0 0',
zIndex: 390,
display: 'flex',
flexDirection: 'column',
boxShadow: '0 -8px 32px rgba(0,0,0,0.18)',
animation: 'slideUp 0.22s ease',
}
return (
<>
{/* Floating button */}
<button
style={fabStyle}
onClick={() => setOpen(v => !v)}
title={open ? 'Close AI tutor' : 'Ask AI tutor'}
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.1)'}
onMouseLeave={e => e.currentTarget.style.transform = 'none'}
>
{open ? '✕' : '🎓'}
</button>
{/* Backdrop on mobile */}
{open && (
<div
className="teach-chat-backdrop"
onClick={() => setOpen(false)}
style={{ position: 'fixed', inset: 0, zIndex: 380, background: 'rgba(0,0,0,0.3)' }}
/>
)}
{/* Drawer */}
{open && (
<div className="teach-chat-drawer" style={drawerMobile}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 16px', borderBottom: '1px solid var(--border)', flexShrink: 0, gap: 8,
}}>
<span style={{ fontWeight: 700, fontSize: '0.9rem', flexShrink: 0 }}>🎓 AI Tutor</span>
{models.length > 1 && (
<select
value={selectedModelId || ''}
onChange={e => { setSelectedModelId(Number(e.target.value)); setMessages([]); }}
style={{
flex: 1, fontSize: '0.78rem', padding: '3px 6px', borderRadius: 6,
border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text)',
minWidth: 0,
}}
>
{models.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
)}
<button onClick={() => setOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.1rem', lineHeight: 1, flexShrink: 0 }}></button>
</div>
{/* Question context chip */}
<div style={{
padding: '8px 16px', background: 'var(--bg)', borderBottom: '1px solid var(--border)',
fontSize: '0.78rem', color: 'var(--text-muted)', flexShrink: 0,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
📋 {question.question_text?.slice(0, 100)}{question.question_text?.length > 100 ? '…' : ''}
</div>
{/* Messages */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{messages.length === 0 && !noModel && (
<div style={{ color: 'var(--text-muted)', fontSize: '0.83rem', textAlign: 'center', marginTop: 24 }}>
Ask the AI tutor anything about this question why an answer is wrong, the underlying concept, related topics, etc.
</div>
)}
{noModel && (
<div className="alert alert-error" style={{ fontSize: '0.82rem' }}>
No teaching AI is configured. Ask an admin to add a model with task <strong>teach</strong> in the Admin Dashboard.
</div>
)}
{messages.map((m, i) => (
<div key={i} style={{
alignSelf: m.role === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '85%',
background: m.role === 'user' ? 'var(--primary)' : 'var(--bg)',
color: m.role === 'user' ? 'white' : 'var(--text)',
padding: '8px 12px',
borderRadius: m.role === 'user' ? '12px 12px 2px 12px' : '12px 12px 12px 2px',
fontSize: '0.85rem',
lineHeight: 1.55,
border: m.role === 'assistant' ? '1px solid var(--border)' : 'none',
whiteSpace: 'pre-wrap',
}}>
{m.content}
</div>
))}
{loading && (
<div style={{
alignSelf: 'flex-start', background: 'var(--bg)', border: '1px solid var(--border)',
borderRadius: '12px 12px 12px 2px', padding: '8px 14px', fontSize: '0.82rem', color: 'var(--text-muted)',
}}>
<span style={{ display: 'inline-flex', gap: 4 }}>
<span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} /> Thinking
</span>
</div>
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<div style={{
padding: '10px 12px', borderTop: '1px solid var(--border)', flexShrink: 0,
display: 'flex', gap: 8,
}}>
<textarea
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={onKey}
placeholder="Ask a question… (Enter to send)"
rows={2}
style={{
flex: 1, resize: 'none', padding: '8px 10px',
border: '1px solid var(--border)', borderRadius: 8,
fontSize: '0.875rem', background: 'var(--input-bg)', color: 'var(--text)',
fontFamily: 'inherit', lineHeight: 1.4,
}}
/>
<button
onClick={send}
disabled={loading || !input.trim()}
className="btn btn-primary btn-sm"
style={{ alignSelf: 'flex-end', padding: '8px 14px' }}
>
Send
</button>
</div>
</div>
)}
<style>{`
@keyframes slideUp {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@media (min-width: 768px) {
.teach-chat-backdrop { display: none !important; }
.teach-chat-drawer {
position: fixed !important;
bottom: 0 !important;
right: 0 !important;
left: auto !important;
top: 0 !important;
width: 340px !important;
height: 100vh !important;
border-radius: 0 !important;
border-top: none !important;
border-left: 2px solid var(--primary) !important;
animation: slideRight 0.22s ease !important;
}
}
@keyframes slideRight {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`}</style>
</>
)
}

View file

@ -0,0 +1,49 @@
import { useState } from 'react'
/**
* useDialog Promise-based hook for confirm and alert dialogs.
*
* Usage:
* const { dialogProps, openConfirm, openAlert } = useDialog()
*
* // In JSX: <Dialog {...dialogProps} />
*
* // Confirm (returns true/false):
* const ok = await openConfirm('Delete this item?', { title: 'Delete', confirmLabel: 'Delete', danger: true })
* if (!ok) return
*
* // Alert (acknowledgement only):
* await openAlert('Something went wrong.')
*/
export function useDialog() {
const [dialog, setDialog] = useState(null)
const openConfirm = (message, { title, confirmLabel = 'Confirm', cancelLabel = 'Cancel', danger = false } = {}) =>
new Promise(resolve => {
setDialog({ message, title, confirmLabel, cancelLabel, danger, resolve })
})
const openAlert = (message, { title } = {}) =>
new Promise(resolve => {
setDialog({ message, title, confirmLabel: 'OK', cancelLabel: null, danger: false, resolve })
})
const close = (result) => {
setDialog(d => { d?.resolve(result); return null })
}
const dialogProps = dialog
? {
open: true,
title: dialog.title,
message: dialog.message,
confirmLabel: dialog.confirmLabel,
cancelLabel: dialog.cancelLabel,
danger: dialog.danger,
onConfirm: () => close(true),
onCancel: () => close(false),
}
: { open: false }
return { dialogProps, openConfirm, openAlert }
}

View file

@ -2,12 +2,15 @@ import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../api/client' import api from '../api/client'
import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog'
const TASKS = ['extraction', 'tts', 'general'] const TASKS = ['extraction', 'tts', 'general', 'teach']
export default function AdminPage() { export default function AdminPage() {
const { user } = useAuth() const { user } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const { dialogProps, openConfirm } = useDialog()
const [tab, setTab] = useState('models') const [tab, setTab] = useState('models')
const [users, setUsers] = useState([]) const [users, setUsers] = useState([])
const [models, setModels] = useState([]) const [models, setModels] = useState([])
@ -26,6 +29,18 @@ export default function AdminPage() {
const [searchLoading, setSearchLoading] = useState(false) const [searchLoading, setSearchLoading] = useState(false)
const [searchError, setSearchError] = useState('') const [searchError, setSearchError] = useState('')
const [searchFilter, setSearchFilter] = useState('') const [searchFilter, setSearchFilter] = useState('')
const [searchTaskHint, setSearchTaskHint] = useState('extraction')
// TTS voice discovery state
const [ttsProvider, setTtsProvider] = useState('elevenlabs')
const [ttsSearchKey, setTtsSearchKey] = useState('')
const [ttsVoices, setTtsVoices] = useState([])
const [ttsVoicesLoading, setTtsVoicesLoading] = useState(false)
const [ttsVoicesError, setTtsVoicesError] = useState('')
const [ttsVoicesFilter, setTtsVoicesFilter] = useState('')
// TTS preview state per model db id: {text, loading, error, audioUrl}
const [ttsPreviews, setTtsPreviews] = useState({})
// Embedding model search state (in settings tab) // Embedding model search state (in settings tab)
const [embedSearchResults, setEmbedSearchResults] = useState([]) const [embedSearchResults, setEmbedSearchResults] = useState([])
@ -34,6 +49,9 @@ export default function AdminPage() {
const [embedSearchFilter, setEmbedSearchFilter] = useState('') const [embedSearchFilter, setEmbedSearchFilter] = useState('')
const [embedTestResult, setEmbedTestResult] = useState(null) const [embedTestResult, setEmbedTestResult] = useState(null)
const [embedTestLoading, setEmbedTestLoading] = useState(false) const [embedTestLoading, setEmbedTestLoading] = useState(false)
const [originalEmbedModel, setOriginalEmbedModel] = useState(null)
const [embedModelChanged, setEmbedModelChanged] = useState(false)
const [regenLoading, setRegenLoading] = useState(false)
useEffect(() => { useEffect(() => {
if (!user?.role || user.role !== 'admin') { navigate('/'); return } if (!user?.role || user.role !== 'admin') { navigate('/'); return }
@ -51,6 +69,7 @@ export default function AdminPage() {
setUsers(usersRes.data) setUsers(usersRes.data)
setModels(modelsRes.data) setModels(modelsRes.data)
setSettings(settingsRes.data) setSettings(settingsRes.data)
setOriginalEmbedModel(settingsRes.data.embedding_model || '')
} catch (err) { } catch (err) {
setError(err.response?.data?.detail || 'Failed to load data') setError(err.response?.data?.detail || 'Failed to load data')
} finally { } finally {
@ -101,7 +120,8 @@ export default function AdminPage() {
} }
const deleteModel = async (modelId) => { const deleteModel = async (modelId) => {
if (!confirm('Delete this model config?')) return const ok = await openConfirm('Remove this model configuration?', { title: 'Remove Model', confirmLabel: 'Remove', danger: true })
if (!ok) return
try { try {
await api.delete(`/admin/models/${modelId}`) await api.delete(`/admin/models/${modelId}`)
loadData() loadData()
@ -110,6 +130,22 @@ export default function AdminPage() {
} }
} }
const [testResults, setTestResults] = useState({})
const [testingModel, setTestingModel] = useState(null)
const testModel = async (model) => {
setTestingModel(model.id)
setTestResults(r => ({ ...r, [model.id]: null }))
try {
const res = await api.post(`/admin/models/${model.id}/test`)
setTestResults(r => ({ ...r, [model.id]: { ok: true, message: res.data.message || 'OK' } }))
} catch (err) {
setTestResults(r => ({ ...r, [model.id]: { ok: false, message: err.response?.data?.detail || 'Test failed' } }))
} finally {
setTestingModel(null)
}
}
const searchLiteLLM = async () => { const searchLiteLLM = async () => {
setSearchError('') setSearchError('')
setSearchResults([]) setSearchResults([])
@ -162,18 +198,76 @@ export default function AdminPage() {
setSettings(s => ({ ...s, embedding_model: modelId })) setSettings(s => ({ ...s, embedding_model: modelId }))
setEmbedSearchResults([]) setEmbedSearchResults([])
setSuccess(`Embedding model set to: ${modelId}`) setSuccess(`Embedding model set to: ${modelId}`)
if (originalEmbedModel && modelId !== originalEmbedModel) {
setEmbedModelChanged(true)
}
} catch (err) { } catch (err) {
setError(err.response?.data?.detail || 'Failed to save embedding model') setError(err.response?.data?.detail || 'Failed to save embedding model')
} }
} }
const startRegeneration = async () => {
setRegenLoading(true)
try {
await api.post('/admin/embedding/regenerate')
setEmbedModelChanged(false)
setSuccess('Regeneration started — watch the Jobs badge for progress.')
} catch (err) {
setError(err.response?.data?.detail || 'Failed to start regeneration')
} finally {
setRegenLoading(false)
}
}
const addFromSearch = (modelId) => { const addFromSearch = (modelId) => {
setNewModel(m => ({ ...m, model_id: modelId, name: modelId })) setNewModel(m => ({ ...m, model_id: modelId, name: modelId, task: searchTaskHint }))
setSearchResults([]) setSearchResults([])
// scroll to add form
document.getElementById('add-model-form')?.scrollIntoView({ behavior: 'smooth' }) document.getElementById('add-model-form')?.scrollIntoView({ behavior: 'smooth' })
} }
// TTS voice discovery
const searchTtsVoices = async () => {
setTtsVoicesError('')
setTtsVoices([])
setTtsVoicesLoading(true)
try {
const params = new URLSearchParams({ provider: ttsProvider })
if (ttsSearchKey) params.set('api_key', ttsSearchKey)
const res = await api.get(`/admin/tts/voices?${params}`)
setTtsVoices(res.data.voices)
} catch (err) {
setTtsVoicesError(err.response?.data?.detail || 'Failed to fetch voices')
} finally {
setTtsVoicesLoading(false)
}
}
const addTtsFromSearch = (voice) => {
setNewModel(m => ({ ...m, model_id: voice.model_id, name: voice.name, task: 'tts' }))
setTtsVoices([])
document.getElementById('add-model-form')?.scrollIntoView({ behavior: 'smooth' })
}
// TTS preview (calls /tts/speak directly)
const setPreview = (id, patch) =>
setTtsPreviews(p => ({ ...p, [id]: { text: 'Hello, this is a voice preview.', ...p[id], ...patch } }))
const playTtsPreview = async (model) => {
const state = ttsPreviews[model.id] || {}
const text = state.text || 'Hello, this is a voice preview.'
setPreview(model.id, { loading: true, error: null })
try {
const res = await api.post('/tts/speak', { text, voice: model.model_id }, { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const audio = new Audio(url)
audio.onended = () => URL.revokeObjectURL(url)
audio.play()
setPreview(model.id, { loading: false })
} catch (err) {
setPreview(model.id, { loading: false, error: err.response?.data?.detail || 'Playback failed' })
}
}
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div> if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
const modelsByTask = TASKS.reduce((acc, t) => { const modelsByTask = TASKS.reduce((acc, t) => {
@ -187,6 +281,7 @@ export default function AdminPage() {
return ( return (
<div> <div>
<Dialog {...dialogProps} />
<div className="card"> <div className="card">
<h2>Admin Dashboard</h2> <h2>Admin Dashboard</h2>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}> <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
@ -203,20 +298,20 @@ export default function AdminPage() {
{tab === 'models' && ( {tab === 'models' && (
<> <>
{/* LiteLLM search */} {/* LiteLLM / OpenAI-compatible search — for extraction, teach, general */}
<div className="card"> <div className="card">
<h2>Search Models from LiteLLM</h2> <h2>Search LLM Models</h2>
<p style={{ color: '#64748b', fontSize: '0.85rem', marginBottom: 16 }}> <p style={{ color: '#64748b', fontSize: '0.85rem', marginBottom: 16 }}>
Query an OpenAI-compatible API (or your LiteLLM proxy) to see available models, then click one to add it. Query your LiteLLM proxy or any OpenAI-compatible API. Works for <strong>extraction</strong>, <strong>teach</strong>, and <strong>general</strong> tasks.
</p> </p>
<div className="grid-2"> <div className="grid-2">
<div className="form-group"> <div className="form-group">
<label>API Base URL (optional)</label> <label>API Base URL (optional uses .env if blank)</label>
<input <input
type="text" type="text"
value={searchApiBase} value={searchApiBase}
onChange={e => setSearchApiBase(e.target.value)} onChange={e => setSearchApiBase(e.target.value)}
placeholder="e.g. https://api.openai.com or LiteLLM proxy URL" placeholder="e.g. https://litellm.myserver.com"
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
@ -228,6 +323,12 @@ export default function AdminPage() {
placeholder="sk-..." placeholder="sk-..."
/> />
</div> </div>
<div className="form-group">
<label>Adding model for task</label>
<select value={searchTaskHint} onChange={e => setSearchTaskHint(e.target.value)}>
{['extraction', 'teach', 'general'].map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
</div> </div>
<button className="btn btn-primary" onClick={searchLiteLLM} disabled={searchLoading}> <button className="btn btn-primary" onClick={searchLiteLLM} disabled={searchLoading}>
{searchLoading ? 'Searching...' : 'Search Models'} {searchLoading ? 'Searching...' : 'Search Models'}
@ -280,29 +381,123 @@ export default function AdminPage() {
<span style={{ fontSize: '0.75rem', color: '#64748b', marginLeft: 8, fontWeight: 400 }}> <span style={{ fontSize: '0.75rem', color: '#64748b', marginLeft: 8, fontWeight: 400 }}>
{task === 'extraction' ? '— AI that extracts questions from PDFs' : {task === 'extraction' ? '— AI that extracts questions from PDFs' :
task === 'tts' ? '— Text-to-speech voices' : task === 'tts' ? '— Text-to-speech voices' :
task === 'teach' ? '— AI tutor shown in study mode chat' :
'— General purpose AI'} '— General purpose AI'}
</span> </span>
</h2> </h2>
{/* TTS voice discovery — only in the tts card */}
{task === 'tts' && (
<div style={{ marginBottom: 16, padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ fontWeight: 600, fontSize: '0.85rem', marginBottom: 8 }}>Discover Voices</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
<select value={ttsProvider} onChange={e => { setTtsProvider(e.target.value); setTtsVoices([]) }}
style={{ padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.85rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
<option value="elevenlabs">ElevenLabs</option>
<option value="polly">AWS Polly (Neural)</option>
<option value="openai">OpenAI TTS</option>
</select>
<input type="password" value={ttsSearchKey} onChange={e => setTtsSearchKey(e.target.value)}
placeholder="API key (uses .env if blank)"
style={{ flex: 1, minWidth: 140, padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.85rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
<button className="btn btn-primary btn-sm" onClick={searchTtsVoices} disabled={ttsVoicesLoading}>
{ttsVoicesLoading ? 'Searching...' : 'Search'}
</button>
</div>
{ttsVoicesError && <div className="alert alert-error" style={{ fontSize: '0.82rem', padding: '6px 12px' }}>{ttsVoicesError}</div>}
{ttsVoices.length > 0 && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>{ttsVoices.length} voices click to add</span>
<input type="text" placeholder="Filter..." value={ttsVoicesFilter} onChange={e => setTtsVoicesFilter(e.target.value)}
style={{ padding: '3px 8px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.82rem', width: 160, background: 'var(--input-bg)', color: 'var(--text)' }} />
</div>
<div style={{ maxHeight: 220, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 8 }}>
{ttsVoices
.filter(v => !ttsVoicesFilter || v.name.toLowerCase().includes(ttsVoicesFilter.toLowerCase()) || v.model_id.toLowerCase().includes(ttsVoicesFilter.toLowerCase()))
.map(v => (
<div key={v.model_id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '7px 12px', borderBottom: '1px solid var(--border)', fontSize: '0.83rem' }}>
<div>
<span style={{ fontWeight: 500 }}>{v.name}</span>
{v.labels && Object.keys(v.labels).length > 0 && (
<span style={{ color: 'var(--text-muted)', marginLeft: 8, fontSize: '0.75rem' }}>
{Object.values(v.labels).filter(Boolean).join(' · ')}
</span>
)}
<div style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: 'var(--text-muted)' }}>{v.model_id}</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => addTtsFromSearch(v)}>+ Add</button>
</div>
))}
</div>
</div>
)}
</div>
)}
{modelsByTask[task].length === 0 ? ( {modelsByTask[task].length === 0 ? (
<div className="empty-state" style={{ padding: 16 }}>No models configured</div> <div className="empty-state" style={{ padding: 16 }}>No models configured</div>
) : ( ) : (
modelsByTask[task].map(m => ( modelsByTask[task].map(m => (
<div className="section-item" key={m.id}> <div key={m.id}>
<div style={{ minWidth: 0 }}> <div className="section-item">
<strong>{m.name}</strong> <div style={{ minWidth: 0 }}>
<div style={{ fontSize: '0.82rem', color: '#64748b', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.model_id}</div> <strong>{m.name}</strong>
</div> <div style={{ fontSize: '0.82rem', color: '#64748b', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.model_id}</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}> </div>
{m.is_default && <span className="badge badge-ready">Default</span>} <div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
{!m.is_default && ( {m.is_default && <span className="badge badge-ready">Default</span>}
<button className="btn btn-secondary btn-sm" onClick={() => setDefault(m.id)}>Set Default</button> {!m.is_default && (
)} <button className="btn btn-secondary btn-sm" onClick={() => setDefault(m.id)}>Set Default</button>
<button )}
className={`btn btn-sm ${m.is_active ? 'btn-secondary' : 'btn-primary'}`} {task === 'tts' ? (
onClick={() => toggleActive(m)} <button
>{m.is_active ? 'Disable' : 'Enable'}</button> className="btn btn-secondary btn-sm"
<button className="btn btn-danger btn-sm" onClick={() => deleteModel(m.id)}>Remove</button> onClick={() => setPreview(m.id, { open: !(ttsPreviews[m.id]?.open) })}
>Preview</button>
) : (
<button
className="btn btn-secondary btn-sm"
onClick={() => testModel(m)}
disabled={testingModel === m.id}
>{testingModel === m.id ? 'Testing...' : 'Test'}</button>
)}
<button
className={`btn btn-sm ${m.is_active ? 'btn-secondary' : 'btn-primary'}`}
onClick={() => toggleActive(m)}
>{m.is_active ? 'Disable' : 'Enable'}</button>
<button className="btn btn-danger btn-sm" onClick={() => deleteModel(m.id)}>Remove</button>
</div>
</div> </div>
{/* LLM test result */}
{testResults[m.id] && (
<div className={`alert ${testResults[m.id].ok ? 'alert-success' : 'alert-error'}`} style={{ marginTop: 6, marginBottom: 0, padding: '6px 12px', fontSize: '0.82rem' }}>
{testResults[m.id].ok ? '✓ ' : '✗ '}{testResults[m.id].message}
</div>
)}
{/* TTS preview */}
{task === 'tts' && ttsPreviews[m.id]?.open && (
<div style={{ marginTop: 6, padding: '10px 12px', background: 'var(--bg)', borderRadius: 8, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-start' }}>
<textarea
value={ttsPreviews[m.id]?.text ?? 'Hello, this is a voice preview.'}
onChange={e => setPreview(m.id, { text: e.target.value })}
rows={2}
style={{ flex: 1, minWidth: 160, padding: '6px 10px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.85rem', resize: 'none', background: 'var(--input-bg)', color: 'var(--text)', fontFamily: 'inherit' }}
/>
<button className="btn btn-primary btn-sm" style={{ alignSelf: 'flex-end' }}
onClick={() => playTtsPreview(m)}
disabled={ttsPreviews[m.id]?.loading}>
{ttsPreviews[m.id]?.loading ? '⏳' : '▶ Play'}
</button>
{ttsPreviews[m.id]?.error && (
<div className="alert alert-error" style={{ width: '100%', marginTop: 4, padding: '5px 10px', fontSize: '0.82rem' }}>
{ttsPreviews[m.id].error}
</div>
)}
</div>
)}
</div> </div>
)) ))
)} )}
@ -470,6 +665,27 @@ export default function AdminPage() {
</label> </label>
</div> </div>
{/* Embedding model change warning */}
{embedModelChanged && (
<div style={{ padding: '12px 16px', background: '#fef3c7', border: '1px solid #f59e0b', borderRadius: 8, marginBottom: 0, marginTop: 8 }}>
<strong style={{ color: '#92400e', display: 'block', marginBottom: 6 }}>
Embedding model changed
</strong>
<span style={{ fontSize: '0.85rem', color: '#78350f' }}>
Existing question embeddings were generated with the previous model and may produce inaccurate semantic search results.
Regenerate them to restore full search quality.
</span>
<div style={{ marginTop: 10 }}>
<button className="btn btn-primary btn-sm" onClick={startRegeneration} disabled={regenLoading}>
{regenLoading ? 'Starting…' : 'Regenerate All Embeddings'}
</button>
<button className="btn btn-secondary btn-sm" style={{ marginLeft: 8 }} onClick={() => setEmbedModelChanged(false)}>
Dismiss
</button>
</div>
</div>
)}
{/* Embedding Model */} {/* Embedding Model */}
<div style={{ padding: '16px 0' }}> <div style={{ padding: '16px 0' }}>
<strong style={{ display: 'block', marginBottom: 4 }}>Embedding Model</strong> <strong style={{ display: 'block', marginBottom: 4 }}>Embedding Model</strong>
@ -492,6 +708,9 @@ export default function AdminPage() {
<button className="btn btn-secondary btn-sm" onClick={testEmbedding} disabled={embedTestLoading}> <button className="btn btn-secondary btn-sm" onClick={testEmbedding} disabled={embedTestLoading}>
{embedTestLoading ? 'Testing...' : 'Test'} {embedTestLoading ? 'Testing...' : 'Test'}
</button> </button>
<button className="btn btn-secondary btn-sm" onClick={startRegeneration} disabled={regenLoading} title="Regenerate embeddings for all questions">
{regenLoading ? 'Starting…' : 'Regenerate All'}
</button>
</div> </div>
{embedTestResult && ( {embedTestResult && (
<div className={`alert ${embedTestResult.ok ? 'alert-success' : 'alert-error'}`} style={{ marginBottom: 8 }}> <div className={`alert ${embedTestResult.ok ? 'alert-success' : 'alert-error'}`} style={{ marginBottom: 8 }}>

View file

@ -1,11 +1,14 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import api from '../api/client' import api from '../api/client'
import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog'
function JobDetail({ job }) { function JobDetail({ job }) {
const [steps, setSteps] = useState([]) const [steps, setSteps] = useState([])
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [quizData, setQuizData] = useState(null) const [quizData, setQuizData] = useState(null)
const { dialogProps, openAlert } = useDialog()
const pollRef = useRef(null) const pollRef = useRef(null)
const navigate = useNavigate() const navigate = useNavigate()
@ -41,6 +44,7 @@ function JobDetail({ job }) {
return ( return (
<div className="card" style={{ marginBottom: 12 }}> <div className="card" style={{ marginBottom: 12 }}>
<Dialog {...dialogProps} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 4, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 4, flexWrap: 'wrap' }}>
@ -70,7 +74,7 @@ function JobDetail({ job }) {
job.status = 'cancelled' job.status = 'cancelled'
load() load()
} catch (e) { } catch (e) {
alert(e.response?.data?.detail || 'Failed to cancel') await openAlert(e.response?.data?.detail || 'Failed to cancel', { title: 'Error' })
} }
}}>Cancel</button> }}>Cancel</button>
)} )}

View file

@ -2,6 +2,8 @@ import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../api/client' import api from '../api/client'
import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog'
function QuestionStudyModal({ question, onClose }) { function QuestionStudyModal({ question, onClose }) {
const [answered, setAnswered] = useState(null) const [answered, setAnswered] = useState(null)
@ -219,6 +221,7 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
} }
export default function QuestionBankPage() { export default function QuestionBankPage() {
const { dialogProps, openAlert } = useDialog()
const [questions, setQuestions] = useState([]) const [questions, setQuestions] = useState([])
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -317,7 +320,7 @@ export default function QuestionBankPage() {
const res = await api.post('/question-categories/', { name: newCatName.trim() }) const res = await api.post('/question-categories/', { name: newCatName.trim() })
setCategories(prev => [...prev, res.data]) setCategories(prev => [...prev, res.data])
setNewCatName(''); setShowCatForm(false) setNewCatName(''); setShowCatForm(false)
} catch (err) { alert(err.response?.data?.detail || 'Failed') } } catch (err) { await openAlert(err.response?.data?.detail || 'Failed', { title: 'Error' }) }
} }
const [deletingCatId, setDeletingCatId] = useState(null) const [deletingCatId, setDeletingCatId] = useState(null)
@ -351,12 +354,13 @@ export default function QuestionBankPage() {
setFavorites(prev => [...prev, questionId]) setFavorites(prev => [...prev, questionId])
} }
} catch (err) { } catch (err) {
alert(err.response?.data?.detail || 'Failed to update favorite') await openAlert(err.response?.data?.detail || 'Failed to update favorite', { title: 'Error' })
} }
} }
return ( return (
<div> <div>
<Dialog {...dialogProps} />
{/* Delete category dialog */} {/* Delete category dialog */}
{deletingCatId && (() => { {deletingCatId && (() => {
const cat = categories.find(c => c.id === deletingCatId) const cat = categories.find(c => c.id === deletingCatId)

View file

@ -1,8 +1,10 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../api/client' import api from '../api/client'
const TeachChat = lazy(() => import('../components/TeachChat'))
function TTSButton({ text, voice, onActiveChange }) { function TTSButton({ text, voice, onActiveChange }) {
const [state, setState] = useState('idle') // idle | loading | playing const [state, setState] = useState('idle') // idle | loading | playing
const audioRef = useRef(null) const audioRef = useRef(null)
@ -139,6 +141,7 @@ export default function QuizPage() {
const [answers, setAnswers] = useState({}) const [answers, setAnswers] = useState({})
const [currentIdx, setCurrentIdx] = useState(0) const [currentIdx, setCurrentIdx] = useState(0)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [starting, setStarting] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [attemptId, setAttemptId] = useState(null) const [attemptId, setAttemptId] = useState(null)
const [timeLeft, setTimeLeft] = useState(null) const [timeLeft, setTimeLeft] = useState(null)
@ -237,15 +240,22 @@ export default function QuizPage() {
if (hasStarted.current) return if (hasStarted.current) return
hasStarted.current = true hasStarted.current = true
setSelectedVoice(voice) setSelectedVoice(voice)
setStarting(true)
// DON'T set quizMode yet wait for data to load first // DON'T set quizMode yet wait for data to load first
// (prevents mobile race condition where quiz renders without correct_answer) // (prevents mobile race condition where quiz renders without correct_answer)
try { try {
const quizRes = await api.get(`/quizzes/${id}${mode === 'study' ? '?study=true' : ''}`) let quizData = quiz
setQuiz(quizRes.data) if (mode === 'study') {
// Need answers/explanations fetch the full study version
const quizRes = await api.get(`/quizzes/${id}?study=true`)
quizData = quizRes.data
setQuiz(quizData)
}
// Exam mode reuses the already-loaded quiz (no answers needed)
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`) const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`)
setAttemptId(attemptRes.data.id) setAttemptId(attemptRes.data.id)
const mins = timerMinutes || quizRes.data.time_limit_minutes const mins = timerMinutes || quizData.time_limit_minutes
const now = new Date().toISOString() const now = new Date().toISOString()
setStartedAt(now) setStartedAt(now)
if (mode === 'exam' && mins) { if (mode === 'exam' && mins) {
@ -255,6 +265,7 @@ export default function QuizPage() {
// NOW set mode quiz data is fully loaded, safe to render // NOW set mode quiz data is fully loaded, safe to render
setQuizMode(mode) setQuizMode(mode)
} catch { navigate('/') } } catch { navigate('/') }
finally { setStarting(false) }
} }
useEffect(() => { useEffect(() => {
@ -309,7 +320,7 @@ useEffect(() => {
const res = await api.post(`/attempts/${attemptId}/submit`, submission) const res = await api.post(`/attempts/${attemptId}/submit`, submission)
navigate(`/results/${attemptId}`, { state: { result: res.data } }) navigate(`/results/${attemptId}`, { state: { result: res.data } })
} catch (err) { } catch (err) {
if (!autoSubmit) alert(err.response?.data?.detail || 'Submission failed') if (!autoSubmit) showToast(err.response?.data?.detail || 'Submission failed')
} finally { setSubmitting(false) } } finally { setSubmitting(false) }
}, [attemptId, answers, submitting]) }, [attemptId, answers, submitting])
@ -323,7 +334,14 @@ useEffect(() => {
<Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit Questions</Link> <Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit Questions</Link>
</div> </div>
)} )}
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} /> {starting ? (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<div className="spinner" style={{ margin: '0 auto 16px' }} />
<div style={{ color: 'var(--text-muted)', fontSize: '0.95rem' }}>Loading quiz</div>
</div>
) : (
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} />
)}
</div> </div>
) )
@ -591,6 +609,13 @@ useEffect(() => {
</div> </div>
</div> </div>
</div> </div>
{/* AI tutor — only in study mode, lazy-loaded */}
{isStudy && current && (
<Suspense fallback={null}>
<TeachChat question={current} />
</Suspense>
)}
</div> </div>
) )
} }

View file

@ -3,6 +3,8 @@ import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../api/client' import api from '../api/client'
import ConfirmButton from '../components/ConfirmButton' import ConfirmButton from '../components/ConfirmButton'
import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog'
function InProgressSection() { function InProgressSection() {
const [inProgress, setInProgress] = useState([]) const [inProgress, setInProgress] = useState([])
@ -50,6 +52,73 @@ function InProgressSection() {
) )
} }
function PastAttemptsSection() {
const [open, setOpen] = useState(false)
const [history, setHistory] = useState(null)
const [loading, setLoading] = useState(false)
const load = async () => {
if (history !== null) { setOpen(v => !v); return }
setLoading(true)
try {
const res = await api.get('/attempts/history')
setHistory(res.data)
} catch { setHistory([]) }
finally { setLoading(false); setOpen(true) }
}
const totalCompleted = history?.reduce((s, q) => s + q.attempts.length, 0) ?? 0
return (
<div className="card" style={{ marginBottom: 16 }}>
<button onClick={load} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
width: '100%', background: 'none', border: 'none', cursor: 'pointer',
padding: 0, textAlign: 'left',
}}>
<h2 style={{ fontSize: '1rem', color: 'var(--text)', margin: 0 }}>
Past Attempts {history !== null && `(${totalCompleted})`}
</h2>
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>
{loading ? 'Loading...' : open ? '▲ Hide' : '▼ Show'}
</span>
</button>
{open && history !== null && (
<div style={{ marginTop: 12 }}>
{history.length === 0 ? (
<div style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>No completed attempts yet.</div>
) : history.map(quiz => (
<div key={quiz.quiz_id} style={{ marginBottom: 14 }}>
<div style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6 }}>{quiz.title}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
{quiz.attempts.map((a, i) => (
<div key={a.attempt_id} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '7px 12px', background: 'var(--bg)', borderRadius: 8,
fontSize: '0.82rem', gap: 8,
}}>
<span style={{ color: 'var(--text-muted)' }}>
{new Date(a.date).toLocaleDateString()} {a.score}/{a.total}
</span>
<span style={{
fontWeight: 700, fontSize: '0.85rem',
color: a.percentage >= 80 ? 'var(--correct-fg)' : a.percentage >= 50 ? '#d97706' : 'var(--wrong-fg)',
}}>{a.percentage}%</span>
<a href={`/results/${a.attempt_id}`} style={{ color: 'var(--primary)', fontSize: '0.78rem', textDecoration: 'none', flexShrink: 0 }}>
Review
</a>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)
}
function HighlightText({ text, query }) { function HighlightText({ text, query }) {
if (!query || !text) return <span>{text}</span> if (!query || !text) return <span>{text}</span>
const idx = text.toLowerCase().indexOf(query.toLowerCase()) const idx = text.toLowerCase().indexOf(query.toLowerCase())
@ -204,6 +273,7 @@ export default function QuizzesPage() {
const [studyQuestion, setStudyQuestion] = useState(null) const [studyQuestion, setStudyQuestion] = useState(null)
const [newCatName, setNewCatName] = useState('') const [newCatName, setNewCatName] = useState('')
const [addingCat, setAddingCat] = useState(false) const [addingCat, setAddingCat] = useState(false)
const { dialogProps, openAlert } = useDialog()
const debounceRef = useRef(null) const debounceRef = useRef(null)
const { user } = useAuth() const { user } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
@ -256,7 +326,7 @@ export default function QuizzesPage() {
setCategories(prev => [...prev, res.data]) setCategories(prev => [...prev, res.data])
setNewCatName('') setNewCatName('')
setAddingCat(false) setAddingCat(false)
} catch (err) { alert(err.response?.data?.detail || 'Failed to create category') } } catch (err) { await openAlert(err.response?.data?.detail || 'Failed to create category', { title: 'Error' }) }
} }
const deleteCategory = async (catId) => { const deleteCategory = async (catId) => {
@ -283,6 +353,7 @@ export default function QuizzesPage() {
return ( return (
<div> <div>
<Dialog {...dialogProps} />
{studyQuestion && ( {studyQuestion && (
<QuestionStudyModal question={studyQuestion} query={searchQuery} onClose={() => setStudyQuestion(null)} /> <QuestionStudyModal question={studyQuestion} query={searchQuery} onClose={() => setStudyQuestion(null)} />
)} )}
@ -374,6 +445,9 @@ export default function QuizzesPage() {
{/* In-progress quizzes */} {/* In-progress quizzes */}
{!isSearching && <InProgressSection />} {!isSearching && <InProgressSection />}
{/* Past attempts (loads on demand) */}
{!isSearching && <PastAttemptsSection />}
{/* Normal quiz grid */} {/* Normal quiz grid */}
{!isSearching && ( {!isSearching && (
<> <>

View file

@ -203,7 +203,7 @@ function AdminSection() {
<Section title="Administration"> <Section title="Administration">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 12 }}>
{[ {[
{ to: '/admin', icon: '👥', label: 'User Management', desc: 'Manage accounts and roles' }, { to: '/admin', icon: '⚙️', label: 'Admin Dashboard', desc: 'Models, users, and settings' },
{ to: '/upload', icon: '📄', label: 'Upload PDF', desc: 'Add new documents' }, { to: '/upload', icon: '📄', label: 'Upload PDF', desc: 'Add new documents' },
{ to: '/trash', icon: '🗑️', label: 'Trash', desc: 'Restore deleted quizzes' }, { to: '/trash', icon: '🗑️', label: 'Trash', desc: 'Restore deleted quizzes' },
{ to: '/jobs', icon: '📋', label: 'Extraction Jobs', desc: 'View extraction history' }, { to: '/jobs', icon: '📋', label: 'Extraction Jobs', desc: 'View extraction history' },