| |
| """ |
| LocalSpace Deployer — Hugging Face Space (Vanilla) |
| Drag a DMG or GGUF. It gets OPENED on the server: extracted, inspected, and its |
| app bundle metadata or model metadata is displayed. The Space itself hosts the |
| contents. Pure FastAPI + HTML/JS. No Gradio, no Streamlit, no API keys. |
| Public by default. |
| """ |
| from __future__ import annotations |
|
|
| import hashlib |
| import json |
| import os |
| import plistlib |
| import re |
| import shutil |
| import struct |
| import subprocess |
| import time |
| import uuid |
| import zipfile |
| from pathlib import Path |
| from typing import Any |
|
|
| from fastapi import FastAPI, File, Request, UploadFile |
| from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse |
| from fastapi.staticfiles import StaticFiles |
|
|
| |
| try: |
| import biplist |
| HAS_BIPLIST = True |
| except ImportError: |
| HAS_BIPLIST = False |
|
|
| |
| try: |
| from llama_cpp import Llama as _Llama |
| HAS_LLAMA = True |
| except ImportError: |
| HAS_LLAMA = False |
|
|
| |
| |
| |
| _PERSISTENT = Path("/data") |
| if _PERSISTENT.exists() and _PERSISTENT.is_dir() and os.access(_PERSISTENT, os.W_OK): |
| DATA_DIR = _PERSISTENT / "localspace" |
| else: |
| DATA_DIR = Path("data") |
| DATA_DIR.mkdir(parents=True, exist_ok=True) |
|
|
| UPLOAD_DIR = DATA_DIR / "uploads" |
| UPLOAD_DIR.mkdir(exist_ok=True) |
| EXTRACT_DIR = DATA_DIR / "extracted" |
| EXTRACT_DIR.mkdir(exist_ok=True) |
| SITES_DIR = DATA_DIR / "sites" |
| SITES_DIR.mkdir(exist_ok=True) |
| DAPPS_DIR = DATA_DIR / "dapps" |
| DAPPS_DIR.mkdir(exist_ok=True) |
| DB_PATH = DATA_DIR / "apps.json" |
|
|
| |
| _llm_cache: dict[str, Any] = {} |
|
|
| app = FastAPI(title="LocalSpace Deployer") |
|
|
| |
| STATIC_DIR = Path("static") |
| STATIC_DIR.mkdir(exist_ok=True) |
| app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") |
|
|
| _store: dict[str, dict[str, Any]] = {} |
|
|
|
|
| def _load_db() -> None: |
| global _store |
| if DB_PATH.exists(): |
| with open(DB_PATH) as f: |
| _store = json.load(f) |
| else: |
| _store = {} |
|
|
|
|
| def _save_db() -> None: |
| with open(DB_PATH, "w") as f: |
| json.dump(_store, f, indent=2) |
|
|
|
|
| def _slugify(name: str) -> str: |
| return re.sub(r"[^a-z0-9]+", "-", name.lower().replace(".dmg", "").replace(".gguf", "").replace(".zip", "").replace(".json", "")).strip("-") |
|
|
|
|
| def _hash_file(path: Path) -> str: |
| h = hashlib.sha256() |
| with open(path, "rb") as f: |
| for chunk in iter(lambda: f.read(65536), b""): |
| h.update(chunk) |
| return h.hexdigest() |
|
|
|
|
| def _format_bytes(n: int) -> str: |
| for unit in ["B", "KB", "MB", "GB", "TB"]: |
| if n < 1024: |
| return f"{n:.1f} {unit}" |
| n /= 1024 |
| return f"{n:.1f} PB" |
|
|
|
|
| def _read_plist(path: Path) -> dict[str, Any]: |
| """Read a plist file (XML or binary).""" |
| try: |
| with open(path, "rb") as f: |
| data = f.read() |
| try: |
| return plistlib.loads(data) |
| except Exception: |
| pass |
| if HAS_BIPLIST: |
| try: |
| return biplist.readPlistFromString(data) |
| except Exception: |
| pass |
| except Exception: |
| pass |
| return {} |
|
|
|
|
| def _find_app_bundles(root: Path) -> list[Path]: |
| """Find all .app directories under root.""" |
| apps = [] |
| for p in root.rglob("*.app"): |
| if p.is_dir(): |
| apps.append(p) |
| return apps |
|
|
|
|
| def _extract_dmg(dmg_path: Path, dest: Path) -> bool: |
| """Extract DMG using 7z. Returns True on success.""" |
| try: |
| dest.mkdir(parents=True, exist_ok=True) |
| result = subprocess.run( |
| ["7z", "x", "-y", "-o" + str(dest), str(dmg_path)], |
| capture_output=True, |
| text=True, |
| timeout=120, |
| ) |
| return result.returncode == 0 |
| except Exception: |
| return False |
|
|
|
|
| def _inspect_app_bundle(app_path: Path) -> dict[str, Any]: |
| """Read Info.plist and extract metadata from an .app bundle.""" |
| info_plist = app_path / "Contents" / "Info.plist" |
| if not info_plist.exists(): |
| info_plist = app_path / "Info.plist" |
|
|
| info = _read_plist(info_plist) if info_plist.exists() else {} |
|
|
| icon_name = info.get("CFBundleIconFile", "") |
| icon_path = None |
| if icon_name: |
| icons_dir = app_path / "Contents" / "Resources" |
| if icons_dir.exists(): |
| icns = icons_dir / (icon_name if icon_name.endswith(".icns") else icon_name + ".icns") |
| if icns.exists(): |
| icon_path = str(icns) |
|
|
| files = [] |
| if app_path.exists(): |
| for f in sorted(app_path.rglob("*")): |
| if f.is_file(): |
| try: |
| rel = str(f.relative_to(app_path)) |
| files.append(rel) |
| except ValueError: |
| pass |
|
|
| return { |
| "bundle_name": app_path.name, |
| "bundle_id": info.get("CFBundleIdentifier", ""), |
| "display_name": info.get("CFBundleDisplayName", "") or info.get("CFBundleName", ""), |
| "version": info.get("CFBundleShortVersionString", "") or info.get("CFBundleVersion", ""), |
| "min_os_version": info.get("LSMinimumSystemVersion", ""), |
| "executable": info.get("CFBundleExecutable", ""), |
| "icon_path": icon_path, |
| "info_plist": info, |
| "file_count": len(files), |
| "files": files[:200], |
| } |
|
|
|
|
| |
|
|
| GGUF_MAGIC = 0x46554747 |
| GGUF_TYPE_MAP = { |
| 0: "UINT8", 1: "INT8", 2: "UINT16", 3: "INT16", |
| 4: "UINT32", 5: "INT32", 6: "FLOAT32", 7: "BOOL", |
| 8: "STRING", 9: "ARRAY", 10: "UINT64", 11: "INT64", 12: "FLOAT64", |
| } |
|
|
|
|
| def _read_gguf_string(f) -> str: |
| n = struct.unpack("<Q", f.read(8))[0] |
| return f.read(n).decode("utf-8", errors="replace") |
|
|
|
|
| def _read_gguf_value(f, vtype: int) -> Any: |
| if vtype == 0: return struct.unpack("<B", f.read(1))[0] |
| if vtype == 1: return struct.unpack("<b", f.read(1))[0] |
| if vtype == 2: return struct.unpack("<H", f.read(2))[0] |
| if vtype == 3: return struct.unpack("<h", f.read(2))[0] |
| if vtype == 4: return struct.unpack("<I", f.read(4))[0] |
| if vtype == 5: return struct.unpack("<i", f.read(4))[0] |
| if vtype == 6: return struct.unpack("<f", f.read(4))[0] |
| if vtype == 7: return struct.unpack("<?", f.read(1))[0] |
| if vtype == 8: return _read_gguf_string(f) |
| if vtype == 10: return struct.unpack("<Q", f.read(8))[0] |
| if vtype == 11: return struct.unpack("<q", f.read(8))[0] |
| if vtype == 12: return struct.unpack("<d", f.read(8))[0] |
| if vtype == 9: |
| inner = struct.unpack("<I", f.read(4))[0] |
| n = struct.unpack("<Q", f.read(8))[0] |
| return {"type": GGUF_TYPE_MAP.get(inner, str(inner)), "count": n} |
| return None |
|
|
|
|
| def _inspect_gguf(gguf_path: Path) -> dict[str, Any]: |
| """Parse GGUF header: version, tensor count, metadata KV pairs.""" |
| try: |
| with open(gguf_path, "rb") as f: |
| magic = struct.unpack("<I", f.read(4))[0] |
| if magic != GGUF_MAGIC: |
| return {"valid": False, "error": f"Not a GGUF file (magic={magic:#x})"} |
|
|
| version = struct.unpack("<I", f.read(4))[0] |
| tensor_count = struct.unpack("<Q", f.read(8))[0] |
| kv_count = struct.unpack("<Q", f.read(8))[0] |
|
|
| metadata = {} |
| for _ in range(kv_count): |
| key = _read_gguf_string(f) |
| vtype = struct.unpack("<I", f.read(4))[0] |
| value = _read_gguf_value(f, vtype) |
| metadata[key] = { |
| "type": GGUF_TYPE_MAP.get(vtype, str(vtype)), |
| "value": value, |
| } |
|
|
| |
| tensors = [] |
| for _ in range(min(tensor_count, 200)): |
| n_dims = struct.unpack("<I", f.read(4))[0] |
| name = _read_gguf_string(f) |
| dims = [struct.unpack("<Q", f.read(8))[0] for _ in range(n_dims)] |
| dtype = struct.unpack("<I", f.read(4))[0] |
| offset = struct.unpack("<Q", f.read(8))[0] |
| tensors.append({ |
| "name": name, |
| "dims": dims, |
| "dtype": GGUF_TYPE_MAP.get(dtype, str(dtype)), |
| "offset": offset, |
| }) |
|
|
| return { |
| "valid": True, |
| "version": version, |
| "tensor_count": tensor_count, |
| "kv_count": kv_count, |
| "metadata": metadata, |
| "tensors": tensors, |
| "architecture": metadata.get("general.architecture", {}).get("value", "unknown"), |
| "name": metadata.get("general.name", {}).get("value", ""), |
| "quantization": metadata.get("general.quantization_version", {}).get("value", ""), |
| "context_length": metadata.get("general.context_length", {}).get("value", 0), |
| "file_size": gguf_path.stat().st_size, |
| } |
| except Exception as exc: |
| return {"valid": False, "error": str(exc)} |
|
|
|
|
| def _open_dmg(app_id: str, dmg_path: Path) -> dict[str, Any]: |
| """Open a DMG: extract it, find apps, inspect them.""" |
| extract_to = EXTRACT_DIR / app_id |
| if extract_to.exists(): |
| shutil.rmtree(extract_to) |
|
|
| success = _extract_dmg(dmg_path, extract_to) |
| if not success: |
| return {"opened": False, "error": "Extraction failed. DMG may be encrypted or use an unsupported format."} |
|
|
| apps = _find_app_bundles(extract_to) |
| inspected = [_inspect_app_bundle(a) for a in apps] |
|
|
| |
| tree = [] |
| for item in sorted(extract_to.iterdir()): |
| tree.append({ |
| "name": item.name, |
| "type": "directory" if item.is_dir() else "file", |
| "size": item.stat().st_size if item.is_file() else 0, |
| }) |
|
|
| |
| html_files: list[dict[str, Any]] = [] |
| for f in extract_to.rglob("*.html"): |
| rel = f.relative_to(extract_to) |
| depth = len(rel.parts) |
| html_files.append({"path": str(rel), "depth": depth, "size": f.stat().st_size}) |
| |
| html_files.sort(key=lambda x: (x["depth"], len(x["path"]), -x["size"])) |
|
|
| return { |
| "opened": True, |
| "extracted_path": str(extract_to), |
| "apps_found": len(inspected), |
| "apps": inspected, |
| "tree": tree, |
| "html_files": html_files[:50], |
| "has_preview": bool(html_files), |
| "preview_entry": html_files[0]["path"] if html_files else None, |
| } |
|
|
|
|
| _load_db() |
|
|
|
|
| @app.get("/", response_class=HTMLResponse) |
| def index(request: Request) -> HTMLResponse: |
| with open("static/index.html") as f: |
| return HTMLResponse(content=f.read()) |
|
|
|
|
| @app.post("/api/upload") |
| async def upload_file(file: UploadFile = File(...)) -> JSONResponse: |
| if not file.filename: |
| return JSONResponse({"error": "No filename provided."}, status_code=400) |
|
|
| fname = file.filename.lower() |
| is_dmg = fname.endswith(".dmg") |
| is_gguf = fname.endswith(".gguf") |
|
|
| if not is_dmg and not is_gguf: |
| return JSONResponse({"error": "Only .dmg and .gguf files are accepted."}, status_code=400) |
|
|
| app_id = str(uuid.uuid4()) |
| slug = _slugify(file.filename) |
| base_slug = slug |
| counter = 1 |
| while any(a.get("slug") == slug for a in _store.values()): |
| slug = f"{base_slug}-{counter}" |
| counter += 1 |
|
|
| ext = ".dmg" if is_dmg else ".gguf" |
| dest = UPLOAD_DIR / f"{app_id}{ext}" |
| with open(dest, "wb") as f: |
| while True: |
| chunk = await file.read(65536) |
| if not chunk: |
| break |
| f.write(chunk) |
|
|
| sha256 = _hash_file(dest) |
| size = dest.stat().st_size |
|
|
| if is_dmg: |
| opened = _open_dmg(app_id, dest) |
| entry = { |
| "app_id": app_id, |
| "slug": slug, |
| "filename": file.filename, |
| "file_type": "dmg", |
| "size": size, |
| "size_human": _format_bytes(size), |
| "sha256": sha256, |
| "download_url": f"/api/download/{app_id}", |
| "opened": opened.get("opened", False), |
| "apps_found": opened.get("apps_found", 0), |
| "apps": opened.get("apps", []), |
| "tree": opened.get("tree", []), |
| "html_files": opened.get("html_files", []), |
| "has_preview": opened.get("has_preview", False), |
| "preview_entry": opened.get("preview_entry", None), |
| "error": opened.get("error"), |
| "created_at": time.time(), |
| } |
| msg = "DMG uploaded and opened." if entry["opened"] else f"DMG uploaded but could not be opened: {entry.get('error', '')}" |
| else: |
| gguf_info = _inspect_gguf(dest) |
| entry = { |
| "app_id": app_id, |
| "slug": slug, |
| "filename": file.filename, |
| "file_type": "gguf", |
| "size": size, |
| "size_human": _format_bytes(size), |
| "sha256": sha256, |
| "download_url": f"/api/download/{app_id}", |
| "gguf": gguf_info, |
| "opened": gguf_info.get("valid", False), |
| "has_preview": False, |
| "preview_entry": None, |
| "created_at": time.time(), |
| } |
| msg = "GGUF uploaded and inspected." if gguf_info.get("valid") else f"GGUF uploaded but inspection failed: {gguf_info.get('error', '')}" |
|
|
| _store[app_id] = entry |
| _save_db() |
|
|
| return JSONResponse({"app": entry, "message": msg}, status_code=201) |
|
|
|
|
| @app.get("/api/apps") |
| def list_apps() -> JSONResponse: |
| apps = sorted(_store.values(), key=lambda a: a["created_at"], reverse=True) |
| return JSONResponse({"apps": apps}) |
|
|
|
|
| @app.get("/app/{app_id}", response_class=HTMLResponse) |
| def serve_app(app_id: str) -> HTMLResponse: |
| """Serve a published app as a full-page iframe viewer.""" |
| entry = _store.get(app_id) |
| if not entry: |
| return HTMLResponse("<h1>Not found</h1>", status_code=404) |
|
|
| if not entry.get("has_preview"): |
| return HTMLResponse("<h1>This app has no web preview</h1>", status_code=404) |
|
|
| entry_path = entry.get("preview_entry", "") |
| iframe_src = f"/api/preview/{app_id}/{entry_path}" if entry_path else f"/api/preview/{app_id}" |
| app_name = entry.get("filename", "App") |
| display_name = "" |
| if entry.get("apps") and entry["apps"]: |
| display_name = entry["apps"][0].get("display_name", "") or entry["apps"][0].get("bundle_name", "") |
| if not display_name: |
| display_name = app_name.replace(".dmg", "").replace(".zip", "") |
|
|
| html = f'''<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>{display_name} — LocalSpace</title> |
| <style> |
| * {{ box-sizing: border-box; margin: 0; }} |
| body {{ background: #0a0a0f; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }} |
| header {{ background: #14141a; border-bottom: 1px solid #2a2a35; padding: 12px 20px; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }} |
| header .title {{ color: #e2e2e8; font-weight: 600; font-size: 1rem; display: flex; align-items: center; gap: 10px; }} |
| header .title .badge {{ background: rgba(34,197,94,0.15); color: #22c55e; padding: 2px 8px; border-radius: 999px; font-size: 0.72rem; font-weight: 500; }} |
| header .actions {{ display: flex; gap: 10px; }} |
| header .actions a, header .actions button {{ background: #1c1c24; border: 1px solid #2a2a35; color: #e2e2e8; padding: 6px 14px; border-radius: 8px; font-size: 0.85rem; text-decoration: none; cursor: pointer; transition: background 0.15s; }} |
| header .actions a:hover, header .actions button:hover {{ background: #2a2a35; }} |
| .iframe-wrap {{ flex: 1; position: relative; overflow: hidden; }} |
| iframe {{ width: 100%; height: 100%; border: none; display: block; }} |
| .loading {{ position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: #0a0a0f; color: #888898; font-size: 0.95rem; z-index: 1; }} |
| </style> |
| </head> |
| <body> |
| <header> |
| <div class="title"> |
| ⬡ {display_name} |
| <span class="badge">Live</span> |
| </div> |
| <div class="actions"> |
| <a href="/" title="Back to deployer">← Back</a> |
| <a href="{iframe_src}" target="_blank" title="Open in new tab">↗ Open</a> |
| <button onclick="document.querySelector('iframe').requestFullscreen()">⛶ Fullscreen</button> |
| </div> |
| </header> |
| <div class="iframe-wrap"> |
| <div class="loading" id="loader">Loading app...</div> |
| <iframe src="{iframe_src}" onload="document.getElementById('loader').style.display='none'"></iframe> |
| </div> |
| </body> |
| </html>''' |
| return HTMLResponse(content=html) |
|
|
|
|
| @app.get("/api/apps/{app_id}") |
| def get_app(app_id: str) -> JSONResponse: |
| entry = _store.get(app_id) |
| if not entry: |
| return JSONResponse({"error": "Not found"}, status_code=404) |
| return JSONResponse({"app": entry}) |
|
|
|
|
| @app.get("/api/download/{app_id}") |
| def download_app(app_id: str): |
| entry = _store.get(app_id) |
| if not entry: |
| return JSONResponse({"error": "Not found"}, status_code=404) |
|
|
| ext = ".dmg" if entry.get("file_type", "dmg") == "dmg" else ".gguf" |
| path = UPLOAD_DIR / f"{app_id}{ext}" |
| if not path.exists(): |
| return JSONResponse({ |
| "error": "File not found on disk", |
| "detail": "This upload was stored in temporary storage that was cleared during a Space restart. Please re-upload." |
| }, status_code=404) |
|
|
| media = "application/x-apple-diskimage" if ext == ".dmg" else "application/octet-stream" |
| return FileResponse(path=path, filename=entry["filename"], media_type=media) |
|
|
|
|
| @app.get("/api/browse/{app_id}/{path:path}") |
| def browse_extracted(app_id: str, path: str): |
| entry = _store.get(app_id) |
| if not entry: |
| return JSONResponse({"error": "Not found"}, status_code=404) |
|
|
| safe_path = Path(path).name if not path else path |
| base = EXTRACT_DIR / app_id |
| target = base / safe_path |
|
|
| if not base.exists(): |
| return JSONResponse({ |
| "error": "Extracted files not found", |
| "detail": "This upload was stored in temporary storage that was cleared during a Space restart. Please re-upload." |
| }, status_code=404) |
|
|
| try: |
| target.resolve().relative_to(base.resolve()) |
| except ValueError: |
| return JSONResponse({"error": "Access denied"}, status_code=403) |
|
|
| if not target.exists(): |
| return JSONResponse({"error": "Not found"}, status_code=404) |
|
|
| if target.is_dir(): |
| items = [] |
| for item in sorted(target.iterdir()): |
| items.append({ |
| "name": item.name, |
| "type": "directory" if item.is_dir() else "file", |
| "size": item.stat().st_size if item.is_file() else 0, |
| }) |
| return JSONResponse({"items": items}) |
|
|
| return FileResponse(path=target) |
|
|
|
|
| |
|
|
| @app.get("/api/preview/{app_id}/{path:path}") |
| def preview_content(app_id: str, path: str): |
| """Serve extracted DMG content for iframe preview.""" |
| entry = _store.get(app_id) |
| if not entry: |
| return JSONResponse({"error": "Not found"}, status_code=404) |
|
|
| base = EXTRACT_DIR / app_id |
| if not base.exists(): |
| return JSONResponse({ |
| "error": "Extracted files not found", |
| "detail": "This upload was stored in temporary storage that was cleared during a Space restart. Please re-upload." |
| }, status_code=404) |
|
|
| if path: |
| target = base / path |
| else: |
| target = base / "index.html" |
| if not target.exists(): |
| for f in base.rglob("*.html"): |
| target = f |
| break |
|
|
| if not target.exists(): |
| return JSONResponse({"error": "Not found"}, status_code=404) |
|
|
| try: |
| target.resolve().relative_to(base.resolve()) |
| except ValueError: |
| return JSONResponse({"error": "Access denied"}, status_code=403) |
|
|
| if target.is_dir(): |
| idx = target / "index.html" |
| if idx.exists(): |
| target = idx |
| else: |
| return JSONResponse({"error": "No index.html"}, status_code=404) |
|
|
| mime_types = { |
| ".html": "text/html", |
| ".htm": "text/html", |
| ".js": "application/javascript", |
| ".css": "text/css", |
| ".png": "image/png", |
| ".jpg": "image/jpeg", |
| ".jpeg": "image/jpeg", |
| ".gif": "image/gif", |
| ".svg": "image/svg+xml", |
| ".json": "application/json", |
| ".woff2": "font/woff2", |
| ".woff": "font/woff", |
| ".ttf": "font/ttf", |
| } |
| suffix = target.suffix.lower() |
| media_type = mime_types.get(suffix, "application/octet-stream") |
|
|
| if media_type == "text/html": |
| content = target.read_text(errors="replace") |
| if "<base" not in content: |
| rel_dir = str(target.parent.relative_to(base)) if target.parent != base else "" |
| base_tag = f'<base href="/api/preview/{app_id}/{rel_dir}/">' if rel_dir else f'<base href="/api/preview/{app_id}/">' |
| content = content.replace("<head>", f"<head>{base_tag}", 1) |
| content = content.replace("<HEAD>", f"<HEAD>{base_tag}", 1) |
| return HTMLResponse(content=content, media_type=media_type) |
|
|
| return FileResponse(path=target, media_type=media_type) |
|
|
|
|
| |
|
|
| WEBAPP_DIR = DATA_DIR / "webapps" |
| WEBAPP_DIR.mkdir(exist_ok=True) |
|
|
|
|
| def _create_app_bundle(url: str, app_name: str, bundle_id: str, version: str) -> Path: |
| """Create a minimal macOS .app bundle that opens a URL.""" |
| bundle_root = WEBAPP_DIR / f"{app_name}.app" |
| if bundle_root.exists(): |
| shutil.rmtree(bundle_root) |
|
|
| contents = bundle_root / "Contents" |
| macos = contents / "MacOS" |
| resources = contents / "Resources" |
| macos.mkdir(parents=True) |
| resources.mkdir(parents=True) |
|
|
| plist = { |
| "CFBundleDevelopmentRegion": "en", |
| "CFBundleExecutable": app_name.replace(" ", ""), |
| "CFBundleIdentifier": bundle_id, |
| "CFBundleInfoDictionaryVersion": "6.0", |
| "CFBundleName": app_name, |
| "CFBundlePackageType": "APPL", |
| "CFBundleShortVersionString": version, |
| "CFBundleVersion": version, |
| "LSMinimumSystemVersion": "10.15", |
| "LSUIElement": False, |
| } |
| with open(contents / "Info.plist", "wb") as f: |
| plistlib.dump(plist, f) |
|
|
| script_path = macos / app_name.replace(" ", "") |
| script_content = f'''#!/bin/bash |
| # Auto-generated web app wrapper |
| URL="{url}" |
| if command -v open >/dev/null 2>&1; then |
| open "$URL" |
| else |
| xdg-open "$URL" 2>/dev/null || python3 -m webbrowser "$URL" |
| fi |
| ''' |
| script_path.write_text(script_content) |
| script_path.chmod(0o755) |
|
|
| html_path = resources / "index.html" |
| html_path.write_text(f'''<!DOCTYPE html> |
| <html><head><meta charset="utf-8"><title>{app_name}</title> |
| <style>body{{margin:0;height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:sans-serif;background:#0a0a0f;color:#e2e2e8}}iframe{{width:95%;height:85%;border:1px solid #333;border-radius:8px}}</style></head> |
| <body><h2>{app_name}</h2><p>Loading {url}...</p><iframe src="{url}" sandbox="allow-scripts allow-same-origin allow-popups allow-forms"></iframe></body></html> |
| ''') |
|
|
| return bundle_root |
|
|
|
|
| def _create_dmg_from_app(app_bundle: Path, output_name: str) -> Path | None: |
| """Best-effort DMG creation. Returns path to DMG or None.""" |
| dmg_path = WEBAPP_DIR / f"{output_name}.dmg" |
|
|
| try: |
| iso_path = WEBAPP_DIR / f"{output_name}.iso" |
| result = subprocess.run( |
| ["genisoimage", "-D", "-V", output_name, "-no-pad", "-r", "-apple", |
| "-o", str(iso_path), str(app_bundle)], |
| capture_output=True, text=True, timeout=30, |
| ) |
| if result.returncode == 0: |
| dmg_result = subprocess.run( |
| ["dmg", "iso", str(iso_path), str(dmg_path)], |
| capture_output=True, text=True, timeout=30, |
| ) |
| if dmg_result.returncode == 0 and dmg_path.exists(): |
| iso_path.unlink(missing_ok=True) |
| return dmg_path |
| except FileNotFoundError: |
| pass |
| except Exception: |
| pass |
|
|
| zip_path = WEBAPP_DIR / f"{output_name}.zip" |
| shutil.make_archive( |
| base_name=str(WEBAPP_DIR / output_name), |
| format="zip", |
| root_dir=str(app_bundle.parent), |
| base_dir=app_bundle.name, |
| ) |
| if zip_path.exists(): |
| return zip_path |
|
|
| return None |
|
|
|
|
| @app.post("/api/create-webapp") |
| async def create_webapp(request: Request) -> JSONResponse: |
| """Create a macOS app bundle + DMG from a URL.""" |
| try: |
| data = await request.json() |
| except Exception: |
| return JSONResponse({"error": "Invalid JSON"}, status_code=400) |
|
|
| url = data.get("url", "").strip() |
| app_name = data.get("app_name", "WebApp").strip() |
| bundle_id = data.get("bundle_id", "app.localspace.webapp").strip() |
| version = data.get("version", "1.0.0").strip() |
|
|
| if not url: |
| return JSONResponse({"error": "URL is required"}, status_code=400) |
| if not app_name: |
| return JSONResponse({"error": "App name is required"}, status_code=400) |
|
|
| app_name_safe = re.sub(r'[^a-zA-Z0-9 ]+', '', app_name).strip() |
| if not app_name_safe: |
| app_name_safe = "WebApp" |
|
|
| try: |
| bundle = _create_app_bundle(url, app_name_safe, bundle_id, version) |
| pkg = _create_dmg_from_app(bundle, app_name_safe.replace(" ", "-")) |
|
|
| if pkg is None: |
| return JSONResponse({"error": "Failed to create package"}, status_code=500) |
|
|
| pkg_id = str(uuid.uuid4()) |
| dest = UPLOAD_DIR / f"{pkg_id}{pkg.suffix}" |
| shutil.copy2(pkg, dest) |
|
|
| entry = { |
| "app_id": pkg_id, |
| "slug": _slugify(app_name_safe), |
| "filename": dest.name, |
| "file_type": "webapp", |
| "size": dest.stat().st_size, |
| "size_human": _format_bytes(dest.stat().st_size), |
| "sha256": _hash_file(dest), |
| "download_url": f"/api/download/{pkg_id}", |
| "source_url": url, |
| "app_name": app_name_safe, |
| "bundle_id": bundle_id, |
| "version": version, |
| "is_webapp": True, |
| "created_at": time.time(), |
| } |
| _store[pkg_id] = entry |
| _save_db() |
|
|
| return JSONResponse({ |
| "app": entry, |
| "message": f"'{app_name_safe}' packaged." if pkg.suffix == ".zip" else f"'{app_name_safe}' DMG created.", |
| }, status_code=201) |
| except Exception as exc: |
| return JSONResponse({"error": str(exc)}, status_code=500) |
|
|
|
|
| |
|
|
| def _find_index_html(root: Path) -> str | None: |
| """Find the entry HTML file in a deployed site.""" |
| candidates = ["index.html", "index.htm"] |
| for c in candidates: |
| if (root / c).exists(): |
| return c |
| |
| for item in sorted(root.iterdir()): |
| if item.is_dir(): |
| for c in candidates: |
| if (item / c).exists(): |
| return f"{item.name}/{c}" |
| |
| for f in root.rglob("*.html"): |
| return str(f.relative_to(root)) |
| return None |
|
|
|
|
| def _build_site_tree(root: Path, max_depth: int = 3) -> list[dict[str, Any]]: |
| """Build a file tree for a deployed site.""" |
| tree = [] |
| try: |
| for item in sorted(root.iterdir()): |
| entry: dict[str, Any] = { |
| "name": item.name, |
| "type": "directory" if item.is_dir() else "file", |
| "size": item.stat().st_size if item.is_file() else 0, |
| } |
| if item.is_dir() and max_depth > 0: |
| entry["children"] = _build_site_tree(item, max_depth - 1) |
| tree.append(entry) |
| except Exception: |
| pass |
| return tree |
|
|
|
|
| @app.post("/api/deploy-site") |
| async def deploy_site(file: UploadFile = File(...)) -> JSONResponse: |
| """Deploy a static site from a ZIP file. Returns a hosted URL.""" |
| if not file.filename or not file.filename.lower().endswith(".zip"): |
| return JSONResponse({"error": "Only .zip files are accepted for site deployment."}, status_code=400) |
|
|
| site_id = str(uuid.uuid4()) |
| slug = _slugify(file.filename) or f"site-{site_id[:8]}" |
| base_slug = slug |
| counter = 1 |
| while any(a.get("slug") == slug and a.get("file_type") == "site" for a in _store.values()): |
| slug = f"{base_slug}-{counter}" |
| counter += 1 |
|
|
| |
| zip_path = UPLOAD_DIR / f"{site_id}.zip" |
| with open(zip_path, "wb") as f: |
| while True: |
| chunk = await file.read(65536) |
| if not chunk: |
| break |
| f.write(chunk) |
|
|
| |
| site_root = SITES_DIR / site_id |
| site_root.mkdir(parents=True, exist_ok=True) |
| try: |
| with zipfile.ZipFile(zip_path, "r") as zf: |
| zf.extractall(site_root) |
| except zipfile.BadZipFile: |
| shutil.rmtree(site_root) |
| zip_path.unlink(missing_ok=True) |
| return JSONResponse({"error": "Invalid ZIP file."}, status_code=400) |
|
|
| |
| top_items = list(site_root.iterdir()) |
| if len(top_items) == 1 and top_items[0].is_dir(): |
| real_root = top_items[0] |
| else: |
| real_root = site_root |
|
|
| index_file = _find_index_html(real_root) |
| if not index_file: |
| return JSONResponse({"error": "No HTML file found in ZIP."}, status_code=400) |
|
|
| sha256 = _hash_file(zip_path) |
| file_count = sum(1 for _ in real_root.rglob("*") if _.is_file()) |
| total_size = sum(f.stat().st_size for f in real_root.rglob("*") if f.is_file()) |
| tree = _build_site_tree(real_root) |
|
|
| entry = { |
| "app_id": site_id, |
| "slug": slug, |
| "filename": file.filename, |
| "file_type": "site", |
| "size": zip_path.stat().st_size, |
| "size_human": _format_bytes(zip_path.stat().st_size), |
| "sha256": sha256, |
| "site_url": f"/site/{site_id}/", |
| "site_preview": f"/site/{site_id}/{index_file}", |
| "index_file": index_file, |
| "file_count": file_count, |
| "total_size": total_size, |
| "total_size_human": _format_bytes(total_size), |
| "tree": tree, |
| "has_preview": True, |
| "preview_entry": index_file, |
| "opened": True, |
| "created_at": time.time(), |
| } |
| _store[site_id] = entry |
| _save_db() |
|
|
| return JSONResponse({ |
| "app": entry, |
| "message": f"Site deployed. {file_count} files, {entry['size_human']}.", |
| "site_url": entry["site_url"], |
| }, status_code=201) |
|
|
|
|
| @app.get("/site/{site_id}/", response_class=HTMLResponse) |
| @app.get("/site/{site_id}", response_class=HTMLResponse) |
| def serve_site_root(site_id: str) -> HTMLResponse: |
| """Serve a deployed static site's index page.""" |
| entry = _store.get(site_id) |
| if not entry or entry.get("file_type") != "site": |
| return HTMLResponse("<h1>Site not found</h1>", status_code=404) |
|
|
| site_root = SITES_DIR / site_id |
| if not site_root.exists(): |
| return HTMLResponse("<h1>Site files not found. Storage may have been cleared.</h1>", status_code=404) |
|
|
| index_file = entry.get("index_file", "index.html") |
| target = site_root / index_file |
| if not target.exists(): |
| for f in site_root.rglob("*.html"): |
| target = f |
| break |
|
|
| if not target.exists(): |
| return HTMLResponse("<h1>No HTML found</h1>", status_code=404) |
|
|
| content = target.read_text(errors="replace") |
| rel_dir = str(target.parent.relative_to(site_root)) |
| if "<base" not in content: |
| base_href = f"/site/{site_id}/{rel_dir}/" if rel_dir and rel_dir != "." else f"/site/{site_id}/" |
| base_tag = f'<base href="{base_href}">' |
| content = content.replace("<head>", f"<head>{base_tag}", 1) |
| content = content.replace("<HEAD>", f"<HEAD>{base_tag}", 1) |
|
|
| return HTMLResponse(content=content) |
|
|
|
|
| @app.get("/site/{site_id}/{path:path}") |
| def serve_site_file(site_id: str, path: str): |
| """Serve any file from a deployed static site.""" |
| entry = _store.get(site_id) |
| if not entry or entry.get("file_type") != "site": |
| return JSONResponse({"error": "Site not found"}, status_code=404) |
|
|
| site_root = SITES_DIR / site_id |
| if not site_root.exists(): |
| return JSONResponse({"error": "Site storage cleared"}, status_code=404) |
|
|
| target = site_root / path |
|
|
| try: |
| target.resolve().relative_to(site_root.resolve()) |
| except ValueError: |
| return JSONResponse({"error": "Access denied"}, status_code=403) |
|
|
| if not target.exists(): |
| return JSONResponse({"error": "File not found"}, status_code=404) |
|
|
| if target.is_dir(): |
| idx = target / "index.html" |
| if idx.exists(): |
| content = idx.read_text(errors="replace") |
| rel_dir = str(idx.parent.relative_to(site_root)) |
| if "<base" not in content: |
| base_href = f"/site/{site_id}/{rel_dir}/" if rel_dir and rel_dir != "." else f"/site/{site_id}/" |
| content = content.replace("<head>", f'<head><base href="{base_href}">', 1) |
| return HTMLResponse(content=content) |
| return JSONResponse({"error": "No index.html in directory"}, status_code=404) |
|
|
| mime_types = { |
| ".html": "text/html", ".htm": "text/html", |
| ".js": "application/javascript", ".mjs": "application/javascript", |
| ".css": "text/css", |
| ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", |
| ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp", |
| ".ico": "image/x-icon", |
| ".json": "application/json", ".xml": "application/xml", |
| ".woff2": "font/woff2", ".woff": "font/woff", ".ttf": "font/ttf", |
| ".otf": "font/otf", |
| ".txt": "text/plain", ".md": "text/markdown", |
| ".wasm": "application/wasm", |
| ".map": "application/json", |
| } |
| media_type = mime_types.get(target.suffix.lower(), "application/octet-stream") |
| return FileResponse(path=target, media_type=media_type) |
|
|
|
|
| |
|
|
| def _get_llm(app_id: str) -> Any | None: |
| """Load a GGUF model into memory, with caching.""" |
| if not HAS_LLAMA: |
| return None |
|
|
| if app_id in _llm_cache: |
| return _llm_cache[app_id] |
|
|
| entry = _store.get(app_id) |
| if not entry or entry.get("file_type") != "gguf": |
| return None |
|
|
| gguf_path = UPLOAD_DIR / f"{app_id}.gguf" |
| if not gguf_path.exists(): |
| return None |
|
|
| try: |
| ctx = entry.get("gguf", {}).get("context_length", 2048) |
| n_ctx = min(int(ctx) if ctx else 2048, 4096) |
| llm = _Llama(model_path=str(gguf_path), n_ctx=n_ctx, verbose=False) |
| _llm_cache[app_id] = llm |
| return llm |
| except Exception: |
| return None |
|
|
|
|
| @app.get("/api/inference/status") |
| def inference_status() -> JSONResponse: |
| """Check if GGUF inference is available.""" |
| loaded = list(_llm_cache.keys()) |
| gguf_apps = [a for a in _store.values() if a.get("file_type") == "gguf"] |
| return JSONResponse({ |
| "available": HAS_LLAMA, |
| "loaded_models": len(loaded), |
| "loaded_ids": loaded, |
| "gguf_apps": [{"app_id": a["app_id"], "filename": a["filename"]} for a in gguf_apps], |
| }) |
|
|
|
|
| @app.post("/api/inference/load/{app_id}") |
| def load_model(app_id: str) -> JSONResponse: |
| """Load a GGUF model into memory for inference.""" |
| if not HAS_LLAMA: |
| return JSONResponse({ |
| "error": "llama-cpp-python not installed. Inference unavailable.", |
| "hint": "Add llama-cpp-python to requirements.txt to enable inference.", |
| }, status_code=503) |
|
|
| entry = _store.get(app_id) |
| if not entry or entry.get("file_type") != "gguf": |
| return JSONResponse({"error": "Not a GGUF artifact."}, status_code=400) |
|
|
| llm = _get_llm(app_id) |
| if llm is None: |
| return JSONResponse({"error": "Failed to load model."}, status_code=500) |
|
|
| return JSONResponse({ |
| "ok": True, |
| "message": f"Model '{entry['filename']}' loaded.", |
| "context_length": entry.get("gguf", {}).get("context_length", 2048), |
| }) |
|
|
|
|
| @app.post("/api/inference/chat/{app_id}") |
| async def inference_chat(app_id: str, request: Request) -> JSONResponse: |
| """OpenAI-compatible chat completion endpoint using a loaded GGUF model.""" |
| if not HAS_LLAMA: |
| return JSONResponse({"error": "Inference unavailable. llama-cpp-python not installed."}, status_code=503) |
|
|
| llm = _get_llm(app_id) |
| if llm is None: |
| return JSONResponse({"error": "Model not loaded. POST /api/inference/load/{app_id} first."}, status_code=400) |
|
|
| try: |
| data = await request.json() |
| except Exception: |
| return JSONResponse({"error": "Invalid JSON"}, status_code=400) |
|
|
| messages = data.get("messages", []) |
| if not messages: |
| return JSONResponse({"error": "messages is required"}, status_code=400) |
|
|
| max_tokens = min(int(data.get("max_tokens", 512)), 2048) |
| temperature = float(data.get("temperature", 0.7)) |
| stream = bool(data.get("stream", False)) |
|
|
| if stream: |
| def gen(): |
| for chunk in llm.create_chat_completion( |
| messages=messages, max_tokens=max_tokens, |
| temperature=temperature, stream=True, |
| ): |
| yield f"data: {json.dumps(chunk)}\n\n" |
| yield "data: [DONE]\n\n" |
| return StreamingResponse(gen(), media_type="text/event-stream") |
|
|
| result = llm.create_chat_completion( |
| messages=messages, max_tokens=max_tokens, temperature=temperature, |
| ) |
| return JSONResponse(result) |
|
|
|
|
| @app.post("/api/inference/completion/{app_id}") |
| async def inference_completion(app_id: str, request: Request) -> JSONResponse: |
| """Text completion endpoint using a loaded GGUF model.""" |
| if not HAS_LLAMA: |
| return JSONResponse({"error": "Inference unavailable. llama-cpp-python not installed."}, status_code=503) |
|
|
| llm = _get_llm(app_id) |
| if llm is None: |
| return JSONResponse({"error": "Model not loaded."}, status_code=400) |
|
|
| try: |
| data = await request.json() |
| except Exception: |
| return JSONResponse({"error": "Invalid JSON"}, status_code=400) |
|
|
| prompt = data.get("prompt", "") |
| if not prompt: |
| return JSONResponse({"error": "prompt is required"}, status_code=400) |
|
|
| max_tokens = min(int(data.get("max_tokens", 256)), 2048) |
| temperature = float(data.get("temperature", 0.7)) |
|
|
| result = llm(prompt=prompt, max_tokens=max_tokens, temperature=temperature) |
| return JSONResponse(result) |
|
|
|
|
| |
|
|
| def _parse_chatgpt_export(data: Any) -> dict[str, Any]: |
| """Parse a ChatGPT export conversations.json structure.""" |
| conversations = [] |
| raw = data if isinstance(data, list) else data.get("conversations", data) if isinstance(data, dict) else [] |
|
|
| for conv in raw: |
| if not isinstance(conv, dict): |
| continue |
| title = conv.get("title", "Untitled") |
| conv_id = conv.get("id", conv.get("uuid", str(uuid.uuid4()))) |
| create_time = conv.get("create_time", 0) |
| update_time = conv.get("update_time", 0) |
|
|
| messages = [] |
| mapping = conv.get("mapping", {}) |
| if isinstance(mapping, dict): |
| for node_id, node in mapping.items(): |
| if not isinstance(node, dict): |
| continue |
| msg = node.get("message") |
| if not msg or not isinstance(msg, dict): |
| continue |
| author = msg.get("author", {}) |
| role = author.get("role", "unknown") |
| content = msg.get("content", {}) |
| parts = content.get("parts", []) |
| text = "" |
| for p in parts: |
| if isinstance(p, str): |
| text += p |
| elif isinstance(p, dict): |
| text += p.get("text", str(p)) |
| if text.strip(): |
| messages.append({ |
| "role": role, |
| "text": text[:50000], |
| "create_time": msg.get("create_time", 0), |
| }) |
|
|
| messages.sort(key=lambda m: m.get("create_time", 0) or 0) |
| conversations.append({ |
| "id": str(conv_id), |
| "title": title, |
| "message_count": len(messages), |
| "create_time": create_time, |
| "update_time": update_time, |
| "messages": messages, |
| }) |
|
|
| conversations.sort(key=lambda c: c.get("create_time", 0) or 0, reverse=True) |
| return {"conversations": conversations, "total": len(conversations)} |
|
|
|
|
| def _generate_dapp_html(parsed: dict[str, Any], title: str) -> str: |
| """Generate a self-contained dApp HTML for browsing ChatGPT conversations.""" |
| convs = parsed["conversations"] |
| convs_json = json.dumps(convs[:500]) |
|
|
| return f'''<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>{title} — ChatGPT Archive dApp</title> |
| <style> |
| * {{ box-sizing: border-box; margin: 0; padding: 0; }} |
| body {{ background: #0a0a0f; color: #e2e2e8; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; height: 100vh; display: flex; overflow: hidden; }} |
| .sidebar {{ width: 300px; min-width: 300px; background: #14141a; border-right: 1px solid #2a2a35; overflow-y: auto; display: flex; flex-direction: column; }} |
| .sidebar-header {{ padding: 16px; border-bottom: 1px solid #2a2a35; }} |
| .sidebar-header h2 {{ font-size: 1rem; margin-bottom: 4px; }} |
| .sidebar-header .count {{ font-size: 0.8rem; color: #888898; }} |
| .search {{ padding: 12px 16px; border-bottom: 1px solid #2a2a35; }} |
| .search input {{ width: 100%; background: #0a0a0f; border: 1px solid #2a2a35; color: #e2e2e8; padding: 8px 12px; border-radius: 8px; font-size: 0.85rem; outline: none; }} |
| .search input:focus {{ border-color: #6366f1; }} |
| .conv-list {{ flex: 1; overflow-y: auto; }} |
| .conv-item {{ padding: 12px 16px; border-bottom: 1px solid #1c1c24; cursor: pointer; transition: background 0.15s; }} |
| .conv-item:hover {{ background: #1c1c24; }} |
| .conv-item.active {{ background: rgba(99,102,241,0.15); border-left: 3px solid #6366f1; }} |
| .conv-item .title {{ font-size: 0.88rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }} |
| .conv-item .meta {{ font-size: 0.75rem; color: #5a5a68; margin-top: 2px; }} |
| .main {{ flex: 1; overflow-y: auto; padding: 24px 32px; }} |
| .welcome {{ display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #5a5a68; }} |
| .welcome .icon {{ font-size: 3rem; margin-bottom: 12px; }} |
| .welcome h3 {{ font-size: 1.1rem; margin-bottom: 4px; color: #888898; }} |
| .msg {{ margin-bottom: 20px; max-width: 800px; }} |
| .msg .role {{ font-size: 0.78rem; font-weight: 600; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.05em; }} |
| .msg .role.user {{ color: #6366f1; }} |
| .msg .role.assistant {{ color: #22c55e; }} |
| .msg .role.system {{ color: #eab300; }} |
| .msg .bubble {{ background: #14141a; border: 1px solid #2a2a35; border-radius: 12px; padding: 14px 18px; font-size: 0.9rem; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }} |
| .msg .bubble.user {{ border-left: 3px solid #6366f1; }} |
| .msg .bubble.assistant {{ border-left: 3px solid #22c55e; }} |
| .empty {{ text-align: center; padding: 40px; color: #5a5a68; }} |
| </style> |
| </head> |
| <body> |
| <div class="sidebar"> |
| <div class="sidebar-header"> |
| <h2>⬡ {title}</h2> |
| <div class="count">{parsed["total"]} conversations</div> |
| </div> |
| <div class="search"> |
| <input type="text" id="search" placeholder="Search conversations..." oninput="filterConvs()"> |
| </div> |
| <div class="conv-list" id="conv-list"></div> |
| </div> |
| <div class="main" id="main"> |
| <div class="welcome"> |
| <div class="icon">💬</div> |
| <h3>ChatGPT Archive dApp</h3> |
| <p>Select a conversation to browse</p> |
| </div> |
| </div> |
| <script> |
| const CONVS = {convs_json}; |
| let activeId = null; |
| |
| function renderConvList(filter) {{ |
| filter = (filter || "").toLowerCase(); |
| const list = document.getElementById("conv-list"); |
| const filtered = CONVS.filter(c => c.title.toLowerCase().includes(filter)); |
| list.innerHTML = filtered.map(c => ` |
| <div class="conv-item ${{c.id === activeId ? 'active' : ''}}" onclick="selectConv('${{c.id}}')"> |
| <div class="title">${{escHtml(c.title)}}</div> |
| <div class="meta">${{c.message_count}} messages</div> |
| </div> |
| `).join('') || '<div class="empty">No matches</div>'; |
| }} |
| |
| function selectConv(id) {{ |
| activeId = id; |
| const conv = CONVS.find(c => c.id === id); |
| if (!conv) return; |
| const main = document.getElementById("main"); |
| main.innerHTML = `<div style="margin-bottom:20px"><h2 style="font-size:1.2rem">${{escHtml(conv.title)}}</h2><div style="font-size:0.8rem;color:#888898">${{conv.message_count}} messages</div></div>` + |
| conv.messages.map(m => ` |
| <div class="msg"> |
| <div class="role ${{m.role}}">${{m.role}}</div> |
| <div class="bubble ${{m.role}}">${{escHtml(m.text)}}</div> |
| </div> |
| `).join(''); |
| renderConvList(document.getElementById("search").value); |
| main.scrollTop = 0; |
| }} |
| |
| function filterConvs() {{ |
| renderConvList(document.getElementById("search").value); |
| }} |
| |
| function escHtml(s) {{ |
| if (!s) return ''; |
| return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); |
| }} |
| |
| renderConvList(); |
| </script> |
| </body> |
| </html>''' |
|
|
|
|
| @app.post("/api/import/chatgpt") |
| async def import_chatgpt(file: UploadFile = File(...)) -> JSONResponse: |
| """Import a ChatGPT export (conversations.json) and deploy as a browsable dApp.""" |
| if not file.filename: |
| return JSONResponse({"error": "No filename provided."}, status_code=400) |
|
|
| fname = file.filename.lower() |
| if not (fname.endswith(".json") or fname.endswith(".zip")): |
| return JSONResponse({"error": "Only .json or .zip ChatGPT exports are accepted."}, status_code=400) |
|
|
| app_id = str(uuid.uuid4()) |
| slug = _slugify(file.filename) or f"chatgpt-archive-{app_id[:8]}" |
|
|
| |
| if fname.endswith(".zip"): |
| zip_path = UPLOAD_DIR / f"{app_id}.zip" |
| with open(zip_path, "wb") as f: |
| while True: |
| chunk = await file.read(65536) |
| if not chunk: |
| break |
| f.write(chunk) |
| |
| extract_to = EXTRACT_DIR / app_id |
| extract_to.mkdir(parents=True, exist_ok=True) |
| try: |
| with zipfile.ZipFile(zip_path, "r") as zf: |
| zf.extractall(extract_to) |
| except zipfile.BadZipFile: |
| shutil.rmtree(extract_to) |
| zip_path.unlink(missing_ok=True) |
| return JSONResponse({"error": "Invalid ZIP file."}, status_code=400) |
|
|
| |
| conv_file = None |
| for f in extract_to.rglob("conversations.json"): |
| conv_file = f |
| break |
| if not conv_file: |
| shutil.rmtree(extract_to) |
| return JSONResponse({"error": "No conversations.json found in ZIP export."}, status_code=400) |
| raw_data = json.loads(conv_file.read_text(encoding="utf-8")) |
| sha256 = _hash_file(zip_path) |
| stored_size = zip_path.stat().st_size |
| else: |
| |
| json_path = UPLOAD_DIR / f"{app_id}.json" |
| with open(json_path, "wb") as f: |
| while True: |
| chunk = await file.read(65536) |
| if not chunk: |
| break |
| f.write(chunk) |
| try: |
| raw_data = json.loads(json_path.read_text(encoding="utf-8")) |
| except json.JSONDecodeError: |
| json_path.unlink(missing_ok=True) |
| return JSONResponse({"error": "Invalid JSON file."}, status_code=400) |
| sha256 = _hash_file(json_path) |
| stored_size = json_path.stat().st_size |
|
|
| |
| parsed = _parse_chatgpt_export(raw_data) |
| if parsed["total"] == 0: |
| return JSONResponse({"error": "No conversations found in export."}, status_code=400) |
|
|
| |
| title = f"ChatGPT Archive ({parsed['total']} conversations)" |
| dapp_html = _generate_dapp_html(parsed, title) |
|
|
| |
| dapp_root = DAPPS_DIR / app_id |
| dapp_root.mkdir(parents=True, exist_ok=True) |
| (dapp_root / "index.html").write_text(dapp_html, encoding="utf-8") |
|
|
| entry = { |
| "app_id": app_id, |
| "slug": slug, |
| "filename": file.filename, |
| "file_type": "chatgpt", |
| "size": stored_size, |
| "size_human": _format_bytes(stored_size), |
| "sha256": sha256, |
| "dapp_url": f"/dapp/{app_id}/", |
| "conversation_count": parsed["total"], |
| "total_messages": sum(c["message_count"] for c in parsed["conversations"]), |
| "has_preview": True, |
| "preview_entry": "index.html", |
| "opened": True, |
| "created_at": time.time(), |
| } |
| _store[app_id] = entry |
| _save_db() |
|
|
| return JSONResponse({ |
| "app": entry, |
| "message": f"ChatGPT archive deployed as dApp. {parsed['total']} conversations, {entry['total_messages']} messages.", |
| "dapp_url": entry["dapp_url"], |
| }, status_code=201) |
|
|
|
|
| @app.get("/dapp/{app_id}/", response_class=HTMLResponse) |
| @app.get("/dapp/{app_id}", response_class=HTMLResponse) |
| def serve_dapp(app_id: str) -> HTMLResponse: |
| """Serve a deployed ChatGPT dApp.""" |
| entry = _store.get(app_id) |
| if not entry or entry.get("file_type") != "chatgpt": |
| return HTMLResponse("<h1>dApp not found</h1>", status_code=404) |
|
|
| dapp_root = DAPPS_DIR / app_id |
| if not dapp_root.exists(): |
| return HTMLResponse("<h1>dApp files not found. Storage may have been cleared.</h1>", status_code=404) |
|
|
| index = dapp_root / "index.html" |
| if not index.exists(): |
| return HTMLResponse("<h1>dApp index not found</h1>", status_code=404) |
|
|
| return HTMLResponse(content=index.read_text(encoding="utf-8")) |
|
|
|
|
| @app.get("/dapp/{app_id}/{path:path}") |
| def serve_dapp_file(app_id: str, path: str): |
| """Serve any file from a deployed dApp.""" |
| entry = _store.get(app_id) |
| if not entry or entry.get("file_type") != "chatgpt": |
| return JSONResponse({"error": "dApp not found"}, status_code=404) |
|
|
| dapp_root = DAPPS_DIR / app_id |
| if not dapp_root.exists(): |
| return JSONResponse({"error": "dApp storage cleared"}, status_code=404) |
|
|
| target = dapp_root / path |
| try: |
| target.resolve().relative_to(dapp_root.resolve()) |
| except ValueError: |
| return JSONResponse({"error": "Access denied"}, status_code=403) |
|
|
| if not target.exists(): |
| return JSONResponse({"error": "File not found"}, status_code=404) |
|
|
| return FileResponse(path=target) |
|
|
|
|
| @app.get("/api/storage/status") |
| def storage_status() -> JSONResponse: |
| """Check if persistent storage is attached.""" |
| persistent = str(DATA_DIR).startswith("/data") |
| return JSONResponse({ |
| "persistent": persistent, |
| "data_dir": str(DATA_DIR), |
| "has_uploads": UPLOAD_DIR.exists(), |
| "has_extracted": EXTRACT_DIR.exists(), |
| "has_sites": SITES_DIR.exists(), |
| "has_dapps": DAPPS_DIR.exists(), |
| "inference_available": HAS_LLAMA, |
| "artifact_count": len(_store), |
| }) |
|
|
|
|
| @app.delete("/api/apps/{app_id}") |
| def delete_app(app_id: str) -> JSONResponse: |
| entry = _store.pop(app_id, None) |
| if entry: |
| |
| for suffix in [".dmg", ".gguf", ".zip", ".json"]: |
| f = UPLOAD_DIR / f"{app_id}{suffix}" |
| if f.exists(): |
| f.unlink() |
| |
| extracted = EXTRACT_DIR / app_id |
| if extracted.exists(): |
| shutil.rmtree(extracted) |
| |
| site = SITES_DIR / app_id |
| if site.exists(): |
| shutil.rmtree(site) |
| |
| dapp = DAPPS_DIR / app_id |
| if dapp.exists(): |
| shutil.rmtree(dapp) |
| |
| _llm_cache.pop(app_id, None) |
| _save_db() |
| return JSONResponse({"ok": True}) |
|
|