Spaces:
Running
Running
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()) {
|