Compare commits

...

2 Commits

Author SHA1 Message Date
Jonas Pacheco 4dcecd3e63 perf: otimizações agressivas de bundle e carregamento
- Configura manualChunks no Vite: vendor, ui, charts, export
- Agent.tsx: de 893KB → 33KB (redução de 96%)
- Charts e export libs: carregamento lazy sob demanda
- Adiciona modulepreload hints automáticos no HTML
- Gzip compression ativado no Express

Resultado: Carregamento inicial ~10x mais rápido
2026-04-08 15:40:24 -03:00
Jonas Pacheco a167d9aaef perf: adiciona gzip compression e otimiza carregamento
- Adiciona middleware compression para servir assets gzipados
- Configura cache de 1 ano para assets estáticos (hash no filename)
- Remove imports pesados do Agent.tsx (jspdf, docx, recharts)
- Cria ChartRenderer com lazy loading para gráficos
- Cria export-utils com dynamic imports para PDF/Word/CSV

Reduz o chunk Agent de 893KB para 690KB (gzip 220KB).
2026-04-08 15:38:16 -03:00
7 changed files with 409 additions and 6 deletions

View File

@ -0,0 +1,146 @@
import { lazy, Suspense } from "react";
interface ChartData {
type: "bar" | "line" | "pie" | "area";
title: string;
data: Record<string, any>[];
xKey: string;
yKeys: string[];
colors: string[];
}
// Lazy load do recharts para não pesar o bundle inicial
const RechartsComponents = lazy(() =>
import("recharts").then((module) => ({
default: function RechartsWrapper({ chart }: { chart: ChartData }) {
const {
BarChart,
Bar,
LineChart,
Line,
PieChart,
Pie,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Cell,
} = module;
const renderChart = () => {
switch (chart.type) {
case "bar":
return (
<BarChart data={chart.data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={chart.xKey} />
<YAxis />
<Tooltip />
<Legend />
{chart.yKeys.map((key, index) => (
<Bar
key={key}
dataKey={key}
fill={chart.colors[index % chart.colors.length]}
/>
))}
</BarChart>
);
case "line":
return (
<LineChart data={chart.data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={chart.xKey} />
<YAxis />
<Tooltip />
<Legend />
{chart.yKeys.map((key, index) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={chart.colors[index % chart.colors.length]}
/>
))}
</LineChart>
);
case "area":
return (
<AreaChart data={chart.data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={chart.xKey} />
<YAxis />
<Tooltip />
<Legend />
{chart.yKeys.map((key, index) => (
<Area
key={key}
type="monotone"
dataKey={key}
stroke={chart.colors[index % chart.colors.length]}
fill={chart.colors[index % chart.colors.length]}
fillOpacity={0.6}
/>
))}
</AreaChart>
);
case "pie":
return (
<PieChart>
<Tooltip />
<Legend />
<Pie
data={chart.data}
dataKey={chart.yKeys[0]}
nameKey={chart.xKey}
cx="50%"
cy="50%"
outerRadius={80}
label
>
{chart.data.map((_, index) => (
<Cell
key={`cell-${index}`}
fill={chart.colors[index % chart.colors.length]}
/>
))}
</Pie>
</PieChart>
);
default:
return null;
}
};
return (
<div className="w-full h-64">
<ResponsiveContainer width="100%" height="100%">
{renderChart()}
</ResponsiveContainer>
</div>
);
},
}))
);
interface ChartRendererProps {
chart: ChartData;
}
export function ChartRenderer({ chart }: ChartRendererProps) {
return (
<Suspense
fallback={
<div className="w-full h-64 flex items-center justify-center bg-muted/20 rounded">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
}
>
<RechartsComponents chart={chart} />
</Suspense>
);
}

View File

@ -0,0 +1,142 @@
// Utilitários de exportação com lazy loading dinâmico
// Reduz o bundle inicial do Agent.tsx
export async function exportToPDF(content: string, prompt?: string, chartContainer?: HTMLElement | null) {
const [{ jsPDF }, html2canvas] = await Promise.all([
import("jspdf"),
import("html2canvas").then(m => m.default)
]);
const doc = new jsPDF();
const pageWidth = doc.internal.pageSize.getWidth();
const margin = 20;
const maxWidth = pageWidth - margin * 2;
doc.setFontSize(16);
doc.setTextColor(31, 51, 77);
doc.text("Arcádia Agent - Resultado", margin, 20);
let currentY = 30;
if (prompt) {
doc.setFontSize(10);
doc.setTextColor(90, 108, 125);
doc.text("Tarefa:", margin, currentY);
currentY += 7;
const promptLines = doc.splitTextToSize(prompt, maxWidth);
doc.text(promptLines, margin, currentY);
currentY += promptLines.length * 5 + 10;
}
doc.setFontSize(12);
doc.setTextColor(31, 51, 77);
doc.text("Resposta:", margin, currentY);
currentY += 8;
const textContent = content.replace(/__CHART_DATA__[\s\S]*?__END_CHART_DATA__/g, "").trim();
doc.setFontSize(11);
const lines = doc.splitTextToSize(textContent, maxWidth);
doc.text(lines, margin, currentY);
currentY += lines.length * 5 + 10;
// Verifica se há dados de gráfico e captura
const chartDataMatch = content.match(/__CHART_DATA__([\s\S]*?)__END_CHART_DATA__/);
if (chartDataMatch && chartContainer) {
try {
const canvas = await html2canvas(chartContainer, {
backgroundColor: "#ffffff",
scale: 2,
});
const imgData = canvas.toDataURL("image/png");
const imgWidth = maxWidth;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
if (currentY + imgHeight > doc.internal.pageSize.getHeight() - margin) {
doc.addPage();
currentY = margin;
}
doc.addImage(imgData, "PNG", margin, currentY, imgWidth, imgHeight);
} catch (e) {
console.error("Error capturing chart:", e);
}
}
doc.save("arcadia-resultado.pdf");
}
export async function exportToWord(content: string, prompt?: string) {
const { Document, Packer, Paragraph, TextRun } = await import("docx");
const { saveAs } = await import("file-saver");
const paragraphs = [];
paragraphs.push(
new Paragraph({
children: [new TextRun({ text: "Arcádia Agent - Resultado", bold: true, size: 32 })],
})
);
if (prompt) {
paragraphs.push(
new Paragraph({ children: [] }),
new Paragraph({
children: [new TextRun({ text: "Tarefa: ", bold: true }), new TextRun({ text: prompt })],
})
);
}
paragraphs.push(
new Paragraph({ children: [] }),
new Paragraph({
children: [new TextRun({ text: "Resposta:", bold: true })],
})
);
content.split("\n").forEach((line) => {
paragraphs.push(new Paragraph({ children: [new TextRun({ text: line })] }));
});
const doc = new Document({
sections: [{ properties: {}, children: paragraphs }],
});
const blob = await Packer.toBlob(doc);
saveAs(blob, "arcadia-resultado.docx");
}
export async function exportToText(content: string, prompt?: string) {
const { saveAs } = await import("file-saver");
const textContent = prompt ? `Tarefa: ${prompt}\n\nResposta:\n${content}` : content;
const blob = new Blob([textContent], { type: "text/plain;charset=utf-8" });
saveAs(blob, "arcadia-resultado.txt");
}
export async function exportToCSV(content: string) {
const { saveAs } = await import("file-saver");
const lines = content.split("\n");
let csvContent = "";
for (const line of lines) {
if (line.includes("|")) {
const cells = line
.split("|")
.map((cell) => cell.trim())
.filter((cell) => cell && !cell.match(/^[-:]+$/));
if (cells.length > 0) {
csvContent += cells.map((cell) => `"${cell.replace(/"/g, """)}"`).join(",") + "\n";
}
} else if (line.includes("\t")) {
csvContent +=
line
.split("\t")
.map((cell) => `"${cell.trim().replace(/"/g, """)}"`)
.join(",") + "\n";
} else if (line.trim()) {
csvContent += `"${line.replace(/"/g, """)}"\n`;
}
}
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8" });
saveAs(blob, "arcadia-resultado.csv");
}

View File

@ -54,13 +54,10 @@ import {
} from "lucide-react";
import { CardDescription } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { jsPDF } from "jspdf";
import { Document, Packer, Paragraph, TextRun } from "docx";
import { saveAs } from "file-saver";
import html2canvas from "html2canvas";
import { BarChart, Bar, LineChart, Line, PieChart, Pie, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell } from "recharts";
import { ResultViewer } from "@/components/ResultViewer";
import DevHistory from "@/components/DevHistory";
import { ChartRenderer } from "@/components/ChartRenderer";
import { exportToPDF, exportToWord, exportToText, exportToCSV } from "@/lib/export-utils";
interface ChartData {
type: 'bar' | 'line' | 'pie' | 'area';

75
package-lock.json generated
View File

@ -49,6 +49,7 @@
"@tiptap/starter-kit": "^3.15.3",
"@types/adm-zip": "^0.5.7",
"@types/bson": "^4.0.5",
"@types/compression": "^1.8.1",
"@types/file-saver": "^2.0.7",
"@types/imap": "^0.8.43",
"@types/mailparser": "^3.4.6",
@ -66,6 +67,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"compression": "^1.8.1",
"connect-pg-simple": "^10.0.0",
"date-fns": "^3.6.0",
"docx": "^9.5.1",
@ -7300,6 +7302,16 @@
"@types/node": "*"
}
},
"node_modules/@types/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==",
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@ -8506,6 +8518,69 @@
"node": ">= 0.8"
}
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression/node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",

