Spaces:
Paused
Paused
| import os | |
| import time | |
| import json | |
| import shutil | |
| import asyncio | |
| import zipfile | |
| import re | |
| import subprocess | |
| from pathlib import Path | |
| from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, BackgroundTasks, Form | |
| from fastapi.responses import HTMLResponse, FileResponse, JSONResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| import httpx | |
| import uvicorn | |
| # ========================================== | |
| # KONFIGURASI & VARIABEL GLOBAL | |
| # ========================================== | |
| PORT = 7860 | |
| BASE_DIR = Path("/tmp/puruai_sessions") | |
| SESSION_TIMEOUT = 259200 # 3 Hari | |
| API_URL = 'https://puruboy-api.vercel.app/api/ai/gemini-v2' | |
| BASE_DELAY = 3 | |
| # Prompt Utama (Pelaksana) dengan kemampuan Penjelasan (Chain of Thought) | |
| SYSTEM_PROMPT = """YOU ARE AN ELITE AI CODE EDITOR AGENT DESIGNED TO OPERATE WITH MAXIMUM PRECISION IN A CONTROLLED FILE SYSTEM ENVIRONMENT. YOU MUST EXECUTE EXACTLY ONE ACTION PER LOOP AND STRICTLY FOLLOW THE PROVIDED EXECUTION COMMAND FORMAT. | |
| YOUR CORE OBJECTIVE IS TO ANALYZE, DECIDE, EXPLAIN YOUR LOGIC, AND EXECUTE FILE OPERATIONS STEP-BY-STEP. | |
| --- | |
| ### 🔥 CORE RULES | |
| - YOU MUST MAKE **ONLY ONE DECISION PER LOOP** | |
| - YOU MUST **EXPLAIN YOUR THINKING PROCESS FIRST**, THEN OUTPUT EXACTLY ONE <execution> COMMAND | |
| - YOU MUST OPERATE BASED ONLY ON AVAILABLE CONTEXT (NO INTERNET, NO ASSUMPTIONS) | |
| - YOU MUST FOLLOW STRICT PATH FORMAT: `#root/...` | |
| --- | |
| ### 📂 AVAILABLE ACTIONS | |
| 1. READ ALL FILE STRUCTURE | |
| <execution>all</execution> | |
| 2. READ FILE CONTENT | |
| <execution>read <path>#root/filename.ext</path></execution> | |
| 3. WRITE / OVERWRITE FILE | |
| <execution>write <path>#root/filename.ext</path><content>FILE_CONTENT_HERE</content></execution> | |
| 4. MOVE FILE | |
| <execution>move <path>#root/source.ext</path><to>#root/destination.ext</to></execution> | |
| 5. DELETE FILE | |
| <execution>delete <path>#root/filename.ext</path></execution> | |
| 6. SEARCH (TRIMMED CONTEXT) | |
| <execution><trim>SEARCH_QUERY</trim></execution> | |
| 7. FINISH / STOP EXECUTION (WHEN TASK IS COMPLETE) | |
| <execution>stop</execution> | |
| --- | |
| ### 🧠 CHAIN OF THOUGHT PROCESS (MANDATORY) | |
| YOU MUST FOLLOW THIS THINKING SEQUENCE AND **WRITE IT DOWN** BEFORE EVERY ACTION: | |
| 1. UNDERSTAND: Identify the user's goal clearly. | |
| 2. ANALYZE: Check what information is missing based on current context. | |
| 3. DECIDE: Decide the safest and most logical next step (read, write, search, etc). | |
| 4. EXECUTE: Output exactly one valid <execution> command. | |
| --- | |
| ### ⚠️ WHAT NOT TO DO (CRITICAL) | |
| - NEVER OUTPUT MULTIPLE COMMANDS | |
| ❌ WRONG: | |
| <execution>all</execution> <execution>read ...</execution> | |
| - NEVER PUT YOUR EXPLANATION INSIDE THE EXECUTION TAG | |
| ❌ WRONG: | |
| <execution>I will read the file now. read <path>#root/file.js</path></execution> | |
| ✅ CORRECT: | |
| I need to check the contents of file.js to find the bug. | |
| <execution>read <path>#root/file.js</path></execution> | |
| - NEVER GUESS FILE CONTENT | |
| ❌ WRONG: | |
| <execution>write ...> (without reading first) | |
| - NEVER SKIP STEPS (LIKE WRITING BEFORE READING STRUCTURE) | |
| - NEVER USE INVALID PATH FORMAT | |
| ❌ WRONG: `/home/user/file.js` | |
| ✅ CORRECT: `#root/file.js` | |
| - NEVER PERFORM MORE THAN ONE ACTION PER LOOP | |
| --- | |
| ### ✅ OPTIMAL STRATEGY | |
| - ALWAYS START WITH EXPLANATION + <execution>all</execution> | |
| - THEN: READ relevant files → ANALYZE → MODIFY step-by-step | |
| - WHEN FINISHED: EXPLAIN completion → <execution>stop</execution> | |
| --- | |
| ### 🎯 FEW-SHOT EXAMPLES | |
| #### Example 1: Initial Step | |
| User: "Fix bug in app.js" | |
| ✅ Correct AI Response: | |
| I need to understand the current project structure first before locating and modifying `app.js`. Let me read the directory structure. | |
| <execution>all</execution> | |
| --- | |
| #### Example 2: After Seeing Structure | |
| ✅ Correct AI Response: | |
| I see `app.js` in the root directory. I need to read its content to understand the bug before making any changes. | |
| <execution>read <path>#root/app.js</path></execution> | |
| --- | |
| #### Example 3: After Analysis | |
| ✅ Correct AI Response: | |
| I found the issue. The variable `userData` is undefined. I will fix the function and write the updated code back to `app.js`. | |
| <execution>write <path>#root/app.js</path><content>FIXED_CODE</content></execution> | |
| --- | |
| #### Example 4: Task Completed | |
| ✅ Correct AI Response: | |
| The bug in `app.js` has been successfully fixed and the changes are written. My task is complete. | |
| <execution>stop</execution> | |
| --- | |
| ### 🚀 FINAL DIRECTIVE | |
| YOU ARE A DETERMINISTIC FILE-SYSTEM AGENT. | |
| ALWAYS EXPLAIN YOUR LOGIC BRIEFLY FIRST. | |
| THEN EXECUTE EXACTLY ONE ACTION. | |
| NEVER BREAK THE XML FORMAT FOR COMMANDS.""" | |
| SESSIONS = {} | |
| app = FastAPI() | |
| app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) | |
| # ========================================== | |
| # UTILITAS FILE SYSTEM SERVER | |
| # ========================================== | |
| def get_session_dir(session_id: str) -> Path: | |
| return BASE_DIR / session_id | |
| def update_session_activity(session_id: str): | |
| if session_id in SESSIONS: | |
| SESSIONS[session_id]["last_active"] = time.time() | |
| session_dir = get_session_dir(session_id) | |
| if session_dir.exists(): | |
| os.utime(session_dir, None) | |
| def get_vfs_list(session_id: str) -> list: | |
| session_dir = get_session_dir(session_id) | |
| if not session_dir.exists(): return [] | |
| files = [] | |
| for root, _, filenames in os.walk(session_dir): | |
| for filename in filenames: | |
| full_path = Path(root) / filename | |
| rel_path = full_path.relative_to(session_dir).as_posix() | |
| files.append(rel_path) | |
| return sorted(files) | |
| def is_binary(file_path: Path) -> bool: | |
| try: | |
| with open(file_path, 'tr') as check_file: | |
| check_file.read(1024) | |
| return False | |
| except UnicodeDecodeError: | |
| return True | |
| async def broadcast_ws(session_id: str, message: dict): | |
| if session_id in SESSIONS and SESSIONS[session_id].get("ws"): | |
| try: | |
| await SESSIONS[session_id]["ws"].send_json(message) | |
| except: pass | |
| async def send_vfs_update(session_id: str): | |
| await broadcast_ws(session_id, {"type": "vfs", "data": get_vfs_list(session_id)}) | |
| # ========================================== | |
| # LOGIKA AGENT AI PELAKSANA | |
| # ========================================== | |
| def execute_command(session_id: str, command_str: str) -> dict: | |
| session_dir = get_session_dir(session_id) | |
| os.makedirs(session_dir, exist_ok=True) | |
| try: | |
| exec_match = re.search(r'<execution>\s*([\s\S]*?)\s*</execution>', command_str, re.IGNORECASE) | |
| if not exec_match: | |
| return {"action": "stop", "log": "Operasi selesai atau tag <execution> tidak ditemukan. Berhenti."} | |
| cmd_body = exec_match.group(1).strip() | |
| # 0. STOP | |
| if cmd_body.lower() == 'stop': | |
| return {"action": "stop", "log": "Task Completed. Agent dihentikan secara eksplisit oleh AI."} | |
| # 1. ALL | |
| if cmd_body == 'all': | |
| files = get_vfs_list(session_id) | |
| return {"action": "all", "log": f"VFS Structure:\n" + "\n".join(files) if files else 'Directory is empty.'} | |
| # 2. READ | |
| read_match = re.search(r'^read\s*<path>(.*?)</path>', cmd_body, re.IGNORECASE) | |
| if read_match: | |
| clean_path = read_match.group(1).replace('#root/', '').strip() | |
| file_path = session_dir / clean_path | |
| if file_path.exists() and file_path.is_file(): | |
| if is_binary(file_path): return {"action": "read", "log": "[Binary File Unreadable]"} | |
| with open(file_path, 'r', encoding='utf-8') as f: content = f.read() | |
| return {"action": "read", "log": f"Content of {clean_path}:\n{content}"} | |
| raise Exception("File not found.") | |
| # 3. WRITE | |
| write_match = re.search(r'^write\s*<path>(.*?)</path>\s*<content>([\s\S]*?)</content>', cmd_body, re.IGNORECASE) | |
| if write_match: | |
| clean_path = write_match.group(1).replace('#root/', '').strip() | |
| content = write_match.group(2) | |
| file_path = session_dir / clean_path | |
| os.makedirs(file_path.parent, exist_ok=True) | |
| with open(file_path, 'w', encoding='utf-8') as f: f.write(content) | |
| return {"action": "write", "log": f"Successfully written to {clean_path}."} | |
| # 4. DELETE | |
| delete_match = re.search(r'^delete\s*<path>(.*?)</path>', cmd_body, re.IGNORECASE) | |
| if delete_match: | |
| clean_path = delete_match.group(1).replace('#root/', '').strip() | |
| file_path = session_dir / clean_path | |
| if file_path.exists(): | |
| if file_path.is_dir(): shutil.rmtree(file_path) | |
| else: os.remove(file_path) | |
| return {"action": "delete", "log": f"Deleted: {clean_path}"} | |
| raise Exception("File not found.") | |
| # 5. MOVE | |
| move_match = re.search(r'^move\s*<path>(.*?)</path>\s*<to>(.*?)</to>', cmd_body, re.IGNORECASE) | |
| if move_match: | |
| src_path = move_match.group(1).replace('#root/', '').strip() | |
| dst_path = move_match.group(2).replace('#root/', '').strip() | |
| src_full = session_dir / src_path | |
| dst_full = session_dir / dst_path | |
| if src_full.exists(): | |
| os.makedirs(dst_full.parent, exist_ok=True) | |
| shutil.move(str(src_full), str(dst_full)) | |
| return {"action": "move", "log": f"Moved {src_path} to {dst_path}"} | |
| raise Exception("Source file not found.") | |
| # 6. SEARCH / TRIM | |
| trim_match = re.search(r'^<trim>(.*?)</trim>', cmd_body, re.IGNORECASE) | |
| if trim_match: | |
| query = trim_match.group(1).strip() | |
| results = [] | |
| for root, _, files in os.walk(session_dir): | |
| for file in files: | |
| fp = Path(root) / file | |
| if is_binary(fp): continue | |
| try: | |
| with open(fp, 'r', encoding='utf-8') as f: | |
| lines = f.readlines() | |
| for i, line in enumerate(lines): | |
| if query.lower() in line.lower(): | |
| rel_path = fp.relative_to(session_dir).as_posix() | |
| results.append(f"{rel_path}:{i+1}: {line.strip()[:120]}") | |
| except: pass | |
| res_str = "\n".join(results) if results else "No matches found." | |
| return {"action": "search", "log": f"Search Results for '{query}':\n{res_str}"} | |
| raise Exception("Command structure unrecognized or missing required tags.") | |
| except Exception as e: | |
| return {"action": "error", "log": f"ERROR: {str(e)}"} | |
| async def fetch_ai(prompt_text: str) -> str: | |
| async with httpx.AsyncClient(timeout=90.0) as client: | |
| response = await client.post(API_URL, json={"prompt": prompt_text}) | |
| response.raise_for_status() | |
| return response.json()['result']['answer'] | |
| async def agent_loop(session_id: str, initial_prompt: str): | |
| session = SESSIONS[session_id] | |
| session["is_looping"] = True | |
| session["chat_history"].append({"role": "user", "text": initial_prompt}) | |
| session["ai_memory"].append({"role": "user", "text": initial_prompt}) | |
| await broadcast_ws(session_id, {"type": "chat_update", "data": session["chat_history"]}) | |
| while session["is_looping"]: | |
| try: | |
| update_session_activity(session_id) | |
| vfs_list = get_vfs_list(session_id) | |
| base_payload = f"{SYSTEM_PROMPT}\n\n[Context VFS: {', '.join(vfs_list) if vfs_list else 'Empty'}]\n\n" | |
| history_log = "\n".join([f"{m['role'].upper()}: {m['text']}" for m in session["ai_memory"][-12:]]) | |
| ai_response = await fetch_ai(f"{base_payload}History:\n{history_log}\n\nExecute NEXT STEP:") | |
| session["chat_history"].append({"role": "ai", "text": ai_response}) | |
| session["ai_memory"].append({"role": "ai", "text": ai_response}) | |
| await broadcast_ws(session_id, {"type": "chat_update", "data": session["chat_history"]}) | |
| exec_result = execute_command(session_id, ai_response) | |
| if exec_result: | |
| await send_vfs_update(session_id) | |
| if exec_result["action"] == "stop": | |
| session["is_looping"] = False | |
| session["ai_memory"] = [] | |
| # Tambahkan log terakhir sebelum berhenti ke UI | |
| sys_msg = f"SystemLog (STOP):\n{exec_result['log']}" | |
| session["chat_history"].append({"role": "system", "text": sys_msg}) | |
| await broadcast_ws(session_id, {"type": "chat_update", "data": session["chat_history"]}) | |
| await broadcast_ws(session_id, {"type": "status", "text": "Selesai", "statusType": "done", "isLooping": False}) | |
| break | |
| sys_msg = f"SystemLog ({exec_result['action']}):\n{exec_result['log']}" | |
| session["chat_history"].append({"role": "system", "text": sys_msg}) | |
| session["ai_memory"].append({"role": "system", "text": sys_msg}) | |
| await broadcast_ws(session_id, {"type": "chat_update", "data": session["chat_history"]}) | |
| await broadcast_ws(session_id, {"type": "status", "text": f"Jeda {BASE_DELAY}s...", "statusType": "idle"}) | |
| await asyncio.sleep(BASE_DELAY) | |
| except Exception as e: | |
| session["is_looping"] = False | |
| session["chat_history"].append({"role": "system", "text": f"SYSTEM ERROR: {str(e)}"}) | |
| await broadcast_ws(session_id, {"type": "chat_update", "data": session["chat_history"]}) | |
| await broadcast_ws(session_id, {"type": "status", "text": "Error", "statusType": "error", "isLooping": False}) | |
| break | |
| # ========================================== | |
| # ENDPOINTS REST & WEBSOCKET | |
| # ========================================== | |
| async def get_ui(): return HTMLResponse(HTML_CONTENT) | |
| async def check_session(session_id: str): | |
| return {"valid": session_id in SESSIONS or get_session_dir(session_id).exists()} | |
| async def websocket_endpoint(websocket: WebSocket, session_id: str): | |
| await websocket.accept() | |
| if session_id not in SESSIONS: | |
| SESSIONS[session_id] = {"chat_history": [], "ai_memory": [], "is_looping": False, "last_active": time.time(), "ws": websocket} | |
| else: | |
| SESSIONS[session_id]["ws"] = websocket | |
| session = SESSIONS[session_id] | |
| await websocket.send_json({"type": "chat_update", "data": session["chat_history"]}) | |
| await websocket.send_json({"type": "vfs", "data": get_vfs_list(session_id)}) | |
| try: | |
| while True: | |
| data = await websocket.receive_text() | |
| payload = json.loads(data) | |
| if payload["action"] == "prompt" and not session["is_looping"]: | |
| asyncio.create_task(agent_loop(session_id, payload["text"])) | |
| except WebSocketDisconnect: | |
| if session_id in SESSIONS: | |
| SESSIONS[session_id]["ws"] = None | |
| async def upload_zip(session_id: str = Form(...), file: UploadFile = File(...)): | |
| s_dir = get_session_dir(session_id) | |
| if s_dir.exists(): shutil.rmtree(s_dir) | |
| os.makedirs(s_dir, exist_ok=True) | |
| temp_zip = s_dir / "temp.zip" | |
| with open(temp_zip, "wb") as b: shutil.copyfileobj(file.file, b) | |
| with zipfile.ZipFile(temp_zip, 'r') as z: z.extractall(s_dir) | |
| os.remove(temp_zip) | |
| await send_vfs_update(session_id) | |
| return {"status": "success"} | |
| async def download_zip(session_id: str): | |
| s_dir = get_session_dir(session_id) | |
| z_name = f"/tmp/PuruAI_{session_id}.zip" | |
| with zipfile.ZipFile(z_name, 'w', zipfile.ZIP_DEFLATED) as z: | |
| for root, _, files in os.walk(s_dir): | |
| if "_context_upload" in root: continue | |
| for f in files: z.write(os.path.join(root, f), os.path.relpath(os.path.join(root, f), s_dir)) | |
| return FileResponse(z_name, filename="Project.zip") | |
| async def clear_session(session_id: str = Form(...)): | |
| if session_id in SESSIONS: | |
| SESSIONS[session_id]["chat_history"] = [] | |
| SESSIONS[session_id]["ai_memory"] = [] | |
| SESSIONS[session_id]["is_looping"] = False | |
| return {"status": "success"} | |
| async def delete_session(session_id: str = Form(...)): | |
| if session_id in SESSIONS: del SESSIONS[session_id] | |
| s_dir = get_session_dir(session_id) | |
| if s_dir.exists(): shutil.rmtree(s_dir) | |
| return {"status": "success"} | |
| # ========================================== | |
| # UI FRONTEND (HTML) | |
| # ========================================== | |
| HTML_CONTENT = """ | |
| <!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>PuruAI - Autonomous Agent</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> | |
| <style> | |
| ::-webkit-scrollbar { width: 4px; } | |
| ::-webkit-scrollbar-thumb { background: #374151; border-radius: 4px; } | |
| .blink { animation: blinker 1s linear infinite; } | |
| @keyframes blinker { 50% { opacity: 0.3; } } | |
| [x-cloak] { display: none !important; } | |
| /* Styling untuk xml syntax highlight */ | |
| .xml-tag { color: #f472b6; font-weight: 600; } | |
| </style> | |
| </head> | |
| <body class="bg-gray-900 text-gray-100 font-sans h-[100dvh] flex flex-col overflow-hidden" x-data="puruApp()"> | |
| <header class="bg-gray-800 border-b border-gray-700 p-3 flex justify-between items-center z-20"> | |
| <div class="flex items-center gap-2"> | |
| <div class="w-7 h-7 bg-blue-600 rounded flex items-center justify-center font-bold text-sm">P</div> | |
| <h1 class="text-lg font-bold">PuruAI</h1> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <div class="text-[10px] sm:text-xs font-medium bg-gray-900 px-3 py-1.5 rounded-full flex items-center gap-2"> | |
| <span class="w-2 h-2 rounded-full" :class="statusColor"></span> | |
| <span x-text="statusText"></span> | |
| </div> | |
| <button x-show="screen === 'workspace'" @click="isMenuOpen = !isMenuOpen" class="p-1.5 bg-gray-700 rounded-md"> | |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg> | |
| </button> | |
| <div x-show="isMenuOpen" @click.outside="isMenuOpen = false" x-cloak class="absolute right-3 top-14 w-48 bg-gray-800 border border-gray-600 rounded-xl shadow-2xl z-50 divide-y divide-gray-700"> | |
| <button @click="clearSession(); isMenuOpen = false;" class="w-full text-left px-4 py-3 text-sm hover:bg-gray-700">Clear Percakapan</button> | |
| <button @click="resetAll(); isMenuOpen = false;" class="w-full text-left px-4 py-3 text-sm text-red-400 hover:bg-gray-700">Reset All</button> | |
| </div> | |
| </div> | |
| </header> | |
| <div x-show="screen === 'setup'" class="flex-1 flex flex-col items-center justify-center p-6"> | |
| <div class="w-full max-w-sm bg-gray-800 p-6 rounded-2xl border border-gray-700"> | |
| <h2 class="text-xl font-bold mb-6 text-center">Mulai Project</h2> | |
| <button @click="startFreshProject()" class="w-full mb-4 bg-blue-600 py-3 rounded-xl font-semibold hover:bg-blue-500 transition">Buat Project Baru</button> | |
| <label class="w-full cursor-pointer bg-gray-700 hover:bg-gray-600 transition border border-dashed border-gray-500 py-4 rounded-xl flex flex-col items-center"> | |
| <span class="text-sm">Upload File ZIP</span> | |
| <input type="file" class="hidden" @change="handleZipUpload($event)"> | |
| </label> | |
| </div> | |
| </div> | |
| <main x-show="screen === 'workspace'" class="flex-1 relative flex flex-col overflow-hidden bg-[#0d1117]"> | |
| <div x-show="activeTab === 'chat'" class="flex-1 flex flex-col overflow-hidden relative"> | |
| <div id="chatContainer" class="flex-1 overflow-y-auto p-4 space-y-4 pb-24"> | |
| <template x-for="(msg, index) in chatHistory" :key="index"> | |
| <div class="p-3 rounded-xl max-w-[90%] border text-sm transition-all" | |
| x-data="{ expanded: false, get isLong() { return msg.text && msg.text.length > 300; } }" | |
| :class="{'bg-blue-900/30 border-blue-800 ml-auto': msg.role === 'user', 'bg-gray-800/80 border-gray-600 font-mono text-xs text-gray-300': msg.role === 'system', 'bg-gray-800 border-gray-700 mr-auto': msg.role === 'ai'}"> | |
| <!-- Role Badge --> | |
| <div class="font-bold text-[10px] mb-2 uppercase" | |
| :class="{'text-blue-400': msg.role === 'user', 'text-yellow-500': msg.role === 'system', 'text-teal-400': msg.role === 'ai'}" | |
| x-text="msg.role"></div> | |
| <!-- Format Text Content --> | |
| <div class="whitespace-pre-wrap break-words" | |
| x-html="formatXML(expanded ? msg.text : (isLong ? msg.text.substring(0, 300) + '...' : msg.text))"> | |
| </div> | |
| <!-- Tombol Baca Selengkapnya --> | |
| <template x-if="isLong"> | |
| <button @click="expanded = !expanded" | |
| class="text-blue-400 text-[10px] mt-2 font-bold uppercase hover:text-blue-300 flex items-center gap-1"> | |
| <span x-text="expanded ? 'Sembunyikan' : 'Baca Selengkapnya'"></span> | |
| </button> | |
| </template> | |
| </div> | |
| </template> | |
| </div> | |
| <div class="absolute bottom-0 left-0 w-full bg-gray-900 border-t border-gray-800 p-2 flex gap-2"> | |
| <textarea x-model="userInput" :disabled="isLooping" placeholder="Tulis instruksi..." class="flex-1 bg-gray-800 rounded-xl px-3 py-2 text-sm focus:outline-none h-12 resize-none"></textarea> | |
| <button @click="sendPrompt()" :disabled="!userInput.trim() || isLooping" class="bg-blue-600 px-4 rounded-xl disabled:bg-gray-700 hover:bg-blue-500 transition">Kirim</button> | |
| </div> | |
| </div> | |
| <div x-show="activeTab === 'files'" class="flex-1 overflow-y-auto p-4"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <span class="text-xs font-bold text-gray-500">PROJECT FILES</span> | |
| <a :href="'/api/download_zip/' + sessionId" class="text-xs bg-blue-600 px-2 py-1 rounded hover:bg-blue-500">Download ZIP</a> | |
| </div> | |
| <template x-for="file in filesList"> | |
| <div class="py-2 border-b border-gray-800 text-sm font-mono text-gray-400" x-text="file"></div> | |
| </template> | |
| </div> | |
| </main> | |
| <nav x-show="screen === 'workspace'" class="bg-gray-800 border-t border-gray-700 flex h-14 shrink-0"> | |
| <button @click="activeTab = 'chat'" class="flex-1 flex flex-col items-center justify-center" :class="activeTab === 'chat' ? 'text-blue-400' : 'text-gray-500'"> | |
| <span class="text-[10px] font-bold">CHAT</span> | |
| </button> | |
| <button @click="activeTab = 'files'" class="flex-1 flex flex-col items-center justify-center" :class="activeTab === 'files' ? 'text-blue-400' : 'text-gray-500'"> | |
| <span class="text-[10px] font-bold">FILES (<span x-text="filesList.length"></span>)</span> | |
| </button> | |
| </nav> | |
| <script> | |
| function puruApp() { | |
| return { | |
| sessionId: 'sid_' + Math.random().toString(36).substr(2, 9), | |
| screen: 'setup', activeTab: 'chat', statusText: 'Idle', statusType: 'idle', | |
| isLooping: false, isMenuOpen: false, filesList: [], chatHistory: [], userInput: '', ws: null, | |
| get statusColor() { | |
| return {idle: 'bg-gray-500', active: 'bg-blue-500 blink', error: 'bg-red-500', done: 'bg-green-500'}[this.statusType] || 'bg-gray-500'; | |
| }, | |
| async init() { | |
| if(localStorage.getItem('puru_started')) { | |
| this.sessionId = localStorage.getItem('puru_sid') || this.sessionId; | |
| this.screen = 'workspace'; | |
| this.connectWS(); | |
| } | |
| }, | |
| // Parser HTML Escape & Highlight Tag XML | |
| formatXML(text) { | |
| if (!text) return ''; | |
| let safeText = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| safeText = safeText.replace(/(<\/?[\w\s="\'-]+>)/g, '<span class="xml-tag">$1</span>'); | |
| return safeText; | |
| }, | |
| connectWS() { | |
| this.ws = new WebSocket(`${location.protocol==='https:'?'wss:':'ws:'}//${location.host}/ws/${this.sessionId}`); | |
| this.ws.onmessage = (e) => { | |
| const m = JSON.parse(e.data); | |
| if(m.type==='chat_update') this.chatHistory = m.data; | |
| if(m.type==='vfs') this.filesList = m.data; | |
| if(m.type==='status') { | |
| this.statusText = m.text; | |
| this.statusType = m.statusType; | |
| if(m.isLooping!==undefined) this.isLooping=m.isLooping; | |
| } | |
| this.$nextTick(() => { | |
| const c = document.getElementById('chatContainer'); | |
| if(c) c.scrollTop = c.scrollHeight; | |
| }); | |
| }; | |
| }, | |
| startFreshProject() { | |
| this.screen = 'workspace'; | |
| localStorage.setItem('puru_started', 'true'); | |
| localStorage.setItem('puru_sid', this.sessionId); | |
| this.connectWS(); | |
| }, | |
| async handleZipUpload(e) { | |
| const fd = new FormData(); fd.append('session_id', this.sessionId); fd.append('file', e.target.files[0]); | |
| await fetch('/api/upload_zip', {method: 'POST', body: fd}); | |
| this.startFreshProject(); | |
| }, | |
| sendPrompt() { | |
| this.ws.send(JSON.stringify({action: 'prompt', text: this.userInput})); | |
| this.userInput = ''; this.isLooping = true; | |
| this.statusType = 'active'; this.statusText = 'Berpikir...'; | |
| }, | |
| async clearSession() { | |
| const fd = new FormData(); fd.append('session_id', this.sessionId); | |
| await fetch('/api/clear_session', {method: 'POST', body: fd}); | |
| this.chatHistory = []; | |
| }, | |
| async resetAll() { | |
| const fd = new FormData(); fd.append('session_id', this.sessionId); | |
| await fetch('/api/delete_session', {method: 'POST', body: fd}); | |
| localStorage.clear(); location.reload(); | |
| } | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| if __name__ == "__main__": | |
| uvicorn.run(app, host="0.0.0.0", port=PORT) |