Ricky01anjay commited on
Commit
78b0d9d
Β·
verified Β·
1 Parent(s): 99506dc

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +379 -220
app.py CHANGED
@@ -1,23 +1,29 @@
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]
@@ -29,7 +35,7 @@ def new_job(payload: dict) -> str:
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,
@@ -47,8 +53,9 @@ def update_job(jid: str, **kwargs):
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:
@@ -72,16 +79,12 @@ def calculate_similarity(s1: str, s2: str) -> float:
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:
@@ -89,7 +92,7 @@ def fuzzy_replace(source: str, search: str, replacement: str) -> dict:
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
 
@@ -102,12 +105,11 @@ def fuzzy_replace(source: str, search: str, replacement: str) -> dict:
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)
@@ -115,70 +117,163 @@ def fuzzy_replace(source: str, search: str, replacement: str) -> dict:
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
  # ─────────────────────────────────────────────
@@ -200,65 +295,32 @@ def call_ai(prompt: str) -> str:
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
 
@@ -269,23 +331,22 @@ def run_ai_loop(jid: str, user_instruction: str, code_base: str,
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
 
@@ -294,7 +355,6 @@ def api_chat():
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
@@ -314,7 +374,6 @@ def api_job_status(jid):
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]
@@ -344,7 +403,7 @@ HTML = r"""<!DOCTYPE html>
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">
@@ -356,15 +415,15 @@ HTML = r"""<!DOCTYPE html>
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">
@@ -372,9 +431,9 @@ HTML = r"""<!DOCTYPE html>
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">
@@ -388,7 +447,7 @@ HTML = r"""<!DOCTYPE html>
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>
@@ -414,23 +473,108 @@ HTML = r"""<!DOCTYPE html>
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>
@@ -444,15 +588,22 @@ HTML = r"""<!DOCTYPE html>
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>
@@ -460,67 +611,72 @@ HTML = r"""<!DOCTYPE html>
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);
@@ -533,13 +689,16 @@ HTML = r"""<!DOCTYPE html>
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);
@@ -547,82 +706,77 @@ HTML = r"""<!DOCTYPE html>
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
  },
@@ -643,20 +797,24 @@ HTML = r"""<!DOCTYPE html>
643
  }
644
 
645
  if (data.status === 'done') {
646
- this.codeBase = data.result.code;
 
 
 
 
647
  this.chatHistory.push({
648
- role: 'ai',
649
  content: data.result.message,
650
  action: data.result.action_label,
651
  });
652
  } else {
653
  this.chatHistory.push({
654
- role: 'ai',
655
  content: '❌ ' + (data.error || 'Terjadi kesalahan pada server.'),
656
  });
657
  }
658
  } catch {
659
- this.chatHistory.push({role:'ai', content:'❌ Gagal membaca status job dari server.'});
660
  } finally {
661
  this.isProcessing = false;
662
  this.scrollToBottom();
@@ -670,10 +828,11 @@ HTML = r"""<!DOCTYPE html>
670
  </body>
671
  </html>"""
672
 
 
673
  @app.route("/")
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)
 
1
  """
2
  AI HTML Editor β€” Flask Server
3
+ Multi-file support, system prompt from file, Eruda-only preview.
4
  Job disimpan di memori dengan TTL 1 hari.
5
  """
6
 
7
  from flask import Flask, request, jsonify, Response
8
+ import uuid, time, re, threading, requests, os
9
 
10
  app = Flask(__name__)
11
 
12
+ # ─────────────────────────────────────────────
13
+ # LOAD SYSTEM PROMPT FROM FILE
14
+ # ─────────────────────────────────────────────
15
+ _SP_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'system_prompt.txt')
16
+ with open(_SP_PATH, 'r', encoding='utf-8') as _f:
17
+ SYSTEM_PROMPT = _f.read()
18
+
19
  # ─────────────────────────────────────────────
20
  # JOB STORE (in-memory, TTL = 1 hari)
21
  # ─────────────────────────────────────────────
22
  JOBS: dict[str, dict] = {}
23
+ JOB_TTL = 86_400
24
  _lock = threading.Lock()
25
 
26
  def _purge_expired():
 
