Spaces:
Sleeping
Sleeping
| import { useState, useRef, useEffect, useMemo } from 'react'; | |
| import ReactMarkdown from 'react-markdown'; | |
| import remarkGfm from 'remark-gfm'; | |
| import './App.css'; | |
| const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'; | |
| const PIPELINE_NODES = [ | |
| { id: 'researcher', label: 'Researcher', icon: '⬡', color: '#6366f1', match: 'research specialist' }, | |
| { id: 'credibility', label: 'Credibility', icon: '◈', color: '#a855f7', match: 'credibility analyst' }, | |
| { id: 'rag', label: 'RAG Engine', icon: '✦', color: '#60a5fa', match: 'rag pipeline' }, | |
| { id: 'synthesis', label: 'Synthesis', icon: '◉', color: '#34d399', match: 'synthesis analyst' }, | |
| ]; | |
| const DEPTHS = [ | |
| { id: 'quick', label: 'Quick', meta: '5 sources · ~30s' }, | |
| { id: 'standard', label: 'Standard', meta: '10 sources · ~90s' }, | |
| { id: 'deep', label: 'Deep', meta: '15 sources · ~3m' }, | |
| ]; | |
| const EXAMPLES = [ | |
| 'Health risks of microplastics in drinking water', | |
| 'Latest advances in large language model reasoning 2025', | |
| 'Economic impact of remote work on urban real estate', | |
| 'CRISPR gene editing applications in treating genetic diseases', | |
| ]; | |
| function detectAgentIndex(line) { | |
| const l = line.toLowerCase(); | |
| for (let i = 0; i < PIPELINE_NODES.length; i++) { | |
| if (l.includes(PIPELINE_NODES[i].match)) return i; | |
| } | |
| return -1; | |
| } | |
| function getLogClass(line) { | |
| const l = line.toLowerCase(); | |
| if (l.startsWith('[error]') || l.includes('traceback') || l.includes('exception')) return 'log-error'; | |
| if (l.includes('warning') || l.includes('warn')) return 'log-warn'; | |
| if (l.includes('agent:') || l.includes('working agent') || l.includes('# agent')) return 'log-agent'; | |
| if (l.includes('final answer') || l.includes('completed') || l.includes('finished')) return 'log-success'; | |
| if (l.includes('thought:') || l.includes('action:') || l.includes('observation:')) return 'log-step'; | |
| return ''; | |
| } | |
| function scoreColor(score) { | |
| if (score >= 0.7) return { color: '#34d399', borderColor: 'rgba(52,211,153,0.3)' }; | |
| if (score >= 0.5) return { color: '#fbbf24', borderColor: 'rgba(251,191,36,0.3)' }; | |
| return { color: '#f87171', borderColor: 'rgba(248,113,113,0.3)' }; | |
| } | |
| function scoreBarColor(score) { | |
| if (score >= 0.7) return '#34d399'; | |
| if (score >= 0.5) return '#fbbf24'; | |
| return '#f87171'; | |
| } | |
| function extractConfidence(report) { | |
| const m = report.match(/Confidence Assessment.*?(HIGH|MEDIUM|LOW|INSUFFICIENT)/is); | |
| return m ? m[1].toUpperCase() : null; | |
| } | |
| function triggerDownload(filename, content) { | |
| const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; a.download = filename; a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| function PipelineDiagram({ logs, status }) { | |
| const seen = useMemo(() => { | |
| const s = new Set(); | |
| for (const line of logs) { const idx = detectAgentIndex(line); if (idx !== -1) s.add(idx); } | |
| return s; | |
| }, [logs]); | |
| const activeIdx = useMemo(() => { | |
| if (status !== 'running') return -1; | |
| for (let i = logs.length - 1; i >= 0; i--) { const idx = detectAgentIndex(logs[i]); if (idx !== -1) return idx; } | |
| return status === 'running' ? 0 : -1; | |
| }, [logs, status]); | |
| const items = []; | |
| PIPELINE_NODES.forEach((node, i) => { | |
| const isActive = activeIdx === i; | |
| const isDone = seen.has(i) && (status === 'done' || (activeIdx !== -1 && activeIdx > i)); | |
| const isLit = isActive || isDone; | |
| items.push( | |
| <div key={node.id} | |
| className={`pipeline-node${isActive ? ' pipeline-node--active' : ''}${isDone ? ' pipeline-node--done' : ''}${!isLit ? ' pipeline-node--idle' : ''}`} | |
| style={{ '--nc': node.color }}> | |
| <span className="pipeline-node-icon">{isDone ? '✓' : node.icon}</span> | |
| <span className="pipeline-node-label">{node.label}</span> | |
| </div> | |
| ); | |
| if (i < PIPELINE_NODES.length - 1) { | |
| items.push( | |
| <div key={`c${i}`} className={`pipeline-conn${isLit ? ' pipeline-conn--lit' : ''}`}> | |
| <div className="pipeline-conn-line" /> | |
| <div className="pipeline-conn-arrow">›</div> | |
| </div> | |
| ); | |
| } | |
| }); | |
| return <div className="pipeline">{items}</div>; | |
| } | |
| export default function App() { | |
| const [query, setQuery] = useState(''); | |
| const [depth, setDepth] = useState('standard'); | |
| const [logs, setLogs] = useState([]); | |
| const [report, setReport] = useState(null); | |
| const [sources, setSources] = useState([]); | |
| const [status, setStatus] = useState('idle'); | |
| const [activeTab, setActiveTab] = useState('logs'); | |
| const logEndRef = useRef(null); | |
| const abortRef = useRef(null); | |
| useEffect(() => { | |
| if (activeTab === 'logs') logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); | |
| }, [logs, activeTab]); | |
| useEffect(() => { if (report) setActiveTab('result'); }, [report]); | |
| async function handleRun() { | |
| if (!query.trim() || status === 'running') return; | |
| const controller = new AbortController(); | |
| abortRef.current = controller; | |
| setLogs([]); setReport(null); setSources([]); setActiveTab('logs'); setStatus('running'); | |
| try { | |
| const res = await fetch(`${API}/research`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ query: query.trim(), depth }), | |
| signal: controller.signal, | |
| }); | |
| if (!res.ok) { | |
| const j = await res.json().catch(() => ({})); | |
| setLogs(prev => [...prev, `[ERROR] ${j.error ?? `Server ${res.status}`}`]); | |
| setStatus('error'); | |
| return; | |
| } | |
| const reader = res.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buf = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buf += decoder.decode(value, { stream: true }); | |
| const lines = buf.split('\n'); | |
| buf = lines.pop(); | |
| for (const raw of lines) { | |
| if (!raw.startsWith('data: ')) continue; | |
| let payload; | |
| try { payload = JSON.parse(raw.slice(6)); } catch { continue; } | |
| if (payload === '__DONE__') { setStatus(s => s === 'running' ? 'done' : s); return; } | |
| if (payload?.type === 'report') setReport(payload.content); | |
| else if (payload?.type === 'sources') setSources(payload.content ?? []); | |
| else setLogs(prev => [...prev, payload]); | |
| } | |
| } | |
| setStatus('done'); | |
| } catch (err) { | |
| if (err.name !== 'AbortError') { | |
| setLogs(prev => [...prev, `[ERROR] ${err.message}`]); | |
| setStatus('error'); | |
| } | |
| } | |
| } | |
| function handleReset() { | |
| setQuery(''); setLogs([]); setReport(null); setSources([]); | |
| setStatus('idle'); setActiveTab('logs'); | |
| } | |
| const confidence = report ? extractConfidence(report) : null; | |
| const confClass = confidence ? `confidence-${confidence.toLowerCase()}` : ''; | |
| return ( | |
| <div className="app"> | |
| <header className="topbar"> | |
| <div className="topbar-brand"> | |
| <svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true"> | |
| <polygon points="10,1 18,5.5 18,14.5 10,19 2,14.5 2,5.5" stroke="currentColor" strokeWidth="1.5" fill="none"/> | |
| <circle cx="10" cy="10" r="2.5" fill="currentColor"/> | |
| </svg> | |
| <span>ResearchAgent</span> | |
| </div> | |
| <div className="topbar-right"> | |
| <span className="topbar-tag">AI Research Pipeline</span> | |
| </div> | |
| </header> | |
| <main className="workspace"> | |
| {/* ── Left panel ────────────────────────────────────── */} | |
| <section className="input-panel"> | |
| <h1 className="panel-title">What do you want researched?</h1> | |
| <p className="panel-sub"> | |
| The agent pipeline searches authoritative sources, scores each one for credibility, | |
| and synthesises a fully cited report — only trusted sources make it in. | |
| </p> | |
| <div className="field"> | |
| <label className="field-label">Research Topic</label> | |
| <textarea | |
| className="context-area" | |
| placeholder="e.g. What are the latest advances in quantum computing?" | |
| value={query} | |
| onChange={e => setQuery(e.target.value)} | |
| rows={4} | |
| disabled={status === 'running'} | |
| /> | |
| </div> | |
| <div className="field"> | |
| <label className="field-label">Depth</label> | |
| <div className="depth-row"> | |
| {DEPTHS.map(d => ( | |
| <button key={d.id} | |
| className={`depth-btn${depth === d.id ? ' depth-btn--active' : ''}`} | |
| onClick={() => setDepth(d.id)} | |
| disabled={status === 'running'}> | |
| <span className="depth-label">{d.label}</span> | |
| <span className="depth-meta">{d.meta}</span> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| <div className="action-row"> | |
| <button className="btn btn-primary" onClick={handleRun} disabled={!query.trim() || status === 'running'}> | |
| {status === 'running' ? <><span className="btn-spinner" /> Researching…</> : 'Run Research'} | |
| </button> | |
| {status === 'running' && ( | |
| <button className="btn btn-ghost" onClick={() => { abortRef.current?.abort(); setStatus('idle'); }}>Stop</button> | |
| )} | |
| {(status === 'done' || status === 'error') && ( | |
| <button className="btn btn-ghost" onClick={handleReset}>Reset</button> | |
| )} | |
| </div> | |
| <div className="examples"> | |
| <div className="examples-label">Examples</div> | |
| {EXAMPLES.map((ex, i) => ( | |
| <button key={i} className="example-btn" onClick={() => setQuery(ex)} disabled={status === 'running'}> | |
| {ex} | |
| </button> | |
| ))} | |
| </div> | |
| </section> | |
| {/* ── Right panel ───────────────────────────────────── */} | |
| <section className="log-panel"> | |
| <PipelineDiagram logs={logs} status={status} /> | |
| <div className="tab-bar"> | |
| <div className="tab-bar-left"> | |
| <div className="log-dots"> | |
| <div className="log-dot log-dot-r" /><div className="log-dot log-dot-y" /><div className="log-dot log-dot-g" /> | |
| </div> | |
| <button className={`tab-btn${activeTab === 'logs' ? ' tab-btn--active' : ''}`} onClick={() => setActiveTab('logs')}> | |
| Logs {logs.length > 0 && <span className="tab-count">{logs.length}</span>} | |
| </button> | |
| <button className={`tab-btn${activeTab === 'result' ? ' tab-btn--active' : ''}${report ? ' tab-btn--has-result' : ''}`} | |
| onClick={() => setActiveTab('result')} disabled={!report}> | |
| Report {report && <span className="tab-count tab-count--result">✓</span>} | |
| </button> | |
| <button className={`tab-btn${activeTab === 'sources' ? ' tab-btn--active' : ''}`} | |
| onClick={() => setActiveTab('sources')} disabled={sources.length === 0}> | |
| Sources {sources.length > 0 && <span className="tab-count tab-count--sources">{sources.length}</span>} | |
| </button> | |
| </div> | |
| {status !== 'idle' && ( | |
| <span className={`log-badge log-badge--${status}`}> | |
| {status === 'running' && <span className="badge-pulse" />} | |
| {status === 'running' ? 'Live' : status === 'done' ? 'Complete' : 'Error'} | |
| </span> | |
| )} | |
| </div> | |
| {/* Logs */} | |
| {activeTab === 'logs' && ( | |
| <> | |
| <div className="log-terminal" role="log" aria-live="polite"> | |
| {logs.length === 0 && status === 'idle' && ( | |
| <div className="log-empty"><span className="log-empty-icon">⬡</span><span>Agent output will stream here.</span></div> | |
| )} | |
| {logs.length === 0 && status === 'running' && ( | |
| <div className="log-empty log-empty--active"><span>Initializing research pipeline</span></div> | |
| )} | |
| {logs.map((line, i) => { | |
| const cls = getLogClass(line); | |
| const agentIdx = detectAgentIndex(line); | |
| if (agentIdx !== -1 && (line.toLowerCase().includes('agent:') || line.toLowerCase().includes('working agent'))) { | |
| const node = PIPELINE_NODES[agentIdx]; | |
| return ( | |
| <div key={i} className="log-step-card" style={{ '--nc': node.color }}> | |
| <span className="log-step-card-icon">{node.icon}</span> | |
| <span className="log-step-card-name">{node.label}</span> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div key={i} className={`log-line ${cls}`}> | |
| <span className="log-gutter">{String(i + 1).padStart(3, ' ')}</span> | |
| <span className="log-text">{line}</span> | |
| </div> | |
| ); | |
| })} | |
| <div ref={logEndRef} /> | |
| </div> | |
| {logs.length > 0 && ( | |
| <div className="log-footer"> | |
| <span>{logs.length} lines</span> | |
| <button className="log-copy" onClick={() => navigator.clipboard.writeText(logs.join('\n'))}>Copy all</button> | |
| </div> | |
| )} | |
| </> | |
| )} | |
| {/* Report */} | |
| {activeTab === 'result' && ( | |
| <div className="result-panel"> | |
| {!report | |
| ? <div className="log-empty"><span className="log-empty-icon">⬡</span><span>Report will appear once research completes.</span></div> | |
| : <> | |
| <div className="result-type-bar"> | |
| <span className="result-type-badge">REPORT</span> | |
| {confidence && <span className={`confidence-badge ${confClass}`}>{confidence}</span>} | |
| </div> | |
| <div className="result-body"> | |
| <div className="result-markdown"> | |
| <ReactMarkdown remarkPlugins={[remarkGfm]}>{report}</ReactMarkdown> | |
| </div> | |
| </div> | |
| <div className="log-footer"> | |
| <span>{report.length} chars · {sources.length} sources</span> | |
| <button className="btn-download" onClick={() => triggerDownload(`research_${query.slice(0,30).replace(/[^\w]/g,'_')}.md`, report)}> | |
| ⬇ Download .md | |
| </button> | |
| </div> | |
| </> | |
| } | |
| </div> | |
| )} | |
| {/* Sources */} | |
| {activeTab === 'sources' && ( | |
| <div className="result-panel"> | |
| {sources.length === 0 | |
| ? <div className="sources-empty"><span className="log-empty-icon">◈</span><span>Sources appear after research completes.</span></div> | |
| : <> | |
| <div className="result-type-bar"> | |
| <span className="result-type-badge">SOURCES</span> | |
| <span style={{ fontSize: 10, color: '#3d405a', fontFamily: 'var(--mono)', marginLeft: 'auto' }}>sorted by credibility</span> | |
| </div> | |
| <div className="sources-panel"> | |
| {sources.map((src, i) => { | |
| const score = src.credibility_score ?? 0; | |
| const { color, borderColor } = scoreColor(score); | |
| return ( | |
| <div key={i} className="source-card"> | |
| <div className="source-card-top"> | |
| <span className="source-score-pill" style={{ color, borderColor }}>{score.toFixed(2)}</span> | |
| <span className="source-title">{src.title || 'Untitled'}</span> | |
| <span className={`source-conf-badge ${src.confidence === 'high' ? 'conf-high' : 'conf-low'}`}>{src.confidence}</span> | |
| </div> | |
| <div className="source-bar-track"> | |
| <div className="source-bar-fill" style={{ width: `${score * 100}%`, background: scoreBarColor(score) }} /> | |
| </div> | |
| <div className="source-url"><a href={src.url} target="_blank" rel="noopener noreferrer">{src.url}</a></div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| <div className="log-footer"> | |
| <span> | |
| {sources.filter(s => s.confidence === 'high').length} high ·{' '} | |
| {sources.filter(s => s.confidence === 'low').length} low confidence | |
| </span> | |
| </div> | |
| </> | |
| } | |
| </div> | |
| )} | |
| </section> | |
| </main> | |
| </div> | |
| ); | |
| } | |