enzostvs HF Staff commited on
Commit
d7b37e7
·
1 Parent(s): e3c353c

stream PUT request to avoid timeout from cloudfront

Browse files
app/api/ask/route.ts CHANGED
@@ -6,35 +6,19 @@ import { InferenceClient } from "@huggingface/inference";
6
 
7
  import { MODELS } from "@/lib/providers";
8
  import {
9
- DIVIDER,
10
  FOLLOW_UP_SYSTEM_PROMPT,
11
  INITIAL_SYSTEM_PROMPT,
12
  MAX_REQUESTS_PER_IP,
13
- NEW_FILE_END,
14
- NEW_FILE_START,
15
- REPLACE_END,
16
- SEARCH_START,
17
- UPDATE_FILE_START,
18
- UPDATE_FILE_END,
19
  PROMPT_FOR_PROJECT_NAME,
20
  } from "@/lib/prompts";
21
  import { calculateMaxTokens, estimateInputTokens, getProviderSpecificConfig } from "@/lib/max-tokens";
22
  import MY_TOKEN_KEY from "@/lib/get-cookie-name";
23
  import { Page } from "@/types";
24
- import { createRepo, RepoDesignation, uploadFiles } from "@huggingface/hub";
25
  import { isAuthenticated } from "@/lib/auth";
26
  import { getBestProvider } from "@/lib/best-provider";
27
- // import { rewritePrompt } from "@/lib/rewrite-prompt";
28
- import { COLORS } from "@/lib/utils";
29
- import { templates } from "@/lib/templates";
30
- import { injectDeepSiteBadge, isIndexPage } from "@/lib/inject-badge";
31
 
32
  const ipAddresses = new Map();
33
 
