victor HF Staff commited on
Commit
9432484
·
1 Parent(s): 91493ca

feat: Implement diff-based updates for follow-up AI requests

Browse files
Files changed (1) hide show
  1. server.js +328 -58
server.js CHANGED
@@ -6,6 +6,7 @@ import cookieParser from "cookie-parser";
6
  import { createRepo, uploadFiles, whoAmI } from "@huggingface/hub";
7
  import { InferenceClient } from "@huggingface/inference";
8
  import bodyParser from "body-parser";
 
9
 
10
  import checkUser from "./middlewares/checkUser.js";
11
 
@@ -23,10 +24,10 @@ const PORT = process.env.APP_PORT || 3000;
23
  const REDIRECT_URI =
24
  process.env.REDIRECT_URI || `http://localhost:${PORT}/auth/login`;
25
  const MODEL_ID = "deepseek-ai/DeepSeek-V3-0324";
26
- const MAX_REQUESTS_PER_IP = 4;
27
 
28
  app.use(cookieParser());
29
- app.use(bodyParser.json());
30
  app.use(express.static(path.join(__dirname, "dist")));
31
 
32
  app.get("/api/login", (_req, res) => {
@@ -173,6 +174,194 @@ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-
173
  }
174
  });
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  app.post("/api/ask-ai", async (req, res) => {
177
  const { prompt, html, previousPrompt } = req.body;
178
  if (!prompt) {
@@ -182,6 +371,8 @@ app.post("/api/ask-ai", async (req, res) => {
182
  });
183
  }
184
 
 
 
185
  const { hf_token } = req.cookies;
186
  let token = hf_token;
187
  const ip =
@@ -191,24 +382,81 @@ app.post("/api/ask-ai", async (req, res) => {
191
  req.ip ||
192
  "0.0.0.0";
193
 
 
194
  if (!hf_token) {
195
- // Rate limit requests from the same IP address, to prevent abuse, free is limited to 2 requests per IP
196
  ipAddresses.set(ip, (ipAddresses.get(ip) || 0) + 1);
197
  if (ipAddresses.get(ip) > MAX_REQUESTS_PER_IP) {
198
  return res.status(429).send({
199
  ok: false,
200
  openLogin: true,
201
- message: "Log In to continue using the service",
202
  });
203
  }
204
-
205
  token = process.env.DEFAULT_HF_TOKEN;
206
  }
207
 
208
- // Set up response headers for streaming
209
- res.setHeader("Content-Type", "text/plain");
210
- res.setHeader("Cache-Control", "no-cache");
211
- res.setHeader("Connection", "keep-alive");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
 
213
  const client = new InferenceClient(token);
214
  let completeResponse = "";
@@ -216,68 +464,90 @@ app.post("/api/ask-ai", async (req, res) => {
216
  try {
217
  const chatCompletion = client.chatCompletionStream({
218
  model: MODEL_ID,
219
- provider: "fireworks-ai",
220
- messages: [
221
- {
222
- role: "system",
223
- content:
224
- "ONLY USE HTML, CSS AND JAVASCRIPT. If you want to use ICON make sure to import the library first. Try to create the best UI possible by using only HTML, CSS and JAVASCRIPT. Also, try to ellaborate as much as you can, to create something unique. ALWAYS GIVE THE RESPONSE INTO A SINGLE HTML FILE",
225
- },
226
- ...(previousPrompt
227
- ? [
228
- {
229
- role: "user",
230
- content: previousPrompt,
231
- },
232
- ]
233
- : []),
234
- ...(html
235
- ? [
236
- {
237
- role: "assistant",
238
- content: `The current code is: ${html}.`,
239
- },
240
- ]
241
- : []),
242
- {
243
- role: "user",
244
- content: prompt,
245
- },
246
- ],
247
- max_tokens: 12_000,
248
  });
249
 
250
- while (true) {
251
- const { done, value } = await chatCompletion.next();
252
- if (done) {
253
- break;
 
 
 
 
 
 
 
 
 
254
  }
255
- const chunk = value.choices[0]?.delta?.content;
256
- if (chunk) {
257
- res.write(chunk);
258
- completeResponse += chunk;
259
-
260
- // Break when HTML is complete
261
- if (completeResponse.includes("</html>")) {
262
- break;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  }
264
  }
 
 
 
 
 
 
 
 
265
  }
266
 
267
- // End the response stream
268
- res.end();
269
  } catch (error) {
270
- console.error("Error:", error);
271
- // If we haven't sent a response yet, send an error
272
  if (!res.headersSent) {
 
 
 
 
 
 
273
  res.status(500).send({
274
  ok: false,
275
- message: `You probably reached the MAX_TOKENS limit, context is too long. You can start a new conversation by refreshing the page.`,
 
276
  });
277
- } else {
278
- // Otherwise end the stream
279
- res.end();
 
280
  }
 
 
281
  }
282
  });
283
 
 
6
  import { createRepo, uploadFiles, whoAmI } from "@huggingface/hub";
7
  import { InferenceClient } from "@huggingface/inference";
8
  import bodyParser from "body-parser";
9
+ import { diff_match_patch } from 'diff-match-patch'; // Using a library for robustness
10
 
11
  import checkUser from "./middlewares/checkUser.js";
12
 
 
24
  const REDIRECT_URI =
25
  process.env.REDIRECT_URI || `http://localhost:${PORT}/auth/login`;
26
  const MODEL_ID = "deepseek-ai/DeepSeek-V3-0324";
27
+ const MAX_REQUESTS_PER_IP = 4; // Increased limit for testing diffs
28
 
29
  app.use(cookieParser());
30
+ app.use(bodyParser.json({ limit: '10mb' })); // Increase limit if HTML gets large
31
  app.use(express.static(path.join(__dirname, "dist")));
32
 
33
  app.get("/api/login", (_req, res) => {
 
174
  }
175
  });
176
 
177
+
178
+ // --- Diff Parsing and Applying Logic ---
179
+
180
+ const SEARCH_START = "<<<<<<< SEARCH";
181
+ const DIVIDER = "=======";
182
+ const REPLACE_END = ">>>>>>> REPLACE";
183
+
184
+ /**
185
+ * Parses AI response content for SEARCH/REPLACE blocks.
186
+ * @param {string} content - The AI response content.
187
+ * @returns {Array<{original: string, updated: string}>} - Array of diff blocks.
188
+ */
189
+ function parseDiffBlocks(content) {
190
+ const blocks = [];
191
+ const lines = content.split('\n');
192
+ let i = 0;
193
+ while (i < lines.length) {
194
+ // Trim lines for comparison to handle potential trailing whitespace from AI
195
+ if (lines[i].trim() === SEARCH_START) {
196
+ const originalLines = [];
197
+ const updatedLines = [];
198
+ i++; // Move past SEARCH_START
199
+ while (i < lines.length && lines[i].trim() !== DIVIDER) {
200
+ originalLines.push(lines[i]);
201
+ i++;
202
+ }
203
+ if (i >= lines.length || lines[i].trim() !== DIVIDER) {
204
+ console.warn("Malformed diff block: Missing or misplaced '=======' after SEARCH block. Block content:", originalLines.join('\n'));
205
+ // Skip to next potential block start or end
206
+ while (i < lines.length && !lines[i].includes(SEARCH_START)) i++;
207
+ continue;
208
+ }
209
+ i++; // Move past DIVIDER
210
+ while (i < lines.length && lines[i].trim() !== REPLACE_END) {
211
+ updatedLines.push(lines[i]);
212
+ i++;
213
+ }
214
+ if (i >= lines.length || lines[i].trim() !== REPLACE_END) {
215
+ console.warn("Malformed diff block: Missing or misplaced '>>>>>>> REPLACE' after REPLACE block. Block content:", updatedLines.join('\n'));
216
+ // Skip to next potential block start or end
217
+ while (i < lines.length && !lines[i].includes(SEARCH_START)) i++;
218
+ continue;
219
+ }
220
+ // Important: Re-add newline characters lost during split('\n')
221
+ // Only add trailing newline if it wasn't the *very last* line of the block content before split
222
+ const originalText = originalLines.join('\n');
223
+ const updatedText = updatedLines.join('\n');
224
+
225
+ blocks.push({
226
+ original: originalText, // Don't add trailing newline here, handle in apply
227
+ updated: updatedText
228
+ });
229
+ }
230
+ i++;
231
+ }
232
+ return blocks;
233
+ }
234
+
235
+
236
+ /**
237
+ * Applies a single diff block to the current HTML content using diff-match-patch.
238
+ * @param {string} currentHtml - The current HTML content.
239
+ * @param {string} originalBlock - The content from the SEARCH block.
240
+ * @param {string} updatedBlock - The content from the REPLACE block.
241
+ * @returns {string | null} - The updated HTML content, or null if patching failed.
242
+ */
243
+ function applySingleDiffFuzzy(currentHtml, originalBlock, updatedBlock) {
244
+ const dmp = new diff_match_patch();
245
+
246
+ // Handle potential trailing newline inconsistencies between AI and actual file
247
+ // If originalBlock doesn't end with newline but exists in currentHtml *with* one, add it.
248
+ let searchBlock = originalBlock;
249
+ if (!originalBlock.endsWith('\n') && currentHtml.includes(originalBlock + '\n')) {
250
+ searchBlock = originalBlock + '\n';
251
+ }
252
+ // If updatedBlock is meant to replace a block ending in newline, ensure it also does (unless empty)
253
+ let replaceBlock = updatedBlock;
254
+ if (searchBlock.endsWith('\n') && updatedBlock.length > 0 && !updatedBlock.endsWith('\n')) {
255
+ replaceBlock = updatedBlock + '\n';
256
+ }
257
+ // If deleting a block ending in newline, the replacement is empty
258
+ if (searchBlock.endsWith('\n') && updatedBlock.length === 0) {
259
+ replaceBlock = "";
260
+ }
261
+
262
+
263
+ // 1. Create a patch from the (potentially adjusted) original and updated blocks
264
+ const patchText = dmp.patch_make(searchBlock, replaceBlock);
265
+
266
+ // 2. Apply the patch to the current HTML
267
+ // diff-match-patch is good at finding the location even with slight context variations.
268
+ // Increase Match_Threshold for potentially larger files or more significant context drift.
269
+ dmp.Match_Threshold = 0.6; // Adjust as needed (0.0 to 1.0)
270
+ dmp.Patch_DeleteThreshold = 0.6; // Adjust as needed
271
+ const [patchedHtml, results] = dmp.patch_apply(patchText, currentHtml);
272
+
273
+ // 3. Check if the patch applied successfully
274
+ if (results.every(result => result === true)) {
275
+ return patchedHtml;
276
+ } else {
277
+ console.warn("Patch application failed using diff-match-patch. Results:", results);
278
+ // Fallback: Try exact string replacement (less robust)
279
+ if (currentHtml.includes(searchBlock)) {
280
+ console.log("Falling back to direct string replacement.");
281
+ // Use replace only once
282
+ const index = currentHtml.indexOf(searchBlock);
283
+ if (index !== -1) {
284
+ return currentHtml.substring(0, index) + replaceBlock + currentHtml.substring(index + searchBlock.length);
285
+ }
286
+ }
287
+ console.error("Direct string replacement fallback also failed.");
288
+ return null; // Indicate failure
289
+ }
290
+ }
291
+
292
+
293
+ /**
294
+ * Applies all parsed diff blocks sequentially to the original HTML.
295
+ * @param {string} originalHtml - The initial HTML content.
296
+ * @param {string} aiResponseContent - The full response from the AI containing diff blocks.
297
+ * @returns {string} - The final modified HTML.
298
+ * @throws {Error} If any diff block fails to apply.
299
+ */
300
+ function applyDiffs(originalHtml, aiResponseContent) {
301
+ const diffBlocks = parseDiffBlocks(aiResponseContent);
302
+
303
+ if (diffBlocks.length === 0) {
304
+ console.warn("AI response did not contain valid SEARCH/REPLACE blocks.");
305
+ // Check if the AI *tried* to use the format but failed, or just gave full code
306
+ if (aiResponseContent.includes(SEARCH_START) || aiResponseContent.includes(DIVIDER) || aiResponseContent.includes(REPLACE_END)) {
307
+ throw new Error("AI response contained malformed or unparseable diff blocks. Could not apply changes.");
308
+ }
309
+ // If no diff blocks *at all*, maybe the AI ignored the instruction and gave full code?
310
+ // Heuristic: If the response looks like a full HTML doc, use it directly.
311
+ const trimmedResponse = aiResponseContent.trim().toLowerCase();
312
+ if (trimmedResponse.startsWith('<!doctype html') || trimmedResponse.startsWith('<html')) {
313
+ console.warn("AI response seems to be full HTML despite diff instructions. Using full response.");
314
+ return aiResponseContent;
315
+ }
316
+ console.warn("No diff blocks found and response doesn't look like full HTML. Returning original HTML.");
317
+ return originalHtml; // Return original if no diffs and not full HTML
318
+ }
319
+
320
+ console.log(`Found ${diffBlocks.length} diff blocks to apply.`);
321
+ let currentHtml = originalHtml;
322
+ for (let i = 0; i < diffBlocks.length; i++) {
323
+ const { original, updated } = diffBlocks[i];
324
+ console.log(`Applying block ${i + 1}...`);
325
+ const result = applySingleDiffFuzzy(currentHtml, original, updated);
326
+
327
+ if (result === null) {
328
+ // Log detailed error for debugging
329
+ console.error(`Failed to apply diff block ${i + 1}:`);
330
+ console.error("--- SEARCH ---");
331
+ console.error(original);
332
+ console.error("--- REPLACE ---");
333
+ console.error(updated);
334
+ console.error("--- CURRENT CONTEXT (approx) ---");
335
+ // Try finding the first line of the original block for context
336
+ const firstLine = original.split('\n')[0];
337
+ let contextIndex = -1;
338
+ if (firstLine) {
339
+ contextIndex = currentHtml.indexOf(firstLine);
340
+ }
341
+ if (contextIndex === -1) { // If first line not found, maybe try middle line?
342
+ const lines = original.split('\n');
343
+ if (lines.length > 2) {
344
+ contextIndex = currentHtml.indexOf(lines[Math.floor(lines.length / 2)]);
345
+ }
346
+ }
347
+ if (contextIndex === -1) { // Still not found, just show start
348
+ contextIndex = 0;
349
+ }
350
+
351
+ console.error(currentHtml.substring(Math.max(0, contextIndex - 150), Math.min(currentHtml.length, contextIndex + original.length + 300)));
352
+ console.error("---------------------------------");
353
+
354
+ throw new Error(`Failed to apply AI-suggested change ${i + 1}. The 'SEARCH' block might not accurately match the current code.`);
355
+ }
356
+ currentHtml = result;
357
+ }
358
+
359
+ console.log("All diff blocks applied successfully.");
360
+ return currentHtml;
361
+ }
362
+
363
+
364
+ // --- AI Interaction Route ---
365
  app.post("/api/ask-ai", async (req, res) => {
366
  const { prompt, html, previousPrompt } = req.body;
367
  if (!prompt) {
 
371
  });
372
  }
373
 
374
+ const isFollowUp = !!html && !!previousPrompt; // Check if it's a follow-up request
375
+
376
  const { hf_token } = req.cookies;
377
  let token = hf_token;
378
  const ip =
 
382
  req.ip ||
383
  "0.0.0.0";
384
 
385
+ // --- Rate Limiting (Unchanged) ---
386
  if (!hf_token) {
 
387
  ipAddresses.set(ip, (ipAddresses.get(ip) || 0) + 1);
388
  if (ipAddresses.get(ip) > MAX_REQUESTS_PER_IP) {
389
  return res.status(429).send({
390
  ok: false,
391
  openLogin: true,
392
+ message: "Log In to continue using the service (Rate limit exceeded for anonymous users)",
393
  });
394
  }
 
395
  token = process.env.DEFAULT_HF_TOKEN;
396
  }
397
 
398
+ // --- Define System Prompts ---
399
+ const initialSystemPrompt = `ONLY USE HTML, CSS AND JAVASCRIPT. If you want to use ICON make sure to import the library first. Try to create the best UI possible by using only HTML, CSS and JAVASCRIPT. Also, try to ellaborate as much as you can, to create something unique. ALWAYS GIVE THE RESPONSE INTO A SINGLE HTML FILE.`;
400
+
401
+ const followUpSystemPrompt = `You are an expert web developer modifying an existing HTML file.
402
+ The user wants to apply changes based on their request.
403
+ You MUST output ONLY the changes required using the following SEARCH/REPLACE block format. Do NOT output the entire file.
404
+ Explain the changes briefly *before* the blocks if necessary, but the code changes THEMSELVES MUST be within the blocks.
405
+
406
+ Format Rules:
407
+ 1. Start with ${SEARCH_START}
408
+ 2. Provide the exact lines from the current code that need to be replaced.
409
+ 3. Use ${DIVIDER} to separate the search block from the replacement.
410
+ 4. Provide the new lines that should replace the original lines.
411
+ 5. End with ${REPLACE_END}
412
+ 6. You can use multiple SEARCH/REPLACE blocks if changes are needed in different parts of the file.
413
+ 7. To insert code, use an empty SEARCH block (only ${SEARCH_START} and ${DIVIDER} on their lines) if inserting at the very beginning, otherwise provide the line *before* the insertion point in the SEARCH block and include that line plus the new lines in the REPLACE block.
414
+ 8. To delete code, provide the lines to delete in the SEARCH block and leave the REPLACE block empty (only ${DIVIDER} and ${REPLACE_END} on their lines).
415
+ 9. IMPORTANT: The SEARCH block must *exactly* match the current code, including indentation and whitespace.
416
+
417
+ Example Modifying Code:
418
+ \`\`\`
419
+ Some explanation...
420
+ ${SEARCH_START}
421
+ <h1>Old Title</h1>
422
+ ${DIVIDER}
423
+ <h1>New Title</h1>
424
+ ${REPLACE_END}
425
+
426
+ ${SEARCH_START}
427
+ </body>
428
+ ${DIVIDER}
429
+ <script>console.log("Added script");</script>
430
+ </body>
431
+ ${REPLACE_END}
432
+ \`\`\`
433
+
434
+ Example Deleting Code:
435
+ \`\`\`
436
+ Removing the paragraph...
437
+ ${SEARCH_START}
438
+ <p>This paragraph will be deleted.</p>
439
+ ${DIVIDER}
440
+
441
+ ${REPLACE_END}
442
+ \`\`\`
443
+
444
+ ONLY output the changes in this format. Do NOT output the full HTML file again.`;
445
+
446
+ // --- Prepare Messages for AI ---
447
+ const messages = [
448
+ {
449
+ role: "system",
450
+ content: isFollowUp ? followUpSystemPrompt : initialSystemPrompt,
451
+ },
452
+ // Include previous context if available
453
+ ...(previousPrompt ? [{ role: "user", content: previousPrompt }] : []),
454
+ // Provide current code clearly ONLY if it's a follow-up
455
+ ...(isFollowUp && html ? [{ role: "assistant", content: `Okay, I have the current code. It is:\n\`\`\`html\n${html}\n\`\`\`` }] : []),
456
+ // Current user prompt
457
+ { role: "user", content: prompt },
458
+ ];
459
+
460
 
461
  const client = new InferenceClient(token);
462
  let completeResponse = "";
 
464
  try {
465
  const chatCompletion = client.chatCompletionStream({
466
  model: MODEL_ID,
467
+ provider: "fireworks-ai", // Ensure provider is correct if needed
468
+ messages: messages,
469
+ max_tokens: 12_000, // Keep max_tokens reasonable
470
+ // temperature: 0.7, // Adjust temperature if needed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  });
472
 
473
+ // --- Conditional Response Handling ---
474
+ if (isFollowUp) {
475
+ // **Accumulate response, then parse and apply diffs**
476
+ for await (const value of chatCompletion) {
477
+ const chunk = value.choices[0]?.delta?.content;
478
+ if (chunk) {
479
+ completeResponse += chunk;
480
+ }
481
+ // Optional: Add a timeout or check response length to prevent infinite loops
482
+ if (completeResponse.length > 50000) { // Example limit
483
+ console.error("AI response exceeded length limit during accumulation.");
484
+ throw new Error("AI response too long during accumulation.");
485
+ }
486
  }
487
+
488
+ // Check if the response seems truncated (didn't finish properly)
489
+ // This is heuristic - might need refinement
490
+ if (!chatCompletion?.controller?.signal?.aborted && !completeResponse.trim()) {
491
+ console.warn("AI stream finished but response is empty.");
492
+ // Return original HTML as maybe no changes were needed or AI failed silently.
493
+ return res.status(200).type('text/html').send(html);
494
+ }
495
+
496
+
497
+ console.log("--- AI Raw Diff Response ---");
498
+ console.log(completeResponse);
499
+ console.log("--------------------------");
500
+
501
+
502
+ // Apply the diffs
503
+ const modifiedHtml = applyDiffs(html, completeResponse);
504
+ res.status(200).type('text/html').send(modifiedHtml); // Send the fully modified HTML
505
+
506
+ } else {
507
+ // **Stream response directly (Initial Request)**
508
+ res.setHeader("Content-Type", "text/html"); // Send as HTML
509
+ res.setHeader("Cache-Control", "no-cache");
510
+ res.setHeader("Connection", "keep-alive");
511
+
512
+ for await (const value of chatCompletion) {
513
+ const chunk = value.choices[0]?.delta?.content;
514
+ if (chunk) {
515
+ res.write(chunk);
516
+ completeResponse += chunk; // Still useful for checking completion
517
  }
518
  }
519
+
520
+ // Basic check if the streamed response looks like HTML
521
+ if (!completeResponse.trim().toLowerCase().includes("</html>")) {
522
+ console.warn("Streamed response might be incomplete or not valid HTML.");
523
+ // Client side might handle this, but good to log.
524
+ }
525
+
526
+ res.end(); // End the stream
527
  }
528
 
 
 
529
  } catch (error) {
530
+ console.error("Error during AI interaction or diff application:", error);
531
+ // If we haven't sent a response yet (likely in diff mode or before stream start)
532
  if (!res.headersSent) {
533
+ // Check if it's an AbortError which might happen if the client disconnects
534
+ if (error.name === 'AbortError') {
535
+ console.warn('Client disconnected before AI response finished.');
536
+ // Don't send another response if client is gone
537
+ return;
538
+ }
539
  res.status(500).send({
540
  ok: false,
541
+ // Provide a more user-friendly message, but keep details for logs
542
+ message: `Error processing AI request: ${error.message}. You might need to start a new conversation by refreshing the page.`,
543
  });
544
+ } else if (!isFollowUp && !res.writableEnded) {
545
+ // If streaming failed mid-stream and stream hasn't been ended yet
546
+ console.error("Error occurred mid-stream.");
547
+ res.end(); // End the stream abruptly if error occurs during streaming
548
  }
549
+ // If diff application failed, error was already sent by applyDiffs throwing.
550
+ // If streaming failed *after* res.end() was called (unlikely but possible), do nothing more.
551
  }
552
  });
553