import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import './App.css'; import { fetchHistory, fetchProviders, fetchSessions, sendQuery } from './api/client'; const STORAGE_KEYS = { sessionId: 'backend.sessionId', sessionTitle: 'backend.sessionTitle', }; const DEFAULT_SESSION_TITLE = 'Your virtual receptionist for faculty, research, and events.'; const formatTimestamp = (ts) => { if (!ts) { return ''; } const date = new Date(ts * 1000); return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }; const friendlyTitleFromDate = (date = new Date()) => `Chat – ${date.toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', })}`; const toHistoryEntry = (entry, index = 0) => ({ id: `${entry.session_id || 'session'}-${entry.timestamp || index}`, question: entry.question, answer: entry.answer, timestamp: entry.timestamp, blueprint: entry.blueprint, facts: entry.facts || [], notes: entry.notes || [], usage: entry.usage, action: entry.action, pending: Boolean(entry.pending), }); const buildUsageMeta = (entry, providerCatalog) => { if (entry.pending) { return 'Thinking…'; } const usage = entry.usage || {}; const metaParts = []; if (usage.provider) { const providerLabel = providerCatalog[usage.provider]?.name || usage.provider; metaParts.push(`Provider: ${providerLabel}`); } if (usage.model) { metaParts.push(`Model: ${usage.model}`); } if (usage.total_tokens != null) { metaParts.push(`Tokens: ${usage.total_tokens}`); } else if (usage.response_tokens != null) { metaParts.push(`Tokens: ${usage.response_tokens}`); } if (usage.fallback) { metaParts.push('Fallback: rule-based'); } if (metaParts.length) { return metaParts.join(' • '); } return formatTimestamp(entry.timestamp); }; const hasProvenance = (entry) => Boolean( entry.blueprint || entry.facts?.length || entry.notes?.length || entry.action || (entry.usage && Object.keys(entry.usage).length), ); const formatContent = (value) => { if (value == null) { return ''; } if (typeof value === 'string') { return value; } if (typeof value === 'number' || typeof value === 'boolean') { return String(value); } try { return JSON.stringify(value); } catch { return String(value); } }; function Message({ role, text, meta, pending }) { return (
{text}
{meta ?
{meta}
: null}
); } function Provenance({ entry, providerCatalog }) { if (!hasProvenance(entry)) { return null; } const facts = entry.facts || []; const notes = entry.notes || []; const usage = entry.usage || {}; const action = entry.action || {}; const providerLabel = usage.provider ? providerCatalog[usage.provider]?.name || usage.provider : null; return (
Show provenance
{entry.blueprint ? (

Blueprint: {entry.blueprint}

) : null} {action.type ? (

Action: {action.type} {action.arguments ? ` — ${JSON.stringify(action.arguments)}` : ''}

) : null} {providerLabel || usage.model || usage.total_tokens != null || usage.response_tokens != null || usage.fallback ? (

Usage:{' '} {[ providerLabel ? `Provider: ${providerLabel}` : null, usage.model ? `Model: ${usage.model}` : null, usage.total_tokens != null ? `Tokens: ${usage.total_tokens}` : usage.response_tokens != null ? `Tokens: ${usage.response_tokens}` : null, usage.fallback ? 'Fallback: rule-based' : null, ] .filter(Boolean) .join(' • ')}

) : null} {facts.length ? ( <>

Facts

) : null} {notes.length ? ( <>

Notes

) : null}
); } function HistoryEntry({ entry, providerCatalog }) { return (
); } function App() { const storedSessionId = localStorage.getItem(STORAGE_KEYS.sessionId); const storedSessionTitle = localStorage.getItem(STORAGE_KEYS.sessionTitle); const [sessionId, setSessionId] = useState(storedSessionId || `session-${Date.now()}`); const [sessionTitle, setSessionTitle] = useState(storedSessionTitle || DEFAULT_SESSION_TITLE); const [historyEntries, setHistoryEntries] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); const [sessions, setSessions] = useState([]); const [providers, setProviders] = useState([]); const [selectedProvider, setSelectedProvider] = useState(null); const [providerError, setProviderError] = useState(''); const [sessionsError, setSessionsError] = useState(''); const [historyError, setHistoryError] = useState(''); const [composerValue, setComposerValue] = useState(''); const [isSending, setIsSending] = useState(false); const composerRef = useRef(null); const providerCatalog = useMemo(() => { const catalog = {}; providers.forEach((provider) => { catalog[provider.id] = provider; }); return catalog; }, [providers]); const providerNote = useMemo(() => { if (providerError) { return providerError; } if (!selectedProvider) { return 'Select a model to route responses.'; } const provider = providerCatalog[selectedProvider]; if (!provider) { return 'Select a model to route responses.'; } if (!provider.configured) { return provider.note || 'Configure this provider before using it.'; } return `Default model: ${provider.default_model || 'default'}`; }, [providerCatalog, providerError, selectedProvider]); const persistSessionId = useCallback((value) => { localStorage.setItem(STORAGE_KEYS.sessionId, value); setSessionId(value); }, []); const persistSessionTitle = useCallback((value) => { localStorage.setItem(STORAGE_KEYS.sessionTitle, value); setSessionTitle(value); }, []); const loadProviders = useCallback(async () => { try { setProviderError(''); const payload = await fetchProviders(); const entries = Object.values(payload.providers || {}); entries.sort((a, b) => { if (a.id === 'vanilla') return -1; if (b.id === 'vanilla') return 1; return a.name.localeCompare(b.name); }); setProviders(entries); const available = new Set(entries.map((entry) => entry.id)); const fallback = (payload.default_provider && available.has(payload.default_provider) && payload.default_provider) || entries[0]?.id || null; setSelectedProvider((current) => { if (current && available.has(current)) { return current; } return fallback; }); } catch (error) { setProviderError(error.message || 'Unable to load providers.'); } }, []); const loadSessions = useCallback(async () => { try { setSessionsError(''); const payload = await fetchSessions(); setSessions(payload.sessions || []); } catch (error) { setSessionsError(error.message || 'Unable to load sessions.'); } }, []); const loadHistoryData = useCallback( async (targetSessionId) => { if (!targetSessionId) { return; } try { setHistoryError(''); setHistoryLoading(true); const payload = await fetchHistory(targetSessionId); const entries = (payload.history || []).map((entry, index) => toHistoryEntry( { ...entry, session_id: targetSessionId, }, index, ), ); setHistoryEntries(entries); if (payload.title) { persistSessionTitle(payload.title); } } catch (error) { setHistoryError(error.message || 'Unable to load history.'); } finally { setHistoryLoading(false); } }, [persistSessionTitle], ); useEffect(() => { const updateShellHeight = () => { const root = document.getElementById('root'); if (!root) { return; } const topOffset = root.getBoundingClientRect().top; const available = window.innerHeight - topOffset; const clamped = Math.max(available, 320); document.documentElement.style.setProperty('--app-shell-height', `${clamped}px`); }; updateShellHeight(); window.addEventListener('resize', updateShellHeight); window.addEventListener('orientationchange', updateShellHeight); return () => { window.removeEventListener('resize', updateShellHeight); window.removeEventListener('orientationchange', updateShellHeight); }; }, []); useEffect(() => { loadProviders(); loadSessions(); }, [loadProviders, loadSessions]); useEffect(() => { loadHistoryData(sessionId); }, [loadHistoryData, sessionId]); useEffect(() => { composerRef.current?.focus(); }, []); const handleSelectSession = (session) => { if (!session) { return; } persistSessionId(session.session_id); persistSessionTitle(session.title || friendlyTitleFromDate(new Date(session.created_at * 1000))); loadHistoryData(session.session_id); }; const handleNewChat = () => { const newSessionId = `session-${Date.now()}`; const title = friendlyTitleFromDate(); persistSessionId(newSessionId); persistSessionTitle(title); setHistoryEntries([]); setComposerValue(''); }; const sendMessage = async () => { if (isSending) { return; } const question = composerValue.trim(); if (!question) { return; } setComposerValue(''); setIsSending(true); setHistoryError(''); const tempId = `pending-${Date.now()}`; const timestamp = Date.now() / 1000; setHistoryEntries((prev) => [ ...prev, { id: tempId, session_id: sessionId, question, answer: 'Thinking…', timestamp, pending: true, facts: [], notes: [], usage: null, action: null, }, ]); try { const payload = await sendQuery({ sessionId, question, provider: selectedProvider || undefined, }); setHistoryEntries((prev) => prev.map((entry) => entry.id === tempId ? { ...entry, id: `${payload.session_id || sessionId}-${timestamp}`, pending: false, answer: payload.answer || entry.answer, blueprint: payload.blueprint, facts: payload.facts || [], notes: payload.notes || [], usage: payload.usage || {}, action: payload.action || {}, } : entry, ), ); if (payload.session_id && payload.session_id !== sessionId) { persistSessionId(payload.session_id); } if (payload.session_title) { persistSessionTitle(payload.session_title); } loadSessions(); } catch (error) { setHistoryEntries((prev) => prev.map((entry) => entry.id === tempId ? { ...entry, pending: false, answer: `Sorry, something went wrong: ${error.message}`, } : entry, ), ); } finally { setIsSending(false); composerRef.current?.focus(); } }; const handleSend = (event) => { event.preventDefault(); void sendMessage(); }; const handleComposerKeyDown = (event) => { if ((event.key === 'Enter' || event.key === 'NumpadEnter') && !event.shiftKey) { if (event.nativeEvent.isComposing) { return; } event.preventDefault(); const form = event.currentTarget.form; if (form && typeof form.requestSubmit === 'function') { form.requestSubmit(); } else { void sendMessage(); } } }; const providerOptions = providers.map((provider) => { const label = provider.id === 'vanilla' ? provider.name : provider.default_model ? `${provider.name} (${provider.default_model})` : provider.name; return ( ); }); const hasActiveSession = sessions.some((session) => session.session_id === sessionId); const placeholderSession = ( ); return (

Northwestern CS Kiosk (Vanilla LM)

{sessionTitle || DEFAULT_SESSION_TITLE}

{providerNote}

{historyLoading ?

Loading conversation…

: null} {historyError ?

{historyError}

: null} {!historyEntries.length && !historyLoading ? (

Ask about faculty, research, upcoming events, and more.

) : null} {historyEntries.map((entry) => ( ))}