299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
import { useState, useCallback, useRef } from 'react';
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Play, FileDown, Loader2, AlertCircle } from 'lucide-react';
|
|
import type { AuditConfig, AuditScenario, ScenarioResult, AuditReport } from '@/lib/audit/types';
|
|
import { generateAllScenarios } from '@/lib/audit/scenarios';
|
|
import AuditDetailPopup from './AuditDetailPopup';
|
|
|
|
const PROVIDERS: { value: string; label: string }[] = [
|
|
{ value: 'openai', label: 'OpenAI' },
|
|
{ value: 'anthropic', label: 'Anthropic' },
|
|
{ value: 'minimax', label: 'MiniMax' },
|
|
{ value: 'openrouter', label: 'OpenRouter' },
|
|
{ value: 'ollama', label: 'Ollama' },
|
|
];
|
|
|
|
const DEFAULT_MODELS: Record<string, string> = {
|
|
openai: 'gpt-4o',
|
|
anthropic: 'claude-3-5-sonnet-latest',
|
|
minimax: 'minimax-m3.0',
|
|
openrouter: 'openai/gpt-4o',
|
|
ollama: 'deepseek-r1',
|
|
};
|
|
|
|
export default function AuditPanel() {
|
|
|
|
const [config, setConfig] = useState<AuditConfig>({
|
|
provider: 'openai',
|
|
model: 'gpt-4o',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
});
|
|
|
|
const [status, setStatus] = useState<'idle' | 'generating' | 'running' | 'done' | 'error'>('idle');
|
|
const [progress, setProgress] = useState(0);
|
|
const [total, setTotal] = useState(0);
|
|
const [report, setReport] = useState<AuditReport | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const abortRef = useRef(false);
|
|
|
|
const [popupOpen, setPopupOpen] = useState(false);
|
|
const [selectedScenario, setSelectedScenario] = useState<AuditScenario | null>(null);
|
|
const [selectedResult, setSelectedResult] = useState<ScenarioResult | null>(null);
|
|
|
|
const handleProviderChange = (val: string) => {
|
|
setConfig((c) => ({
|
|
...c,
|
|
provider: val as AuditConfig['provider'],
|
|
model: DEFAULT_MODELS[val] ?? c.model,
|
|
}));
|
|
};
|
|
|
|
const handleRun = useCallback(async () => {
|
|
if (!config.apiKey && config.provider !== 'ollama') {
|
|
setError('Informe uma API key para continuar.');
|
|
setStatus('error');
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
abortRef.current = controller as any; // Usando any pois não precisamos refatorar todos os tipos agora, apenas para passar o signal
|
|
|
|
setError(null);
|
|
setReport(null);
|
|
setProgress(0);
|
|
|
|
setStatus('generating');
|
|
const scenarios = generateAllScenarios();
|
|
setTotal(scenarios.length);
|
|
|
|
setStatus('running');
|
|
|
|
try {
|
|
const { runAudit } = await import('@/lib/audit/runner');
|
|
const reportResult = await runAudit(
|
|
config,
|
|
scenarios,
|
|
(p) => {
|
|
if (typeof p === 'number') setProgress(p);
|
|
},
|
|
controller.signal
|
|
);
|
|
setReport(reportResult);
|
|
setStatus('done');
|
|
|
|
// Auto-download MD
|
|
import('@/lib/audit/export-audit-md').then(m => m.downloadAuditMarkdown(reportResult));
|
|
|
|
} catch (e) {
|
|
if (e instanceof Error && e.message.includes('Abortado')) {
|
|
setStatus('idle');
|
|
return;
|
|
}
|
|
setError(e instanceof Error ? e.message : 'Erro desconhecido');
|
|
setStatus('error');
|
|
}
|
|
}, [config]);
|
|
|
|
const handleExportPDF = () => {
|
|
if (!report) return;
|
|
import('@/lib/audit/export-audit-pdf').then((mod) => mod.exportAuditReportToPDF(report));
|
|
};
|
|
|
|
const grouped = report && report.modules
|
|
? report.modules.map((m: { module: string; moduleLabel: string; totalScenarios: number; passed: number; failed: number; warnings: number }) => ({
|
|
module: m.module,
|
|
label: m.moduleLabel,
|
|
total: m.totalScenarios,
|
|
passed: m.passed,
|
|
failed: m.failed,
|
|
warnings: m.warnings,
|
|
}))
|
|
: [];
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Auditoria LLM</CardTitle>
|
|
<CardDescription>
|
|
Validação dos cálculos de vento usando LLMs sequenciais. 100+ cenários cobrindo todos os módulos.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium">Provedor</label>
|
|
<Select value={config.provider} onValueChange={handleProviderChange}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{PROVIDERS.map((p) => (
|
|
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium">Modelo</label>
|
|
<Input
|
|
value={config.model}
|
|
onChange={(e) => setConfig((c) => ({ ...c, model: e.target.value }))}
|
|
placeholder="gpt-4o, claude-3-5-sonnet-..."
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium">API Key</label>
|
|
<Input
|
|
type="password"
|
|
value={config.apiKey}
|
|
onChange={(e) => setConfig((c) => ({ ...c, apiKey: e.target.value }))}
|
|
placeholder="sk-..."
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium">Base URL (opcional)</label>
|
|
<Input
|
|
value={config.baseUrl ?? ''}
|
|
onChange={(e) => setConfig((c) => ({ ...c, baseUrl: e.target.value || undefined }))}
|
|
placeholder={config.provider === 'openrouter' ? 'https://openrouter.ai/api/v1 (padrão)' : config.provider === 'minimax' ? 'https://api.minimax.chat/v1 (padrão)' : config.provider === 'ollama' ? 'http://localhost:11434 (padrão)' : 'https://api.openai.com/v1'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Button
|
|
onClick={handleRun}
|
|
disabled={status === 'running' || status === 'generating'}
|
|
>
|
|
{status === 'running' || status === 'generating' ? (
|
|
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
|
) : (
|
|
<Play className="w-4 h-4 mr-1.5" />
|
|
)}
|
|
Executar Auditoria
|
|
</Button>
|
|
{(status === 'running' || status === 'generating') && (
|
|
<Button variant="outline" size="sm" onClick={() => { if (abortRef.current && typeof (abortRef.current as any).abort === 'function') (abortRef.current as any).abort(); }}>
|
|
Cancelar
|
|
</Button>
|
|
)}
|
|
{status === 'done' && report && (
|
|
<>
|
|
<Button variant="outline" size="sm" onClick={() => import('@/lib/audit/export-audit-md').then(m => m.downloadAuditMarkdown(report))}>
|
|
<FileDown className="w-3.5 h-3.5 mr-1.5" />
|
|
Exportar MD
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={handleExportPDF}>
|
|
<FileDown className="w-3.5 h-3.5 mr-1.5" />
|
|
Exportar PDF
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{(status === 'running' || status === 'generating') && total > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-xs text-muted-foreground">
|
|
<span>{status === 'generating' ? 'Gerando cenários...' : `Processando cenários...`}</span>
|
|
<span>{progress} / {total}</span>
|
|
</div>
|
|
<div className="w-full bg-muted rounded-full h-2 overflow-hidden">
|
|
<div
|
|
className="bg-purple-600 h-full rounded-full transition-all duration-300"
|
|
style={{ width: `${(progress / total) * 100}%` }}
|
|
/>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground animate-pulse text-center">
|
|
Aguardando resposta do modelo para o lote atual... (pode demorar alguns segundos)
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{status === 'error' && error && (
|
|
<div className="rounded-md border border-red-200 bg-red-50 dark:bg-red-950 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-300 flex items-start gap-2">
|
|
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
|
|
<span>{error}</span>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{status === 'done' && report && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg flex items-center justify-between">
|
|
<span>Resultados</span>
|
|
<span className="text-sm font-normal text-muted-foreground">
|
|
{report.totalPassed}/{report.totalScenarios} PASS —
|
|
W:{report.totalWarnings} F:{report.totalFailed}
|
|
</span>
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Provedor: {report.provider} ({report.model}) — {new Date(report.timestamp).toLocaleString('pt-BR')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="rounded-md border overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-muted/50">
|
|
<tr>
|
|
<th className="p-2 text-left">Módulo</th>
|
|
<th className="p-2 text-center">Total</th>
|
|
<th className="p-2 text-center">PASS</th>
|
|
<th className="p-2 text-center">WARN</th>
|
|
<th className="p-2 text-center">FAIL</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{grouped.map((g: { module: string; label: string; total: number; passed: number; failed: number; warnings: number }) => (
|
|
<tr key={g.module} className="border-t border-border cursor-pointer hover:bg-muted/30"
|
|
onClick={() => {
|
|
const mod = report.modules.find((m) => m.module === g.module);
|
|
if (!mod) return;
|
|
const modScenarios = generateAllScenarios().filter((s) => s.module === g.module);
|
|
const dialog = mod.scenarios[0];
|
|
if (modScenarios.length > 0 && dialog) {
|
|
setSelectedScenario(modScenarios[0]);
|
|
setSelectedResult(dialog);
|
|
setPopupOpen(true);
|
|
}
|
|
}}>
|
|
<td className="p-2 font-medium">{g.label}</td>
|
|
<td className="p-2 text-center">{g.total}</td>
|
|
<td className="p-2 text-center"><Badge className="bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-300">{g.passed}</Badge></td>
|
|
<td className="p-2 text-center"><Badge className="bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300">{g.warnings}</Badge></td>
|
|
<td className="p-2 text-center"><Badge className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300">{g.failed}</Badge></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-2">
|
|
Clique em um módulo para ver detalhes dos cenários e verificações.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<AuditDetailPopup
|
|
open={popupOpen}
|
|
onOpenChange={setPopupOpen}
|
|
scenario={selectedScenario}
|
|
result={selectedResult}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|