Spaces:
Sleeping
Sleeping
refactor: Apply AI diffs server-side instead of client-side
Browse files- server.js +25 -0
- src/components/App.tsx +7 -10
- src/components/ask-ai/ask-ai.tsx +42 -156
server.js
CHANGED
|
@@ -402,6 +402,31 @@ function applyDiffs(originalHtml, aiResponseContent) {
|
|
| 402 |
return currentHtml;
|
| 403 |
}
|
| 404 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
// --- AI Interaction Route ---
|
| 406 |
app.post("/api/ask-ai", async (req, res) => {
|
| 407 |
const { prompt, html, previousPrompt } = req.body;
|
|
|
|
| 402 |
return currentHtml;
|
| 403 |
}
|
| 404 |
|
| 405 |
+
|
| 406 |
+
// --- Endpoint to Apply Diffs Server-Side ---
|
| 407 |
+
app.post("/api/apply-diffs", (req, res) => {
|
| 408 |
+
const { originalHtml, aiResponseContent } = req.body;
|
| 409 |
+
|
| 410 |
+
if (typeof originalHtml !== 'string' || typeof aiResponseContent !== 'string') {
|
| 411 |
+
return res.status(400).json({ ok: false, message: "Missing or invalid originalHtml or aiResponseContent." });
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
try {
|
| 415 |
+
console.log("[Apply Diffs] Received request to apply diffs.");
|
| 416 |
+
const modifiedHtml = applyDiffs(originalHtml, aiResponseContent);
|
| 417 |
+
console.log("[Apply Diffs] Diffs applied successfully.");
|
| 418 |
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
| 419 |
+
res.status(200).send(modifiedHtml);
|
| 420 |
+
} catch (error: any) {
|
| 421 |
+
console.error("[Apply Diffs] Error applying diffs:", error);
|
| 422 |
+
res.status(400).json({ // Use 400 for client-side correctable errors (bad diff format)
|
| 423 |
+
ok: false,
|
| 424 |
+
message: error.message || "Failed to apply AI suggestions.",
|
| 425 |
+
});
|
| 426 |
+
}
|
| 427 |
+
});
|
| 428 |
+
|
| 429 |
+
|
| 430 |
// --- AI Interaction Route ---
|
| 431 |
app.post("/api/ask-ai", async (req, res) => {
|
| 432 |
const { prompt, html, previousPrompt } = req.body;
|
src/components/App.tsx
CHANGED
|
@@ -19,7 +19,7 @@ function App() {
|
|
| 19 |
const preview = useRef<HTMLDivElement>(null);
|
| 20 |
const editor = useRef<HTMLDivElement>(null);
|
| 21 |
const resizer = useRef<HTMLDivElement>(null);
|
| 22 |
-
|
| 23 |
|
| 24 |
const [isResizing, setIsResizing] = useState(false);
|
| 25 |
const [error, setError] = useState(false);
|
|
@@ -148,9 +148,7 @@ function App() {
|
|
| 148 |
setHtml(defaultHTML);
|
| 149 |
setError(false);
|
| 150 |
removeHtmlStorage();
|
| 151 |
-
editorRef
|
| 152 |
-
editorRef.current?.getModel()?.getLineCount() ?? 0
|
| 153 |
-
);
|
| 154 |
}
|
| 155 |
}}
|
| 156 |
>
|
|
@@ -189,18 +187,17 @@ function App() {
|
|
| 189 |
setHtml(newValue);
|
| 190 |
setError(false);
|
| 191 |
}}
|
| 192 |
-
onMount
|
| 193 |
/>
|
| 194 |
<AskAI
|
| 195 |
html={html}
|
| 196 |
-
setHtml={setHtml} //
|
| 197 |
-
|
| 198 |
isAiWorking={isAiWorking}
|
| 199 |
setisAiWorking={setisAiWorking}
|
| 200 |
onScrollToBottom={() => {
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
);
|
| 204 |
}}
|
| 205 |
/>
|
| 206 |
</div>
|
|
|
|
| 19 |
const preview = useRef<HTMLDivElement>(null);
|
| 20 |
const editor = useRef<HTMLDivElement>(null);
|
| 21 |
const resizer = useRef<HTMLDivElement>(null);
|
| 22 |
+
// Removed editorRef
|
| 23 |
|
| 24 |
const [isResizing, setIsResizing] = useState(false);
|
| 25 |
const [error, setError] = useState(false);
|
|
|
|
| 148 |
setHtml(defaultHTML);
|
| 149 |
setError(false);
|
| 150 |
removeHtmlStorage();
|
| 151 |
+
// Removed editorRef scroll logic
|
|
|
|
|
|
|
| 152 |
}
|
| 153 |
}}
|
| 154 |
>
|
|
|
|
| 187 |
setHtml(newValue);
|
| 188 |
setError(false);
|
| 189 |
}}
|
| 190 |
+
// Removed onMount for editorRef
|
| 191 |
/>
|
| 192 |
<AskAI
|
| 193 |
html={html}
|
| 194 |
+
setHtml={setHtml} // Used for both full and diff updates now
|
| 195 |
+
// Removed editorRef prop
|
| 196 |
isAiWorking={isAiWorking}
|
| 197 |
setisAiWorking={setisAiWorking}
|
| 198 |
onScrollToBottom={() => {
|
| 199 |
+
// Consider if scrolling is still needed here, maybe based on html length change?
|
| 200 |
+
// For now, removing the direct editor scroll.
|
|
|
|
| 201 |
}}
|
| 202 |
/>
|
| 203 |
</div>
|
src/components/ask-ai/ask-ai.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 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 |
|
| 8 |
import Login from "../login/login";
|
| 9 |
import { defaultHTML } from "../../utils/consts";
|
|
@@ -11,172 +11,35 @@ 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
|
| 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 |
-
|
| 26 |
}) {
|
| 27 |
const [open, setOpen] = useState(false);
|
| 28 |
const [prompt, setPrompt] = useState("");
|
| 29 |
const [hasAsked, setHasAsked] = useState(false);
|
| 30 |
const [previousPrompt, setPreviousPrompt] = useState("");
|
| 31 |
-
// Removed unused diffBuffer state: const [diffBuffer, setDiffBuffer] = useState("");
|
| 32 |
const audio = new Audio(SuccessSound);
|
| 33 |
audio.volume = 0.5;
|
| 34 |
|
| 35 |
-
//
|
| 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 |
// --- Main AI Call Logic ---
|
| 172 |
const callAi = async () => {
|
| 173 |
if (isAiWorking || !prompt.trim()) return;
|
|
|
|
| 174 |
setisAiWorking(true);
|
| 175 |
-
// Removed setDiffBuffer("") call
|
| 176 |
|
| 177 |
let fullContentResponse = ""; // Used for full HTML mode
|
|
|
|
| 178 |
let lastRenderTime = 0; // For throttling full HTML updates
|
| 179 |
-
let currentDiffBuffer = ""; // Local variable for buffer within this call
|
| 180 |
|
| 181 |
try {
|
| 182 |
const request = await fetch("/api/ask-ai", {
|
|
@@ -214,18 +77,41 @@ function AskAI({
|
|
| 214 |
const { done, value } = await reader.read();
|
| 215 |
if (done) {
|
| 216 |
console.log("[AI Response] Stream finished.");
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
if (finalDoc) {
|
| 230 |
setHtml(finalDoc); // Ensure final complete HTML is set
|
| 231 |
} else if (fullContentResponse.trim()) {
|
|
|
|
| 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 |
+
// Removed monaco editor import
|
| 7 |
|
| 8 |
import Login from "../login/login";
|
| 9 |
import { defaultHTML } from "../../utils/consts";
|
|
|
|
| 11 |
|
| 12 |
function AskAI({
|
| 13 |
html, // Current full HTML content (used for initial request and context)
|
| 14 |
+
setHtml, // Used for updates (both full and diff-based)
|
| 15 |
onScrollToBottom, // Used for full updates
|
| 16 |
isAiWorking,
|
| 17 |
setisAiWorking,
|
|
|
|
| 18 |
}: {
|
| 19 |
html: string;
|
| 20 |
setHtml: (html: string) => void;
|
| 21 |
onScrollToBottom: () => void;
|
| 22 |
isAiWorking: boolean;
|
| 23 |
setisAiWorking: React.Dispatch<React.SetStateAction<boolean>>;
|
| 24 |
+
// Removed editorRef prop
|
| 25 |
}) {
|
| 26 |
const [open, setOpen] = useState(false);
|
| 27 |
const [prompt, setPrompt] = useState("");
|
| 28 |
const [hasAsked, setHasAsked] = useState(false);
|
| 29 |
const [previousPrompt, setPreviousPrompt] = useState("");
|
|
|
|
| 30 |
const audio = new Audio(SuccessSound);
|
| 31 |
audio.volume = 0.5;
|
| 32 |
|
| 33 |
+
// Removed client-side diff parsing/applying logic
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
// --- Main AI Call Logic ---
|
| 35 |
const callAi = async () => {
|
| 36 |
if (isAiWorking || !prompt.trim()) return;
|
| 37 |
+
const originalHtml = html; // Store the HTML state at the start of the request
|
| 38 |
setisAiWorking(true);
|
|
|
|
| 39 |
|
| 40 |
let fullContentResponse = ""; // Used for full HTML mode
|
| 41 |
+
let accumulatedDiffResponse = ""; // Used for diff mode
|
| 42 |
let lastRenderTime = 0; // For throttling full HTML updates
|
|
|
|
| 43 |
|
| 44 |
try {
|
| 45 |
const request = await fetch("/api/ask-ai", {
|
|
|
|
| 77 |
const { done, value } = await reader.read();
|
| 78 |
if (done) {
|
| 79 |
console.log("[AI Response] Stream finished.");
|
| 80 |
+
|
| 81 |
+
// --- Post-stream processing ---
|
| 82 |
+
if (responseType === 'diff') {
|
| 83 |
+
// Apply diffs server-side
|
| 84 |
+
try {
|
| 85 |
+
console.log("[Diff Apply] Sending original HTML and AI diff response to server...");
|
| 86 |
+
const applyRequest = await fetch("/api/apply-diffs", {
|
| 87 |
+
method: "POST",
|
| 88 |
+
headers: { "Content-Type": "application/json" },
|
| 89 |
+
body: JSON.stringify({
|
| 90 |
+
originalHtml: originalHtml, // Send the HTML from the start of the request
|
| 91 |
+
aiResponseContent: accumulatedDiffResponse,
|
| 92 |
+
}),
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
if (!applyRequest.ok) {
|
| 96 |
+
const errorData = await applyRequest.json();
|
| 97 |
+
throw new Error(errorData.message || `Server failed to apply diffs (status ${applyRequest.status})`);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
const patchedHtml = await applyRequest.text();
|
| 101 |
+
console.log("[Diff Apply] Received patched HTML from server.");
|
| 102 |
+
setHtml(patchedHtml); // Update editor with the final result
|
| 103 |
+
toast.success("AI changes applied");
|
| 104 |
+
|
| 105 |
+
} catch (applyError: any) {
|
| 106 |
+
console.error("Error applying diffs server-side:", applyError);
|
| 107 |
+
toast.error(`Failed to apply AI changes: ${applyError.message}`);
|
| 108 |
+
// Optionally revert to originalHtml? Or leave the editor as is?
|
| 109 |
+
// setHtml(originalHtml); // Uncomment to revert on failure
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
} else {
|
| 113 |
+
// Final update for full HTML mode
|
| 114 |
+
const finalDoc = fullContentResponse.match(/<!DOCTYPE html>[\s\S]*<\/html>/)?.[0];
|
| 115 |
if (finalDoc) {
|
| 116 |
setHtml(finalDoc); // Ensure final complete HTML is set
|
| 117 |
} else if (fullContentResponse.trim()) {
|