| import { useState, useEffect, useCallback, useRef } from 'react'; |
| import { Sun, Moon, Download, RefreshCw, Menu, X, Settings, Send, FileText, Users, Cpu, Ban, Eye, EyeOff, Sparkles } from 'lucide-react'; |
| import { useTheme } from './contexts/ThemeContext'; |
| import { fetchModels, runComparisonStream, uploadCsvComparison, downloadHistory } from './utils/api'; |
| import NeonModelSelector from './components/NeonModelSelector'; |
| import ComparisonSelector from './components/ComparisonSelector'; |
| import QueryInput from './components/QueryInput'; |
| import ResultsArea from './components/ResultsArea'; |
| import './App.css'; |
|
|
| function App() { |
| const { theme, toggleTheme } = useTheme(); |
| const [neonModels, setNeonModels] = useState([]); |
| const [comparisonProviders, setComparisonProviders] = useState([]); |
| const [selectedNeon, setSelectedNeon] = useState([]); |
| const [selectedComparison, setSelectedComparison] = useState([]); |
| const [results, setResults] = useState([]); |
| const [loading, setLoading] = useState(false); |
| const [modelsLoading, setModelsLoading] = useState(true); |
| const [error, setError] = useState(null); |
| const sessionIdRef = useRef(`session-${Date.now()}-${Math.random().toString(36).slice(2)}`); |
| const [hasHistory, setHasHistory] = useState(false); |
| const [sidebarOpen, setSidebarOpen] = useState(false); |
| const [csvMode, setCsvMode] = useState(false); |
| const [settingsOpen, setSettingsOpen] = useState(false); |
| const [personaTarget, setPersonaTarget] = useState('neon-only'); |
| const [showPersonaPrompt, setShowPersonaPrompt] = useState(false); |
| const [showPrePromptIndicator, setShowPrePromptIndicator] = useState(false); |
| const settingsRef = useRef(null); |
|
|
| useEffect(() => { |
| const handleClickOutside = (e) => { |
| if (settingsRef.current && !settingsRef.current.contains(e.target)) { |
| setSettingsOpen(false); |
| } |
| }; |
| document.addEventListener('mousedown', handleClickOutside); |
| return () => document.removeEventListener('mousedown', handleClickOutside); |
| }, []); |
|
|
| const loadModels = useCallback(async () => { |
| setModelsLoading(true); |
| setError(null); |
| try { |
| const data = await fetchModels(); |
| setNeonModels(data.neon_models || []); |
| setComparisonProviders(data.comparison_providers || []); |
| } catch (e) { |
| setError(`Failed to load models: ${e.message}`); |
| } finally { |
| setModelsLoading(false); |
| } |
| }, []); |
|
|
| useEffect(() => { loadModels(); }, [loadModels]); |
|
|
| const handleQuery = async (query) => { |
| if (!selectedNeon.length) { |
| setError('Please select at least one Neon model'); |
| return; |
| } |
| setLoading(true); |
| setError(null); |
|
|
| const resultEntry = { query, groups: [], timestamp: Date.now() }; |
| setResults(prev => [resultEntry, ...prev]); |
| const resultIndex = 0; |
|
|
| try { |
| await runComparisonStream( |
| query, |
| selectedNeon, |
| selectedComparison, |
| sessionIdRef.current, |
| (groupMeta) => { |
| setResults(prev => { |
| const updated = [...prev]; |
| const entry = { ...updated[resultIndex] }; |
| entry.groups = [...entry.groups, { |
| neon_model_id: groupMeta.neon_model_id, |
| neon_persona: groupMeta.neon_persona, |
| system_prompt: groupMeta.system_prompt || '', |
| query: groupMeta.query, |
| responses: [], |
| }]; |
| updated[resultIndex] = entry; |
| return updated; |
| }); |
| }, |
| (response) => { |
| setResults(prev => { |
| const updated = [...prev]; |
| const entry = { ...updated[resultIndex] }; |
| entry.groups = entry.groups.map((g, gi) => { |
| if (gi !== response.group_index) return g; |
| return { ...g, responses: [...g.responses, response] }; |
| }); |
| updated[resultIndex] = entry; |
| return updated; |
| }); |
| }, |
| () => { |
| setHasHistory(true); |
| }, |
| personaTarget, |
| ); |
| } catch (e) { |
| setError(e.message); |
| } finally { |
| setLoading(false); |
| } |
| }; |
|
|
| const handleCsvUpload = async (file) => { |
| if (!selectedNeon.length) { |
| setError('Please select at least one Neon model'); |
| return; |
| } |
| setLoading(true); |
| setError(null); |
| try { |
| const blob = await uploadCsvComparison(file, selectedNeon, selectedComparison, personaTarget); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `comparison_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')}.csv`; |
| a.click(); |
| URL.revokeObjectURL(url); |
| } catch (e) { |
| setError(e.message); |
| } finally { |
| setLoading(false); |
| } |
| }; |
|
|
| const handleDownloadHistory = async () => { |
| try { |
| const blob = await downloadHistory(sessionIdRef.current); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `comparison_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')}.csv`; |
| a.click(); |
| URL.revokeObjectURL(url); |
| } catch (e) { |
| setError('No history to download yet'); |
| } |
| }; |
|
|
| return ( |
| <div className="app"> |
| <header className="app-header"> |
| <div className="header-left"> |
| <button className="icon-btn sidebar-toggle" onClick={() => setSidebarOpen(o => !o)} title="Toggle model selection"> |
| {sidebarOpen ? <X size={18} /> : <Menu size={18} />} |
| </button> |
| <a href="https://www.neon.ai/" target="_blank" rel="noopener noreferrer" className="header-brand-link" aria-label="Neon.ai"> |
| <Sparkles size={28} className="app-logo" strokeWidth={1.75} /> |
| </a> |
| <h1 className="app-title"><a href="https://www.neon.ai/" target="_blank" rel="noopener noreferrer" className="app-title-link">Neon.ai</a> LLM Comparison Tool</h1> |
| </div> |
| <div className="header-right"> |
| <button className="icon-btn" onClick={loadModels} title="Refresh models"> |
| <RefreshCw size={18} className={modelsLoading ? 'spin' : ''} /> |
| </button> |
| {hasHistory && ( |
| <button className="icon-btn" onClick={handleDownloadHistory} title="Download session history as CSV"> |
| <Download size={18} /> |
| </button> |
| )} |
| <button className="icon-btn" onClick={toggleTheme} title="Toggle theme"> |
| {theme === 'light' ? <Moon size={18} /> : <Sun size={18} />} |
| </button> |
| <div className="settings-wrapper" ref={settingsRef}> |
| <button className="icon-btn" onClick={() => setSettingsOpen(o => !o)} title="Settings"> |
| <Settings size={18} /> |
| </button> |
| {settingsOpen && ( |
| <div className="settings-dropdown"> |
| <div className="settings-section"> |
| <span className="settings-label">Query Mode</span> |
| <button |
| className={`settings-option ${!csvMode ? 'active' : ''}`} |
| onClick={() => setCsvMode(false)} |
| > |
| <Send size={14} /> Single Query |
| </button> |
| <button |
| className={`settings-option ${csvMode ? 'active' : ''}`} |
| onClick={() => setCsvMode(true)} |
| > |
| <FileText size={14} /> CSV Batch |
| </button> |
| </div> |
| <div className="settings-section"> |
| <span className="settings-label">Pre-prompt</span> |
| <button |
| className={`settings-option ${personaTarget === 'all' ? 'active' : ''}`} |
| onClick={() => setPersonaTarget('all')} |
| > |
| <Users size={14} /> All models |
| </button> |
| <button |
| className={`settings-option ${personaTarget === 'neon-only' ? 'active' : ''}`} |
| onClick={() => setPersonaTarget('neon-only')} |
| > |
| <Cpu size={14} /> Neon.ai models only |
| </button> |
| <button |
| className={`settings-option ${personaTarget === 'none' ? 'active' : ''}`} |
| onClick={() => setPersonaTarget('none')} |
| > |
| <Ban size={14} /> No models |
| </button> |
| </div> |
| <div className="settings-section"> |
| <span className="settings-label">Display</span> |
| <button |
| className={`settings-option ${showPersonaPrompt ? 'active' : ''}`} |
| onClick={() => setShowPersonaPrompt(v => !v)} |
| > |
| {showPersonaPrompt ? <Eye size={14} /> : <EyeOff size={14} />} Show pre-prompt text |
| </button> |
| <button |
| className={`settings-option ${showPrePromptIndicator ? 'active' : ''}`} |
| onClick={() => setShowPrePromptIndicator(v => !v)} |
| > |
| {showPrePromptIndicator ? <Eye size={14} /> : <EyeOff size={14} />} Show pre-prompt status |
| </button> |
| </div> |
| </div> |
| )} |
| </div> |
| </div> |
| </header> |
| |
| <main className="app-main"> |
| <div className={`sidebar-overlay ${sidebarOpen ? 'visible' : ''}`} onClick={() => setSidebarOpen(false)} /> |
| <aside className={`sidebar ${sidebarOpen ? 'open' : ''}`}> |
| <h2 className="sidebar-title">AI Models</h2> |
| <NeonModelSelector |
| models={neonModels} |
| selected={selectedNeon} |
| onSelectionChange={setSelectedNeon} |
| loading={modelsLoading} |
| /> |
| <ComparisonSelector |
| providers={comparisonProviders} |
| selected={selectedComparison} |
| onSelectionChange={setSelectedComparison} |
| /> |
| </aside> |
| |
| <section className="content"> |
| <p className="content-hint content-hint-desktop">Select Neon.ai models and comparison models, then add your question and click 'Compare'</p> |
| <p className="content-hint content-hint-mobile">Select Neon.ai models and comparison models using the menu on the upper left, then add your question and click 'Compare'</p> |
| <QueryInput |
| onQuery={handleQuery} |
| onCsvUpload={handleCsvUpload} |
| loading={loading} |
| csvMode={csvMode} |
| selectedNeon={selectedNeon} |
| selectedComparison={selectedComparison} |
| comparisonProviders={comparisonProviders} |
| /> |
| |
| {error && ( |
| <div className="error-banner"> |
| {error} |
| <button onClick={() => setError(null)}>×</button> |
| </div> |
| )} |
| |
| <ResultsArea |
| results={results} |
| multipleNeon={selectedNeon.length > 1} |
| comparisonModelOrder={selectedComparison} |
| comparisonProviders={comparisonProviders} |
| showPersonaPrompt={showPersonaPrompt} |
| showPrePromptIndicator={showPrePromptIndicator} |
| /> |
| </section> |
| </main> |
| <footer className="app-footer"> |
| Copyright Neon.ai. All rights reserved.{' '} |
| <a href="https://www.neon.ai/contact" target="_blank" rel="noopener noreferrer">Patents and licensing</a> |
| </footer> |
| </div> |
| ); |
| } |
|
|
| export default App; |
|
|