Spaces:
Sleeping
Sleeping
Monish Vijay Kumar
Deploy Northwestern CS Kiosk Docker Space with checkpoint and inference runtime
7056be6 | 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 ( | |
| <div className={`message ${role}${pending ? ' pending' : ''}`}> | |
| <div className="content">{text}</div> | |
| {meta ? <div className="meta">{meta}</div> : null} | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <details className="provenance"> | |
| <summary>Show provenance</summary> | |
| <div className="provenance-body"> | |
| {entry.blueprint ? ( | |
| <p> | |
| <strong>Blueprint:</strong> {entry.blueprint} | |
| </p> | |
| ) : null} | |
| {action.type ? ( | |
| <p> | |
| <strong>Action:</strong> {action.type} | |
| {action.arguments ? ` — ${JSON.stringify(action.arguments)}` : ''} | |
| </p> | |
| ) : null} | |
| {providerLabel || usage.model || usage.total_tokens != null || usage.response_tokens != null || usage.fallback ? ( | |
| <p> | |
| <strong>Usage:</strong>{' '} | |
| {[ | |
| 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(' • ')} | |
| </p> | |
| ) : null} | |
| {facts.length ? ( | |
| <> | |
| <h4>Facts</h4> | |
| <ul> | |
| {facts.map((fact, index) => ( | |
| <li key={`${fact.subject || fact.predicate || index}-${index}`}> | |
| <strong>{formatContent(fact.subject) || 'Subject'}</strong> —{' '} | |
| {formatContent(fact.predicate) || 'Detail'}: {formatContent(fact.value) || 'N/A'} | |
| {fact.source ? <em> ({formatContent(fact.source)})</em> : null} | |
| </li> | |
| ))} | |
| </ul> | |
| </> | |
| ) : null} | |
| {notes.length ? ( | |
| <> | |
| <h4>Notes</h4> | |
| <ul> | |
| {notes.map((note, index) => ( | |
| <li key={`${index}-${formatContent(note)}`}>{formatContent(note) || 'Note'}</li> | |
| ))} | |
| </ul> | |
| </> | |
| ) : null} | |
| </div> | |
| </details> | |
| ); | |
| } | |
| function HistoryEntry({ entry, providerCatalog }) { | |
| return ( | |
| <div className="history-entry"> | |
| <Message role="user" text={entry.question} meta={formatTimestamp(entry.timestamp)} /> | |
| <Message | |
| role="assistant" | |
| text={entry.answer || (entry.pending ? 'Thinking…' : '')} | |
| meta={buildUsageMeta(entry, providerCatalog)} | |
| pending={entry.pending} | |
| /> | |
| <Provenance entry={entry} providerCatalog={providerCatalog} /> | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <option key={provider.id} value={provider.id}> | |
| {label} | |
| {!provider.configured ? ' – configure to enable' : ''} | |
| </option> | |
| ); | |
| }); | |
| const hasActiveSession = sessions.some((session) => session.session_id === sessionId); | |
| const placeholderSession = ( | |
| <button type="button" className="session-item active" key="draft"> | |
| <span className="title">{sessionTitle || friendlyTitleFromDate()}</span> | |
| <span className="timestamp">Draft</span> | |
| </button> | |
| ); | |
| return ( | |
| <div className="app-shell"> | |
| <aside className="sidebar"> | |
| <div className="sidebar-header"> | |
| <div> | |
| <h2>Conversations</h2> | |
| <p>Jump back into a previous session or start fresh.</p> | |
| </div> | |
| <button type="button" className="new-chat" onClick={handleNewChat}> | |
| New chat | |
| </button> | |
| </div> | |
| <div className="session-list"> | |
| {sessionsError ? <p className="error">{sessionsError}</p> : null} | |
| {!sessions.length && placeholderSession} | |
| {sessions.map((session) => ( | |
| <button | |
| type="button" | |
| key={session.session_id} | |
| className={`session-item${session.session_id === sessionId ? ' active' : ''}`} | |
| onClick={() => handleSelectSession(session)} | |
| > | |
| <span className="title">{session.title || session.session_id}</span> | |
| <span className="timestamp"> | |
| {session.updated_at ? new Date(session.updated_at * 1000).toLocaleString() : ''} | |
| </span> | |
| </button> | |
| ))} | |
| {!hasActiveSession && sessions.length ? placeholderSession : null} | |
| </div> | |
| </aside> | |
| <main className="chat-pane"> | |
| <header className="chat-header"> | |
| <div> | |
| <h1>Northwestern CS Kiosk (Vanilla LM)</h1> | |
| <p>{sessionTitle || DEFAULT_SESSION_TITLE}</p> | |
| </div> | |
| <div className="model-picker"> | |
| <label htmlFor="provider-select">Response model</label> | |
| <select | |
| id="provider-select" | |
| value={selectedProvider || ''} | |
| onChange={(event) => setSelectedProvider(event.target.value || null)} | |
| > | |
| {providerOptions} | |
| </select> | |
| <p className="model-note">{providerNote}</p> | |
| </div> | |
| </header> | |
| <section className="chat-log"> | |
| {historyLoading ? <p className="status">Loading conversation…</p> : null} | |
| {historyError ? <p className="error">{historyError}</p> : null} | |
| {!historyEntries.length && !historyLoading ? ( | |
| <div className="empty-state"> | |
| <p>Ask about faculty, research, upcoming events, and more.</p> | |
| </div> | |
| ) : null} | |
| {historyEntries.map((entry) => ( | |
| <HistoryEntry key={entry.id} entry={entry} providerCatalog={providerCatalog} /> | |
| ))} | |
| </section> | |
| <form className="composer" onSubmit={handleSend}> | |
| <textarea | |
| ref={composerRef} | |
| placeholder="Ask anything about the CS department…" | |
| value={composerValue} | |
| onChange={(event) => setComposerValue(event.target.value)} | |
| onKeyDown={handleComposerKeyDown} | |
| disabled={isSending} | |
| rows={3} | |
| /> | |
| <button type="submit" disabled={isSending || !composerValue.trim()}> | |
| {isSending ? 'Sending…' : 'Send'} | |
| </button> | |
| </form> | |
| </main> | |
| </div> | |
| ); | |
| } | |
| export default App; | |