Ricky01anjay commited on
Commit
2e99bf5
Β·
verified Β·
1 Parent(s): 4981d19

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +678 -0
app.py ADDED
@@ -0,0 +1,678 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI HTML Editor β€” Flask Server
3
+ Semua pemrosesan (AI call, find/change, fuzzy match) dilakukan di sisi server.
4
+ Job disimpan di memori dengan TTL 1 hari.
5
+ """
6
+
7
+ from flask import Flask, request, jsonify, Response
8
+ import uuid, time, re, json, threading, requests
9
+
10
+ app = Flask(__name__)
11
+
12
+ # ─────────────────────────────────────────────
13
+ # JOB STORE (in-memory, TTL = 1 hari)
14
+ # ─────────────────────────────────────────────
15
+ JOBS: dict[str, dict] = {}
16
+ JOB_TTL = 86_400 # 1 hari dalam detik
17
+ _lock = threading.Lock()
18
+
19
+ def _purge_expired():
20
+ """Hapus job yang sudah expired."""
21
+ now = time.time()
22
+ with _lock:
23
+ expired = [jid for jid, j in JOBS.items() if now - j["created_at"] > JOB_TTL]
24
+ for jid in expired:
25
+ del JOBS[jid]
26
+
27
+ def new_job(payload: dict) -> str:
28
+ _purge_expired()
29
+ jid = str(uuid.uuid4())
30
+ with _lock:
31
+ JOBS[jid] = {
32
+ "status": "pending", # pending | running | done | error
33
+ "created_at": time.time(),
34
+ "payload": payload,
35
+ "result": None,
36
+ "error": None,
37
+ }
38
+ return jid
39
+
40
+ def get_job(jid: str) -> dict | None:
41
+ _purge_expired()
42
+ with _lock:
43
+ return JOBS.get(jid)
44
+
45
+ def update_job(jid: str, **kwargs):
46
+ with _lock:
47
+ if jid in JOBS:
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
57
+ if lb == 0: return la
58
+ matrix = [list(range(la + 1))] + [[i] + [0] * la for i in range(1, lb + 1)]
59
+ for i in range(1, lb + 1):
60
+ for j in range(1, la + 1):
61
+ if b[i-1] == a[j-1]:
62
+ matrix[i][j] = matrix[i-1][j-1]
63
+ else:
64
+ matrix[i][j] = 1 + min(matrix[i-1][j-1], matrix[i][j-1], matrix[i-1][j])
65
+ return matrix[lb][la]
66
+
67
+ def calculate_similarity(s1: str, s2: str) -> float:
68
+ longer = s1 if len(s1) >= len(s2) else s2
69
+ shorter = s1 if len(s1) < len(s2) else s2
70
+ if len(longer) == 0:
71
+ return 1.0
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}
79
+
80
+ # Level 1 β€” whitespace-agnostic regex
81
+ def escape_re(s):
82
+ return re.escape(s)
83
+
84
+ flex = re.sub(r'\s+', r'\\s+', escape_re(search_trimmed))
85
+ try:
86
+ m = re.search(flex, source)
87
+ if m:
88
+ return {"success": True, "result": source[:m.start()] + replacement.strip() + source[m.end():]}
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
+
96
+ source_lines = source.split('\n')
97
+ search_lines = [l.strip() for l in search_trimmed.split('\n') if l.strip()]
98
+ if not search_lines:
99
+ return {"success": False, "result": source}
100
+
101
+ search_str = ''.join(search_lines)
102
+ best = {"index": -1, "score": 0.0, "length": 0}
103
+ n = len(search_lines)
104
+
105
+ for window_size in [max(1, n-1), n, n+1]:
106
+ for i in range(len(source_lines)):
107
+ if i + window_size > len(source_lines):
108
+ continue
109
+ window_lines = source_lines[i:i+window_size]
110
+ window_str = ''.join(l.strip() for l in window_lines)
111
+ if abs(len(window_str) - len(search_str)) > len(search_str) * 0.5:
112
+ continue
113
+ score = calculate_similarity(window_str, search_str)
114
+ if score > best["score"]:
115
+ best = {"index": i, "score": score, "length": window_size}
116
+
117
+ if best["score"] > 0.70:
118
+ result_lines = source_lines[:best["index"]] + [replacement] + source_lines[best["index"] + best["length"]:]
119
+ return {"success": True, "result": '\n'.join(result_lines)}
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
144
+ is_fuzzy = False
145
+
146
+ for i, block in enumerate(find_blocks):
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('&amp;', '&')
156
+ .replace('&lt;', '<')
157
+ .replace('&gt;', '>')
158
+ .replace('&quot;', '"')
159
+ .replace('&#39;', "'"))
160
+
161
+ if old_code in temp_code:
162
+ temp_code = temp_code.replace(old_code, new_code, 1)
163
+ success_count += 1
164
+ elif old_code_trimmed and old_code_trimmed in temp_code:
165
+ temp_code = temp_code.replace(old_code_trimmed, new_code.strip(), 1)
166
+ success_count += 1
167
+ else:
168
+ res = fuzzy_replace(temp_code, old_code, new_code)
169
+ if res["success"]:
170
+ temp_code = res["result"]
171
+ success_count += 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).
205
+ Codebase saat ini:
206
+ ```html
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>
221
+ <element>
222
+ <h1>Halo Dunia!</h1>
223
+ </element>
224
+ <change>
225
+ <h1>Dunia Baru!</h1>
226
+ </change>
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,
252
+ status="done",
253
+ result=result,
254
+ loop_count=loop,
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
282
+
283
+ jid = new_job({"instruction": instruction})
284
+ update_job(jid, status="running", loop_count=0)
285
+
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
301
+
302
+ resp = {
303
+ "job_id": jid,
304
+ "status": job["status"],
305
+ "loop_count": job.get("loop_count", 0),
306
+ }
307
+ if job["status"] == "done":
308
+ resp["result"] = job["result"]
309
+ if job["status"] == "error":
310
+ resp["error"] = job["error"]
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
+ # ─────────────────────────────────────────────
328
+ HTML = r"""<!DOCTYPE html>
329
+ <html lang="id">
330
+ <head>
331
+ <meta charset="UTF-8">
332
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
333
+ <title>AI HTML Editor</title>
334
+ <script src="https://cdn.tailwindcss.com"></script>
335
+ <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
336
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
337
+ <style>
338
+ ::-webkit-scrollbar { width: 6px; }
339
+ ::-webkit-scrollbar-track { background: transparent; }
340
+ ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
341
+ iframe { border: none; width: 100%; height: 100%; }
342
+ </style>
343
+ </head>
344
+ <body class="bg-gray-100 flex justify-center h-[100dvh] overflow-hidden text-gray-800">
345
+
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
+
364
+ <!-- Main -->
365
+ <main class="flex-1 min-h-0 relative bg-gray-50 flex flex-col overflow-hidden">
366
+
367
+ <!-- CHAT PAGE -->
368
+ <div x-show="tab === 'chat'" class="h-full flex flex-col" x-transition.opacity>
369
+ <div class="flex-1 overflow-y-auto p-4 space-y-4" id="chat-container">
370
+ <template x-for="(msg, index) in chatHistory" :key="index">
371
+ <div class="flex flex-col" :class="msg.role === 'user' ? 'items-end' : 'items-start'">
372
+ <span class="text-[10px] text-gray-400 mb-1 font-semibold uppercase tracking-wider"
373
+ x-text="msg.role === 'user' ? 'Kamu' : 'AI'"></span>
374
+ <div class="max-w-[85%] p-3 rounded-2xl shadow-sm text-sm"
375
+ :class="msg.role === 'user' ? 'bg-indigo-600 text-white rounded-tr-none'
376
+ : (msg.isErrorPrompt ? 'bg-red-50 text-red-800 border border-red-200 rounded-tr-none'
377
+ : 'bg-white text-gray-800 border border-gray-200 rounded-tl-none')">
378
+ <p class="whitespace-pre-wrap leading-relaxed" x-text="msg.content"></p>
379
+ <template x-if="msg.action">
380
+ <div class="mt-2 pt-2 border-t border-gray-100/30 flex items-center gap-1">
381
+ <span class="text-[10px] px-2 py-0.5 rounded-full font-bold flex items-center gap-1 bg-emerald-100 text-emerald-700">
382
+ <i class="fa-solid fa-pen-to-square"></i>
383
+ <span x-text="msg.action"></span>
384
+ </span>
385
+ </div>
386
+ </template>
387
+ </div>
388
+ </div>
389
+ </template>
390
+
391
+ <!-- Loading -->
392
+ <div x-show="isProcessing" class="flex items-start">
393
+ <div class="bg-white border border-gray-200 p-3 rounded-2xl rounded-tl-none shadow-sm flex items-center gap-2">
394
+ <i class="fa-solid fa-circle-notch fa-spin text-indigo-600"></i>
395
+ <span class="text-sm text-gray-600">
396
+ AI sedang bekerja...
397
+ <span x-show="loopCount > 0" class="text-xs font-bold text-red-500 ml-1">
398
+ (Self-Healing: <span x-text="loopCount"></span>/10)
399
+ </span>
400
+ </span>
401
+ </div>
402
+ </div>
403
+ </div>
404
+
405
+ <!-- Chat Input -->
406
+ <div class="flex-shrink-0 bg-white border-t p-3 flex gap-2 items-center z-10 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
407
+ <input type="text" x-model="userInput" @keydown.enter="sendPrompt" :disabled="isProcessing"
408
+ placeholder="Minta AI ubah kode..."
409
+ class="flex-1 bg-gray-100 text-sm rounded-full px-4 py-2.5 outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50 transition">
410
+ <button @click="sendPrompt" :disabled="isProcessing || !userInput.trim()"
411
+ class="bg-indigo-600 text-white h-10 w-10 rounded-full flex justify-center items-center shadow-md disabled:bg-gray-400 transition transform active:scale-95">
412
+ <i class="fa-solid fa-paper-plane"></i>
413
+ </button>
414
+ </div>
415
+ </div>
416
+
417
+ <!-- CODE PAGE -->
418
+ <div x-show="tab === 'code'" class="h-full flex flex-col" x-transition.opacity style="display:none">
419
+ <div class="flex-shrink-0 bg-[#2d2d2d] text-gray-300 text-xs px-4 py-2 flex justify-between items-center border-b border-black">
420
+ <span class="font-mono flex items-center gap-2"><i class="fa-brands fa-html5 text-orange-500 text-sm"></i> index.html</span>
421
+ <button @click="copyCode" class="hover:text-white transition flex items-center gap-1">
422
+ <i class="fa-regular fa-copy"></i> Salin
423
+ </button>
424
+ </div>
425
+ <textarea x-model="codeBase" spellcheck="false"
426
+ class="flex-1 w-full h-full min-h-0 bg-[#1e1e1e] text-[#d4d4d4] font-mono p-4 text-sm outline-none resize-none leading-relaxed"
427
+ placeholder="<!-- Tulis atau tempel kode HTML di sini -->"></textarea>
428
+ </div>
429
+
430
+ <!-- PREVIEW PAGE -->
431
+ <div x-show="tab === 'preview'" class="h-full flex flex-col bg-white" x-transition.opacity style="display:none">
432
+ <div class="flex-shrink-0 bg-gray-200 text-gray-700 text-xs px-4 py-2 flex justify-between items-center border-b border-gray-300">
433
+ <span class="font-semibold flex items-center gap-2"><i class="fa-solid fa-eye text-indigo-500"></i> Hasil Render</span>
434
+ <button @click="updatePreview" class="hover:text-indigo-600 transition flex items-center gap-1">
435
+ <i class="fa-solid fa-rotate-right"></i> Refresh
436
+ </button>
437
+ </div>
438
+ <div class="flex-1 min-h-0 w-full relative">
439
+ <iframe id="preview-frame" class="absolute inset-0 w-full h-full"></iframe>
440
+ </div>
441
+ </div>
442
+
443
+ </main>
444
+
445
+ <!-- Bottom Nav -->
446
+ <nav class="flex-shrink-0 bg-white border-t flex justify-between text-gray-500 text-xs pb-[env(safe-area-inset-bottom)] shadow-[0_-4px_10px_rgba(0,0,0,0.05)] z-20 px-2">
447
+ <button @click="tab='chat'" class="flex-1 py-3 flex flex-col items-center gap-1.5 transition"
448
+ :class="tab==='chat' ? 'text-indigo-600 font-bold' : 'hover:text-indigo-400'">
449
+ <i class="fa-solid fa-message text-lg"></i> Chat
450
+ </button>
451
+ <button @click="tab='code'" class="flex-1 py-3 flex flex-col items-center gap-1.5 transition"
452
+ :class="tab==='code' ? 'text-indigo-600 font-bold' : 'hover:text-indigo-400'">
453
+ <i class="fa-solid fa-code text-lg"></i> Code
454
+ </button>
455
+ <button @click="tab='preview'" class="flex-1 py-3 flex flex-col items-center gap-1.5 transition"
456
+ :class="tab==='preview' ? 'text-indigo-600 font-bold' : 'hover:text-indigo-400'">
457
+ <i class="fa-solid fa-play text-lg"></i> Preview
458
+ </button>
459
+ </nav>
460
+
461
+ <!-- Toast -->
462
+ <div x-show="toastMsg" x-transition.duration.300ms
463
+ class="absolute top-20 left-1/2 transform -translate-x-1/2 bg-gray-800 text-white px-4 py-2 rounded-full text-sm shadow-xl z-50 whitespace-nowrap">
464
+ <i class="fa-solid fa-circle-check text-green-400 mr-1"></i>
465
+ <span x-text="toastMsg"></span>
466
+ </div>
467
+
468
+ <!-- Error Modal -->
469
+ <div x-show="previewError" class="absolute inset-0 bg-black/60 z-50 flex justify-center items-end p-0"
470
+ x-transition.opacity style="display:none">
471
+ <div class="bg-white rounded-t-2xl w-full max-w-md shadow-2xl flex flex-col" style="max-height:80dvh">
472
+ <div class="flex-shrink-0 flex items-center justify-between gap-3 px-5 pt-5 pb-3 border-b border-gray-100">
473
+ <div class="flex items-center gap-3 text-red-600">
474
+ <div class="bg-red-100 p-2 rounded-full"><i class="fa-solid fa-triangle-exclamation text-lg"></i></div>
475
+ <h2 class="font-bold text-base">Error Terdeteksi!</h2>
476
+ </div>
477
+ <button @click="previewError=null" class="text-gray-400 hover:text-gray-600 text-xl leading-none">&times;</button>
478
+ </div>
479
+ <div class="flex-1 overflow-y-auto px-5 py-3 min-h-0">
480
+ <p class="text-xs text-gray-500 mb-2">Log error lengkap dari preview:</p>
481
+ <div class="bg-gray-950 p-3 rounded-xl text-xs font-mono text-green-400 whitespace-pre-wrap border border-gray-800 leading-relaxed">
482
+ <span x-text="previewError"></span>
483
+ </div>
484
+ </div>
485
+ <div class="flex-shrink-0 px-5 py-4 border-t border-gray-100 bg-white flex flex-col gap-2">
486
+ <p class="text-xs text-gray-500 text-center">Kirim error ini ke AI agar diperbaiki otomatis?</p>
487
+ <div class="flex gap-2">
488
+ <button @click="previewError=null" class="flex-1 bg-gray-100 text-gray-700 py-2.5 rounded-xl text-sm font-semibold hover:bg-gray-200 transition">Abaikan</button>
489
+ <button @click="sendErrorToAI" class="flex-1 bg-indigo-600 text-white py-2.5 rounded-xl text-sm font-semibold hover:bg-indigo-700 shadow-md flex items-center justify-center gap-2 transition">
490
+ <i class="fa-solid fa-robot"></i> Send ke AI
491
+ </button>
492
+ </div>
493
+ </div>
494
+ </div>
495
+ </div>
496
+
497
+ </div>
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,
508
+ toastMsg: '',
509
+ previewError: null,
510
+ _pollTimer: null,
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
518
+ ? this.previewError + '\n\n' + event.data.error
519
+ : event.data.error;
520
+ }
521
+ });
522
+ },
523
+
524
+ showToast(msg) {
525
+ this.toastMsg = msg;
526
+ setTimeout(() => this.toastMsg = '', 3000);
527
+ },
528
+
529
+ scrollToBottom() {
530
+ setTimeout(() => {
531
+ const c = document.getElementById('chat-container');
532
+ if (c) c.scrollTop = c.scrollHeight;
533
+ }, 100);
534
+ },
535
+
536
+ uploadFile(event) {
537
+ const file = event.target.files[0];
538
+ if (!file) return;
539
+ const reader = new FileReader();
540
+ reader.onload = e => {
541
+ this.codeBase = e.target.result;
542
+ this.showToast('File berhasil diunggah!');
543
+ this.tab = 'code';
544
+ };
545
+ reader.readAsText(file);
546
+ event.target.value = '';
547
+ },
548
+
549
+ copyCode() {
550
+ navigator.clipboard.writeText(this.codeBase);
551
+ this.showToast('Kode berhasil disalin!');
552
+ },
553
+
554
+ downloadCode() {
555
+ try {
556
+ const blob = new Blob([this.codeBase], {type:'text/html'});
557
+ const url = URL.createObjectURL(blob);
558
+ const a = document.createElement('a');
559
+ a.href = url; a.download = 'index.html';
560
+ document.body.appendChild(a); a.click();
561
+ document.body.removeChild(a);
562
+ URL.revokeObjectURL(url);
563
+ this.showToast('File berhasil diunduh!');
564
+ } catch { this.showToast('Gagal mengunduh file'); }
565
+ },
566
+
567
+ updatePreview() {
568
+ const iframe = document.getElementById('preview-frame');
569
+ if (!iframe) return;
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
+ })();
583
+ <\/script>`;
584
+ let html = this.codeBase;
585
+ this.previewError = null;
586
+ html = html.includes('<head>')
587
+ ? html.replace('<head>', '<head>' + errorCatcherScript)
588
+ : errorCatcherScript + html;
589
+ iframe.srcdoc = html;
590
+ },
591
+
592
+ sendErrorToAI() {
593
+ const msg = `Perbaiki error berikut yang terdeteksi saat preview:\n\n${this.previewError}\n\nAnalisa penyebabnya berdasarkan error di atas, lalu perbaiki kode menggunakan format <find>.`;
594
+ this.previewError = null;
595
+ this.tab = 'chat';
596
+ this.chatHistory.push({role:'user', content: msg, isErrorPrompt: true});
597
+ this.scrollToBottom();
598
+ this._submitToServer(msg);
599
+ },
600
+
601
+ async sendPrompt() {
602
+ if (!this.userInput.trim() || this.isProcessing) return;
603
+ const prompt = this.userInput;
604
+ this.userInput = '';
605
+ this.chatHistory.push({role:'user', content: prompt});
606
+ this.scrollToBottom();
607
+ this._submitToServer(prompt);
608
+ },
609
+
610
+ /* ── Server-side job flow ── */
611
+ async _submitToServer(instruction) {
612
+ this.isProcessing = true;
613
+ this.loopCount = 0;
614
+ try {
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');
622
+ this._pollJob(data.job_id);
623
+ } catch (err) {
624
+ this.isProcessing = false;
625
+ this.chatHistory.push({role:'ai', content: '❌ ' + err.message});
626
+ this.scrollToBottom();
627
+ }
628
+ },
629
+
630
+ _pollJob(jobId, interval = 1500) {
631
+ clearTimeout(this._pollTimer);
632
+ this._pollTimer = setTimeout(async () => {
633
+ try {
634
+ const res = await fetch(`/api/job/${jobId}`);
635
+ const data = await res.json();
636
+
637
+ this.loopCount = data.loop_count || 0;
638
+
639
+ if (data.status === 'running' || data.status === 'pending') {
640
+ this._pollJob(jobId, interval);
641
+ return;
642
+ }
643
+
644
+ if (data.status === 'done') {
645
+ this.codeBase = data.result.code;
646
+ this.chatHistory.push({
647
+ role: 'ai',
648
+ content: data.result.message,
649
+ action: data.result.action_label,
650
+ });
651
+ } else {
652
+ this.chatHistory.push({
653
+ role: 'ai',
654
+ content: '❌ ' + (data.error || 'Terjadi kesalahan pada server.'),
655
+ });
656
+ }
657
+ } catch {
658
+ this.chatHistory.push({role:'ai', content:'❌ Gagal membaca status job dari server.'});
659
+ } finally {
660
+ this.isProcessing = false;
661
+ this.scrollToBottom();
662
+ }
663
+ }, interval);
664
+ },
665
+
666
+ }));
667
+ });
668
+ </script>
669
+ </body>
670
+ </html>"""
671
+
672
+ @app.route("/")
673
+ def index():
674
+ return Response(HTML, mimetype="text/html")
675
+
676
+
677
+ if __name__ == "__main__":
678
+ app.run(debug=True, port=5000)