34
- const STREAMING_TIMEOUT = 180000;
35
- const REQUEST_TIMEOUT = 240000;
36
- export const maxDuration = 300;
37
-
38
  export async function POST(request: NextRequest) {
39
  const authHeaders = await headers();
40
  const userToken = request.cookies.get(MY_TOKEN_KEY())?.value;
@@ -117,9 +101,6 @@ export async function POST(request: NextRequest) {
117
 
118
  (async () => {
119
  // let completeResponse = "";
120
- let timeoutId: NodeJS.Timeout | null = null;
121
- let isTimedOut = false;
122
-
123
  try {
124
  const client = new InferenceClient(token);
125
 
@@ -151,51 +132,21 @@ export async function POST(request: NextRequest) {
151
  billTo ? { billTo } : {}
152
  );
153
 
154
- // Set up timeout
155
- const timeoutPromise = new Promise((_, reject) => {
156
- timeoutId = setTimeout(() => {
157
- isTimedOut = true;
158
- reject(new Error('Request timeout: The AI model took too long to respond. Please try again with a simpler prompt or try a different model.'));
159
- }, STREAMING_TIMEOUT);
160
- });
161
-
162
- // Race between streaming and timeout
163
- await Promise.race([
164
- (async () => {
165
- while (true) {
166
- const { done, value } = await chatCompletion.next()
167
- if (done) {
168
- break;
169
- }
170
-
171
- const chunk = value.choices[0]?.delta?.content;
172
- if (chunk) {
173
- await writer.write(encoder.encode(chunk));
174
- }
175
- }
176
- })(),
177
- timeoutPromise
178
- ]);
179
-
180
- // Clear timeout if successful
181
- if (timeoutId) clearTimeout(timeoutId);
182
 
183
- // Explicitly close the writer after successful completion
184
  await writer.close();
185
  } catch (error: any) {
186
- // Clear timeout on error
187
- if (timeoutId) clearTimeout(timeoutId);
188
-
189
- if (isTimedOut || error.message?.includes('timeout') || error.message?.includes('Request timeout')) {
190
- await writer.write(
191
- encoder.encode(
192
- JSON.stringify({
193
- ok: false,
194
- message: "Request timeout: The AI model took too long to respond. Please try again with a simpler prompt or try a different model.",
195
- })
196
- )
197
- );
198
- } else if (error.message?.includes("exceeded your monthly included credits")) {
199
  await writer.write(
200
  encoder.encode(
201
  JSON.stringify({
@@ -259,11 +210,9 @@ export async function PUT(request: NextRequest) {
259
  const authHeaders = await headers();
260
 
261
  const body = await request.json();
262
- const { prompt, provider, selectedElementHtml, model, pages, files, repoId: repoIdFromBody, isNew } =
263
  body;
264
 
265
- let repoId = repoIdFromBody;
266
-
267
  if (!prompt || pages.length === 0) {
268
  return NextResponse.json(
269
  { ok: false, error: "Missing required fields" },
@@ -314,453 +263,133 @@ export async function PUT(request: NextRequest) {
314
  billTo = "huggingface";
315
  }
316
 
317
- const client = new InferenceClient(token);
318
 
319
- const escapeRegExp = (string: string) => {
320
- return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
321
- };
 
322
 
323
- const createFlexibleHtmlRegex = (searchBlock: string) => {
324
- let searchRegex = escapeRegExp(searchBlock)
325
- .replace(/\s+/g, '\\s*')
326
- .replace(/>\s*</g, '>\\s*<')
327
- .replace(/\s*>/g, '\\s*>');
328
-
329
- return new RegExp(searchRegex, 'g');
330
- };
331
 
332
- const selectedProvider = await getBestProvider(selectedModel.value, provider)
 
 
333
 
334
- try {
335
- const systemPrompt = FOLLOW_UP_SYSTEM_PROMPT + (isNew ? PROMPT_FOR_PROJECT_NAME : "");
336
- const userContext = "You are modifying the HTML file based on the user's request.";
337
-
338
- const allPages = pages || [];
339
- const pagesContext = allPages
340
- .map((p: Page) => `- ${p.path}\n${p.html}`)
341
- .join("\n\n");
342
-
343
- const assistantContext = `${
344
- selectedElementHtml
345
- ? `\n\nYou have to update ONLY the following element, NOTHING ELSE: \n\n\`\`\`html\n${selectedElementHtml}\n\`\`\` Could be in multiple pages, if so, update all the pages.`
346
- : ""
347
- }. Current pages (${allPages.length} total): ${pagesContext}. ${files?.length > 0 ? `Available images: ${files?.map((f: string) => f).join(', ')}.` : ""}`;
348
-
349
- const estimatedInputTokens = estimateInputTokens(systemPrompt, prompt, userContext + assistantContext);
350
- const dynamicMaxTokens = calculateMaxTokens(selectedProvider, estimatedInputTokens, false);
351
- const providerConfig = getProviderSpecificConfig(selectedProvider, dynamicMaxTokens);
352
-
353
- const chatCompletion = client.chatCompletionStream(
354
- {
355
- model: selectedModel.value,
356
- provider: selectedProvider.provider,
357
- messages: [
358
- {
359
- role: "system",
360
- content: systemPrompt,
361
- },
362
- {
363
- role: "user",
364
- content: userContext,
365
- },
366
- {
367
- role: "assistant",
368
- content: assistantContext,
369
- },
370
- {
371
- role: "user",
372
- content: prompt,
373
- },
374
- ],
375
- ...providerConfig,
376
- },
377
- billTo ? { billTo } : {}
378
- );
379
 
380
- // Set up timeout for AI streaming
381
- let chunk = "";
382
- let timeoutId: NodeJS.Timeout | null = null;
383
- let isTimedOut = false;
384
 
385
- const timeoutPromise = new Promise<never>((_, reject) => {
386
- timeoutId = setTimeout(() => {
387
- isTimedOut = true;
388
- reject(new Error('Request timeout: The AI model took too long to respond. Please try again with a simpler prompt or try a different model.'));
389
- }, REQUEST_TIMEOUT);
390
- });
391
 
392
- try {
393
- await Promise.race([
394
- (async () => {
395
- while (true) {
396
- const { done, value } = await chatCompletion.next();
397
- if (done) {
398
- break;
399
- }
400
-
401
- const deltaContent = value.choices[0]?.delta?.content;
402
- if (deltaContent) {
403
- chunk += deltaContent;
404
- }
405
- }
406
- })(),
407
- timeoutPromise
408
- ]);
409
-
410
- // Clear timeout if successful
411
- if (timeoutId) clearTimeout(timeoutId);
412
- } catch (timeoutError: any) {
413
- console.error("++TIMEOUT ERROR++", timeoutError);
414
- console.error("++TIMEOUT ERROR MESSAGE++", timeoutError.message);
415
- // Clear timeout on error
416
- if (timeoutId) clearTimeout(timeoutId);
417
-
418
- if (isTimedOut || timeoutError.message?.includes('timeout') || timeoutError.message?.includes('Request timeout')) {
419
- return NextResponse.json(
420
  {
421
- ok: false,
422
- message: "Request timeout: The AI model took too long to respond. Please try again with a simpler prompt or try a different model.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
  },
424
- { status: 504 }
425
  );
426
- }
427
- throw timeoutError;
428
- }
429
- if (!chunk) {
430
- return NextResponse.json(
431
- { ok: false, message: "No content returned from the model" },
432
- { status: 400 }
433
- );
434
- }
435
-
436
- if (chunk) {
437
- const updatedLines: number[][] = [];
438
- let newHtml = "";
439
- const updatedPages = [...(pages || [])];
440
 
441
- const updateFileRegex = new RegExp(`${UPDATE_FILE_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^\\s]+)\\s*${UPDATE_FILE_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)(?=${UPDATE_FILE_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|${NEW_FILE_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|$)`, 'g');
442
- let updateFileMatch;
443
-
444
- while ((updateFileMatch = updateFileRegex.exec(chunk)) !== null) {
445
- const [, filePath, fileContent] = updateFileMatch;
446
-
447
- const pageIndex = updatedPages.findIndex(p => p.path === filePath);
448
- if (pageIndex !== -1) {
449
- let pageHtml = updatedPages[pageIndex].html;
450
-
451
- let processedContent = fileContent;
452
- const htmlMatch = fileContent.match(/```html\s*([\s\S]*?)\s*```/);
453
- if (htmlMatch) {
454
- processedContent = htmlMatch[1];
455
- }
456
- let position = 0;
457
- let moreBlocks = true;
458
-
459
- while (moreBlocks) {
460
- const searchStartIndex = processedContent.indexOf(SEARCH_START, position);
461
- if (searchStartIndex === -1) {
462
- moreBlocks = false;
463
- continue;
464
- }
465
-
466
- const dividerIndex = processedContent.indexOf(DIVIDER, searchStartIndex);
467
- if (dividerIndex === -1) {
468
- moreBlocks = false;
469
- continue;
470
- }
471
-
472
- const replaceEndIndex = processedContent.indexOf(REPLACE_END, dividerIndex);
473
- if (replaceEndIndex === -1) {
474
- moreBlocks = false;
475
- continue;
476
- }
477
-
478
- const searchBlock = processedContent.substring(
479
- searchStartIndex + SEARCH_START.length,
480
- dividerIndex
481
- );
482
- const replaceBlock = processedContent.substring(
483
- dividerIndex + DIVIDER.length,
484
- replaceEndIndex
485
- );
486
-
487
- if (searchBlock.trim() === "") {
488
- pageHtml = `${replaceBlock}\n${pageHtml}`;
489
- updatedLines.push([1, replaceBlock.split("\n").length]);
490
- } else {
491
- const regex = createFlexibleHtmlRegex(searchBlock);
492
- const match = regex.exec(pageHtml);
493
-
494
- if (match) {
495
- const matchedText = match[0];
496
- const beforeText = pageHtml.substring(0, match.index);
497
- const startLineNumber = beforeText.split("\n").length;
498
- const replaceLines = replaceBlock.split("\n").length;
499
- const endLineNumber = startLineNumber + replaceLines - 1;
500
-
501
- updatedLines.push([startLineNumber, endLineNumber]);
502
- pageHtml = pageHtml.replace(matchedText, replaceBlock);
503
- }
504
- }
505
-
506
- position = replaceEndIndex + REPLACE_END.length;
507
  }
508
 
509
- updatedPages[pageIndex].html = pageHtml;
510
-
511
- if (filePath === '/' || filePath === '/index' || filePath === 'index' || filePath === 'index.html') {
512
- newHtml = pageHtml;
513
  }
514
  }
515
- }
516
-
517
- const newFileRegex = new RegExp(`${NEW_FILE_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^\\s]+)\\s*${NEW_FILE_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)(?=${UPDATE_FILE_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|${NEW_FILE_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|$)`, 'g');
518
- let newFileMatch;
519
-
520
- while ((newFileMatch = newFileRegex.exec(chunk)) !== null) {
521
- const [, filePath, fileContent] = newFileMatch;
522
-
523
- let fileData = fileContent;
524
- // Try to extract content from code blocks
525
- const htmlMatch = fileContent.match(/```html\s*([\s\S]*?)\s*```/);
526
- const cssMatch = fileContent.match(/```css\s*([\s\S]*?)\s*```/);
527
- const jsMatch = fileContent.match(/```javascript\s*([\s\S]*?)\s*```/);
528
-
529
- if (htmlMatch) {
530
- fileData = htmlMatch[1];
531
- } else if (cssMatch) {
532
- fileData = cssMatch[1];
533
- } else if (jsMatch) {
534
- fileData = jsMatch[1];
535
- }
536
-
537
- const existingFileIndex = updatedPages.findIndex(p => p.path === filePath);
538
-
539
- if (existingFileIndex !== -1) {
540
- updatedPages[existingFileIndex] = {
541
- path: filePath,
542
- html: fileData.trim()
543
- };
544
- } else {
545
- updatedPages.push({
546
- path: filePath,
547
- html: fileData.trim()
548
- });
549
- }
550
- }
551
-
552
- if (updatedPages.length === pages?.length && !chunk.includes(UPDATE_FILE_START)) {
553
- let position = 0;
554
- let moreBlocks = true;
555
-
556
- while (moreBlocks) {
557
- const searchStartIndex = chunk.indexOf(SEARCH_START, position);
558
- if (searchStartIndex === -1) {
559
- moreBlocks = false;
560
- continue;
561
- }
562
 
563
- const dividerIndex = chunk.indexOf(DIVIDER, searchStartIndex);
564
- if (dividerIndex === -1) {
565
- moreBlocks = false;
566
- continue;
567
- }
568
 
569
- const replaceEndIndex = chunk.indexOf(REPLACE_END, dividerIndex);
570
- if (replaceEndIndex === -1) {
571
- moreBlocks = false;
572
- continue;
573
- }
574
-
575
- const searchBlock = chunk.substring(
576
- searchStartIndex + SEARCH_START.length,
577
- dividerIndex
 
 
578
  );
579
- const replaceBlock = chunk.substring(
580
- dividerIndex + DIVIDER.length,
581
- replaceEndIndex
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
582
  );
583
-
584
- if (searchBlock.trim() === "") {
585
- newHtml = `${replaceBlock}\n${newHtml}`;
586
- updatedLines.push([1, replaceBlock.split("\n").length]);
587
- } else {
588
- const regex = createFlexibleHtmlRegex(searchBlock);
589
- const match = regex.exec(newHtml);
590
-
591
- if (match) {
592
- const matchedText = match[0];
593
- const beforeText = newHtml.substring(0, match.index);
594
- const startLineNumber = beforeText.split("\n").length;
595
- const replaceLines = replaceBlock.split("\n").length;
596
- const endLineNumber = startLineNumber + replaceLines - 1;
597
-
598
- updatedLines.push([startLineNumber, endLineNumber]);
599
- newHtml = newHtml.replace(matchedText, replaceBlock);
600
- }
601
- }
602
-
603
- position = replaceEndIndex + REPLACE_END.length;
604
- }
605
-
606
- const mainPageIndex = updatedPages.findIndex(p => p.path === '/' || p.path === '/index' || p.path === 'index');
607
- if (mainPageIndex !== -1) {
608
- updatedPages[mainPageIndex].html = newHtml;
609
- }
610
- }
611
-
612
- const files: File[] = [];
613
- updatedPages.forEach((page: Page) => {
614
- let mimeType = "text/html";
615
- if (page.path.endsWith(".css")) {
616
- mimeType = "text/css";
617
- } else if (page.path.endsWith(".js")) {
618
- mimeType = "text/javascript";
619
- } else if (page.path.endsWith(".json")) {
620
- mimeType = "application/json";
621
  }
622
- const content = (mimeType === "text/html" && isIndexPage(page.path)) && isNew
623
- ? injectDeepSiteBadge(page.html)
624
- : page.html;
625
- const file = new File([content], page.path, { type: mimeType });
626
- files.push(file);
627
- });
628
-
629
- if (isNew) {
630
- const projectName = chunk.match(/<<<<<<< PROJECT_NAME_START\s*([\s\S]*?)\s*>>>>>>> PROJECT_NAME_END/)?.[1]?.trim();
631
- const formattedTitle = projectName?.toLowerCase()
632
- .replace(/[^a-z0-9]+/g, "-")
633
- .split("-")
634
- .filter(Boolean)
635
- .join("-")
636
- .slice(0, 96);
637
- const repo: RepoDesignation = {
638
- type: "space",
639
- name: `${user.name}/${formattedTitle}`,
640
- };
641
-
642
  try {
643
- const { repoUrl} = await createRepo({
644
- repo,
645
- accessToken: user.token as string,
646
- });
647
- repoId = repoUrl.split("/").slice(-2).join("/");
648
- } catch (createRepoError: any) {
649
- console.error("++CREATE REPO ERROR++", createRepoError);
650
- throw new Error(`Failed to create repository: ${createRepoError.message || 'Unknown error'}`);
651
  }
652
-
653
- const colorFrom = COLORS[Math.floor(Math.random() * COLORS.length)];
654
- const colorTo = COLORS[Math.floor(Math.random() * COLORS.length)];
655
- const README = `---
656
- title: ${projectName}
657
- colorFrom: ${colorFrom}
658
- colorTo: ${colorTo}
659
- emoji: 🐳
660
- sdk: static
661
- pinned: false
662
- tags:
663
- - deepsite-v3
664
- ---
665
-
666
- # Welcome to your new DeepSite project!
667
- This project was created with [DeepSite](https://huggingface.co/deepsite).
668
- `;
669
- files.push(new File([README], "README.md", { type: "text/markdown" }));
670
- }
671
-
672
- let response;
673
- try {
674
- // Add a timeout wrapper for the upload
675
- const uploadPromise = uploadFiles({
676
- repo: {
677
- type: "space",
678
- name: repoId,
679
- },
680
- files,
681
- commitTitle: prompt,
682
- accessToken: user.token as string,
683
- });
684
-
685
- const uploadTimeout = new Promise<never>((_, reject) => {
686
- setTimeout(() => {
687
- reject(new Error('Upload operation timed out'));
688
- }, 180000); // 3 minutes timeout for upload
689
- });
690
-
691
- response = await Promise.race([uploadPromise, uploadTimeout]);
692
- } catch (uploadError: any) {
693
- console.error("++UPLOAD FILES ERROR++", uploadError);
694
- console.error("++UPLOAD FILES ERROR MESSAGE++", uploadError.message);
695
- console.error("++REPO ID++", repoId);
696
-
697
- // If it's a timeout, files might have been uploaded but we didn't get response
698
- if (uploadError.message?.includes('timed out') || uploadError.message?.includes('timeout')) {
699
- console.warn("++UPLOAD TIMEOUT - Files may have been uploaded++");
700
- // Return a partial success response
701
- return NextResponse.json({
702
- ok: true,
703
- updatedLines,
704
- pages: updatedPages,
705
- repoId,
706
- commit: {
707
- title: prompt,
708
- oid: 'timeout',
709
- timedOut: true,
710
- }
711
- });
712
- }
713
-
714
- throw new Error(`Failed to upload files to repository: ${uploadError.message || 'Unknown error'}`);
715
- }
716
- const responseData: any = {
717
- ok: true,
718
- updatedLines,
719
- pages: updatedPages,
720
- repoId,
721
- };
722
-
723
- if (response && response.commit) {
724
- responseData.commit = {
725
- ...response.commit,
726
- title: prompt,
727
- };
728
- } else {
729
- responseData.commit = {
730
- title: prompt,
731
- oid: 'unknown',
732
- };
733
  }
 
734
 
735
- return NextResponse.json(responseData);
736
- } else {
737
- return NextResponse.json(
738
- { ok: false, message: "No content returned from the model" },
739
- { status: 400 }
740
- );
741
- }
742
  } catch (error: any) {
743
- console.error("++ERROR++", error);
744
- console.error("++ERROR MESSAGE++", error.message);
745
- if (error.message?.includes('timeout') || error.message?.includes('Request timeout')) {
746
- return NextResponse.json(
747
- {
748
- ok: false,
749
- message: "Request timeout: The operation took too long to complete. Please try again with a simpler request or try a different model.",
750
- },
751
- { status: 504 }
752
- );
753
- }
754
- if (error.message?.includes("exceeded your monthly included credits")) {
755
- return NextResponse.json(
756
- {
757
- ok: false,
758
- openProModal: true,
759
- message: error.message,
760
- },
761
- { status: 402 }
762
- );
763
- }
764
  return NextResponse.json(
765
  {
766
  ok: false,
 
6
 
7
  import { MODELS } from "@/lib/providers";
8
  import {
 
9
  FOLLOW_UP_SYSTEM_PROMPT,
10
  INITIAL_SYSTEM_PROMPT,
11
  MAX_REQUESTS_PER_IP,
 
 
 
 
 
 
12
  PROMPT_FOR_PROJECT_NAME,
13
  } from "@/lib/prompts";
14
  import { calculateMaxTokens, estimateInputTokens, getProviderSpecificConfig } from "@/lib/max-tokens";
15
  import MY_TOKEN_KEY from "@/lib/get-cookie-name";
16
  import { Page } from "@/types";
 
17
  import { isAuthenticated } from "@/lib/auth";
18
  import { getBestProvider } from "@/lib/best-provider";
 
 
 
 
19
 
20
  const ipAddresses = new Map();
21
 
 
 
 
 
22
  export async function POST(request: NextRequest) {
23
  const authHeaders = await headers();
24
  const userToken = request.cookies.get(MY_TOKEN_KEY())?.value;
 
101
 
102
  (async () => {
103
  // let completeResponse = "";
 
 
 
104
  try {
105
  const client = new InferenceClient(token);
106
 
 
132
  billTo ? { billTo } : {}
133
  );
134
 
135
+ while (true) {
136
+ const { done, value } = await chatCompletion.next()
137
+ if (done) {
138
+ break;
139
+ }
140
+
141
+ const chunk = value.choices[0]?.delta?.content;
142
+ if (chunk) {
143
+ await writer.write(encoder.encode(chunk));
144
+ }
145
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
 
147
  await writer.close();
148
  } catch (error: any) {
149
+ if (error.message?.includes("exceeded your monthly included credits")) {
 
 
 
 
 
 
 
 
 
 
 
 
150
  await writer.write(
151
  encoder.encode(
152
  JSON.stringify({
 
210
  const authHeaders = await headers();
211
 
212
  const body = await request.json();
213
+ const { prompt, provider, selectedElementHtml, model, pages, files, repoId, isNew } =
214
  body;
215
 
 
 
216
  if (!prompt || pages.length === 0) {
217
  return NextResponse.json(
218
  { ok: false, error: "Missing required fields" },
 
263
  billTo = "huggingface";
264
  }
265
 
266
+ const selectedProvider = await getBestProvider(selectedModel.value, provider);
267
 
268
+ try {
269
+ const encoder = new TextEncoder();
270
+ const stream = new TransformStream();
271
+ const writer = stream.writable.getWriter();
272
 
273
+ const response = new NextResponse(stream.readable, {
274
+ headers: {
275
+ "Content-Type": "text/plain; charset=utf-8",
276
+ "Cache-Control": "no-cache",
277
+ Connection: "keep-alive",
278
+ },
279
+ });
 
280
 
281
+ (async () => {
282
+ try {
283
+ const client = new InferenceClient(token);
284
 
285
+ const systemPrompt = FOLLOW_UP_SYSTEM_PROMPT + (isNew ? PROMPT_FOR_PROJECT_NAME : "");
286
+ const userContext = "You are modifying the HTML file based on the user's request.";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
 
288
+ const allPages = pages || [];
289
+ const pagesContext = allPages
290
+ .map((p: Page) => `- ${p.path}\n${p.html}`)
291
+ .join("\n\n");
292
 
293
+ const assistantContext = `${selectedElementHtml
294
+ ? `\n\nYou have to update ONLY the following element, NOTHING ELSE: \n\n\`\`\`html\n${selectedElementHtml}\n\`\`\` Could be in multiple pages, if so, update all the pages.`
295
+ : ""
296
+ }. Current pages (${allPages.length} total): ${pagesContext}. ${files?.length > 0 ? `Available images: ${files?.map((f: string) => f).join(', ')}.` : ""}`;
 
 
297
 
298
+ const estimatedInputTokens = estimateInputTokens(systemPrompt, prompt, userContext + assistantContext);
299
+ const dynamicMaxTokens = calculateMaxTokens(selectedProvider, estimatedInputTokens, false);
300
+ const providerConfig = getProviderSpecificConfig(selectedProvider, dynamicMaxTokens);
301
+
302
+ const chatCompletion = client.chatCompletionStream(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  {
304
+ model: selectedModel.value,
305
+ provider: selectedProvider.provider,
306
+ messages: [
307
+ {
308
+ role: "system",
309
+ content: systemPrompt,
310
+ },
311
+ {
312
+ role: "user",
313
+ content: userContext,
314
+ },
315
+ {
316
+ role: "assistant",
317
+ content: assistantContext,
318
+ },
319
+ {
320
+ role: "user",
321
+ content: prompt,
322
+ },
323
+ ],
324
+ ...providerConfig,
325
  },
326
+ billTo ? { billTo } : {}
327
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
 
329
+ // Stream the response chunks to the client
330
+ while (true) {
331
+ const { done, value } = await chatCompletion.next();
332
+ if (done) {
333
+ break;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  }
335
 
336
+ const chunk = value.choices[0]?.delta?.content;
337
+ if (chunk) {
338
+ await writer.write(encoder.encode(chunk));
 
339
  }
340
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
 
342
+ await writer.write(encoder.encode(`\n___METADATA_START___\n${JSON.stringify({
343
+ repoId,
344
+ isNew,
345
+ userName: user.name,
346
+ })}\n___METADATA_END___\n`));
347
 
348
+ await writer.close();
349
+ } catch (error: any) {
350
+ if (error.message?.includes("exceeded your monthly included credits")) {
351
+ await writer.write(
352
+ encoder.encode(
353
+ JSON.stringify({
354
+ ok: false,
355
+ openProModal: true,
356
+ message: error.message,
357
+ })
358
+ )
359
  );
360
+ } else if (error?.message?.includes("inference provider information")) {
361
+ await writer.write(
362
+ encoder.encode(
363
+ JSON.stringify({
364
+ ok: false,
365
+ openSelectProvider: true,
366
+ message: error.message,
367
+ })
368
+ )
369
+ );
370
+ } else {
371
+ await writer.write(
372
+ encoder.encode(
373
+ JSON.stringify({
374
+ ok: false,
375
+ message:
376
+ error.message ||
377
+ "An error occurred while processing your request.",
378
+ })
379
+ )
380
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  }
382
+ } finally {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  try {
384
+ await writer?.close();
385
+ } catch {
386
+ // ignore
 
 
 
 
 
387
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  }
389
+ })();
390
 
391
+ return response;
 
 
 
 
 
 
392
  } catch (error: any) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  return NextResponse.json(
394
  {
395
  ok: false,
app/api/me/projects/[namespace]/[repoId]/update/route.ts ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { createRepo, RepoDesignation, uploadFiles } from "@huggingface/hub";
3
+
4
+ import { isAuthenticated } from "@/lib/auth";
5
+ import { Page } from "@/types";
6
+ import { COLORS } from "@/lib/utils";
7
+ import { injectDeepSiteBadge, isIndexPage } from "@/lib/inject-badge";
8
+ import { pagesToFiles } from "@/lib/format-ai-response";
9
+
10
+ /**
11
+ * UPDATE route - for updating existing projects or creating new ones after AI streaming
12
+ * This route handles the HuggingFace upload after client-side AI response processing
13
+ */
14
+ export async function PUT(
15
+ req: NextRequest,
16
+ { params }: { params: Promise<{ namespace: string; repoId: string }> }
17
+ ) {
18
+ const user = await isAuthenticated();
19
+ if (user instanceof NextResponse || !user) {
20
+ return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
21
+ }
22
+
23
+ const param = await params;
24
+ let { namespace, repoId } = param;
25
+ const { pages, commitTitle = "AI-generated changes", isNew, projectName } = await req.json();
26
+
27
+ if (!pages || !Array.isArray(pages) || pages.length === 0) {
28
+ return NextResponse.json(
29
+ { ok: false, error: "Pages are required" },
30
+ { status: 400 }
31
+ );
32
+ }
33
+
34
+ try {
35
+ let files: File[];
36
+
37
+ if (isNew) {
38
+ // Creating a new project
39
+ const title = projectName || "DeepSite Project";
40
+ const formattedTitle = title
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9]+/g, "-")
43
+ .split("-")
44
+ .filter(Boolean)
45
+ .join("-")
46
+ .slice(0, 96);
47
+
48
+ const repo: RepoDesignation = {
49
+ type: "space",
50
+ name: `${user.name}/${formattedTitle}`,
51
+ };
52
+
53
+ try {
54
+ const { repoUrl } = await createRepo({
55
+ repo,
56
+ accessToken: user.token as string,
57
+ });
58
+ namespace = user.name;
59
+ repoId = repoUrl.split("/").slice(-2).join("/").split("/")[1];
60
+ } catch (createRepoError: any) {
61
+ return NextResponse.json(
62
+ {
63
+ ok: false,
64
+ error: `Failed to create repository: ${createRepoError.message || 'Unknown error'}`,
65
+ },
66
+ { status: 500 }
67
+ );
68
+ }
69
+
70
+ // Prepare files with badge injection for new projects
71
+ files = [];
72
+ pages.forEach((page: Page) => {
73
+ let mimeType = "text/html";
74
+ if (page.path.endsWith(".css")) {
75
+ mimeType = "text/css";
76
+ } else if (page.path.endsWith(".js")) {
77
+ mimeType = "text/javascript";
78
+ } else if (page.path.endsWith(".json")) {
79
+ mimeType = "application/json";
80
+ }
81
+ const content = (mimeType === "text/html" && isIndexPage(page.path))
82
+ ? injectDeepSiteBadge(page.html)
83
+ : page.html;
84
+ const file = new File([content], page.path, { type: mimeType });
85
+ files.push(file);
86
+ });
87
+
88
+ // Add README.md for new projects
89
+ const colorFrom = COLORS[Math.floor(Math.random() * COLORS.length)];
90
+ const colorTo = COLORS[Math.floor(Math.random() * COLORS.length)];
91
+ const README = `---
92
+ title: ${title}
93
+ colorFrom: ${colorFrom}
94
+ colorTo: ${colorTo}
95
+ emoji: 🐳
96
+ sdk: static
97
+ pinned: false
98
+ tags:
99
+ - deepsite-v3
100
+ ---
101
+
102
+ # Welcome to your new DeepSite project!
103
+ This project was created with [DeepSite](https://huggingface.co/deepsite).
104
+ `;
105
+ files.push(new File([README], "README.md", { type: "text/markdown" }));
106
+ } else {
107
+ // Updating existing project - no badge injection
108
+ files = pagesToFiles(pages);
109
+ }
110
+
111
+ const response = await uploadFiles({
112
+ repo: {
113
+ type: "space",
114
+ name: `${namespace}/${repoId}`,
115
+ },
116
+ files,
117
+ commitTitle,
118
+ accessToken: user.token as string,
119
+ });
120
+
121
+ return NextResponse.json({
122
+ ok: true,
123
+ pages,
124
+ repoId: `${namespace}/${repoId}`,
125
+ commit: {
126
+ ...response.commit,
127
+ title: commitTitle,
128
+ }
129
+ });
130
+ } catch (error: any) {
131
+ console.error("Error updating project:", error);
132
+ return NextResponse.json(
133
+ {
134
+ ok: false,
135
+ error: error.message || "Failed to update project",
136
+ },
137
+ { status: 500 }
138
+ );
139
+ }
140
+ }
141
+
components/editor/ask-ai/index.tsx CHANGED
@@ -100,7 +100,8 @@ export const AskAi = ({
100
  prompt,
101
  enhancedSettings,
102
  redesignMarkdown,
103
- !!user
 
104
  );
105
 
106
  if (result?.error) {
 
100
  prompt,
101
  enhancedSettings,
102
  redesignMarkdown,
103
+ !!user,
104
+ user?.name
105
  );
106
 
107
  if (result?.error) {
components/editor/preview/index.tsx CHANGED
@@ -72,7 +72,6 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
72
  (html: string): string => {
73
  if (!html) return html;
74
 
75
- // Find all CSS and JS files (including those in subdirectories)
76
  const cssFiles = pages.filter(
77
  (p) => p.path.endsWith(".css") && p.path !== previewPageData?.path
78
  );
@@ -102,11 +101,9 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
102
  `<head>\n${allCssContent}`
103
  );
104
  } else {
105
- // If no head tag, prepend to document
106
  modifiedHtml = allCssContent + "\n" + modifiedHtml;
107
  }
108
 
109
- // Remove all link tags that reference CSS files we're injecting
110
  cssFiles.forEach((file) => {
111
  const escapedPath = file.path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
112
  modifiedHtml = modifiedHtml.replace(
@@ -119,7 +116,6 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
119
  });
120
  }
121
 
122
- // Inject all JS files
123
  if (jsFiles.length > 0) {
124
  const allJsContent = jsFiles
125
  .map(
@@ -136,11 +132,9 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
136
  } else if (modifiedHtml.includes("<body>")) {
137
  modifiedHtml = modifiedHtml + allJsContent;
138
  } else {
139
- // If no body tag, append to document
140
  modifiedHtml = modifiedHtml + "\n" + allJsContent;
141
  }
142
 
143
- // Remove all script tags that reference JS files we're injecting
144
  jsFiles.forEach((file) => {
145
  const escapedPath = file.path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
146
  modifiedHtml = modifiedHtml.replace(
@@ -214,7 +208,6 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
214
  if (iframeRef?.current?.contentDocument) {
215
  const iframeDocument = iframeRef.current.contentDocument;
216
 
217
- // Use event delegation to catch clicks on anchors in both light and shadow DOM
218
  iframeDocument.addEventListener(
219
  "click",
220
  handleCustomNavigation as any,
@@ -323,18 +316,11 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
323
  const path = event.composedPath();
324
  const targetElement = path[0] as HTMLElement;
325
 
326
- console.log(
327
- "[handleClick] Target element:",
328
- targetElement.tagName,
329
- targetElement
330
- );
331
-
332
  const findClosestAnchor = (
333
  element: HTMLElement
334
  ): HTMLAnchorElement | null => {
335
  let current: HTMLElement | null = element;
336
  while (current) {
337
- console.log("[handleClick] Checking element:", current.tagName);
338
  if (current.tagName?.toUpperCase() === "A") {
339
  return current as HTMLAnchorElement;
340
  }
@@ -342,13 +328,9 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
342
  break;
343
  }
344
  const parent: Node | null = current.parentNode;
345
- // Use nodeType to check - works across iframe boundaries
346
- // nodeType 1 = Element, nodeType 11 = DocumentFragment (including ShadowRoot)
347
  if (parent && parent.nodeType === 11) {
348
- // ShadowRoot
349
  current = (parent as ShadowRoot).host as HTMLElement;
350
  } else if (parent && parent.nodeType === 1) {
351
- // Element node
352
  current = parent as HTMLElement;
353
  } else {
354
  break;
@@ -359,8 +341,6 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
359
 
360
  const anchorElement = findClosestAnchor(targetElement);
361
 
362
- console.log("[handleClick] Found anchor:", anchorElement);
363
-
364
  if (anchorElement) {
365
  return;
366
  }
@@ -379,49 +359,23 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
379
  const path = event.composedPath();
380
  const actualTarget = path[0] as HTMLElement;
381
 
382
- console.log(
383
- "[handleCustomNavigation] Click detected in iframe:",
384
- actualTarget.tagName,
385
- actualTarget
386
- );
387
-
388
  const findClosestAnchor = (
389
  element: HTMLElement
390
  ): HTMLAnchorElement | null => {
391
  let current: HTMLElement | null = element;
392
  while (current) {
393
- console.log(
394
- "[handleCustomNavigation] Checking element:",
395
- current.tagName,
396
- current
397
- );
398
  if (current.tagName?.toUpperCase() === "A") {
399
- console.log("[handleCustomNavigation] Found anchor!", current);
400
  return current as HTMLAnchorElement;
401
  }
402
  if (current === iframeDocument.body) {
403
- console.log("[handleCustomNavigation] Reached body, stopping");
404
  break;
405
  }
406
  const parent: Node | null = current.parentNode;
407
- console.log(
408
- "[handleCustomNavigation] Parent node:",
409
- parent,
410
- "nodeType:",
411
- parent?.nodeType
412
- );
413
- // Use nodeType to check - works across iframe boundaries
414
- // nodeType 1 = Element, nodeType 11 = DocumentFragment (including ShadowRoot)
415
  if (parent && parent.nodeType === 11) {
416
- // ShadowRoot
417
  current = (parent as ShadowRoot).host as HTMLElement;
418
  } else if (parent && parent.nodeType === 1) {
419
- // Element node
420
  current = parent as HTMLElement;
421
  } else {
422
- console.log(
423
- "[handleCustomNavigation] Parent is not an element node, breaking"
424
- );
425
  break;
426
  }
427
  }
@@ -429,7 +383,6 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
429
  };
430
 
431
  const anchorElement = findClosestAnchor(actualTarget);
432
- console.log("[handleCustomNavigation] Anchor element:", anchorElement);
433
  if (anchorElement) {
434
  let href = anchorElement.getAttribute("href");
435
  if (href) {
@@ -573,7 +526,6 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
573
  }
574
  );
575
  }
576
- // Set up event listeners after iframe loads
577
  setupIframeListeners();
578
  }
579
  : undefined
@@ -583,7 +535,7 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
583
  />
584
  {!isNew && (
585
  <>
586
- <div
587
  className={classNames(
588
  "w-full h-full flex items-center justify-center absolute left-0 top-0 bg-black/40 backdrop-blur-lg transition-all duration-200",
589
  {
@@ -601,7 +553,7 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
601
  <AnimatedBlobs />
602
  <AnimatedBlobs />
603
  </div>
604
- </div>
605
  <HistoryNotification
606
  isVisible={!!currentCommit}
607
  isPromotingVersion={isPromotingVersion}
 
72
  (html: string): string => {
73
  if (!html) return html;
74
 
 
75
  const cssFiles = pages.filter(
76
  (p) => p.path.endsWith(".css") && p.path !== previewPageData?.path
77
  );
 
101
  `<head>\n${allCssContent}`
102
  );
103
  } else {
 
104
  modifiedHtml = allCssContent + "\n" + modifiedHtml;
105
  }
106
 
 
107
  cssFiles.forEach((file) => {
108
  const escapedPath = file.path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
109
  modifiedHtml = modifiedHtml.replace(
 
116
  });
117
  }
118
 
 
119
  if (jsFiles.length > 0) {
120
  const allJsContent = jsFiles
121
  .map(
 
132
  } else if (modifiedHtml.includes("<body>")) {
133
  modifiedHtml = modifiedHtml + allJsContent;
134
  } else {
 
135
  modifiedHtml = modifiedHtml + "\n" + allJsContent;
136
  }
137
 
 
138
  jsFiles.forEach((file) => {
139
  const escapedPath = file.path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
140
  modifiedHtml = modifiedHtml.replace(
 
208
  if (iframeRef?.current?.contentDocument) {
209
  const iframeDocument = iframeRef.current.contentDocument;
210
 
 
211
  iframeDocument.addEventListener(
212
  "click",
213
  handleCustomNavigation as any,
 
316
  const path = event.composedPath();
317
  const targetElement = path[0] as HTMLElement;
318
 
 
 
 
 
 
 
319
  const findClosestAnchor = (
320
  element: HTMLElement
321
  ): HTMLAnchorElement | null => {
322
  let current: HTMLElement | null = element;
323
  while (current) {
 
324
  if (current.tagName?.toUpperCase() === "A") {
325
  return current as HTMLAnchorElement;
326
  }
 
328
  break;
329
  }
330
  const parent: Node | null = current.parentNode;
 
 
331
  if (parent && parent.nodeType === 11) {
 
332
  current = (parent as ShadowRoot).host as HTMLElement;
333
  } else if (parent && parent.nodeType === 1) {
 
334
  current = parent as HTMLElement;
335
  } else {
336
  break;
 
341
 
342
  const anchorElement = findClosestAnchor(targetElement);
343
 
 
 
344
  if (anchorElement) {
345
  return;
346
  }
 
359
  const path = event.composedPath();
360
  const actualTarget = path[0] as HTMLElement;
361
 
 
 
 
 
 
 
362
  const findClosestAnchor = (
363
  element: HTMLElement
364
  ): HTMLAnchorElement | null => {
365
  let current: HTMLElement | null = element;
366
  while (current) {
 
 
 
 
 
367
  if (current.tagName?.toUpperCase() === "A") {
 
368
  return current as HTMLAnchorElement;
369
  }
370
  if (current === iframeDocument.body) {
 
371
  break;
372
  }
373
  const parent: Node | null = current.parentNode;
 
 
 
 
 
 
 
 
374
  if (parent && parent.nodeType === 11) {
 
375
  current = (parent as ShadowRoot).host as HTMLElement;
376
  } else if (parent && parent.nodeType === 1) {
 
377
  current = parent as HTMLElement;
378
  } else {
 
 
 
379
  break;
380
  }
381
  }
 
383
  };
384
 
385
  const anchorElement = findClosestAnchor(actualTarget);
 
386
  if (anchorElement) {
387
  let href = anchorElement.getAttribute("href");
388
  if (href) {
 
526
  }
527
  );
528
  }
 
529
  setupIframeListeners();
530
  }
531
  : undefined
 
535
  />
536
  {!isNew && (
537
  <>
538
+ {/* <div
539
  className={classNames(
540
  "w-full h-full flex items-center justify-center absolute left-0 top-0 bg-black/40 backdrop-blur-lg transition-all duration-200",
541
  {
 
553
  <AnimatedBlobs />
554
  <AnimatedBlobs />
555
  </div>
556
+ </div> */}
557
  <HistoryNotification
558
  isVisible={!!currentCommit}
559
  isPromotingVersion={isPromotingVersion}
hooks/useAi.ts CHANGED
@@ -124,27 +124,37 @@ export const useAi = (onScrollToBottom?: () => void) => {
124
  client.setQueryData(["ai.model"], newModel);
125
  };
126
 
127
- const createNewProject = async (prompt: string, htmlPages: Page[], projectName: string | undefined, isLoggedIn?: boolean) => {
128
- if (isLoggedIn) {
129
- api.post("/me/projects", {
130
- title: projectName,
131
- pages: htmlPages,
132
- prompt,
133
- })
134
- .then((response) => {
135
- if (response.data.ok) {
136
- setIsAiWorking(false);
137
- router.replace(`/${response.data.space.project.space_id}`);
138
- setProject(response.data.space);
139
- setProjects([...projects, response.data.space]);
140
- toast.success("AI responded successfully");
141
- if (audio.current) audio.current.play();
 
 
 
 
 
 
142
  }
143
- })
144
- .catch((error) => {
145
  setIsAiWorking(false);
146
- toast.error(error?.response?.data?.message || error?.message || "Failed to create project");
147
- });
 
 
 
 
 
148
  } else {
149
  setIsAiWorking(false);
150
  toast.success("AI responded successfully");
@@ -152,7 +162,7 @@ export const useAi = (onScrollToBottom?: () => void) => {
152
  }
153
  }
154
 
155
- const callAiNewProject = async (prompt: string, enhancedSettings?: EnhancedSettings, redesignMarkdown?: string, isLoggedIn?: boolean) => {
156
  if (isAiWorking) return;
157
  if (!redesignMarkdown && !prompt.trim()) return;
158
 
@@ -218,7 +228,7 @@ export const useAi = (onScrollToBottom?: () => void) => {
218
  setPages(newPages);
219
  setLastSavedPages([...newPages]);
220
  if (newPages.length > 0 && !isTheSameHtml(newPages[0].html)) {
221
- createNewProject(prompt, newPages, projectName, isLoggedIn);
222
  }
223
  setPrompts([...prompts, prompt]);
224
 
@@ -311,118 +321,162 @@ export const useAi = (onScrollToBottom?: () => void) => {
311
  });
312
 
313
  if (request && request.body) {
314
- if (request.status === 504) {
315
- console.warn("++504 GATEWAY TIMEOUT - Upload likely succeeded but response timed out++");
316
- console.warn("++REQUEST STATUS++", request.status);
317
- console.warn("++REQUEST BODY++", request.body);
318
-
319
- setIsAiWorking(false);
320
-
321
- if (isNew) {
322
- toast.error("The request timed out. Your project may have been created. Please check your HuggingFace spaces.");
323
- return { error: "gateway_timeout", message: "Request timed out after upload" };
324
- }
325
 
326
- toast.success("Changes saved! Refreshing page to show updates...", { duration: 3000 });
327
- setTimeout(() => {
328
- window.location.reload();
329
- }, 1000);
330
- return { success: true, timedOut: true };
331
- }
332
-
333
- const clonedRequest = request.clone();
334
- let res;
335
- try {
336
- res = await request.json();
337
- } catch (jsonError: any) {
338
- try {
339
- const text = await clonedRequest.text();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
 
341
- // Check if it's a CloudFront/gateway timeout in the HTML
342
- if (text.includes("504") || text.includes("Gateway Timeout") || text.includes("gateway timeout")) {
343
- console.warn("++DETECTED 504 IN HTML RESPONSE++");
344
- setIsAiWorking(false);
345
-
346
- if (isNew) {
347
- toast.error("The request timed out. Your project may have been created. Please check your HuggingFace spaces.");
348
- return { error: "gateway_timeout", message: "Request timed out after upload" };
 
 
 
 
 
 
 
 
 
 
349
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
 
351
- toast.success("Changes saved! Refreshing page to show updates...", { duration: 3000 });
352
- setTimeout(() => {
353
- window.location.reload();
354
- }, 1000);
355
- return { success: true, timedOut: true };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  }
357
- } catch (textError) {
358
- }
359
- setIsAiWorking(false);
360
- toast.error("Server returned invalid response. Check console for details.");
361
- return { error: "invalid_response", message: "Server returned non-JSON response" };
362
- }
363
-
364
- if (!request.ok) {
365
- if (res.openLogin) {
366
- setIsAiWorking(false);
367
- return { error: "login_required" };
368
- } else if (res.openSelectProvider) {
369
- setIsAiWorking(false);
370
- return { error: "provider_required", message: res.message };
371
- } else if (res.openProModal) {
372
- setIsAiWorking(false);
373
- return { error: "pro_required" };
374
- } else {
375
- setIsAiWorking(false);
376
- return { error: "api_error", message: res.message };
377
  }
378
- }
379
 
380
- toast.success("AI responded successfully");
381
- const iframe = document.getElementById(
382
- "preview-iframe"
383
- ) as HTMLIFrameElement;
384
-
385
- if (isNew && res.repoId) {
386
- router.push(`/${res.repoId}`);
387
- setIsAiWorking(false);
388
- } else {
389
- const returnedPages = res.pages as Page[];
390
- const updatedPagesMap = new Map(returnedPages.map((p: Page) => [p.path, p]));
391
- const mergedPages: Page[] = pages.map(page =>
392
- updatedPagesMap.has(page.path) ? updatedPagesMap.get(page.path)! : page
393
- );
394
- returnedPages.forEach((page: Page) => {
395
- if (!pages.find(p => p.path === page.path)) {
396
- mergedPages.push(page);
 
 
 
 
 
 
397
  }
398
- });
399
 
400
- setPages(mergedPages);
401
- setLastSavedPages([...mergedPages]);
402
- setCommits([res.commit, ...commits]);
403
- setPrompts(
404
- [...prompts, prompt]
405
- )
406
- setSelectedElement(null);
407
- setSelectedFiles([]);
408
- // setContextFile(null); not needed yet, keep context for the next request.
409
- setIsEditableModeEnabled(false);
410
- setIsAiWorking(false);
411
- }
412
-
413
- if (audio.current) audio.current.play();
414
- if (iframe) {
415
- setTimeout(() => {
416
- iframe.src = iframe.src;
417
- }, 500);
418
- }
419
 
420
- return { success: true, html: res.html, updatedLines: res.updatedLines };
421
  }
422
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
423
  } catch (error: any) {
424
  setIsAiWorking(false);
425
- toast.error(error.message);
 
 
 
 
 
 
426
  if (error.openLogin) {
427
  return { error: "login_required" };
428
  }
@@ -470,7 +524,6 @@ export const useAi = (onScrollToBottom?: () => void) => {
470
  if (pages.length > 0) {
471
  setPages(pages);
472
  if (isStreaming) {
473
- // Find new pages that haven't been shown yet (HTML, CSS, JS, etc.)
474
  const newPages = pages.filter(p =>
475
  !streamingPagesRef.current.has(p.path)
476
  );
@@ -480,7 +533,6 @@ export const useAi = (onScrollToBottom?: () => void) => {
480
  setCurrentPage(newPage.path);
481
  streamingPagesRef.current.add(newPage.path);
482
 
483
- // Update preview if it's an HTML file not in components folder
484
  if (newPage.path.endsWith('.html') && !newPage.path.includes('/components/')) {
485
  setPreviewPage(newPage.path);
486
  }
@@ -500,41 +552,30 @@ export const useAi = (onScrollToBottom?: () => void) => {
500
  const extractFileContent = (chunk: string, filePath: string): string => {
501
  if (!chunk) return "";
502
 
503
- // Remove backticks first
504
  let content = chunk.trim();
505
 
506
- // Handle different file types
507
  if (filePath.endsWith('.css')) {
508
- // Try to extract CSS from complete code blocks first
509
  const cssMatch = content.match(/```css\s*([\s\S]*?)\s*```/);
510
  if (cssMatch) {
511
  content = cssMatch[1];
512
  } else {
513
- // Handle incomplete code blocks during streaming (remove opening fence)
514
  content = content.replace(/^```css\s*/i, "");
515
  }
516
- // Remove any remaining backticks
517
  return content.replace(/```/g, "").trim();
518
  } else if (filePath.endsWith('.js')) {
519
- // Try to extract JavaScript from complete code blocks first
520
  const jsMatch = content.match(/```(?:javascript|js)\s*([\s\S]*?)\s*```/);
521
  if (jsMatch) {
522
  content = jsMatch[1];
523
  } else {
524
- // Handle incomplete code blocks during streaming (remove opening fence)
525
  content = content.replace(/^```(?:javascript|js)\s*/i, "");
526
  }
527
- // Remove any remaining backticks
528
  return content.replace(/```/g, "").trim();
529
  } else {
530
- // Handle HTML files
531
  const htmlMatch = content.match(/```html\s*([\s\S]*?)\s*```/);
532
  if (htmlMatch) {
533
  content = htmlMatch[1];
534
  } else {
535
- // Handle incomplete code blocks during streaming (remove opening fence)
536
  content = content.replace(/^```html\s*/i, "");
537
- // Try to find HTML starting with DOCTYPE
538
  const doctypeMatch = content.match(/<!DOCTYPE html>[\s\S]*/);
539
  if (doctypeMatch) {
540
  content = doctypeMatch[0];
 
124
  client.setQueryData(["ai.model"], newModel);
125
  };
126
 
127
+ const createNewProject = async (prompt: string, htmlPages: Page[], projectName: string | undefined, isLoggedIn?: boolean, userName?: string) => {
128
+ if (isLoggedIn && userName) {
129
+ try {
130
+ const uploadRequest = await fetch(`/deepsite/api/me/projects/${userName}/new/update`, {
131
+ method: "PUT",
132
+ body: JSON.stringify({
133
+ pages: htmlPages,
134
+ commitTitle: prompt,
135
+ isNew: true,
136
+ projectName,
137
+ }),
138
+ headers: {
139
+ "Content-Type": "application/json",
140
+ "Authorization": `Bearer ${token}`,
141
+ },
142
+ });
143
+
144
+ const uploadRes = await uploadRequest.json();
145
+
146
+ if (!uploadRequest.ok || !uploadRes.ok) {
147
+ throw new Error(uploadRes.error || "Failed to create project");
148
  }
149
+
 
150
  setIsAiWorking(false);
151
+ router.replace(`/${uploadRes.repoId}`);
152
+ toast.success("AI responded successfully");
153
+ if (audio.current) audio.current.play();
154
+ } catch (error: any) {
155
+ setIsAiWorking(false);
156
+ toast.error(error?.message || "Failed to create project");
157
+ }
158
  } else {
159
  setIsAiWorking(false);
160
  toast.success("AI responded successfully");
 
162
  }
163
  }
164
 
165
+ const callAiNewProject = async (prompt: string, enhancedSettings?: EnhancedSettings, redesignMarkdown?: string, isLoggedIn?: boolean, userName?: string) => {
166
  if (isAiWorking) return;
167
  if (!redesignMarkdown && !prompt.trim()) return;
168
 
 
228
  setPages(newPages);
229
  setLastSavedPages([...newPages]);
230
  if (newPages.length > 0 && !isTheSameHtml(newPages[0].html)) {
231
+ createNewProject(prompt, newPages, projectName, isLoggedIn, userName);
232
  }
233
  setPrompts([...prompts, prompt]);
234
 
 
321
  });
322
 
323
  if (request && request.body) {
324
+ const reader = request.body.getReader();
325
+ const decoder = new TextDecoder("utf-8");
326
+ let contentResponse = "";
327
+ let metadata: any = null;
328
+
329
+ const read = async (): Promise<any> => {
330
+ const { done, value } = await reader.read();
 
 
 
 
331
 
332
+ if (done) {
333
+ const metadataMatch = contentResponse.match(/___METADATA_START___([\s\S]*?)___METADATA_END___/);
334
+ if (metadataMatch) {
335
+ try {
336
+ metadata = JSON.parse(metadataMatch[1]);
337
+ contentResponse = contentResponse.replace(/___METADATA_START___[\s\S]*?___METADATA_END___/, '').trim();
338
+ } catch (e) {
339
+ console.error("Failed to parse metadata", e);
340
+ }
341
+ }
342
+
343
+ const trimmedResponse = contentResponse.trim();
344
+ if (trimmedResponse.startsWith("{") && trimmedResponse.endsWith("}")) {
345
+ try {
346
+ const jsonResponse = JSON.parse(trimmedResponse);
347
+ if (jsonResponse && !jsonResponse.ok) {
348
+ setIsAiWorking(false);
349
+ if (jsonResponse.openLogin) {
350
+ return { error: "login_required" };
351
+ } else if (jsonResponse.openSelectProvider) {
352
+ return { error: "provider_required", message: jsonResponse.message };
353
+ } else if (jsonResponse.openProModal) {
354
+ return { error: "pro_required" };
355
+ } else {
356
+ toast.error(jsonResponse.message);
357
+ return { error: "api_error", message: jsonResponse.message };
358
+ }
359
+ }
360
+ } catch (e) {
361
+ // Not JSON, continue with normal processing
362
+ }
363
+ }
364
 
365
+ const { processAiResponse, extractProjectName } = await import("@/lib/format-ai-response");
366
+ const { updatedPages, updatedLines } = processAiResponse(contentResponse, pagesToSend);
367
+
368
+ const updatedPagesMap = new Map(updatedPages.map((p: Page) => [p.path, p]));
369
+ const mergedPages: Page[] = pages.map(page =>
370
+ updatedPagesMap.has(page.path) ? updatedPagesMap.get(page.path)! : page
371
+ );
372
+ updatedPages.forEach((page: Page) => {
373
+ if (!pages.find(p => p.path === page.path)) {
374
+ mergedPages.push(page);
375
+ }
376
+ });
377
+
378
+ let projectName = null;
379
+ if (isNew) {
380
+ projectName = extractProjectName(contentResponse);
381
+ if (!projectName) {
382
+ projectName = prompt.substring(0, 40).replace(/[^a-zA-Z0-9]/g, "-").slice(0, 40) + "-" + Math.random().toString(36).substring(2, 15);
383
  }
384
+ }
385
+
386
+ try {
387
+ const uploadRequest = await fetch(`/deepsite/api/me/projects/${metadata?.userName || 'unknown'}/${isNew ? 'new' : (project?.space_id?.split('/')[1] || 'unknown')}/update`, {
388
+ method: "PUT",
389
+ body: JSON.stringify({
390
+ pages: mergedPages,
391
+ commitTitle: prompt,
392
+ isNew,
393
+ projectName,
394
+ }),
395
+ headers: {
396
+ "Content-Type": "application/json",
397
+ "Authorization": `Bearer ${token}`,
398
+ },
399
+ });
400
+
401
+ const uploadRes = await uploadRequest.json();
402
 
403
+ if (!uploadRequest.ok || !uploadRes.ok) {
404
+ throw new Error(uploadRes.error || "Failed to upload to HuggingFace");
405
+ }
406
+
407
+ toast.success("AI responded successfully");
408
+ const iframe = document.getElementById("preview-iframe") as HTMLIFrameElement;
409
+
410
+ if (isNew && uploadRes.repoId) {
411
+ router.push(`/${uploadRes.repoId}`);
412
+ setIsAiWorking(false);
413
+ } else {
414
+ setPages(mergedPages);
415
+ setLastSavedPages([...mergedPages]);
416
+ setCommits([uploadRes.commit, ...commits]);
417
+ setPrompts([...prompts, prompt]);
418
+ setSelectedElement(null);
419
+ setSelectedFiles([]);
420
+ setIsEditableModeEnabled(false);
421
+ setIsAiWorking(false);
422
+ }
423
+
424
+ if (audio.current) audio.current.play();
425
+ if (iframe) {
426
+ setTimeout(() => {
427
+ iframe.src = iframe.src;
428
+ }, 500);
429
+ }
430
+
431
+ return { success: true, updatedLines };
432
+ } catch (uploadError: any) {
433
+ setIsAiWorking(false);
434
+ toast.error(uploadError.message || "Failed to save changes");
435
+ return { error: "upload_error", message: uploadError.message };
436
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  }
 
438
 
439
+ const chunk = decoder.decode(value, { stream: true });
440
+ contentResponse += chunk;
441
+
442
+ // Check for error responses during streaming
443
+ const trimmedResponse = contentResponse.trim();
444
+ if (trimmedResponse.startsWith("{") && trimmedResponse.endsWith("}")) {
445
+ try {
446
+ const jsonResponse = JSON.parse(trimmedResponse);
447
+ if (jsonResponse && !jsonResponse.ok) {
448
+ setIsAiWorking(false);
449
+ if (jsonResponse.openLogin) {
450
+ return { error: "login_required" };
451
+ } else if (jsonResponse.openSelectProvider) {
452
+ return { error: "provider_required", message: jsonResponse.message };
453
+ } else if (jsonResponse.openProModal) {
454
+ return { error: "pro_required" };
455
+ } else {
456
+ toast.error(jsonResponse.message);
457
+ return { error: "api_error", message: jsonResponse.message };
458
+ }
459
+ }
460
+ } catch (e) {
461
+ // Not complete JSON yet, continue
462
  }
463
+ }
464
 
465
+ return read();
466
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
467
 
468
+ return await read();
469
  }
470
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
471
  } catch (error: any) {
472
  setIsAiWorking(false);
473
+ setIsThinking(false);
474
+ setController(null);
475
+
476
+ if (!abortController.signal.aborted) {
477
+ toast.error(error.message || "Network error occurred");
478
+ }
479
+
480
  if (error.openLogin) {
481
  return { error: "login_required" };
482
  }
 
524
  if (pages.length > 0) {
525
  setPages(pages);
526
  if (isStreaming) {
 
527
  const newPages = pages.filter(p =>
528
  !streamingPagesRef.current.has(p.path)
529
  );
 
533
  setCurrentPage(newPage.path);
534
  streamingPagesRef.current.add(newPage.path);
535
 
 
536
  if (newPage.path.endsWith('.html') && !newPage.path.includes('/components/')) {
537
  setPreviewPage(newPage.path);
538
  }
 
552
  const extractFileContent = (chunk: string, filePath: string): string => {
553
  if (!chunk) return "";
554
 
 
555
  let content = chunk.trim();
556
 
 
557
  if (filePath.endsWith('.css')) {
 
558
  const cssMatch = content.match(/```css\s*([\s\S]*?)\s*```/);
559
  if (cssMatch) {
560
  content = cssMatch[1];
561
  } else {
 
562
  content = content.replace(/^```css\s*/i, "");
563
  }
 
564
  return content.replace(/```/g, "").trim();
565
  } else if (filePath.endsWith('.js')) {
 
566
  const jsMatch = content.match(/```(?:javascript|js)\s*([\s\S]*?)\s*```/);
567
  if (jsMatch) {
568
  content = jsMatch[1];
569
  } else {
 
570
  content = content.replace(/^```(?:javascript|js)\s*/i, "");
571
  }
 
572
  return content.replace(/```/g, "").trim();
573
  } else {
 
574
  const htmlMatch = content.match(/```html\s*([\s\S]*?)\s*```/);
575
  if (htmlMatch) {
576
  content = htmlMatch[1];
577
  } else {
 
578
  content = content.replace(/^```html\s*/i, "");
 
579
  const doctypeMatch = content.match(/<!DOCTYPE html>[\s\S]*/);
580
  if (doctypeMatch) {
581
  content = doctypeMatch[0];
lib/format-ai-response.ts ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { Page } from "@/types";
3
+ import {
4
+ DIVIDER,
5
+ NEW_FILE_END,
6
+ NEW_FILE_START,
7
+ REPLACE_END,
8
+ SEARCH_START,
9
+ UPDATE_FILE_END,
10
+ UPDATE_FILE_START,
11
+ } from "./prompts";
12
+
13
+ /**
14
+ * Escape special regex characters in a string
15
+ */
16
+ export const escapeRegExp = (string: string): string => {
17
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
18
+ };
19
+
20
+ /**
21
+ * Create a flexible HTML regex that accounts for whitespace variations
22
+ */
23
+ export const createFlexibleHtmlRegex = (searchBlock: string): RegExp => {
24
+ let searchRegex = escapeRegExp(searchBlock)
25
+ .replace(/\s+/g, '\\s*')
26
+ .replace(/>\s*</g, '>\\s*<')
27
+ .replace(/\s*>/g, '\\s*>');
28
+
29
+ return new RegExp(searchRegex, 'g');
30
+ };
31
+
32
+ /**
33
+ * Process AI response chunk and apply updates to pages
34
+ * Returns updated pages and updated line numbers
35
+ */
36
+ export interface ProcessAiResponseResult {
37
+ updatedPages: Page[];
38
+ updatedLines: number[][];
39
+ }
40
+
41
+ export const processAiResponse = (
42
+ chunk: string,
43
+ pages: Page[]
44
+ ): ProcessAiResponseResult => {
45
+ const updatedLines: number[][] = [];
46
+ const updatedPages = [...pages];
47
+
48
+ // Process UPDATE_FILE blocks
49
+ const updateFileRegex = new RegExp(
50
+ `${UPDATE_FILE_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^\\s]+)\\s*${UPDATE_FILE_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)(?=${UPDATE_FILE_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|${NEW_FILE_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|$)`,
51
+ 'g'
52
+ );
53
+ let updateFileMatch;
54
+
55
+ while ((updateFileMatch = updateFileRegex.exec(chunk)) !== null) {
56
+ const [, filePath, fileContent] = updateFileMatch;
57
+
58
+ const pageIndex = updatedPages.findIndex(p => p.path === filePath);
59
+ if (pageIndex !== -1) {
60
+ let pageHtml = updatedPages[pageIndex].html;
61
+
62
+ let processedContent = fileContent;
63
+ const htmlMatch = fileContent.match(/```html\s*([\s\S]*?)\s*```/);
64
+ if (htmlMatch) {
65
+ processedContent = htmlMatch[1];
66
+ }
67
+ let position = 0;
68
+ let moreBlocks = true;
69
+
70
+ while (moreBlocks) {
71
+ const searchStartIndex = processedContent.indexOf(SEARCH_START, position);
72
+ if (searchStartIndex === -1) {
73
+ moreBlocks = false;
74
+ continue;
75
+ }
76
+
77
+ const dividerIndex = processedContent.indexOf(DIVIDER, searchStartIndex);
78
+ if (dividerIndex === -1) {
79
+ moreBlocks = false;
80
+ continue;
81
+ }
82
+
83
+ const replaceEndIndex = processedContent.indexOf(REPLACE_END, dividerIndex);
84
+ if (replaceEndIndex === -1) {
85
+ moreBlocks = false;
86
+ continue;
87
+ }
88
+
89
+ const searchBlock = processedContent.substring(
90
+ searchStartIndex + SEARCH_START.length,
91
+ dividerIndex
92
+ );
93
+ const replaceBlock = processedContent.substring(
94
+ dividerIndex + DIVIDER.length,
95
+ replaceEndIndex
96
+ );
97
+
98
+ if (searchBlock.trim() === "") {
99
+ pageHtml = `${replaceBlock}\n${pageHtml}`;
100
+ updatedLines.push([1, replaceBlock.split("\n").length]);
101
+ } else {
102
+ const regex = createFlexibleHtmlRegex(searchBlock);
103
+ const match = regex.exec(pageHtml);
104
+
105
+ if (match) {
106
+ const matchedText = match[0];
107
+ const beforeText = pageHtml.substring(0, match.index);
108
+ const startLineNumber = beforeText.split("\n").length;
109
+ const replaceLines = replaceBlock.split("\n").length;
110
+ const endLineNumber = startLineNumber + replaceLines - 1;
111
+
112
+ updatedLines.push([startLineNumber, endLineNumber]);
113
+ pageHtml = pageHtml.replace(matchedText, replaceBlock);
114
+ }
115
+ }
116
+
117
+ position = replaceEndIndex + REPLACE_END.length;
118
+ }
119
+
120
+ updatedPages[pageIndex].html = pageHtml;
121
+ }
122
+ }
123
+
124
+ // Process NEW_FILE blocks
125
+ const newFileRegex = new RegExp(
126
+ `${NEW_FILE_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^\\s]+)\\s*${NEW_FILE_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)(?=${UPDATE_FILE_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|${NEW_FILE_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|$)`,
127
+ 'g'
128
+ );
129
+ let newFileMatch;
130
+
131
+ while ((newFileMatch = newFileRegex.exec(chunk)) !== null) {
132
+ const [, filePath, fileContent] = newFileMatch;
133
+
134
+ let fileData = fileContent;
135
+ // Try to extract content from code blocks
136
+ const htmlMatch = fileContent.match(/```html\s*([\s\S]*?)\s*```/);
137
+ const cssMatch = fileContent.match(/```css\s*([\s\S]*?)\s*```/);
138
+ const jsMatch = fileContent.match(/```javascript\s*([\s\S]*?)\s*```/);
139
+
140
+ if (htmlMatch) {
141
+ fileData = htmlMatch[1];
142
+ } else if (cssMatch) {
143
+ fileData = cssMatch[1];
144
+ } else if (jsMatch) {
145
+ fileData = jsMatch[1];
146
+ }
147
+
148
+ const existingFileIndex = updatedPages.findIndex(p => p.path === filePath);
149
+
150
+ if (existingFileIndex !== -1) {
151
+ updatedPages[existingFileIndex] = {
152
+ path: filePath,
153
+ html: fileData.trim()
154
+ };
155
+ } else {
156
+ updatedPages.push({
157
+ path: filePath,
158
+ html: fileData.trim()
159
+ });
160
+ }
161
+ }
162
+
163
+ // Fallback: process SEARCH/REPLACE blocks without UPDATE_FILE wrapper (backward compatibility)
164
+ if (updatedPages.length === pages.length && !chunk.includes(UPDATE_FILE_START)) {
165
+ let position = 0;
166
+ let moreBlocks = true;
167
+ let newHtml = updatedPages[0]?.html || "";
168
+
169
+ while (moreBlocks) {
170
+ const searchStartIndex = chunk.indexOf(SEARCH_START, position);
171
+ if (searchStartIndex === -1) {
172
+ moreBlocks = false;
173
+ continue;
174
+ }
175
+
176
+ const dividerIndex = chunk.indexOf(DIVIDER, searchStartIndex);
177
+ if (dividerIndex === -1) {
178
+ moreBlocks = false;
179
+ continue;
180
+ }
181
+
182
+ const replaceEndIndex = chunk.indexOf(REPLACE_END, dividerIndex);
183
+ if (replaceEndIndex === -1) {
184
+ moreBlocks = false;
185
+ continue;
186
+ }
187
+
188
+ const searchBlock = chunk.substring(
189
+ searchStartIndex + SEARCH_START.length,
190
+ dividerIndex
191
+ );
192
+ const replaceBlock = chunk.substring(
193
+ dividerIndex + DIVIDER.length,
194
+ replaceEndIndex
195
+ );
196
+
197
+ if (searchBlock.trim() === "") {
198
+ newHtml = `${replaceBlock}\n${newHtml}`;
199
+ updatedLines.push([1, replaceBlock.split("\n").length]);
200
+ } else {
201
+ const regex = createFlexibleHtmlRegex(searchBlock);
202
+ const match = regex.exec(newHtml);
203
+
204
+ if (match) {
205
+ const matchedText = match[0];
206
+ const beforeText = newHtml.substring(0, match.index);
207
+ const startLineNumber = beforeText.split("\n").length;
208
+ const replaceLines = replaceBlock.split("\n").length;
209
+ const endLineNumber = startLineNumber + replaceLines - 1;
210
+
211
+ updatedLines.push([startLineNumber, endLineNumber]);
212
+ newHtml = newHtml.replace(matchedText, replaceBlock);
213
+ }
214
+ }
215
+
216
+ position = replaceEndIndex + REPLACE_END.length;
217
+ }
218
+
219
+ const mainPageIndex = updatedPages.findIndex(p => p.path === '/' || p.path === '/index' || p.path === 'index');
220
+ if (mainPageIndex !== -1) {
221
+ updatedPages[mainPageIndex].html = newHtml;
222
+ }
223
+ }
224
+
225
+ return { updatedPages, updatedLines };
226
+ };
227
+
228
+ /**
229
+ * Convert pages to File objects for upload to HuggingFace
230
+ */
231
+ export const pagesToFiles = (pages: Page[]): File[] => {
232
+ const files: File[] = [];
233
+ pages.forEach((page: Page) => {
234
+ let mimeType = "text/html";
235
+ if (page.path.endsWith(".css")) {
236
+ mimeType = "text/css";
237
+ } else if (page.path.endsWith(".js")) {
238
+ mimeType = "text/javascript";
239
+ } else if (page.path.endsWith(".json")) {
240
+ mimeType = "application/json";
241
+ }
242
+ const file = new File([page.html], page.path, { type: mimeType });
243
+ files.push(file);
244
+ });
245
+ return files;
246
+ };
247
+
248
+ /**
249
+ * Extract project name from AI response
250
+ */
251
+ export const extractProjectName = (chunk: string): string | null => {
252
+ const projectName = chunk.match(/<<<<<<< PROJECT_NAME_START\s*([\s\S]*?)\s*>>>>>>> PROJECT_NAME_END/)?.[1]?.trim();
253
+ return projectName || null;
254
+ };
255
+