Spaces:
Paused
Paused
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
| 1 |
-
|
| 2 |
"""
|
| 3 |
AI HTML Editor β Flask Server
|
| 4 |
Semua pemrosesan (AI call, find/change, fuzzy match) dilakukan di sisi server.
|
|
@@ -6,7 +5,7 @@ Job disimpan di memori dengan TTL 1 hari.
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
from flask import Flask, request, jsonify, Response
|
| 9 |
-
import uuid, time, re, threading, requests
|
| 10 |
|
| 11 |
app = Flask(__name__)
|
| 12 |
|
|
@@ -49,24 +48,9 @@ def update_job(jid: str, **kwargs):
|
|
| 49 |
JOBS[jid].update(kwargs)
|
| 50 |
|
| 51 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
-
# ALGORITMA FIND & CHANGE
|
| 53 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 54 |
|
| 55 |
-
def normalize_ai_text(text: str) -> str:
|
| 56 |
-
"""
|
| 57 |
-
1. Mengubah literal '\n', '\t', '\r' dari AI menjadi karakter whitespace asli.
|
| 58 |
-
2. Memperbaiki jika AI mengembalikan HTML yang ter-escape secara tidak sengaja.
|
| 59 |
-
"""
|
| 60 |
-
# Ubah literal escape menjadi karakter sesungguhnya
|
| 61 |
-
text = text.replace('\\n', '\n').replace('\\t', '\t').replace('\\r', '\r')
|
| 62 |
-
|
| 63 |
-
# Decode HTML Entities
|
| 64 |
-
return (text.replace('&', '&')
|
| 65 |
-
.replace('<', '<')
|
| 66 |
-
.replace('>', '>')
|
| 67 |
-
.replace('"', '"')
|
| 68 |
-
.replace(''', "'"))
|
| 69 |
-
|
| 70 |
def levenshtein(a: str, b: str) -> int:
|
| 71 |
la, lb = len(a), len(b)
|
| 72 |
if la == 0: return lb
|
|
@@ -88,6 +72,7 @@ def calculate_similarity(s1: str, s2: str) -> float:
|
|
| 88 |
return (len(longer) - levenshtein(longer, shorter)) / len(longer)
|
| 89 |
|
| 90 |
def fuzzy_replace(source: str, search: str, replacement: str) -> dict:
|
|
|
|
| 91 |
search_trimmed = search.strip()
|
| 92 |
if not search_trimmed:
|
| 93 |
return {"success": False, "result": source}
|
|
@@ -104,7 +89,7 @@ def fuzzy_replace(source: str, search: str, replacement: str) -> dict:
|
|
| 104 |
except re.error:
|
| 105 |
pass
|
| 106 |
|
| 107 |
-
# Level 2 β sliding window + Levenshtein
|
| 108 |
if len(search_trimmed) > 3000:
|
| 109 |
return {"success": False, "result": source}
|
| 110 |
|
|
@@ -135,18 +120,24 @@ def fuzzy_replace(source: str, search: str, replacement: str) -> dict:
|
|
| 135 |
|
| 136 |
return {"success": False, "result": source}
|
| 137 |
|
|
|
|
| 138 |
def apply_find_change(code_base: str, ai_response: str) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
exec_match = re.search(r'<execution>([\s\S]*?)</execution>', ai_response, re.IGNORECASE)
|
| 140 |
if not exec_match:
|
| 141 |
-
raise ValueError("
|
| 142 |
|
| 143 |
exec_content = exec_match.group(1)
|
|
|
|
| 144 |
respond_match = re.search(r'<responding>([\s\S]*?)</responding>', exec_content, re.IGNORECASE)
|
| 145 |
respond_text = respond_match.group(1).strip() if respond_match else "Kode berhasil diperbarui."
|
| 146 |
|
| 147 |
find_blocks = re.findall(r'<find>([\s\S]*?)</find>', exec_content, re.IGNORECASE)
|
| 148 |
if not find_blocks:
|
| 149 |
-
raise ValueError("
|
| 150 |
|
| 151 |
temp_code = code_base
|
| 152 |
success_count = 0
|
|
@@ -156,13 +147,16 @@ def apply_find_change(code_base: str, ai_response: str) -> dict:
|
|
| 156 |
el_match = re.search(r'<element>([\s\S]*?)</element>', block, re.IGNORECASE)
|
| 157 |
ch_match = re.search(r'<change>([\s\S]*?)</change>', block, re.IGNORECASE)
|
| 158 |
if not el_match or not ch_match:
|
| 159 |
-
raise ValueError(f"Blok <find> ke-{i+1}
|
| 160 |
|
| 161 |
-
|
| 162 |
-
old_code = normalize_ai_text(el_match.group(1))
|
| 163 |
-
new_code = normalize_ai_text(ch_match.group(1))
|
| 164 |
-
|
| 165 |
old_code_trimmed = old_code.strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
if old_code in temp_code:
|
| 168 |
temp_code = temp_code.replace(old_code, new_code, 1)
|
|
@@ -178,43 +172,33 @@ def apply_find_change(code_base: str, ai_response: str) -> dict:
|
|
| 178 |
is_fuzzy = True
|
| 179 |
else:
|
| 180 |
raise ValueError(
|
| 181 |
-
f"
|
| 182 |
-
|
|
|
|
| 183 |
)
|
| 184 |
|
| 185 |
action_label = f"Diperbarui ({success_count} bagian){' β¨ Auto-Fix' if is_fuzzy else ''}"
|
| 186 |
return {"code": temp_code, "message": respond_text, "action_label": action_label}
|
| 187 |
|
|
|
|
| 188 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 189 |
# AI CALL
|
| 190 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 191 |
-
GEMINI_API = "https://api.
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
"gemini-3.1-flash-lite-preview",
|
| 195 |
-
"gemini-2.5-flash-lite",
|
| 196 |
-
"gemini-2.5-flash",
|
| 197 |
-
}
|
| 198 |
-
DEFAULT_MODEL = "gemini-2.5-flash"
|
| 199 |
-
|
| 200 |
-
def call_ai(prompt: str, model: str = DEFAULT_MODEL) -> str:
|
| 201 |
-
if model not in VALID_MODELS:
|
| 202 |
-
model = DEFAULT_MODEL
|
| 203 |
resp = requests.post(
|
| 204 |
GEMINI_API,
|
| 205 |
-
json={"prompt": prompt
|
| 206 |
timeout=60,
|
| 207 |
headers={"Content-Type": "application/json"},
|
| 208 |
)
|
| 209 |
resp.raise_for_status()
|
| 210 |
data = resp.json()
|
| 211 |
-
if
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
answer = answer.get("answer") or answer.get("text") or str(answer)
|
| 216 |
-
return str(answer)
|
| 217 |
-
raise RuntimeError(f"Format respons API tidak dikenali: {str(data)[:200]}")
|
| 218 |
|
| 219 |
def build_system_prompt(code_base: str) -> str:
|
| 220 |
return f"""Kamu adalah AI HTML Editor. Tugasmu HANYA mengubah bagian tertentu dari kode (Partial Update).
|
|
@@ -223,13 +207,14 @@ Codebase saat ini:
|
|
| 223 |
{code_base}
|
| 224 |
```
|
| 225 |
|
| 226 |
-
ATURAN WAJIB
|
| 227 |
-
1. <
|
| 228 |
-
2.
|
| 229 |
-
3. <change> berisi kode baru
|
| 230 |
-
4.
|
|
|
|
| 231 |
|
| 232 |
-
CONTOH JAWABAN
|
| 233 |
<execution>
|
| 234 |
<responding>Saya telah mengganti teks H1 dan warna tombol.</responding>
|
| 235 |
<find>
|
|
@@ -242,29 +227,25 @@ CONTOH JAWABAN YANG BENAR:
|
|
| 242 |
</find>
|
| 243 |
</execution>"""
|
| 244 |
|
|
|
|
| 245 |
def run_ai_loop(jid: str, user_instruction: str, code_base: str,
|
| 246 |
-
loop: int = 0, error_feedback: str = None, max_loops: int = 10
|
| 247 |
-
|
| 248 |
-
"""Rekursif self-healing AI."""
|
| 249 |
if loop >= max_loops:
|
| 250 |
update_job(jid,
|
| 251 |
status="error",
|
| 252 |
-
error="Gagal merubah kode setelah 10x loop perbaikan. AI terus menerus gagal mencocokkan kode dengan Codebase.
|
| 253 |
loop_count=loop,
|
| 254 |
)
|
| 255 |
return
|
| 256 |
|
| 257 |
try:
|
| 258 |
prompt = build_system_prompt(code_base) + f"\n\nInstruksi User: {user_instruction}"
|
| 259 |
-
|
| 260 |
if error_feedback:
|
| 261 |
-
prompt += (f"\n\n[
|
| 262 |
-
"
|
| 263 |
-
"1. Kesalahan ini terjadi karena teks di <element> beda spasi/karakter dengan codebase.\n"
|
| 264 |
-
"2. SOLUSI: Ambil potongan kode yang LEBIH PENDEK (1-2 baris saja) yang unik.\n"
|
| 265 |
-
"3. Jangan mengubah spasi dan indentasi sedikitpun saat menyalin ke <element>!")
|
| 266 |
|
| 267 |
-
ai_text = call_ai(prompt
|
| 268 |
result = apply_find_change(code_base, ai_text)
|
| 269 |
|
| 270 |
update_job(jid,
|
|
@@ -274,24 +255,27 @@ def run_ai_loop(jid: str, user_instruction: str, code_base: str,
|
|
| 274 |
)
|
| 275 |
|
| 276 |
except Exception as exc:
|
| 277 |
-
# Self-healing: retry
|
| 278 |
threading.Thread(
|
| 279 |
target=run_ai_loop,
|
| 280 |
args=(jid, user_instruction, code_base, loop + 1, str(exc), max_loops),
|
| 281 |
-
kwargs={"model": model},
|
| 282 |
daemon=True,
|
| 283 |
).start()
|
| 284 |
|
|
|
|
| 285 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 286 |
# ROUTES
|
| 287 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 288 |
|
| 289 |
@app.route("/api/chat", methods=["POST"])
|
| 290 |
def api_chat():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
body = request.get_json(silent=True) or {}
|
| 292 |
instruction = body.get("instruction", "").strip()
|
| 293 |
code_base = body.get("code_base", "")
|
| 294 |
-
model = body.get("model", DEFAULT_MODEL)
|
| 295 |
|
| 296 |
if not instruction:
|
| 297 |
return jsonify({"error": "instruction wajib diisi"}), 400
|
|
@@ -302,14 +286,15 @@ def api_chat():
|
|
| 302 |
threading.Thread(
|
| 303 |
target=run_ai_loop,
|
| 304 |
args=(jid, instruction, code_base),
|
| 305 |
-
kwargs={"model": model},
|
| 306 |
daemon=True,
|
| 307 |
).start()
|
| 308 |
|
| 309 |
return jsonify({"job_id": jid}), 202
|
| 310 |
|
|
|
|
| 311 |
@app.route("/api/job/<jid>", methods=["GET"])
|
| 312 |
def api_job_status(jid):
|
|
|
|
| 313 |
job = get_job(jid)
|
| 314 |
if not job:
|
| 315 |
return jsonify({"error": "Job tidak ditemukan atau sudah expired (> 1 hari)"}), 404
|
|
@@ -326,14 +311,17 @@ def api_job_status(jid):
|
|
| 326 |
|
| 327 |
return jsonify(resp)
|
| 328 |
|
|
|
|
| 329 |
@app.route("/api/job/<jid>", methods=["DELETE"])
|
| 330 |
def api_delete_job(jid):
|
|
|
|
| 331 |
with _lock:
|
| 332 |
if jid in JOBS:
|
| 333 |
del JOBS[jid]
|
| 334 |
return jsonify({"deleted": True})
|
| 335 |
return jsonify({"error": "Job tidak ditemukan"}), 404
|
| 336 |
|
|
|
|
| 337 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 338 |
# UI (embedded HTML)
|
| 339 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -358,34 +346,18 @@ HTML = r"""<!DOCTYPE html>
|
|
| 358 |
<div x-data="aiEditor()" class="w-full max-w-md h-full bg-white shadow-2xl relative flex flex-col">
|
| 359 |
|
| 360 |
<!-- Header -->
|
| 361 |
-
<header class="flex-shrink-0 bg-indigo-600 text-white shadow-md z-10">
|
| 362 |
-
<
|
| 363 |
-
<
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
<
|
| 367 |
-
<
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
<
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
<label class="cursor-pointer bg-indigo-500 hover:bg-indigo-400 p-2 rounded text-sm transition font-medium shadow">
|
| 374 |
-
<i class="fa-solid fa-file-upload"></i> Upload
|
| 375 |
-
<input type="file" accept=".html" class="hidden" @change="uploadFile">
|
| 376 |
-
</label>
|
| 377 |
-
</div>
|
| 378 |
-
</div>
|
| 379 |
-
<div class="px-4 pb-2 flex items-center gap-2">
|
| 380 |
-
<i class="fa-solid fa-robot text-indigo-300 text-xs"></i>
|
| 381 |
-
<span class="text-indigo-200 text-xs">Model:</span>
|
| 382 |
-
<select x-model="selectedModel" @change="saveModel"
|
| 383 |
-
class="flex-1 bg-indigo-700 text-white text-xs rounded px-2 py-1 outline-none border border-indigo-400 cursor-pointer">
|
| 384 |
-
<option value="gemini-3-flash-preview">Gemini 3 Flash Preview</option>
|
| 385 |
-
<option value="gemini-3.1-flash-lite-preview">Gemini 3.1 Flash Lite Preview</option>
|
| 386 |
-
<option value="gemini-2.5-flash-lite">Gemini 2.5 Flash Lite</option>
|
| 387 |
-
<option value="gemini-2.5-flash">Gemini 2.5 Flash</option>
|
| 388 |
-
</select>
|
| 389 |
</div>
|
| 390 |
</header>
|
| 391 |
|
|
@@ -526,17 +498,10 @@ HTML = r"""<!DOCTYPE html>
|
|
| 526 |
|
| 527 |
<script>
|
| 528 |
document.addEventListener('alpine:init', () => {
|
| 529 |
-
const LS_CODE = 'aiEditor_codeBase';
|
| 530 |
-
const LS_CHAT = 'aiEditor_chatHistory';
|
| 531 |
-
const LS_MODEL = 'aiEditor_model';
|
| 532 |
-
const DEFAULT_CODE = `<!DOCTYPE html>\n<html>\n<head>\n <title>Halo Dunia</title>\n</head>\n<body>\n <h1>Halo Dunia!</h1>\n <button onclick="showAlert()">Klik Saya</button>\n <script>\n function showAlert() {\n alert('Halo!');\n }\n <\/script>\n</body>\n</html>`;
|
| 533 |
-
const DEFAULT_CHAT = [{role:'ai', content:'Halo! Saya AI Editor (Server-Side). Minta saya mengubah bagian mana saja di halaman ini!'}];
|
| 534 |
-
|
| 535 |
Alpine.data('aiEditor', () => ({
|
| 536 |
tab: 'chat',
|
| 537 |
-
codeBase:
|
| 538 |
-
chatHistory:
|
| 539 |
-
selectedModel: localStorage.getItem(LS_MODEL) || 'gemini-2.5-flash',
|
| 540 |
userInput: '',
|
| 541 |
isProcessing: false,
|
| 542 |
loopCount: 0,
|
|
@@ -546,13 +511,7 @@ HTML = r"""<!DOCTYPE html>
|
|
| 546 |
|
| 547 |
init() {
|
| 548 |
this.$watch('tab', val => { if (val === 'preview') this.updatePreview(); });
|
| 549 |
-
this.$watch('codeBase',
|
| 550 |
-
localStorage.setItem(LS_CODE, val);
|
| 551 |
-
if (this.tab === 'preview') this.updatePreview();
|
| 552 |
-
});
|
| 553 |
-
this.$watch('chatHistory', val => {
|
| 554 |
-
try { localStorage.setItem(LS_CHAT, JSON.stringify(val)); } catch {}
|
| 555 |
-
});
|
| 556 |
window.addEventListener('message', event => {
|
| 557 |
if (event.data && event.data.type === 'PREVIEW_ERROR') {
|
| 558 |
this.previewError = this.previewError
|
|
@@ -562,18 +521,6 @@ HTML = r"""<!DOCTYPE html>
|
|
| 562 |
});
|
| 563 |
},
|
| 564 |
|
| 565 |
-
saveModel() {
|
| 566 |
-
localStorage.setItem(LS_MODEL, this.selectedModel);
|
| 567 |
-
this.showToast('Model: ' + this.selectedModel);
|
| 568 |
-
},
|
| 569 |
-
|
| 570 |
-
clearChat() {
|
| 571 |
-
if (!confirm('Hapus semua riwayat chat?')) return;
|
| 572 |
-
this.chatHistory = [{role:'ai', content:'Halo! Saya AI Editor (Server-Side). Minta saya mengubah bagian mana saja di halaman ini!'}];
|
| 573 |
-
localStorage.removeItem(LS_CHAT);
|
| 574 |
-
this.showToast('Riwayat chat dihapus!');
|
| 575 |
-
},
|
| 576 |
-
|
| 577 |
showToast(msg) {
|
| 578 |
this.toastMsg = msg;
|
| 579 |
setTimeout(() => this.toastMsg = '', 3000);
|
|
@@ -623,14 +570,13 @@ HTML = r"""<!DOCTYPE html>
|
|
| 623 |
const errorCatcherScript = `<script>
|
| 624 |
(function(){
|
| 625 |
var _all=[],_t=null;
|
| 626 |
-
function _isTailwind(m){return/tailwind|cdn\\.tailwindcss/i.test(m);}
|
| 627 |
function _report(d){_all.push(d);clearTimeout(_t);_t=setTimeout(function(){if(_all.length)window.parent.postMessage({type:'PREVIEW_ERROR',error:_all.join('\\n\\n---\\n\\n')},'*');},200);}
|
| 628 |
function _fmt(e){if(!e)return'';var o=e.name?e.name+': '+e.message:String(e);if(e.stack){o+='\\n Stack:\\n '+e.stack.split('\\n').slice(0,5).join('\\n ');}return o;}
|
| 629 |
-
window.onerror=function(m,s,l,c,e){
|
| 630 |
window.addEventListener('unhandledrejection',function(e){var r=e.reason,d='[Unhandled Promise Rejection]\\n';if(r instanceof Error){d+=_fmt(r);}else{d+=String(r);}_report(d);});
|
| 631 |
var _oe=console.error.bind(console),_ow=console.warn.bind(console);
|
| 632 |
console.error=function(){var m=Array.from(arguments).map(function(a){return a instanceof Error?_fmt(a):(typeof a==='object'?JSON.stringify(a,null,2):String(a));}).join(' ');_report('[console.error] '+m);_oe.apply(console,arguments);};
|
| 633 |
-
console.warn=function(){var m=Array.from(arguments).map(function(a){return typeof a==='object'?JSON.stringify(a):String(a);}).join(' ');
|
| 634 |
var _of=window.fetch;window.fetch=function(url,opts){return _of.apply(this,arguments).then(function(r){if(!r.ok)_report('[Fetch Error] '+(opts&&opts.method||'GET')+' '+url+'\\n Status: '+r.status+' '+r.statusText);return r;}).catch(function(err){_report('[Fetch Failed] '+url+'\\n '+_fmt(err));throw err;});};
|
| 635 |
var _oc=document.createElement.bind(document);document.createElement=function(tag){var el=_oc(tag);if(tag.toLowerCase()==='script')el.addEventListener('error',function(){_report('[Script Load Error] Gagal memuat: '+(el.src||'unknown'));});return el;};
|
| 636 |
})();
|
|
@@ -669,7 +615,7 @@ HTML = r"""<!DOCTYPE html>
|
|
| 669 |
const res = await fetch('/api/chat', {
|
| 670 |
method: 'POST',
|
| 671 |
headers: {'Content-Type': 'application/json'},
|
| 672 |
-
body: JSON.stringify({instruction, code_base: this.codeBase
|
| 673 |
});
|
| 674 |
const data = await res.json();
|
| 675 |
if (!res.ok) throw new Error(data.error || 'Server error');
|
|
@@ -728,5 +674,6 @@ HTML = r"""<!DOCTYPE html>
|
|
| 728 |
def index():
|
| 729 |
return Response(HTML, mimetype="text/html")
|
| 730 |
|
|
|
|
| 731 |
if __name__ == "__main__":
|
| 732 |
app.run(host="0.0.0.0", port=7860)
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
AI HTML Editor β Flask Server
|
| 3 |
Semua pemrosesan (AI call, find/change, fuzzy match) dilakukan di sisi server.
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from flask import Flask, request, jsonify, Response
|
| 8 |
+
import uuid, time, re, json, threading, requests
|
| 9 |
|
| 10 |
app = Flask(__name__)
|
| 11 |
|
|
|
|
| 48 |
JOBS[jid].update(kwargs)
|
| 49 |
|
| 50 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 51 |
+
# ALGORITMA FIND & CHANGE (ported dari JS)
|
| 52 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
def levenshtein(a: str, b: str) -> int:
|
| 55 |
la, lb = len(a), len(b)
|
| 56 |
if la == 0: return lb
|
|
|
|
| 72 |
return (len(longer) - levenshtein(longer, shorter)) / len(longer)
|
| 73 |
|
| 74 |
def fuzzy_replace(source: str, search: str, replacement: str) -> dict:
|
| 75 |
+
"""Sama persis dengan fuzzyReplace() di JS."""
|
| 76 |
search_trimmed = search.strip()
|
| 77 |
if not search_trimmed:
|
| 78 |
return {"success": False, "result": source}
|
|
|
|
| 89 |
except re.error:
|
| 90 |
pass
|
| 91 |
|
| 92 |
+
# Level 2 β sliding window + Levenshtein (hanya jika < 3000 chars)
|
| 93 |
if len(search_trimmed) > 3000:
|
| 94 |
return {"success": False, "result": source}
|
| 95 |
|
|
|
|
| 120 |
|
| 121 |
return {"success": False, "result": source}
|
| 122 |
|
| 123 |
+
|
| 124 |
def apply_find_change(code_base: str, ai_response: str) -> dict:
|
| 125 |
+
"""
|
| 126 |
+
Parse respons AI dan terapkan semua blok <find>/<element>/<change>.
|
| 127 |
+
Return dict: {success, code, message, action_label}
|
| 128 |
+
"""
|
| 129 |
exec_match = re.search(r'<execution>([\s\S]*?)</execution>', ai_response, re.IGNORECASE)
|
| 130 |
if not exec_match:
|
| 131 |
+
raise ValueError("Gunakan format tag <execution>.")
|
| 132 |
|
| 133 |
exec_content = exec_match.group(1)
|
| 134 |
+
|
| 135 |
respond_match = re.search(r'<responding>([\s\S]*?)</responding>', exec_content, re.IGNORECASE)
|
| 136 |
respond_text = respond_match.group(1).strip() if respond_match else "Kode berhasil diperbarui."
|
| 137 |
|
| 138 |
find_blocks = re.findall(r'<find>([\s\S]*?)</find>', exec_content, re.IGNORECASE)
|
| 139 |
if not find_blocks:
|
| 140 |
+
raise ValueError("Kamu wajib menggunakan tag <find> berisi <element> dan <change>.")
|
| 141 |
|
| 142 |
temp_code = code_base
|
| 143 |
success_count = 0
|
|
|
|
| 147 |
el_match = re.search(r'<element>([\s\S]*?)</element>', block, re.IGNORECASE)
|
| 148 |
ch_match = re.search(r'<change>([\s\S]*?)</change>', block, re.IGNORECASE)
|
| 149 |
if not el_match or not ch_match:
|
| 150 |
+
raise ValueError(f"Blok <find> ke-{i+1} tidak memiliki <element> atau <change>.")
|
| 151 |
|
| 152 |
+
old_code = el_match.group(1)
|
|
|
|
|
|
|
|
|
|
| 153 |
old_code_trimmed = old_code.strip()
|
| 154 |
+
new_code = (ch_match.group(1)
|
| 155 |
+
.replace('&', '&')
|
| 156 |
+
.replace('<', '<')
|
| 157 |
+
.replace('>', '>')
|
| 158 |
+
.replace('"', '"')
|
| 159 |
+
.replace(''', "'"))
|
| 160 |
|
| 161 |
if old_code in temp_code:
|
| 162 |
temp_code = temp_code.replace(old_code, new_code, 1)
|
|
|
|
| 172 |
is_fuzzy = True
|
| 173 |
else:
|
| 174 |
raise ValueError(
|
| 175 |
+
f"Isi dari <element> pada <find> ke-{i+1}:\n{old_code}\n\n"
|
| 176 |
+
"TIDAK DITEMUKAN di codebase meskipun fitur Similarity Fallback sudah aktif. "
|
| 177 |
+
"Salin teks dengan lebih teliti!"
|
| 178 |
)
|
| 179 |
|
| 180 |
action_label = f"Diperbarui ({success_count} bagian){' β¨ Auto-Fix' if is_fuzzy else ''}"
|
| 181 |
return {"code": temp_code, "message": respond_text, "action_label": action_label}
|
| 182 |
|
| 183 |
+
|
| 184 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 185 |
# AI CALL
|
| 186 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 187 |
+
GEMINI_API = "https://puruboy-api.vercel.app/api/ai/gemini-v2"
|
| 188 |
+
|
| 189 |
+
def call_ai(prompt: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
resp = requests.post(
|
| 191 |
GEMINI_API,
|
| 192 |
+
json={"prompt": prompt},
|
| 193 |
timeout=60,
|
| 194 |
headers={"Content-Type": "application/json"},
|
| 195 |
)
|
| 196 |
resp.raise_for_status()
|
| 197 |
data = resp.json()
|
| 198 |
+
if not data.get("success"):
|
| 199 |
+
raise RuntimeError("Gagal terhubung ke API / Server Error.")
|
| 200 |
+
return data["result"]["answer"]
|
| 201 |
+
|
|
|
|
|
|
|
|
|
|
| 202 |
|
| 203 |
def build_system_prompt(code_base: str) -> str:
|
| 204 |
return f"""Kamu adalah AI HTML Editor. Tugasmu HANYA mengubah bagian tertentu dari kode (Partial Update).
|
|
|
|
| 207 |
{code_base}
|
| 208 |
```
|
| 209 |
|
| 210 |
+
ATURAN WAJIB:
|
| 211 |
+
1. Gunakan blok <find> untuk setiap perubahan.
|
| 212 |
+
2. <element> HARUS berisi teks/kode lama yang IDENTIK PERSIS dengan Codebase.
|
| 213 |
+
3. <change> berisi kode baru penggantinya.
|
| 214 |
+
4. Boleh memakai banyak blok <find> sekaligus.
|
| 215 |
+
5. Balas HANYA dengan format <execution> seperti contoh di bawah. DILARANG KERAS menulis ulang seluruh kode!
|
| 216 |
|
| 217 |
+
CONTOH JAWABAN:
|
| 218 |
<execution>
|
| 219 |
<responding>Saya telah mengganti teks H1 dan warna tombol.</responding>
|
| 220 |
<find>
|
|
|
|
| 227 |
</find>
|
| 228 |
</execution>"""
|
| 229 |
|
| 230 |
+
|
| 231 |
def run_ai_loop(jid: str, user_instruction: str, code_base: str,
|
| 232 |
+
loop: int = 0, error_feedback: str = None, max_loops: int = 10):
|
| 233 |
+
"""Rekursif di thread terpisah β sama dengan runAILoop() JS."""
|
|
|
|
| 234 |
if loop >= max_loops:
|
| 235 |
update_job(jid,
|
| 236 |
status="error",
|
| 237 |
+
error="Gagal merubah kode setelah 10x loop perbaikan. AI terus menerus gagal mencocokkan kode dengan Codebase. Coba beri instruksi yang lebih jelas.",
|
| 238 |
loop_count=loop,
|
| 239 |
)
|
| 240 |
return
|
| 241 |
|
| 242 |
try:
|
| 243 |
prompt = build_system_prompt(code_base) + f"\n\nInstruksi User: {user_instruction}"
|
|
|
|
| 244 |
if error_feedback:
|
| 245 |
+
prompt += (f"\n\n[ERROR PADA JAWABANMU SEBELUMNYA]:\n{error_feedback}\n"
|
| 246 |
+
"Perbaiki pencarian teksmu. Jika kau kesulitan, setidaknya salin elemen persis seperti aslinya.")
|
|
|
|
|
|
|
|
|
|
| 247 |
|
| 248 |
+
ai_text = call_ai(prompt)
|
| 249 |
result = apply_find_change(code_base, ai_text)
|
| 250 |
|
| 251 |
update_job(jid,
|
|
|
|
| 255 |
)
|
| 256 |
|
| 257 |
except Exception as exc:
|
| 258 |
+
# Self-healing: retry
|
| 259 |
threading.Thread(
|
| 260 |
target=run_ai_loop,
|
| 261 |
args=(jid, user_instruction, code_base, loop + 1, str(exc), max_loops),
|
|
|
|
| 262 |
daemon=True,
|
| 263 |
).start()
|
| 264 |
|
| 265 |
+
|
| 266 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 267 |
# ROUTES
|
| 268 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 269 |
|
| 270 |
@app.route("/api/chat", methods=["POST"])
|
| 271 |
def api_chat():
|
| 272 |
+
"""
|
| 273 |
+
Body: { instruction: str, code_base: str }
|
| 274 |
+
Return: { job_id: str }
|
| 275 |
+
"""
|
| 276 |
body = request.get_json(silent=True) or {}
|
| 277 |
instruction = body.get("instruction", "").strip()
|
| 278 |
code_base = body.get("code_base", "")
|
|
|
|
| 279 |
|
| 280 |
if not instruction:
|
| 281 |
return jsonify({"error": "instruction wajib diisi"}), 400
|
|
|
|
| 286 |
threading.Thread(
|
| 287 |
target=run_ai_loop,
|
| 288 |
args=(jid, instruction, code_base),
|
|
|
|
| 289 |
daemon=True,
|
| 290 |
).start()
|
| 291 |
|
| 292 |
return jsonify({"job_id": jid}), 202
|
| 293 |
|
| 294 |
+
|
| 295 |
@app.route("/api/job/<jid>", methods=["GET"])
|
| 296 |
def api_job_status(jid):
|
| 297 |
+
"""Poll status job."""
|
| 298 |
job = get_job(jid)
|
| 299 |
if not job:
|
| 300 |
return jsonify({"error": "Job tidak ditemukan atau sudah expired (> 1 hari)"}), 404
|
|
|
|
| 311 |
|
| 312 |
return jsonify(resp)
|
| 313 |
|
| 314 |
+
|
| 315 |
@app.route("/api/job/<jid>", methods=["DELETE"])
|
| 316 |
def api_delete_job(jid):
|
| 317 |
+
"""Hapus job secara manual."""
|
| 318 |
with _lock:
|
| 319 |
if jid in JOBS:
|
| 320 |
del JOBS[jid]
|
| 321 |
return jsonify({"deleted": True})
|
| 322 |
return jsonify({"error": "Job tidak ditemukan"}), 404
|
| 323 |
|
| 324 |
+
|
| 325 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 326 |
# UI (embedded HTML)
|
| 327 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 346 |
<div x-data="aiEditor()" class="w-full max-w-md h-full bg-white shadow-2xl relative flex flex-col">
|
| 347 |
|
| 348 |
<!-- Header -->
|
| 349 |
+
<header class="flex-shrink-0 bg-indigo-600 text-white p-4 flex justify-between items-center shadow-md z-10">
|
| 350 |
+
<h1 class="text-lg font-bold flex items-center gap-2">
|
| 351 |
+
<i class="fa-solid fa-wand-magic-sparkles"></i> AI Editor
|
| 352 |
+
</h1>
|
| 353 |
+
<div class="flex gap-2">
|
| 354 |
+
<button @click="downloadCode" class="bg-indigo-500 hover:bg-indigo-400 p-2 rounded text-sm transition font-medium shadow">
|
| 355 |
+
<i class="fa-solid fa-file-download"></i> Simpan
|
| 356 |
+
</button>
|
| 357 |
+
<label class="cursor-pointer bg-indigo-500 hover:bg-indigo-400 p-2 rounded text-sm transition font-medium shadow">
|
| 358 |
+
<i class="fa-solid fa-file-upload"></i> Upload
|
| 359 |
+
<input type="file" accept=".html" class="hidden" @change="uploadFile">
|
| 360 |
+
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
</div>
|
| 362 |
</header>
|
| 363 |
|
|
|
|
| 498 |
|
| 499 |
<script>
|
| 500 |
document.addEventListener('alpine:init', () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
Alpine.data('aiEditor', () => ({
|
| 502 |
tab: 'chat',
|
| 503 |
+
codeBase: `<!DOCTYPE html>\n<html>\n<head>\n <title>Halo Dunia</title>\n</head>\n<body>\n <h1>Halo Dunia!</h1>\n <button onclick="showAlert()">Klik Saya</button>\n <script>\n function showAlert() {\n alert('Halo!');\n }\n <\/script>\n</body>\n</html>`,
|
| 504 |
+
chatHistory: [{role:'ai', content:'Halo! Saya AI Editor (Server-Side). Minta saya mengubah bagian mana saja di halaman ini!'}],
|
|
|
|
| 505 |
userInput: '',
|
| 506 |
isProcessing: false,
|
| 507 |
loopCount: 0,
|
|
|
|
| 511 |
|
| 512 |
init() {
|
| 513 |
this.$watch('tab', val => { if (val === 'preview') this.updatePreview(); });
|
| 514 |
+
this.$watch('codeBase', () => { if (this.tab === 'preview') this.updatePreview(); });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
window.addEventListener('message', event => {
|
| 516 |
if (event.data && event.data.type === 'PREVIEW_ERROR') {
|
| 517 |
this.previewError = this.previewError
|
|
|
|
| 521 |
});
|
| 522 |
},
|
| 523 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
showToast(msg) {
|
| 525 |
this.toastMsg = msg;
|
| 526 |
setTimeout(() => this.toastMsg = '', 3000);
|
|
|
|
| 570 |
const errorCatcherScript = `<script>
|
| 571 |
(function(){
|
| 572 |
var _all=[],_t=null;
|
|
|
|
| 573 |
function _report(d){_all.push(d);clearTimeout(_t);_t=setTimeout(function(){if(_all.length)window.parent.postMessage({type:'PREVIEW_ERROR',error:_all.join('\\n\\n---\\n\\n')},'*');},200);}
|
| 574 |
function _fmt(e){if(!e)return'';var o=e.name?e.name+': '+e.message:String(e);if(e.stack){o+='\\n Stack:\\n '+e.stack.split('\\n').slice(0,5).join('\\n ');}return o;}
|
| 575 |
+
window.onerror=function(m,s,l,c,e){var d;if(e){d='[Runtime Error]\\n'+_fmt(e);if(s)d+='\\n File: '+s;if(l)d+='\\n Line: '+l+(c?', Col: '+c:'');}else if(m==='Script error.'||m==='Script error'){d='[Cross-Origin Script Error]\\n Browser menyembunyikan detail.\\n File: '+(s||'unknown');}else{d='[Runtime Error] '+m;if(s)d+='\\n File: '+s;if(l)d+='\\n Line: '+l+(c?', Col: '+c:'');}_report(d);return true;};
|
| 576 |
window.addEventListener('unhandledrejection',function(e){var r=e.reason,d='[Unhandled Promise Rejection]\\n';if(r instanceof Error){d+=_fmt(r);}else{d+=String(r);}_report(d);});
|
| 577 |
var _oe=console.error.bind(console),_ow=console.warn.bind(console);
|
| 578 |
console.error=function(){var m=Array.from(arguments).map(function(a){return a instanceof Error?_fmt(a):(typeof a==='object'?JSON.stringify(a,null,2):String(a));}).join(' ');_report('[console.error] '+m);_oe.apply(console,arguments);};
|
| 579 |
+
console.warn=function(){var m=Array.from(arguments).map(function(a){return typeof a==='object'?JSON.stringify(a):String(a);}).join(' ');_report('[console.warn] '+m);_ow.apply(console,arguments);};
|
| 580 |
var _of=window.fetch;window.fetch=function(url,opts){return _of.apply(this,arguments).then(function(r){if(!r.ok)_report('[Fetch Error] '+(opts&&opts.method||'GET')+' '+url+'\\n Status: '+r.status+' '+r.statusText);return r;}).catch(function(err){_report('[Fetch Failed] '+url+'\\n '+_fmt(err));throw err;});};
|
| 581 |
var _oc=document.createElement.bind(document);document.createElement=function(tag){var el=_oc(tag);if(tag.toLowerCase()==='script')el.addEventListener('error',function(){_report('[Script Load Error] Gagal memuat: '+(el.src||'unknown'));});return el;};
|
| 582 |
})();
|
|
|
|
| 615 |
const res = await fetch('/api/chat', {
|
| 616 |
method: 'POST',
|
| 617 |
headers: {'Content-Type': 'application/json'},
|
| 618 |
+
body: JSON.stringify({instruction, code_base: this.codeBase}),
|
| 619 |
});
|
| 620 |
const data = await res.json();
|
| 621 |
if (!res.ok) throw new Error(data.error || 'Server error');
|
|
|
|
| 674 |
def index():
|
| 675 |
return Response(HTML, mimetype="text/html")
|
| 676 |
|
| 677 |
+
|
| 678 |
if __name__ == "__main__":
|
| 679 |
app.run(host="0.0.0.0", port=7860)
|