From 42570a14b763984a2794abcf0335b33d8d1bbd03 Mon Sep 17 00:00:00 2001 From: Gemini AI Date: Mon, 29 Dec 2025 00:09:31 +0400 Subject: [PATCH] fix(ai-assist): prevent HTML-as-JSON crash; add safeJsonFetch + JSON-only API errors - Created lib/safeJsonFetch.ts helper to safely parse JSON responses - Updated AIAssist.tsx to use safeJsonFetch with proper Content-Type header - Improved app/api/ai-assist/route.ts with requestId tracking and safe body parsing - All API responses now return JSON with proper error messages --- app/api/ai-assist/route.ts | 89 ++++++++++++++++++++++++++------------ components/AIAssist.tsx | 24 +++++++++- lib/safeJsonFetch.ts | 73 +++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 30 deletions(-) create mode 100644 lib/safeJsonFetch.ts diff --git a/app/api/ai-assist/route.ts b/app/api/ai-assist/route.ts index 0e97e96..aaf49b5 100644 --- a/app/api/ai-assist/route.ts +++ b/app/api/ai-assist/route.ts @@ -1,18 +1,19 @@ import { NextRequest, NextResponse } from "next/server"; +import { randomUUID } from "crypto"; import { z } from "zod"; -// We'll use the environment variables for provider routing +// Schema validation const schema = z.object({ - request: z.string().min(10), - step: z.enum(["plan", "generate", "preview"]).default("plan"), - plan: z.any().optional(), - code: z.string().optional(), - provider: z.string().optional(), - model: z.string().optional() + request: z.string().min(1), + step: z.enum(["plan", "generate", "preview"]).default("plan"), + plan: z.any().optional(), + code: z.string().optional(), + provider: z.string().optional(), + model: z.string().optional() }); const STEPS = { - plan: `You are an expert software architect. Create a DETAILED DEVELOPMENT PLAN for the following request: "{request}" + plan: `You are an expert software architect. Create a DETAILED DEVELOPMENT PLAN for the following request: "{request}" Output ONLY a JSON object: { @@ -27,7 +28,7 @@ Output ONLY a JSON object: "risks": ["Potential blockers"] }`, - generate: `You are a Senior Vibe Coder. Execute the following approved plan: + generate: `You are a Senior Vibe Coder. Execute the following approved plan: Plan: {plan} Generate COMPLETE, PRODUCTION-READY code for all files. @@ -42,7 +43,7 @@ Output ONLY a JSON object: "explanation": "How it works" }`, - preview: `Convert the following code into a single-file interactive HTML preview (Standalone). + preview: `Convert the following code into a single-file interactive HTML preview (Standalone). Use Tailwind CDN. Code: {code} @@ -51,25 +52,57 @@ Output ONLY valid HTML.` }; export async function POST(req: NextRequest) { - try { - const body = await req.json(); - const { request, step, plan, code } = schema.parse(body); + const requestId = randomUUID(); - let prompt = STEPS[step]; - prompt = prompt.replace("{request}", request); - if (plan) prompt = prompt.replace("{plan}", JSON.stringify(plan)); - if (code) prompt = prompt.replace("{code}", code); + try { + // Safe body parsing + const body = await req.json().catch(() => null); - // In a real scenario, this would call the ModelAdapter/Service - // For now, we'll return a structure that the frontend can handle, - // instructing it to use the existing streaming adapter for the heavy lifting. - - return NextResponse.json({ - prompt, - step, - success: true - }); - } catch (error: any) { - return NextResponse.json({ success: false, error: error.message }, { status: 400 }); + if (!body) { + return NextResponse.json( + { error: "Invalid JSON body", requestId, success: false }, + { status: 400 } + ); } + + // Validate schema + const parseResult = schema.safeParse(body); + if (!parseResult.success) { + return NextResponse.json( + { + error: "Invalid request body", + details: parseResult.error.flatten(), + requestId, + success: false + }, + { status: 400 } + ); + } + + const { request, step, plan, code } = parseResult.data; + + let prompt = STEPS[step]; + prompt = prompt.replace("{request}", request); + if (plan) prompt = prompt.replace("{plan}", JSON.stringify(plan)); + if (code) prompt = prompt.replace("{code}", code); + + // Return the prompt for the frontend to use with the streaming adapter + return NextResponse.json({ + prompt, + step, + requestId, + success: true + }); + } catch (err: any) { + console.error(`[ai-assist] requestId=${requestId}`, err); + + return NextResponse.json( + { + error: err?.message ?? "AI Assist failed", + requestId, + success: false + }, + { status: 500 } + ); + } } diff --git a/components/AIAssist.tsx b/components/AIAssist.tsx index 60c037c..cf20cec 100644 --- a/components/AIAssist.tsx +++ b/components/AIAssist.tsx @@ -18,6 +18,7 @@ import { Input } from "@/components/ui/input"; import useStore from "@/lib/store"; import { translations } from "@/lib/i18n/translations"; import modelAdapter from "@/lib/services/adapter-instance"; +import { safeJsonFetch } from "@/lib/safeJsonFetch"; // --- Types --- @@ -346,15 +347,34 @@ export default function AIAssist() { try { // First, get the plan orchestrator prompt from our new API - const apiRes = await fetch("/api/ai-assist", { + type AiAssistApiResponse = { + prompt?: string; + step?: string; + requestId?: string; + success?: boolean; + error?: string; + }; + + const apiResult = await safeJsonFetch("/api/ai-assist", { method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ request: finalInput, step: assistStep === "plan" ? "generate" : "plan", plan: aiPlan }), }); - const { prompt } = await apiRes.json(); + + if (!apiResult.ok) { + console.error("AI Assist API failed:", apiResult.error); + throw new Error(apiResult.error.message); + } + + if (apiResult.data.error) { + throw new Error(apiResult.data.error); + } + + const prompt = apiResult.data.prompt ?? ""; let accumulated = ""; let lastParsedPreview: PreviewData | null = null; diff --git a/lib/safeJsonFetch.ts b/lib/safeJsonFetch.ts new file mode 100644 index 0000000..057668c --- /dev/null +++ b/lib/safeJsonFetch.ts @@ -0,0 +1,73 @@ +export class NonJsonResponseError extends Error { + status: number; + contentType: string | null; + bodyPreview: string; + + constructor(args: { status: number; contentType: string | null; bodyPreview: string }) { + super(`Expected JSON but received ${args.contentType ?? "unknown content-type"} (HTTP ${args.status})`); + this.name = "NonJsonResponseError"; + this.status = args.status; + this.contentType = args.contentType; + this.bodyPreview = args.bodyPreview; + } +} + +type SafeJsonFetchResult = + | { ok: true; data: T } + | { ok: false; error: { message: string; status?: number; bodyPreview?: string } }; + +export async function safeJsonFetch( + url: string, + init?: RequestInit +): Promise> { + const res = await fetch(url, init); + + const contentType = res.headers.get("content-type"); + const text = await res.text(); + + // HTTP error — return readable details (don't JSON.parse blindly) + if (!res.ok) { + // Try JSON first if it looks like JSON + if (contentType?.includes("application/json")) { + try { + const parsed = JSON.parse(text); + return { ok: false, error: { message: parsed?.error ?? "Request failed", status: res.status } }; + } catch { + // fall through to generic + } + } + return { + ok: false, + error: { + message: `Request failed (HTTP ${res.status})`, + status: res.status, + bodyPreview: text.slice(0, 300), + }, + }; + } + + // Success but not JSON => this is exactly the "Unexpected token <" case + if (!contentType?.includes("application/json")) { + return { + ok: false, + error: { + message: `Server returned non-JSON (content-type: ${contentType ?? "unknown"})`, + status: res.status, + bodyPreview: text.slice(0, 300), + }, + }; + } + + try { + return { ok: true, data: JSON.parse(text) as T }; + } catch { + return { + ok: false, + error: { + message: "Server returned invalid JSON", + status: res.status, + bodyPreview: text.slice(0, 300), + }, + }; + } +}