feat: FluxAI multi-step autonomy + rate limiting + image pipeline
Deploy to VPS / deploy (push) Has been cancelled

Two production-grade hardening additions and one cost optimisation.

FLUXAI AUTONOMY RESTORED (api/chat)
- Brings back the multi-step agentic flow that the system prompt was
  always designed for. The "temporarily removed maxSteps" comment is
  gone — replaced with the AI SDK 6 equivalent stopWhen: stepCountIs(5).
- Cap at 5 chained tool calls per turn bounds latency + LLM cost.
- maxDuration raised 30s → 60s to absorb tool-chain runs.
- Result: one user prompt now triggers, e.g. search_installations →
  energy_savings_calculator → show_case_study → schedule_consultation
  in a single turn — exactly the SPIN methodology in the prompt.

RATE LIMITING (src/lib/rateLimit.ts + api/chat)
- Token-bucket per IP: 30 messages burst, sustained 30/minute. Trips
  to 429 with Retry-After + X-RateLimit-Remaining headers when abused.
- IP extracted from x-forwarded-for (Nginx already passes this).
- In-memory Map with 10-min GC of stale buckets — no Redis dep.
  If we scale to multiple replicas later, swap the Map for Upstash.
- Protects the OpenAI quota from someone hammering the chat endpoint.

IMAGE PIPELINE (src/lib/imageOptimizer.ts)
- sharp-based optimizer: auto-orient (EXIF), cap at 2560px long side,
  re-encode WebP@85, content-hash filename. Re-uploads with same
  content reuse the same hash; new content gets a new URL — perfect
  cache invalidation without header tricks.
- Opt-in via optimize=1 form/query param on /api/assets POST.
- Hero CMS and Site Settings uploads turn it on automatically (those
  are user-facing brand assets where compression matters most).
- App/news/parts uploads remain untouched (editors may be uploading
  CAD drawings, datasheets, etc. that shouldn't be transcoded).
- Falls back gracefully to a no-op for unsupported formats (SVG, GIF,
  videos, anything sharp can't decode) so it never breaks an upload.

DOCKERFILE
- Adds vips/vips-dev for sharp on Alpine + --include=optional so the
  @img/sharp-linuxmusl-x64 prebuilt is downloaded
- Explicitly copies node_modules/sharp + node_modules/@img to the
  runner stage (Next.js trace can miss conditional deps).

NO DB SCHEMA CHANGES.
This commit is contained in:
2026-05-04 14:48:37 -05:00
parent 09e6d0c7cf
commit a199891a3c
9 changed files with 303 additions and 25 deletions
+43 -7
View File
@@ -31,6 +31,7 @@ import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { revalidateContent, type RevalidateScope } from "@/lib/revalidate";
import { optimizeImage, isOptimizable } from "@/lib/imageOptimizer";
const SCOPE_ROOTS: Record<string, string> = {
applications: path.join(process.cwd(), "public", "applications"),
@@ -186,6 +187,13 @@ export async function GET(request: NextRequest) {
}
// POST — Upload a file
//
// Optional query / form param `optimize=true` (or `optimize=1`) routes the
// upload through the sharp pipeline: auto-orient, cap at 2560px, encode to
// WebP, and save under a content-hashed filename. The same image always
// produces the same hash, so re-uploading is idempotent. Different content
// produces a different hash, so the browser cache invalidates instantly
// without any header trickery.
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
@@ -194,6 +202,12 @@ export async function POST(request: NextRequest) {
const subPath = formData.get("path") as string || "";
const file = formData.get("file") as File;
// Two ways to opt into optimization: ?optimize=1 query or form field "optimize".
const optFlag =
formData.get("optimize") ??
new URL(request.url).searchParams.get("optimize");
const shouldOptimize = optFlag === "true" || optFlag === "1" || optFlag === "on";
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
@@ -211,13 +225,26 @@ export async function POST(request: NextRequest) {
fs.mkdirSync(dirPath, { recursive: true });
const safeName = file.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, "");
const filePath = path.join(dirPath, safeName);
const inputBuffer: Buffer = Buffer.from(await file.arrayBuffer());
// Optimization branch: replace filename with a content-hashed WebP one.
let saveName = file.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, "");
let outputBuffer: Buffer | Uint8Array = inputBuffer;
let optimizedMeta: { width: number | null; height: number | null; bytes: number } | null = null;
if (shouldOptimize && isOptimizable(file.name)) {
const opt = await optimizeImage(inputBuffer, file.name);
saveName = opt.filename;
outputBuffer = opt.buffer;
optimizedMeta = { width: opt.width, height: opt.height, bytes: opt.bytes };
}
const filePath = path.join(dirPath, saveName);
const existed = fs.existsSync(filePath);
fs.writeFileSync(filePath, Buffer.from(await file.arrayBuffer()));
fs.writeFileSync(filePath, outputBuffer);
const rel = subPath ? `${subPath}/${safeName}` : safeName;
const rel = subPath ? `${subPath}/${saveName}` : saveName;
// 🔥 Invalida caché para que la imagen aparezca sin recompilar
revalidateContent({ scope: scope as RevalidateScope, slug });
@@ -225,12 +252,21 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
success: true,
file: {
name: safeName,
name: saveName,
publicUrl: buildPublicUrl(scope, slug, rel),
path: rel,
mediaType: getFileType(safeName),
size: getFileSize(file.size),
mediaType: getFileType(saveName),
size: getFileSize(outputBuffer.byteLength),
overwritten: existed,
optimized: optimizedMeta !== null,
...(optimizedMeta
? {
width: optimizedMeta.width,
height: optimizedMeta.height,
originalBytes: file.size,
savedBytes: file.size - optimizedMeta.bytes,
}
: {}),
}
});
} catch (error) {