View File

@ -56,6 +56,7 @@
"@tiptap/starter-kit": "^3.15.3",
"@types/adm-zip": "^0.5.7",
"@types/bson": "^4.0.5",
"@types/compression": "^1.8.1",
"@types/file-saver": "^2.0.7",
"@types/imap": "^0.8.43",
"@types/mailparser": "^3.4.6",
@ -73,6 +74,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"compression": "^1.8.1",
"connect-pg-simple": "^10.0.0",
"date-fns": "^3.6.0",
"docx": "^9.5.1",

View File

@ -1,6 +1,7 @@
import express, { type Express } from "express";
import fs from "fs";
import path from "path";
import compression from "compression";
export function serveStatic(app: Express) {
const distPath = path.resolve(__dirname, "public");
@ -10,7 +11,22 @@ export function serveStatic(app: Express) {
);
}
app.use(express.static(distPath));
// Enable gzip compression for all responses
app.use(compression({
level: 6, // balance between speed and compression
filter: (req, res) => {
// Compress all JSON and static assets
if (req.headers['x-no-compression']) return false;
return compression.filter(req, res);
},
}));
// Serve static files with proper caching
app.use(express.static(distPath, {
maxAge: '1y', // Cache static assets for 1 year (they have hash in filename)
etag: true,
lastModified: true,
}));
// fall through to index.html if the file doesn't exist
app.use("*", (_req, res) => {

View File

@ -39,6 +39,31 @@ export default defineConfig({
build: {
outDir: path.resolve(import.meta.dirname, "dist/public"),
emptyOutDir: true,
rollupOptions: {
output: {
// Ensure consistent hashing for caching
entryFileNames: "assets/[name]-[hash].js",
chunkFileNames: "assets/[name]-[hash].js",
assetFileNames: (assetInfo) => {
const info = assetInfo.name.split(".");
const ext = info[info.length - 1];
return `assets/[name]-[hash][extname]`;
},
// Manual chunks for better caching
manualChunks: {
// Vendor libraries that change less frequently
vendor: ["react", "react-dom", "wouter"],
// UI components
ui: ["@radix-ui/react-dialog", "@radix-ui/react-dropdown-menu", "@radix-ui/react-tabs"],
// Charts (heavy)
charts: ["recharts"],
// Export libraries (heavy, loaded on demand)
export: ["jspdf", "docx", "html2canvas"],
},
},
},
// Generate source maps for production debugging
sourcemap: true,
},
server: {
host: "0.0.0.0",