27
  now = time.time()
28
  with _lock:
29
  expired = [jid for jid, j in JOBS.items() if now - j["created_at"] > JOB_TTL]
 
35
  jid = str(uuid.uuid4())
36
  with _lock:
37
  JOBS[jid] = {
38
+ "status": "pending",
39
  "created_at": time.time(),
40
  "payload": payload,
41
  "result": None,
 
53
  if jid in JOBS:
54
  JOBS[jid].update(kwargs)
55
 
56
+
57
  # ─────────────────────────────────────────────
58
+ # FUZZY REPLACE (Levenshtein-based)
59
  # ─────────────────────────────────────────────
60
 
61
  def levenshtein(a: str, b: str) -> int:
 
79
  return (len(longer) - levenshtein(longer, shorter)) / len(longer)
80
 
81
  def fuzzy_replace(source: str, search: str, replacement: str) -> dict:
 
82
  search_trimmed = search.strip()
83
  if not search_trimmed:
84
  return {"success": False, "result": source}
85
 
86
  # Level 1 β€” whitespace-agnostic regex
87
+ flex = re.sub(r'\s+', r'\\s+', re.escape(search_trimmed))
 
 
 
88
  try:
89
  m = re.search(flex, source)
90
  if m:
 
92
  except re.error:
93
  pass
94
 
95
+ # Level 2 β€” sliding window + Levenshtein
96
  if len(search_trimmed) > 3000:
97
  return {"success": False, "result": source}
98
 
 
105
  best = {"index": -1, "score": 0.0, "length": 0}
106
  n = len(search_lines)
107
 
108
+ for window_size in [max(1, n - 1), n, n + 1]:
109
  for i in range(len(source_lines)):
110
  if i + window_size > len(source_lines):
111
  continue
112
+ window_str = ''.join(l.strip() for l in source_lines[i:i + window_size])
 
113
  if abs(len(window_str) - len(search_str)) > len(search_str) * 0.5:
114
  continue
115
  score = calculate_similarity(window_str, search_str)
 
117
  best = {"index": i, "score": score, "length": window_size}
118
 
119
  if best["score"] > 0.70:
120
+ result_lines = (source_lines[:best["index"]]
121
+ + [replacement]
122
+ + source_lines[best["index"] + best["length"]:])
123
  return {"success": True, "result": '\n'.join(result_lines)}
124
 
125
  return {"success": False, "result": source}
126
 
127
 
128
+ # ─────────────────────────────────────────────
129
+ # PARSE tools_call BLOCKS
130
+ # ───────────────────────��─────────────────────
131
+
132
+ def _parse_eof_content(block: str, tag: str) -> str | None:
133
  """
134
+ Extract content between:
135
+ [tag] (optional text on same line)
136
+ <<EOF >
137
+ ...content...
138
+ EOF
139
+ Returns the content string or None if tag not found.
140
  """
141
+ pattern = (
142
+ rf'\[{re.escape(tag)}\][^\n]*\n' # [tag] line
143
+ r'<<EOF\s*>\s*\n' # <<EOF > line
144
+ r'([\s\S]*?)' # content (captured)
145
+ r'\nEOF(?:\s|$)' # EOF terminator
146
+ )
147
+ m = re.search(pattern, block)
148
+ return m.group(1) if m else None
149
+
150
+
151
+ def parse_tools_call_blocks(ai_response: str) -> list[dict]:
152
+ """Parse all ```tools_call ... ``` blocks from AI response."""
153
+ pattern = r'```tools_call\s*\n([\s\S]*?)```'
154
+ raw_blocks = re.findall(pattern, ai_response)
155
+ results = []
156
+ for block in raw_blocks:
157
+ tool: dict = {}
158
+
159
+ m = re.search(r'\[name\]\s*\n\s*(\S+)', block)
160
+ if m: tool['name'] = m.group(1).strip()
161
+
162
+ m = re.search(r'\[reason\]\s*\n\s*(.+)', block)
163
+ if m: tool['reason'] = m.group(1).strip()
164
 
165
+ m = re.search(r'\[path\]\s*\n\s*(\S+)', block)
166
+ if m: tool['path'] = m.group(1).strip()
167
 
168
+ for tag in ('old_string', 'new_string', 'content'):
169
+ val = _parse_eof_content(block, tag)
170
+ if val is not None:
171
+ tool[tag] = val
172
 
173
+ if tool.get('name') and tool.get('path'):
174
+ results.append(tool)
 
175
 
176
+ return results
177
+
178
+
179
+ # ─────────────────────────────────────────────
180
+ # APPLY tools_call TO FILES
181
+ # ─────────────────────────────────────────────
182
+
183
+ def apply_tools_call(files: dict, ai_response: str) -> dict:
184
+ """
185
+ Parse AI response (tools_call format) dan terapkan ke dict files.
186
+ Return: {files, message, action_label}
187
+ """
188
+ tools = parse_tools_call_blocks(ai_response)
189
+ if not tools:
190
+ raise ValueError(
191
+ "Tidak ada blok ```tools_call``` yang valid ditemukan dalam respons AI. "
192
+ "Gunakan format write_file atau edit_file sesuai instruksi sistem."
193
+ )
194
+
195
+ # Ambil kalimat pembuka "Saya akan..." sebagai pesan balasan
196
+ respond_m = re.search(r'Saya akan[^\n]+', ai_response)
197
+ respond_text = respond_m.group(0).strip() if respond_m else "Kode berhasil diperbarui."
198
+
199
+ temp_files = dict(files)
200
  success_count = 0
201
  is_fuzzy = False
202
 
203
+ for i, tool in enumerate(tools):
204
+ name = tool.get('name', '')
205
+ path = tool.get('path', '')
206
+
207
+ if name == 'write_file':
208
+ content = tool.get('content', '')
209
+ temp_files[path] = content
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  success_count += 1
211
+
212
+ elif name == 'edit_file':
213
+ old_string = tool.get('old_string', '')
214
+ new_string = tool.get('new_string', '')
215
+
216
+ if path not in temp_files:
 
217
  raise ValueError(
218
+ f"[edit_file ke-{i+1}] File '{path}' tidak ditemukan dalam project."
 
 
219
  )
220
 
221
+ current = temp_files[path]
222
+
223
+ # Exact match
224
+ if old_string in current:
225
+ temp_files[path] = current.replace(old_string, new_string, 1)
226
+ success_count += 1
227
+ # Trimmed exact match
228
+ elif old_string.strip() and old_string.strip() in current:
229
+ temp_files[path] = current.replace(old_string.strip(), new_string.strip(), 1)
230
+ success_count += 1
231
+ else:
232
+ # Similarity fallback
233
+ res = fuzzy_replace(current, old_string, new_string)
234
+ if res['success']:
235
+ temp_files[path] = res['result']
236
+ success_count += 1
237
+ is_fuzzy = True
238
+ else:
239
+ raise ValueError(
240
+ f"[edit_file ke-{i+1}, file '{path}'] old_string:\n"
241
+ f"{old_string}\n\n"
242
+ "TIDAK DITEMUKAN meskipun Similarity Fallback sudah aktif. "
243
+ "Salin [old_string] dengan lebih teliti dari isi file yang diberikan!"
244
+ )
245
+ else:
246
+ raise ValueError(f"Tool tidak dikenal '{name}' pada blok ke-{i+1}. Gunakan write_file atau edit_file.")
247
+
248
+ action_label = f"Diperbarui ({success_count} operasi){' ✨ Auto-Fix' if is_fuzzy else ''}"
249
+ return {"files": temp_files, "message": respond_text, "action_label": action_label}
250
+
251
+
252
+ # ─────────────────────────────────────────────
253
+ # BUILD PROMPT
254
+ # Context: system_prompt.txt + semua file + instruksi user + error log (jika loop)
255
+ # ─────────────────────────────────────────────
256
+
257
+ def build_prompt(files: dict, user_instruction: str, error_feedback: str = None) -> str:
258
+ files_context = ""
259
+ for path, content in files.items():
260
+ files_context += f"\n[File: {path}]\n```\n{content}\n```\n"
261
+
262
+ prompt = (
263
+ f"{SYSTEM_PROMPT}\n\n"
264
+ f"--- PROJECT FILES ---\n{files_context}"
265
+ f"--- END OF FILES ---\n\n"
266
+ f"Instruksi User: {user_instruction}"
267
+ )
268
+
269
+ if error_feedback:
270
+ prompt += (
271
+ f"\n\n[ERROR PADA JAWABANMU SEBELUMNYA]:\n{error_feedback}\n\n"
272
+ "Perbaiki [old_string] agar identik persis dengan isi file di PROJECT FILES. "
273
+ "Jika perlu, salin ulang teks tersebut langsung dari sana."
274
+ )
275
+
276
+ return prompt
277
 
278
 
279
  # ─────────────────────────────────────────────
 
295
  return data["result"]["answer"]
296
 
297
 
298
+ def run_ai_loop(jid: str, user_instruction: str, files: dict,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  loop: int = 0, error_feedback: str = None, max_loops: int = 10):
300
+ """Self-healing AI loop β€” rekursif di thread terpisah."""
301
  if loop >= max_loops:
302
  update_job(jid,
303
  status="error",
304
+ error=(
305
+ "Gagal merubah kode setelah 10x loop perbaikan. "
306
+ "AI terus-menerus gagal mencocokkan kode. "
307
+ "Coba beri instruksi yang lebih spesifik."
308
+ ),
309
  loop_count=loop,
310
  )
311
  return
312
 
313
  try:
314
+ prompt = build_prompt(files, user_instruction, error_feedback)
 
 
 
 
315
  ai_text = call_ai(prompt)
316
+ result = apply_tools_call(files, ai_text)
317
 
318
+ update_job(jid, status="done", result=result, loop_count=loop)
 
 
 
 
319
 
320
  except Exception as exc:
 
321
  threading.Thread(
322
  target=run_ai_loop,
323
+ args=(jid, user_instruction, files, loop + 1, str(exc), max_loops),
324
  daemon=True,
325
  ).start()
326
 
 
331
 
332
  @app.route("/api/chat", methods=["POST"])
333
  def api_chat():
334
+ """Body: { instruction: str, files: {path: content} } β†’ { job_id: str }"""
335
+ body = request.get_json(silent=True) or {}
 
 
 
336
  instruction = body.get("instruction", "").strip()
337
+ files = body.get("files", {})
338
 
339
  if not instruction:
340
  return jsonify({"error": "instruction wajib diisi"}), 400
341
+ if not isinstance(files, dict):
342
+ return jsonify({"error": "files harus berupa object {path: content}"}), 400
343
 
344
  jid = new_job({"instruction": instruction})
345
  update_job(jid, status="running", loop_count=0)
346
 
347
  threading.Thread(
348
  target=run_ai_loop,
349
+ args=(jid, instruction, files),
350
  daemon=True,
351
  ).start()
352
 
 
355
 
356
  @app.route("/api/job/<jid>", methods=["GET"])
357
  def api_job_status(jid):
 
358
  job = get_job(jid)
359
  if not job:
360
  return jsonify({"error": "Job tidak ditemukan atau sudah expired (> 1 hari)"}), 404
 
374
 
375
  @app.route("/api/job/<jid>", methods=["DELETE"])
376
  def api_delete_job(jid):
 
377
  with _lock:
378
  if jid in JOBS:
379
  del JOBS[jid]
 
403
  <body class="bg-gray-100 flex justify-center h-[100dvh] overflow-hidden text-gray-800">
404
 
405
  <div x-data="aiEditor()" class="w-full max-w-md h-full bg-white shadow-2xl relative flex flex-col">
406
+
407
  <!-- Header -->
408
  <header class="flex-shrink-0 bg-indigo-600 text-white p-4 flex justify-between items-center shadow-md z-10">
409
  <h1 class="text-lg font-bold flex items-center gap-2">
 
415
  </button>
416
  <label class="cursor-pointer bg-indigo-500 hover:bg-indigo-400 p-2 rounded text-sm transition font-medium shadow">
417
  <i class="fa-solid fa-file-upload"></i> Upload
418
+ <input type="file" class="hidden" @change="uploadFile">
419
  </label>
420
  </div>
421
  </header>
422
 
423
  <!-- Main -->
424
  <main class="flex-1 min-h-0 relative bg-gray-50 flex flex-col overflow-hidden">
425
+
426
+ <!-- ═══ CHAT PAGE ═══ -->
427
  <div x-show="tab === 'chat'" class="h-full flex flex-col" x-transition.opacity>
428
  <div class="flex-1 overflow-y-auto p-4 space-y-4" id="chat-container">
429
  <template x-for="(msg, index) in chatHistory" :key="index">
 
431
  <span class="text-[10px] text-gray-400 mb-1 font-semibold uppercase tracking-wider"
432
  x-text="msg.role === 'user' ? 'Kamu' : 'AI'"></span>
433
  <div class="max-w-[85%] p-3 rounded-2xl shadow-sm text-sm"
434
+ :class="msg.role === 'user'
435
+ ? 'bg-indigo-600 text-white rounded-tr-none'
436
+ : 'bg-white text-gray-800 border border-gray-200 rounded-tl-none'">
437
  <p class="whitespace-pre-wrap leading-relaxed" x-text="msg.content"></p>
438
  <template x-if="msg.action">
439
  <div class="mt-2 pt-2 border-t border-gray-100/30 flex items-center gap-1">
 
447
  </div>
448
  </template>
449
 
450
+ <!-- Loading indicator -->
451
  <div x-show="isProcessing" class="flex items-start">
452
  <div class="bg-white border border-gray-200 p-3 rounded-2xl rounded-tl-none shadow-sm flex items-center gap-2">
453
  <i class="fa-solid fa-circle-notch fa-spin text-indigo-600"></i>
 
473
  </div>
474
  </div>
475
 
476
+ <!-- ═══ CODE PAGE ═══ -->
477
  <div x-show="tab === 'code'" class="h-full flex flex-col" x-transition.opacity style="display:none">
478
+
479
+ <!-- Code Toolbar -->
480
+ <div class="flex-shrink-0 bg-[#2d2d2d] text-gray-300 text-xs px-3 py-2 flex justify-between items-center border-b border-black">
481
+ <div class="flex items-center gap-2 min-w-0">
482
+ <!-- Hamburger -->
483
+ <button @click="showFileList = !showFileList"
484
+ class="flex-shrink-0 hover:text-white transition p-1 rounded hover:bg-white/10"
485
+ :class="showFileList ? 'text-white bg-white/10' : ''">
486
+ <i class="fa-solid fa-bars text-sm"></i>
487
+ </button>
488
+ <!-- Active file name -->
489
+ <span class="font-mono text-gray-200 truncate" x-text="activeFile"></span>
490
+ </div>
491
+ <button @click="copyCode" class="flex-shrink-0 hover:text-white transition flex items-center gap-1 ml-2">
492
  <i class="fa-regular fa-copy"></i> Salin
493
  </button>
494
  </div>
495
+
496
+ <!-- File List Drawer -->
497
+ <div x-show="showFileList"
498
+ x-transition:enter="transition ease-out duration-150"
499
+ x-transition:enter-start="opacity-0 -translate-y-1"
500
+ x-transition:enter-end="opacity-100 translate-y-0"
501
+ x-transition:leave="transition ease-in duration-100"
502
+ x-transition:leave-start="opacity-100"
503
+ x-transition:leave-end="opacity-0"
504
+ class="flex-shrink-0 bg-[#252526] border-b border-black text-gray-300 text-xs z-10">
505
+
506
+ <!-- Drawer header -->
507
+ <div class="px-3 py-2 flex justify-between items-center border-b border-[#3e3e3e]">
508
+ <span class="font-semibold text-gray-400 uppercase tracking-widest text-[10px]">
509
+ <i class="fa-solid fa-folder-open mr-1 text-yellow-500"></i> Project Files
510
+ </span>
511
+ <button @click="showNewFileInput = !showNewFileInput"
512
+ class="flex items-center gap-1 text-gray-400 hover:text-white transition px-2 py-0.5 rounded hover:bg-white/10">
513
+ <i class="fa-solid fa-plus text-xs"></i> New File
514
+ </button>
515
+ </div>
516
+
517
+ <!-- New file input -->
518
+ <div x-show="showNewFileInput" class="px-3 py-2 border-b border-[#3e3e3e] flex gap-2">
519
+ <input x-model="newFileName"
520
+ @keydown.enter="addFile"
521
+ @keydown.escape="showNewFileInput = false"
522
+ placeholder="nama-file.html"
523
+ class="flex-1 bg-[#3c3c3c] text-gray-200 text-xs px-2 py-1.5 rounded outline-none focus:ring-1 focus:ring-indigo-500 placeholder-gray-500">
524
+ <button @click="addFile"
525
+ class="bg-indigo-600 hover:bg-indigo-500 text-white px-2.5 py-1 rounded text-xs transition">
526
+ Tambah
527
+ </button>
528
+ <button @click="showNewFileInput = false"
529
+ class="text-gray-500 hover:text-gray-300 px-1 transition">
530
+ <i class="fa-solid fa-xmark"></i>
531
+ </button>
532
+ </div>
533
+
534
+ <!-- File list -->
535
+ <div class="max-h-44 overflow-y-auto">
536
+ <template x-for="fname in Object.keys(files)" :key="fname">
537
+ <div @click="selectFile(fname)"
538
+ class="flex items-center justify-between px-3 py-2 cursor-pointer transition group"
539
+ :class="fname === activeFile
540
+ ? 'bg-[#094771] text-white'
541
+ : 'hover:bg-[#37373d] text-gray-300'">
542
+ <span class="font-mono flex items-center gap-2 truncate text-xs">
543
+ <i class="fa-brands fa-html5 text-orange-400 flex-shrink-0"
544
+ x-show="fname.endsWith('.html')"></i>
545
+ <i class="fa-brands fa-css3-alt text-blue-400 flex-shrink-0"
546
+ x-show="fname.endsWith('.css')"></i>
547
+ <i class="fa-brands fa-js text-yellow-400 flex-shrink-0"
548
+ x-show="fname.endsWith('.js')"></i>
549
+ <i class="fa-solid fa-file-code text-gray-400 flex-shrink-0"
550
+ x-show="!fname.endsWith('.html') && !fname.endsWith('.css') && !fname.endsWith('.js')"></i>
551
+ <span x-text="fname" class="truncate"></span>
552
+ </span>
553
+ <button @click.stop="deleteFile(fname)"
554
+ x-show="Object.keys(files).length > 1"
555
+ class="flex-shrink-0 ml-2 text-gray-600 group-hover:text-gray-400 hover:!text-red-400 transition">
556
+ <i class="fa-solid fa-xmark text-xs"></i>
557
+ </button>
558
+ </div>
559
+ </template>
560
+ </div>
561
+ </div>
562
+
563
+ <!-- Editor Textarea -->
564
+ <textarea :value="files[activeFile] || ''"
565
+ @input="files[activeFile] = $event.target.value"
566
+ spellcheck="false"
567
+ class="flex-1 w-full min-h-0 bg-[#1e1e1e] text-[#d4d4d4] font-mono p-4 text-sm outline-none resize-none leading-relaxed"
568
+ placeholder="<!-- Tulis atau tempel kode di sini -->"></textarea>
569
  </div>
570
 
571
+ <!-- ═══ PREVIEW PAGE ═══ -->
572
  <div x-show="tab === 'preview'" class="h-full flex flex-col bg-white" x-transition.opacity style="display:none">
573
  <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">
574
+ <span class="font-semibold flex items-center gap-2">
575
+ <i class="fa-solid fa-eye text-indigo-500"></i> Hasil Render
576
+ <span class="text-gray-400 font-normal">β€” debug via Eruda (pojok bawah)</span>
577
+ </span>
578
  <button @click="updatePreview" class="hover:text-indigo-600 transition flex items-center gap-1">
579
  <i class="fa-solid fa-rotate-right"></i> Refresh
580
  </button>
 
588
 
589
  <!-- Bottom Nav -->
590
  <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">
591
+ <button @click="tab='chat'; showFileList=false"
592
+ class="flex-1 py-3 flex flex-col items-center gap-1.5 transition"
593
  :class="tab==='chat' ? 'text-indigo-600 font-bold' : 'hover:text-indigo-400'">
594
  <i class="fa-solid fa-message text-lg"></i> Chat
595
  </button>
596
+ <button @click="tab='code'"
597
+ class="flex-1 py-3 flex flex-col items-center gap-1.5 transition"
598
  :class="tab==='code' ? 'text-indigo-600 font-bold' : 'hover:text-indigo-400'">
599
  <i class="fa-solid fa-code text-lg"></i> Code
600
+ <!-- File count badge -->
601
+ <span x-show="Object.keys(files).length > 1"
602
+ class="absolute mt-0 ml-5 bg-indigo-500 text-white text-[9px] rounded-full px-1.5 leading-4 font-bold"
603
+ x-text="Object.keys(files).length"></span>
604
  </button>
605
+ <button @click="tab='preview'; showFileList=false"
606
+ class="flex-1 py-3 flex flex-col items-center gap-1.5 transition"
607
  :class="tab==='preview' ? 'text-indigo-600 font-bold' : 'hover:text-indigo-400'">
608
  <i class="fa-solid fa-play text-lg"></i> Preview
609
  </button>
 
611
 
612
  <!-- Toast -->
613
  <div x-show="toastMsg" x-transition.duration.300ms
614
+ 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 pointer-events-none">
615
  <i class="fa-solid fa-circle-check text-green-400 mr-1"></i>
616
  <span x-text="toastMsg"></span>
617
  </div>
618
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
619
  </div>
620
 
621
  <script>
622
  document.addEventListener('alpine:init', () => {
623
  Alpine.data('aiEditor', () => ({
624
  tab: 'chat',
625
+ files: {
626
+ 'index.html': `<!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>`
627
+ },
628
+ activeFile: 'index.html',
629
+ showFileList: false,
630
+ showNewFileInput: false,
631
+ newFileName: '',
632
+ chatHistory: [{ role: 'ai', content: 'Halo! Saya AI Editor. Minta saya membuat atau mengubah file apa saja di project ini!' }],
633
  userInput: '',
634
  isProcessing: false,
635
  loopCount: 0,
636
  toastMsg: '',
 
637
  _pollTimer: null,
638
 
639
  init() {
640
+ this.$watch('tab', val => {
641
+ if (val === 'preview') this.updatePreview();
642
+ if (val !== 'code') this.showFileList = false;
 
 
 
 
 
643
  });
644
  },
645
 
646
+ /* ─── File Management ─── */
647
+
648
+ selectFile(fname) {
649
+ this.activeFile = fname;
650
+ this.showFileList = false;
651
+ },
652
+
653
+ addFile() {
654
+ const name = this.newFileName.trim();
655
+ if (!name) return;
656
+ // Avoid overwriting existing file without confirmation
657
+ if (!this.files[name]) {
658
+ this.files[name] = '';
659
+ }
660
+ this.activeFile = name;
661
+ this.newFileName = '';
662
+ this.showNewFileInput = false;
663
+ this.showFileList = false;
664
+ this.showToast(`File '${name}' dibuat!`);
665
+ },
666
+
667
+ deleteFile(fname) {
668
+ if (Object.keys(this.files).length <= 1) return;
669
+ const newFiles = Object.assign({}, this.files);
670
+ delete newFiles[fname];
671
+ this.files = newFiles;
672
+ if (this.activeFile === fname) {
673
+ this.activeFile = Object.keys(newFiles)[0];
674
+ }
675
+ this.showToast(`File '${fname}' dihapus.`);
676
+ },
677
+
678
+ /* ─── UI Helpers ─── */
679
+
680
  showToast(msg) {
681
  this.toastMsg = msg;
682
  setTimeout(() => this.toastMsg = '', 3000);
 
689
  }, 100);
690
  },
691
 
692
+ /* ─── File I/O ─── */
693
+
694
  uploadFile(event) {
695
  const file = event.target.files[0];
696
  if (!file) return;
697
  const reader = new FileReader();
698
  reader.onload = e => {
699
+ this.files[file.name] = e.target.result;
700
+ this.activeFile = file.name;
701
+ this.showToast(`'${file.name}' berhasil diunggah!`);
702
  this.tab = 'code';
703
  };
704
  reader.readAsText(file);
 
706
  },
707
 
708
  copyCode() {
709
+ navigator.clipboard.writeText(this.files[this.activeFile] || '');
710
  this.showToast('Kode berhasil disalin!');
711
  },
712
 
713
  downloadCode() {
714
  try {
715
+ const content = this.files[this.activeFile] || '';
716
+ const blob = new Blob([content], { type: 'text/plain' });
717
  const url = URL.createObjectURL(blob);
718
  const a = document.createElement('a');
719
+ a.href = url;
720
+ a.download = this.activeFile;
721
+ document.body.appendChild(a);
722
+ a.click();
723
  document.body.removeChild(a);
724
  URL.revokeObjectURL(url);
725
+ this.showToast(`'${this.activeFile}' berhasil diunduh!`);
726
+ } catch {
727
+ this.showToast('Gagal mengunduh file.');
728
+ }
729
  },
730
 
731
+ /* ─── Preview (Eruda only) ─── */
732
+
733
  updatePreview() {
734
  const iframe = document.getElementById('preview-frame');
735
  if (!iframe) return;
736
+
737
+ // Render index.html first, fallback to first file
738
+ let html = this.files['index.html'] || Object.values(this.files)[0] || '';
739
+
740
+ // Inject Eruda debugger only β€” no custom error modal
741
+ const erudaSnippet = [
742
+ '<script src="https://cdn.jsdelivr.net/npm/eruda"><\/script>',
743
+ '<script>eruda.init();<\/script>'
744
+ ].join('');
745
+
746
+ html = html.includes('</body>')
747
+ ? html.replace('</body>', erudaSnippet + '</body>')
748
+ : html + erudaSnippet;
749
+
 
 
 
 
 
750
  iframe.srcdoc = html;
751
  },
752
 
753
+ /* ─── Chat / AI ─── */
 
 
 
 
 
 
 
754
 
755
  async sendPrompt() {
756
  if (!this.userInput.trim() || this.isProcessing) return;
757
  const prompt = this.userInput;
758
  this.userInput = '';
759
+ this.chatHistory.push({ role: 'user', content: prompt });
760
  this.scrollToBottom();
761
  this._submitToServer(prompt);
762
  },
763
 
 
764
  async _submitToServer(instruction) {
765
  this.isProcessing = true;
766
  this.loopCount = 0;
767
  try {
768
  const res = await fetch('/api/chat', {
769
  method: 'POST',
770
+ headers: { 'Content-Type': 'application/json' },
771
+ // Context: only all files + latest user instruction
772
+ body: JSON.stringify({ instruction, files: this.files }),
773
  });
774
  const data = await res.json();
775
  if (!res.ok) throw new Error(data.error || 'Server error');
776
  this._pollJob(data.job_id);
777
  } catch (err) {
778
  this.isProcessing = false;
779
+ this.chatHistory.push({ role: 'ai', content: '❌ ' + err.message });
780
  this.scrollToBottom();
781
  }
782
  },
 
797
  }
798
 
799
  if (data.status === 'done') {
800
+ this.files = data.result.files;
801
+ // Ensure activeFile still exists after AI response
802
+ if (!this.files[this.activeFile]) {
803
+ this.activeFile = Object.keys(this.files)[0] || 'index.html';
804
+ }
805
  this.chatHistory.push({
806
+ role: 'ai',
807
  content: data.result.message,
808
  action: data.result.action_label,
809
  });
810
  } else {
811
  this.chatHistory.push({
812
+ role: 'ai',
813
  content: '❌ ' + (data.error || 'Terjadi kesalahan pada server.'),
814
  });
815
  }
816
  } catch {
817
+ this.chatHistory.push({ role: 'ai', content: '❌ Gagal membaca status job dari server.' });
818
  } finally {
819
  this.isProcessing = false;
820
  this.scrollToBottom();
 
828
  </body>
829
  </html>"""
830
 
831
+
832
  @app.route("/")
833
  def index():
834
  return Response(HTML, mimetype="text/html")
835
 
836
 
837
  if __name__ == "__main__":
838
+ app.run(host="0.0.0.0", port=7860)