luguog's picture
Prod ready: persistent storage, tiny Netlify, GGUF inference, ChatGPT export dApp
0067ed1 verified
Raw
History Blame
53.6 kB
#!/usr/bin/env python3
"""
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 biplist for binary plists, fall back to plistlib
try:
import biplist
HAS_BIPLIST = True
except ImportError:
HAS_BIPLIST = False
# Try llama-cpp-python for GGUF inference (optional)
try:
from llama_cpp import Llama as _Llama
HAS_LLAMA = True
except ImportError:
HAS_LLAMA = False
# ─── Persistent storage detection ──────────────────────────────────────────
# HF Spaces mounts persistent storage at /data when enabled.
# Fall back to local ./data when running outside HF or without persistent storage.
_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"
# Inference model cache (in-memory)
_llm_cache: dict[str, Any] = {}
app = FastAPI(title="LocalSpace Deployer")
# Ensure static dir exists
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 parsing ───────────────────────────────────────────────────────────
GGUF_MAGIC = 0x46554747 # "GGUF" in little-endian
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,
}
# Read tensor info (names + shapes)
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]
# Build top-level tree
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,
})
# Discover all HTML files recursively for iframe preview
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})
# Prefer shallowest HTML, then shortest path, then largest
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)
# ─── Preview extracted HTML content in iframe ──────────────────────────────
@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)
# ─── Create DMG from iframe URL (Web App → macOS App) ─────────────────────
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)
# ─── Tiny Netlify: deploy static sites from ZIP ─────────────────────────────
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
# Search one level deep
for item in sorted(root.iterdir()):
if item.is_dir():
for c in candidates:
if (item / c).exists():
return f"{item.name}/{c}"
# Fallback: any HTML file
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
# Save ZIP
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)
# Extract
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)
# Handle nested root: if ZIP contains a single top-level dir, use that as root
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)
# ─── GGUF Inference ─────────────────────────────────────────────────────────
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)
# ─── ChatGPT Export → dApp ──────────────────────────────────────────────────
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]) # Limit to 500 conversations
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}}
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]}"
# Save the uploaded file
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 and find conversations.json
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)
# Find conversations.json
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:
# Direct JSON upload
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
# Parse the ChatGPT export
parsed = _parse_chatgpt_export(raw_data)
if parsed["total"] == 0:
return JSONResponse({"error": "No conversations found in export."}, status_code=400)
# Generate dApp HTML
title = f"ChatGPT Archive ({parsed['total']} conversations)"
dapp_html = _generate_dapp_html(parsed, title)
# Deploy as a site
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:
# Clean up uploads (all possible extensions)
for suffix in [".dmg", ".gguf", ".zip", ".json"]:
f = UPLOAD_DIR / f"{app_id}{suffix}"
if f.exists():
f.unlink()
# Clean up extracted files
extracted = EXTRACT_DIR / app_id
if extracted.exists():
shutil.rmtree(extracted)
# Clean up deployed sites
site = SITES_DIR / app_id
if site.exists():
shutil.rmtree(site)
# Clean up dapps
dapp = DAPPS_DIR / app_id
if dapp.exists():
shutil.rmtree(dapp)
# Unload model from inference cache
_llm_cache.pop(app_id, None)
_save_db()
return JSONResponse({"ok": True})