victor HF Staff commited on
Commit
8761cb3
·
1 Parent(s): 96a764f

feat: Handle streamed diffs and apply to Monaco editor

Browse files
Files changed (1) hide show
  1. src/components/ask-ai/ask-ai.tsx +214 -40
src/components/ask-ai/ask-ai.tsx CHANGED
@@ -1,39 +1,182 @@
1
- import { useState } from "react";
2
  import { RiSparkling2Fill } from "react-icons/ri";
3
  import { GrSend } from "react-icons/gr";
4
  import classNames from "classnames";
5
  import { toast } from "react-toastify";
 
6
 
7
  import Login from "../login/login";
8
  import { defaultHTML } from "../../utils/consts";
9
  import SuccessSound from "./../../assets/success.mp3";
10
 
11
  function AskAI({
12
- html,
13
- setHtml,
14
- onScrollToBottom,
15
  isAiWorking,
16
  setisAiWorking,
 
17
  }: {
18
  html: string;
19
  setHtml: (html: string) => void;
20
  onScrollToBottom: () => void;
21
  isAiWorking: boolean;
22
  setisAiWorking: React.Dispatch<React.SetStateAction<boolean>>;
 
23
  }) {
24
  const [open, setOpen] = useState(false);
25
  const [prompt, setPrompt] = useState("");
26
  const [hasAsked, setHasAsked] = useState(false);
27
  const [previousPrompt, setPreviousPrompt] = useState("");
 
28
  const audio = new Audio(SuccessSound);
29
  audio.volume = 0.5;
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  const callAi = async () => {
32
  if (isAiWorking || !prompt.trim()) return;
33
  setisAiWorking(true);
 
 
 
 
 
34
 
35
- let contentResponse = "";
36
- let lastRenderTime = 0;
37
  try {
38
  const request = await fetch("/api/ask-ai", {
39
  method: "POST",
@@ -58,58 +201,89 @@ function AskAI({
58
  setisAiWorking(false);
59
  return;
60
  }
 
 
 
 
61
  const reader = request.body.getReader();
62
  const decoder = new TextDecoder("utf-8");
63
 
64
- const read = async () => {
 
65
  const { done, value } = await reader.read();
66
  if (done) {
67
- toast.success("AI responded successfully");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  setPrompt("");
69
  setPreviousPrompt(prompt);
70
  setisAiWorking(false);
71
  setHasAsked(true);
72
  audio.play();
73
-
74
- // Now we have the complete HTML including </html>, so set it to be sure
75
- const finalDoc = contentResponse.match(
76
- /<!DOCTYPE html>[\s\S]*<\/html>/
77
- )?.[0];
78
- if (finalDoc) {
79
- setHtml(finalDoc);
80
- }
81
-
82
- return;
83
  }
84
 
85
  const chunk = decoder.decode(value, { stream: true });
86
- contentResponse += chunk;
87
- const newHtml = contentResponse.match(/<!DOCTYPE html>[\s\S]*/)?.[0];
88
- if (newHtml) {
89
- // Force-close the HTML tag so the iframe doesn't render half-finished markup
90
- let partialDoc = newHtml;
91
- if (!partialDoc.includes("</html>")) {
92
- partialDoc += "\n</html>";
93
- }
94
 
95
- // Throttle the re-renders to avoid flashing/flicker
96
- const now = Date.now();
97
- if (now - lastRenderTime > 300) {
98
- setHtml(partialDoc);
99
- lastRenderTime = now;
100
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
- if (partialDoc.length > 200) {
103
- onScrollToBottom();
 
 
104
  }
105
  }
106
- read();
107
- };
108
-
109
- read();
110
  }
111
-
112
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
113
  } catch (error: any) {
114
  setisAiWorking(false);
115
  toast.error(error.message);
 
1
+ import { useState, useRef } from "react"; // Import useRef
2
  import { RiSparkling2Fill } from "react-icons/ri";
3
  import { GrSend } from "react-icons/gr";
4
  import classNames from "classnames";
5
  import { toast } from "react-toastify";
6
+ import { editor } from "monaco-editor"; // Import editor type
7
 
8
  import Login from "../login/login";
9
  import { defaultHTML } from "../../utils/consts";
10
  import SuccessSound from "./../../assets/success.mp3";
11
 
12
  function AskAI({
13
+ html, // Current full HTML content (used for initial request and context)
14
+ setHtml, // Used only for full updates now
15
+ onScrollToBottom, // Used for full updates
16
  isAiWorking,
17
  setisAiWorking,
18
+ editorRef, // Pass the editor instance ref
19
  }: {
20
  html: string;
21
  setHtml: (html: string) => void;
22
  onScrollToBottom: () => void;
23
  isAiWorking: boolean;
24
  setisAiWorking: React.Dispatch<React.SetStateAction<boolean>>;
25
+ editorRef: React.RefObject<editor.IStandaloneCodeEditor | null>; // Add editorRef prop
26
  }) {
27
  const [open, setOpen] = useState(false);
28
  const [prompt, setPrompt] = useState("");
29
  const [hasAsked, setHasAsked] = useState(false);
30
  const [previousPrompt, setPreviousPrompt] = useState("");
31
+ const [diffBuffer, setDiffBuffer] = useState(""); // Buffer for accumulating diff chunks
32
  const audio = new Audio(SuccessSound);
33
  audio.volume = 0.5;
34
 
35
+ // --- Diff Constants ---
36
+ const SEARCH_START = "<<<<<<< SEARCH";
37
+ const DIVIDER = "=======";
38
+ const REPLACE_END = ">>>>>>> REPLACE";
39
+
40
+ // --- Diff Applying Logic ---
41
+
42
+ /**
43
+ * Applies a single parsed diff block to the Monaco editor.
44
+ */
45
+ const applyMonacoDiff = (
46
+ original: string,
47
+ updated: string,
48
+ editorInstance: editor.IStandaloneCodeEditor
49
+ ) => {
50
+ const model = editorInstance.getModel();
51
+ if (!model) {
52
+ console.error("Monaco model not available for applying diff.");
53
+ toast.error("Editor model not found, cannot apply change.");
54
+ return false; // Indicate failure
55
+ }
56
+
57
+ // Monaco's findMatches can be sensitive. Let's try a simple search first.
58
+ // We need to be careful about potential regex characters in the original block.
59
+ // Escape basic regex characters for the search string.
60
+ const escapedOriginal = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
61
+
62
+ // Find the first occurrence. Might need more robust logic for multiple identical blocks.
63
+ const matches = model.findMatches(
64
+ escapedOriginal,
65
+ false, // isRegex
66
+ false, // matchCase
67
+ false, // wordSeparators
68
+ null, // searchScope
69
+ true, // captureMatches
70
+ 1 // limitResultCount
71
+ );
72
+
73
+ if (matches.length > 0) {
74
+ const range = matches[0].range;
75
+ const editOperation = {
76
+ range: range,
77
+ text: updated,
78
+ forceMoveMarkers: true,
79
+ };
80
+
81
+ try {
82
+ // Use pushEditOperations for better undo/redo integration if needed,
83
+ // but executeEdits is simpler for direct replacement.
84
+ editorInstance.executeEdits("ai-diff-apply", [editOperation]);
85
+ // Scroll to the change
86
+ editorInstance.revealRangeInCenter(range, editor.ScrollType.Smooth);
87
+ console.log("[Diff Apply] Applied block:", { original, updated });
88
+ return true; // Indicate success
89
+ } catch (editError) {
90
+ console.error("Error applying edit operation:", editError);
91
+ toast.error(`Failed to apply change: ${editError}`);
92
+ return false; // Indicate failure
93
+ }
94
+ } else {
95
+ console.warn("Could not find SEARCH block in editor:", original);
96
+ // Attempt fuzzy match (simple whitespace normalization) as fallback
97
+ const normalizedOriginal = original.replace(/\s+/g, ' ').trim();
98
+ const editorContent = model.getValue();
99
+ const normalizedContent = editorContent.replace(/\s+/g, ' ').trim();
100
+ const startIndex = normalizedContent.indexOf(normalizedOriginal);
101
+
102
+ if (startIndex !== -1) {
103
+ console.warn("Applying diff using fuzzy whitespace match.");
104
+ // This is tricky - need to map normalized index back to original positions
105
+ // For now, let's just log and skip applying this specific block
106
+ toast.warn("Could not precisely locate change, skipping one diff block.");
107
+ // TODO: Implement more robust fuzzy matching if needed
108
+ } else {
109
+ toast.error("Could not locate the code block to change. AI might be referencing outdated code.");
110
+ }
111
+ return false; // Indicate failure
112
+ }
113
+ };
114
+
115
+ /**
116
+ * Processes the accumulated diff buffer, parsing and applying complete blocks.
117
+ */
118
+ const processDiffBuffer = (
119
+ currentBuffer: string,
120
+ editorInstance: editor.IStandaloneCodeEditor | null
121
+ ): string => {
122
+ if (!editorInstance) return currentBuffer; // Don't process if editor isn't ready
123
+
124
+ let remainingBuffer = currentBuffer;
125
+ let appliedSuccess = true;
126
+
127
+ // eslint-disable-next-line no-constant-condition
128
+ while (true) {
129
+ const searchStartIndex = remainingBuffer.indexOf(SEARCH_START);
130
+ if (searchStartIndex === -1) break; // No more potential blocks
131
+
132
+ const dividerIndex = remainingBuffer.indexOf(DIVIDER, searchStartIndex);
133
+ if (dividerIndex === -1) break; // Incomplete block
134
+
135
+ const replaceEndIndex = remainingBuffer.indexOf(REPLACE_END, dividerIndex);
136
+ if (replaceEndIndex === -1) break; // Incomplete block
137
+
138
+ // Extract the block content
139
+ const originalBlockContent = remainingBuffer
140
+ .substring(searchStartIndex + SEARCH_START.length, dividerIndex)
141
+ .trimEnd(); // Trim potential trailing newline before divider
142
+ const updatedBlockContent = remainingBuffer
143
+ .substring(dividerIndex + DIVIDER.length, replaceEndIndex)
144
+ .trimEnd(); // Trim potential trailing newline before end marker
145
+
146
+ // Adjust for newlines potentially trimmed by .trimEnd() if they were intended
147
+ const original = originalBlockContent.startsWith('\n') ? originalBlockContent.substring(1) : originalBlockContent;
148
+ const updated = updatedBlockContent.startsWith('\n') ? updatedBlockContent.substring(1) : updatedBlockContent;
149
+
150
+
151
+ console.log("[Diff Parse] Found block:", { original, updated });
152
+
153
+ // Apply the diff
154
+ appliedSuccess = applyMonacoDiff(original, updated, editorInstance) && appliedSuccess;
155
+
156
+ // Remove the processed block from the buffer
157
+ remainingBuffer = remainingBuffer.substring(replaceEndIndex + REPLACE_END.length);
158
+ }
159
+
160
+ if (!appliedSuccess) {
161
+ // If any block failed, maybe stop processing further blocks in this stream?
162
+ // Or just let it continue and report errors per block? Let's continue for now.
163
+ console.warn("One or more diff blocks failed to apply.");
164
+ }
165
+
166
+ return remainingBuffer; // Return the part of the buffer that couldn't be processed yet
167
+ };
168
+
169
+
170
+ // --- Main AI Call Logic ---
171
  const callAi = async () => {
172
  if (isAiWorking || !prompt.trim()) return;
173
  setisAiWorking(true);
174
+ setDiffBuffer(""); // Clear buffer for new request
175
+
176
+ let fullContentResponse = ""; // Used for full HTML mode
177
+ let lastRenderTime = 0; // For throttling full HTML updates
178
+ let currentDiffBuffer = ""; // Local variable for buffer within this call
179
 
 
 
180
  try {
181
  const request = await fetch("/api/ask-ai", {
182
  method: "POST",
 
201
  setisAiWorking(false);
202
  return;
203
  }
204
+
205
+ const responseType = request.headers.get("X-Response-Type") || "full"; // Default to full if header missing
206
+ console.log(`[AI Response] Type: ${responseType}`);
207
+
208
  const reader = request.body.getReader();
209
  const decoder = new TextDecoder("utf-8");
210
 
211
+ // eslint-disable-next-line no-constant-condition
212
+ while (true) {
213
  const { done, value } = await reader.read();
214
  if (done) {
215
+ console.log("[AI Response] Stream finished.");
216
+ // Process any remaining buffer content in diff mode
217
+ if (responseType === 'diff' && currentDiffBuffer.trim()) {
218
+ console.warn("[AI Response] Processing remaining diff buffer after stream end:", currentDiffBuffer);
219
+ const finalRemaining = processDiffBuffer(currentDiffBuffer, editorRef.current);
220
+ if (finalRemaining.trim()) {
221
+ console.error("[AI Response] Stream ended with incomplete diff block:", finalRemaining);
222
+ toast.error("AI response ended with an incomplete change block.");
223
+ }
224
+ setDiffBuffer(""); // Clear state buffer
225
+ }
226
+ // Final update for full HTML mode
227
+ if (responseType === 'full') {
228
+ const finalDoc = fullContentResponse.match(/<!DOCTYPE html>[\s\S]*<\/html>/)?.[0];
229
+ if (finalDoc) {
230
+ setHtml(finalDoc); // Ensure final complete HTML is set
231
+ } else if (fullContentResponse.trim()) {
232
+ // If we got content but it doesn't look like HTML, maybe it's an error message or explanation?
233
+ console.warn("[AI Response] Final response doesn't look like HTML:", fullContentResponse);
234
+ // Decide if we should show this to the user? Maybe a toast?
235
+ // For now, let's assume the throttled updates were sufficient or it wasn't HTML.
236
+ }
237
+ }
238
+
239
+ toast.success("AI processing complete");
240
  setPrompt("");
241
  setPreviousPrompt(prompt);
242
  setisAiWorking(false);
243
  setHasAsked(true);
244
  audio.play();
245
+ break; // Exit the loop
 
 
 
 
 
 
 
 
 
246
  }
247
 
248
  const chunk = decoder.decode(value, { stream: true });
 
 
 
 
 
 
 
 
249
 
250
+ if (responseType === 'diff') {
251
+ // --- Diff Mode ---
252
+ currentDiffBuffer += chunk;
253
+ const remaining = processDiffBuffer(currentDiffBuffer, editorRef.current);
254
+ currentDiffBuffer = remaining; // Update local buffer with unprocessed part
255
+ setDiffBuffer(currentDiffBuffer); // Update state for potential display/debugging
256
+ } else {
257
+ // --- Full HTML Mode ---
258
+ fullContentResponse += chunk;
259
+ // Use regex to find the start of the HTML doc
260
+ const newHtmlMatch = fullContentResponse.match(/<!DOCTYPE html>[\s\S]*/);
261
+ const newHtml = newHtmlMatch ? newHtmlMatch[0] : null;
262
+
263
+ if (newHtml) {
264
+ // Throttle the re-renders to avoid flashing/flicker
265
+ const now = Date.now();
266
+ if (now - lastRenderTime > 300) {
267
+ // Force-close the HTML tag for preview if needed
268
+ let partialDoc = newHtml;
269
+ if (!partialDoc.trim().endsWith("</html>")) {
270
+ partialDoc += "\n</html>";
271
+ }
272
+ setHtml(partialDoc); // Update the preview iframe content
273
+ lastRenderTime = now;
274
+ }
275
 
276
+ // Scroll editor down if content is long (heuristic)
277
+ if (newHtml.length > 200 && now - lastRenderTime < 50) { // Only scroll if recently rendered
278
+ onScrollToBottom();
279
+ }
280
  }
281
  }
282
+ } // end while loop
283
+ } else {
284
+ throw new Error("Response body is null");
 
285
  }
286
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
 
287
  } catch (error: any) {
288
  setisAiWorking(false);
289
  toast.error(error.message);