| import { useState, useRef, useEffect, useMemo } from 'react'; |
| import ReactMarkdown from 'react-markdown'; |
| import remarkGfm from 'remark-gfm'; |
| import { |
| BarChart, Bar, |
| LineChart, Line, |
| PieChart, Pie, |
| ScatterChart, Scatter, ZAxis, |
| FunnelChart, Funnel, LabelList, |
| RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis, |
| Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, |
| } from 'recharts'; |
| import './App.css'; |
|
|
| const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'; |
| const CHART_COLORS = [ |
| '#6366f1', |
| '#34d399', |
| '#f472b6', |
| '#fbbf24', |
| '#60a5fa', |
| '#a78bfa', |
| '#fb923c', |
| ]; |
| const HISTORY_KEY = 'dataflow_history'; |
| const MAX_HISTORY = 5; |
|
|
| const PIPELINE_NODES = [ |
| { id: 'context', label: 'Context', match: 'analysis directive specialist', icon: '◈', color: '#6366f1' }, |
| { id: 'cleaner', label: 'Cleaner', match: 'data quality inspector', icon: '✧', color: '#60a5fa' }, |
| { id: 'prompt', label: 'Prompt Eng', match: 'data analysis prompt engineer', icon: '✦', color: '#8b5cf6' }, |
| { id: 'analyst', label: 'Analyst', match: 'senior data analyst', icon: '⬡', color: '#34d399' }, |
| { id: 'formatter', label: 'Formatter', match: 'structured output specialist', icon: '◉', color: '#f472b6' }, |
| { id: 'qa', label: 'QA Critic', match: 'analysis quality critic', icon: '◎', color: '#fb923c' }, |
| ]; |
|
|
| |
|
|
| 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 loadHistory() { |
| try { |
| return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'); |
| } catch { |
| return []; |
| } |
| } |
|
|
| function saveHistory(entries) { |
| try { |
| localStorage.setItem(HISTORY_KEY, JSON.stringify(entries)); |
| } catch {} |
| } |
|
|
| function timeAgo(ts) { |
| const diff = Date.now() - new Date(ts).getTime(); |
| const m = Math.floor(diff / 60000); |
| if (m < 1) return 'just now'; |
| if (m < 60) return `${m}m ago`; |
| const h = Math.floor(m / 60); |
| if (h < 24) return `${h}h ago`; |
| return `${Math.floor(h / 24)}d ago`; |
| } |
|
|
| function getLogClass(line) { |
| const l = line.toLowerCase(); |
| if (l.startsWith('[rotate]') || l.startsWith('[retry]')) return 'log-rotate'; |
| if ( |
| l.startsWith('[error]') || |
| l.includes('traceback') || |
| l.includes('exception') |
| ) |
| return 'log-error'; |
| if (l.includes('warning') || l.includes('warn')) return 'log-warn'; |
| if ( |
| detectAgentIndex(line) !== -1 && |
| (l.includes('agent:') || |
| l.includes('# agent') || |
| l.includes('working agent')) |
| ) |
| return 'log-agent-header'; |
| if ( |
| l.includes('agent:') || |
| l.includes('task:') || |
| l.includes('> entering') || |
| l.includes('crew') |
| ) |
| 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 encodeShare(str) { |
| try { return btoa(unescape(encodeURIComponent(str))); } catch { return ''; } |
| } |
| function decodeShare(str) { |
| try { return decodeURIComponent(escape(atob(str))); } catch { return null; } |
| } |
|
|
| function parseResult(content) { |
| try { |
| const parsed = JSON.parse(content.trim()); |
| const STRUCTURED_TYPES = ['chart', 'report', 'code', 'table', 'metrics']; |
| if (STRUCTURED_TYPES.includes(parsed.output_type)) |
| return { type: 'structured', data: parsed }; |
| return { type: 'json', data: parsed }; |
| } catch {} |
| const lines = content |
| .trim() |
| .split('\n') |
| .filter((l) => l.trim()); |
| if (lines.length >= 2) { |
| const cols = lines[0].split(',').length; |
| if ( |
| cols > 1 && |
| lines.slice(1, 5).every((l) => l.split(',').length === cols) |
| ) |
| return { type: 'csv', data: content }; |
| } |
| return { type: 'text', data: content }; |
| } |
|
|
| 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 useTypewriter(text, speed = 10) { |
| const [displayed, setDisplayed] = useState(''); |
| const [done, setDone] = useState(false); |
| const prevRef = useRef(''); |
|
|
| useEffect(() => { |
| if (!text) { |
| setDisplayed(''); |
| setDone(false); |
| return; |
| } |
| if (text === prevRef.current) return; |
| prevRef.current = text; |
| setDisplayed(''); |
| setDone(false); |
| let i = 0; |
| const id = setInterval(() => { |
| i++; |
| setDisplayed(text.slice(0, i)); |
| if (i >= text.length) { |
| setDone(true); |
| clearInterval(id); |
| } |
| }, speed); |
| return () => clearInterval(id); |
| }, [text, speed]); |
|
|
| return { displayed, done }; |
| } |
|
|
| |
|
|
| 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={`conn-${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>; |
| } |
|
|
| |
|
|
| function DownloadPopup({ parsed, onClose }) { |
| const options = []; |
| if (parsed.type === 'structured') { |
| const d = parsed.data; |
| const reportMd = [ |
| `# ${d.chart_title || 'Analysis Report'}`, |
| `\n## Summary\n${d.summary}`, |
| `\n## Findings\n${d.findings.map((f) => `- ${f}`).join('\n')}`, |
| `\n## Recommendations\n${d.recommendations.map((r) => `- ${r}`).join('\n')}`, |
| ].join('\n'); |
| options.push({ icon: '{}', label: 'Full JSON', ext: 'json', content: JSON.stringify(d, null, 2) }); |
| options.push({ icon: '📝', label: 'Report (Markdown)', ext: 'md', content: reportMd }); |
| if (d.data_points?.length) { |
| const csv = ['label,value,category,x_value', ...d.data_points.map((p) => `${p.label},${p.value},${p.category ?? ''},${p.x_value ?? ''}`)]; |
| options.push({ icon: '⊞', label: 'Chart Data (CSV)', ext: 'csv', content: csv.join('\n') }); |
| } |
| if (d.code_blocks?.length) { |
| const ext = { python: 'py', sql: 'sql', bash: 'sh', r: 'r', javascript: 'js' }; |
| d.code_blocks.forEach((b, i) => { |
| options.push({ icon: '{ }', label: `Code: ${b.title}`, ext: ext[b.language] ?? 'txt', content: b.code }); |
| }); |
| } |
| if (d.table_headers?.length) { |
| const csv = [d.table_headers.join(','), ...d.table_rows.map((r) => r.join(','))]; |
| options.push({ icon: '⊞', label: 'Table (CSV)', ext: 'csv', content: csv.join('\n') }); |
| } |
| if (d.metrics?.length) { |
| const csv = ['label,value,unit,trend,change,context', ...d.metrics.map((m) => `${m.label},${m.value},${m.unit ?? ''},${m.trend ?? ''},${m.change ?? ''},${m.context ?? ''}`)]; |
| options.push({ icon: '◈', label: 'Metrics (CSV)', ext: 'csv', content: csv.join('\n') }); |
| } |
| if (d.comparison_rows?.length) { |
| const csv = [`metric,${d.comparison_a_label || 'A'},${d.comparison_b_label || 'B'},winner`, |
| ...d.comparison_rows.map((r) => `${r.metric},${r.value_a},${r.value_b},${r.winner ?? ''}`)]; |
| options.push({ icon: '⇌', label: 'Comparison (CSV)', ext: 'csv', content: csv.join('\n') }); |
| } |
| if (d.heatmap_values?.length) { |
| const header = ['', ...(d.heatmap_col_labels ?? [])].join(','); |
| const dataRows = (d.heatmap_row_labels ?? []).map((r, i) => |
| [r, ...(d.heatmap_values[i] ?? [])].join(',') |
| ); |
| options.push({ icon: '▦', label: 'Heatmap (CSV)', ext: 'csv', content: [header, ...dataRows].join('\n') }); |
| } |
| } else if (parsed.type === 'json') { |
| options.push({ |
| icon: '{}', |
| label: 'JSON', |
| ext: 'json', |
| content: JSON.stringify(parsed.data, null, 2), |
| }); |
| } else if (parsed.type === 'csv') { |
| options.push({ icon: '⊞', label: 'CSV', ext: 'csv', content: parsed.data }); |
| } |
| options.push({ |
| icon: '📄', |
| label: 'Plain Text', |
| ext: 'txt', |
| content: |
| typeof parsed.data === 'string' |
| ? parsed.data |
| : JSON.stringify(parsed.data, null, 2), |
| }); |
|
|
| return ( |
| <div |
| className='popup-overlay' |
| onClick={onClose}> |
| <div |
| className='popup' |
| onClick={(e) => e.stopPropagation()}> |
| <div className='popup-header'> |
| <span>Download Result</span> |
| <button |
| className='popup-close' |
| onClick={onClose}> |
| × |
| </button> |
| </div> |
| <div className='popup-body'> |
| {options.map((opt, i) => ( |
| <button |
| key={i} |
| className='popup-option' |
| onClick={() => { |
| triggerDownload(`result.${opt.ext}`, opt.content); |
| onClose(); |
| }}> |
| <span className='popup-option-icon'>{opt.icon}</span> |
| <div className='popup-option-info'> |
| <span className='popup-option-label'>{opt.label}</span> |
| <span className='popup-option-ext'>.{opt.ext}</span> |
| </div> |
| </button> |
| ))} |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
|
|
| function ChartView({ data, overrideType }) { |
| const points = data.data_points ?? []; |
| const chartType = overrideType || data.chart_type; |
| const chartData = points.map((p) => ({ |
| name: p.label, |
| value: p.value, |
| x: p.x_value ?? null, |
| category: p.category, |
| })); |
| const tooltipStyle = { |
| background: '#13141a', |
| border: '1px solid #1e2030', |
| borderRadius: 8, |
| fontSize: 12, |
| }; |
|
|
| if (chartType === 'pie') { |
| return ( |
| <ResponsiveContainer width='100%' height={300}> |
| <PieChart> |
| <Pie |
| data={chartData} |
| dataKey='value' |
| nameKey='name' |
| cx='50%' |
| cy='50%' |
| outerRadius={110} |
| label={({ name, percent }) => |
| `${name} ${(percent * 100).toFixed(0)}%` |
| } |
| labelLine={false}> |
| {chartData.map((_, i) => ( |
| <Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} /> |
| ))} |
| </Pie> |
| <Tooltip contentStyle={tooltipStyle} /> |
| <Legend wrapperStyle={{ fontSize: 12, color: '#6b7280' }} /> |
| </PieChart> |
| </ResponsiveContainer> |
| ); |
| } |
|
|
| if (chartType === 'scatter') { |
| const scatterData = chartData.map((p) => ({ |
| x: p.x ?? 0, |
| y: p.value, |
| name: p.name, |
| })); |
| return ( |
| <ResponsiveContainer width='100%' height={300}> |
| <ScatterChart margin={{ top: 10, right: 20, bottom: 20, left: 0 }}> |
| <CartesianGrid strokeDasharray='3 3' stroke='#1e2030' /> |
| <XAxis |
| dataKey='x' |
| type='number' |
| name={data.x_axis_label || 'X'} |
| tick={{ fill: '#4a4f6a', fontSize: 11 }} |
| label={{ value: data.x_axis_label, position: 'insideBottom', offset: -10, fill: '#3d405a', fontSize: 11 }} |
| /> |
| <YAxis |
| dataKey='y' |
| type='number' |
| name={data.y_axis_label || 'Y'} |
| tick={{ fill: '#4a4f6a', fontSize: 11 }} |
| label={{ value: data.y_axis_label, angle: -90, position: 'insideLeft', fill: '#3d405a', fontSize: 11 }} |
| /> |
| <ZAxis range={[40, 40]} /> |
| <Tooltip |
| contentStyle={tooltipStyle} |
| cursor={{ strokeDasharray: '3 3' }} |
| content={({ payload }) => { |
| if (!payload?.length) return null; |
| const d = payload[0].payload; |
| return ( |
| <div style={tooltipStyle} className='scatter-tip'> |
| <p style={{ color: '#e2e8f0', margin: 0 }}>{d.name}</p> |
| <p style={{ color: '#6366f1', margin: 0 }}>{data.x_axis_label}: {d.x}</p> |
| <p style={{ color: '#34d399', margin: 0 }}>{data.y_axis_label}: {d.y}</p> |
| </div> |
| ); |
| }} |
| /> |
| <Scatter data={scatterData} fill='#6366f1' fillOpacity={0.8} /> |
| </ScatterChart> |
| </ResponsiveContainer> |
| ); |
| } |
|
|
| if (chartType === 'line') { |
| return ( |
| <ResponsiveContainer width='100%' height={300}> |
| <LineChart data={chartData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}> |
| <CartesianGrid strokeDasharray='3 3' stroke='#1e2030' /> |
| <XAxis dataKey='name' tick={{ fill: '#4a4f6a', fontSize: 11 }} /> |
| <YAxis |
| tick={{ fill: '#4a4f6a', fontSize: 11 }} |
| label={{ value: data.y_axis_label, angle: -90, position: 'insideLeft', fill: '#3d405a', fontSize: 11 }} |
| /> |
| <Tooltip contentStyle={tooltipStyle} /> |
| <Line |
| type='monotone' |
| dataKey='value' |
| stroke='#6366f1' |
| strokeWidth={2} |
| dot={{ fill: '#6366f1', r: 4 }} |
| activeDot={{ r: 6 }} |
| /> |
| </LineChart> |
| </ResponsiveContainer> |
| ); |
| } |
|
|
| if (chartType === 'funnel') { |
| const funnelData = chartData.map((d, i) => ({ |
| ...d, |
| fill: CHART_COLORS[i % CHART_COLORS.length], |
| })); |
| return ( |
| <ResponsiveContainer width='100%' height={300}> |
| <FunnelChart> |
| <Funnel dataKey='value' data={funnelData} isAnimationActive> |
| <LabelList position='center' fill='#fff' fontSize={11} dataKey='name' /> |
| </Funnel> |
| <Tooltip contentStyle={tooltipStyle} /> |
| </FunnelChart> |
| </ResponsiveContainer> |
| ); |
| } |
|
|
| if (chartType === 'radar') { |
| const radarData = points.map((p) => ({ |
| subject: p.label, |
| value: p.value, |
| value2: p.value2 ?? null, |
| })); |
| const hasB = radarData.some((d) => d.value2 != null); |
| return ( |
| <ResponsiveContainer width='100%' height={300}> |
| <RadarChart cx='50%' cy='50%' outerRadius={100} data={radarData}> |
| <PolarGrid stroke='#1e2030' /> |
| <PolarAngleAxis dataKey='subject' tick={{ fill: '#4a4f6a', fontSize: 11 }} /> |
| <PolarRadiusAxis tick={{ fill: '#3d405a', fontSize: 9 }} /> |
| <Radar |
| name={data.chart_title || 'Series A'} |
| dataKey='value' |
| stroke='#6366f1' |
| fill='#6366f1' |
| fillOpacity={0.35} |
| /> |
| {hasB && ( |
| <Radar |
| name={data.radar_b_label || 'Series B'} |
| dataKey='value2' |
| stroke='#34d399' |
| fill='#34d399' |
| fillOpacity={0.2} |
| /> |
| )} |
| <Legend wrapperStyle={{ fontSize: 12, color: '#6b7280' }} /> |
| <Tooltip contentStyle={tooltipStyle} /> |
| </RadarChart> |
| </ResponsiveContainer> |
| ); |
| } |
|
|
| |
| return ( |
| <ResponsiveContainer width='100%' height={300}> |
| <BarChart data={chartData} margin={{ top: 5, right: 20, bottom: 30, left: 0 }}> |
| <CartesianGrid strokeDasharray='3 3' stroke='#1e2030' vertical={false} /> |
| <XAxis |
| dataKey='name' |
| tick={{ fill: '#4a4f6a', fontSize: 11 }} |
| angle={-30} |
| textAnchor='end' |
| interval={0} |
| /> |
| <YAxis |
| tick={{ fill: '#4a4f6a', fontSize: 11 }} |
| label={{ value: data.y_axis_label, angle: -90, position: 'insideLeft', fill: '#3d405a', fontSize: 11 }} |
| /> |
| <Tooltip contentStyle={tooltipStyle} cursor={{ fill: 'rgba(99,102,241,0.06)' }} /> |
| <Bar dataKey='value' radius={[4, 4, 0, 0]}> |
| {chartData.map((_, i) => ( |
| <Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} /> |
| ))} |
| </Bar> |
| </BarChart> |
| </ResponsiveContainer> |
| ); |
| } |
|
|
| |
|
|
| function DataPointTable({ data }) { |
| const points = data.data_points ?? []; |
| const hasCategory = points.some((p) => p.category); |
| const hasX = points.some((p) => p.x_value != null); |
| return ( |
| <div className='result-table-wrap'> |
| <table className='result-table'> |
| <thead> |
| <tr> |
| <th>{data.x_axis_label || 'Label'}</th> |
| {hasX && <th>X Value</th>} |
| <th>{data.y_axis_label || 'Value'}</th> |
| {hasCategory && <th>Category</th>} |
| </tr> |
| </thead> |
| <tbody> |
| {points.map((p, i) => ( |
| <tr key={i}> |
| <td>{p.label}</td> |
| {hasX && <td>{p.x_value?.toLocaleString() ?? '—'}</td>} |
| <td>{p.value.toLocaleString()}</td> |
| {hasCategory && <td>{p.category ?? '—'}</td>} |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| ); |
| } |
|
|
| |
|
|
| function StructuredReport({ data }) { |
| const { displayed: sumDisplayed, done: sumDone } = useTypewriter(data.summary, 10); |
| const hasChart = data.output_type === 'chart' && data.data_points?.length > 0; |
| const hasCode = data.output_type === 'code' && data.code_blocks?.length > 0; |
| const hasMetrics = data.output_type === 'metrics' && data.metrics?.length > 0; |
| const hasTable = data.output_type === 'table' && data.table_headers?.length > 0; |
| const hasComparison = data.output_type === 'comparison' && data.comparison_rows?.length > 0; |
| const hasHeatmap = data.output_type === 'heatmap' && data.heatmap_values?.length > 0; |
|
|
| return ( |
| <div className='structured-report'> |
| {hasChart && ( |
| <div className='report-chart-inline'> |
| {data.chart_title && <h3 className='chart-title chart-title--inline'>{data.chart_title}</h3>} |
| <ChartView data={data} /> |
| </div> |
| )} |
| {hasMetrics && ( |
| <div className='report-chart-inline'> |
| <MetricsView data={data} /> |
| </div> |
| )} |
| {hasCode && ( |
| <div className='report-chart-inline'> |
| <CodeView data={data} /> |
| </div> |
| )} |
| {hasTable && ( |
| <div className='report-chart-inline'> |
| <TableOutputView data={data} /> |
| </div> |
| )} |
| {hasComparison && ( |
| <div className='report-chart-inline'> |
| <ComparisonView data={data} /> |
| </div> |
| )} |
| {hasHeatmap && ( |
| <div className='report-chart-inline'> |
| <HeatmapView data={data} /> |
| </div> |
| )} |
| <div className='report-summary'> |
| {sumDisplayed} |
| {!sumDone && <span className='typewriter-cursor' />} |
| </div> |
| <div className={`report-sections${sumDone ? ' report-sections--visible' : ''}`}> |
| {data.findings?.length > 0 && ( |
| <div className='report-section'> |
| <h3 className='report-section-title'>Findings</h3> |
| <ul className='report-list'> |
| {data.findings.map((f, i) => ( |
| <li key={i}> |
| <ReactMarkdown remarkPlugins={[remarkGfm]}>{f}</ReactMarkdown> |
| </li> |
| ))} |
| </ul> |
| </div> |
| )} |
| {data.recommendations?.length > 0 && ( |
| <div className='report-section'> |
| <h3 className='report-section-title'>Recommendations</h3> |
| <ul className='report-list report-list--rec'> |
| {data.recommendations.map((r, i) => ( |
| <li key={i}> |
| <ReactMarkdown remarkPlugins={[remarkGfm]}>{r}</ReactMarkdown> |
| </li> |
| ))} |
| </ul> |
| </div> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|
| function ResultView({ parsed }) { |
| if (parsed.type === 'structured') |
| return <StructuredReport data={parsed.data} />; |
| if (parsed.type === 'json') |
| return ( |
| <pre className='result-code result-json'> |
| {JSON.stringify(parsed.data, null, 2)} |
| </pre> |
| ); |
| if (parsed.type === 'csv') { |
| const lines = parsed.data |
| .trim() |
| .split('\n') |
| .filter((l) => l.trim()); |
| const headers = lines[0] |
| .split(',') |
| .map((h) => h.trim().replace(/^"|"$/g, '')); |
| const rows = lines |
| .slice(1) |
| .map((l) => l.split(',').map((c) => c.trim().replace(/^"|"$/g, ''))); |
| return ( |
| <div className='result-table-wrap'> |
| <table className='result-table'> |
| <thead> |
| <tr> |
| {headers.map((h, i) => ( |
| <th key={i}>{h}</th> |
| ))} |
| </tr> |
| </thead> |
| <tbody> |
| {rows.map((row, i) => ( |
| <tr key={i}> |
| {row.map((c, j) => ( |
| <td key={j}>{c}</td> |
| ))} |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| ); |
| } |
| return ( |
| <div className='result-markdown'> |
| <ReactMarkdown remarkPlugins={[remarkGfm]}>{parsed.data}</ReactMarkdown> |
| </div> |
| ); |
| } |
|
|
| |
|
|
| const TYPE_COLORS = { |
| chart: '#a78bfa', |
| report: '#34d399', |
| json: '#60a5fa', |
| csv: '#fbbf24', |
| text: '#94a3b8', |
| }; |
|
|
| function HistoryDrawer({ history, onSelect, onClose }) { |
| return ( |
| <div |
| className='popup-overlay' |
| onClick={onClose}> |
| <div |
| className='history-drawer' |
| onClick={(e) => e.stopPropagation()}> |
| <div className='popup-header'> |
| <span>Session History</span> |
| <button |
| className='popup-close' |
| onClick={onClose}> |
| × |
| </button> |
| </div> |
| <div className='history-list'> |
| {history.length === 0 ? ( |
| <div className='history-empty'> |
| No saved sessions yet. Complete an analysis to save it. |
| </div> |
| ) : ( |
| history.map((entry) => { |
| const c = TYPE_COLORS[entry.resultType] || '#94a3b8'; |
| return ( |
| <button |
| key={entry.id} |
| className='history-item' |
| onClick={() => onSelect(entry)}> |
| <div className='history-item-top'> |
| <span |
| className='history-item-badge' |
| style={{ |
| color: c, |
| borderColor: c + '44', |
| background: c + '11', |
| }}> |
| {(entry.resultType || 'unknown').toUpperCase()} |
| </span> |
| <span className='history-item-time'> |
| {timeAgo(entry.timestamp)} |
| </span> |
| </div> |
| <p className='history-item-context'> |
| {entry.context.slice(0, 90)} |
| {entry.context.length > 90 ? '…' : ''} |
| </p> |
| <div className='history-item-meta'> |
| {entry.logCount} log lines |
| {entry.fileName ? ` · ${entry.fileName}` : ''} |
| </div> |
| </button> |
| ); |
| }) |
| )} |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
|
|
| const LANG_COLORS = { |
| python: '#3b82f6', |
| sql: '#f59e0b', |
| bash: '#34d399', |
| r: '#a78bfa', |
| javascript: '#fbbf24', |
| }; |
|
|
| function CodeView({ data }) { |
| const blocks = data.code_blocks ?? []; |
| const [copied, setCopied] = useState(null); |
|
|
| function copyBlock(code, i) { |
| navigator.clipboard.writeText(code).then(() => { |
| setCopied(i); |
| setTimeout(() => setCopied(null), 1500); |
| }); |
| } |
|
|
| return ( |
| <div className='code-view'> |
| {blocks.map((block, i) => ( |
| <div key={i} className='code-block'> |
| <div className='code-block-header'> |
| <span |
| className='code-lang-badge' |
| style={{ color: LANG_COLORS[block.language] ?? '#94a3b8', borderColor: (LANG_COLORS[block.language] ?? '#94a3b8') + '44', background: (LANG_COLORS[block.language] ?? '#94a3b8') + '11' }}> |
| {block.language} |
| </span> |
| <span className='code-block-title'>{block.title}</span> |
| <button className='code-copy-btn' onClick={() => copyBlock(block.code, i)}> |
| {copied === i ? '✓ copied' : 'copy'} |
| </button> |
| </div> |
| <pre className='code-pre'><code>{block.code}</code></pre> |
| </div> |
| ))} |
| </div> |
| ); |
| } |
|
|
| |
|
|
| const TREND_ICON = { up: '↑', down: '↓', neutral: '→' }; |
| const TREND_COLOR = { up: '#34d399', down: '#f87171', neutral: '#6b7280' }; |
|
|
| function MetricsView({ data }) { |
| const items = data.metrics ?? []; |
| return ( |
| <div className='metrics-grid'> |
| {items.map((m, i) => { |
| const tc = TREND_COLOR[m.trend] ?? '#6b7280'; |
| const ti = TREND_ICON[m.trend] ?? ''; |
| return ( |
| <div key={i} className='metric-card'> |
| <span className='metric-label'>{m.label}</span> |
| <div className='metric-value-row'> |
| <span className='metric-value'>{m.value}</span> |
| {m.unit && <span className='metric-unit'>{m.unit}</span>} |
| </div> |
| {(m.trend || m.change) && ( |
| <div className='metric-trend' style={{ color: tc }}> |
| {ti && <span>{ti}</span>} |
| {m.change && <span>{m.change}</span>} |
| {m.context && <span className='metric-context'>{m.context}</span>} |
| </div> |
| )} |
| </div> |
| ); |
| })} |
| </div> |
| ); |
| } |
|
|
| |
|
|
| function TableOutputView({ data }) { |
| const headers = data.table_headers ?? []; |
| const rows = data.table_rows ?? []; |
| return ( |
| <div className='result-table-wrap'> |
| <table className='result-table'> |
| <thead> |
| <tr>{headers.map((h, i) => <th key={i}>{h}</th>)}</tr> |
| </thead> |
| <tbody> |
| {rows.map((row, i) => ( |
| <tr key={i}> |
| {row.map((cell, j) => <td key={j}>{cell}</td>)} |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| ); |
| } |
|
|
| |
|
|
| function ComparisonView({ data }) { |
| const rows = data.comparison_rows ?? []; |
| const aLabel = data.comparison_a_label || 'A'; |
| const bLabel = data.comparison_b_label || 'B'; |
| const aWins = rows.filter((r) => r.winner === 'a').length; |
| const bWins = rows.filter((r) => r.winner === 'b').length; |
| return ( |
| <div className='comparison-view'> |
| <div className='comparison-header'> |
| <div className='comparison-cell comparison-cell--metric' /> |
| <div className='comparison-cell comparison-cell--a'>{aLabel}</div> |
| <div className='comparison-cell comparison-cell--b'>{bLabel}</div> |
| </div> |
| {rows.map((row, i) => ( |
| <div key={i} className='comparison-row'> |
| <div className='comparison-cell comparison-cell--metric'>{row.metric}</div> |
| <div className={`comparison-cell comparison-cell--a${row.winner === 'a' ? ' comparison-cell--winner' : ''}`}> |
| {row.winner === 'a' && <span className='winner-badge'>▲</span>} |
| {row.value_a} |
| </div> |
| <div className={`comparison-cell comparison-cell--b${row.winner === 'b' ? ' comparison-cell--winner' : ''}`}> |
| {row.winner === 'b' && <span className='winner-badge'>▲</span>} |
| {row.value_b} |
| </div> |
| </div> |
| ))} |
| {rows.length > 0 && ( |
| <div className='comparison-footer'> |
| <span style={{ color: '#6366f1' }}>{aLabel}: {aWins} wins</span> |
| <span style={{ color: '#34d399' }}>{bLabel}: {bWins} wins</span> |
| {rows.length - aWins - bWins > 0 && ( |
| <span style={{ color: '#4a4f6a' }}>{rows.length - aWins - bWins} ties</span> |
| )} |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|
| |
|
|
| function HeatmapView({ data }) { |
| const rows = data.heatmap_row_labels ?? []; |
| const cols = data.heatmap_col_labels ?? []; |
| const values = data.heatmap_values ?? []; |
| const flat = values.flat().filter((v) => typeof v === 'number'); |
| const min = flat.length ? Math.min(...flat) : 0; |
| const max = flat.length ? Math.max(...flat) : 1; |
|
|
| function colorFor(v) { |
| const t = max === min ? 0.5 : (v - min) / (max - min); |
| const r = Math.round(60 + t * 190); |
| const g = Math.round(70 - t * 30); |
| const b = Math.round(241 - t * 180); |
| return `rgba(${r},${g},${b},${0.3 + t * 0.6})`; |
| } |
|
|
| return ( |
| <div className='heatmap-wrap'> |
| {data.heatmap_title && <h3 className='heatmap-title'>{data.heatmap_title}</h3>} |
| <div |
| className='heatmap-grid' |
| style={{ gridTemplateColumns: `auto repeat(${cols.length}, 1fr)` }}> |
| <div className='heatmap-cell heatmap-cell--corner' /> |
| {cols.map((c, j) => ( |
| <div key={j} className='heatmap-cell heatmap-cell--col-label'>{c}</div> |
| ))} |
| {rows.map((r, i) => ( |
| <> |
| <div key={`rl${i}`} className='heatmap-cell heatmap-cell--row-label'>{r}</div> |
| {cols.map((_, j) => { |
| const v = values[i]?.[j] ?? 0; |
| return ( |
| <div |
| key={j} |
| className='heatmap-cell heatmap-cell--value' |
| title={`${r} × ${cols[j]}: ${v}`} |
| style={{ background: colorFor(v) }}> |
| {v.toLocaleString(undefined, { maximumFractionDigits: 1 })} |
| </div> |
| ); |
| })} |
| </> |
| ))} |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
|
|
| const CHART_TYPES = [ |
| { id: 'bar', label: '▬ Bar' }, |
| { id: 'line', label: '╱ Line' }, |
| { id: 'pie', label: '◔ Pie' }, |
| { id: 'scatter', label: '⁘ Scatter' }, |
| { id: 'funnel', label: '⯆ Funnel' }, |
| { id: 'radar', label: '⬡ Radar' }, |
| ]; |
|
|
| function ArtifactsPanel({ parsed, onDownload }) { |
| const data = parsed.data; |
| const otype = data.output_type; |
|
|
| |
| const [viewType, setViewType] = useState(data.chart_type || 'bar'); |
| const [artifactTab, setArtifactTab] = useState( |
| otype === 'code' ? 'code' |
| : otype === 'metrics' ? 'metrics' |
| : otype === 'table' ? 'table' |
| : otype === 'comparison' ? 'comparison' |
| : otype === 'heatmap' ? 'heatmap' |
| : 'chart' |
| ); |
| const hasScatterData = data.data_points?.some((p) => p.x_value != null); |
| const hasRadarB = data.data_points?.some((p) => p.value2 != null); |
| const availableTypes = CHART_TYPES.filter((t) => { |
| if (t.id === 'scatter') return hasScatterData; |
| if (t.id === 'radar') return (data.data_points?.length ?? 0) >= 3; |
| if (t.id === 'pie') return (data.data_points?.length ?? 0) <= 6; |
| if (t.id === 'funnel') return (data.data_points?.length ?? 0) >= 2; |
| return true; |
| }); |
|
|
| const badgeLabel = { |
| chart: 'CHART', code: 'CODE', table: 'TABLE', |
| metrics: 'METRICS', comparison: 'COMPARE', heatmap: 'HEATMAP', |
| }[otype] ?? otype.toUpperCase(); |
| const footerInfo = otype === 'chart' ? `${data.data_points?.length} data points · ${viewType} view` |
| : otype === 'code' ? `${data.code_blocks?.length} block(s)` |
| : otype === 'table' ? `${data.table_rows?.length} rows · ${data.table_headers?.length} cols` |
| : otype === 'metrics' ? `${data.metrics?.length} metrics` |
| : otype === 'comparison' ? `${data.comparison_rows?.length} metrics compared` |
| : otype === 'heatmap' ? `${data.heatmap_row_labels?.length} × ${data.heatmap_col_labels?.length} matrix` |
| : ''; |
|
|
| return ( |
| <div className='result-panel'> |
| <div className='result-type-bar'> |
| <span className='result-type-badge'>{badgeLabel}</span> |
| {otype === 'chart' && ( |
| <div className='chart-type-switcher'> |
| {availableTypes.map((t) => ( |
| <button |
| key={t.id} |
| className={`chart-type-btn${viewType === t.id ? ' chart-type-btn--active' : ''}`} |
| onClick={() => setViewType(t.id)}> |
| {t.label} |
| </button> |
| ))} |
| </div> |
| )} |
| </div> |
| |
| {otype === 'chart' && ( |
| <div className='artifact-sub-tabs'> |
| {['chart', 'table', 'stats'].map((tab) => ( |
| <button key={tab} className={`artifact-sub-tab${artifactTab === tab ? ' artifact-sub-tab--active' : ''}`} onClick={() => setArtifactTab(tab)}> |
| {tab.charAt(0).toUpperCase() + tab.slice(1)} |
| </button> |
| ))} |
| </div> |
| )} |
| |
| <div className='artifact-body'> |
| {otype === 'code' && <CodeView data={data} />} |
| {otype === 'metrics' && <MetricsView data={data} />} |
| {otype === 'table' && <TableOutputView data={data} />} |
| {otype === 'comparison' && <ComparisonView data={data} />} |
| {otype === 'heatmap' && <HeatmapView data={data} />} |
| {otype === 'chart' && ( |
| <> |
| {artifactTab === 'chart' && ( |
| <> |
| {data.chart_title && <h2 className='chart-title'>{data.chart_title}</h2>} |
| {data.x_axis_label && <p className='chart-axis-label'>X: {data.x_axis_label} · Y: {data.y_axis_label}</p>} |
| <ChartView data={data} overrideType={viewType} /> |
| </> |
| )} |
| {artifactTab === 'table' && <DataPointTable data={data} />} |
| {artifactTab === 'stats' && ( |
| <div className='artifact-stats'> |
| {data.data_points.map((p, i) => ( |
| <div key={i} className='stat-chip' |
| style={{ borderColor: CHART_COLORS[i % CHART_COLORS.length] + '44', background: CHART_COLORS[i % CHART_COLORS.length] + '11' }}> |
| <span className='stat-label'>{p.label}</span> |
| <span className='stat-value' style={{ color: CHART_COLORS[i % CHART_COLORS.length] }}>{p.value.toLocaleString()}</span> |
| {p.category && <span className='stat-category'>{p.category}</span>} |
| </div> |
| ))} |
| </div> |
| )} |
| </> |
| )} |
| </div> |
| <div className='log-footer'> |
| <span>{footerInfo}</span> |
| <button className='btn-download' onClick={onDownload}>⬇ Download</button> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
|
|
| function FilePreviewPanel({ previews, files }) { |
| const [expanded, setExpanded] = useState(true); |
| const entries = files.filter((f) => previews[f.name]); |
| if (entries.length === 0) return null; |
| return ( |
| <div className='file-preview-panel'> |
| <div className='file-preview-header' onClick={() => setExpanded((v) => !v)}> |
| <span>File Preview</span> |
| <span className='file-preview-toggle'>{expanded ? '▾' : '▸'}</span> |
| </div> |
| {expanded && entries.map((f, i) => ( |
| <div key={i} className='file-preview-item'> |
| <div className='file-preview-name'>{f.name}</div> |
| <pre className='file-preview-content'>{previews[f.name]}</pre> |
| </div> |
| ))} |
| </div> |
| ); |
| } |
|
|
| |
|
|
| export default function App() { |
| const [context, setContext] = useState(''); |
| const [files, setFiles] = useState([]); |
| const [logs, setLogs] = useState([]); |
| const [result, setResult] = useState(null); |
| const [status, setStatus] = useState('idle'); |
| const [showLog, setShowLog] = useState(true); |
| const [activeTab, setActiveTab] = useState('logs'); |
| const [showDownload, setShowDownload] = useState(false); |
| const [showHistory, setShowHistory] = useState(false); |
| const [history, setHistory] = useState(() => loadHistory()); |
| const [filePreviews, setFilePreviews] = useState({}); |
| const [shareCopied, setShareCopied] = useState(false); |
| const logEndRef = useRef(null); |
| const fileInputRef = useRef(null); |
| const abortRef = useRef(null); |
| const analyzeRef = useRef(null); |
|
|
| useEffect(() => { |
| if (activeTab === 'logs') |
| logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); |
| }, [logs, activeTab]); |
|
|
| |
| useEffect(() => { |
| const hash = window.location.hash; |
| if (hash.startsWith('#r=')) { |
| const decoded = decodeShare(hash.slice(3)); |
| if (decoded) { |
| setResult(decoded); |
| setStatus('done'); |
| setActiveTab('result'); |
| } |
| } |
| }, []); |
|
|
| |
| useEffect(() => { |
| if (files.length === 0) { setFilePreviews({}); return; } |
| files.forEach((f) => { |
| if (filePreviews[f.name]) return; |
| if (f.type === 'application/pdf') { |
| setFilePreviews((p) => ({ ...p, [f.name]: '(PDF — preview not available)' })); |
| return; |
| } |
| f.text() |
| .then((text) => { |
| const lines = text.split('\n').slice(0, 8).join('\n'); |
| const preview = lines.length > 500 ? lines.slice(0, 500) + '…' : lines; |
| setFilePreviews((p) => ({ ...p, [f.name]: preview })); |
| }) |
| .catch(() => setFilePreviews((p) => ({ ...p, [f.name]: '(preview unavailable)' }))); |
| }); |
| }, [files]); |
|
|
| |
| useEffect(() => { |
| function onKeyDown(e) { |
| if (e.key === 'Escape') { |
| setShowDownload(false); |
| setShowHistory(false); |
| } |
| if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { |
| e.preventDefault(); |
| analyzeRef.current?.(); |
| } |
| } |
| window.addEventListener('keydown', onKeyDown); |
| return () => window.removeEventListener('keydown', onKeyDown); |
| }, []); |
|
|
| useEffect(() => { |
| if (result) setActiveTab('result'); |
| }, [result]); |
|
|
| |
| useEffect(() => { |
| if (status !== 'done' || !result) return; |
| const p = parseResult(result); |
| const entry = { |
| id: Date.now(), |
| timestamp: new Date().toISOString(), |
| context, |
| fileName: files.length > 0 ? files.map((f) => f.name).join(', ') : null, |
| result, |
| logCount: logs.length, |
| resultType: p.type === 'structured' ? p.data.output_type : p.type, |
| }; |
| const updated = [entry, ...history].slice(0, MAX_HISTORY); |
| setHistory(updated); |
| saveHistory(updated); |
| }, [status]); |
|
|
| const parsed = result ? parseResult(result) : null; |
| const hasChart = |
| parsed?.type === 'structured' && |
| parsed.data?.output_type === 'chart' && |
| parsed.data?.data_points?.length > 0; |
| const hasArtifacts = |
| parsed?.type === 'structured' && |
| ['chart', 'code', 'table', 'metrics', 'comparison', 'heatmap'].includes(parsed.data?.output_type); |
|
|
| async function handleAnalyze(isAutoRetry = false) { |
| if (!context.trim() || (status === 'running' && !isAutoRetry)) return; |
| const controller = new AbortController(); |
| abortRef.current = controller; |
|
|
| if (isAutoRetry) { |
| setLogs((prev) => [ |
| ...prev, |
| '[RETRY] All providers rate-limited. Auto-retrying in 10s…', |
| ]); |
| await new Promise((r) => setTimeout(r, 10000)); |
| if (controller.signal.aborted) return; |
| } |
| setLogs(isAutoRetry ? (prev) => [...prev] : []); |
| setResult(null); |
| setActiveTab('logs'); |
| setStatus('running'); |
| setShowLog(true); |
|
|
| const form = new FormData(); |
| form.append('context', context); |
| files.forEach((f) => form.append('files', f)); |
| let gotResult = false; |
|
|
| try { |
| const res = await fetch(`${API}/analyze`, { |
| method: 'POST', |
| body: form, |
| signal: controller.signal, |
| }); |
| if (!res.ok) { |
| let msg = `Server returned ${res.status}`; |
| try { |
| const j = await res.json(); |
| if (j.error) msg = j.error; |
| } catch {} |
| setLogs((prev) => [...prev, `[ERROR] ${msg}`]); |
| 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__') { |
| if (!gotResult && !isAutoRetry) { |
| |
| handleAnalyze(true); |
| } else { |
| setStatus(gotResult ? 'done' : 'error'); |
| } |
| return; |
| } |
| if (payload?.type === 'result') { |
| gotResult = true; |
| setResult(payload.content); |
| } else { |
| setLogs((prev) => [...prev, payload]); |
| } |
| } |
| } |
| setStatus('done'); |
| } catch (err) { |
| if (err.name !== 'AbortError') { |
| setLogs((prev) => [...prev, `[ERROR] ${err.message}`]); |
| setStatus('error'); |
| } |
| } |
| } |
|
|
| |
| analyzeRef.current = handleAnalyze; |
|
|
| function shareResult() { |
| if (!result) return; |
| const encoded = encodeShare(result); |
| const base = window.location.href.split('#')[0]; |
| const url = base + '#r=' + encoded; |
| window.location.hash = 'r=' + encoded; |
| navigator.clipboard.writeText(url).then(() => { |
| setShareCopied(true); |
| setTimeout(() => setShareCopied(false), 2000); |
| }); |
| } |
|
|
| function handleReset() { |
| setLogs([]); |
| setResult(null); |
| setStatus('idle'); |
| setActiveTab('logs'); |
| setFiles([]); |
| setFilePreviews({}); |
| setContext(''); |
| if (fileInputRef.current) fileInputRef.current.value = ''; |
| window.location.hash = ''; |
| } |
|
|
| function handleHistorySelect(entry) { |
| setContext(entry.context); |
| setResult(entry.result); |
| setLogs([]); |
| setStatus('done'); |
| setActiveTab('result'); |
| setFiles([]); |
| setShowHistory(false); |
| } |
|
|
| 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>DataFlow</span> |
| </div> |
| <div className='topbar-right'> |
| <span className='topbar-tag'>AI Agent Pipeline</span> |
| <button |
| className='toggle-log-btn' |
| onClick={() => setShowHistory(true)}> |
| History |
| {history.length > 0 && ( |
| <span className='toggle-log-count'>{history.length}</span> |
| )} |
| </button> |
| <button |
| className={`toggle-log-btn${showLog ? ' toggle-log-btn--active' : ''}`} |
| onClick={() => setShowLog((v) => !v)}> |
| <svg |
| width='16' |
| height='16' |
| viewBox='0 0 16 16' |
| fill='none'> |
| <rect |
| x='1' |
| y='2' |
| width='14' |
| height='12' |
| rx='2' |
| stroke='currentColor' |
| strokeWidth='1.3' |
| /> |
| <line |
| x1='6' |
| y1='2' |
| x2='6' |
| y2='14' |
| stroke='currentColor' |
| strokeWidth='1.3' |
| /> |
| </svg> |
| {showLog ? 'Hide Output' : 'Show Output'} |
| {!showLog && logs.length > 0 && ( |
| <span className='toggle-log-count'>{logs.length}</span> |
| )} |
| </button> |
| </div> |
| </header> |
| |
| <main className={`workspace${showLog ? '' : ' workspace--log-hidden'}`}> |
| <section className='input-panel'> |
| <h1 className='panel-title'>What do you want to analyze?</h1> |
| <p className='panel-sub'> |
| Describe your dataset and the question you want answered. The agent |
| pipeline will interpret your request, clean the data, and extract |
| insights. |
| </p> |
| |
| <div className='field'> |
| <label className='field-label'>Context</label> |
| <textarea |
| className='context-area' |
| placeholder='e.g. I have a CSV of monthly sales. Show me the top 5 products by revenue in Q3 and flag any anomalies…' |
| value={context} |
| onChange={(e) => setContext(e.target.value)} |
| rows={5} |
| disabled={status === 'running'} |
| /> |
| </div> |
| |
| <div className='field'> |
| <label className='field-label'>Data files <span className='field-label-sub'>(up to 3)</span></label> |
| <div |
| className={`dropzone${files.length > 0 ? ' dropzone--has-file' : ''}`} |
| onDrop={(e) => { |
| e.preventDefault(); |
| const dropped = Array.from(e.dataTransfer.files).slice(0, 3); |
| setFiles((prev) => [...prev, ...dropped].slice(0, 3)); |
| }} |
| onDragOver={(e) => e.preventDefault()} |
| onClick={() => fileInputRef.current?.click()} |
| role='button' |
| tabIndex={0} |
| onKeyDown={(e) => e.key === 'Enter' && fileInputRef.current?.click()}> |
| <input |
| ref={fileInputRef} |
| type='file' |
| accept='.csv,.json,.txt,.pdf,.xml' |
| multiple |
| onChange={(e) => { |
| const picked = Array.from(e.target.files).slice(0, 3); |
| setFiles((prev) => [...prev, ...picked].slice(0, 3)); |
| e.target.value = ''; |
| }} |
| style={{ display: 'none' }} |
| /> |
| {files.length > 0 ? ( |
| <div className='dropzone-filelist'> |
| {files.map((f, i) => ( |
| <span key={i} className='dropzone-filename'> |
| <span className='dropzone-icon'>📄</span> |
| {f.name} |
| <button |
| className='dropzone-clear' |
| onClick={(ev) => { |
| ev.stopPropagation(); |
| setFiles((prev) => prev.filter((_, j) => j !== i)); |
| }}> |
| × |
| </button> |
| </span> |
| ))} |
| {files.length < 3 && ( |
| <span className='dropzone-add'>+ add more</span> |
| )} |
| </div> |
| ) : ( |
| <span className='dropzone-hint'> |
| Drop files here or <u>click to upload</u> |
| <span className='dropzone-types'>CSV · JSON · TXT · PDF · XML · up to 3 files</span> |
| </span> |
| )} |
| </div> |
| </div> |
| |
| <FilePreviewPanel previews={filePreviews} files={files} /> |
| |
| <div className='action-row'> |
| <button |
| className='btn btn-primary' |
| onClick={handleAnalyze} |
| disabled={!context.trim() || status === 'running'}> |
| {status === 'running' ? ( |
| <> |
| <span className='btn-spinner' /> Running… |
| </> |
| ) : ( |
| 'Run Analysis' |
| )} |
| </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={() => handleAnalyze()} |
| title='Re-run with the same context and files'> |
| ↺ Rerun |
| </button> |
| <button |
| className='btn btn-ghost' |
| onClick={handleReset}> |
| Reset |
| </button> |
| </> |
| )} |
| <span className='kbd-hint'>Ctrl+Enter</span> |
| </div> |
| </section> |
|
|
| <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' : ''}${parsed ? ' tab-btn--has-result' : ''}`} |
| onClick={() => setActiveTab('result')} |
| disabled={!parsed}> |
| Result{' '} |
| {parsed && ( |
| <span className='tab-count tab-count--result'>✓</span> |
| )} |
| </button> |
| {hasArtifacts && ( |
| <button |
| className={`tab-btn${activeTab === 'artifacts' ? ' tab-btn--active' : ''} tab-btn--chart`} |
| onClick={() => setActiveTab('artifacts')}> |
| Artifacts{' '} |
| <span className='tab-count tab-count--chart'> |
| {parsed.data.output_type === 'code' ? '{ }' : |
| parsed.data.output_type === 'metrics' ? '◈' : |
| parsed.data.output_type === 'table' ? '⊞' : |
| parsed.data.output_type === 'comparison' ? '⇌' : |
| parsed.data.output_type === 'heatmap' ? '▦' : '📊'} |
| </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> |
| |
| {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 in real-time.</span> |
| </div> |
| )} |
| {logs.length === 0 && status === 'running' && ( |
| <div className='log-empty log-empty--active'> |
| <span>Initializing agent pipeline</span> |
| </div> |
| )} |
| {logs.map((line, i) => { |
| const cls = getLogClass(line); |
| if (cls === 'log-agent-header') { |
| const agentIdx = detectAgentIndex(line); |
| if (agentIdx !== -1) { |
| 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> |
| )} |
| </> |
| )} |
|
|
| {activeTab === 'result' && ( |
| <div className='result-panel'> |
| {!parsed ? ( |
| <div className='log-empty'> |
| <span className='log-empty-icon'>⬡</span> |
| <span>Result will appear once analysis completes.</span> |
| </div> |
| ) : ( |
| <> |
| <div className='result-type-bar'> |
| <span className='result-type-badge'> |
| {parsed.type === 'structured' |
| ? parsed.data.output_type.toUpperCase() |
| : parsed.type.toUpperCase()} |
| </span> |
| {parsed.type === 'structured' && parsed.data.chart_type && ( |
| <span className='result-chart-badge'> |
| {parsed.data.chart_type} chart |
| </span> |
| )} |
| {parsed.type === 'structured' && parsed.data.quality_score != null && ( |
| <span |
| className='quality-badge' |
| style={{ |
| color: parsed.data.quality_score >= 8 ? '#34d399' |
| : parsed.data.quality_score >= 5 ? '#fbbf24' |
| : '#f87171', |
| }}> |
| Quality {parsed.data.quality_score}/10 |
| </span> |
| )} |
| {parsed.type === 'structured' && parsed.data.quality_verdict && ( |
| <span className='quality-verdict'>{parsed.data.quality_verdict}</span> |
| )} |
| </div> |
| <div className='result-body'> |
| <ResultView parsed={parsed} /> |
| </div> |
| <div className='log-footer'> |
| <span>{result.length} chars</span> |
| <div style={{ display: 'flex', gap: '6px' }}> |
| <button |
| className='btn-share' |
| onClick={shareResult}> |
| {shareCopied ? '✓ Copied!' : '⎋ Share'} |
| </button> |
| <button |
| className='btn-download' |
| onClick={() => setShowDownload(true)}> |
| ⬇ Download |
| </button> |
| </div> |
| </div> |
| </> |
| )} |
| </div> |
| )} |
|
|
| {activeTab === 'artifacts' && hasArtifacts && ( |
| <ArtifactsPanel |
| parsed={parsed} |
| onDownload={() => setShowDownload(true)} |
| /> |
| )} |
| </section> |
| </main> |
|
|
| {showDownload && parsed && ( |
| <DownloadPopup |
| parsed={parsed} |
| onClose={() => setShowDownload(false)} |
| /> |
| )} |
| {showHistory && ( |
| <HistoryDrawer |
| history={history} |
| onSelect={handleHistorySelect} |
| onClose={() => setShowHistory(false)} |
| /> |
| )} |
| </div> |
| ); |
| } |
|
|