victor HF Staff Claude commited on
Commit
473668f
·
1 Parent(s): f51f08d

feat: add automatic error detection and fixing for preview

Browse files

- Add error detection script that captures runtime, syntax, and resource errors in iframe
- Implement auto-fix UI with toggle button and attempt counter (max 3 attempts)
- Create error formatter to convert errors into AI-friendly prompts
- Add TypeScript types for preview errors
- Implement defensive resets to prevent stale error state
- Add proper error sanitization and logging controls
- Extract magic numbers to constants
- Fix infinite loop issue by always reporting error state

The feature detects JavaScript errors in the preview iframe and automatically
sends them to the AI for correction, with safety limits and user controls.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

app/api/ask-ai/route.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
  SEARCH_START,
15
  } from "@/lib/prompts";
16
  import MY_TOKEN_KEY from "@/lib/get-cookie-name";
 
17
 
18
  const ipAddresses = new Map();
19
 
@@ -223,8 +224,15 @@ export async function PUT(request: NextRequest) {
223
  const userToken = request.cookies.get(MY_TOKEN_KEY())?.value;
224
 
225
  const body = await request.json();
226
- const { prompt, html, previousPrompt, provider, selectedElementHtml, model } =
227
- body;
 
 
 
 
 
 
 
228
 
229
  if (!prompt || !html) {
230
  return NextResponse.json(
@@ -243,6 +251,16 @@ export async function PUT(request: NextRequest) {
243
  );
244
  }
245
 
 
 
 
 
 
 
 
 
 
 
246
  let token = userToken;
247
  let billTo: string | null = null;
248
 
@@ -311,7 +329,10 @@ export async function PUT(request: NextRequest) {
311
  },
312
  {
313
  role: "user",
314
- content: prompt,
 
 
 
315
  },
316
  ],
317
  ...(selectedProvider.id !== "sambanova"
 
14
  SEARCH_START,
15
  } from "@/lib/prompts";
16
  import MY_TOKEN_KEY from "@/lib/get-cookie-name";
17
+ import { createErrorFixPrompt } from "@/lib/error-formatter";
18
 
19
  const ipAddresses = new Map();
20
 
 
224
  const userToken = request.cookies.get(MY_TOKEN_KEY())?.value;
225
 
226
  const body = await request.json();
227
+ const {
228
+ prompt,
229
+ html,
230
+ previousPrompt,
231
+ provider,
232
+ selectedElementHtml,
233
+ model,
234
+ errors,
235
+ } = body;
236
 
237
  if (!prompt || !html) {
238
  return NextResponse.json(
 
251
  );
252
  }
253
 
254
+ if (process.env.NODE_ENV !== "production") {
255
+ // Sanitize error messages to prevent log injection
256
+ const errorCount = errors && Array.isArray(errors) ? errors.length : 0;
257
+ console.log(
258
+ `[PUT /api/ask-ai] Model: ${selectedModel.label}, Provider: ${provider}${
259
+ errorCount > 0 ? `, Fixing ${errorCount} errors` : ""
260
+ }`
261
+ );
262
+ }
263
+
264
  let token = userToken;
265
  let billTo: string | null = null;
266
 
 
329
  },
330
  {
331
  role: "user",
332
+ content:
333
+ errors && errors.length > 0
334
+ ? createErrorFixPrompt(errors, html)
335
+ : prompt,
336
  },
337
  ],
