import { streamText } from "ai"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import { headers } from "next/headers"; // Route generation through the ai.mm.mk fleet gateway (OpenAI-compatible) // instead of the default Vercel AI Gateway. const gateway = createOpenAICompatible({ name: "ai-mm-mk", baseURL: process.env.AI_API_URL || "https://ai.mm.mk/v1", apiKey: process.env.AI_API_KEY || "sk-ai-mm-mk-2026", headers: { "X-AI-Caller": "site=apps/web/app/api/generate/route.ts; comp=script; via=jsonrender-playground", }, }); import type { Spec, EditMode } from "@json-render/core"; import { buildUserPrompt, buildEditUserPrompt, isNonEmptySpec, } from "@json-render/core"; import { yamlPrompt } from "@json-render/yaml"; import { stringify as yamlStringify } from "yaml"; import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit"; import { playgroundCatalog } from "@/lib/render/catalog"; export const maxDuration = 30; const PLAYGROUND_RULES = [ "NEVER use viewport height classes (min-h-screen, h-screen) - the UI renders inside a fixed-size container.", "NEVER use page background colors (bg-gray-50) - the container has its own background.", "For forms or small UIs: use Card as root with maxWidth:'sm' or 'md' and centered:true.", "For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts. Keep the total UI compact — avoid sprawling multi-section pages. Prefer a single focused Card over a full page layout.", "Wrap each repeated item in a Card for visual separation and structure.", "Use realistic, professional sample data. Include 3-5 items with varied content. Never leave state arrays empty.", 'For form inputs (Input, Textarea, Select), always include checks for validation (e.g. required, email, minLength). Always pair checks with a $bindState expression on the value prop (e.g. { "$bindState": "/path" }).', "NEVER use emoji characters. Use the Icon component with Lucide icon names instead. For example, use Icon with name:'MapPin' instead of a pin emoji, Icon with name:'Mail' instead of an envelope emoji, etc.", "For icon+label patterns, use a horizontal Stack with gap:'sm' and align:'center' containing an Icon and a Text.", "For any tabular or list data with consistent columns (items, orders, stats), ALWAYS use the Table component. Never simulate tables with Stacks — the columns won't align.", "For Metric, the value must be the RAW number/text only — NEVER include the currency symbol or unit inside value. Put symbols in prefix/suffix instead. Correct: value:'48,320', prefix:'$'. WRONG: value:'$48,320', prefix:'$' (renders a double '$$'). Same for percentages and units — use suffix:'%', not a '%' inside value.", "For a small, fixed set of tags/badges/chips (skills, categories, labels — up to ~8 items), emit one explicit Badge element PER item inside a horizontal Stack (direction:'horizontal', gap:'sm', wrap:true). Do NOT use repeat for these short static lists.", 'CRITICAL repeat semantics: the top-level \'repeat\' field renders an element\'s CHILDREN once per state-array item — it must be placed on a CONTAINER (Stack/Grid) that has a child template element, and the child reads item fields via {"$item":"field"}. NEVER put \'repeat\' directly on a leaf element (Badge, Text, Metric) with {"$item":...} in its own props — a leaf has no children to repeat, so it renders NOTHING.', "For Kanban boards or any side-by-side COLUMN layout: the board root must be a horizontal Stack with wrap:false (so columns stay on one row and scroll horizontally instead of stacking vertically). Give EACH column a Card with maxWidth:'sm' (or a Stack of fixed width) so columns keep a readable width. Do NOT use a vertical Stack or a wrapping horizontal Stack for board columns.", "For a photo gallery WITH a lightbox (click-to-enlarge), ALWAYS use the single self-contained Lightbox component — pass images:[{src, caption}] with real picsum.photos URLs (different seed per photo). It renders the thumbnail grid AND the fullscreen overlay with prev/next internally. Do NOT build galleries from Pressable+Dialog+setState for the lightbox — that interaction does not work; Lightbox is the correct component.", "For plain images (non-gallery), give Image a real 'src' (https://picsum.photos/seed///, different seed each) — never leave them as empty placeholders.", "For any click-to-open modal (confirmation, detail popup, modal form), ALWAYS use the self-contained Modal component. The Modal element ITSELF is the trigger button (its text = triggerLabel) — place the Modal where you want that button and put the modal body as its children. Do NOT also add a separate Button to open it, and do NOT attach setState/openPath/on.press anywhere for the modal — that pattern does not work and creates a duplicate dead button. One Modal node renders both the button and its popup.", ]; const MAX_PROMPT_LENGTH = 500; const DEFAULT_MODEL = "anthropic/claude-haiku-4.5"; function getSystemPrompt(isYaml: boolean, editModes?: EditMode[]): string { if (isYaml) { return yamlPrompt(playgroundCatalog, { mode: "standalone", customRules: PLAYGROUND_RULES, editModes: editModes ?? ["merge"], }); } return playgroundCatalog.prompt({ customRules: PLAYGROUND_RULES, editModes, }); } function buildYamlUserPrompt( prompt: string, previousSpec?: Spec | null, editModes?: EditMode[], ): string { if (isNonEmptySpec(previousSpec)) { return buildEditUserPrompt({ prompt, currentSpec: previousSpec, config: { modes: editModes ?? ["merge"] }, format: "yaml", maxPromptLength: MAX_PROMPT_LENGTH, serializer: (s) => yamlStringify(s, { indent: 2 }).trimEnd(), }); } const userText = prompt.slice(0, MAX_PROMPT_LENGTH); return [ userText, "", "Output the full spec in a ```yaml-spec fence. Stream progressively — output elements one at a time.", ].join("\n"); } export async function POST(req: Request) { const headersList = await headers(); const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous"; const [minuteResult, dailyResult] = await Promise.all([ minuteRateLimit.limit(ip), dailyRateLimit.limit(ip), ]); if (!minuteResult.success || !dailyResult.success) { const isMinuteLimit = !minuteResult.success; return new Response( JSON.stringify({ error: "Rate limit exceeded", message: isMinuteLimit ? "Too many requests. Please wait a moment before trying again." : "Daily limit reached. Please try again tomorrow.", }), { status: 429, headers: { "Content-Type": "application/json" }, }, ); } const { prompt, context, format, editModes } = await req.json(); const isYaml = format === "yaml"; const systemPrompt = getSystemPrompt(isYaml, editModes); const userPrompt = isYaml ? buildYamlUserPrompt(prompt, context?.previousSpec, editModes) : buildUserPrompt({ prompt, currentSpec: context?.previousSpec, maxPromptLength: MAX_PROMPT_LENGTH, editModes, }); const result = streamText({ model: gateway(process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL), system: [ { role: "system", content: systemPrompt, providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } }, }, }, ], prompt: userPrompt, temperature: 0.7, }); const encoder = new TextEncoder(); const textStream = result.textStream; const stream = new ReadableStream({ async start(controller) { for await (const chunk of textStream) { controller.enqueue(encoder.encode(chunk)); } try { const usage = await result.usage; const meta = JSON.stringify({ __meta: "usage", promptTokens: usage.inputTokens, completionTokens: usage.outputTokens, totalTokens: usage.totalTokens, cachedTokens: usage.inputTokenDetails?.cacheReadTokens ?? 0, cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens ?? 0, }); controller.enqueue(encoder.encode(`\n${meta}\n`)); } catch { // Usage not available } controller.close(); }, }); return new Response(stream, { headers: { "Content-Type": "text/plain; charset=utf-8" }, }); }