sonance-brand-mcp 1.3.19 → 1.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,656 @@
1
+ import { NextResponse } from "next/server";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import Anthropic from "@anthropic-ai/sdk";
5
+
6
+ /**
7
+ * Sonance DevTools API - Vision Mode Editor
8
+ *
9
+ * Uses Claude's vision capabilities to understand page screenshots and modify
10
+ * multiple files based on natural language requests.
11
+ *
12
+ * Supports actions:
13
+ * - "edit": Analyze screenshot + code context, generate multi-file modifications
14
+ * - "save": Write all modified files to disk
15
+ *
16
+ * DEVELOPMENT ONLY.
17
+ */
18
+
19
+ interface VisionFocusedElement {
20
+ name: string;
21
+ type: string;
22
+ variantId?: string;
23
+ coordinates: {
24
+ x: number;
25
+ y: number;
26
+ width: number;
27
+ height: number;
28
+ };
29
+ description?: string;
30
+ }
31
+
32
+ interface VisionFileModification {
33
+ filePath: string;
34
+ originalContent: string;
35
+ modifiedContent: string;
36
+ diff: string;
37
+ explanation: string;
38
+ previewCSS?: string;
39
+ }
40
+
41
+ interface VisionEditRequest {
42
+ action: "edit" | "save";
43
+ screenshot?: string;
44
+ pageRoute: string;
45
+ userPrompt: string;
46
+ focusedElements?: VisionFocusedElement[];
47
+ modifications?: VisionFileModification[];
48
+ }
49
+
50
+ interface VisionEditResponse {
51
+ success: boolean;
52
+ modifications?: VisionFileModification[];
53
+ aggregatedPreviewCSS?: string;
54
+ explanation?: string;
55
+ reasoning?: string;
56
+ error?: string;
57
+ }
58
+
59
+ const VISION_SYSTEM_PROMPT = `You are a React/Tailwind CSS expert with vision capabilities for the Sonance brand system.
60
+
61
+ You can see screenshots of web pages and understand their visual layout, then modify code to implement requested changes.
62
+
63
+ ═══════════════════════════════════════════════════════════════════════════════
64
+ CRITICAL RULES
65
+ ═══════════════════════════════════════════════════════════════════════════════
66
+
67
+ **FILE RULES (MOST IMPORTANT):**
68
+ 1. You may ONLY edit files that are provided in the PAGE CONTEXT section
69
+ 2. NEVER create new files - only modify existing ones shown to you
70
+ 3. The filePath in your response MUST exactly match one of the provided file paths
71
+ 4. If you cannot find the right file to edit, explain this in your response instead of creating a new file
72
+
73
+ **ANALYSIS:**
74
+ 5. CAREFULLY analyze the screenshot to understand the current visual state
75
+ 6. Identify elements mentioned in the user's request
76
+ 7. Understand the current styling and layout
77
+ 8. Consider how changes will affect the overall design
78
+
79
+ **PRESERVATION RULES:**
80
+ 9. NEVER delete or remove existing content, children, or JSX elements
81
+ 10. NEVER change component structure unless specifically requested
82
+ 11. NEVER modify TypeScript types, imports, or exports unless necessary
83
+ 12. NEVER remove data-sonance-* attributes
84
+
85
+ **CHANGE RULES:**
86
+ 13. Make ONLY the changes requested by the user
87
+ 14. Modify the minimum amount of code necessary
88
+ 15. Use semantic Tailwind classes (bg-primary, text-foreground, etc.)
89
+ 16. Maintain dark mode compatibility with CSS variables
90
+ 17. Keep the cn() utility for className merging
91
+
92
+ **SONANCE BRAND COLORS:**
93
+ - Charcoal: #333F48, #343D46 (primary)
94
+ - Silver: #E2E2E2, #D1D1D6 (secondary)
95
+ - IPORT Orange: #FC4C02
96
+ - IPORT Dark: #0F161D
97
+ - Blaze Blue: #00A3E1
98
+ - Blaze Red: #C02B0A
99
+
100
+ **RESPONSE FORMAT:**
101
+ Return ONLY a valid JSON object. Do not include any conversational text before or after the JSON.
102
+ The JSON must include:
103
+ - "reasoning": Brief explanation of what you see in the screenshot and your plan
104
+ - "modifications": Array of file modifications, each with:
105
+ - "filePath": Path to the file
106
+ - "modifiedContent": Complete updated file content
107
+ - "explanation": What changed in this file
108
+ - "previewCSS": CSS for live preview (use [data-sonance-name="ComponentName"] selectors)
109
+ - "aggregatedPreviewCSS": Combined CSS for all changes
110
+ - "explanation": Overall summary of changes`;
111
+
112
+ export async function POST(request: Request) {
113
+ // Only allow in development
114
+ if (process.env.NODE_ENV !== "development") {
115
+ return NextResponse.json(
116
+ { error: "Vision edit is only available in development mode" },
117
+ { status: 403 }
118
+ );
119
+ }
120
+
121
+ try {
122
+ const body: VisionEditRequest = await request.json();
123
+ const { action, screenshot, pageRoute, userPrompt, focusedElements, modifications } = body;
124
+
125
+ const projectRoot = process.cwd();
126
+
127
+ // Handle save action - write all modified files
128
+ if (action === "save") {
129
+ if (!modifications || modifications.length === 0) {
130
+ return NextResponse.json(
131
+ { error: "modifications array is required for save action" },
132
+ { status: 400 }
133
+ );
134
+ }
135
+
136
+ // Create backups first
137
+ const backups: { filePath: string; content: string }[] = [];
138
+ for (const mod of modifications) {
139
+ const fullPath = path.join(projectRoot, mod.filePath);
140
+ if (fs.existsSync(fullPath)) {
141
+ backups.push({
142
+ filePath: mod.filePath,
143
+ content: fs.readFileSync(fullPath, "utf-8"),
144
+ });
145
+ }
146
+ }
147
+
148
+ try {
149
+ // Write all files
150
+ for (const mod of modifications) {
151
+ const fullPath = path.join(projectRoot, mod.filePath);
152
+
153
+ // Validate path is within project
154
+ if (!fullPath.startsWith(projectRoot)) {
155
+ throw new Error(`Invalid file path: ${mod.filePath}`);
156
+ }
157
+
158
+ fs.writeFileSync(fullPath, mod.modifiedContent, "utf-8");
159
+ }
160
+
161
+ return NextResponse.json({
162
+ success: true,
163
+ message: `Successfully updated ${modifications.length} file(s)`,
164
+ });
165
+ } catch (writeError) {
166
+ // Rollback on error
167
+ for (const backup of backups) {
168
+ try {
169
+ const fullPath = path.join(projectRoot, backup.filePath);
170
+ fs.writeFileSync(fullPath, backup.content, "utf-8");
171
+ } catch {
172
+ console.error(`Failed to rollback ${backup.filePath}`);
173
+ }
174
+ }
175
+ throw writeError;
176
+ }
177
+ }
178
+
179
+ // Handle edit action (Vision AI modification)
180
+ if (action === "edit") {
181
+ if (!userPrompt) {
182
+ return NextResponse.json(
183
+ { error: "userPrompt is required for edit action" },
184
+ { status: 400 }
185
+ );
186
+ }
187
+
188
+ // Check for API key
189
+ const apiKey = process.env.NEXT_PUBLIC_CLAUDE_API_KEY;
190
+ if (!apiKey) {
191
+ return NextResponse.json(
192
+ { error: "NEXT_PUBLIC_CLAUDE_API_KEY not configured. Add it to your .env.local file." },
193
+ { status: 500 }
194
+ );
195
+ }
196
+
197
+ // Gather page context (including focused element files via project-wide search)
198
+ const pageContext = gatherPageContext(pageRoute, projectRoot, focusedElements);
199
+
200
+ // Build user message with vision
201
+ const messageContent: Anthropic.MessageCreateParams["messages"][0]["content"] = [];
202
+
203
+ // Add screenshot if provided
204
+ if (screenshot) {
205
+ const base64Data = screenshot.split(",")[1] || screenshot;
206
+ messageContent.push({
207
+ type: "image",
208
+ source: {
209
+ type: "base64",
210
+ media_type: "image/png",
211
+ data: base64Data,
212
+ },
213
+ });
214
+ }
215
+
216
+ // Build text content
217
+ let textContent = `VISION MODE EDIT REQUEST
218
+
219
+ Page Route: ${pageRoute}
220
+ User Request: "${userPrompt}"
221
+
222
+ `;
223
+
224
+ if (focusedElements && focusedElements.length > 0) {
225
+ textContent += `FOCUSED ELEMENTS (user clicked on these):
226
+ ${focusedElements.map((el) => `- ${el.name} (${el.type}) at (${el.coordinates.x}, ${el.coordinates.y})`).join("\n")}
227
+
228
+ `;
229
+ }
230
+
231
+ textContent += `PAGE CONTEXT:
232
+
233
+ Page File: ${pageContext.pageFile || "Not found"}
234
+ ${pageContext.pageContent ? `\`\`\`tsx\n${pageContext.pageContent}\n\`\`\`` : ""}
235
+
236
+ `;
237
+
238
+ if (pageContext.componentSources.length > 0) {
239
+ textContent += `IMPORTED COMPONENTS:\n`;
240
+ for (const comp of pageContext.componentSources) {
241
+ textContent += `
242
+ File: ${comp.path}
243
+ \`\`\`tsx
244
+ ${comp.content.substring(0, 3000)}${comp.content.length > 3000 ? "\n// ... (truncated)" : ""}
245
+ \`\`\`
246
+ `;
247
+ }
248
+ }
249
+
250
+ textContent += `
251
+ GLOBALS.CSS (relevant theme variables):
252
+ \`\`\`css
253
+ ${pageContext.globalsCSS.substring(0, 2000)}${pageContext.globalsCSS.length > 2000 ? "\n/* ... (truncated) */" : ""}
254
+ \`\`\`
255
+
256
+ VALID FILES YOU MAY EDIT:
257
+ ${pageContext.pageFile ? `- ${pageContext.pageFile}` : ""}
258
+ ${pageContext.componentSources.map((c) => `- ${c.path}`).join("\n")}
259
+
260
+ INSTRUCTIONS:
261
+ 1. Look at the screenshot and identify elements mentioned in the user's request
262
+ 2. Review the provided code to understand current implementation
263
+ 3. Choose which of the VALID FILES above need modifications
264
+ 4. Generate complete modified code for each file
265
+ 5. Provide previewCSS for immediate visual feedback
266
+ 6. Return as JSON in the specified format
267
+
268
+ CRITICAL: Only use file paths from the VALID FILES list above. Do NOT create new files.`;
269
+
270
+ messageContent.push({
271
+ type: "text",
272
+ text: textContent,
273
+ });
274
+
275
+ // Call Claude Vision API
276
+ const anthropic = new Anthropic({ apiKey });
277
+
278
+ const response = await anthropic.messages.create({
279
+ model: "claude-sonnet-4-20250514",
280
+ max_tokens: 16384,
281
+ messages: [
282
+ {
283
+ role: "user",
284
+ content: messageContent,
285
+ },
286
+ ],
287
+ system: VISION_SYSTEM_PROMPT,
288
+ });
289
+
290
+ // Extract text content from response
291
+ const textResponse = response.content.find((block) => block.type === "text");
292
+ if (!textResponse || textResponse.type !== "text") {
293
+ return NextResponse.json(
294
+ { error: "No text response from AI" },
295
+ { status: 500 }
296
+ );
297
+ }
298
+
299
+ // Parse AI response
300
+ let aiResponse: {
301
+ reasoning?: string;
302
+ modifications: Array<{
303
+ filePath: string;
304
+ modifiedContent: string;
305
+ explanation: string;
306
+ previewCSS?: string;
307
+ }>;
308
+ aggregatedPreviewCSS?: string;
309
+ explanation?: string;
310
+ };
311
+
312
+ try {
313
+ let jsonText = textResponse.text.trim();
314
+
315
+ // Try to extract JSON from markdown code blocks
316
+ const jsonMatch = jsonText.match(/```json\n([\s\S]*?)\n```/) ||
317
+ jsonText.match(/```\n([\s\S]*?)\n```/);
318
+
319
+ if (jsonMatch) {
320
+ jsonText = jsonMatch[1];
321
+ } else if (jsonText.includes("```json")) {
322
+ // Fallback for cases where regex might miss due to newlines
323
+ const start = jsonText.indexOf("```json") + 7;
324
+ const end = jsonText.lastIndexOf("```");
325
+ if (end > start) {
326
+ jsonText = jsonText.substring(start, end);
327
+ }
328
+ }
329
+
330
+ // Clean up any remaining whitespace
331
+ jsonText = jsonText.trim();
332
+
333
+ aiResponse = JSON.parse(jsonText);
334
+ } catch {
335
+ console.error("Failed to parse AI response:", textResponse.text);
336
+ return NextResponse.json(
337
+ { error: "Failed to parse AI response. Please try again." },
338
+ { status: 500 }
339
+ );
340
+ }
341
+
342
+ // Build list of valid file paths
343
+ const validPaths = new Set<string>();
344
+ if (pageContext.pageFile) {
345
+ validPaths.add(pageContext.pageFile);
346
+ }
347
+ for (const comp of pageContext.componentSources) {
348
+ validPaths.add(comp.path);
349
+ }
350
+
351
+ // Validate AI response - reject any file paths not in our valid list
352
+ const invalidMods = (aiResponse.modifications || []).filter(
353
+ (mod) => !validPaths.has(mod.filePath)
354
+ );
355
+
356
+ if (invalidMods.length > 0) {
357
+ console.error(
358
+ "AI attempted to create new files:",
359
+ invalidMods.map((m) => m.filePath)
360
+ );
361
+ return NextResponse.json(
362
+ {
363
+ success: false,
364
+ error: `Cannot create new files. The following paths were not found in the project: ${invalidMods.map((m) => m.filePath).join(", ")}. Please try a more specific request targeting existing components.`,
365
+ } as VisionEditResponse,
366
+ { status: 400 }
367
+ );
368
+ }
369
+
370
+ // Read original content and generate diffs for each modification
371
+ const modificationsWithOriginals: VisionFileModification[] = [];
372
+ for (const mod of aiResponse.modifications || []) {
373
+ const fullPath = path.join(projectRoot, mod.filePath);
374
+ let originalContent = "";
375
+ if (fs.existsSync(fullPath)) {
376
+ originalContent = fs.readFileSync(fullPath, "utf-8");
377
+ }
378
+
379
+ modificationsWithOriginals.push({
380
+ filePath: mod.filePath,
381
+ originalContent,
382
+ modifiedContent: mod.modifiedContent,
383
+ diff: generateSimpleDiff(originalContent, mod.modifiedContent),
384
+ explanation: mod.explanation,
385
+ previewCSS: mod.previewCSS,
386
+ });
387
+ }
388
+
389
+ // Aggregate preview CSS
390
+ const aggregatedCSS = modificationsWithOriginals
391
+ .filter((m) => m.previewCSS)
392
+ .map((m) => `/* ${m.filePath} */\n${m.previewCSS}`)
393
+ .join("\n\n");
394
+
395
+ return NextResponse.json({
396
+ success: true,
397
+ modifications: modificationsWithOriginals,
398
+ aggregatedPreviewCSS: aiResponse.aggregatedPreviewCSS || aggregatedCSS,
399
+ explanation: aiResponse.explanation,
400
+ reasoning: aiResponse.reasoning,
401
+ } as VisionEditResponse);
402
+ }
403
+
404
+ return NextResponse.json(
405
+ { error: "Invalid action. Use 'edit' or 'save'." },
406
+ { status: 400 }
407
+ );
408
+ } catch (error) {
409
+ console.error("Vision edit error:", error);
410
+ return NextResponse.json(
411
+ { error: "Failed to process request", details: String(error) },
412
+ { status: 500 }
413
+ );
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Search for component files by name across the project
419
+ * Uses similar logic to /api/sonance-find-component
420
+ */
421
+ function findComponentFileByName(
422
+ componentName: string,
423
+ projectRoot: string
424
+ ): string | null {
425
+ const normalizedName = componentName.toLowerCase().replace(/\s+/g, "-");
426
+ const baseName = normalizedName.split("-")[0];
427
+
428
+ const SEARCH_DIRS = ["src/components", "components", "src", "app"];
429
+ const EXTENSIONS = [".tsx", ".jsx", ".ts", ".js"];
430
+
431
+ function searchRecursive(
432
+ dir: string,
433
+ fileName: string,
434
+ depth = 0
435
+ ): string | null {
436
+ if (depth > 4) return null;
437
+ try {
438
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
439
+ // Check direct matches
440
+ for (const ext of EXTENSIONS) {
441
+ const match = entries.find(
442
+ (e) => e.isFile() && e.name.toLowerCase() === `${fileName}${ext}`
443
+ );
444
+ if (match) return path.join(dir, match.name);
445
+ }
446
+ // Recurse into subdirs
447
+ for (const entry of entries) {
448
+ if (
449
+ entry.isDirectory() &&
450
+ !entry.name.startsWith(".") &&
451
+ entry.name !== "node_modules"
452
+ ) {
453
+ const result = searchRecursive(
454
+ path.join(dir, entry.name),
455
+ fileName,
456
+ depth + 1
457
+ );
458
+ if (result) return result;
459
+ }
460
+ }
461
+ } catch {
462
+ /* skip unreadable */
463
+ }
464
+ return null;
465
+ }
466
+
467
+ for (const dir of SEARCH_DIRS) {
468
+ const searchDir = path.join(projectRoot, dir);
469
+ if (fs.existsSync(searchDir)) {
470
+ const result = searchRecursive(searchDir, normalizedName);
471
+ if (result) return path.relative(projectRoot, result);
472
+ if (baseName !== normalizedName) {
473
+ const baseResult = searchRecursive(searchDir, baseName);
474
+ if (baseResult) return path.relative(projectRoot, baseResult);
475
+ }
476
+ }
477
+ }
478
+ return null;
479
+ }
480
+
481
+ /**
482
+ * Gather context about the current page for AI analysis
483
+ */
484
+ function gatherPageContext(
485
+ pageRoute: string,
486
+ projectRoot: string,
487
+ focusedElements?: VisionFocusedElement[]
488
+ ): {
489
+ pageFile: string | null;
490
+ pageContent: string;
491
+ componentSources: { path: string; content: string }[];
492
+ globalsCSS: string;
493
+ } {
494
+ // Map route to page file
495
+ const pageFile = discoverPageFile(pageRoute, projectRoot);
496
+ let pageContent = "";
497
+ const componentSources: { path: string; content: string }[] = [];
498
+
499
+ if (pageFile) {
500
+ const fullPath = path.join(projectRoot, pageFile);
501
+ if (fs.existsSync(fullPath)) {
502
+ pageContent = fs.readFileSync(fullPath, "utf-8");
503
+
504
+ // Extract imports and read component files
505
+ const imports = extractImports(pageContent);
506
+ for (const importPath of imports) {
507
+ const resolvedPath = resolveImportPath(importPath, pageFile, projectRoot);
508
+ if (resolvedPath && fs.existsSync(path.join(projectRoot, resolvedPath))) {
509
+ try {
510
+ const content = fs.readFileSync(path.join(projectRoot, resolvedPath), "utf-8");
511
+ componentSources.push({ path: resolvedPath, content });
512
+ } catch {
513
+ // Skip files that can't be read
514
+ }
515
+ }
516
+ }
517
+ }
518
+ }
519
+
520
+ // Also search for focused element components if provided
521
+ if (focusedElements && focusedElements.length > 0) {
522
+ for (const el of focusedElements) {
523
+ // Try to find component file by name
524
+ const foundPath = findComponentFileByName(el.name, projectRoot);
525
+ if (foundPath && !componentSources.some((c) => c.path === foundPath)) {
526
+ try {
527
+ const content = fs.readFileSync(
528
+ path.join(projectRoot, foundPath),
529
+ "utf-8"
530
+ );
531
+ componentSources.push({ path: foundPath, content });
532
+ } catch {
533
+ /* skip if unreadable */
534
+ }
535
+ }
536
+ }
537
+ }
538
+
539
+ // Read globals.css
540
+ let globalsCSS = "";
541
+ const globalsPath = path.join(projectRoot, "src/app/globals.css");
542
+ if (fs.existsSync(globalsPath)) {
543
+ globalsCSS = fs.readFileSync(globalsPath, "utf-8");
544
+ }
545
+
546
+ return { pageFile, pageContent, componentSources, globalsCSS };
547
+ }
548
+
549
+ /**
550
+ * Discover the page file for a given route
551
+ */
552
+ function discoverPageFile(route: string, projectRoot: string): string | null {
553
+ // Handle root route
554
+ if (route === "/" || route === "") {
555
+ const rootPage = "src/app/page.tsx";
556
+ if (fs.existsSync(path.join(projectRoot, rootPage))) {
557
+ return rootPage;
558
+ }
559
+ return null;
560
+ }
561
+
562
+ // Remove leading slash
563
+ const cleanRoute = route.replace(/^\//, "");
564
+
565
+ // Try different patterns
566
+ const patterns = [
567
+ `src/app/${cleanRoute}/page.tsx`,
568
+ `src/app/${cleanRoute}/page.jsx`,
569
+ `src/app/${cleanRoute}.tsx`,
570
+ ];
571
+
572
+ for (const pattern of patterns) {
573
+ if (fs.existsSync(path.join(projectRoot, pattern))) {
574
+ return pattern;
575
+ }
576
+ }
577
+
578
+ return null;
579
+ }
580
+
581
+ /**
582
+ * Extract import paths from file content
583
+ */
584
+ function extractImports(content: string): string[] {
585
+ const importRegex = /import\s+.*?\s+from\s+["'](@\/components\/[^"']+|\.\.?\/[^"']+)["']/g;
586
+ const imports: string[] = [];
587
+ let match;
588
+
589
+ while ((match = importRegex.exec(content)) !== null) {
590
+ imports.push(match[1]);
591
+ }
592
+
593
+ return imports;
594
+ }
595
+
596
+ /**
597
+ * Resolve an import path to a file system path
598
+ */
599
+ function resolveImportPath(
600
+ importPath: string,
601
+ fromFile: string,
602
+ projectRoot: string
603
+ ): string | null {
604
+ // Handle @ alias (maps to src/)
605
+ if (importPath.startsWith("@/")) {
606
+ const resolved = importPath.replace("@/", "src/");
607
+ // Try with .tsx extension
608
+ const withExt = resolved.endsWith(".tsx") ? resolved : `${resolved}.tsx`;
609
+ if (fs.existsSync(path.join(projectRoot, withExt))) {
610
+ return withExt;
611
+ }
612
+ // Try as directory with index
613
+ const indexPath = `${resolved}/index.tsx`;
614
+ if (fs.existsSync(path.join(projectRoot, indexPath))) {
615
+ return indexPath;
616
+ }
617
+ return withExt; // Return even if not found for context
618
+ }
619
+
620
+ // Handle relative imports
621
+ if (importPath.startsWith(".")) {
622
+ const dir = path.dirname(fromFile);
623
+ const resolved = path.join(dir, importPath);
624
+ const withExt = resolved.endsWith(".tsx") ? resolved : `${resolved}.tsx`;
625
+ return withExt;
626
+ }
627
+
628
+ return null;
629
+ }
630
+
631
+ /**
632
+ * Generate a simple unified diff between two strings
633
+ */
634
+ function generateSimpleDiff(original: string, modified: string): string {
635
+ const originalLines = original.split("\n");
636
+ const modifiedLines = modified.split("\n");
637
+
638
+ const diff: string[] = [];
639
+ const maxLines = Math.max(originalLines.length, modifiedLines.length);
640
+
641
+ for (let i = 0; i < maxLines; i++) {
642
+ const origLine = originalLines[i];
643
+ const modLine = modifiedLines[i];
644
+
645
+ if (origLine === undefined && modLine !== undefined) {
646
+ diff.push(`+ ${modLine}`);
647
+ } else if (origLine !== undefined && modLine === undefined) {
648
+ diff.push(`- ${origLine}`);
649
+ } else if (origLine !== modLine) {
650
+ diff.push(`- ${origLine}`);
651
+ diff.push(`+ ${modLine}`);
652
+ }
653
+ }
654
+
655
+ return diff.join("\n");
656
+ }