pymite6941's picture
Initial commit
57086e9
Raw
History Blame Contribute Delete
17.4 kB
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>
);
}