forked from 0xWheatyz/SPARC
e0ed39908e
Replace hardcoded dark-theme hex colors in recharts components (tooltips, axes) with a useChartTheme hook that reads the current theme from ThemeContext. Charts now render correctly in both light and dark mode. Closes leeworks-agents/SPARC#1324 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
431 lines
17 KiB
TypeScript
431 lines
17 KiB
TypeScript
import { useState } from 'react';
|
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
import { analysisApi } from '../api/client';
|
|
import { Rocket, CheckCircle, AlertCircle, ChevronDown, ChevronUp, RefreshCw, Inbox } from 'lucide-react';
|
|
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
|
import { useChartTheme } from '../context/useChartTheme';
|
|
import type { BatchAnalysisResult } from '../types';
|
|
|
|
export function Batch() {
|
|
const [companiesInput, setCompaniesInput] = useState('');
|
|
const [maxWorkers, setMaxWorkers] = useState(3);
|
|
const [selectedModel, setSelectedModel] = useState('');
|
|
const [result, setResult] = useState<BatchAnalysisResult | null>(null);
|
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
|
|
|
const chartTheme = useChartTheme();
|
|
|
|
const modelsQuery = useQuery({
|
|
queryKey: ['models'],
|
|
queryFn: () => analysisApi.listModels(),
|
|
});
|
|
|
|
const jobsQuery = useQuery({
|
|
queryKey: ['jobs'],
|
|
queryFn: () => analysisApi.listJobs(undefined, 20),
|
|
});
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: ({ companies, workers }: { companies: string[]; workers: number }) =>
|
|
analysisApi.analyzeBatch(companies, workers, selectedModel || undefined),
|
|
onSuccess: (data) => {
|
|
setResult(data);
|
|
jobsQuery.refetch();
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const companies = companiesInput
|
|
.split(/[,\n]/)
|
|
.map((c) => c.trim())
|
|
.filter((c) => c.length > 0);
|
|
|
|
if (companies.length > 0) {
|
|
mutation.mutate({ companies, workers: maxWorkers });
|
|
}
|
|
};
|
|
|
|
const toggleExpand = (company: string) => {
|
|
const newExpanded = new Set(expandedItems);
|
|
if (newExpanded.has(company)) {
|
|
newExpanded.delete(company);
|
|
} else {
|
|
newExpanded.add(company);
|
|
}
|
|
setExpandedItems(newExpanded);
|
|
};
|
|
|
|
const chartData = result?.results.map((r) => ({
|
|
name: r.company_name.toUpperCase(),
|
|
patents: r.patent_count,
|
|
success: r.success,
|
|
}));
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div>
|
|
<h2 className="text-xl font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-2">
|
|
Batch Company Analysis
|
|
</h2>
|
|
<p className="text-text-secondary">
|
|
Analyze multiple companies simultaneously for comparative insights.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Input Form */}
|
|
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="md:col-span-2">
|
|
<textarea
|
|
value={companiesInput}
|
|
onChange={(e) => setCompaniesInput(e.target.value)}
|
|
placeholder="Enter company names (one per line or comma-separated): nvidia amd intel qualcomm"
|
|
rows={6}
|
|
className="w-full bg-bg-card/80 border border-primary/30 rounded-xl px-4 py-3 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all resize-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-secondary mb-2">
|
|
Concurrent Workers
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min={1}
|
|
max={5}
|
|
value={maxWorkers}
|
|
onChange={(e) => setMaxWorkers(Number(e.target.value))}
|
|
className="w-full accent-primary"
|
|
/>
|
|
<div className="text-center text-text-primary font-semibold">{maxWorkers}</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-secondary mb-2">
|
|
LLM Model
|
|
</label>
|
|
<div className="relative">
|
|
<select
|
|
value={selectedModel}
|
|
onChange={(e) => setSelectedModel(e.target.value)}
|
|
className="w-full appearance-none bg-bg-card/80 border border-primary/30 rounded-lg pl-3 pr-8 py-2 text-sm text-text-primary focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all cursor-pointer"
|
|
>
|
|
<option value="">
|
|
{modelsQuery.data ? `Default (${modelsQuery.data.default})` : 'Default'}
|
|
</option>
|
|
{modelsQuery.data?.models.map((m) => (
|
|
<option key={m.id} value={m.id}>
|
|
{m.name} ({m.provider})
|
|
</option>
|
|
))}
|
|
</select>
|
|
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 text-text-secondary pointer-events-none" size={16} />
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={mutation.isPending || !companiesInput.trim()}
|
|
className="w-full bg-gradient-to-r from-primary to-primary-dark text-white font-semibold py-3 px-6 rounded-xl hover:shadow-lg hover:shadow-primary/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
{mutation.isPending ? (
|
|
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
|
|
) : (
|
|
<>
|
|
<Rocket size={18} />
|
|
Run Batch Analysis
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
{/* Progress */}
|
|
{mutation.isPending && (
|
|
<div className="bg-bg-card/60 border border-primary/15 rounded-xl p-4">
|
|
<div className="flex items-center gap-2 text-secondary">
|
|
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-secondary"></div>
|
|
<span>Analyzing companies...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{mutation.isError && (
|
|
<div className="bg-error/10 border border-error/20 rounded-xl px-4 py-3">
|
|
<div className="flex items-center gap-2 text-error">
|
|
<AlertCircle size={18} />
|
|
<span className="font-semibold">Batch analysis failed</span>
|
|
</div>
|
|
<p className="text-text-secondary text-sm mt-1 ml-7">
|
|
{mutation.error instanceof Error ? mutation.error.message : 'An unexpected error occurred.'}
|
|
{' '}Check your connection and try again.
|
|
</p>
|
|
<div className="ml-7 mt-2 flex items-center gap-3">
|
|
<button
|
|
onClick={() => {
|
|
const companies = companiesInput
|
|
.split(/[,\n]/)
|
|
.map((c) => c.trim())
|
|
.filter((c) => c.length > 0);
|
|
if (companies.length > 0) {
|
|
mutation.mutate({ companies, workers: maxWorkers });
|
|
}
|
|
}}
|
|
className="text-sm text-primary hover:text-primary-dark underline flex items-center gap-1"
|
|
>
|
|
<RefreshCw size={14} />
|
|
Retry
|
|
</button>
|
|
<button
|
|
onClick={() => mutation.reset()}
|
|
className="text-sm text-text-secondary hover:text-text-primary underline"
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Results */}
|
|
{result && (
|
|
<div className="space-y-6">
|
|
{/* Summary Metrics */}
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-4">
|
|
Results Summary
|
|
</h3>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<SummaryCard label="Total Companies" value={result.total_companies} />
|
|
<SummaryCard label="Successful" value={result.successful} color="success" />
|
|
<SummaryCard label="Failed" value={result.failed} color="error" />
|
|
<SummaryCard
|
|
label="Success Rate"
|
|
value={`${Math.round((result.successful / result.total_companies) * 100)}%`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Chart */}
|
|
{chartData && chartData.length > 0 && (
|
|
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6">
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<BarChart data={chartData}>
|
|
<XAxis dataKey="name" stroke={chartTheme.axisStroke} fontSize={12} />
|
|
<YAxis stroke={chartTheme.axisStroke} fontSize={12} />
|
|
<Tooltip
|
|
contentStyle={chartTheme.tooltipContentStyle}
|
|
labelStyle={chartTheme.tooltipLabelStyle}
|
|
/>
|
|
<Bar dataKey="patents" radius={[4, 4, 0, 0]}>
|
|
{chartData.map((entry, index) => (
|
|
<Cell
|
|
key={`cell-${index}`}
|
|
fill={entry.success ? '#10b981' : '#ef4444'}
|
|
/>
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
|
|
{/* Detailed Results */}
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-4">
|
|
Detailed Results
|
|
</h3>
|
|
<div className="space-y-3">
|
|
{result.results.map((r) => (
|
|
<div
|
|
key={r.company_name}
|
|
className="bg-bg-card/60 border border-primary/15 rounded-xl overflow-hidden"
|
|
>
|
|
<button
|
|
onClick={() => toggleExpand(r.company_name)}
|
|
className="w-full flex items-center justify-between p-4 hover:bg-bg-card-hover transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
{r.success ? (
|
|
<CheckCircle className="text-success" size={20} />
|
|
) : (
|
|
<AlertCircle className="text-error" size={20} />
|
|
)}
|
|
<span className="font-semibold text-text-primary">
|
|
{r.company_name.toUpperCase()}
|
|
</span>
|
|
<span className="text-text-secondary">
|
|
{r.patent_count} patents
|
|
</span>
|
|
</div>
|
|
{expandedItems.has(r.company_name) ? (
|
|
<ChevronUp className="text-text-secondary" size={20} />
|
|
) : (
|
|
<ChevronDown className="text-text-secondary" size={20} />
|
|
)}
|
|
</button>
|
|
{expandedItems.has(r.company_name) && (
|
|
<div className="border-t border-primary/10 p-4 bg-bg-dark/40">
|
|
{r.success ? (
|
|
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
|
|
{r.analysis}
|
|
</div>
|
|
) : (
|
|
<div className="text-error">{r.error}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Job History */}
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-4">
|
|
Job History
|
|
</h3>
|
|
|
|
{/* Loading skeleton */}
|
|
{jobsQuery.isLoading && (
|
|
<div className="space-y-3">
|
|
{[...Array(3)].map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="bg-bg-card/60 border border-primary/15 rounded-xl p-4 animate-pulse"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-5 w-5 rounded-full bg-primary/20" />
|
|
<div className="h-4 w-32 rounded bg-primary/20" />
|
|
<div className="h-4 w-20 rounded bg-primary/10" />
|
|
</div>
|
|
<div className="h-6 w-20 rounded-full bg-primary/15" />
|
|
</div>
|
|
<div className="mt-3 flex gap-4">
|
|
<div className="h-3 w-24 rounded bg-primary/10" />
|
|
<div className="h-3 w-16 rounded bg-primary/10" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Job history error */}
|
|
{jobsQuery.isError && (
|
|
<div className="bg-error/10 border border-error/20 rounded-xl px-4 py-3">
|
|
<div className="flex items-center gap-2 text-error">
|
|
<AlertCircle size={18} />
|
|
<span className="font-semibold">Failed to load job history</span>
|
|
</div>
|
|
<p className="text-text-secondary text-sm mt-1 ml-7">
|
|
{jobsQuery.error instanceof Error ? jobsQuery.error.message : 'Could not retrieve past jobs.'}
|
|
</p>
|
|
<button
|
|
onClick={() => jobsQuery.refetch()}
|
|
className="ml-7 mt-2 text-sm text-primary hover:text-primary-dark underline flex items-center gap-1"
|
|
>
|
|
<RefreshCw size={14} />
|
|
Retry
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty state */}
|
|
{jobsQuery.isSuccess && jobsQuery.data.length === 0 && !result && (
|
|
<div className="bg-bg-card/60 border border-primary/15 border-dashed rounded-xl p-8 text-center">
|
|
<Inbox className="mx-auto text-text-secondary/40 mb-3" size={40} />
|
|
<p className="text-text-secondary font-medium">No batch jobs yet</p>
|
|
<p className="text-text-secondary/70 text-sm mt-1">
|
|
Submit a batch analysis above to get started. Your job history will appear here.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Job list */}
|
|
{jobsQuery.isSuccess && jobsQuery.data.length > 0 && (
|
|
<div className="space-y-3">
|
|
{jobsQuery.data.map((job) => (
|
|
<div
|
|
key={job.job_id}
|
|
className="bg-bg-card/60 border border-primary/15 rounded-xl p-4"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
{job.status === 'completed' && <CheckCircle className="text-success" size={18} />}
|
|
{job.status === 'failed' && <AlertCircle className="text-error" size={18} />}
|
|
{(job.status === 'pending' || job.status === 'running') && (
|
|
<div className="animate-spin rounded-full h-[18px] w-[18px] border-t-2 border-b-2 border-secondary" />
|
|
)}
|
|
<span className="font-mono text-sm text-text-primary">{job.job_id.slice(0, 8)}</span>
|
|
<span className="text-text-secondary text-sm">
|
|
{job.total_companies} {job.total_companies === 1 ? 'company' : 'companies'}
|
|
</span>
|
|
</div>
|
|
<span
|
|
className={`text-xs font-semibold px-2.5 py-1 rounded-full ${
|
|
job.status === 'completed'
|
|
? 'bg-success/15 text-success'
|
|
: job.status === 'failed'
|
|
? 'bg-error/15 text-error'
|
|
: 'bg-secondary/15 text-secondary'
|
|
}`}
|
|
>
|
|
{job.status}
|
|
</span>
|
|
</div>
|
|
{(job.status === 'running' || job.status === 'pending') && job.total_companies > 0 && (
|
|
<div className="mt-3">
|
|
<div className="flex items-center justify-between text-xs text-text-secondary mb-1">
|
|
<span>Progress</span>
|
|
<span>{job.completed_companies}/{job.total_companies}</span>
|
|
</div>
|
|
<div className="h-1.5 bg-bg-dark rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-primary to-secondary rounded-full transition-all duration-300"
|
|
style={{ width: `${(job.completed_companies / job.total_companies) * 100}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{job.status === 'failed' && job.error && (
|
|
<p className="mt-2 text-sm text-error/80">{job.error}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SummaryCard({
|
|
label,
|
|
value,
|
|
color,
|
|
}: {
|
|
label: string;
|
|
value: number | string;
|
|
color?: 'success' | 'error';
|
|
}) {
|
|
const colorClass = color === 'success' ? 'text-success' : color === 'error' ? 'text-error' : '';
|
|
|
|
return (
|
|
<div className="bg-gradient-to-br from-primary/10 to-secondary/10 border border-primary/20 rounded-xl p-4 text-center">
|
|
<div
|
|
className={`text-2xl font-bold ${
|
|
colorClass || 'bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent'
|
|
}`}
|
|
>
|
|
{value}
|
|
</div>
|
|
<div className="text-sm text-text-secondary uppercase tracking-wide mt-1">{label}</div>
|
|
</div>
|
|
);
|
|
}
|