/** * Floating help chatbot: asks POST /api/help/chat and shows answer + sources. * Works offline via the backend FAQ matcher when Ollama is unreachable. */ import { useEffect, useRef, useState } from 'react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Chip from '@mui/material/Chip'; import CircularProgress from '@mui/material/CircularProgress'; import Drawer from '@mui/material/Drawer'; import Fab from '@mui/material/Fab'; import IconButton from '@mui/material/IconButton'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import { MessageCircle, Send, X } from 'lucide-react'; import { askHelp, type HelpChatSource } from '../../services/help'; import { useLabelEval, type PageId } from '../../context/LabelEvalContext'; import { PAGE_GUIDES } from '../../help/pageGuides'; interface ChatTurn { role: 'user' | 'assistant'; text: string; sources?: HelpChatSource[]; provider?: string; } const SUGGESTIONS = [ 'What is Sensorflow Studio?', 'How do I get started?', "What's new?", 'What version is this?', 'When do humans review labels?', 'What does the Command Center do?', 'Does help chat need a GPU?', ]; export default function HelpChatbot({ open, onOpenChange, }: { open: boolean; onOpenChange: (open: boolean) => void; }) { const { page, navigate } = useLabelEval(); const [input, setInput] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [turns, setTurns] = useState([ { role: 'assistant', text: 'Ask about any page or feature. I use the local help index (works on CPU Spaces); if Ollama is available the answer may be enriched.', }, ]); const bottomRef = useRef(null); const abortRef = useRef(null); useEffect(() => { if (open) bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [turns, open]); useEffect(() => { return () => abortRef.current?.abort(); }, []); const send = async (raw: string) => { const question = raw.trim(); if (!question || busy) return; setError(null); setInput(''); setTurns((t) => [...t, { role: 'user', text: question }]); setBusy(true); abortRef.current?.abort(); const ac = new AbortController(); abortRef.current = ac; try { const res = await askHelp({ question, page_id: page }, ac.signal); setTurns((t) => [ ...t, { role: 'assistant', text: res.answer, sources: res.sources, provider: res.provider, }, ]); } catch (err) { if ((err as Error).name === 'AbortError') return; // Client-side soft fallback when the API is down const guide = PAGE_GUIDES[page]; const fallback = guide != null ? `${guide.title}: ${guide.summary}\n\nKey actions:\n${guide.keyActions.map((a) => `• ${a}`).join('\n')}\n\n(Help API unreachable — showing the local page guide.)` : 'Help API unreachable. Open Help (?) → Pages for guides, or retry when the backend is up.'; setTurns((t) => [...t, { role: 'assistant', text: fallback }]); setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }; const jumpToSource = (src: HelpChatSource) => { const pageId = (src as HelpChatSource & { page_id?: string }).page_id; // sources from API may include page_id; also parse page:id const fromId = src.id.startsWith('page:') ? (src.id.slice(5) as PageId) : null; const target = (pageId || fromId) as PageId | null; if (target && PAGE_GUIDES[target]) { onOpenChange(false); navigate(target); } }; return ( <> onOpenChange(true)} aria-label="Open help chatbot" sx={{ position: 'fixed', right: 20, bottom: 20, zIndex: (t) => t.zIndex.drawer + 2, bgcolor: '#0288d1', '&:hover': { bgcolor: '#0277bd' }, }} > onOpenChange(false)} PaperProps={{ sx: { width: { xs: '100%', sm: 400 }, bgcolor: '#12171d', borderLeft: '1px solid #232a31', display: 'flex', flexDirection: 'column', }, }} > Help chatbot onOpenChange(false)} aria-label="Close help chatbot"> {turns.map((turn, i) => ( {turn.role === 'user' ? 'You' : 'Help'} {turn.text} {turn.provider ? ( via {turn.provider === 'faq_offline' ? 'local FAQ index' : turn.provider} ) : null} {turn.sources && turn.sources.length > 0 ? ( {turn.sources.map((s) => ( jumpToSource(s)} sx={{ bgcolor: '#1d242c', fontSize: 10, height: 22, cursor: s.id.startsWith('page:') ? 'pointer' : 'default' }} title={s.kind} /> ))} ) : null} ))} {busy ? ( Searching help index… ) : null} {error ? ( {error} ) : null}
{SUGGESTIONS.map((s) => ( void send(s)} disabled={busy} sx={{ bgcolor: '#1d242c', fontSize: 10, height: 22 }} /> ))} setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void send(input); } }} inputProps={{ 'aria-label': 'Help question' }} /> ); }