feat: FluxAI multi-step autonomy + rate limiting + image pipeline
Deploy to VPS / deploy (push) Has been cancelled
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user