Spaces:
Paused
Paused
| """ | |
| 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 | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| 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) | |
| 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>""" | |
| def index(): | |
| return Response(HTML, mimetype="text/html") | |
| if __name__ == "__main__": | |
| app.run(host="0.0.0.0", port=7860) |