route.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  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.",
  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. ];
  23. const MAX_PROMPT_LENGTH = 500;
  24. const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
  25. function getSystemPrompt(isYaml: boolean, editModes?: EditMode[]): string {
  26. if (isYaml) {
  27. return yamlPrompt(playgroundCatalog, {
  28. mode: "standalone",
  29. customRules: PLAYGROUND_RULES,
  30. editModes: editModes ?? ["merge"],
  31. });
  32. }
  33. return playgroundCatalog.prompt({
  34. customRules: PLAYGROUND_RULES,
  35. editModes,
  36. });
  37. }
  38. function buildYamlUserPrompt(
  39. prompt: string,
  40. previousSpec?: Spec | null,
  41. editModes?: EditMode[],
  42. ): string {
  43. if (isNonEmptySpec(previousSpec)) {
  44. return buildEditUserPrompt({
  45. prompt,
  46. currentSpec: previousSpec,
  47. config: { modes: editModes ?? ["merge"] },
  48. format: "yaml",
  49. maxPromptLength: MAX_PROMPT_LENGTH,
  50. serializer: (s) => yamlStringify(s, { indent: 2 }).trimEnd(),
  51. });
  52. }
  53. const userText = prompt.slice(0, MAX_PROMPT_LENGTH);
  54. return [
  55. userText,
  56. "",
  57. "Output the full spec in a ```yaml-spec fence. Stream progressively — output elements one at a time.",
  58. ].join("\n");
  59. }
  60. export async function POST(req: Request) {
  61. const headersList = await headers();
  62. const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
  63. const [minuteResult, dailyResult] = await Promise.all([
  64. minuteRateLimit.limit(ip),
  65. dailyRateLimit.limit(ip),
  66. ]);
  67. if (!minuteResult.success || !dailyResult.success) {
  68. const isMinuteLimit = !minuteResult.success;
  69. return new Response(
  70. JSON.stringify({
  71. error: "Rate limit exceeded",
  72. message: isMinuteLimit
  73. ? "Too many requests. Please wait a moment before trying again."
  74. : "Daily limit reached. Please try again tomorrow.",
  75. }),
  76. {
  77. status: 429,
  78. headers: { "Content-Type": "application/json" },
  79. },
  80. );
  81. }
  82. const { prompt, context, format, editModes } = await req.json();
  83. const isYaml = format === "yaml";
  84. const systemPrompt = getSystemPrompt(isYaml, editModes);
  85. const userPrompt = isYaml
  86. ? buildYamlUserPrompt(prompt, context?.previousSpec, editModes)
  87. : buildUserPrompt({
  88. prompt,
  89. currentSpec: context?.previousSpec,
  90. maxPromptLength: MAX_PROMPT_LENGTH,
  91. editModes,
  92. });
  93. const result = streamText({
  94. model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
  95. system: [
  96. {
  97. role: "system",
  98. content: systemPrompt,
  99. providerOptions: {
  100. anthropic: { cacheControl: { type: "ephemeral" } },
  101. },
  102. },
  103. ],
  104. prompt: userPrompt,
  105. temperature: 0.7,
  106. });
  107. const encoder = new TextEncoder();
  108. const textStream = result.textStream;
  109. const stream = new ReadableStream({
  110. async start(controller) {
  111. for await (const chunk of textStream) {
  112. controller.enqueue(encoder.encode(chunk));
  113. }
  114. try {
  115. const usage = await result.usage;
  116. const meta = JSON.stringify({
  117. __meta: "usage",
  118. promptTokens: usage.inputTokens,
  119. completionTokens: usage.outputTokens,
  120. totalTokens: usage.totalTokens,
  121. cachedTokens: usage.inputTokenDetails?.cacheReadTokens ?? 0,
  122. cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens ?? 0,
  123. });
  124. controller.enqueue(encoder.encode(`\n${meta}\n`));
  125. } catch {
  126. // Usage not available
  127. }
  128. controller.close();
  129. },
  130. });
  131. return new Response(stream, {
  132. headers: { "Content-Type": "text/plain; charset=utf-8" },
  133. });
  134. }