|
| 1 | +import { env } from '@/env.mjs' |
| 2 | +import OpenAI from 'openai' |
| 3 | +import { headers } from 'next/headers' |
| 4 | +import { z } from 'zod' |
| 5 | + |
| 6 | +// Rate limit: 10 requests per minute per IP |
| 7 | +const RATE_LIMIT = 10 |
| 8 | +const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute in milliseconds |
| 9 | + |
| 10 | +// Store IP addresses and their request timestamps |
| 11 | +const ipRequests = new Map<string, { count: number; resetTime: number }>() |
| 12 | + |
| 13 | +// Clean up old entries every minute |
| 14 | +setInterval(() => { |
| 15 | + const now = Date.now() |
| 16 | + for (const [ip, data] of ipRequests.entries()) { |
| 17 | + if (data.resetTime < now) { |
| 18 | + ipRequests.delete(ip) |
| 19 | + } |
| 20 | + } |
| 21 | +}, RATE_LIMIT_WINDOW) |
| 22 | + |
| 23 | +const deepseekClient = new OpenAI({ |
| 24 | + apiKey: env.DEEPSEEK_API_KEY, |
| 25 | + baseURL: 'https://api.deepseek.com', |
| 26 | +}) |
| 27 | + |
| 28 | +const corsHeaders = { |
| 29 | + 'Access-Control-Allow-Origin': env.NEXT_PUBLIC_APP_URL, |
| 30 | + 'Access-Control-Allow-Methods': 'POST', |
| 31 | + 'Access-Control-Allow-Headers': 'Content-Type', |
| 32 | + 'Access-Control-Allow-Credentials': 'true', |
| 33 | +} |
| 34 | + |
| 35 | +// Handle OPTIONS preflight request |
| 36 | +export async function OPTIONS() { |
| 37 | + return new Response(null, { |
| 38 | + status: 204, |
| 39 | + headers: corsHeaders, |
| 40 | + }) |
| 41 | +} |
| 42 | + |
| 43 | +// Define request schema |
| 44 | +const RequestSchema = z.object({ |
| 45 | + prompt: z.union([ |
| 46 | + z.string().min(1, { message: 'Prompt is required' }), |
| 47 | + z.array(z.string()).min(1, { message: 'At least one prompt is required' }), |
| 48 | + ]), |
| 49 | +}) |
| 50 | + |
| 51 | +const cleanCodeBlocks = ( |
| 52 | + content: string |
| 53 | +): { html: string; message: string } => { |
| 54 | + // Extract all code blocks |
| 55 | + const codeMatches = content.match(/```[\w-]*\n([\s\S]*?)\n```/g) || [] |
| 56 | + |
| 57 | + // Get just the code content from each block |
| 58 | + const codeContent = codeMatches.reduce((acc, curr) => { |
| 59 | + const match = curr.match(/```[\w-]*\n([\s\S]*?)\n```/) |
| 60 | + return match ? acc + '\n' + match[1] : acc |
| 61 | + }, '') |
| 62 | + |
| 63 | + return { |
| 64 | + html: codeContent, |
| 65 | + // Keep the original text for the message, replacing code blocks with a placeholder |
| 66 | + message: content.replace( |
| 67 | + /```[\w-]*\n[\s\S]*?\n```/g, |
| 68 | + '- Editing file: web/src/app/page.tsx' |
| 69 | + ), |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +const checkRateLimit = (ip: string): Response | null => { |
| 74 | + const now = Date.now() |
| 75 | + const requestData = ipRequests.get(ip) |
| 76 | + |
| 77 | + if (requestData) { |
| 78 | + // If window has expired, reset count |
| 79 | + if (requestData.resetTime < now) { |
| 80 | + ipRequests.set(ip, { count: 1, resetTime: now + RATE_LIMIT_WINDOW }) |
| 81 | + } else { |
| 82 | + // If we're still within the window, increment count |
| 83 | + if (requestData.count >= RATE_LIMIT) { |
| 84 | + return new Response( |
| 85 | + JSON.stringify({ |
| 86 | + error: 'Rate limit exceeded. Please try again later.', |
| 87 | + }), |
| 88 | + { |
| 89 | + status: 429, |
| 90 | + headers: { |
| 91 | + 'Content-Type': 'application/json', |
| 92 | + ...corsHeaders, |
| 93 | + }, |
| 94 | + } |
| 95 | + ) |
| 96 | + } |
| 97 | + requestData.count++ |
| 98 | + } |
| 99 | + } else { |
| 100 | + // First request from this IP |
| 101 | + ipRequests.set(ip, { count: 1, resetTime: now + RATE_LIMIT_WINDOW }) |
| 102 | + } |
| 103 | + |
| 104 | + return null |
| 105 | +} |
| 106 | + |
| 107 | +const validateRequest = async ( |
| 108 | + request: Request |
| 109 | +): Promise<{ |
| 110 | + data: z.infer<typeof RequestSchema> | null |
| 111 | + error: Response | null |
| 112 | +}> => { |
| 113 | + try { |
| 114 | + const body = await request.json() |
| 115 | + const result = RequestSchema.safeParse(body) |
| 116 | + |
| 117 | + if (!result.success) { |
| 118 | + return { |
| 119 | + data: null, |
| 120 | + error: new Response( |
| 121 | + JSON.stringify({ |
| 122 | + error: result.error.issues[0].message, |
| 123 | + }), |
| 124 | + { |
| 125 | + status: 400, |
| 126 | + headers: { |
| 127 | + 'Content-Type': 'application/json', |
| 128 | + ...corsHeaders, |
| 129 | + }, |
| 130 | + } |
| 131 | + ), |
| 132 | + } |
| 133 | + } |
| 134 | + |
| 135 | + return { data: result.data, error: null } |
| 136 | + } catch (error) { |
| 137 | + console.error('Error parsing request:', error) |
| 138 | + return { |
| 139 | + data: null, |
| 140 | + error: new Response( |
| 141 | + JSON.stringify({ |
| 142 | + error: 'Invalid request. Please check your input.', |
| 143 | + }), |
| 144 | + { |
| 145 | + status: 400, |
| 146 | + headers: { |
| 147 | + 'Content-Type': 'application/json', |
| 148 | + ...corsHeaders, |
| 149 | + }, |
| 150 | + } |
| 151 | + ), |
| 152 | + } |
| 153 | + } |
| 154 | +} |
| 155 | + |
| 156 | +const callDeepseekAPI = async ( |
| 157 | + prompts: string[] |
| 158 | +): Promise<{ |
| 159 | + html: string |
| 160 | + message: string |
| 161 | + error: Response | null |
| 162 | +}> => { |
| 163 | + try { |
| 164 | + const response = await deepseekClient.chat.completions.create({ |
| 165 | + model: 'deepseek-chat', |
| 166 | + messages: [ |
| 167 | + { |
| 168 | + role: 'system', |
| 169 | + content: |
| 170 | + "You are a helpful assistant. Respond with valid HTML body that can be injected into an iframe based on the user's messages below. Don't write comments in the code. Be succinct with your response and focus just on the code, with a brief explanation beforehand. Make sure to wrap the code in backticks (```).", |
| 171 | + }, |
| 172 | + ...prompts.map((p) => ({ |
| 173 | + role: 'user' as const, |
| 174 | + content: p, |
| 175 | + })), |
| 176 | + ], |
| 177 | + temperature: 0, |
| 178 | + }) |
| 179 | + |
| 180 | + const { html, message } = cleanCodeBlocks( |
| 181 | + response.choices[0]?.message?.content || 'No response generated' |
| 182 | + ) |
| 183 | + |
| 184 | + return { html, message, error: null } |
| 185 | + } catch (error) { |
| 186 | + console.error('Error calling Deepseek:', error) |
| 187 | + return { |
| 188 | + html: '', |
| 189 | + message: '', |
| 190 | + error: new Response( |
| 191 | + JSON.stringify({ error: 'Failed to generate response' }), |
| 192 | + { |
| 193 | + status: 500, |
| 194 | + headers: { |
| 195 | + 'Content-Type': 'application/json', |
| 196 | + ...corsHeaders, |
| 197 | + }, |
| 198 | + } |
| 199 | + ), |
| 200 | + } |
| 201 | + } |
| 202 | +} |
| 203 | + |
| 204 | +export async function POST(request: Request) { |
| 205 | + // Check origin |
| 206 | + const origin = request.headers.get('origin') |
| 207 | + if (origin !== env.NEXT_PUBLIC_APP_URL) { |
| 208 | + return new Response(JSON.stringify({ error: 'Unauthorized origin' }), { |
| 209 | + status: 403, |
| 210 | + headers: { |
| 211 | + 'Content-Type': 'application/json', |
| 212 | + ...corsHeaders, |
| 213 | + }, |
| 214 | + }) |
| 215 | + } |
| 216 | + |
| 217 | + // Get IP address from headers |
| 218 | + const forwardedFor = headers().get('x-forwarded-for') |
| 219 | + const ip = forwardedFor?.split(',')[0] || 'unknown' |
| 220 | + |
| 221 | + // Check rate limit |
| 222 | + const rateLimitError = checkRateLimit(ip) |
| 223 | + if (rateLimitError) return rateLimitError |
| 224 | + |
| 225 | + // Validate request |
| 226 | + const { data, error: validationError } = await validateRequest(request) |
| 227 | + if (validationError) return validationError |
| 228 | + if (!data) { |
| 229 | + return new Response(JSON.stringify({ error: 'Invalid request data' }), { |
| 230 | + status: 400, |
| 231 | + headers: { |
| 232 | + 'Content-Type': 'application/json', |
| 233 | + ...corsHeaders, |
| 234 | + }, |
| 235 | + }) |
| 236 | + } |
| 237 | + |
| 238 | + // Call Deepseek API |
| 239 | + const prompts = Array.isArray(data.prompt) ? data.prompt : [data.prompt] |
| 240 | + const { html, message, error: apiError } = await callDeepseekAPI(prompts) |
| 241 | + if (apiError) return apiError |
| 242 | + |
| 243 | + return new Response(JSON.stringify({ html, message }), { |
| 244 | + headers: { |
| 245 | + 'Content-Type': 'application/json', |
| 246 | + ...corsHeaders, |
| 247 | + }, |
| 248 | + }) |
| 249 | +} |
0 commit comments