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.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.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 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="Titan Embed v2 (Embedding)", model_id="titan-embed-v2", task="general", is_active=True, is_default=False),
]
db.add_all(defaults)
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)
tts_voices = [
# OpenAI (work with OPENAI_API_KEY)
@ -123,7 +131,23 @@ def setup_pgvector():
from sqlalchemy import text
# Import new models so create_all picks them up
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:
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("ALTER TABLE questions ADD COLUMN IF NOT EXISTS embedding vector(1024)"))
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(question_categories.router, prefix="/api/question-categories", tags=["question-categories"])
app.include_router(favorites.router, prefix="/api/favorites", tags=["favorites"])
app.include_router(teach.router, prefix="/api/teach", tags=["teach"])
@app.get("/api/health")

View file

@ -1,6 +1,6 @@
from pgvector.sqlalchemy import Vector
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.database import Base
@ -23,7 +23,7 @@ class Question(Base):
explanation = Column(Text, nullable=True)
page_reference = Column(Integer, 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",
foreign_keys=[question_category_id])

View file

@ -143,8 +143,8 @@ def create_model(
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
if data.task not in ("extraction", "tts", "general"):
raise HTTPException(status_code=400, detail="Task must be extraction, tts, or general")
if data.task not in ("extraction", "tts", "general", "teach"):
raise HTTPException(status_code=400, detail="Task must be extraction, tts, general, or teach")
if data.is_default:
db.query(AIModelConfig).filter(
@ -201,6 +201,124 @@ def delete_model(
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 ---
@router.get("/settings")
@ -262,3 +380,13 @@ def test_embedding(admin: User = Depends(require_admin)):
if result is None:
raise HTTPException(status_code=500, detail=f"Embedding failed for model: {model}")
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:
pct = round((a.score / a.total_questions * 100) if a.total_questions > 0 else 0, 1)
by_quiz[a.quiz_id].append({
"attempt_id": a.id,
"date": a.completed_at.isoformat(),
"percentage": pct,
"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
finally:
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;
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
add_header X-Frame-Options "SAMEORIGIN" 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 api from '../api/client'
function JobsBadge() {
const [activeJobs, setActiveJobs] = useState([])
const [allJobs, setAllJobs] = useState([])
function JobsBadge({ jobs }) {
const [open, setOpen] = useState(false)
useEffect(() => {
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)
}, [])
const allJobs = jobs
const activeJobs = jobs.filter(j => j.status === 'running' || j.status === 'pending')
if (allJobs.length === 0) return null
@ -79,12 +60,32 @@ function JobsBadge() {
export default function Navbar() {
const { user, logout } = useAuth()
const [menuOpen, setMenuOpen] = useState(false)
const [jobs, setJobs] = useState([])
const location = useLocation()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
// Close menu on route change
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 ? [
{ to: '/', label: 'Dashboard' },
@ -109,13 +110,13 @@ export default function Navbar() {
{l.label}
</Link>
))}
<JobsBadge />
<JobsBadge jobs={jobs} />
<button onClick={logout}>Logout</button>
</nav>
{/* Mobile: jobs + hamburger */}
<div className="nav-mobile-controls">
<JobsBadge />
<JobsBadge jobs={jobs} />
<button
onClick={() => setMenuOpen(v => !v)}
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 { useAuth } from '../context/AuthContext'
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() {
const { user } = useAuth()
const navigate = useNavigate()
const { dialogProps, openConfirm } = useDialog()
const [tab, setTab] = useState('models')
const [users, setUsers] = useState([])
const [models, setModels] = useState([])
@ -26,6 +29,18 @@ export default function AdminPage() {
const [searchLoading, setSearchLoading] = useState(false)
const [searchError, setSearchError] = 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)
const [embedSearchResults, setEmbedSearchResults] = useState([])
@ -34,6 +49,9 @@ export default function AdminPage() {
const [embedSearchFilter, setEmbedSearchFilter] = useState('')
const [embedTestResult, setEmbedTestResult] = useState(null)
const [embedTestLoading, setEmbedTestLoading] = useState(false)
const [originalEmbedModel, setOriginalEmbedModel] = useState(null)
const [embedModelChanged, setEmbedModelChanged] = useState(false)
const [regenLoading, setRegenLoading] = useState(false)
useEffect(() => {
if (!user?.role || user.role !== 'admin') { navigate('/'); return }
@ -51,6 +69,7 @@ export default function AdminPage() {
setUsers(usersRes.data)
setModels(modelsRes.data)
setSettings(settingsRes.data)
setOriginalEmbedModel(settingsRes.data.embedding_model || '')
} catch (err) {
setError(err.response?.data?.detail || 'Failed to load data')
} finally {
@ -101,7 +120,8 @@ export default function AdminPage() {
}
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 {
await api.delete(`/admin/models/${modelId}`)
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 () => {
setSearchError('')
setSearchResults([])
@ -162,18 +198,76 @@ export default function AdminPage() {
setSettings(s => ({ ...s, embedding_model: modelId }))
setEmbedSearchResults([])
setSuccess(`Embedding model set to: ${modelId}`)
if (originalEmbedModel && modelId !== originalEmbedModel) {
setEmbedModelChanged(true)
}
} catch (err) {
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) => {
setNewModel(m => ({ ...m, model_id: modelId, name: modelId }))
setNewModel(m => ({ ...m, model_id: modelId, name: modelId, task: searchTaskHint }))
setSearchResults([])
// scroll to add form
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>
const modelsByTask = TASKS.reduce((acc, t) => {
@ -187,6 +281,7 @@ export default function AdminPage() {
return (
<div>
<Dialog {...dialogProps} />
<div className="card">
<h2>Admin Dashboard</h2>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
@ -203,20 +298,20 @@ export default function AdminPage() {
{tab === 'models' && (
<>
{/* LiteLLM search */}
{/* LiteLLM / OpenAI-compatible search — for extraction, teach, general */}
<div className="card">
<h2>Search Models from LiteLLM</h2>
<h2>Search LLM Models</h2>
<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>
<div className="grid-2">
<div className="form-group">
<label>API Base URL (optional)</label>
<label>API Base URL (optional uses .env if blank)</label>
<input
type="text"
value={searchApiBase}
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 className="form-group">
@ -228,6 +323,12 @@ export default function AdminPage() {
placeholder="sk-..."
/>
</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>
<button className="btn btn-primary" onClick={searchLiteLLM} disabled={searchLoading}>
{searchLoading ? 'Searching...' : 'Search Models'}
@ -280,29 +381,123 @@ export default function AdminPage() {
<span style={{ fontSize: '0.75rem', color: '#64748b', marginLeft: 8, fontWeight: 400 }}>
{task === 'extraction' ? '— AI that extracts questions from PDFs' :
task === 'tts' ? '— Text-to-speech voices' :
task === 'teach' ? '— AI tutor shown in study mode chat' :
'— General purpose AI'}
</span>
</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 ? (
<div className="empty-state" style={{ padding: 16 }}>No models configured</div>
) : (
modelsByTask[task].map(m => (
<div className="section-item" key={m.id}>
<div style={{ minWidth: 0 }}>
<strong>{m.name}</strong>
<div style={{ fontSize: '0.82rem', color: '#64748b', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.model_id}</div>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
{m.is_default && <span className="badge badge-ready">Default</span>}
{!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'}`}
onClick={() => toggleActive(m)}
>{m.is_active ? 'Disable' : 'Enable'}</button>
<button className="btn btn-danger btn-sm" onClick={() => deleteModel(m.id)}>Remove</button>
<div key={m.id}>
<div className="section-item">
<div style={{ minWidth: 0 }}>
<strong>{m.name}</strong>
<div style={{ fontSize: '0.82rem', color: '#64748b', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.model_id}</div>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
{m.is_default && <span className="badge badge-ready">Default</span>}
{!m.is_default && (
<button className="btn btn-secondary btn-sm" onClick={() => setDefault(m.id)}>Set Default</button>
)}
{task === 'tts' ? (
<button
className="btn btn-secondary btn-sm"
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>
{/* 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>
))
)}
@ -470,6 +665,27 @@ export default function AdminPage() {
</label>
</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 */}
<div style={{ padding: '16px 0' }}>
<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}>
{embedTestLoading ? 'Testing...' : 'Test'}
</button>
<button className="btn btn-secondary btn-sm" onClick={startRegeneration} disabled={regenLoading} title="Regenerate embeddings for all questions">
{regenLoading ? 'Starting…' : 'Regenerate All'}
</button>
</div>
{embedTestResult && (
<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 { Link, useNavigate } from 'react-router-dom'
import api from '../api/client'
import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog'
function JobDetail({ job }) {
const [steps, setSteps] = useState([])
const [expanded, setExpanded] = useState(false)
const [quizData, setQuizData] = useState(null)
const { dialogProps, openAlert } = useDialog()
const pollRef = useRef(null)
const navigate = useNavigate()
@ -41,6 +44,7 @@ function JobDetail({ job }) {
return (
<div className="card" style={{ marginBottom: 12 }}>
<Dialog {...dialogProps} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 4, flexWrap: 'wrap' }}>
@ -70,7 +74,7 @@ function JobDetail({ job }) {
job.status = 'cancelled'
load()
} catch (e) {
alert(e.response?.data?.detail || 'Failed to cancel')
await openAlert(e.response?.data?.detail || 'Failed to cancel', { title: 'Error' })
}
}}>Cancel</button>
)}

View file

@ -2,6 +2,8 @@ import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog'
function QuestionStudyModal({ question, onClose }) {
const [answered, setAnswered] = useState(null)
@ -219,6 +221,7 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
}
export default function QuestionBankPage() {
const { dialogProps, openAlert } = useDialog()
const [questions, setQuestions] = useState([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
@ -317,7 +320,7 @@ export default function QuestionBankPage() {
const res = await api.post('/question-categories/', { name: newCatName.trim() })
setCategories(prev => [...prev, res.data])
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)
@ -351,12 +354,13 @@ export default function QuestionBankPage() {
setFavorites(prev => [...prev, questionId])
}
} catch (err) {
alert(err.response?.data?.detail || 'Failed to update favorite')
await openAlert(err.response?.data?.detail || 'Failed to update favorite', { title: 'Error' })
}
}
return (
<div>
<Dialog {...dialogProps} />
{/* Delete category dialog */}
{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 { useAuth } from '../context/AuthContext'
import api from '../api/client'
const TeachChat = lazy(() => import('../components/TeachChat'))
function TTSButton({ text, voice, onActiveChange }) {
const [state, setState] = useState('idle') // idle | loading | playing
const audioRef = useRef(null)
@ -139,6 +141,7 @@ export default function QuizPage() {
const [answers, setAnswers] = useState({})
const [currentIdx, setCurrentIdx] = useState(0)
const [loading, setLoading] = useState(true)
const [starting, setStarting] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [attemptId, setAttemptId] = useState(null)
const [timeLeft, setTimeLeft] = useState(null)
@ -237,15 +240,22 @@ export default function QuizPage() {
if (hasStarted.current) return
hasStarted.current = true
setSelectedVoice(voice)
setStarting(true)
// DON'T set quizMode yet wait for data to load first
// (prevents mobile race condition where quiz renders without correct_answer)
try {
const quizRes = await api.get(`/quizzes/${id}${mode === 'study' ? '?study=true' : ''}`)
setQuiz(quizRes.data)
let quizData = quiz
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}`)
setAttemptId(attemptRes.data.id)
const mins = timerMinutes || quizRes.data.time_limit_minutes
const mins = timerMinutes || quizData.time_limit_minutes
const now = new Date().toISOString()
setStartedAt(now)
if (mode === 'exam' && mins) {
@ -255,6 +265,7 @@ export default function QuizPage() {
// NOW set mode quiz data is fully loaded, safe to render
setQuizMode(mode)
} catch { navigate('/') }
finally { setStarting(false) }
}
useEffect(() => {
@ -309,7 +320,7 @@ useEffect(() => {
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
navigate(`/results/${attemptId}`, { state: { result: res.data } })
} catch (err) {
if (!autoSubmit) alert(err.response?.data?.detail || 'Submission failed')
if (!autoSubmit) showToast(err.response?.data?.detail || 'Submission failed')
} finally { setSubmitting(false) }
}, [attemptId, answers, submitting])
@ -323,7 +334,14 @@ useEffect(() => {
<Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit Questions</Link>
</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>
)
@ -591,6 +609,13 @@ useEffect(() => {
</div>
</div>
</div>
{/* AI tutor — only in study mode, lazy-loaded */}
{isStudy && current && (
<Suspense fallback={null}>
<TeachChat question={current} />
</Suspense>
)}
</div>
)
}

View file

@ -3,6 +3,8 @@ import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import ConfirmButton from '../components/ConfirmButton'
import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog'
function InProgressSection() {
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 }) {
if (!query || !text) return <span>{text}</span>
const idx = text.toLowerCase().indexOf(query.toLowerCase())
@ -204,6 +273,7 @@ export default function QuizzesPage() {
const [studyQuestion, setStudyQuestion] = useState(null)
const [newCatName, setNewCatName] = useState('')
const [addingCat, setAddingCat] = useState(false)
const { dialogProps, openAlert } = useDialog()
const debounceRef = useRef(null)
const { user } = useAuth()
const navigate = useNavigate()
@ -256,7 +326,7 @@ export default function QuizzesPage() {
setCategories(prev => [...prev, res.data])
setNewCatName('')
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) => {
@ -283,6 +353,7 @@ export default function QuizzesPage() {
return (
<div>
<Dialog {...dialogProps} />
{studyQuestion && (
<QuestionStudyModal question={studyQuestion} query={searchQuery} onClose={() => setStudyQuestion(null)} />
)}
@ -374,6 +445,9 @@ export default function QuizzesPage() {
{/* In-progress quizzes */}
{!isSearching && <InProgressSection />}
{/* Past attempts (loads on demand) */}
{!isSearching && <PastAttemptsSection />}
{/* Normal quiz grid */}
{!isSearching && (
<>

View file

@ -203,7 +203,7 @@ function AdminSection() {
<Section title="Administration">
<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: '/trash', icon: '🗑️', label: 'Trash', desc: 'Restore deleted quizzes' },
{ to: '/jobs', icon: '📋', label: 'Extraction Jobs', desc: 'View extraction history' },