Spaces:
Sleeping
Sleeping
Download frontend/src/App.js from neongeckocom/LLM_Comparison_Tool: direct link, hf CLI and curl.
- Browser
- Download file 15.9 kB
-
https://huggingface.co/spaces/neongeckocom/LLM_Comparison_Tool/resolve/main/frontend/src/App.js
- Command line
-
hf download hf://spaces/neongeckocom/LLM_Comparison_Tool/frontend/src/App.js
-
curl -L -o App.js https://huggingface.co/spaces/neongeckocom/LLM_Comparison_Tool/resolve/main/frontend/src/App.js
15.9 kB
| import { useState, useEffect, useCallback, useRef } from 'react'; | |
| import { Sun, Moon, Download, RefreshCw, Menu, X, Settings, Send, FileText, Users, Cpu, Ban, Eye, EyeOff, Activity } from 'lucide-react'; | |
| import { useTheme } from './contexts/ThemeContext'; | |
| import { fetchModels, fetchModelStatuses, 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 ModelStatusModal from './components/ModelStatusModal'; | |
| import './App.css'; | |
| function filterToOnlineModels(neonModels, comparisonProviders, statuses) { | |
| const onlineIds = new Set( | |
| (statuses || []) | |
| .filter((s) => s.status === 'online') | |
| .map((s) => s.model_id), | |
| ); | |
| // If status check returned nothing (e.g. failed), keep the full list. | |
| if (!statuses || !statuses.length) { | |
| return { neonModels, comparisonProviders }; | |
| } | |
| const filteredNeon = (neonModels || []).filter((m) => onlineIds.has(m.model_id)); | |
| const filteredProviders = (comparisonProviders || []) | |
| .map((p) => ({ | |
| ...p, | |
| models: (p.models || []).filter((m) => onlineIds.has(m.id)), | |
| })) | |
| .filter((p) => p.models.length > 0); | |
| return { neonModels: filteredNeon, comparisonProviders: filteredProviders }; | |
| } | |
| 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 [statusChecking, setStatusChecking] = useState(false); | |
| const [modelStatuses, setModelStatuses] = useState([]); | |
| const [modelStatusOpen, setModelStatusOpen] = useState(false); | |
| 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 applyStatusFilter = useCallback((rawNeon, rawProviders, statuses) => { | |
| const { neonModels: filteredNeon, comparisonProviders: filteredProviders } = | |
| filterToOnlineModels(rawNeon, rawProviders, statuses); | |
| setNeonModels(filteredNeon); | |
| setComparisonProviders(filteredProviders); | |
| setSelectedNeon((prev) => prev.filter((s) => filteredNeon.some((m) => m.model_id === s.model_id))); | |
| const onlineComparisonIds = new Set(filteredProviders.flatMap((p) => p.models.map((m) => m.id))); | |
| setSelectedComparison((prev) => prev.filter((id) => onlineComparisonIds.has(id))); | |
| }, []); | |
| const checkModelStatuses = useCallback(async (rawNeon, rawProviders) => { | |
| setStatusChecking(true); | |
| try { | |
| const data = await fetchModelStatuses(); | |
| const statuses = data.statuses || []; | |
| setModelStatuses(statuses); | |
| applyStatusFilter(rawNeon, rawProviders, statuses); | |
| } catch (e) { | |
| console.warn('Model status check failed; keeping full model list:', e); | |
| setModelStatuses([]); | |
| setNeonModels(rawNeon || []); | |
| setComparisonProviders(rawProviders || []); | |
| } finally { | |
| setStatusChecking(false); | |
| } | |
| }, [applyStatusFilter]); | |
| const loadModels = useCallback(async () => { | |
| setModelsLoading(true); | |
| setError(null); | |
| try { | |
| const data = await fetchModels(); | |
| const rawNeon = data.neon_models || []; | |
| const rawProviders = data.comparison_providers || []; | |
| // Show unfiltered list briefly while status check runs, then filter. | |
| setNeonModels(rawNeon); | |
| setComparisonProviders(rawProviders); | |
| setModelsLoading(false); | |
| await checkModelStatuses(rawNeon, rawProviders); | |
| } catch (e) { | |
| setError(`Failed to load models: ${e.message}`); | |
| setModelsLoading(false); | |
| } | |
| }, [checkModelStatuses]); | |
| useEffect(() => { loadModels(); }, [loadModels]); | |
| const handleRefreshStatuses = async () => { | |
| try { | |
| const data = await fetchModels(); | |
| const rawNeon = data.neon_models || []; | |
| const rawProviders = data.comparison_providers || []; | |
| await checkModelStatuses(rawNeon, rawProviders); | |
| } catch (e) { | |
| setError(`Failed to refresh model status: ${e.message}`); | |
| } | |
| }; | |
| 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"> | |
| <img src={`${process.env.PUBLIC_URL}/neon-logo.png`} alt="Neon.ai" className="app-logo" /> | |
| </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 || statusChecking ? '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 className="settings-section"> | |
| <button | |
| className="settings-option" | |
| onClick={() => { | |
| setSettingsOpen(false); | |
| setModelStatusOpen(true); | |
| }} | |
| > | |
| <Activity size={14} /> Model 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 || statusChecking} | |
| /> | |
| <ComparisonSelector | |
| providers={comparisonProviders} | |
| selected={selectedComparison} | |
| onSelectionChange={setSelectedComparison} | |
| loading={modelsLoading || statusChecking} | |
| /> | |
| </aside> | |
| <section className="content"> | |
| <div className="content-hint content-hint-desktop"> | |
| <p className="content-hint-lead">Ready to Compare?</p> | |
| <p className="content-hint-body">Pick at least one Neon.ai model in the sidebar, add comparison models, then enter a question and click Compare.</p> | |
| </div> | |
| <div className="content-hint content-hint-mobile"> | |
| <p className="content-hint-lead">Ready to Compare?</p> | |
| <p className="content-hint-body">Pick at least one Neon.ai model using the menu on the upper left, add comparison models, then enter a question and click Compare.</p> | |
| </div> | |
| <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> | |
| {modelStatusOpen && ( | |
| <ModelStatusModal | |
| statuses={modelStatuses} | |
| loading={statusChecking} | |
| onClose={() => setModelStatusOpen(false)} | |
| onRefresh={handleRefreshStatuses} | |
| /> | |
| )} | |
| </div> | |
| ); | |
| } | |
| export default App; | |