Ai-HTML-Editor / app.py
Ricky01anjay's picture
Update app.py
5374126 verified
"""
AI HTML Editor β€” Flask Server
Multi-file support, system prompt from file, Eruda-only preview.
Job disimpan di memori dengan TTL 1 hari.
"""
from flask import Flask, request, jsonify, Response
import uuid, time, re, threading, requests, os
app = Flask(__name__)
# ─────────────────────────────────────────────
# LOAD SYSTEM PROMPT FROM FILE
# ─────────────────────────────────────────────
_SP_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'system_prompt.md')
with open(_SP_PATH, 'r', encoding='utf-8') as _f:
SYSTEM_PROMPT = _f.read()
# ─────────────────────────────────────────────
# JOB STORE (in-memory, TTL = 1 hari)
# ─────────────────────────────────────────────
JOBS: dict[str, dict] = {}
JOB_TTL = 86_400
_lock = threading.Lock()
def _purge_expired():
now = time.time()
with _lock:
expired = [jid for jid, j in JOBS.items() if now - j["created_at"] > JOB_TTL]
for jid in expired:
del JOBS[jid]
def new_job(payload: dict) -> str:
_purge_expired()
jid = str(uuid.uuid4())
with _lock:
JOBS[jid] = {
"status": "pending",
"created_at": time.time(),
"payload": payload,
"result": None,
"error": None,
}
return jid
def get_job(jid: str) -> dict | None:
_purge_expired()
with _lock:
return JOBS.get(jid)
def update_job(jid: str, **kwargs):
with _lock:
if jid in JOBS:
JOBS[jid].update(kwargs)
# ─────────────────────────────────────────────
# FUZZY REPLACE (Levenshtein-based)
# ─────────────────────────────────────────────
def levenshtein(a: str, b: str) -> int:
la, lb = len(a), len(b)
if la == 0: return lb
if lb == 0: return la
matrix = [list(range(la + 1))] + [[i] + [0] * la for i in range(1, lb + 1)]
for i in range(1, lb + 1):
for j in range(1, la + 1):
if b[i-1] == a[j-1]:
matrix[i][j] = matrix[i-1][j-1]
else:
matrix[i][j] = 1 + min(matrix[i-1][j-1], matrix[i][j-1], matrix[i-1][j])
return matrix[lb][la]
def calculate_similarity(s1: str, s2: str) -> float:
longer = s1 if len(s1) >= len(s2) else s2
shorter = s1 if len(s1) < len(s2) else s2
if len(longer) == 0:
return 1.0
return (len(longer) - levenshtein(longer, shorter)) / len(longer)
def fuzzy_replace(source: str, search: str, replacement: str) -> dict:
search_trimmed = search.strip()
if not search_trimmed:
return {"success": False, "result": source}
# Level 1 β€” whitespace-agnostic regex
flex = re.sub(r'\s+', r'\\s+', re.escape(search_trimmed))
try:
m = re.search(flex, source)
if m:
return {"success": True, "result": source[:m.start()] + replacement.strip() + source[m.end():]}
except re.error:
pass
# Level 2 β€” sliding window + Levenshtein
if len(search_trimmed) > 3000:
return {"success": False, "result": source}
source_lines = source.split('\n')
search_lines = [l.strip() for l in search_trimmed.split('\n') if l.strip()]
if not search_lines:
return {"success": False, "result": source}
search_str = ''.join(search_lines)
best = {"index": -1, "score": 0.0, "length": 0}
n = len(search_lines)
for window_size in [max(1, n - 1), n, n + 1]:
for i in range(len(source_lines)):
if i + window_size > len(source_lines):
continue
window_str = ''.join(l.strip() for l in source_lines[i:i + window_size])
if abs(len(window_str) - len(search_str)) > len(search_str) * 0.5:
continue
score = calculate_similarity(window_str, search_str)
if score > best["score"]:
best = {"index": i, "score": score, "length": window_size}
if best["score"] > 0.70:
result_lines = (source_lines[:best["index"]]
+ [replacement]
+ source_lines[best["index"] + best["length"]:])
return {"success": True, "result": '\n'.join(result_lines)}
return {"success": False, "result": source}
# ─────────────────────────────────────────────
# PARSE tools_call BLOCKS
# ─────────────────────────────────────────────
def _parse_eof_content(block: str, tag: str) -> str | None:
"""
Extract content between:
[tag] (optional text on same line)
<<EOF >
...content...
EOF
Returns the content string or None if tag not found.
"""
pattern = (
rf'\[{re.escape(tag)}\][^\n]*\n' # [tag] line
r'<<EOF\s*>\s*\n' # <<EOF > line
r'([\s\S]*?)' # content (captured)
r'\nEOF(?:\s|$)' # EOF terminator
)
m = re.search(pattern, block)
return m.group(1) if m else None
def parse_tools_call_blocks(ai_response: str) -> list[dict]:
"""Parse all ```tools_call ... ``` blocks from AI response."""
pattern = r'```tools_call\s*\n([\s\S]*?)```'
raw_blocks = re.findall(pattern, ai_response)
results = []
for block in raw_blocks:
tool: dict = {}
m = re.search(r'\[name\]\s*\n\s*(\S+)', block)
if m: tool['name'] = m.group(1).strip()
m = re.search(r'\[reason\]\s*\n\s*(.+)', block)
if m: tool['reason'] = m.group(1).strip()
m = re.search(r'\[path\]\s*\n\s*(\S+)', block)
if m: tool['path'] = m.group(1).strip()
for tag in ('old_string', 'new_string', 'content'):
val = _parse_eof_content(block, tag)
if val is not None:
tool[tag] = val
if tool.get('name') and tool.get('path'):
results.append(tool)
return results
# ─────────────────────────────────────────────
# APPLY tools_call TO FILES
# ─────────────────────────────────────────────
def apply_tools_call(files: dict, ai_response: str) -> dict:
"""
Parse AI response (tools_call format) dan terapkan ke dict files.
Return: {files, message, action_label}
"""
tools = parse_tools_call_blocks(ai_response)
if not tools:
raise ValueError(
"Tidak ada blok ```tools_call``` yang valid ditemukan dalam respons AI. "
"Gunakan format write_file atau edit_file sesuai instruksi sistem."
)
# Ambil kalimat pembuka "Saya akan..." sebagai pesan balasan
respond_m = re.search(r'Saya akan[^\n]+', ai_response)
respond_text = respond_m.group(0).strip() if respond_m else "Kode berhasil diperbarui."
temp_files = dict(files)
success_count = 0
is_fuzzy = False
for i, tool in enumerate(tools):
name = tool.get('name', '')
path = tool.get('path', '')
if name == 'write_file':
content = tool.get('content', '')
temp_files[path] = content
success_count += 1
elif name == 'edit_file':
old_string = tool.get('old_string', '')
new_string = tool.get('new_string', '')
if path not in temp_files:
raise ValueError(
f"[edit_file ke-{i+1}] File '{path}' tidak ditemukan dalam project."
)
current = temp_files[path]
# Exact match
if old_string in current:
temp_files[path] = current.replace(old_string, new_string, 1)
success_count += 1
# Trimmed exact match
elif old_string.strip() and old_string.strip() in current:
temp_files[path] = current.replace(old_string.strip(), new_string.strip(), 1)
success_count += 1
else:
# Similarity fallback
res = fuzzy_replace(current, old_string, new_string)
if res['success']:
temp_files[path] = res['result']
success_count += 1
is_fuzzy = True
else:
raise ValueError(
f"[edit_file ke-{i+1}, file '{path}'] old_string:\n"
f"{old_string}\n\n"
"TIDAK DITEMUKAN meskipun Similarity Fallback sudah aktif. "
"Salin [old_string] dengan lebih teliti dari isi file yang diberikan!"
)
else:
raise ValueError(f"Tool tidak dikenal '{name}' pada blok ke-{i+1}. Gunakan write_file atau edit_file.")
action_label = f"Diperbarui ({success_count} operasi){' ✨ Auto-Fix' if is_fuzzy else ''}"
reasons = [t.get('reason', '') for t in tools if t.get('reason')]
return {"files": temp_files, "message": respond_text, "action_label": action_label, "reasons": reasons}
# ─────────────────────────────────────────────
# BUILD PROMPT
# Context: system_prompt.txt + semua file + instruksi user + error log (jika loop)
# ─────────────────────────────────────────────
def build_prompt(files: dict, user_instruction: str, error_feedback: str = None) -> str:
files_context = ""
for path, content in files.items():
files_context += f"\n[File: {path}]\n```\n{content}\n```\n"
prompt = (
f"{SYSTEM_PROMPT}\n\n"
f"--- PROJECT FILES ---\n{files_context}"
f"--- END OF FILES ---\n\n"
f"Instruksi User: {user_instruction}"
)
if error_feedback:
prompt += (
f"\n\n[ERROR PADA JAWABANMU SEBELUMNYA]:\n{error_feedback}\n\n"
"Perbaiki [old_string] agar identik persis dengan isi file di PROJECT FILES. "
"Jika perlu, salin ulang teks tersebut langsung dari sana."
)
return prompt
# ─────────────────────────────────────────────
# AI CALL
# ─────────────────────────────────────────────
GEMINI_API = "https://puruboy-api.vercel.app/api/ai/gemini-v2"
def call_ai(prompt: str) -> str:
resp = requests.post(
GEMINI_API,
json={"prompt": prompt},
timeout=60,
headers={"Content-Type": "application/json"},
)
resp.raise_for_status()
data = resp.json()
if not data.get("success"):
raise RuntimeError("Gagal terhubung ke API / Server Error.")
return data["result"]["answer"]
def run_ai_loop(jid: str, user_instruction: str, files: dict,
loop: int = 0, error_feedback: str = None, max_loops: int = 10):
"""Self-healing AI loop β€” rekursif di thread terpisah."""
if loop >= max_loops:
update_job(jid,
status="error",
error=(
"Gagal merubah kode setelah 10x loop perbaikan. "
"AI terus-menerus gagal mencocokkan kode. "
"Coba beri instruksi yang lebih spesifik."
),
loop_count=loop,
)
return
try:
prompt = build_prompt(files, user_instruction, error_feedback)
ai_text = call_ai(prompt)
result = apply_tools_call(files, ai_text)
update_job(jid, status="done", result=result, loop_count=loop)
except Exception as exc:
threading.Thread(
target=run_ai_loop,
args=(jid, user_instruction, files, loop + 1, str(exc), max_loops),
daemon=True,
).start()
# ─────────────────────────────────────────────
# ROUTES
# ─────────────────────────────────────────────
@app.route("/api/chat", methods=["POST"])
def api_chat():
"""Body: { instruction: str, files: {path: content} } β†’ { job_id: str }"""
body = request.get_json(silent=True) or {}
instruction = body.get("instruction", "").strip()
files = body.get("files", {})
if not instruction:
return jsonify({"error": "instruction wajib diisi"}), 400
if not isinstance(files, dict):
return jsonify({"error": "files harus berupa object {path: content}"}), 400
jid = new_job({"instruction": instruction})
update_job(jid, status="running", loop_count=0)
threading.Thread(
target=run_ai_loop,
args=(jid, instruction, files),
daemon=True,
).start()
return jsonify({"job_id": jid}), 202
@app.route("/api/job/<jid>", methods=["GET"])
def api_job_status(jid):
job = get_job(jid)
if not job:
return jsonify({"error": "Job tidak ditemukan atau sudah expired (> 1 hari)"}), 404
resp = {
"job_id": jid,
"status": job["status"],
"loop_count": job.get("loop_count", 0),
}
if job["status"] == "done":
resp["result"] = job["result"]
if job["status"] == "error":
resp["error"] = job["error"]
return jsonify(resp)
@app.route("/api/job/<jid>", methods=["DELETE"])
def api_delete_job(jid):
with _lock:
if jid in JOBS:
del JOBS[jid]
return jsonify({"deleted": True})
return jsonify({"error": "Job tidak ditemukan"}), 404
# ─────────────────────────────────────────────
# UI (embedded HTML)
# ─────────────────────────────────────────────
HTML = r"""<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>AI HTML Editor</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
iframe { border: none; width: 100%; height: 100%; }
@keyframes loadingSlide {
0% { transform: translateX(-150%); }
100% { transform: translateX(300%); }
}
.loading-fill {
width: 45%;
height: 100%;
background: linear-gradient(90deg, #818cf8, #6366f1, #4f46e5);
border-radius: 999px;
animation: loadingSlide 1.1s cubic-bezier(.4,0,.6,1) infinite;
}
</style>
</head>
<body class="bg-gray-100 flex justify-center h-[100dvh] overflow-hidden text-gray-800">
<div x-data="aiEditor()" class="w-full max-w-md h-full bg-white shadow-2xl relative flex flex-col">
<!-- Header -->
<header class="flex-shrink-0 bg-indigo-600 text-white px-4 py-3 flex justify-between items-center shadow-md z-10">
<h1 class="text-lg font-bold flex items-center gap-2">
<i class="fa-solid fa-wand-magic-sparkles"></i> AI Editor
</h1>
<!-- Hamburger Menu -->
<div class="relative">
<button @click="showMenu = !showMenu"
class="bg-indigo-500 hover:bg-indigo-400 w-9 h-9 rounded flex items-center justify-center transition shadow"
:class="showMenu ? 'bg-indigo-400' : ''">
<i class="fa-solid fa-bars text-base"></i>
</button>
<!-- Dropdown -->
<div x-show="showMenu"
@click.away="showMenu = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 top-full mt-2 w-44 bg-white rounded-xl shadow-2xl border border-gray-100 overflow-hidden z-50 text-gray-700"
style="display:none">
<button @click="downloadZip(); showMenu=false"
class="w-full text-left px-4 py-3 text-sm hover:bg-indigo-50 flex items-center gap-3 transition border-b border-gray-100">
<i class="fa-solid fa-file-zipper text-indigo-500 w-4"></i>
<span class="font-medium">Download ZIP</span>
</button>
<button @click="downloadCode(); showMenu=false"
class="w-full text-left px-4 py-3 text-sm hover:bg-indigo-50 flex items-center gap-3 transition border-b border-gray-100">
<i class="fa-solid fa-file-arrow-down text-emerald-500 w-4"></i>
<span class="font-medium">Simpan File</span>
</button>
<label class="w-full text-left px-4 py-3 text-sm hover:bg-indigo-50 flex items-center gap-3 transition cursor-pointer">
<i class="fa-solid fa-file-arrow-up text-orange-500 w-4"></i>
<span class="font-medium">Upload File</span>
<input type="file" class="hidden" @change="uploadFile($event); showMenu=false">
</label>
</div>
</div>
</header>
<!-- Loading bar (visible on all tabs saat polling) -->
<div x-show="isProcessing"
class="flex-shrink-0 h-1 bg-indigo-100 overflow-hidden"
style="display:none">
<div class="loading-fill"></div>
</div>
<main class="flex-1 min-h-0 relative bg-gray-50 flex flex-col overflow-hidden">
<!-- ═══ CHAT PAGE ═══ -->
<div x-show="tab === 'chat'" class="h-full flex flex-col" x-transition.opacity>
<div class="flex-1 overflow-y-auto p-4 space-y-4" id="chat-container">
<template x-for="(msg, index) in chatHistory" :key="index">
<div class="flex flex-col" :class="msg.role === 'user' ? 'items-end' : 'items-start'">
<span class="text-[10px] text-gray-400 mb-1 font-semibold uppercase tracking-wider"
x-text="msg.role === 'user' ? 'Kamu' : 'AI'"></span>
<div class="max-w-[85%] p-3 rounded-2xl shadow-sm text-sm"
:class="msg.role === 'user'
? 'bg-indigo-600 text-white rounded-tr-none'
: 'bg-white text-gray-800 border border-gray-200 rounded-tl-none'">
<p class="whitespace-pre-wrap leading-relaxed" x-text="msg.content"></p>
<!-- Reasons dari tool calls -->
<template x-if="msg.reasons && msg.reasons.length">
<div class="mt-2 space-y-1">
<template x-for="(r, ri) in msg.reasons" :key="ri">
<div class="text-xs bg-indigo-50 border border-indigo-100 rounded-lg px-2 py-1.5 text-indigo-700 flex items-start gap-1.5">
<i class="fa-solid fa-pencil text-indigo-400 mt-0.5 flex-shrink-0 text-[10px]"></i>
<span x-text="r" class="leading-snug"></span>
</div>
</template>
</div>
</template>
<template x-if="msg.action">
<div class="mt-2 pt-2 border-t border-gray-100/30 flex items-center gap-1">
<span class="text-[10px] px-2 py-0.5 rounded-full font-bold flex items-center gap-1 bg-emerald-100 text-emerald-700">
<i class="fa-solid fa-pen-to-square"></i>
<span x-text="msg.action"></span>
</span>
</div>
</template>
</div>
</div>
</template>
<!-- Loading indicator -->
<div x-show="isProcessing" class="flex items-start">
<div class="bg-white border border-gray-200 p-3 rounded-2xl rounded-tl-none shadow-sm flex items-center gap-2">
<i class="fa-solid fa-circle-notch fa-spin text-indigo-600"></i>
<span class="text-sm text-gray-600">
AI sedang bekerja...
<span x-show="loopCount > 0" class="text-xs font-bold text-red-500 ml-1">
(Self-Healing: <span x-text="loopCount"></span>/10)
</span>
</span>
</div>
</div>
</div>
<!-- Chat Input -->
<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)]">
<input type="text" x-model="userInput" @keydown.enter="sendPrompt" :disabled="isProcessing"
placeholder="Minta AI ubah kode..."
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">
<button @click="sendPrompt" :disabled="isProcessing || !userInput.trim()"
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">
<i class="fa-solid fa-paper-plane"></i>
</button>
</div>
</div>
<!-- ═══ CODE PAGE ═══ -->
<div x-show="tab === 'code'" class="h-full flex flex-col" x-transition.opacity style="display:none">
<!-- Code Toolbar -->
<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">
<div class="flex items-center gap-2 min-w-0">
<!-- Hamburger -->
<button @click="showFileList = !showFileList"
class="flex-shrink-0 hover:text-white transition p-1 rounded hover:bg-white/10"
:class="showFileList ? 'text-white bg-white/10' : ''">
<i class="fa-solid fa-bars text-sm"></i>
</button>
<!-- Active file name -->
<span class="font-mono text-gray-200 truncate" x-text="activeFile"></span>
</div>
<button @click="copyCode" class="flex-shrink-0 hover:text-white transition flex items-center gap-1 ml-2">
<i class="fa-regular fa-copy"></i> Salin
</button>
</div>
<!-- File List Drawer -->
<div x-show="showFileList"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="flex-shrink-0 bg-[#252526] border-b border-black text-gray-300 text-xs z-10">
<!-- Drawer header -->
<div class="px-3 py-2 flex justify-between items-center border-b border-[#3e3e3e]">
<span class="font-semibold text-gray-400 uppercase tracking-widest text-[10px]">
<i class="fa-solid fa-folder-open mr-1 text-yellow-500"></i> Project Files
</span>
<button @click="showNewFileInput = !showNewFileInput"
class="flex items-center gap-1 text-gray-400 hover:text-white transition px-2 py-0.5 rounded hover:bg-white/10">
<i class="fa-solid fa-plus text-xs"></i> New File
</button>
</div>
<!-- New file input -->
<div x-show="showNewFileInput" class="px-3 py-2 border-b border-[#3e3e3e] flex gap-2">
<input x-model="newFileName"
@keydown.enter="addFile"
@keydown.escape="showNewFileInput = false"
placeholder="nama-file.html"
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">
<button @click="addFile"
class="bg-indigo-600 hover:bg-indigo-500 text-white px-2.5 py-1 rounded text-xs transition">
Tambah
</button>
<button @click="showNewFileInput = false"
class="text-gray-500 hover:text-gray-300 px-1 transition">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<!-- File list -->
<div class="max-h-44 overflow-y-auto">
<template x-for="fname in Object.keys(files)" :key="fname">
<div @click="selectFile(fname)"
class="flex items-center justify-between px-3 py-2 cursor-pointer transition group"
:class="fname === activeFile
? 'bg-[#094771] text-white'
: 'hover:bg-[#37373d] text-gray-300'">
<span class="font-mono flex items-center gap-2 truncate text-xs">
<i class="fa-brands fa-html5 text-orange-400 flex-shrink-0"
x-show="fname.endsWith('.html')"></i>
<i class="fa-brands fa-css3-alt text-blue-400 flex-shrink-0"
x-show="fname.endsWith('.css')"></i>
<i class="fa-brands fa-js text-yellow-400 flex-shrink-0"
x-show="fname.endsWith('.js')"></i>
<i class="fa-solid fa-file-code text-gray-400 flex-shrink-0"
x-show="!fname.endsWith('.html') && !fname.endsWith('.css') && !fname.endsWith('.js')"></i>
<span x-text="fname" class="truncate"></span>
</span>
<button @click.stop="deleteFile(fname)"
x-show="Object.keys(files).length > 1"
class="flex-shrink-0 ml-2 text-gray-600 group-hover:text-gray-400 hover:!text-red-400 transition">
<i class="fa-solid fa-xmark text-xs"></i>
</button>
</div>
</template>
</div>
</div>
<!-- Editor Textarea -->
<textarea :value="files[activeFile] || ''"
@input="files[activeFile] = $event.target.value"
spellcheck="false"
class="flex-1 w-full min-h-0 bg-[#1e1e1e] text-[#d4d4d4] font-mono p-4 text-sm outline-none resize-none leading-relaxed"
placeholder="<!-- Tulis atau tempel kode di sini -->"></textarea>
</div>
<!-- ═══ PREVIEW PAGE ═══ -->
<div x-show="tab === 'preview'" class="h-full flex flex-col bg-white" x-transition.opacity style="display:none">
<div class="flex-shrink-0 bg-gray-100 text-gray-500 px-3 py-1.5 flex justify-between items-center border-b border-gray-200">
<span class="text-xs flex items-center gap-1.5">
<i class="fa-solid fa-bug text-indigo-400"></i>
<span class="text-gray-400">Eruda</span>
</span>
<button @click="updatePreview" class="text-xs hover:text-indigo-600 transition flex items-center gap-1 px-2 py-1 rounded hover:bg-white">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
<div class="flex-1 min-h-0 w-full relative">
<iframe id="preview-frame" class="absolute inset-0 w-full h-full"></iframe>
</div>
</div>
</main>
<!-- Bottom Nav -->
<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">
<button @click="tab='chat'; showFileList=false"
class="flex-1 py-3 flex flex-col items-center gap-1.5 transition"
:class="tab==='chat' ? 'text-indigo-600 font-bold' : 'hover:text-indigo-400'">
<i class="fa-solid fa-message text-lg"></i> Chat
</button>
<button @click="tab='code'"
class="flex-1 py-3 flex flex-col items-center gap-1.5 transition"
:class="tab==='code' ? 'text-indigo-600 font-bold' : 'hover:text-indigo-400'">
<i class="fa-solid fa-code text-lg"></i> Code
<!-- File count badge -->
<span x-show="Object.keys(files).length > 1"
class="absolute mt-0 ml-5 bg-indigo-500 text-white text-[9px] rounded-full px-1.5 leading-4 font-bold"
x-text="Object.keys(files).length"></span>
</button>
<button @click="tab='preview'; showFileList=false"
class="flex-1 py-3 flex flex-col items-center gap-1.5 transition"
:class="tab==='preview' ? 'text-indigo-600 font-bold' : 'hover:text-indigo-400'">
<i class="fa-solid fa-play text-lg"></i> Preview
</button>
</nav>
<!-- Toast -->
<div x-show="toastMsg" x-transition.duration.300ms
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">
<i class="fa-solid fa-circle-check text-green-400 mr-1"></i>
<span x-text="toastMsg"></span>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('aiEditor', () => ({
tab: 'chat',
showMenu: false,
files: {
'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>`
},
activeFile: 'index.html',
showFileList: false,
showNewFileInput: false,
newFileName: '',
chatHistory: [{ role: 'ai', content: 'Halo! Saya AI Editor. Minta saya membuat atau mengubah file apa saja di project ini!' }],
userInput: '',
isProcessing: false,
loopCount: 0,
toastMsg: '',
_pollTimer: null,
_blobUrls: [],
init() {
this.$watch('tab', val => {
if (val === 'preview') this.updatePreview();
if (val !== 'code') this.showFileList = false;
});
},
/* ─── File Management ─── */
selectFile(fname) {
this.activeFile = fname;
this.showFileList = false;
},
addFile() {
const name = this.newFileName.trim();
if (!name) return;
// Avoid overwriting existing file without confirmation
if (!this.files[name]) {
this.files[name] = '';
}
this.activeFile = name;
this.newFileName = '';
this.showNewFileInput = false;
this.showFileList = false;
this.showToast(`File '${name}' dibuat!`);
},
deleteFile(fname) {
if (Object.keys(this.files).length <= 1) return;
const newFiles = Object.assign({}, this.files);
delete newFiles[fname];
this.files = newFiles;
if (this.activeFile === fname) {
this.activeFile = Object.keys(newFiles)[0];
}
this.showToast(`File '${fname}' dihapus.`);
},
/* ─── UI Helpers ─── */
showToast(msg) {
this.toastMsg = msg;
setTimeout(() => this.toastMsg = '', 3000);
},
scrollToBottom() {
setTimeout(() => {
const c = document.getElementById('chat-container');
if (c) c.scrollTop = c.scrollHeight;
}, 100);
},
/* ─── File I/O ─── */
uploadFile(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
this.files[file.name] = e.target.result;
this.activeFile = file.name;
this.showToast(`'${file.name}' berhasil diunggah!`);
this.tab = 'code';
};
reader.readAsText(file);
event.target.value = '';
},
copyCode() {
navigator.clipboard.writeText(this.files[this.activeFile] || '');
this.showToast('Kode berhasil disalin!');
},
downloadCode() {
try {
const content = this.files[this.activeFile] || '';
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = this.activeFile;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.showToast(`'${this.activeFile}' berhasil diunduh!`);
} catch {
this.showToast('Gagal mengunduh file.');
}
},
async downloadZip() {
try {
const JSZip = (await import('https://cdn.jsdelivr.net/npm/jszip@3.10.1/+esm')).default;
const zip = new JSZip();
for (const [fname, content] of Object.entries(this.files)) {
zip.file(fname, content);
}
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'project.zip';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.showToast(`ZIP berhasil diunduh (${Object.keys(this.files).length} file)!`);
} catch (e) {
this.showToast('Gagal membuat ZIP.');
}
},
/* ─── Preview (Eruda + Blob URLs for CSS/JS) ─── */
updatePreview() {
const iframe = document.getElementById('preview-frame');
if (!iframe) return;
// Revoke blob URLs dari render sebelumnya
this._blobUrls.forEach(u => URL.revokeObjectURL(u));
this._blobUrls = [];
// Render index.html first, fallback to first file
let html = this.files['index.html'] || Object.values(this.files)[0] || '';
// Buat blob URL untuk setiap file .css dan .js, lalu ganti referensinya di HTML
for (const [fname, content] of Object.entries(this.files)) {
if (fname === 'index.html') continue;
let mimeType = null;
if (fname.endsWith('.css')) mimeType = 'text/css';
else if (fname.endsWith('.js')) mimeType = 'application/javascript';
if (!mimeType) continue;
const blob = new Blob([content], { type: mimeType });
const blobUrl = URL.createObjectURL(blob);
this._blobUrls.push(blobUrl);
// Escape nama file untuk regex (titik, dll)
const escaped = fname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Ganti href="fname" / href='fname' dan src="fname" / src='fname'
html = html.replace(
new RegExp(`((?:href|src)=["'])${escaped}(["'])`, 'g'),
`$1${blobUrl}$2`
);
}
// Inject Eruda debugger
const erudaSnippet = [
'<script src="https://cdn.jsdelivr.net/npm/eruda"><\/script>',
'<script>eruda.init();<\/script>'
].join('');
html = html.includes('</body>')
? html.replace('</body>', erudaSnippet + '</body>')
: html + erudaSnippet;
iframe.srcdoc = html;
},
/* ─── Chat / AI ─── */
async sendPrompt() {
if (!this.userInput.trim() || this.isProcessing) return;
const prompt = this.userInput;
this.userInput = '';
this.chatHistory.push({ role: 'user', content: prompt });
this.scrollToBottom();
this._submitToServer(prompt);
},
async _submitToServer(instruction) {
this.isProcessing = true;
this.loopCount = 0;
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// Context: only all files + latest user instruction
body: JSON.stringify({ instruction, files: this.files }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Server error');
this._pollJob(data.job_id);
} catch (err) {
this.isProcessing = false;
this.chatHistory.push({ role: 'ai', content: '❌ ' + err.message });
this.scrollToBottom();
}
},
_pollJob(jobId, interval = 1500) {
clearTimeout(this._pollTimer);
this._pollTimer = setTimeout(async () => {
try {
const res = await fetch(`/api/job/${jobId}`);
const data = await res.json();
this.loopCount = data.loop_count || 0;
if (data.status === 'running' || data.status === 'pending') {
this.isProcessing = true;
this._pollJob(jobId, interval);
return;
}
if (data.status === 'done') {
this.files = data.result.files;
// Ensure activeFile still exists after AI response
if (!this.files[this.activeFile]) {
this.activeFile = Object.keys(this.files)[0] || 'index.html';
}
this.chatHistory.push({
role: 'ai',
content: data.result.message,
action: data.result.action_label,
reasons: data.result.reasons || [],
});
} else {
this.chatHistory.push({
role: 'ai',
content: '❌ ' + (data.error || 'Terjadi kesalahan pada server.'),
});
}
} catch {
this.chatHistory.push({ role: 'ai', content: '❌ Gagal membaca status job dari server.' });
} finally {
this.isProcessing = false;
this.scrollToBottom();
}
}, interval);
},
}));
});
</script>
</body>
</html>"""
@app.route("/")
def index():
return Response(HTML, mimetype="text/html")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=7860)