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);
|