|
| 1 | +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; |
| 2 | +import { createOpenAI } from "@ai-sdk/openai"; |
| 3 | +import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; |
| 4 | +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; |
| 5 | +import { generateText } from "ai"; |
| 6 | + |
| 7 | +const aiProvider = getEnvVariable("STACK_AI_PROVIDER", "openai"); |
| 8 | +const openai = createOpenAI({ |
| 9 | + apiKey: aiProvider === "openrouter" |
| 10 | + ? getEnvVariable("STACK_OPENROUTER_API_KEY", "MISSING_OPENROUTER_API_KEY") |
| 11 | + : getEnvVariable("STACK_OPENAI_API_KEY", "MISSING_OPENAI_API_KEY"), |
| 12 | + baseURL: aiProvider === "openrouter" ? "https://openrouter.ai/api/v1" : undefined, |
| 13 | +}); |
| 14 | + |
| 15 | +const WYSIWYG_SYSTEM_PROMPT = `You are an expert at editing React/JSX code. Your task is to update a specific text string in the source code. |
| 16 | +
|
| 17 | +RULES: |
| 18 | +1. You will be given the original source code and details about a text edit the user wants to make. |
| 19 | +2. Find the text at the specified location and replace it with the new text. |
| 20 | +3. If there are multiple occurrences of the same text, use the provided location info (line, column, occurrence index) to identify the correct one. |
| 21 | +4. The text you're given is given as plaintext, so you should escape it properly. Be smart about what the user's intent may have been; if it contains eg. an added newline character, that's because the user added a newline character, so depending on the context sometimes you should replace it with <br />, sometimes you should create a new <p>, and sometimes you should do something else. Change it in a good-faith interpretation of what the user may have wanted to do, not in perfect spec-compliance. |
| 22 | +5. If the text is part of a template literal or JSX expression, only change the static text portion. |
| 23 | +6. Return ONLY the complete updated source code, nothing else. |
| 24 | +7. Do NOT add any explanation, markdown formatting, or code fences - just the raw source code. |
| 25 | +8. Context: The user is editing the text in a WYSIWYG editor. They expect that the change they made will be reflected as-is, without massively the rest of the source code. However, in most cases, the user don't actually care about the rest of the source code, so in the rare cases where things are complex and you would have to change a bit more than just the text node, you should make the changes that sound reasonable from a UX perspective. |
| 26 | +9. If the user added whitespace padding at the very end or the very beginning of the text node, that was probably an accident and you can ignore it. |
| 27 | +
|
| 28 | +IMPORTANT: |
| 29 | +- The location info includes: line number, column, source context (lines before/after), JSX path, parent element. |
| 30 | +- Use all available information to find the exact text to replace. |
| 31 | +`; |
| 32 | + |
| 33 | +const editMetadataSchema = yupObject({ |
| 34 | + id: yupString().defined(), |
| 35 | + loc: yupObject({ |
| 36 | + start: yupNumber().defined(), |
| 37 | + end: yupNumber().defined(), |
| 38 | + line: yupNumber().defined(), |
| 39 | + column: yupNumber().defined(), |
| 40 | + }).defined(), |
| 41 | + originalText: yupString().defined(), |
| 42 | + textHash: yupString().defined(), |
| 43 | + jsxPath: yupArray(yupString().defined()).defined(), |
| 44 | + parentElement: yupObject({ |
| 45 | + tagName: yupString().defined(), |
| 46 | + props: yupMixed().defined(), |
| 47 | + }).defined(), |
| 48 | + sourceContext: yupObject({ |
| 49 | + before: yupString().defined(), |
| 50 | + after: yupString().defined(), |
| 51 | + }).defined(), |
| 52 | + siblingIndex: yupNumber().defined(), |
| 53 | + occurrenceCount: yupNumber().defined(), |
| 54 | + occurrenceIndex: yupNumber().defined(), |
| 55 | + sourceFile: yupString().oneOf(["template", "theme"]).defined(), |
| 56 | +}); |
| 57 | + |
| 58 | +const domPathItemSchema = yupObject({ |
| 59 | + tagName: yupString().defined(), |
| 60 | + index: yupNumber().defined(), |
| 61 | +}); |
| 62 | + |
| 63 | +export const POST = createSmartRouteHandler({ |
| 64 | + metadata: { |
| 65 | + summary: "Apply WYSIWYG text edit", |
| 66 | + description: "Uses AI to update source code based on a WYSIWYG text edit", |
| 67 | + tags: ["Internal", "AI"], |
| 68 | + hidden: true, |
| 69 | + }, |
| 70 | + request: yupObject({ |
| 71 | + auth: yupObject({ |
| 72 | + type: yupString().oneOf(["admin"]).defined(), |
| 73 | + tenancy: adaptSchema.defined(), |
| 74 | + }).defined(), |
| 75 | + body: yupObject({ |
| 76 | + /** The type of source being edited */ |
| 77 | + source_type: yupString().oneOf(["template", "theme", "draft"]).defined(), |
| 78 | + /** The current source code to edit */ |
| 79 | + source_code: yupString().defined(), |
| 80 | + /** The original text that was in the editable region */ |
| 81 | + old_text: yupString().defined(), |
| 82 | + /** The new text the user wants */ |
| 83 | + new_text: yupString().defined(), |
| 84 | + /** Metadata from the editable region for locating the text */ |
| 85 | + metadata: editMetadataSchema.defined(), |
| 86 | + /** DOM path from the iframe for additional context */ |
| 87 | + dom_path: yupArray(domPathItemSchema.defined()).defined(), |
| 88 | + /** HTML context from the rendered output */ |
| 89 | + html_context: yupString().defined(), |
| 90 | + }).defined(), |
| 91 | + }), |
| 92 | + response: yupObject({ |
| 93 | + statusCode: yupNumber().oneOf([200]).defined(), |
| 94 | + bodyType: yupString().oneOf(["json"]).defined(), |
| 95 | + body: yupObject({ |
| 96 | + updated_source: yupString().defined(), |
| 97 | + }).defined(), |
| 98 | + }), |
| 99 | + async handler({ body }) { |
| 100 | + const { |
| 101 | + source_code, |
| 102 | + old_text, |
| 103 | + new_text, |
| 104 | + metadata, |
| 105 | + dom_path, |
| 106 | + html_context, |
| 107 | + } = body; |
| 108 | + |
| 109 | + // If no change, return original |
| 110 | + if (old_text === new_text) { |
| 111 | + return { |
| 112 | + statusCode: 200, |
| 113 | + bodyType: "json", |
| 114 | + body: { updated_source: source_code }, |
| 115 | + }; |
| 116 | + } |
| 117 | + |
| 118 | + // Build the prompt for the AI |
| 119 | + const userPrompt = ` |
| 120 | +## Source Code to Edit |
| 121 | +\`\`\`tsx |
| 122 | +${source_code} |
| 123 | +\`\`\` |
| 124 | +
|
| 125 | +## Edit Request |
| 126 | +- **Old text:** "${old_text}" |
| 127 | +- **New text:** "${new_text}" |
| 128 | +
|
| 129 | +## Location Information |
| 130 | +- **Line:** ${metadata.loc.line} |
| 131 | +- **Column:** ${metadata.loc.column} |
| 132 | +- **JSX Path:** ${metadata.jsxPath.join(" > ")} |
| 133 | +- **Parent Element:** <${metadata.parentElement.tagName}> |
| 134 | +- **Sibling Index:** ${metadata.siblingIndex} |
| 135 | +- **Occurrence:** ${metadata.occurrenceIndex} of ${metadata.occurrenceCount} |
| 136 | +
|
| 137 | +## Source Context (lines around the text) |
| 138 | +Before: |
| 139 | +\`\`\` |
| 140 | +${metadata.sourceContext.before} |
| 141 | +\`\`\` |
| 142 | +
|
| 143 | +After: |
| 144 | +\`\`\` |
| 145 | +${metadata.sourceContext.after} |
| 146 | +\`\`\` |
| 147 | +
|
| 148 | +## Runtime DOM Path (for disambiguation) |
| 149 | +${dom_path.map((p, i) => `${i + 1}. <${p.tagName}> (index: ${p.index})`).join("\n")} |
| 150 | +
|
| 151 | +## Rendered HTML Context |
| 152 | +\`\`\`html |
| 153 | +${html_context.slice(0, 500)} |
| 154 | +\`\`\` |
| 155 | +
|
| 156 | +Please update the source code to change "${old_text}" to "${new_text}" at the specified location. Return ONLY the complete updated source code. |
| 157 | +`; |
| 158 | + |
| 159 | + const modelName = "google/gemini-3-flash-preview"; |
| 160 | + |
| 161 | + const result = await generateText({ |
| 162 | + model: openai(modelName), |
| 163 | + system: WYSIWYG_SYSTEM_PROMPT, |
| 164 | + messages: [{ role: "user", content: userPrompt }], |
| 165 | + }); |
| 166 | + |
| 167 | + // Extract the updated source code from the response |
| 168 | + let updatedSource = result.text.trim(); |
| 169 | + |
| 170 | + // Remove any markdown code fences if the AI added them despite instructions |
| 171 | + if (updatedSource.startsWith("```")) { |
| 172 | + const lines = updatedSource.split("\n"); |
| 173 | + // Remove first line (```tsx or similar) |
| 174 | + lines.shift(); |
| 175 | + // Remove last line if it's ``` |
| 176 | + if (lines[lines.length - 1]?.trim() === "```") { |
| 177 | + lines.pop(); |
| 178 | + } |
| 179 | + updatedSource = lines.join("\n"); |
| 180 | + } |
| 181 | + |
| 182 | + return { |
| 183 | + statusCode: 200, |
| 184 | + bodyType: "json", |
| 185 | + body: { updated_source: updatedSource }, |
| 186 | + }; |
| 187 | + }, |
| 188 | +}); |
0 commit comments