route.ts 5.4 KB

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