Spaces:
Running
Running
feat: Handle streamed diffs and apply to Monaco editor
Browse files- 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 |
-
|
|
|
65 |
const { done, value } = await reader.read();
|
66 |
if (done) {
|
67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
101 |
|
102 |
-
|
103 |
-
|
|
|
|
|
104 |
}
|
105 |
}
|
106 |
-
|
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);
|