sonance-brand-mcp 1.3.104 → 1.3.105
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.
|
@@ -141,6 +141,132 @@ function debugLog(message: string, data?: unknown) {
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Sanitize a JSON string by finding the correct end point using bracket balancing.
|
|
146
|
+
* Handles cases where LLM outputs trailing garbage like extra ]} characters.
|
|
147
|
+
*/
|
|
148
|
+
function sanitizeJsonString(text: string): string {
|
|
149
|
+
const trimmed = text.trim();
|
|
150
|
+
|
|
151
|
+
debugLog("[sanitizeJsonString] Starting", {
|
|
152
|
+
inputLength: trimmed.length,
|
|
153
|
+
first100: trimmed.substring(0, 100),
|
|
154
|
+
last100: trimmed.substring(trimmed.length - 100)
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Try parsing as-is first
|
|
158
|
+
try {
|
|
159
|
+
JSON.parse(trimmed);
|
|
160
|
+
debugLog("[sanitizeJsonString] Parsed as-is successfully");
|
|
161
|
+
return trimmed;
|
|
162
|
+
} catch (e) {
|
|
163
|
+
const error = e as Error;
|
|
164
|
+
debugLog("[sanitizeJsonString] Initial parse failed", {
|
|
165
|
+
error: error.message,
|
|
166
|
+
// Try to find where the error is
|
|
167
|
+
errorPosition: error.message.match(/position (\d+)/)?.[1]
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Find the correct end using balanced bracket counting
|
|
172
|
+
let braceCount = 0;
|
|
173
|
+
let bracketCount = 0;
|
|
174
|
+
let inString = false;
|
|
175
|
+
let escapeNext = false;
|
|
176
|
+
let endIndex = -1;
|
|
177
|
+
|
|
178
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
179
|
+
const char = trimmed[i];
|
|
180
|
+
|
|
181
|
+
if (escapeNext) {
|
|
182
|
+
escapeNext = false;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (char === '\\' && inString) {
|
|
187
|
+
escapeNext = true;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (char === '"') {
|
|
192
|
+
inString = !inString;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (inString) continue;
|
|
197
|
+
|
|
198
|
+
if (char === '{') braceCount++;
|
|
199
|
+
if (char === '}') braceCount--;
|
|
200
|
+
if (char === '[') bracketCount++;
|
|
201
|
+
if (char === ']') bracketCount--;
|
|
202
|
+
|
|
203
|
+
// Found the end of the root object
|
|
204
|
+
if (braceCount === 0 && bracketCount === 0 && char === '}') {
|
|
205
|
+
endIndex = i;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
debugLog("[sanitizeJsonString] Bracket counting complete", {
|
|
211
|
+
endIndex,
|
|
212
|
+
finalBraceCount: braceCount,
|
|
213
|
+
finalBracketCount: bracketCount,
|
|
214
|
+
inString
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
if (endIndex !== -1) {
|
|
218
|
+
const sanitized = trimmed.slice(0, endIndex + 1);
|
|
219
|
+
const removed = trimmed.slice(endIndex + 1);
|
|
220
|
+
debugLog("[sanitizeJsonString] Attempting parse of balanced portion", {
|
|
221
|
+
sanitizedLength: sanitized.length,
|
|
222
|
+
removedLength: removed.length,
|
|
223
|
+
removedChars: removed.substring(0, 50)
|
|
224
|
+
});
|
|
225
|
+
try {
|
|
226
|
+
JSON.parse(sanitized);
|
|
227
|
+
debugLog("[sanitizeJsonString] SUCCESS: Removed trailing garbage", {
|
|
228
|
+
originalLength: trimmed.length,
|
|
229
|
+
sanitizedLength: sanitized.length,
|
|
230
|
+
removedChars: removed
|
|
231
|
+
});
|
|
232
|
+
return sanitized;
|
|
233
|
+
} catch (e) {
|
|
234
|
+
const error = e as Error;
|
|
235
|
+
debugLog("[sanitizeJsonString] Balanced portion still invalid", {
|
|
236
|
+
error: error.message
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Last resort: progressively remove trailing characters
|
|
242
|
+
debugLog("[sanitizeJsonString] Starting progressive trimming");
|
|
243
|
+
let attempt = trimmed;
|
|
244
|
+
let iterations = 0;
|
|
245
|
+
const maxIterations = Math.min(1000, trimmed.length); // Safety limit
|
|
246
|
+
|
|
247
|
+
while (attempt.length > 1 && iterations < maxIterations) {
|
|
248
|
+
iterations++;
|
|
249
|
+
try {
|
|
250
|
+
JSON.parse(attempt);
|
|
251
|
+
debugLog("[sanitizeJsonString] SUCCESS via progressive trimming", {
|
|
252
|
+
originalLength: trimmed.length,
|
|
253
|
+
sanitizedLength: attempt.length,
|
|
254
|
+
iterations
|
|
255
|
+
});
|
|
256
|
+
return attempt;
|
|
257
|
+
} catch {
|
|
258
|
+
attempt = attempt.slice(0, -1).trim();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
debugLog("[sanitizeJsonString] FAILED: Could not sanitize JSON", {
|
|
263
|
+
originalLength: trimmed.length,
|
|
264
|
+
iterations
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
return trimmed; // Return original if all else fails
|
|
268
|
+
}
|
|
269
|
+
|
|
144
270
|
/**
|
|
145
271
|
* Extract JSON from LLM response that may contain preamble text
|
|
146
272
|
* Handles: pure JSON, markdown code fences, and text with embedded JSON
|
|
@@ -154,14 +280,14 @@ function debugLog(message: string, data?: unknown) {
|
|
|
154
280
|
function extractJsonFromResponse(text: string): string {
|
|
155
281
|
// Try direct parse first - if it starts with {, it's likely pure JSON
|
|
156
282
|
const trimmed = text.trim();
|
|
157
|
-
if (trimmed.startsWith('{')) return trimmed;
|
|
283
|
+
if (trimmed.startsWith('{')) return sanitizeJsonString(trimmed);
|
|
158
284
|
|
|
159
285
|
// PRIORITY 1: Look specifically for ```json fence (most reliable)
|
|
160
286
|
const jsonFenceMatch = text.match(/```json\s*([\s\S]*?)```/);
|
|
161
287
|
if (jsonFenceMatch) {
|
|
162
288
|
const extracted = jsonFenceMatch[1].trim();
|
|
163
289
|
debugLog("Extracted JSON from explicit json fence", { previewLength: extracted.length });
|
|
164
|
-
return extracted;
|
|
290
|
+
return sanitizeJsonString(extracted);
|
|
165
291
|
}
|
|
166
292
|
|
|
167
293
|
// PRIORITY 2: Find raw JSON object in text (handles prose + JSON at end)
|
|
@@ -169,7 +295,7 @@ function extractJsonFromResponse(text: string): string {
|
|
|
169
295
|
const modMatch = text.match(/(\{"modifications"\s*:\s*\[[\s\S]*\](?:\s*,\s*"explanation"\s*:\s*"[^"]*")?\s*\})/);
|
|
170
296
|
if (modMatch) {
|
|
171
297
|
debugLog("Extracted JSON via modifications pattern", { length: modMatch[1].length });
|
|
172
|
-
return modMatch[1];
|
|
298
|
+
return sanitizeJsonString(modMatch[1]);
|
|
173
299
|
}
|
|
174
300
|
|
|
175
301
|
// PRIORITY 3: Find any JSON object starting with {"
|
|
@@ -181,7 +307,7 @@ function extractJsonFromResponse(text: string): string {
|
|
|
181
307
|
preambleLength: jsonStart,
|
|
182
308
|
jsonLength: extracted.length
|
|
183
309
|
});
|
|
184
|
-
return extracted;
|
|
310
|
+
return sanitizeJsonString(extracted);
|
|
185
311
|
}
|
|
186
312
|
|
|
187
313
|
// PRIORITY 4 (last resort): Try generic code fence
|
|
@@ -192,12 +318,12 @@ function extractJsonFromResponse(text: string): string {
|
|
|
192
318
|
// Only use if it looks like JSON
|
|
193
319
|
if (extracted.startsWith('{')) {
|
|
194
320
|
debugLog("Extracted JSON from generic fence", { previewLength: extracted.length });
|
|
195
|
-
return extracted;
|
|
321
|
+
return sanitizeJsonString(extracted);
|
|
196
322
|
}
|
|
197
323
|
}
|
|
198
324
|
|
|
199
|
-
// Return as-is if no JSON structure found
|
|
200
|
-
return text;
|
|
325
|
+
// Return as-is if no JSON structure found (sanitizeJsonString will handle validation)
|
|
326
|
+
return sanitizeJsonString(text);
|
|
201
327
|
}
|
|
202
328
|
|
|
203
329
|
/**
|
|
@@ -328,9 +454,19 @@ function findElementLineInFile(
|
|
|
328
454
|
}
|
|
329
455
|
}
|
|
330
456
|
|
|
457
|
+
// Helper: Normalize text for searching (handle bullets, whitespace, special chars)
|
|
458
|
+
const normalizeTextForSearch = (text: string): string => {
|
|
459
|
+
return text
|
|
460
|
+
.replace(/[•·●○◦‣⁃▪▫→←↓↑]/g, '') // Remove bullet/arrow characters
|
|
461
|
+
.replace(/\s+/g, ' ') // Collapse whitespace
|
|
462
|
+
.replace(/[""'']/g, '"') // Normalize quotes
|
|
463
|
+
.trim();
|
|
464
|
+
};
|
|
465
|
+
|
|
331
466
|
// PRIORITY 2: Exact text content in JSX
|
|
332
467
|
if (focusedElement.textContent && focusedElement.textContent.trim().length >= 2) {
|
|
333
468
|
const text = focusedElement.textContent.trim();
|
|
469
|
+
const normalizedText = normalizeTextForSearch(text);
|
|
334
470
|
// Escape special regex characters in the text
|
|
335
471
|
const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
336
472
|
|
|
@@ -370,6 +506,34 @@ function findElementLineInFile(
|
|
|
370
506
|
}
|
|
371
507
|
}
|
|
372
508
|
}
|
|
509
|
+
|
|
510
|
+
// PRIORITY 2c: Word-based matching for text with special characters (bullets, etc.)
|
|
511
|
+
// Extract significant words and find lines containing multiple of them
|
|
512
|
+
if (normalizedText.length > 10) {
|
|
513
|
+
const commonWords = ['the', 'this', 'that', 'with', 'from', 'have', 'will', 'your', 'for', 'and', 'use'];
|
|
514
|
+
const significantWords = normalizedText.split(' ')
|
|
515
|
+
.filter(word => word.length >= 4 && !commonWords.includes(word.toLowerCase()))
|
|
516
|
+
.slice(0, 5); // Take up to 5 significant words
|
|
517
|
+
|
|
518
|
+
if (significantWords.length >= 2) {
|
|
519
|
+
for (let i = 0; i < lines.length; i++) {
|
|
520
|
+
const lineLower = lines[i].toLowerCase();
|
|
521
|
+
const matchCount = significantWords.filter(word =>
|
|
522
|
+
lineLower.includes(word.toLowerCase())
|
|
523
|
+
).length;
|
|
524
|
+
|
|
525
|
+
// Require at least 2 word matches for confidence
|
|
526
|
+
if (matchCount >= 2) {
|
|
527
|
+
return {
|
|
528
|
+
lineNumber: i + 1,
|
|
529
|
+
snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
|
|
530
|
+
confidence: 'medium',
|
|
531
|
+
matchedBy: `word match: ${significantWords.slice(0, 3).join(', ')}... (${matchCount} words)`
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
373
537
|
}
|
|
374
538
|
|
|
375
539
|
// PRIORITY 2b: Look for icon patterns if type is button and no text (like X buttons)
|
|
@@ -390,159 +554,72 @@ function findElementLineInFile(
|
|
|
390
554
|
}
|
|
391
555
|
}
|
|
392
556
|
|
|
393
|
-
// PRIORITY 3: Distinctive className patterns (
|
|
557
|
+
// PRIORITY 3: Distinctive className patterns (semantic classes from design system)
|
|
394
558
|
if (focusedElement.className) {
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
/^
|
|
402
|
-
|
|
403
|
-
/^
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
/^
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
/^col-/, // grid columns
|
|
421
|
-
/^row-/, // grid rows
|
|
422
|
-
/^gap-/, // gap
|
|
423
|
-
/^items-/, // align-items
|
|
424
|
-
/^justify-/, // justify-content
|
|
425
|
-
/^self-/, // align-self
|
|
426
|
-
/^place-/, // place utilities
|
|
427
|
-
/^space-/, // space-x, space-y
|
|
428
|
-
/^overflow/, // overflow
|
|
429
|
-
/^scroll/, // scroll utilities
|
|
430
|
-
/^cursor-/, // cursor
|
|
431
|
-
/^pointer-/, // pointer-events
|
|
432
|
-
/^select-/, // user-select
|
|
433
|
-
/^opacity-/, // opacity
|
|
434
|
-
/^visible/, // visibility
|
|
435
|
-
/^invisible/, // visibility
|
|
436
|
-
/^z-/, // z-index
|
|
437
|
-
/^inset/, // inset utilities
|
|
438
|
-
/^top-/, // positioning
|
|
439
|
-
/^right-/, // positioning
|
|
440
|
-
/^bottom-/, // positioning
|
|
441
|
-
/^left-/, // positioning
|
|
442
|
-
/^static/, // position
|
|
443
|
-
/^fixed/, // position
|
|
444
|
-
/^absolute/, // position
|
|
445
|
-
/^relative/, // position
|
|
446
|
-
/^sticky/, // position
|
|
447
|
-
/^transition/, // transitions
|
|
448
|
-
/^duration-/, // duration
|
|
449
|
-
/^ease-/, // timing function
|
|
450
|
-
/^delay-/, // delay
|
|
451
|
-
/^animate-/, // animations
|
|
452
|
-
/^transform/, // transform
|
|
453
|
-
/^scale-/, // scale
|
|
454
|
-
/^rotate-/, // rotate
|
|
455
|
-
/^translate-/, // translate
|
|
456
|
-
/^skew-/, // skew
|
|
457
|
-
/^origin-/, // transform-origin
|
|
458
|
-
/^appearance-/, // appearance
|
|
459
|
-
/^accent-/, // accent-color
|
|
460
|
-
/^caret-/, // caret-color
|
|
461
|
-
/^fill-/, // SVG fill
|
|
462
|
-
/^stroke-/, // SVG stroke
|
|
463
|
-
/^object-/, // object-fit/position
|
|
464
|
-
/^aspect-/, // aspect-ratio
|
|
465
|
-
/^container/, // container
|
|
466
|
-
/^columns-/, // columns
|
|
467
|
-
/^break-/, // word-break
|
|
468
|
-
/^truncate/, // text-overflow
|
|
469
|
-
/^whitespace-/, // whitespace
|
|
470
|
-
/^list-/, // list-style
|
|
471
|
-
/^decoration-/, // text-decoration
|
|
472
|
-
/^underline/, // underline
|
|
473
|
-
/^overline/, // overline
|
|
474
|
-
/^line-through/, // line-through
|
|
475
|
-
/^no-underline/, // no underline
|
|
476
|
-
/^antialiased/, // font-smoothing
|
|
477
|
-
/^subpixel/, // subpixel-antialiased
|
|
478
|
-
/^italic/, // font-style
|
|
479
|
-
/^not-italic/, // font-style
|
|
480
|
-
/^uppercase/, // text-transform
|
|
481
|
-
/^lowercase/, // text-transform
|
|
482
|
-
/^capitalize/, // text-transform
|
|
483
|
-
/^normal-case/, // text-transform
|
|
484
|
-
/^align-/, // vertical-align
|
|
485
|
-
/^indent-/, // text-indent
|
|
486
|
-
/^content-/, // content
|
|
487
|
-
/^will-change/, // will-change
|
|
488
|
-
/^hover:/, // hover states
|
|
489
|
-
/^focus:/, // focus states
|
|
490
|
-
/^focus-within:/, // focus-within states
|
|
491
|
-
/^focus-visible:/, // focus-visible states
|
|
492
|
-
/^active:/, // active states
|
|
493
|
-
/^visited:/, // visited states
|
|
494
|
-
/^disabled:/, // disabled states
|
|
495
|
-
/^checked:/, // checked states
|
|
496
|
-
/^group-/, // group utilities
|
|
497
|
-
/^peer-/, // peer utilities
|
|
498
|
-
/^dark:/, // dark mode
|
|
499
|
-
/^light:/, // light mode
|
|
500
|
-
/^motion-/, // motion utilities
|
|
501
|
-
/^print:/, // print utilities
|
|
502
|
-
/^portrait:/, // orientation
|
|
503
|
-
/^landscape:/, // orientation
|
|
504
|
-
/^sm:|^md:|^lg:|^xl:|^2xl:/, // responsive prefixes
|
|
505
|
-
/^data-\[/, // data attribute variants
|
|
506
|
-
/^aria-/, // aria variants
|
|
507
|
-
/^supports-/, // supports variants
|
|
508
|
-
/^has-/, // has variants
|
|
509
|
-
/^group$/, // group class itself
|
|
510
|
-
/^peer$/, // peer class itself
|
|
511
|
-
/^sr-only/, // screen reader only
|
|
512
|
-
/^not-sr-only/, // not screen reader only
|
|
513
|
-
/^isolate/, // isolation
|
|
514
|
-
/^block$/, // display
|
|
515
|
-
/^inline/, // display
|
|
516
|
-
/^hidden$/, // display
|
|
517
|
-
/^table/, // display table
|
|
559
|
+
// SEMANTIC CLASS DETECTION: Instead of filtering OUT utilities, filter IN semantics
|
|
560
|
+
// These patterns identify classes that reference CSS variables (design tokens)
|
|
561
|
+
const semanticClassPatterns = [
|
|
562
|
+
// Background colors referencing CSS variables
|
|
563
|
+
/^bg-(card|background|foreground|primary|secondary|accent|destructive|muted|popover|input|sidebar)/,
|
|
564
|
+
// Text colors referencing CSS variables
|
|
565
|
+
/^text-(foreground|muted|primary|secondary|accent|destructive|card)/,
|
|
566
|
+
// Border colors referencing CSS variables
|
|
567
|
+
/^border-(border|card|input|primary|accent|destructive|muted)/,
|
|
568
|
+
// Any class containing these semantic tokens (catches hover:bg-card-hover, etc.)
|
|
569
|
+
/-card($|-)/,
|
|
570
|
+
/-foreground($|-)/,
|
|
571
|
+
/-background($|-)/,
|
|
572
|
+
/-border($|-)/,
|
|
573
|
+
/-primary($|-)/,
|
|
574
|
+
/-secondary($|-)/,
|
|
575
|
+
/-accent($|-)/,
|
|
576
|
+
/-muted($|-)/,
|
|
577
|
+
/-destructive($|-)/,
|
|
578
|
+
/-popover($|-)/,
|
|
579
|
+
/-sidebar($|-)/,
|
|
580
|
+
// Component-specific classes (likely custom)
|
|
581
|
+
/^(btn|card|modal|dialog|panel|sidebar|nav|menu|dropdown|tooltip|badge|chip|tag|avatar|icon)-/,
|
|
582
|
+
// Data attribute classes (often semantic)
|
|
583
|
+
/^\[data-/,
|
|
518
584
|
];
|
|
519
585
|
|
|
520
|
-
const
|
|
521
|
-
|
|
586
|
+
const isSemanticClass = (cls: string): boolean => {
|
|
587
|
+
// Remove variant prefixes for checking (hover:, focus:, dark:, etc.)
|
|
588
|
+
const baseClass = cls.replace(/^(hover:|focus:|active:|dark:|light:|md:|lg:|xl:|2xl:|sm:)+/, '');
|
|
589
|
+
return semanticClassPatterns.some(pattern => pattern.test(baseClass));
|
|
590
|
+
};
|
|
522
591
|
|
|
523
|
-
//
|
|
592
|
+
// Find semantic classes
|
|
524
593
|
const semanticClasses = focusedElement.className.split(/\s+/)
|
|
525
|
-
.filter(c => c.length > 3 &&
|
|
594
|
+
.filter(c => c.length > 3 && isSemanticClass(c));
|
|
526
595
|
|
|
527
596
|
// Only proceed if we found semantic classes
|
|
528
597
|
if (semanticClasses.length > 0) {
|
|
598
|
+
debugLog("Found semantic classes for element search", {
|
|
599
|
+
semanticClasses,
|
|
600
|
+
originalClasses: focusedElement.className.substring(0, 80)
|
|
601
|
+
});
|
|
602
|
+
|
|
529
603
|
for (const cls of semanticClasses) {
|
|
604
|
+
// Remove variant prefix for searching (hover:bg-card -> bg-card)
|
|
605
|
+
const searchClass = cls.replace(/^(hover:|focus:|active:|dark:|light:|md:|lg:|xl:|2xl:|sm:)+/, '');
|
|
606
|
+
|
|
530
607
|
for (let i = 0; i < lines.length; i++) {
|
|
531
|
-
if (lines[i].includes(
|
|
608
|
+
if (lines[i].includes(searchClass)) {
|
|
532
609
|
return {
|
|
533
610
|
lineNumber: i + 1,
|
|
534
611
|
snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
|
|
535
612
|
confidence: 'medium',
|
|
536
|
-
matchedBy: `semantic className "${
|
|
613
|
+
matchedBy: `semantic className "${searchClass}"`
|
|
537
614
|
};
|
|
538
615
|
}
|
|
539
616
|
}
|
|
540
617
|
}
|
|
541
618
|
}
|
|
542
619
|
|
|
543
|
-
// Log when we
|
|
620
|
+
// Log when we couldn't find semantic classes (helpful for debugging)
|
|
544
621
|
if (semanticClasses.length === 0 && focusedElement.className.trim().length > 0) {
|
|
545
|
-
console.log('[findElementLineInFile]
|
|
622
|
+
console.log('[findElementLineInFile] No semantic classes found in:',
|
|
546
623
|
focusedElement.className.substring(0, 100));
|
|
547
624
|
}
|
|
548
625
|
}
|
|
@@ -651,39 +728,105 @@ function scoreFilesForTextContent(
|
|
|
651
728
|
*/
|
|
652
729
|
function findElementInImportedFiles(
|
|
653
730
|
focusedElement: VisionFocusedElement,
|
|
654
|
-
importedFiles: { path: string; content: string }[]
|
|
731
|
+
importedFiles: { path: string; content: string }[],
|
|
732
|
+
pageFilePath?: string // Pass page file for proximity scoring
|
|
655
733
|
): { path: string; lineNumber: number; matchedBy: string; content: string; confidence: string } | null {
|
|
656
734
|
debugLog("Searching imported components for element", {
|
|
657
735
|
elementType: focusedElement.type,
|
|
658
736
|
textContent: focusedElement.textContent?.substring(0, 30),
|
|
659
|
-
filesCount: importedFiles.length
|
|
737
|
+
filesCount: importedFiles.length,
|
|
738
|
+
pageFilePath
|
|
660
739
|
});
|
|
661
740
|
|
|
741
|
+
// Collect ALL matches with scores instead of returning first match
|
|
742
|
+
const matches: Array<{
|
|
743
|
+
path: string;
|
|
744
|
+
lineNumber: number;
|
|
745
|
+
matchedBy: string;
|
|
746
|
+
content: string;
|
|
747
|
+
confidence: string;
|
|
748
|
+
score: number;
|
|
749
|
+
}> = [];
|
|
750
|
+
|
|
751
|
+
// Extract route/directory info from page file for proximity scoring
|
|
752
|
+
const pageDir = pageFilePath ? path.dirname(pageFilePath) : '';
|
|
753
|
+
const routeName = pageDir.split('/').pop() || ''; // e.g., "typography"
|
|
754
|
+
|
|
662
755
|
for (const file of importedFiles) {
|
|
663
756
|
// Focus on component files (where UI elements live)
|
|
664
757
|
// Skip types, stores, utils, hooks - they don't contain JSX elements
|
|
665
|
-
if (!file.path.includes('components/') && !file.path.includes('/ui/')) continue;
|
|
758
|
+
if (!file.path.includes('components/') && !file.path.includes('/ui/') && !file.path.includes('/_components/')) continue;
|
|
666
759
|
|
|
667
760
|
const result = findElementLineInFile(file.content, focusedElement);
|
|
668
761
|
if (result && result.confidence !== 'low') {
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
}
|
|
675
|
-
|
|
762
|
+
let score = 0;
|
|
763
|
+
|
|
764
|
+
// Text content match is highest priority (+100)
|
|
765
|
+
if (result.matchedBy.includes('textContent') || result.matchedBy.includes('word match')) {
|
|
766
|
+
score += 100;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Directory proximity - same directory tree as page file (+50)
|
|
770
|
+
// e.g., for page src/app/typography/page.tsx, prefer src/app/typography/_components/
|
|
771
|
+
if (pageDir && file.path.includes(pageDir)) {
|
|
772
|
+
score += 50;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Route name in filename (+30)
|
|
776
|
+
// e.g., for /typography route, prefer DigitalTypography.tsx
|
|
777
|
+
if (routeName && routeName.length > 2 && file.path.toLowerCase().includes(routeName.toLowerCase())) {
|
|
778
|
+
score += 30;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Confidence bonus
|
|
782
|
+
score += result.confidence === 'high' ? 20 : 10;
|
|
783
|
+
|
|
784
|
+
matches.push({
|
|
676
785
|
path: file.path,
|
|
677
786
|
lineNumber: result.lineNumber,
|
|
678
787
|
matchedBy: result.matchedBy,
|
|
679
788
|
content: file.content,
|
|
680
|
-
confidence: result.confidence
|
|
681
|
-
|
|
789
|
+
confidence: result.confidence,
|
|
790
|
+
score
|
|
791
|
+
});
|
|
682
792
|
}
|
|
683
793
|
}
|
|
684
794
|
|
|
685
|
-
|
|
686
|
-
|
|
795
|
+
// Return highest-scoring match
|
|
796
|
+
if (matches.length === 0) {
|
|
797
|
+
debugLog("Element not found in any imported component files");
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Sort by score descending
|
|
802
|
+
matches.sort((a, b) => b.score - a.score);
|
|
803
|
+
|
|
804
|
+
debugLog("Scored imported component matches", {
|
|
805
|
+
matchCount: matches.length,
|
|
806
|
+
topMatches: matches.slice(0, 3).map(m => ({
|
|
807
|
+
path: m.path,
|
|
808
|
+
score: m.score,
|
|
809
|
+
matchedBy: m.matchedBy,
|
|
810
|
+
confidence: m.confidence
|
|
811
|
+
}))
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
const bestMatch = matches[0];
|
|
815
|
+
debugLog("Found element in imported component (best match)", {
|
|
816
|
+
path: bestMatch.path,
|
|
817
|
+
lineNumber: bestMatch.lineNumber,
|
|
818
|
+
matchedBy: bestMatch.matchedBy,
|
|
819
|
+
confidence: bestMatch.confidence,
|
|
820
|
+
score: bestMatch.score
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
return {
|
|
824
|
+
path: bestMatch.path,
|
|
825
|
+
lineNumber: bestMatch.lineNumber,
|
|
826
|
+
matchedBy: bestMatch.matchedBy,
|
|
827
|
+
content: bestMatch.content,
|
|
828
|
+
confidence: bestMatch.confidence
|
|
829
|
+
};
|
|
687
830
|
}
|
|
688
831
|
|
|
689
832
|
/**
|
|
@@ -1817,6 +1960,7 @@ export async function POST(request: Request) {
|
|
|
1817
1960
|
let smartSearchFiles: { path: string; content: string }[] = [];
|
|
1818
1961
|
let recommendedFile: { path: string; reason: string } | null = null;
|
|
1819
1962
|
let deterministicMatch: { path: string; lineNumber: number } | null = null;
|
|
1963
|
+
let phase1VisibleText: string[] = []; // Store Phase 1 visible text for use in file scoring
|
|
1820
1964
|
|
|
1821
1965
|
// ========================================================================
|
|
1822
1966
|
// PHASE 0: Deterministic Element ID Search (Cursor-style explicit selection)
|
|
@@ -1882,6 +2026,9 @@ export async function POST(request: Request) {
|
|
|
1882
2026
|
if (!deterministicMatch && screenshot) {
|
|
1883
2027
|
debugLog("Starting Phase 1: LLM screenshot analysis");
|
|
1884
2028
|
const analysis = await analyzeScreenshotForSearch(screenshot, userPrompt, apiKey);
|
|
2029
|
+
|
|
2030
|
+
// Store visible text for use in file scoring when no element is focused
|
|
2031
|
+
phase1VisibleText = analysis.visibleText || [];
|
|
1885
2032
|
|
|
1886
2033
|
const hasSearchTerms = analysis.visibleText.length > 0 ||
|
|
1887
2034
|
analysis.componentNames.length > 0 ||
|
|
@@ -2192,7 +2339,8 @@ export async function POST(request: Request) {
|
|
|
2192
2339
|
|
|
2193
2340
|
const importedMatch = findElementInImportedFiles(
|
|
2194
2341
|
focusedElements[0],
|
|
2195
|
-
pageContext.componentSources
|
|
2342
|
+
pageContext.componentSources,
|
|
2343
|
+
actualTargetFile?.path // Pass page file for proximity scoring
|
|
2196
2344
|
);
|
|
2197
2345
|
|
|
2198
2346
|
if (importedMatch) {
|
|
@@ -2211,6 +2359,48 @@ export async function POST(request: Request) {
|
|
|
2211
2359
|
}
|
|
2212
2360
|
}
|
|
2213
2361
|
}
|
|
2362
|
+
// ========== AUTO TEXT SCORING (no focused element) ==========
|
|
2363
|
+
// When no element is clicked but we have Phase 1 visible text, use it to find the best file
|
|
2364
|
+
else if (actualTargetFile && (!focusedElements || focusedElements.length === 0) && phase1VisibleText.length > 0) {
|
|
2365
|
+
debugLog("No focused element - using Phase 1 visible text for file scoring", {
|
|
2366
|
+
textCount: phase1VisibleText.length,
|
|
2367
|
+
texts: phase1VisibleText.slice(0, 5)
|
|
2368
|
+
});
|
|
2369
|
+
|
|
2370
|
+
// Score all imported files based on visible text from Phase 1
|
|
2371
|
+
const bestMatch = scoreFilesForTextContent(
|
|
2372
|
+
phase1VisibleText,
|
|
2373
|
+
pageContext.componentSources
|
|
2374
|
+
);
|
|
2375
|
+
|
|
2376
|
+
// Check how well the current target file matches
|
|
2377
|
+
const currentFileScore = phase1VisibleText.filter(text =>
|
|
2378
|
+
actualTargetFile!.content.includes(text.substring(0, 20))
|
|
2379
|
+
).length;
|
|
2380
|
+
|
|
2381
|
+
debugLog("Phase 1 text scoring comparison", {
|
|
2382
|
+
currentFile: actualTargetFile.path,
|
|
2383
|
+
currentScore: currentFileScore,
|
|
2384
|
+
bestImport: bestMatch?.path || 'none',
|
|
2385
|
+
bestImportScore: bestMatch?.score || 0
|
|
2386
|
+
});
|
|
2387
|
+
|
|
2388
|
+
// Redirect if an imported file has significantly more text matches
|
|
2389
|
+
if (bestMatch && bestMatch.score > currentFileScore && bestMatch.score >= 2) {
|
|
2390
|
+
debugLog("TEXT REDIRECT (no element): Phase 1 visible text found better file", {
|
|
2391
|
+
originalFile: actualTargetFile.path,
|
|
2392
|
+
originalScore: currentFileScore,
|
|
2393
|
+
redirectTo: bestMatch.path,
|
|
2394
|
+
redirectScore: bestMatch.score,
|
|
2395
|
+
matchedTexts: bestMatch.matchedTexts
|
|
2396
|
+
});
|
|
2397
|
+
|
|
2398
|
+
actualTargetFile = {
|
|
2399
|
+
path: bestMatch.path,
|
|
2400
|
+
content: bestMatch.content
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2214
2404
|
|
|
2215
2405
|
debugLog("File redirect complete", {
|
|
2216
2406
|
originalRecommended: recommendedFileContent?.path || 'none',
|
|
@@ -2601,11 +2791,24 @@ This is better than generating patches with made-up code.`,
|
|
|
2601
2791
|
try {
|
|
2602
2792
|
// Use robust JSON extraction that handles preamble text, code fences, etc.
|
|
2603
2793
|
const jsonText = extractJsonFromResponse(textResponse.text);
|
|
2794
|
+
debugLog("[JSON Parse] Attempting to parse extracted JSON", {
|
|
2795
|
+
extractedLength: jsonText.length,
|
|
2796
|
+
extractedFirst100: jsonText.substring(0, 100),
|
|
2797
|
+
extractedLast100: jsonText.substring(jsonText.length - 100)
|
|
2798
|
+
});
|
|
2604
2799
|
aiResponse = JSON.parse(jsonText);
|
|
2605
|
-
|
|
2800
|
+
debugLog("[JSON Parse] Successfully parsed AI response");
|
|
2801
|
+
} catch (parseError) {
|
|
2802
|
+
const error = parseError as Error;
|
|
2606
2803
|
console.error("Failed to parse AI response:", textResponse.text);
|
|
2804
|
+
debugLog("[JSON Parse] FAILED to parse", {
|
|
2805
|
+
error: error.message,
|
|
2806
|
+
originalLength: textResponse.text.length,
|
|
2807
|
+
originalFirst100: textResponse.text.substring(0, 100),
|
|
2808
|
+
originalLast100: textResponse.text.substring(textResponse.text.length - 100)
|
|
2809
|
+
});
|
|
2607
2810
|
return NextResponse.json(
|
|
2608
|
-
{ error:
|
|
2811
|
+
{ error: `Failed to parse AI response: ${error.message}` },
|
|
2609
2812
|
{ status: 500 }
|
|
2610
2813
|
);
|
|
2611
2814
|
}
|
|
@@ -137,6 +137,132 @@ function debugLog(message: string, data?: unknown) {
|
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Sanitize a JSON string by finding the correct end point using bracket balancing.
|
|
142
|
+
* Handles cases where LLM outputs trailing garbage like extra ]} characters.
|
|
143
|
+
*/
|
|
144
|
+
function sanitizeJsonString(text: string): string {
|
|
145
|
+
const trimmed = text.trim();
|
|
146
|
+
|
|
147
|
+
debugLog("[sanitizeJsonString] Starting", {
|
|
148
|
+
inputLength: trimmed.length,
|
|
149
|
+
first100: trimmed.substring(0, 100),
|
|
150
|
+
last100: trimmed.substring(trimmed.length - 100)
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Try parsing as-is first
|
|
154
|
+
try {
|
|
155
|
+
JSON.parse(trimmed);
|
|
156
|
+
debugLog("[sanitizeJsonString] Parsed as-is successfully");
|
|
157
|
+
return trimmed;
|
|
158
|
+
} catch (e) {
|
|
159
|
+
const error = e as Error;
|
|
160
|
+
debugLog("[sanitizeJsonString] Initial parse failed", {
|
|
161
|
+
error: error.message,
|
|
162
|
+
// Try to find where the error is
|
|
163
|
+
errorPosition: error.message.match(/position (\d+)/)?.[1]
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Find the correct end using balanced bracket counting
|
|
168
|
+
let braceCount = 0;
|
|
169
|
+
let bracketCount = 0;
|
|
170
|
+
let inString = false;
|
|
171
|
+
let escapeNext = false;
|
|
172
|
+
let endIndex = -1;
|
|
173
|
+
|
|
174
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
175
|
+
const char = trimmed[i];
|
|
176
|
+
|
|
177
|
+
if (escapeNext) {
|
|
178
|
+
escapeNext = false;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (char === '\\' && inString) {
|
|
183
|
+
escapeNext = true;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (char === '"') {
|
|
188
|
+
inString = !inString;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (inString) continue;
|
|
193
|
+
|
|
194
|
+
if (char === '{') braceCount++;
|
|
195
|
+
if (char === '}') braceCount--;
|
|
196
|
+
if (char === '[') bracketCount++;
|
|
197
|
+
if (char === ']') bracketCount--;
|
|
198
|
+
|
|
199
|
+
// Found the end of the root object
|
|
200
|
+
if (braceCount === 0 && bracketCount === 0 && char === '}') {
|
|
201
|
+
endIndex = i;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
debugLog("[sanitizeJsonString] Bracket counting complete", {
|
|
207
|
+
endIndex,
|
|
208
|
+
finalBraceCount: braceCount,
|
|
209
|
+
finalBracketCount: bracketCount,
|
|
210
|
+
inString
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (endIndex !== -1) {
|
|
214
|
+
const sanitized = trimmed.slice(0, endIndex + 1);
|
|
215
|
+
const removed = trimmed.slice(endIndex + 1);
|
|
216
|
+
debugLog("[sanitizeJsonString] Attempting parse of balanced portion", {
|
|
217
|
+
sanitizedLength: sanitized.length,
|
|
218
|
+
removedLength: removed.length,
|
|
219
|
+
removedChars: removed.substring(0, 50)
|
|
220
|
+
});
|
|
221
|
+
try {
|
|
222
|
+
JSON.parse(sanitized);
|
|
223
|
+
debugLog("[sanitizeJsonString] SUCCESS: Removed trailing garbage", {
|
|
224
|
+
originalLength: trimmed.length,
|
|
225
|
+
sanitizedLength: sanitized.length,
|
|
226
|
+
removedChars: removed
|
|
227
|
+
});
|
|
228
|
+
return sanitized;
|
|
229
|
+
} catch (e) {
|
|
230
|
+
const error = e as Error;
|
|
231
|
+
debugLog("[sanitizeJsonString] Balanced portion still invalid", {
|
|
232
|
+
error: error.message
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Last resort: progressively remove trailing characters
|
|
238
|
+
debugLog("[sanitizeJsonString] Starting progressive trimming");
|
|
239
|
+
let attempt = trimmed;
|
|
240
|
+
let iterations = 0;
|
|
241
|
+
const maxIterations = Math.min(1000, trimmed.length); // Safety limit
|
|
242
|
+
|
|
243
|
+
while (attempt.length > 1 && iterations < maxIterations) {
|
|
244
|
+
iterations++;
|
|
245
|
+
try {
|
|
246
|
+
JSON.parse(attempt);
|
|
247
|
+
debugLog("[sanitizeJsonString] SUCCESS via progressive trimming", {
|
|
248
|
+
originalLength: trimmed.length,
|
|
249
|
+
sanitizedLength: attempt.length,
|
|
250
|
+
iterations
|
|
251
|
+
});
|
|
252
|
+
return attempt;
|
|
253
|
+
} catch {
|
|
254
|
+
attempt = attempt.slice(0, -1).trim();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
debugLog("[sanitizeJsonString] FAILED: Could not sanitize JSON", {
|
|
259
|
+
originalLength: trimmed.length,
|
|
260
|
+
iterations
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return trimmed; // Return original if all else fails
|
|
264
|
+
}
|
|
265
|
+
|
|
140
266
|
/**
|
|
141
267
|
* Extract JSON from LLM response that may contain preamble text
|
|
142
268
|
* Handles: pure JSON, markdown code fences, and text with embedded JSON
|
|
@@ -150,14 +276,14 @@ function debugLog(message: string, data?: unknown) {
|
|
|
150
276
|
function extractJsonFromResponse(text: string): string {
|
|
151
277
|
// Try direct parse first - if it starts with {, it's likely pure JSON
|
|
152
278
|
const trimmed = text.trim();
|
|
153
|
-
if (trimmed.startsWith('{')) return trimmed;
|
|
279
|
+
if (trimmed.startsWith('{')) return sanitizeJsonString(trimmed);
|
|
154
280
|
|
|
155
281
|
// PRIORITY 1: Look specifically for ```json fence (most reliable)
|
|
156
282
|
const jsonFenceMatch = text.match(/```json\s*([\s\S]*?)```/);
|
|
157
283
|
if (jsonFenceMatch) {
|
|
158
284
|
const extracted = jsonFenceMatch[1].trim();
|
|
159
285
|
debugLog("Extracted JSON from explicit json fence", { previewLength: extracted.length });
|
|
160
|
-
return extracted;
|
|
286
|
+
return sanitizeJsonString(extracted);
|
|
161
287
|
}
|
|
162
288
|
|
|
163
289
|
// PRIORITY 2: Find raw JSON object in text (handles prose + JSON at end)
|
|
@@ -165,7 +291,7 @@ function extractJsonFromResponse(text: string): string {
|
|
|
165
291
|
const modMatch = text.match(/(\{"modifications"\s*:\s*\[[\s\S]*\](?:\s*,\s*"explanation"\s*:\s*"[^"]*")?\s*\})/);
|
|
166
292
|
if (modMatch) {
|
|
167
293
|
debugLog("Extracted JSON via modifications pattern", { length: modMatch[1].length });
|
|
168
|
-
return modMatch[1];
|
|
294
|
+
return sanitizeJsonString(modMatch[1]);
|
|
169
295
|
}
|
|
170
296
|
|
|
171
297
|
// PRIORITY 3: Find any JSON object starting with {"
|
|
@@ -177,7 +303,7 @@ function extractJsonFromResponse(text: string): string {
|
|
|
177
303
|
preambleLength: jsonStart,
|
|
178
304
|
jsonLength: extracted.length
|
|
179
305
|
});
|
|
180
|
-
return extracted;
|
|
306
|
+
return sanitizeJsonString(extracted);
|
|
181
307
|
}
|
|
182
308
|
|
|
183
309
|
// PRIORITY 4 (last resort): Try generic code fence
|
|
@@ -188,12 +314,12 @@ function extractJsonFromResponse(text: string): string {
|
|
|
188
314
|
// Only use if it looks like JSON
|
|
189
315
|
if (extracted.startsWith('{')) {
|
|
190
316
|
debugLog("Extracted JSON from generic fence", { previewLength: extracted.length });
|
|
191
|
-
return extracted;
|
|
317
|
+
return sanitizeJsonString(extracted);
|
|
192
318
|
}
|
|
193
319
|
}
|
|
194
320
|
|
|
195
|
-
// Return as-is if no JSON structure found
|
|
196
|
-
return text;
|
|
321
|
+
// Return as-is if no JSON structure found (sanitizeJsonString will handle validation)
|
|
322
|
+
return sanitizeJsonString(text);
|
|
197
323
|
}
|
|
198
324
|
|
|
199
325
|
/**
|
|
@@ -324,9 +450,19 @@ function findElementLineInFile(
|
|
|
324
450
|
}
|
|
325
451
|
}
|
|
326
452
|
|
|
453
|
+
// Helper: Normalize text for searching (handle bullets, whitespace, special chars)
|
|
454
|
+
const normalizeTextForSearch = (text: string): string => {
|
|
455
|
+
return text
|
|
456
|
+
.replace(/[•·●○◦‣⁃▪▫→←↓↑]/g, '') // Remove bullet/arrow characters
|
|
457
|
+
.replace(/\s+/g, ' ') // Collapse whitespace
|
|
458
|
+
.replace(/[""'']/g, '"') // Normalize quotes
|
|
459
|
+
.trim();
|
|
460
|
+
};
|
|
461
|
+
|
|
327
462
|
// PRIORITY 2: Exact text content in JSX
|
|
328
463
|
if (focusedElement.textContent && focusedElement.textContent.trim().length >= 2) {
|
|
329
464
|
const text = focusedElement.textContent.trim();
|
|
465
|
+
const normalizedText = normalizeTextForSearch(text);
|
|
330
466
|
// Escape special regex characters in the text
|
|
331
467
|
const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
332
468
|
|
|
@@ -366,6 +502,34 @@ function findElementLineInFile(
|
|
|
366
502
|
}
|
|
367
503
|
}
|
|
368
504
|
}
|
|
505
|
+
|
|
506
|
+
// PRIORITY 2c: Word-based matching for text with special characters (bullets, etc.)
|
|
507
|
+
// Extract significant words and find lines containing multiple of them
|
|
508
|
+
if (normalizedText.length > 10) {
|
|
509
|
+
const commonWords = ['the', 'this', 'that', 'with', 'from', 'have', 'will', 'your', 'for', 'and', 'use'];
|
|
510
|
+
const significantWords = normalizedText.split(' ')
|
|
511
|
+
.filter(word => word.length >= 4 && !commonWords.includes(word.toLowerCase()))
|
|
512
|
+
.slice(0, 5); // Take up to 5 significant words
|
|
513
|
+
|
|
514
|
+
if (significantWords.length >= 2) {
|
|
515
|
+
for (let i = 0; i < lines.length; i++) {
|
|
516
|
+
const lineLower = lines[i].toLowerCase();
|
|
517
|
+
const matchCount = significantWords.filter(word =>
|
|
518
|
+
lineLower.includes(word.toLowerCase())
|
|
519
|
+
).length;
|
|
520
|
+
|
|
521
|
+
// Require at least 2 word matches for confidence
|
|
522
|
+
if (matchCount >= 2) {
|
|
523
|
+
return {
|
|
524
|
+
lineNumber: i + 1,
|
|
525
|
+
snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
|
|
526
|
+
confidence: 'medium',
|
|
527
|
+
matchedBy: `word match: ${significantWords.slice(0, 3).join(', ')}... (${matchCount} words)`
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
369
533
|
}
|
|
370
534
|
|
|
371
535
|
// PRIORITY 2b: Look for icon patterns if type is button and no text (like X buttons)
|
|
@@ -386,159 +550,72 @@ function findElementLineInFile(
|
|
|
386
550
|
}
|
|
387
551
|
}
|
|
388
552
|
|
|
389
|
-
// PRIORITY 3: Distinctive className patterns (
|
|
553
|
+
// PRIORITY 3: Distinctive className patterns (semantic classes from design system)
|
|
390
554
|
if (focusedElement.className) {
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
/^
|
|
398
|
-
|
|
399
|
-
/^
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
/^
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
/^col-/, // grid columns
|
|
417
|
-
/^row-/, // grid rows
|
|
418
|
-
/^gap-/, // gap
|
|
419
|
-
/^items-/, // align-items
|
|
420
|
-
/^justify-/, // justify-content
|
|
421
|
-
/^self-/, // align-self
|
|
422
|
-
/^place-/, // place utilities
|
|
423
|
-
/^space-/, // space-x, space-y
|
|
424
|
-
/^overflow/, // overflow
|
|
425
|
-
/^scroll/, // scroll utilities
|
|
426
|
-
/^cursor-/, // cursor
|
|
427
|
-
/^pointer-/, // pointer-events
|
|
428
|
-
/^select-/, // user-select
|
|
429
|
-
/^opacity-/, // opacity
|
|
430
|
-
/^visible/, // visibility
|
|
431
|
-
/^invisible/, // visibility
|
|
432
|
-
/^z-/, // z-index
|
|
433
|
-
/^inset/, // inset utilities
|
|
434
|
-
/^top-/, // positioning
|
|
435
|
-
/^right-/, // positioning
|
|
436
|
-
/^bottom-/, // positioning
|
|
437
|
-
/^left-/, // positioning
|
|
438
|
-
/^static/, // position
|
|
439
|
-
/^fixed/, // position
|
|
440
|
-
/^absolute/, // position
|
|
441
|
-
/^relative/, // position
|
|
442
|
-
/^sticky/, // position
|
|
443
|
-
/^transition/, // transitions
|
|
444
|
-
/^duration-/, // duration
|
|
445
|
-
/^ease-/, // timing function
|
|
446
|
-
/^delay-/, // delay
|
|
447
|
-
/^animate-/, // animations
|
|
448
|
-
/^transform/, // transform
|
|
449
|
-
/^scale-/, // scale
|
|
450
|
-
/^rotate-/, // rotate
|
|
451
|
-
/^translate-/, // translate
|
|
452
|
-
/^skew-/, // skew
|
|
453
|
-
/^origin-/, // transform-origin
|
|
454
|
-
/^appearance-/, // appearance
|
|
455
|
-
/^accent-/, // accent-color
|
|
456
|
-
/^caret-/, // caret-color
|
|
457
|
-
/^fill-/, // SVG fill
|
|
458
|
-
/^stroke-/, // SVG stroke
|
|
459
|
-
/^object-/, // object-fit/position
|
|
460
|
-
/^aspect-/, // aspect-ratio
|
|
461
|
-
/^container/, // container
|
|
462
|
-
/^columns-/, // columns
|
|
463
|
-
/^break-/, // word-break
|
|
464
|
-
/^truncate/, // text-overflow
|
|
465
|
-
/^whitespace-/, // whitespace
|
|
466
|
-
/^list-/, // list-style
|
|
467
|
-
/^decoration-/, // text-decoration
|
|
468
|
-
/^underline/, // underline
|
|
469
|
-
/^overline/, // overline
|
|
470
|
-
/^line-through/, // line-through
|
|
471
|
-
/^no-underline/, // no underline
|
|
472
|
-
/^antialiased/, // font-smoothing
|
|
473
|
-
/^subpixel/, // subpixel-antialiased
|
|
474
|
-
/^italic/, // font-style
|
|
475
|
-
/^not-italic/, // font-style
|
|
476
|
-
/^uppercase/, // text-transform
|
|
477
|
-
/^lowercase/, // text-transform
|
|
478
|
-
/^capitalize/, // text-transform
|
|
479
|
-
/^normal-case/, // text-transform
|
|
480
|
-
/^align-/, // vertical-align
|
|
481
|
-
/^indent-/, // text-indent
|
|
482
|
-
/^content-/, // content
|
|
483
|
-
/^will-change/, // will-change
|
|
484
|
-
/^hover:/, // hover states
|
|
485
|
-
/^focus:/, // focus states
|
|
486
|
-
/^focus-within:/, // focus-within states
|
|
487
|
-
/^focus-visible:/, // focus-visible states
|
|
488
|
-
/^active:/, // active states
|
|
489
|
-
/^visited:/, // visited states
|
|
490
|
-
/^disabled:/, // disabled states
|
|
491
|
-
/^checked:/, // checked states
|
|
492
|
-
/^group-/, // group utilities
|
|
493
|
-
/^peer-/, // peer utilities
|
|
494
|
-
/^dark:/, // dark mode
|
|
495
|
-
/^light:/, // light mode
|
|
496
|
-
/^motion-/, // motion utilities
|
|
497
|
-
/^print:/, // print utilities
|
|
498
|
-
/^portrait:/, // orientation
|
|
499
|
-
/^landscape:/, // orientation
|
|
500
|
-
/^sm:|^md:|^lg:|^xl:|^2xl:/, // responsive prefixes
|
|
501
|
-
/^data-\[/, // data attribute variants
|
|
502
|
-
/^aria-/, // aria variants
|
|
503
|
-
/^supports-/, // supports variants
|
|
504
|
-
/^has-/, // has variants
|
|
505
|
-
/^group$/, // group class itself
|
|
506
|
-
/^peer$/, // peer class itself
|
|
507
|
-
/^sr-only/, // screen reader only
|
|
508
|
-
/^not-sr-only/, // not screen reader only
|
|
509
|
-
/^isolate/, // isolation
|
|
510
|
-
/^block$/, // display
|
|
511
|
-
/^inline/, // display
|
|
512
|
-
/^hidden$/, // display
|
|
513
|
-
/^table/, // display table
|
|
555
|
+
// SEMANTIC CLASS DETECTION: Instead of filtering OUT utilities, filter IN semantics
|
|
556
|
+
// These patterns identify classes that reference CSS variables (design tokens)
|
|
557
|
+
const semanticClassPatterns = [
|
|
558
|
+
// Background colors referencing CSS variables
|
|
559
|
+
/^bg-(card|background|foreground|primary|secondary|accent|destructive|muted|popover|input|sidebar)/,
|
|
560
|
+
// Text colors referencing CSS variables
|
|
561
|
+
/^text-(foreground|muted|primary|secondary|accent|destructive|card)/,
|
|
562
|
+
// Border colors referencing CSS variables
|
|
563
|
+
/^border-(border|card|input|primary|accent|destructive|muted)/,
|
|
564
|
+
// Any class containing these semantic tokens (catches hover:bg-card-hover, etc.)
|
|
565
|
+
/-card($|-)/,
|
|
566
|
+
/-foreground($|-)/,
|
|
567
|
+
/-background($|-)/,
|
|
568
|
+
/-border($|-)/,
|
|
569
|
+
/-primary($|-)/,
|
|
570
|
+
/-secondary($|-)/,
|
|
571
|
+
/-accent($|-)/,
|
|
572
|
+
/-muted($|-)/,
|
|
573
|
+
/-destructive($|-)/,
|
|
574
|
+
/-popover($|-)/,
|
|
575
|
+
/-sidebar($|-)/,
|
|
576
|
+
// Component-specific classes (likely custom)
|
|
577
|
+
/^(btn|card|modal|dialog|panel|sidebar|nav|menu|dropdown|tooltip|badge|chip|tag|avatar|icon)-/,
|
|
578
|
+
// Data attribute classes (often semantic)
|
|
579
|
+
/^\[data-/,
|
|
514
580
|
];
|
|
515
581
|
|
|
516
|
-
const
|
|
517
|
-
|
|
582
|
+
const isSemanticClass = (cls: string): boolean => {
|
|
583
|
+
// Remove variant prefixes for checking (hover:, focus:, dark:, etc.)
|
|
584
|
+
const baseClass = cls.replace(/^(hover:|focus:|active:|dark:|light:|md:|lg:|xl:|2xl:|sm:)+/, '');
|
|
585
|
+
return semanticClassPatterns.some(pattern => pattern.test(baseClass));
|
|
586
|
+
};
|
|
518
587
|
|
|
519
|
-
//
|
|
588
|
+
// Find semantic classes
|
|
520
589
|
const semanticClasses = focusedElement.className.split(/\s+/)
|
|
521
|
-
.filter(c => c.length > 3 &&
|
|
590
|
+
.filter(c => c.length > 3 && isSemanticClass(c));
|
|
522
591
|
|
|
523
592
|
// Only proceed if we found semantic classes
|
|
524
593
|
if (semanticClasses.length > 0) {
|
|
594
|
+
debugLog("Found semantic classes for element search", {
|
|
595
|
+
semanticClasses,
|
|
596
|
+
originalClasses: focusedElement.className.substring(0, 80)
|
|
597
|
+
});
|
|
598
|
+
|
|
525
599
|
for (const cls of semanticClasses) {
|
|
600
|
+
// Remove variant prefix for searching (hover:bg-card -> bg-card)
|
|
601
|
+
const searchClass = cls.replace(/^(hover:|focus:|active:|dark:|light:|md:|lg:|xl:|2xl:|sm:)+/, '');
|
|
602
|
+
|
|
526
603
|
for (let i = 0; i < lines.length; i++) {
|
|
527
|
-
if (lines[i].includes(
|
|
604
|
+
if (lines[i].includes(searchClass)) {
|
|
528
605
|
return {
|
|
529
606
|
lineNumber: i + 1,
|
|
530
607
|
snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
|
|
531
608
|
confidence: 'medium',
|
|
532
|
-
matchedBy: `semantic className "${
|
|
609
|
+
matchedBy: `semantic className "${searchClass}"`
|
|
533
610
|
};
|
|
534
611
|
}
|
|
535
612
|
}
|
|
536
613
|
}
|
|
537
614
|
}
|
|
538
615
|
|
|
539
|
-
// Log when we
|
|
616
|
+
// Log when we couldn't find semantic classes (helpful for debugging)
|
|
540
617
|
if (semanticClasses.length === 0 && focusedElement.className.trim().length > 0) {
|
|
541
|
-
console.log('[findElementLineInFile]
|
|
618
|
+
console.log('[findElementLineInFile] No semantic classes found in:',
|
|
542
619
|
focusedElement.className.substring(0, 100));
|
|
543
620
|
}
|
|
544
621
|
}
|
|
@@ -647,39 +724,105 @@ function scoreFilesForTextContent(
|
|
|
647
724
|
*/
|
|
648
725
|
function findElementInImportedFiles(
|
|
649
726
|
focusedElement: VisionFocusedElement,
|
|
650
|
-
importedFiles: { path: string; content: string }[]
|
|
727
|
+
importedFiles: { path: string; content: string }[],
|
|
728
|
+
pageFilePath?: string // Pass page file for proximity scoring
|
|
651
729
|
): { path: string; lineNumber: number; matchedBy: string; content: string; confidence: string } | null {
|
|
652
730
|
debugLog("Searching imported components for element", {
|
|
653
731
|
elementType: focusedElement.type,
|
|
654
732
|
textContent: focusedElement.textContent?.substring(0, 30),
|
|
655
|
-
filesCount: importedFiles.length
|
|
733
|
+
filesCount: importedFiles.length,
|
|
734
|
+
pageFilePath
|
|
656
735
|
});
|
|
657
736
|
|
|
737
|
+
// Collect ALL matches with scores instead of returning first match
|
|
738
|
+
const matches: Array<{
|
|
739
|
+
path: string;
|
|
740
|
+
lineNumber: number;
|
|
741
|
+
matchedBy: string;
|
|
742
|
+
content: string;
|
|
743
|
+
confidence: string;
|
|
744
|
+
score: number;
|
|
745
|
+
}> = [];
|
|
746
|
+
|
|
747
|
+
// Extract route/directory info from page file for proximity scoring
|
|
748
|
+
const pageDir = pageFilePath ? path.dirname(pageFilePath) : '';
|
|
749
|
+
const routeName = pageDir.split('/').pop() || ''; // e.g., "typography"
|
|
750
|
+
|
|
658
751
|
for (const file of importedFiles) {
|
|
659
752
|
// Focus on component files (where UI elements live)
|
|
660
753
|
// Skip types, stores, utils, hooks - they don't contain JSX elements
|
|
661
|
-
if (!file.path.includes('components/') && !file.path.includes('/ui/')) continue;
|
|
754
|
+
if (!file.path.includes('components/') && !file.path.includes('/ui/') && !file.path.includes('/_components/')) continue;
|
|
662
755
|
|
|
663
756
|
const result = findElementLineInFile(file.content, focusedElement);
|
|
664
757
|
if (result && result.confidence !== 'low') {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
}
|
|
671
|
-
|
|
758
|
+
let score = 0;
|
|
759
|
+
|
|
760
|
+
// Text content match is highest priority (+100)
|
|
761
|
+
if (result.matchedBy.includes('textContent') || result.matchedBy.includes('word match')) {
|
|
762
|
+
score += 100;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Directory proximity - same directory tree as page file (+50)
|
|
766
|
+
// e.g., for page src/app/typography/page.tsx, prefer src/app/typography/_components/
|
|
767
|
+
if (pageDir && file.path.includes(pageDir)) {
|
|
768
|
+
score += 50;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Route name in filename (+30)
|
|
772
|
+
// e.g., for /typography route, prefer DigitalTypography.tsx
|
|
773
|
+
if (routeName && routeName.length > 2 && file.path.toLowerCase().includes(routeName.toLowerCase())) {
|
|
774
|
+
score += 30;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Confidence bonus
|
|
778
|
+
score += result.confidence === 'high' ? 20 : 10;
|
|
779
|
+
|
|
780
|
+
matches.push({
|
|
672
781
|
path: file.path,
|
|
673
782
|
lineNumber: result.lineNumber,
|
|
674
783
|
matchedBy: result.matchedBy,
|
|
675
784
|
content: file.content,
|
|
676
|
-
confidence: result.confidence
|
|
677
|
-
|
|
785
|
+
confidence: result.confidence,
|
|
786
|
+
score
|
|
787
|
+
});
|
|
678
788
|
}
|
|
679
789
|
}
|
|
680
790
|
|
|
681
|
-
|
|
682
|
-
|
|
791
|
+
// Return highest-scoring match
|
|
792
|
+
if (matches.length === 0) {
|
|
793
|
+
debugLog("Element not found in any imported component files");
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Sort by score descending
|
|
798
|
+
matches.sort((a, b) => b.score - a.score);
|
|
799
|
+
|
|
800
|
+
debugLog("Scored imported component matches", {
|
|
801
|
+
matchCount: matches.length,
|
|
802
|
+
topMatches: matches.slice(0, 3).map(m => ({
|
|
803
|
+
path: m.path,
|
|
804
|
+
score: m.score,
|
|
805
|
+
matchedBy: m.matchedBy,
|
|
806
|
+
confidence: m.confidence
|
|
807
|
+
}))
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
const bestMatch = matches[0];
|
|
811
|
+
debugLog("Found element in imported component (best match)", {
|
|
812
|
+
path: bestMatch.path,
|
|
813
|
+
lineNumber: bestMatch.lineNumber,
|
|
814
|
+
matchedBy: bestMatch.matchedBy,
|
|
815
|
+
confidence: bestMatch.confidence,
|
|
816
|
+
score: bestMatch.score
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
return {
|
|
820
|
+
path: bestMatch.path,
|
|
821
|
+
lineNumber: bestMatch.lineNumber,
|
|
822
|
+
matchedBy: bestMatch.matchedBy,
|
|
823
|
+
content: bestMatch.content,
|
|
824
|
+
confidence: bestMatch.confidence
|
|
825
|
+
};
|
|
683
826
|
}
|
|
684
827
|
|
|
685
828
|
/**
|
|
@@ -1786,6 +1929,7 @@ export async function POST(request: Request) {
|
|
|
1786
1929
|
let smartSearchFiles: { path: string; content: string }[] = [];
|
|
1787
1930
|
let recommendedFile: { path: string; reason: string } | null = null;
|
|
1788
1931
|
let deterministicMatch: { path: string; lineNumber: number } | null = null;
|
|
1932
|
+
let phase1VisibleText: string[] = []; // Store Phase 1 visible text for use in file scoring
|
|
1789
1933
|
|
|
1790
1934
|
// ========================================================================
|
|
1791
1935
|
// PHASE 0: Deterministic Element ID Search (Cursor-style explicit selection)
|
|
@@ -1851,6 +1995,9 @@ export async function POST(request: Request) {
|
|
|
1851
1995
|
if (!deterministicMatch && screenshot) {
|
|
1852
1996
|
debugLog("Starting Phase 1: LLM screenshot analysis");
|
|
1853
1997
|
const analysis = await analyzeScreenshotForSearch(screenshot, userPrompt, apiKey);
|
|
1998
|
+
|
|
1999
|
+
// Store visible text for use in file scoring when no element is focused
|
|
2000
|
+
phase1VisibleText = analysis.visibleText || [];
|
|
1854
2001
|
|
|
1855
2002
|
const hasSearchTerms = analysis.visibleText.length > 0 ||
|
|
1856
2003
|
analysis.componentNames.length > 0 ||
|
|
@@ -2161,7 +2308,8 @@ export async function POST(request: Request) {
|
|
|
2161
2308
|
|
|
2162
2309
|
const importedMatch = findElementInImportedFiles(
|
|
2163
2310
|
focusedElements[0],
|
|
2164
|
-
pageContext.componentSources
|
|
2311
|
+
pageContext.componentSources,
|
|
2312
|
+
actualTargetFile?.path ?? undefined // Pass page file for proximity scoring
|
|
2165
2313
|
);
|
|
2166
2314
|
|
|
2167
2315
|
if (importedMatch) {
|
|
@@ -2180,6 +2328,48 @@ export async function POST(request: Request) {
|
|
|
2180
2328
|
}
|
|
2181
2329
|
}
|
|
2182
2330
|
}
|
|
2331
|
+
// ========== AUTO TEXT SCORING (no focused element) ==========
|
|
2332
|
+
// When no element is clicked but we have Phase 1 visible text, use it to find the best file
|
|
2333
|
+
else if (actualTargetFile && (!focusedElements || focusedElements.length === 0) && phase1VisibleText.length > 0) {
|
|
2334
|
+
debugLog("No focused element - using Phase 1 visible text for file scoring", {
|
|
2335
|
+
textCount: phase1VisibleText.length,
|
|
2336
|
+
texts: phase1VisibleText.slice(0, 5)
|
|
2337
|
+
});
|
|
2338
|
+
|
|
2339
|
+
// Score all imported files based on visible text from Phase 1
|
|
2340
|
+
const bestMatch = scoreFilesForTextContent(
|
|
2341
|
+
phase1VisibleText,
|
|
2342
|
+
pageContext.componentSources
|
|
2343
|
+
);
|
|
2344
|
+
|
|
2345
|
+
// Check how well the current target file matches
|
|
2346
|
+
const currentFileScore = phase1VisibleText.filter(text =>
|
|
2347
|
+
actualTargetFile!.content.includes(text.substring(0, 20))
|
|
2348
|
+
).length;
|
|
2349
|
+
|
|
2350
|
+
debugLog("Phase 1 text scoring comparison", {
|
|
2351
|
+
currentFile: actualTargetFile.path,
|
|
2352
|
+
currentScore: currentFileScore,
|
|
2353
|
+
bestImport: bestMatch?.path || 'none',
|
|
2354
|
+
bestImportScore: bestMatch?.score || 0
|
|
2355
|
+
});
|
|
2356
|
+
|
|
2357
|
+
// Redirect if an imported file has significantly more text matches
|
|
2358
|
+
if (bestMatch && bestMatch.score > currentFileScore && bestMatch.score >= 2) {
|
|
2359
|
+
debugLog("TEXT REDIRECT (no element): Phase 1 visible text found better file", {
|
|
2360
|
+
originalFile: actualTargetFile.path,
|
|
2361
|
+
originalScore: currentFileScore,
|
|
2362
|
+
redirectTo: bestMatch.path,
|
|
2363
|
+
redirectScore: bestMatch.score,
|
|
2364
|
+
matchedTexts: bestMatch.matchedTexts
|
|
2365
|
+
});
|
|
2366
|
+
|
|
2367
|
+
actualTargetFile = {
|
|
2368
|
+
path: bestMatch.path,
|
|
2369
|
+
content: bestMatch.content
|
|
2370
|
+
};
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2183
2373
|
|
|
2184
2374
|
debugLog("File redirect complete", {
|
|
2185
2375
|
originalRecommended: recommendedFileContent?.path || 'none',
|
|
@@ -2562,11 +2752,24 @@ This is better than generating patches with made-up code.`,
|
|
|
2562
2752
|
try {
|
|
2563
2753
|
// Use robust JSON extraction that handles preamble text, code fences, etc.
|
|
2564
2754
|
const jsonText = extractJsonFromResponse(textResponse.text);
|
|
2755
|
+
debugLog("[JSON Parse] Attempting to parse extracted JSON", {
|
|
2756
|
+
extractedLength: jsonText.length,
|
|
2757
|
+
extractedFirst100: jsonText.substring(0, 100),
|
|
2758
|
+
extractedLast100: jsonText.substring(jsonText.length - 100)
|
|
2759
|
+
});
|
|
2565
2760
|
aiResponse = JSON.parse(jsonText);
|
|
2566
|
-
|
|
2761
|
+
debugLog("[JSON Parse] Successfully parsed AI response");
|
|
2762
|
+
} catch (parseError) {
|
|
2763
|
+
const error = parseError as Error;
|
|
2567
2764
|
console.error("Failed to parse AI response:", textResponse.text);
|
|
2765
|
+
debugLog("[JSON Parse] FAILED to parse", {
|
|
2766
|
+
error: error.message,
|
|
2767
|
+
originalLength: textResponse.text.length,
|
|
2768
|
+
originalFirst100: textResponse.text.substring(0, 100),
|
|
2769
|
+
originalLast100: textResponse.text.substring(textResponse.text.length - 100)
|
|
2770
|
+
});
|
|
2568
2771
|
return NextResponse.json(
|
|
2569
|
-
{ error:
|
|
2772
|
+
{ error: `Failed to parse AI response: ${error.message}` },
|
|
2570
2773
|
{ status: 500 }
|
|
2571
2774
|
);
|
|
2572
2775
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sonance-brand-mcp",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.105",
|
|
4
4
|
"description": "MCP Server for Sonance Brand Guidelines and Component Library - gives Claude instant access to brand colors, typography, and UI components.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|