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:
parent
ecc4f64ad5
commit
975a31fb01
17 changed files with 1129 additions and 65 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
169
backend/app/routers/teach.py
Normal file
169
backend/app/routers/teach.py
Normal 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)}")
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
41
frontend/src/components/Dialog.jsx
Normal file
41
frontend/src/components/Dialog.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
276
frontend/src/components/TeachChat.jsx
Normal file
276
frontend/src/components/TeachChat.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
49
frontend/src/hooks/useDialog.js
Normal file
49
frontend/src/hooks/useDialog.js
Normal 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 }
|
||||
}
|
||||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue