- 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>
307 lines
12 KiB
JavaScript
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>
|
|
</>
|
|
)
|
|
}
|