pdf-quiz-generator/frontend/src/components/TeachChat.jsx
Daniel 4cbaa529f0 Workers x4, follow-up suggestions, singleton lock, admin error details
- docker-compose.yml: --workers 4 on uvicorn
- main.py: Redis singleton lock so only one worker runs scheduler + backfill (prevents 4x cron jobs)
- teach.py: system prompt requests 3 follow-up suggestions ("> " prefix format)
- teach.py: parse and return suggestions[] separately from reply
- TeachChat.jsx: render clickable suggestion chips after last assistant message (click fills input)
- admin.py: restore full error details for admin endpoints (ElevenLabs, Polly, LiteLLM)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 01:27:03 +02:00

307 lines
12 KiB
JavaScript

import { useState, useEffect, useRef } from 'react'
import ReactMarkdown from 'react-markdown'
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, suggestions?}
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, suggestions: res.data.suggestions || [] }])
} 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: '90%', display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{
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',
}}>
{m.role === 'assistant'
? <ReactMarkdown components={{
p: ({children}) => <p style={{margin: '0 0 6px'}}>{children}</p>,
ul: ({children}) => <ul style={{margin: '4px 0', paddingLeft: 18}}>{children}</ul>,
ol: ({children}) => <ol style={{margin: '4px 0', paddingLeft: 18}}>{children}</ol>,
li: ({children}) => <li style={{marginBottom: 2}}>{children}</li>,
strong: ({children}) => <strong style={{fontWeight: 700}}>{children}</strong>,
code: ({children}) => <code style={{background: 'var(--border)', borderRadius: 3, padding: '1px 4px', fontSize: '0.82em'}}>{children}</code>,
}}>{m.content}</ReactMarkdown>
: m.content
}
</div>
{/* Clickable follow-up suggestion chips — only on last assistant message */}
{m.role === 'assistant' && m.suggestions?.length > 0 && i === messages.length - 1 && !loading && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{m.suggestions.map((s, si) => (
<button
key={si}
onClick={() => { setInput(s); setTimeout(() => inputRef.current?.focus(), 50) }}
style={{
textAlign: 'left', background: 'var(--card-bg)', border: '1px solid var(--primary)',
borderRadius: 8, padding: '5px 10px', fontSize: '0.78rem', cursor: 'pointer',
color: 'var(--primary)', lineHeight: 1.4,
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--primary-light, rgba(99,102,241,0.08))'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--card-bg)'}
>
{s}
</button>
))}
</div>
)}
</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>
</>
)
}