// ChatPanel — persistent LLM consultation panel docked to the right rail. // Toggleable show/hide. Uses a server-side PHP proxy that talks to Groq // (OpenAI-compatible) so the API key stays server-side and visitors don't // need any local installation. // // Default endpoint: /atlas/chat.php (relative to the host) // Default model: llama-3.1-8b-instant (free, very fast on Groq) // // Features: // - context-aware system prompt (current question OR final recommendation) // - first-visit pulse on the toggle button // - auto-open when the result screen appears // - draggable "?" help icon: drag onto any element to ask the assistant // to explain that concept; the icon snaps back to its slot after drop const { useState, useRef, useEffect } = React; const DEFAULT_API_URL = (() => { if (typeof window === 'undefined') return '/chat.php'; const path = window.location.pathname || '/'; const base = path.replace(/[^\/]+$/, ''); return base + 'chat.php'; })(); const DEFAULT_MODEL = 'llama-3.1-8b-instant'; const SUGGESTED_MODELS = [ 'llama-3.1-8b-instant', 'llama-3.3-70b-versatile', 'llama3-groq-70b-8192-tool-use-preview', 'mixtral-8x7b-32768', 'gemma2-9b-it', ]; window.ChatPanel = function ChatPanel({ context, tone = 'serif-dark', open, onToggle, onTrack, sessionId }) { const track = onTrack || (() => {}); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [showSettings, setShowSettings] = useState(false); const [apiUrl, setApiUrl] = useState(() => { const stored = localStorage.getItem('atlas_api_url'); // Refuse stale Ollama-style URLs — the public site only works through // the Groq-backed PHP proxy. Users in dev can still override from ⚙. if (stored && /\/api\/chat\b/.test(stored)) return DEFAULT_API_URL; return stored || DEFAULT_API_URL; }); const [model, setModel] = useState(() => { const stored = localStorage.getItem('atlas_api_model'); // Same: a leftover llama3.1:8b (Ollama tag) makes Groq 404. Force reset. if (stored && /^llama3\.1:/.test(stored)) return DEFAULT_MODEL; return stored || DEFAULT_MODEL; }); const [firstVisit, setFirstVisit] = useState( () => !localStorage.getItem('atlas_chat_seen') ); const [dragging, setDragging] = useState(false); const [dragPos, setDragPos] = useState({ x: 0, y: 0 }); const scrollerRef = useRef(null); useEffect(() => { localStorage.setItem('atlas_api_url', apiUrl); }, [apiUrl]); useEffect(() => { localStorage.setItem('atlas_api_model', model); }, [model]); useEffect(() => { if (open && firstVisit) { localStorage.setItem('atlas_chat_seen', '1'); setFirstVisit(false); } }, [open, firstVisit]); useEffect(() => { if (scrollerRef.current) { scrollerRef.current.scrollTop = scrollerRef.current.scrollHeight; } }, [messages, busy, open]); const systemPrompt = buildSystemPrompt(context); const suggestions = buildSuggestions(context); const send = async (text) => { const content = (text || input).trim(); if (!content || busy) return; setInput(''); setError(null); const userMsg = { role: 'user', content }; const next = [...messages, userMsg]; setMessages(next); setBusy(true); track('chat_question', { content, contextKind: context && context.kind, contextId: context && context.kind === 'question' ? (context.question && context.question.id) : context && context.kind === 'result' ? (context.winner && context.winner.id) : null, turnIdx: next.length, }); try { // Keep only the last 8 messages to stay well under Groq's 6,000 TPM // free-tier limit. The system prompt already carries the relevant // technique context, so older chat history adds little value. const trimmed = next.slice(-8); const reply = await callChat({ url: apiUrl, model, messages: [{ role: 'system', content: systemPrompt }, ...trimmed], max_tokens: 512, }); setMessages([...next, { role: 'assistant', content: reply }]); track('chat_reply', { content: reply, contextKind: context && context.kind, replyChars: (reply || '').length, turnIdx: next.length + 1, }); } catch (e) { const msg = humaniseError(e.message || String(e)); setError(msg); track('chat_error', { error: msg }); } finally { setBusy(false); } }; const reset = () => { setMessages([]); setError(null); }; const onKeyDown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }; // ---- Draggable help icon ------------------------------------------------ // Uses WINDOW-level listeners during the drag so we always catch // pointer-up — and never mutates any DOM styles. The chat is hidden // from elementFromPoint via a `body.atlas-help-dragging` CSS rule // that sets pointer-events:none on the chat-rail for the duration of // the drag, then is removed cleanly on release. const startDrag = (e) => { e.preventDefault(); if (!open) onToggle(); setDragging(true); setDragPos({ x: e.clientX, y: e.clientY }); document.body.classList.add('atlas-help-dragging'); const onMove = (ev) => { setDragPos({ x: ev.clientX, y: ev.clientY }); document.querySelectorAll('.atlas-help-target').forEach(x => x.classList.remove('atlas-help-target')); const el = pickHelpTarget(ev.clientX, ev.clientY); if (el) el.classList.add('atlas-help-target'); }; const cleanup = () => { document.body.classList.remove('atlas-help-dragging'); document.querySelectorAll('.atlas-help-target').forEach(x => x.classList.remove('atlas-help-target')); window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); window.removeEventListener('pointercancel', onUp); setDragging(false); }; const onUp = (ev) => { const el = pickHelpTarget(ev.clientX, ev.clientY); cleanup(); const ctx = extractContextFrom(el); if (!ctx || ctx.text.length < 5) return; track('chat_drag_help', { target: ctx.text.slice(0, 240), tag: ctx.tag || null, }); send(`Explain this concept in the context of the Method Atlas, briefly: "${ctx.text}"`); }; window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); window.addEventListener('pointercancel', onUp); }; // Periodic "drag & ask" tooltip above the ? icon — appears every ~30 s // for a couple of seconds while the chat is open, so the user // discovers the feature without a permanent label cluttering the UI. const [hintShown, setHintShown] = useState(false); useEffect(() => { if (!open) return; let alive = true; const tick = () => { if (!alive) return; setHintShown(true); setTimeout(() => alive && setHintShown(false), 2400); }; const first = setTimeout(tick, 6000); const id = setInterval(tick, 28000); return () => { alive = false; clearTimeout(first); clearInterval(id); }; }, [open]); return (
{open && (
Method consultation
{showSettings && (

Default: server proxy chat.php backed by Groq. To use local Ollama, set URL to http://localhost:11434/api/chat and model to llama3.1:8b.

)}
Context
{describeContext(context)}
{messages.length === 0 && (

Ask anything about this question, the recommended technique, hypotheses, assumptions, sample size, or how to report results. Drag the ? icon onto any text on the page to get an explanation of that concept.

{suggestions.length > 0 && (
{suggestions.map((s, i) => ( ))}
)}
)} {messages.map((m, i) => (
{m.role === 'user' ? 'You' : 'Assistant'}
{m.content}
))} {busy && (
Assistant
)} {error && (
Could not reach the model.
{error}
  1. The server may not have a Groq API key configured yet — contact the site admin.
  2. If using local Ollama: ensure ollama serve is running and the URL ends in /api/chat.
)}