338
  ...(selectedProvider.id !== "sambanova"
components/editor/ask-ai/index.tsx CHANGED
@@ -22,6 +22,23 @@ import { TooltipContent } from "@radix-ui/react-tooltip";
22
  import { SelectedHtmlElement } from "./selected-html-element";
23
  import { FollowUpTooltip } from "./follow-up-tooltip";
24
  import { isTheSameHtml } from "@/lib/compare-html-diff";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  export function AskAI({
27
  html,
@@ -35,6 +52,7 @@ export function AskAI({
35
  setIsEditableModeEnabled,
36
  onNewPrompt,
37
  onSuccess,
 
38
  }: {
39
  html: string;
40
  setHtml: (html: string) => void;
@@ -48,6 +66,7 @@ export function AskAI({
48
  setIsEditableModeEnabled: React.Dispatch<React.SetStateAction<boolean>>;
49
  selectedElement?: HTMLElement | null;
50
  setSelectedElement: React.Dispatch<React.SetStateAction<HTMLElement | null>>;
 
51
  }) {
52
  const refThink = useRef<HTMLDivElement | null>(null);
53
  const audio = useRef<HTMLAudioElement | null>(null);
@@ -66,14 +85,21 @@ export function AskAI({
66
  const [isThinking, setIsThinking] = useState(true);
67
  const [controller, setController] = useState<AbortController | null>(null);
68
  const [isFollowUp, setIsFollowUp] = useState(true);
 
 
 
 
 
 
69
 
70
  const selectedModel = useMemo(() => {
71
  return MODELS.find((m: { value: string }) => m.value === model);
72
  }, [model]);
73
 
74
- const callAi = async (redesignMarkdown?: string) => {
75
  if (isAiWorking) return;
76
- if (!redesignMarkdown && !prompt.trim()) return;
 
77
  setisAiWorking(true);
78
  setProviderError("");
79
  setThink("");
@@ -95,12 +121,16 @@ export function AskAI({
95
  const request = await fetch("/api/ask-ai", {
96
  method: "PUT",
97
  body: JSON.stringify({
98
- prompt,
 
 
 
99
  provider,
100
  previousPrompt,
101
  model,
102
  html,
103
  selectedElementHtml,
 
104
  }),
105
  headers: {
106
  "Content-Type": "application/json",
@@ -129,7 +159,12 @@ export function AskAI({
129
  setPreviousPrompt(prompt);
130
  setPrompt("");
131
  setisAiWorking(false);
132
- onSuccess(res.html, prompt, res.updatedLines);
 
 
 
 
 
133
  if (audio.current) audio.current.play();
134
  }
135
  } else {
@@ -152,7 +187,7 @@ export function AskAI({
152
  const reader = request.body.getReader();
153
  const decoder = new TextDecoder("utf-8");
154
  const selectedModel = MODELS.find(
155
- (m: { value: string }) => m.value === model
156
  );
157
  let contentThink: string | undefined = undefined;
158
  const read = async () => {
@@ -181,6 +216,7 @@ export function AskAI({
181
  setPreviousPrompt(prompt);
182
  setPrompt("");
183
  setisAiWorking(false);
 
184
  setHasAsked(true);
185
  if (selectedModel?.isThinker) {
186
  setModel(MODELS[0].value);
@@ -189,7 +225,7 @@ export function AskAI({
189
 
190
  // Now we have the complete HTML including </html>, so set it to be sure
191
  const finalDoc = contentResponse.match(
192
- /<!DOCTYPE html>[\s\S]*<\/html>/
193
  )?.[0];
194
  if (finalDoc) {
195
  setHtml(finalDoc);
@@ -216,7 +252,7 @@ export function AskAI({
216
  contentResponse += chunk;
217
 
218
  const newHtml = contentResponse.match(
219
- /<!DOCTYPE html>[\s\S]*/
220
  )?.[0];
221
  if (newHtml) {
222
  setIsThinking(false);
@@ -290,6 +326,58 @@ export function AskAI({
290
  return isTheSameHtml(html);
291
  }, [html]);
292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  return (
294
  <div className="px-3">
295
  <div className="relative bg-neutral-800 border border-neutral-700 rounded-2xl ring-[4px] focus-within:ring-neutral-500/30 focus-within:border-neutral-600 ring-transparent z-10 w-full group">
@@ -309,7 +397,7 @@ export function AskAI({
309
  "size-4 text-neutral-400 group-hover:text-neutral-300 transition-all duration-200",
310
  {
311
  "rotate-180": openThink,
312
- }
313
  )}
314
  />
315
  </header>
@@ -321,7 +409,7 @@ export function AskAI({
321
  "max-h-[0px]": !openThink,
322
  "min-h-[250px] max-h-[250px] border-t border-neutral-700":
323
  openThink,
324
- }
325
  )}
326
  >
327
  <p className="text-[13px] text-neutral-400 whitespace-pre-line px-5 pb-4 pt-3">
@@ -364,14 +452,14 @@ export function AskAI({
364
  "w-full bg-transparent text-sm outline-none text-white placeholder:text-neutral-400 p-4",
365
  {
366
  "!pt-2.5": selectedElement && !isAiWorking,
367
- }
368
  )}
369
  placeholder={
370
  selectedElement
371
  ? `Ask DeepSite about ${selectedElement.tagName.toLowerCase()}...`
372
  : hasAsked
373
- ? "Ask DeepSite for edits"
374
- : "Ask DeepSite anything..."
375
  }
376
  value={prompt}
377
  onChange={(e) => setPrompt(e.target.value)}
@@ -412,6 +500,36 @@ export function AskAI({
412
  </TooltipContent>
413
  </Tooltip>
414
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  <InviteFriends />
416
  </div>
417
  <div className="flex items-center justify-end gap-2">
 
22
  import { SelectedHtmlElement } from "./selected-html-element";
23
  import { FollowUpTooltip } from "./follow-up-tooltip";
24
  import { isTheSameHtml } from "@/lib/compare-html-diff";
25
+ import {
26
+ areErrorsFixable,
27
+ deduplicateErrors,
28
+ createErrorFixPrompt,
29
+ } from "@/lib/error-formatter";
30
+ import { AlertCircle } from "lucide-react";
31
+ import type { PreviewError } from "@/types/preview-error";
32
+ import { MAX_AUTO_FIX_ATTEMPTS, AUTO_FIX_DELAY_MS } from "@/lib/constants";
33
+
34
+ // Reset function for cleaning up state
35
+ export function resetAutoFixState(
36
+ setAutoFixEnabled: (value: boolean) => void,
37
+ setFixAttempts: (value: number) => void,
38
+ ) {
39
+ setAutoFixEnabled(false);
40
+ setFixAttempts(0);
41
+ }
42
 
43
  export function AskAI({
44
  html,
 
52
  setIsEditableModeEnabled,
53
  onNewPrompt,
54
  onSuccess,
55
+ previewErrors = [],
56
  }: {
57
  html: string;
58
  setHtml: (html: string) => void;
 
66
  setIsEditableModeEnabled: React.Dispatch<React.SetStateAction<boolean>>;
67
  selectedElement?: HTMLElement | null;
68
  setSelectedElement: React.Dispatch<React.SetStateAction<HTMLElement | null>>;
69
+ previewErrors?: PreviewError[];
70
  }) {
71
  const refThink = useRef<HTMLDivElement | null>(null);
72
  const audio = useRef<HTMLAudioElement | null>(null);
 
85
  const [isThinking, setIsThinking] = useState(true);
86
  const [controller, setController] = useState<AbortController | null>(null);
87
  const [isFollowUp, setIsFollowUp] = useState(true);
88
+ const [autoFixEnabled, setAutoFixEnabled] = useLocalStorage(
89
+ "autoFixEnabled",
90
+ false,
91
+ );
92
+ const [fixAttempts, setFixAttempts] = useState(0);
93
+ const [isFixingErrors, setIsFixingErrors] = useState(false);
94
 
95
  const selectedModel = useMemo(() => {
96
  return MODELS.find((m: { value: string }) => m.value === model);
97
  }, [model]);
98
 
99
+ const callAi = async (redesignMarkdown?: string, errors?: PreviewError[]) => {
100
  if (isAiWorking) return;
101
+ if (!redesignMarkdown && !prompt.trim() && (!errors || errors.length === 0))
102
+ return;
103
  setisAiWorking(true);
104
  setProviderError("");
105
  setThink("");
 
121
  const request = await fetch("/api/ask-ai", {
122
  method: "PUT",
123
  body: JSON.stringify({
124
+ prompt:
125
+ errors && errors.length > 0
126
+ ? createErrorFixPrompt(errors, html)
127
+ : prompt,
128
  provider,
129
  previousPrompt,
130
  model,
131
  html,
132
  selectedElementHtml,
133
+ errors,
134
  }),
135
  headers: {
136
  "Content-Type": "application/json",
 
159
  setPreviousPrompt(prompt);
160
  setPrompt("");
161
  setisAiWorking(false);
162
+ setIsFixingErrors(false);
163
+ onSuccess(
164
+ res.html,
165
+ errors ? "Fixed errors automatically" : prompt,
166
+ res.updatedLines,
167
+ );
168
  if (audio.current) audio.current.play();
169
  }
170
  } else {
 
187
  const reader = request.body.getReader();
188
  const decoder = new TextDecoder("utf-8");
189
  const selectedModel = MODELS.find(
190
+ (m: { value: string }) => m.value === model,
191
  );
192
  let contentThink: string | undefined = undefined;
193
  const read = async () => {
 
216
  setPreviousPrompt(prompt);
217
  setPrompt("");
218
  setisAiWorking(false);
219
+ setIsFixingErrors(false);
220
  setHasAsked(true);
221
  if (selectedModel?.isThinker) {
222
  setModel(MODELS[0].value);
 
225
 
226
  // Now we have the complete HTML including </html>, so set it to be sure
227
  const finalDoc = contentResponse.match(
228
+ /<!DOCTYPE html>[\s\S]*<\/html>/,
229
  )?.[0];
230
  if (finalDoc) {
231
  setHtml(finalDoc);
 
252
  contentResponse += chunk;
253
 
254
  const newHtml = contentResponse.match(
255
+ /<!DOCTYPE html>[\s\S]*/,
256
  )?.[0];
257
  if (newHtml) {
258
  setIsThinking(false);
 
326
  return isTheSameHtml(html);
327
  }, [html]);
328
 
329
+ // Process and deduplicate errors
330
+ const fixableErrors = useMemo(() => {
331
+ if (!previewErrors || previewErrors.length === 0) return [];
332
+ const dedupedErrors = deduplicateErrors(previewErrors);
333
+ return dedupedErrors.filter((error) => areErrorsFixable([error]));
334
+ }, [previewErrors]);
335
+
336
+ // Auto-fix errors when detected
337
+ useUpdateEffect(() => {
338
+ if (
339
+ autoFixEnabled &&
340
+ fixableErrors.length > 0 &&
341
+ !isAiWorking &&
342
+ !isFixingErrors &&
343
+ fixAttempts < MAX_AUTO_FIX_ATTEMPTS
344
+ ) {
345
+ // Add a small delay to avoid immediate re-triggering
346
+ const timer = setTimeout(() => {
347
+ setIsFixingErrors(true);
348
+ setFixAttempts((prev) => prev + 1);
349
+ toast.info(
350
+ `Auto-fixing ${fixableErrors.length} error${fixableErrors.length > 1 ? "s" : ""}...`,
351
+ );
352
+ callAi(undefined, fixableErrors);
353
+ }, AUTO_FIX_DELAY_MS);
354
+
355
+ return () => clearTimeout(timer);
356
+ } else if (
357
+ autoFixEnabled &&
358
+ fixableErrors.length > 0 &&
359
+ fixAttempts >= MAX_AUTO_FIX_ATTEMPTS &&
360
+ !isAiWorking
361
+ ) {
362
+ // Max attempts reached, notify user
363
+ toast.error(
364
+ `Failed to auto-fix after ${MAX_AUTO_FIX_ATTEMPTS} attempts. Please fix manually.`,
365
+ );
366
+ setAutoFixEnabled(false);
367
+ }
368
+ }, [fixableErrors, autoFixEnabled, isAiWorking, fixAttempts]);
369
+
370
+ // Reset fix attempts when errors are cleared
371
+ useUpdateEffect(() => {
372
+ if (fixableErrors.length === 0 && fixAttempts > 0) {
373
+ setFixAttempts(0);
374
+ setIsFixingErrors(false);
375
+ if (autoFixEnabled) {
376
+ toast.success("All errors fixed!");
377
+ }
378
+ }
379
+ }, [fixableErrors]);
380
+
381
  return (
382
  <div className="px-3">
383
  <div className="relative bg-neutral-800 border border-neutral-700 rounded-2xl ring-[4px] focus-within:ring-neutral-500/30 focus-within:border-neutral-600 ring-transparent z-10 w-full group">
 
397
  "size-4 text-neutral-400 group-hover:text-neutral-300 transition-all duration-200",
398
  {
399
  "rotate-180": openThink,
400
+ },
401
  )}
402
  />
403
  </header>
 
409
  "max-h-[0px]": !openThink,
410
  "min-h-[250px] max-h-[250px] border-t border-neutral-700":
411
  openThink,
412
+ },
413
  )}
414
  >
415
  <p className="text-[13px] text-neutral-400 whitespace-pre-line px-5 pb-4 pt-3">
 
452
  "w-full bg-transparent text-sm outline-none text-white placeholder:text-neutral-400 p-4",
453
  {
454
  "!pt-2.5": selectedElement && !isAiWorking,
455
+ },
456
  )}
457
  placeholder={
458
  selectedElement
459
  ? `Ask DeepSite about ${selectedElement.tagName.toLowerCase()}...`
460
  : hasAsked
461
+ ? "Ask DeepSite for edits"
462
+ : "Ask DeepSite anything..."
463
  }
464
  value={prompt}
465
  onChange={(e) => setPrompt(e.target.value)}
 
500
  </TooltipContent>
501
  </Tooltip>
502
  )}
503
+ {fixableErrors.length > 0 && (
504
+ <Tooltip>
505
+ <TooltipTrigger asChild>
506
+ <Button
507
+ size="xs"
508
+ variant={autoFixEnabled ? "destructive" : "outline"}
509
+ onClick={() => setAutoFixEnabled?.(!autoFixEnabled)}
510
+ className={classNames("h-[28px]", {
511
+ "!text-red-400 hover:!text-red-200 !border-red-600 !hover:!border-red-500":
512
+ !autoFixEnabled && fixableErrors.length > 0,
513
+ })}
514
+ >
515
+ <AlertCircle className="size-4" />
516
+ {fixableErrors.length} Error
517
+ {fixableErrors.length > 1 ? "s" : ""}
518
+ {autoFixEnabled &&
519
+ fixAttempts > 0 &&
520
+ ` (${fixAttempts}/${MAX_AUTO_FIX_ATTEMPTS})`}
521
+ </Button>
522
+ </TooltipTrigger>
523
+ <TooltipContent
524
+ align="start"
525
+ className="bg-neutral-950 text-xs text-neutral-200 py-1 px-2 rounded-md -translate-y-0.5"
526
+ >
527
+ {autoFixEnabled
528
+ ? `Auto-fix is ON. Click to disable. ${fixAttempts > 0 ? `Attempted ${fixAttempts}/${MAX_AUTO_FIX_ATTEMPTS} fixes.` : ""}`
529
+ : "Click to enable auto-fix for detected errors"}
530
+ </TooltipContent>
531
+ </Tooltip>
532
+ )}
533
  <InviteFriends />
534
  </div>
535
  <div className="flex items-center justify-end gap-2">
components/editor/index.tsx CHANGED
@@ -26,6 +26,7 @@ import { Project } from "@/types";
26
  import { SaveButton } from "./save-button";
27
  import { LoadProject } from "../my-projects/load-project";
28
  import { isTheSameHtml } from "@/lib/compare-html-diff";
 
29
 
30
  export const AppEditor = ({ project }: { project?: Project | null }) => {
31
  const [htmlStorage, , removeHtmlStorage] = useLocalStorage("html_content");
@@ -51,8 +52,9 @@ export const AppEditor = ({ project }: { project?: Project | null }) => {
51
  const [isAiWorking, setIsAiWorking] = useState(false);
52
  const [isEditableModeEnabled, setIsEditableModeEnabled] = useState(false);
53
  const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(
54
- null
55
  );
 
56
 
57
  /**
58
  * Resets the layout based on screen size
@@ -92,7 +94,7 @@ export const AppEditor = ({ project }: { project?: Project | null }) => {
92
  const editorWidth = e.clientX;
93
  const clampedEditorWidth = Math.max(
94
  minWidth,
95
- Math.min(editorWidth, maxWidth)
96
  );
97
  const calculatedPreviewWidth =
98
  window.innerWidth - clampedEditorWidth - resizerWidth;
@@ -121,7 +123,7 @@ export const AppEditor = ({ project }: { project?: Project | null }) => {
121
  onClick: () => {
122
  window.open(
123
  `https://huggingface.co/spaces/${project?.space_id}`,
124
- "_blank"
125
  );
126
  },
127
  },
@@ -169,6 +171,8 @@ export const AppEditor = ({ project }: { project?: Project | null }) => {
169
  preview.current.style.width = "100%";
170
  }
171
  }
 
 
172
  }, [currentTab]);
173
 
174
  const handleEditorValidation = (markers: editor.IMarker[]) => {
@@ -210,7 +214,7 @@ export const AppEditor = ({ project }: { project?: Project | null }) => {
210
  "h-full bg-neutral-900 transition-all duration-200 absolute left-0 top-0",
211
  {
212
  "pointer-events-none": isAiWorking,
213
- }
214
  )}
215
  options={{
216
  colorDecorators: true,
@@ -242,7 +246,7 @@ export const AppEditor = ({ project }: { project?: Project | null }) => {
242
  onSuccess={(
243
  finalHtml: string,
244
  p: string,
245
- updatedLines?: number[][]
246
  ) => {
247
  const currentHistory = [...htmlHistory];
248
  currentHistory.unshift({
@@ -262,7 +266,7 @@ export const AppEditor = ({ project }: { project?: Project | null }) => {
262
  line[0],
263
  1,
264
  line[1],
265
- 1
266
  ),
267
  options: {
268
  inlineClassName: "matched-line",
@@ -284,13 +288,14 @@ export const AppEditor = ({ project }: { project?: Project | null }) => {
284
  }}
285
  onScrollToBottom={() => {
286
  editorRef.current?.revealLine(
287
- editorRef.current?.getModel()?.getLineCount() ?? 0
288
  );
289
  }}
290
  isEditableModeEnabled={isEditableModeEnabled}
291
  setIsEditableModeEnabled={setIsEditableModeEnabled}
292
  selectedElement={selectedElement}
293
  setSelectedElement={setSelectedElement}
 
294
  />
295
  </div>
296
  <div
@@ -313,6 +318,7 @@ export const AppEditor = ({ project }: { project?: Project | null }) => {
313
  setSelectedElement(element);
314
  setCurrentTab("chat");
315
  }}
 
316
  />
317
  </main>
318
  <Footer
@@ -327,7 +333,7 @@ export const AppEditor = ({ project }: { project?: Project | null }) => {
327
  setHtml(defaultHTML);
328
  removeHtmlStorage();
329
  editorRef.current?.revealLine(
330
- editorRef.current?.getModel()?.getLineCount() ?? 0
331
  );
332
  }
333
  }}
 
26
  import { SaveButton } from "./save-button";
27
  import { LoadProject } from "../my-projects/load-project";
28
  import { isTheSameHtml } from "@/lib/compare-html-diff";
29
+ import type { PreviewError } from "@/types/preview-error";
30
 
31
  export const AppEditor = ({ project }: { project?: Project | null }) => {
32
  const [htmlStorage, , removeHtmlStorage] = useLocalStorage("html_content");
 
52
  const [isAiWorking, setIsAiWorking] = useState(false);
53
  const [isEditableModeEnabled, setIsEditableModeEnabled] = useState(false);
54
  const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(
55
+ null,
56
  );
57
+ const [previewErrors, setPreviewErrors] = useState<PreviewError[]>([]);
58
 
59
  /**
60
  * Resets the layout based on screen size
 
94
  const editorWidth = e.clientX;
95
  const clampedEditorWidth = Math.max(
96
  minWidth,
97
+ Math.min(editorWidth, maxWidth),
98
  );
99
  const calculatedPreviewWidth =
100
  window.innerWidth - clampedEditorWidth - resizerWidth;
 
123
  onClick: () => {
124
  window.open(
125
  `https://huggingface.co/spaces/${project?.space_id}`,
126
+ "_blank",
127
  );
128
  },
129
  },
 
171
  preview.current.style.width = "100%";
172
  }
173
  }
174
+ // Clear preview errors when switching tabs
175
+ setPreviewErrors([]);
176
  }, [currentTab]);
177
 
178
  const handleEditorValidation = (markers: editor.IMarker[]) => {
 
214
  "h-full bg-neutral-900 transition-all duration-200 absolute left-0 top-0",
215
  {
216
  "pointer-events-none": isAiWorking,
217
+ },
218
  )}
219
  options={{
220
  colorDecorators: true,
 
246
  onSuccess={(
247
  finalHtml: string,
248
  p: string,
249
+ updatedLines?: number[][],
250
  ) => {
251
  const currentHistory = [...htmlHistory];
252
  currentHistory.unshift({
 
266
  line[0],
267
  1,
268
  line[1],
269
+ 1,
270
  ),
271
  options: {
272
  inlineClassName: "matched-line",
 
288
  }}
289
  onScrollToBottom={() => {
290
  editorRef.current?.revealLine(
291
+ editorRef.current?.getModel()?.getLineCount() ?? 0,
292
  );
293
  }}
294
  isEditableModeEnabled={isEditableModeEnabled}
295
  setIsEditableModeEnabled={setIsEditableModeEnabled}
296
  selectedElement={selectedElement}
297
  setSelectedElement={setSelectedElement}
298
+ previewErrors={previewErrors}
299
  />
300
  </div>
301
  <div
 
318
  setSelectedElement(element);
319
  setCurrentTab("chat");
320
  }}
321
+ onErrors={setPreviewErrors}
322
  />
323
  </main>
324
  <Footer
 
333
  setHtml(defaultHTML);
334
  removeHtmlStorage();
335
  editorRef.current?.revealLine(
336
+ editorRef.current?.getModel()?.getLineCount() ?? 0,
337
  );
338
  }
339
  }}
components/editor/preview/index.tsx CHANGED
@@ -1,12 +1,15 @@
1
  "use client";
2
  import { useUpdateEffect } from "react-use";
3
- import { useMemo, useState } from "react";
4
  import classNames from "classnames";
5
  import { toast } from "sonner";
6
 
7
  import { cn } from "@/lib/utils";
8
  import { GridPattern } from "@/components/magic-ui/grid-pattern";
9
  import { htmlTagToText } from "@/lib/html-tag-to-text";
 
 
 
10
 
11
  export const Preview = ({
12
  html,
@@ -18,6 +21,7 @@ export const Preview = ({
18
  iframeRef,
19
  isEditableModeEnabled,
20
  onClickElement,
 
21
  }: {
22
  html: string;
23
  isResizing: boolean;
@@ -28,11 +32,29 @@ export const Preview = ({
28
  currentTab: string;
29
  isEditableModeEnabled?: boolean;
30
  onClickElement?: (element: HTMLElement) => void;
 
31
  }) => {
32
  const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(
33
- null
34
  );
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  // add event listener to the iframe to track hovered elements
37
  const handleMouseOver = (event: MouseEvent) => {
38
  if (iframeRef?.current) {
@@ -100,6 +122,36 @@ export const Preview = ({
100
  return hoveredElement;
101
  }, [hoveredElement, isEditableModeEnabled]);
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  return (
104
  <div
105
  ref={ref}
@@ -109,7 +161,7 @@ export const Preview = ({
109
  "lg:p-4": currentTab !== "preview",
110
  "max-lg:h-0": currentTab === "chat",
111
  "max-lg:h-full": currentTab === "preview",
112
- }
113
  )}
114
  onClick={(e) => {
115
  if (isAiWorking) {
@@ -124,7 +176,7 @@ export const Preview = ({
124
  y={-1}
125
  strokeDasharray={"4 2"}
126
  className={cn(
127
- "[mask-image:radial-gradient(900px_circle_at_center,white,transparent)]"
128
  )}
129
  />
130
  {!isAiWorking && hoveredElement && selectedElement && (
@@ -158,10 +210,13 @@ export const Preview = ({
158
  device === "mobile",
159
  "lg:border-[8px] lg:border-neutral-700 lg:shadow-2xl lg:rounded-[24px]":
160
  currentTab !== "preview" && device === "desktop",
161
- }
162
  )}
163
- srcDoc={html}
164
  onLoad={() => {
 
 
 
165
  if (iframeRef?.current?.contentWindow?.document?.body) {
166
  iframeRef.current.contentWindow.document.body.scrollIntoView({
167
  block: isAiWorking ? "end" : "start",
 
1
  "use client";
2
  import { useUpdateEffect } from "react-use";
3
+ import { useMemo, useState, useEffect } from "react";
4
  import classNames from "classnames";
5
  import { toast } from "sonner";
6
 
7
  import { cn } from "@/lib/utils";
8
  import { GridPattern } from "@/components/magic-ui/grid-pattern";
9
  import { htmlTagToText } from "@/lib/html-tag-to-text";
10
+ import { errorDetectorScript, ERROR_DETECTOR_ID } from "@/lib/error-detector";
11
+
12
+ import type { PreviewError } from "@/types/preview-error";
13
 
14
  export const Preview = ({
15
  html,
 
21
  iframeRef,
22
  isEditableModeEnabled,
23
  onClickElement,
24
+ onErrors,
25
  }: {
26
  html: string;
27
  isResizing: boolean;
 
32
  currentTab: string;
33
  isEditableModeEnabled?: boolean;
34
  onClickElement?: (element: HTMLElement) => void;
35
+ onErrors?: (errors: PreviewError[]) => void;
36
  }) => {
37
  const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(
38
+ null,
39
  );
40
 
41
+ // Listen for error messages from the iframe
42
+ useEffect(() => {
43
+ const handleMessage = (event: MessageEvent) => {
44
+ if (event.data?.type === "PREVIEW_ERRORS" && onErrors) {
45
+ onErrors(event.data.errors);
46
+ }
47
+ };
48
+
49
+ window.addEventListener("message", handleMessage);
50
+ return () => window.removeEventListener("message", handleMessage);
51
+ }, [onErrors]);
52
+
53
+ // Defensive reset: Clear errors when HTML changes
54
+ useUpdateEffect(() => {
55
+ onErrors?.([]);
56
+ }, [html]);
57
+
58
  // add event listener to the iframe to track hovered elements
59
  const handleMouseOver = (event: MouseEvent) => {
60
  if (iframeRef?.current) {
 
122
  return hoveredElement;
123
  }, [hoveredElement, isEditableModeEnabled]);
124
 
125
+ // Inject error detection script into the HTML
126
+ const htmlWithErrorDetection = useMemo(() => {
127
+ if (!html) return "";
128
+
129
+ // First, remove any existing error detector script to prevent duplicates
130
+ const cleanedHtml = html.replace(
131
+ new RegExp(
132
+ `<script[^>]*id="${ERROR_DETECTOR_ID}"[^>]*>[\\s\\S]*?</script>`,
133
+ "gi",
134
+ ),
135
+ "",
136
+ );
137
+
138
+ // Create the script tag with proper ID
139
+ const scriptTag = `<script id="${ERROR_DETECTOR_ID}">${errorDetectorScript}</script>`;
140
+
141
+ // If html already has a </head> tag, inject script before it
142
+ if (cleanedHtml.includes("</head>")) {
143
+ return cleanedHtml.replace("</head>", `${scriptTag}</head>`);
144
+ }
145
+ // If html already has a <body> tag, inject script after it
146
+ else if (cleanedHtml.includes("<body")) {
147
+ return cleanedHtml.replace(/<body([^>]*)>/, `<body$1>${scriptTag}`);
148
+ }
149
+ // Otherwise, wrap the content with proper HTML structure
150
+ else {
151
+ return `<!DOCTYPE html><html><head>${scriptTag}</head><body>${cleanedHtml}</body></html>`;
152
+ }
153
+ }, [html]);
154
+
155
  return (
156
  <div
157
  ref={ref}
 
161
  "lg:p-4": currentTab !== "preview",
162
  "max-lg:h-0": currentTab === "chat",
163
  "max-lg:h-full": currentTab === "preview",
164
+ },
165
  )}
166
  onClick={(e) => {
167
  if (isAiWorking) {
 
176
  y={-1}
177
  strokeDasharray={"4 2"}
178
  className={cn(
179
+ "[mask-image:radial-gradient(900px_circle_at_center,white,transparent)]",
180
  )}
181
  />
182
  {!isAiWorking && hoveredElement && selectedElement && (
 
210
  device === "mobile",
211
  "lg:border-[8px] lg:border-neutral-700 lg:shadow-2xl lg:rounded-[24px]":
212
  currentTab !== "preview" && device === "desktop",
213
+ },
214
  )}
215
+ srcDoc={htmlWithErrorDetection}
216
  onLoad={() => {
217
+ // Clear errors on fresh load as extra safety
218
+ onErrors?.([]);
219
+
220
  if (iframeRef?.current?.contentWindow?.document?.body) {
221
  iframeRef.current.contentWindow.document.body.scrollIntoView({
222
  block: isAiWorking ? "end" : "start",
lib/constants.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ // Auto-fix feature constants
2
+ export const MAX_AUTO_FIX_ATTEMPTS = 3;
3
+ export const AUTO_FIX_DELAY_MS = 2000;
4
+ export const ERROR_BATCH_DELAY_MS = 1000;
5
+ export const MAX_ERRORS_TO_DISPLAY = 10;
lib/error-detector.ts ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const ERROR_DETECTOR_ID = "deepsite-error-detector";
2
+
3
+ export const errorDetectorScript = `
4
+ (function() {
5
+ const errors = [];
6
+ let errorTimeout = null;
7
+ const MAX_ERRORS = 10;
8
+ const BATCH_DELAY = 1000; // Wait 1 second before sending errors
9
+
10
+ // Create a safe error object that can be serialized
11
+ function createSafeError(error, type, context = {}) {
12
+ return {
13
+ type,
14
+ message: error?.message || String(error),
15
+ stack: error?.stack,
16
+ lineNumber: error?.lineNumber || context.lineNumber,
17
+ columnNumber: error?.columnNumber || context.columnNumber,
18
+ fileName: error?.fileName || context.fileName,
19
+ timestamp: new Date().toISOString(),
20
+ ...context
21
+ };
22
+ }
23
+
24
+ // Send errors to parent window (always send, even if empty)
25
+ function sendErrors() {
26
+ window.parent.postMessage({
27
+ type: 'PREVIEW_ERRORS',
28
+ errors: errors.slice(0, MAX_ERRORS), // Limit errors sent
29
+ url: window.location.href
30
+ }, '*');
31
+
32
+ errors.length = 0; // Clear sent errors
33
+ }
34
+
35
+ // Batch errors to avoid spamming
36
+ function queueError(error) {
37
+ errors.push(error);
38
+
39
+ if (errorTimeout) clearTimeout(errorTimeout);
40
+ errorTimeout = setTimeout(sendErrors, BATCH_DELAY);
41
+ }
42
+
43
+ // Global error handler
44
+ window.addEventListener('error', function(event) {
45
+ const error = createSafeError(event.error || event.message, 'runtime-error', {
46
+ lineNumber: event.lineno,
47
+ columnNumber: event.colno,
48
+ fileName: event.filename,
49
+ errorType: 'JavaScript Error'
50
+ });
51
+ queueError(error);
52
+ });
53
+
54
+ // Unhandled promise rejection handler
55
+ window.addEventListener('unhandledrejection', function(event) {
56
+ const error = createSafeError(event.reason, 'unhandled-promise', {
57
+ promise: event.promise,
58
+ errorType: 'Unhandled Promise Rejection'
59
+ });
60
+ queueError(error);
61
+ });
62
+
63
+ // Override console.error to catch logged errors
64
+ const originalConsoleError = console.error;
65
+ console.error = function(...args) {
66
+ originalConsoleError.apply(console, args);
67
+
68
+ const error = createSafeError(args.join(' '), 'console-error', {
69
+ errorType: 'Console Error',
70
+ args: args.map(arg => String(arg))
71
+ });
72
+ queueError(error);
73
+ };
74
+
75
+ // Monitor failed resource loads (404s, etc)
76
+ window.addEventListener('error', function(event) {
77
+ if (event.target !== window) {
78
+ // This is a resource loading error
79
+ const target = event.target;
80
+ const error = createSafeError(\`Failed to load resource: \${target.src || target.href}\`, 'resource-error', {
81
+ tagName: target.tagName,
82
+ src: target.src || target.href,
83
+ errorType: 'Resource Loading Error'
84
+ });
85
+ queueError(error);
86
+ }
87
+ }, true); // Use capture phase to catch resource errors
88
+
89
+ // Monitor for common React errors
90
+ if (window.React && window.React.version) {
91
+ const originalError = console.error;
92
+ console.error = function(...args) {
93
+ originalError.apply(console, args);
94
+
95
+ const errorString = args.join(' ');
96
+ if (errorString.includes('ReactDOM.render is no longer supported') ||
97
+ errorString.includes('Cannot read property') ||
98
+ errorString.includes('Cannot access property')) {
99
+ const error = createSafeError(errorString, 'react-error', {
100
+ errorType: 'React Error',
101
+ reactVersion: window.React.version
102
+ });
103
+ queueError(error);
104
+ }
105
+ };
106
+ }
107
+
108
+ // Report current state of errors
109
+ function reportCurrentState() {
110
+ sendErrors();
111
+ }
112
+
113
+ // Send initial ready message and current error state
114
+ window.addEventListener('load', reportCurrentState);
115
+
116
+ // Monitor for DOM changes that might clear errors
117
+ const observer = new MutationObserver(() => {
118
+ // Small delay to let any new errors register
119
+ setTimeout(reportCurrentState, 100);
120
+ });
121
+
122
+ // Start observing once DOM is ready
123
+ if (document.body) {
124
+ observer.observe(document.body, {
125
+ subtree: true,
126
+ childList: true
127
+ });
128
+ } else {
129
+ window.addEventListener('DOMContentLoaded', () => {
130
+ observer.observe(document.body, {
131
+ subtree: true,
132
+ childList: true
133
+ });
134
+ });
135
+ }
136
+
137
+ // Send initial state
138
+ reportCurrentState();
139
+ })();
140
+ `;
lib/error-formatter.ts ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { PreviewError } from "@/types/preview-error";
2
+
3
+ // Sanitize error messages to prevent prompt injection
4
+ function sanitizeErrorMessage(message: string): string {
5
+ return message
6
+ .replace(/[<>]/g, "") // Remove potential HTML tags
7
+ .replace(/```/g, "'''") // Escape code blocks
8
+ .slice(0, 500); // Limit length
9
+ }
10
+
11
+ export function formatErrorsForAI(
12
+ errors: PreviewError[],
13
+ html: string
14
+ ): string {
15
+ if (!errors || errors.length === 0) return "";
16
+
17
+ // Validate errors array
18
+ const validErrors = errors.filter((e) => e && typeof e.message === "string");
19
+ if (validErrors.length === 0) return "";
20
+
21
+ // Group errors by type for better organization
22
+ const errorGroups = validErrors.reduce((acc, error) => {
23
+ const type = error.errorType || error.type;
24
+ if (!acc[type]) acc[type] = [];
25
+ acc[type].push(error);
26
+ return acc;
27
+ }, {} as Record<string, PreviewError[]>);
28
+
29
+ let formattedErrors =
30
+ "The following errors were detected in the preview:\n\n";
31
+
32
+ // Format each error group
33
+ Object.entries(errorGroups).forEach(([type, groupErrors]) => {
34
+ formattedErrors += `### ${type} (${groupErrors.length} error${
35
+ groupErrors.length > 1 ? "s" : ""
36
+ })\n\n`;
37
+
38
+ groupErrors.forEach((error, index) => {
39
+ const sanitizedMessage = sanitizeErrorMessage(error.message);
40
+ formattedErrors += `${index + 1}. **${sanitizedMessage}**\n`;
41
+
42
+ if (error.lineNumber) {
43
+ formattedErrors += ` - Line: ${error.lineNumber}`;
44
+ if (error.columnNumber) {
45
+ formattedErrors += `, Column: ${error.columnNumber}`;
46
+ }
47
+ formattedErrors += "\n";
48
+ }
49
+
50
+ if (error.fileName && error.fileName !== "undefined") {
51
+ formattedErrors += ` - File: ${error.fileName}\n`;
52
+ }
53
+
54
+ // For resource errors, include the problematic resource
55
+ if (error.type === "resource-error" && error.src) {
56
+ formattedErrors += ` - Resource: ${error.src}\n`;
57
+ formattedErrors += ` - Tag: <${error.tagName?.toLowerCase()}>\n`;
58
+ }
59
+
60
+ // Include relevant code snippet if we have line numbers
61
+ if (error.lineNumber && html) {
62
+ const lines = html.split("\n");
63
+ const startLine = Math.max(0, error.lineNumber - 3);
64
+ const endLine = Math.min(lines.length, error.lineNumber + 2);
65
+
66
+ if (lines[error.lineNumber - 1]) {
67
+ formattedErrors += " - Code context:\n";
68
+ formattedErrors += " ```html\n";
69
+ for (let i = startLine; i < endLine; i++) {
70
+ const marker = i === error.lineNumber - 1 ? ">" : " ";
71
+ formattedErrors += ` ${marker} ${i + 1}: ${lines[i]}\n`;
72
+ }
73
+ formattedErrors += " ```\n";
74
+ }
75
+ }
76
+
77
+ formattedErrors += "\n";
78
+ });
79
+ });
80
+
81
+ return formattedErrors;
82
+ }
83
+
84
+ export function createErrorFixPrompt(
85
+ errors: PreviewError[],
86
+ html: string
87
+ ): string {
88
+ const formattedErrors = formatErrorsForAI(errors, html);
89
+
90
+ return `${formattedErrors}
91
+ Please fix these errors in the HTML code. Focus on:
92
+ 1. Fixing JavaScript syntax errors
93
+ 2. Resolving undefined variables or functions
94
+ 3. Fixing broken resource links (404s)
95
+ 4. Ensuring all referenced libraries are properly loaded
96
+ 5. Fixing any HTML structure issues
97
+
98
+ Make the minimum necessary changes to fix the errors while preserving the intended functionality.`;
99
+ }
100
+
101
+ // Check if errors are likely fixable by AI
102
+ export function areErrorsFixable(errors: PreviewError[]): boolean {
103
+ if (!errors || errors.length === 0) return false;
104
+
105
+ // Filter out errors that are likely not fixable
106
+ const fixableErrors = errors.filter((error) => {
107
+ // Skip errors from external resources
108
+ if (
109
+ error.fileName &&
110
+ (error.fileName.includes("http://") ||
111
+ error.fileName.includes("https://"))
112
+ ) {
113
+ return false;
114
+ }
115
+
116
+ // Skip certain console errors that might be intentional
117
+ if (
118
+ error.type === "console-error" &&
119
+ error.message.includes("Development mode")
120
+ ) {
121
+ return false;
122
+ }
123
+
124
+ return true;
125
+ });
126
+
127
+ return fixableErrors.length > 0;
128
+ }
129
+
130
+ // Deduplicate similar errors
131
+ export function deduplicateErrors(errors: PreviewError[]): PreviewError[] {
132
+ const seen = new Set<string>();
133
+ return errors.filter((error) => {
134
+ const key = `${error.type}-${error.message}-${error.lineNumber || 0}`;
135
+ if (seen.has(key)) return false;
136
+ seen.add(key);
137
+ return true;
138
+ });
139
+ }
types/preview-error.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface PreviewError {
2
+ type:
3
+ | "runtime-error"
4
+ | "unhandled-promise"
5
+ | "console-error"
6
+ | "resource-error"
7
+ | "react-error";
8
+ message: string;
9
+ stack?: string;
10
+ lineNumber?: number;
11
+ columnNumber?: number;
12
+ fileName?: string;
13
+ timestamp: string;
14
+ errorType?: string;
15
+ // For resource errors
16
+ tagName?: string;
17
+ src?: string;
18
+ // For React errors
19
+ reactVersion?: string;
20
+ // For console errors
21
+ args?: string[];
22
+ }