route.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. import { streamText } from "ai";
  2. import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
  3. import { headers } from "next/headers";
  4. // Route generation through the ai.mm.mk fleet gateway (OpenAI-compatible)
  5. // instead of the default Vercel AI Gateway.
  6. const gateway = createOpenAICompatible({
  7. name: "ai-mm-mk",
  8. baseURL: process.env.AI_API_URL || "https://ai.mm.mk/v1",
  9. apiKey: process.env.AI_API_KEY || "sk-ai-mm-mk-2026",
  10. headers: {
  11. "X-AI-Caller":
  12. "site=apps/web/app/api/generate/route.ts; comp=script; via=jsonrender-playground",
  13. },
  14. });
  15. import type { Spec, EditMode } from "@json-render/core";
  16. import {
  17. buildUserPrompt,
  18. buildEditUserPrompt,
  19. isNonEmptySpec,
  20. } from "@json-render/core";
  21. import { yamlPrompt } from "@json-render/yaml";
  22. import { stringify as yamlStringify } from "yaml";
  23. import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
  24. import { playgroundCatalog } from "@/lib/render/catalog";
  25. export const maxDuration = 30;
  26. const PLAYGROUND_RULES = [
  27. "NEVER use viewport height classes (min-h-screen, h-screen) - the UI renders inside a fixed-size container.",
  28. "NEVER use page background colors (bg-gray-50) - the container has its own background.",
  29. "For forms or small UIs: use Card as root with maxWidth:'sm' or 'md' and centered:true.",
  30. "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.",
  31. "Wrap each repeated item in a Card for visual separation and structure.",
  32. "Use realistic, professional sample data. Include 3-5 items with varied content. Never leave state arrays empty.",
  33. '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" }).',
  34. "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.",
  35. "For icon+label patterns, use a horizontal Stack with gap:'sm' and align:'center' containing an Icon and a Text.",
  36. "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.",
  37. "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.",
  38. "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.",
  39. '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.',
  40. "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.",
  41. "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.",
  42. "For plain images (non-gallery), give Image a real 'src' (https://picsum.photos/seed/<unique-word>/<w>/<h>, different seed each) — never leave them as empty placeholders.",
  43. "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.",
  44. ];
  45. const MAX_PROMPT_LENGTH = 500;
  46. const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
  47. function getSystemPrompt(isYaml: boolean, editModes?: EditMode[]): string {
  48. if (isYaml) {
  49. return yamlPrompt(playgroundCatalog, {
  50. mode: "standalone",
  51. customRules: PLAYGROUND_RULES,
  52. editModes: editModes ?? ["merge"],
  53. });
  54. }
  55. return playgroundCatalog.prompt({
  56. customRules: PLAYGROUND_RULES,
  57. editModes,
  58. });
  59. }
  60. function buildYamlUserPrompt(
  61. prompt: string,
  62. previousSpec?: Spec | null,
  63. editModes?: EditMode[],
  64. ): string {
  65. if (isNonEmptySpec(previousSpec)) {
  66. return buildEditUserPrompt({
  67. prompt,
  68. currentSpec: previousSpec,
  69. config: { modes: editModes ?? ["merge"] },
  70. format: "yaml",
  71. maxPromptLength: MAX_PROMPT_LENGTH,
  72. serializer: (s) => yamlStringify(s, { indent: 2 }).trimEnd(),
  73. });
  74. }
  75. const userText = prompt.slice(0, MAX_PROMPT_LENGTH);
  76. return [
  77. userText,
  78. "",
  79. "Output the full spec in a ```yaml-spec fence. Stream progressively — output elements one at a time.",
  80. ].join("\n");
  81. }
  82. export async function POST(req: Request) {
  83. const headersList = await headers();
  84. const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
  85. const [minuteResult, dailyResult] = await Promise.all([
  86. minuteRateLimit.limit(ip),
  87. dailyRateLimit.limit(ip),
  88. ]);
  89. if (!minuteResult.success || !dailyResult.success) {
  90. const isMinuteLimit = !minuteResult.success;
  91. return new Response(
  92. JSON.stringify({
  93. error: "Rate limit exceeded",
  94. message: isMinuteLimit
  95. ? "Too many requests. Please wait a moment before trying again."
  96. : "Daily limit reached. Please try again tomorrow.",
  97. }),
  98. {
  99. status: 429,
  100. headers: { "Content-Type": "application/json" },
  101. },
  102. );
  103. }
  104. const { prompt, context, format, editModes } = await req.json();
  105. const isYaml = format === "yaml";
  106. const systemPrompt = getSystemPrompt(isYaml, editModes);
  107. const userPrompt = isYaml
  108. ? buildYamlUserPrompt(prompt, context?.previousSpec, editModes)
  109. : buildUserPrompt({
  110. prompt,
  111. currentSpec: context?.previousSpec,
  112. maxPromptLength: MAX_PROMPT_LENGTH,
  113. editModes,
  114. });
  115. const result = streamText({
  116. model: gateway(process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL),
  117. system: [
  118. {
  119. role: "system",
  120. content: systemPrompt,
  121. providerOptions: {
  122. anthropic: { cacheControl: { type: "ephemeral" } },
  123. },
  124. },
  125. ],
  126. prompt: userPrompt,
  127. temperature: 0.7,
  128. });
  129. const encoder = new TextEncoder();
  130. const textStream = result.textStream;
  131. const stream = new ReadableStream({
  132. async start(controller) {
  133. for await (const chunk of textStream) {
  134. controller.enqueue(encoder.encode(chunk));
  135. }
  136. try {
  137. const usage = await result.usage;
  138. const meta = JSON.stringify({
  139. __meta: "usage",
  140. promptTokens: usage.inputTokens,
  141. completionTokens: usage.outputTokens,
  142. totalTokens: usage.totalTokens,
  143. cachedTokens: usage.inputTokenDetails?.cacheReadTokens ?? 0,
  144. cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens ?? 0,
  145. });
  146. controller.enqueue(encoder.encode(`\n${meta}\n`));
  147. } catch {
  148. // Usage not available
  149. }
  150. controller.close();
  151. },
  152. });
  153. return new Response(stream, {
  154. headers: { "Content-Type": "text/plain; charset=utf-8" },
  155. });
  156. }