Update app.py: persistent storage, tiny Netlify, GGUF inference, ChatGPT dApp

#3
by luguog - opened
Files changed (1) hide show
  1. app.py +863 -61
app.py CHANGED
@@ -1,11 +1,9 @@
1
  #!/usr/bin/env python3
2
  """
3
  LocalSpace Deployer — Hugging Face Space (Vanilla)
4
-
5
- Drag a DMG. It gets OPENED on the server: extracted, inspected, and its
6
- app bundle metadata is displayed. The Space itself hosts the DMG contents.
7
-
8
- Pure FastAPI + HTML/JS. No Gradio, no Streamlit, no API keys.
9
  Public by default.
10
  """
11
  from __future__ import annotations
@@ -16,14 +14,16 @@ import os
16
  import plistlib
17
  import re
18
  import shutil
 
19
  import subprocess
20
  import time
21
  import uuid
 
22
  from pathlib import Path
23
  from typing import Any
24
 
25
  from fastapi import FastAPI, File, Request, UploadFile
26
- from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
27
  from fastapi.staticfiles import StaticFiles
28
 
29
  # Try biplist for binary plists, fall back to plistlib
@@ -33,16 +33,42 @@ try:
33
  except ImportError:
34
  HAS_BIPLIST = False
35
 
36
- DATA_DIR = Path("data")
37
- DATA_DIR.mkdir(exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  UPLOAD_DIR = DATA_DIR / "uploads"
39
  UPLOAD_DIR.mkdir(exist_ok=True)
40
  EXTRACT_DIR = DATA_DIR / "extracted"
41
  EXTRACT_DIR.mkdir(exist_ok=True)
 
 
 
 
42
  DB_PATH = DATA_DIR / "apps.json"
43
 
 
 
 
44
  app = FastAPI(title="LocalSpace Deployer")
45
- app.mount("/static", StaticFiles(directory="static"), name="static")
 
 
 
 
46
 
47
  _store: dict[str, dict[str, Any]] = {}
48
 
@@ -62,7 +88,7 @@ def _save_db() -> None:
62
 
63
 
64
  def _slugify(name: str) -> str:
65
- return re.sub(r"[^a-z0-9]+", "-", name.lower().replace(".dmg", "")).strip("-")
66
 
67
 
68
  def _hash_file(path: Path) -> str:
@@ -73,6 +99,14 @@ def _hash_file(path: Path) -> str:
73
  return h.hexdigest()
74
 
75
 
 
 
 
 
 
 
 
 
76
  def _read_plist(path: Path) -> dict[str, Any]:
77
  """Read a plist file (XML or binary)."""
78
  try:
@@ -157,6 +191,95 @@ def _inspect_app_bundle(app_path: Path) -> dict[str, Any]:
157
  }
158
 
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  def _open_dmg(app_id: str, dmg_path: Path) -> dict[str, Any]:
161
  """Open a DMG: extract it, find apps, inspect them."""
162
  extract_to = EXTRACT_DIR / app_id
@@ -211,8 +334,15 @@ def index(request: Request) -> HTMLResponse:
211
 
212
  @app.post("/api/upload")
213
  async def upload_file(file: UploadFile = File(...)) -> JSONResponse:
214
- if not file.filename or not file.filename.lower().endswith(".dmg"):
215
- return JSONResponse({"error": "Only .dmg files are accepted."}, status_code=400)
 
 
 
 
 
 
 
216
 
217
  app_id = str(uuid.uuid4())
218
  slug = _slugify(file.filename)
@@ -222,7 +352,8 @@ async def upload_file(file: UploadFile = File(...)) -> JSONResponse:
222
  slug = f"{base_slug}-{counter}"
223
  counter += 1
224
 
225
- dest = UPLOAD_DIR / f"{app_id}.dmg"
 
226
  with open(dest, "wb") as f:
227
  while True:
228
  chunk = await file.read(65536)
@@ -233,31 +364,51 @@ async def upload_file(file: UploadFile = File(...)) -> JSONResponse:
233
  sha256 = _hash_file(dest)
234
  size = dest.stat().st_size
235
 
236
- opened = _open_dmg(app_id, dest)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
- entry = {
239
- "app_id": app_id,
240
- "slug": slug,
241
- "filename": file.filename,
242
- "size": size,
243
- "sha256": sha256,
244
- "download_url": f"/api/download/{app_id}",
245
- "opened": opened.get("opened", False),
246
- "apps_found": opened.get("apps_found", 0),
247
- "apps": opened.get("apps", []),
248
- "tree": opened.get("tree", []),
249
- "html_files": opened.get("html_files", []),
250
- "has_preview": opened.get("has_preview", False),
251
- "preview_entry": opened.get("preview_entry", None),
252
- "created_at": time.time(),
253
- }
254
  _store[app_id] = entry
255
  _save_db()
256
 
257
- return JSONResponse({
258
- "app": entry,
259
- "message": "DMG uploaded and opened." if entry["opened"] else "DMG uploaded but could not be opened.",
260
- }, status_code=201)
261
 
262
 
263
  @app.get("/api/apps")
@@ -340,18 +491,16 @@ def download_app(app_id: str):
340
  if not entry:
341
  return JSONResponse({"error": "Not found"}, status_code=404)
342
 
343
- path = UPLOAD_DIR / f"{app_id}.dmg"
 
344
  if not path.exists():
345
  return JSONResponse({
346
  "error": "File not found on disk",
347
- "detail": "This upload was stored in temporary storage that was cleared during a Space restart. Please re-upload the DMG."
348
  }, status_code=404)
349
 
350
- return FileResponse(
351
- path=path,
352
- filename=entry["filename"],
353
- media_type="application/x-apple-diskimage",
354
- )
355
 
356
 
357
  @app.get("/api/browse/{app_id}/{path:path}")
@@ -367,7 +516,7 @@ def browse_extracted(app_id: str, path: str):
367
  if not base.exists():
368
  return JSONResponse({
369
  "error": "Extracted files not found",
370
- "detail": "This upload was stored in temporary storage that was cleared during a Space restart. Please re-upload the DMG."
371
  }, status_code=404)
372
 
373
  try:
@@ -404,16 +553,14 @@ def preview_content(app_id: str, path: str):
404
  if not base.exists():
405
  return JSONResponse({
406
  "error": "Extracted files not found",
407
- "detail": "This upload was stored in temporary storage that was cleared during a Space restart. Please re-upload the DMG."
408
  }, status_code=404)
409
 
410
  if path:
411
  target = base / path
412
  else:
413
- # Default to index.html if no path
414
  target = base / "index.html"
415
  if not target.exists():
416
- # Find any HTML file
417
  for f in base.rglob("*.html"):
418
  target = f
419
  break
@@ -421,14 +568,12 @@ def preview_content(app_id: str, path: str):
421
  if not target.exists():
422
  return JSONResponse({"error": "Not found"}, status_code=404)
423
 
424
- # Security check
425
  try:
426
  target.resolve().relative_to(base.resolve())
427
  except ValueError:
428
  return JSONResponse({"error": "Access denied"}, status_code=403)
429
 
430
  if target.is_dir():
431
- # Look for index.html in directory
432
  idx = target / "index.html"
433
  if idx.exists():
434
  target = idx
@@ -453,10 +598,8 @@ def preview_content(app_id: str, path: str):
453
  suffix = target.suffix.lower()
454
  media_type = mime_types.get(suffix, "application/octet-stream")
455
 
456
- # For HTML, inject base tag to handle relative paths
457
  if media_type == "text/html":
458
  content = target.read_text(errors="replace")
459
- # Inject base tag after <head>
460
  if "<base" not in content:
461
  rel_dir = str(target.parent.relative_to(base)) if target.parent != base else ""
462
  base_tag = f'<base href="/api/preview/{app_id}/{rel_dir}/">' if rel_dir else f'<base href="/api/preview/{app_id}/">'
@@ -485,7 +628,6 @@ def _create_app_bundle(url: str, app_name: str, bundle_id: str, version: str) ->
485
  macos.mkdir(parents=True)
486
  resources.mkdir(parents=True)
487
 
488
- # Info.plist
489
  plist = {
490
  "CFBundleDevelopmentRegion": "en",
491
  "CFBundleExecutable": app_name.replace(" ", ""),
@@ -501,7 +643,6 @@ def _create_app_bundle(url: str, app_name: str, bundle_id: str, version: str) ->
501
  with open(contents / "Info.plist", "wb") as f:
502
  plistlib.dump(plist, f)
503
 
504
- # Wrapper script that opens URL
505
  script_path = macos / app_name.replace(" ", "")
506
  script_content = f'''#!/bin/bash
507
  # Auto-generated web app wrapper
@@ -509,14 +650,12 @@ URL="{url}"
509
  if command -v open >/dev/null 2>&1; then
510
  open "$URL"
511
  else
512
- # Fallback for Linux testing
513
  xdg-open "$URL" 2>/dev/null || python3 -m webbrowser "$URL"
514
  fi
515
  '''
516
  script_path.write_text(script_content)
517
  script_path.chmod(0o755)
518
 
519
- # Create a local HTML file as backup/embedded view
520
  html_path = resources / "index.html"
521
  html_path.write_text(f'''<!DOCTYPE html>
522
  <html><head><meta charset="utf-8"><title>{app_name}</title>
@@ -531,7 +670,6 @@ def _create_dmg_from_app(app_bundle: Path, output_name: str) -> Path | None:
531
  """Best-effort DMG creation. Returns path to DMG or None."""
532
  dmg_path = WEBAPP_DIR / f"{output_name}.dmg"
533
 
534
- # Try genisoimage + dmg if available (Linux)
535
  try:
536
  iso_path = WEBAPP_DIR / f"{output_name}.iso"
537
  result = subprocess.run(
@@ -540,7 +678,6 @@ def _create_dmg_from_app(app_bundle: Path, output_name: str) -> Path | None:
540
  capture_output=True, text=True, timeout=30,
541
  )
542
  if result.returncode == 0:
543
- # Try converting ISO to DMG
544
  dmg_result = subprocess.run(
545
  ["dmg", "iso", str(iso_path), str(dmg_path)],
546
  capture_output=True, text=True, timeout=30,
@@ -553,7 +690,6 @@ def _create_dmg_from_app(app_bundle: Path, output_name: str) -> Path | None:
553
  except Exception:
554
  pass
555
 
556
- # Fallback: create a ZIP that user can extract on Mac and run hdiutil
557
  zip_path = WEBAPP_DIR / f"{output_name}.zip"
558
  shutil.make_archive(
559
  base_name=str(WEBAPP_DIR / output_name),
@@ -585,7 +721,6 @@ async def create_webapp(request: Request) -> JSONResponse:
585
  if not app_name:
586
  return JSONResponse({"error": "App name is required"}, status_code=400)
587
 
588
- # Sanitize
589
  app_name_safe = re.sub(r'[^a-zA-Z0-9 ]+', '', app_name).strip()
590
  if not app_name_safe:
591
  app_name_safe = "WebApp"
@@ -605,7 +740,9 @@ async def create_webapp(request: Request) -> JSONResponse:
605
  "app_id": pkg_id,
606
  "slug": _slugify(app_name_safe),
607
  "filename": dest.name,
 
608
  "size": dest.stat().st_size,
 
609
  "sha256": _hash_file(dest),
610
  "download_url": f"/api/download/{pkg_id}",
611
  "source_url": url,
@@ -620,21 +757,686 @@ async def create_webapp(request: Request) -> JSONResponse:
620
 
621
  return JSONResponse({
622
  "app": entry,
623
- "message": f"'{app_name_safe}' packaged. Download and extract on macOS, then run 'hdiutil create -srcfolder {app_name_safe}.app {app_name_safe}.dmg' to convert to DMG." if pkg.suffix == ".zip" else f"'{app_name_safe}' DMG created.",
624
  }, status_code=201)
625
  except Exception as exc:
626
  return JSONResponse({"error": str(exc)}, status_code=500)
627
 
628
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
629
  @app.delete("/api/apps/{app_id}")
630
  def delete_app(app_id: str) -> JSONResponse:
631
  entry = _store.pop(app_id, None)
632
  if entry:
633
- dmg = UPLOAD_DIR / f"{app_id}.dmg"
634
- if dmg.exists():
635
- dmg.unlink()
 
 
 
636
  extracted = EXTRACT_DIR / app_id
637
  if extracted.exists():
638
  shutil.rmtree(extracted)
 
 
 
 
 
 
 
 
 
 
639
  _save_db()
640
  return JSONResponse({"ok": True})
 
1
  #!/usr/bin/env python3
2
  """
3
  LocalSpace Deployer — Hugging Face Space (Vanilla)
4
+ Drag a DMG or GGUF. It gets OPENED on the server: extracted, inspected, and its
5
+ app bundle metadata or model metadata is displayed. The Space itself hosts the
6
+ contents. Pure FastAPI + HTML/JS. No Gradio, no Streamlit, no API keys.
 
 
7
  Public by default.
8
  """
9
  from __future__ import annotations
 
14
  import plistlib
15
  import re
16
  import shutil
17
+ import struct
18
  import subprocess
19
  import time
20
  import uuid
21
+ import zipfile
22
  from pathlib import Path
23
  from typing import Any
24
 
25
  from fastapi import FastAPI, File, Request, UploadFile
26
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
27
  from fastapi.staticfiles import StaticFiles
28
 
29
  # Try biplist for binary plists, fall back to plistlib
 
33
  except ImportError:
34
  HAS_BIPLIST = False
35
 
36
+ # Try llama-cpp-python for GGUF inference (optional)
37
+ try:
38
+ from llama_cpp import Llama as _Llama
39
+ HAS_LLAMA = True
40
+ except ImportError:
41
+ HAS_LLAMA = False
42
+
43
+ # ─── Persistent storage detection ──────────────────────────────────────────
44
+ # HF Spaces mounts persistent storage at /data when enabled.
45
+ # Fall back to local ./data when running outside HF or without persistent storage.
46
+ _PERSISTENT = Path("/data")
47
+ if _PERSISTENT.exists() and _PERSISTENT.is_dir() and os.access(_PERSISTENT, os.W_OK):
48
+ DATA_DIR = _PERSISTENT / "localspace"
49
+ else:
50
+ DATA_DIR = Path("data")
51
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
52
+
53
  UPLOAD_DIR = DATA_DIR / "uploads"
54
  UPLOAD_DIR.mkdir(exist_ok=True)
55
  EXTRACT_DIR = DATA_DIR / "extracted"
56
  EXTRACT_DIR.mkdir(exist_ok=True)
57
+ SITES_DIR = DATA_DIR / "sites"
58
+ SITES_DIR.mkdir(exist_ok=True)
59
+ DAPPS_DIR = DATA_DIR / "dapps"
60
+ DAPPS_DIR.mkdir(exist_ok=True)
61
  DB_PATH = DATA_DIR / "apps.json"
62
 
63
+ # Inference model cache (in-memory)
64
+ _llm_cache: dict[str, Any] = {}
65
+
66
  app = FastAPI(title="LocalSpace Deployer")
67
+
68
+ # Ensure static dir exists
69
+ STATIC_DIR = Path("static")
70
+ STATIC_DIR.mkdir(exist_ok=True)
71
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
72
 
73
  _store: dict[str, dict[str, Any]] = {}
74
 
 
88
 
89
 
90
  def _slugify(name: str) -> str:
91
+ return re.sub(r"[^a-z0-9]+", "-", name.lower().replace(".dmg", "").replace(".gguf", "").replace(".zip", "").replace(".json", "")).strip("-")
92
 
93
 
94
  def _hash_file(path: Path) -> str:
 
99
  return h.hexdigest()
100
 
101
 
102
+ def _format_bytes(n: int) -> str:
103
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
104
+ if n < 1024:
105
+ return f"{n:.1f} {unit}"
106
+ n /= 1024
107
+ return f"{n:.1f} PB"
108
+
109
+
110
  def _read_plist(path: Path) -> dict[str, Any]:
111
  """Read a plist file (XML or binary)."""
112
  try:
 
191
  }
192
 
193
 
194
+ # ─── GGUF parsing ───────────────────────────────────────────────────────────
195
+
196
+ GGUF_MAGIC = 0x46554747 # "GGUF" in little-endian
197
+ GGUF_TYPE_MAP = {
198
+ 0: "UINT8", 1: "INT8", 2: "UINT16", 3: "INT16",
199
+ 4: "UINT32", 5: "INT32", 6: "FLOAT32", 7: "BOOL",
200
+ 8: "STRING", 9: "ARRAY", 10: "UINT64", 11: "INT64", 12: "FLOAT64",
201
+ }
202
+
203
+
204
+ def _read_gguf_string(f) -> str:
205
+ n = struct.unpack("<Q", f.read(8))[0]
206
+ return f.read(n).decode("utf-8", errors="replace")
207
+
208
+
209
+ def _read_gguf_value(f, vtype: int) -> Any:
210
+ if vtype == 0: return struct.unpack("<B", f.read(1))[0]
211
+ if vtype == 1: return struct.unpack("<b", f.read(1))[0]
212
+ if vtype == 2: return struct.unpack("<H", f.read(2))[0]
213
+ if vtype == 3: return struct.unpack("<h", f.read(2))[0]
214
+ if vtype == 4: return struct.unpack("<I", f.read(4))[0]
215
+ if vtype == 5: return struct.unpack("<i", f.read(4))[0]
216
+ if vtype == 6: return struct.unpack("<f", f.read(4))[0]
217
+ if vtype == 7: return struct.unpack("<?", f.read(1))[0]
218
+ if vtype == 8: return _read_gguf_string(f)
219
+ if vtype == 10: return struct.unpack("<Q", f.read(8))[0]
220
+ if vtype == 11: return struct.unpack("<q", f.read(8))[0]
221
+ if vtype == 12: return struct.unpack("<d", f.read(8))[0]
222
+ if vtype == 9:
223
+ inner = struct.unpack("<I", f.read(4))[0]
224
+ n = struct.unpack("<Q", f.read(8))[0]
225
+ return {"type": GGUF_TYPE_MAP.get(inner, str(inner)), "count": n}
226
+ return None
227
+
228
+
229
+ def _inspect_gguf(gguf_path: Path) -> dict[str, Any]:
230
+ """Parse GGUF header: version, tensor count, metadata KV pairs."""
231
+ try:
232
+ with open(gguf_path, "rb") as f:
233
+ magic = struct.unpack("<I", f.read(4))[0]
234
+ if magic != GGUF_MAGIC:
235
+ return {"valid": False, "error": f"Not a GGUF file (magic={magic:#x})"}
236
+
237
+ version = struct.unpack("<I", f.read(4))[0]
238
+ tensor_count = struct.unpack("<Q", f.read(8))[0]
239
+ kv_count = struct.unpack("<Q", f.read(8))[0]
240
+
241
+ metadata = {}
242
+ for _ in range(kv_count):
243
+ key = _read_gguf_string(f)
244
+ vtype = struct.unpack("<I", f.read(4))[0]
245
+ value = _read_gguf_value(f, vtype)
246
+ metadata[key] = {
247
+ "type": GGUF_TYPE_MAP.get(vtype, str(vtype)),
248
+ "value": value,
249
+ }
250
+
251
+ # Read tensor info (names + shapes)
252
+ tensors = []
253
+ for _ in range(min(tensor_count, 200)):
254
+ n_dims = struct.unpack("<I", f.read(4))[0]
255
+ name = _read_gguf_string(f)
256
+ dims = [struct.unpack("<Q", f.read(8))[0] for _ in range(n_dims)]
257
+ dtype = struct.unpack("<I", f.read(4))[0]
258
+ offset = struct.unpack("<Q", f.read(8))[0]
259
+ tensors.append({
260
+ "name": name,
261
+ "dims": dims,
262
+ "dtype": GGUF_TYPE_MAP.get(dtype, str(dtype)),
263
+ "offset": offset,
264
+ })
265
+
266
+ return {
267
+ "valid": True,
268
+ "version": version,
269
+ "tensor_count": tensor_count,
270
+ "kv_count": kv_count,
271
+ "metadata": metadata,
272
+ "tensors": tensors,
273
+ "architecture": metadata.get("general.architecture", {}).get("value", "unknown"),
274
+ "name": metadata.get("general.name", {}).get("value", ""),
275
+ "quantization": metadata.get("general.quantization_version", {}).get("value", ""),
276
+ "context_length": metadata.get("general.context_length", {}).get("value", 0),
277
+ "file_size": gguf_path.stat().st_size,
278
+ }
279
+ except Exception as exc:
280
+ return {"valid": False, "error": str(exc)}
281
+
282
+
283
  def _open_dmg(app_id: str, dmg_path: Path) -> dict[str, Any]:
284
  """Open a DMG: extract it, find apps, inspect them."""
285
  extract_to = EXTRACT_DIR / app_id
 
334
 
335
  @app.post("/api/upload")
336
  async def upload_file(file: UploadFile = File(...)) -> JSONResponse:
337
+ if not file.filename:
338
+ return JSONResponse({"error": "No filename provided."}, status_code=400)
339
+
340
+ fname = file.filename.lower()
341
+ is_dmg = fname.endswith(".dmg")
342
+ is_gguf = fname.endswith(".gguf")
343
+
344
+ if not is_dmg and not is_gguf:
345
+ return JSONResponse({"error": "Only .dmg and .gguf files are accepted."}, status_code=400)
346
 
347
  app_id = str(uuid.uuid4())
348
  slug = _slugify(file.filename)
 
352
  slug = f"{base_slug}-{counter}"
353
  counter += 1
354
 
355
+ ext = ".dmg" if is_dmg else ".gguf"
356
+ dest = UPLOAD_DIR / f"{app_id}{ext}"
357
  with open(dest, "wb") as f:
358
  while True:
359
  chunk = await file.read(65536)
 
364
  sha256 = _hash_file(dest)
365
  size = dest.stat().st_size
366
 
367
+ if is_dmg:
368
+ opened = _open_dmg(app_id, dest)
369
+ entry = {
370
+ "app_id": app_id,
371
+ "slug": slug,
372
+ "filename": file.filename,
373
+ "file_type": "dmg",
374
+ "size": size,
375
+ "size_human": _format_bytes(size),
376
+ "sha256": sha256,
377
+ "download_url": f"/api/download/{app_id}",
378
+ "opened": opened.get("opened", False),
379
+ "apps_found": opened.get("apps_found", 0),
380
+ "apps": opened.get("apps", []),
381
+ "tree": opened.get("tree", []),
382
+ "html_files": opened.get("html_files", []),
383
+ "has_preview": opened.get("has_preview", False),
384
+ "preview_entry": opened.get("preview_entry", None),
385
+ "error": opened.get("error"),
386
+ "created_at": time.time(),
387
+ }
388
+ msg = "DMG uploaded and opened." if entry["opened"] else f"DMG uploaded but could not be opened: {entry.get('error', '')}"
389
+ else:
390
+ gguf_info = _inspect_gguf(dest)
391
+ entry = {
392
+ "app_id": app_id,
393
+ "slug": slug,
394
+ "filename": file.filename,
395
+ "file_type": "gguf",
396
+ "size": size,
397
+ "size_human": _format_bytes(size),
398
+ "sha256": sha256,
399
+ "download_url": f"/api/download/{app_id}",
400
+ "gguf": gguf_info,
401
+ "opened": gguf_info.get("valid", False),
402
+ "has_preview": False,
403
+ "preview_entry": None,
404
+ "created_at": time.time(),
405
+ }
406
+ msg = "GGUF uploaded and inspected." if gguf_info.get("valid") else f"GGUF uploaded but inspection failed: {gguf_info.get('error', '')}"
407
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
  _store[app_id] = entry
409
  _save_db()
410
 
411
+ return JSONResponse({"app": entry, "message": msg}, status_code=201)
 
 
 
412
 
413
 
414
  @app.get("/api/apps")
 
491
  if not entry:
492
  return JSONResponse({"error": "Not found"}, status_code=404)
493
 
494
+ ext = ".dmg" if entry.get("file_type", "dmg") == "dmg" else ".gguf"
495
+ path = UPLOAD_DIR / f"{app_id}{ext}"
496
  if not path.exists():
497
  return JSONResponse({
498
  "error": "File not found on disk",
499
+ "detail": "This upload was stored in temporary storage that was cleared during a Space restart. Please re-upload."
500
  }, status_code=404)
501
 
502
+ media = "application/x-apple-diskimage" if ext == ".dmg" else "application/octet-stream"
503
+ return FileResponse(path=path, filename=entry["filename"], media_type=media)
 
 
 
504
 
505
 
506
  @app.get("/api/browse/{app_id}/{path:path}")
 
516
  if not base.exists():
517
  return JSONResponse({
518
  "error": "Extracted files not found",
519
+ "detail": "This upload was stored in temporary storage that was cleared during a Space restart. Please re-upload."
520
  }, status_code=404)
521
 
522
  try:
 
553
  if not base.exists():
554
  return JSONResponse({
555
  "error": "Extracted files not found",
556
+ "detail": "This upload was stored in temporary storage that was cleared during a Space restart. Please re-upload."
557
  }, status_code=404)
558
 
559
  if path:
560
  target = base / path
561
  else:
 
562
  target = base / "index.html"
563
  if not target.exists():
 
564
  for f in base.rglob("*.html"):
565
  target = f
566
  break
 
568
  if not target.exists():
569
  return JSONResponse({"error": "Not found"}, status_code=404)
570
 
 
571
  try:
572
  target.resolve().relative_to(base.resolve())
573
  except ValueError:
574
  return JSONResponse({"error": "Access denied"}, status_code=403)
575
 
576
  if target.is_dir():
 
577
  idx = target / "index.html"
578
  if idx.exists():
579
  target = idx
 
598
  suffix = target.suffix.lower()
599
  media_type = mime_types.get(suffix, "application/octet-stream")
600
 
 
601
  if media_type == "text/html":
602
  content = target.read_text(errors="replace")
 
603
  if "<base" not in content:
604
  rel_dir = str(target.parent.relative_to(base)) if target.parent != base else ""
605
  base_tag = f'<base href="/api/preview/{app_id}/{rel_dir}/">' if rel_dir else f'<base href="/api/preview/{app_id}/">'
 
628
  macos.mkdir(parents=True)
629
  resources.mkdir(parents=True)
630
 
 
631
  plist = {
632
  "CFBundleDevelopmentRegion": "en",
633
  "CFBundleExecutable": app_name.replace(" ", ""),
 
643
  with open(contents / "Info.plist", "wb") as f:
644
  plistlib.dump(plist, f)
645
 
 
646
  script_path = macos / app_name.replace(" ", "")
647
  script_content = f'''#!/bin/bash
648
  # Auto-generated web app wrapper
 
650
  if command -v open >/dev/null 2>&1; then
651
  open "$URL"
652
  else
 
653
  xdg-open "$URL" 2>/dev/null || python3 -m webbrowser "$URL"
654
  fi
655
  '''
656
  script_path.write_text(script_content)
657
  script_path.chmod(0o755)
658
 
 
659
  html_path = resources / "index.html"
660
  html_path.write_text(f'''<!DOCTYPE html>
661
  <html><head><meta charset="utf-8"><title>{app_name}</title>
 
670
  """Best-effort DMG creation. Returns path to DMG or None."""
671
  dmg_path = WEBAPP_DIR / f"{output_name}.dmg"
672
 
 
673
  try:
674
  iso_path = WEBAPP_DIR / f"{output_name}.iso"
675
  result = subprocess.run(
 
678
  capture_output=True, text=True, timeout=30,
679
  )
680
  if result.returncode == 0:
 
681
  dmg_result = subprocess.run(
682
  ["dmg", "iso", str(iso_path), str(dmg_path)],
683
  capture_output=True, text=True, timeout=30,
 
690
  except Exception:
691
  pass
692
 
 
693
  zip_path = WEBAPP_DIR / f"{output_name}.zip"
694
  shutil.make_archive(
695
  base_name=str(WEBAPP_DIR / output_name),
 
721
  if not app_name:
722
  return JSONResponse({"error": "App name is required"}, status_code=400)
723
 
 
724
  app_name_safe = re.sub(r'[^a-zA-Z0-9 ]+', '', app_name).strip()
725
  if not app_name_safe:
726
  app_name_safe = "WebApp"
 
740
  "app_id": pkg_id,
741
  "slug": _slugify(app_name_safe),
742
  "filename": dest.name,
743
+ "file_type": "webapp",
744
  "size": dest.stat().st_size,
745
+ "size_human": _format_bytes(dest.stat().st_size),
746
  "sha256": _hash_file(dest),
747
  "download_url": f"/api/download/{pkg_id}",
748
  "source_url": url,
 
757
 
758
  return JSONResponse({
759
  "app": entry,
760
+ "message": f"'{app_name_safe}' packaged." if pkg.suffix == ".zip" else f"'{app_name_safe}' DMG created.",
761
  }, status_code=201)
762
  except Exception as exc:
763
  return JSONResponse({"error": str(exc)}, status_code=500)
764
 
765
 
766
+ # ─── Tiny Netlify: deploy static sites from ZIP ─────────────────────────────
767
+
768
+ def _find_index_html(root: Path) -> str | None:
769
+ """Find the entry HTML file in a deployed site."""
770
+ candidates = ["index.html", "index.htm"]
771
+ for c in candidates:
772
+ if (root / c).exists():
773
+ return c
774
+ # Search one level deep
775
+ for item in sorted(root.iterdir()):
776
+ if item.is_dir():
777
+ for c in candidates:
778
+ if (item / c).exists():
779
+ return f"{item.name}/{c}"
780
+ # Fallback: any HTML file
781
+ for f in root.rglob("*.html"):
782
+ return str(f.relative_to(root))
783
+ return None
784
+
785
+
786
+ def _build_site_tree(root: Path, max_depth: int = 3) -> list[dict[str, Any]]:
787
+ """Build a file tree for a deployed site."""
788
+ tree = []
789
+ try:
790
+ for item in sorted(root.iterdir()):
791
+ entry: dict[str, Any] = {
792
+ "name": item.name,
793
+ "type": "directory" if item.is_dir() else "file",
794
+ "size": item.stat().st_size if item.is_file() else 0,
795
+ }
796
+ if item.is_dir() and max_depth > 0:
797
+ entry["children"] = _build_site_tree(item, max_depth - 1)
798
+ tree.append(entry)
799
+ except Exception:
800
+ pass
801
+ return tree
802
+
803
+
804
+ @app.post("/api/deploy-site")
805
+ async def deploy_site(file: UploadFile = File(...)) -> JSONResponse:
806
+ """Deploy a static site from a ZIP file. Returns a hosted URL."""
807
+ if not file.filename or not file.filename.lower().endswith(".zip"):
808
+ return JSONResponse({"error": "Only .zip files are accepted for site deployment."}, status_code=400)
809
+
810
+ site_id = str(uuid.uuid4())
811
+ slug = _slugify(file.filename) or f"site-{site_id[:8]}"
812
+ base_slug = slug
813
+ counter = 1
814
+ while any(a.get("slug") == slug and a.get("file_type") == "site" for a in _store.values()):
815
+ slug = f"{base_slug}-{counter}"
816
+ counter += 1
817
+
818
+ # Save ZIP
819
+ zip_path = UPLOAD_DIR / f"{site_id}.zip"
820
+ with open(zip_path, "wb") as f:
821
+ while True:
822
+ chunk = await file.read(65536)
823
+ if not chunk:
824
+ break
825
+ f.write(chunk)
826
+
827
+ # Extract
828
+ site_root = SITES_DIR / site_id
829
+ site_root.mkdir(parents=True, exist_ok=True)
830
+ try:
831
+ with zipfile.ZipFile(zip_path, "r") as zf:
832
+ zf.extractall(site_root)
833
+ except zipfile.BadZipFile:
834
+ shutil.rmtree(site_root)
835
+ zip_path.unlink(missing_ok=True)
836
+ return JSONResponse({"error": "Invalid ZIP file."}, status_code=400)
837
+
838
+ # Handle nested root: if ZIP contains a single top-level dir, use that as root
839
+ top_items = list(site_root.iterdir())
840
+ if len(top_items) == 1 and top_items[0].is_dir():
841
+ real_root = top_items[0]
842
+ else:
843
+ real_root = site_root
844
+
845
+ index_file = _find_index_html(real_root)
846
+ if not index_file:
847
+ return JSONResponse({"error": "No HTML file found in ZIP."}, status_code=400)
848
+
849
+ sha256 = _hash_file(zip_path)
850
+ file_count = sum(1 for _ in real_root.rglob("*") if _.is_file())
851
+ total_size = sum(f.stat().st_size for f in real_root.rglob("*") if f.is_file())
852
+ tree = _build_site_tree(real_root)
853
+
854
+ entry = {
855
+ "app_id": site_id,
856
+ "slug": slug,
857
+ "filename": file.filename,
858
+ "file_type": "site",
859
+ "size": zip_path.stat().st_size,
860
+ "size_human": _format_bytes(zip_path.stat().st_size),
861
+ "sha256": sha256,
862
+ "site_url": f"/site/{site_id}/",
863
+ "site_preview": f"/site/{site_id}/{index_file}",
864
+ "index_file": index_file,
865
+ "file_count": file_count,
866
+ "total_size": total_size,
867
+ "total_size_human": _format_bytes(total_size),
868
+ "tree": tree,
869
+ "has_preview": True,
870
+ "preview_entry": index_file,
871
+ "opened": True,
872
+ "created_at": time.time(),
873
+ }
874
+ _store[site_id] = entry
875
+ _save_db()
876
+
877
+ return JSONResponse({
878
+ "app": entry,
879
+ "message": f"Site deployed. {file_count} files, {entry['size_human']}.",
880
+ "site_url": entry["site_url"],
881
+ }, status_code=201)
882
+
883
+
884
+ @app.get("/site/{site_id}/", response_class=HTMLResponse)
885
+ @app.get("/site/{site_id}", response_class=HTMLResponse)
886
+ def serve_site_root(site_id: str) -> HTMLResponse:
887
+ """Serve a deployed static site's index page."""
888
+ entry = _store.get(site_id)
889
+ if not entry or entry.get("file_type") != "site":
890
+ return HTMLResponse("<h1>Site not found</h1>", status_code=404)
891
+
892
+ site_root = SITES_DIR / site_id
893
+ if not site_root.exists():
894
+ return HTMLResponse("<h1>Site files not found. Storage may have been cleared.</h1>", status_code=404)
895
+
896
+ index_file = entry.get("index_file", "index.html")
897
+ target = site_root / index_file
898
+ if not target.exists():
899
+ for f in site_root.rglob("*.html"):
900
+ target = f
901
+ break
902
+
903
+ if not target.exists():
904
+ return HTMLResponse("<h1>No HTML found</h1>", status_code=404)
905
+
906
+ content = target.read_text(errors="replace")
907
+ rel_dir = str(target.parent.relative_to(site_root))
908
+ if "<base" not in content:
909
+ base_href = f"/site/{site_id}/{rel_dir}/" if rel_dir and rel_dir != "." else f"/site/{site_id}/"
910
+ base_tag = f'<base href="{base_href}">'
911
+ content = content.replace("<head>", f"<head>{base_tag}", 1)
912
+ content = content.replace("<HEAD>", f"<HEAD>{base_tag}", 1)
913
+
914
+ return HTMLResponse(content=content)
915
+
916
+
917
+ @app.get("/site/{site_id}/{path:path}")
918
+ def serve_site_file(site_id: str, path: str):
919
+ """Serve any file from a deployed static site."""
920
+ entry = _store.get(site_id)
921
+ if not entry or entry.get("file_type") != "site":
922
+ return JSONResponse({"error": "Site not found"}, status_code=404)
923
+
924
+ site_root = SITES_DIR / site_id
925
+ if not site_root.exists():
926
+ return JSONResponse({"error": "Site storage cleared"}, status_code=404)
927
+
928
+ target = site_root / path
929
+
930
+ try:
931
+ target.resolve().relative_to(site_root.resolve())
932
+ except ValueError:
933
+ return JSONResponse({"error": "Access denied"}, status_code=403)
934
+
935
+ if not target.exists():
936
+ return JSONResponse({"error": "File not found"}, status_code=404)
937
+
938
+ if target.is_dir():
939
+ idx = target / "index.html"
940
+ if idx.exists():
941
+ content = idx.read_text(errors="replace")
942
+ rel_dir = str(idx.parent.relative_to(site_root))
943
+ if "<base" not in content:
944
+ base_href = f"/site/{site_id}/{rel_dir}/" if rel_dir and rel_dir != "." else f"/site/{site_id}/"
945
+ content = content.replace("<head>", f'<head><base href="{base_href}">', 1)
946
+ return HTMLResponse(content=content)
947
+ return JSONResponse({"error": "No index.html in directory"}, status_code=404)
948
+
949
+ mime_types = {
950
+ ".html": "text/html", ".htm": "text/html",
951
+ ".js": "application/javascript", ".mjs": "application/javascript",
952
+ ".css": "text/css",
953
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
954
+ ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp",
955
+ ".ico": "image/x-icon",
956
+ ".json": "application/json", ".xml": "application/xml",
957
+ ".woff2": "font/woff2", ".woff": "font/woff", ".ttf": "font/ttf",
958
+ ".otf": "font/otf",
959
+ ".txt": "text/plain", ".md": "text/markdown",
960
+ ".wasm": "application/wasm",
961
+ ".map": "application/json",
962
+ }
963
+ media_type = mime_types.get(target.suffix.lower(), "application/octet-stream")
964
+ return FileResponse(path=target, media_type=media_type)
965
+
966
+
967
+ # ─── GGUF Inference ─────────────────────────────────────────────────────────
968
+
969
+ def _get_llm(app_id: str) -> Any | None:
970
+ """Load a GGUF model into memory, with caching."""
971
+ if not HAS_LLAMA:
972
+ return None
973
+
974
+ if app_id in _llm_cache:
975
+ return _llm_cache[app_id]
976
+
977
+ entry = _store.get(app_id)
978
+ if not entry or entry.get("file_type") != "gguf":
979
+ return None
980
+
981
+ gguf_path = UPLOAD_DIR / f"{app_id}.gguf"
982
+ if not gguf_path.exists():
983
+ return None
984
+
985
+ try:
986
+ ctx = entry.get("gguf", {}).get("context_length", 2048)
987
+ n_ctx = min(int(ctx) if ctx else 2048, 4096)
988
+ llm = _Llama(model_path=str(gguf_path), n_ctx=n_ctx, verbose=False)
989
+ _llm_cache[app_id] = llm
990
+ return llm
991
+ except Exception:
992
+ return None
993
+
994
+
995
+ @app.get("/api/inference/status")
996
+ def inference_status() -> JSONResponse:
997
+ """Check if GGUF inference is available."""
998
+ loaded = list(_llm_cache.keys())
999
+ gguf_apps = [a for a in _store.values() if a.get("file_type") == "gguf"]
1000
+ return JSONResponse({
1001
+ "available": HAS_LLAMA,
1002
+ "loaded_models": len(loaded),
1003
+ "loaded_ids": loaded,
1004
+ "gguf_apps": [{"app_id": a["app_id"], "filename": a["filename"]} for a in gguf_apps],
1005
+ })
1006
+
1007
+
1008
+ @app.post("/api/inference/load/{app_id}")
1009
+ def load_model(app_id: str) -> JSONResponse:
1010
+ """Load a GGUF model into memory for inference."""
1011
+ if not HAS_LLAMA:
1012
+ return JSONResponse({
1013
+ "error": "llama-cpp-python not installed. Inference unavailable.",
1014
+ "hint": "Add llama-cpp-python to requirements.txt to enable inference.",
1015
+ }, status_code=503)
1016
+
1017
+ entry = _store.get(app_id)
1018
+ if not entry or entry.get("file_type") != "gguf":
1019
+ return JSONResponse({"error": "Not a GGUF artifact."}, status_code=400)
1020
+
1021
+ llm = _get_llm(app_id)
1022
+ if llm is None:
1023
+ return JSONResponse({"error": "Failed to load model."}, status_code=500)
1024
+
1025
+ return JSONResponse({
1026
+ "ok": True,
1027
+ "message": f"Model '{entry['filename']}' loaded.",
1028
+ "context_length": entry.get("gguf", {}).get("context_length", 2048),
1029
+ })
1030
+
1031
+
1032
+ @app.post("/api/inference/chat/{app_id}")
1033
+ async def inference_chat(app_id: str, request: Request) -> JSONResponse:
1034
+ """OpenAI-compatible chat completion endpoint using a loaded GGUF model."""
1035
+ if not HAS_LLAMA:
1036
+ return JSONResponse({"error": "Inference unavailable. llama-cpp-python not installed."}, status_code=503)
1037
+
1038
+ llm = _get_llm(app_id)
1039
+ if llm is None:
1040
+ return JSONResponse({"error": "Model not loaded. POST /api/inference/load/{app_id} first."}, status_code=400)
1041
+
1042
+ try:
1043
+ data = await request.json()
1044
+ except Exception:
1045
+ return JSONResponse({"error": "Invalid JSON"}, status_code=400)
1046
+
1047
+ messages = data.get("messages", [])
1048
+ if not messages:
1049
+ return JSONResponse({"error": "messages is required"}, status_code=400)
1050
+
1051
+ max_tokens = min(int(data.get("max_tokens", 512)), 2048)
1052
+ temperature = float(data.get("temperature", 0.7))
1053
+ stream = bool(data.get("stream", False))
1054
+
1055
+ if stream:
1056
+ def gen():
1057
+ for chunk in llm.create_chat_completion(
1058
+ messages=messages, max_tokens=max_tokens,
1059
+ temperature=temperature, stream=True,
1060
+ ):
1061
+ yield f"data: {json.dumps(chunk)}\n\n"
1062
+ yield "data: [DONE]\n\n"
1063
+ return StreamingResponse(gen(), media_type="text/event-stream")
1064
+
1065
+ result = llm.create_chat_completion(
1066
+ messages=messages, max_tokens=max_tokens, temperature=temperature,
1067
+ )
1068
+ return JSONResponse(result)
1069
+
1070
+
1071
+ @app.post("/api/inference/completion/{app_id}")
1072
+ async def inference_completion(app_id: str, request: Request) -> JSONResponse:
1073
+ """Text completion endpoint using a loaded GGUF model."""
1074
+ if not HAS_LLAMA:
1075
+ return JSONResponse({"error": "Inference unavailable. llama-cpp-python not installed."}, status_code=503)
1076
+
1077
+ llm = _get_llm(app_id)
1078
+ if llm is None:
1079
+ return JSONResponse({"error": "Model not loaded."}, status_code=400)
1080
+
1081
+ try:
1082
+ data = await request.json()
1083
+ except Exception:
1084
+ return JSONResponse({"error": "Invalid JSON"}, status_code=400)
1085
+
1086
+ prompt = data.get("prompt", "")
1087
+ if not prompt:
1088
+ return JSONResponse({"error": "prompt is required"}, status_code=400)
1089
+
1090
+ max_tokens = min(int(data.get("max_tokens", 256)), 2048)
1091
+ temperature = float(data.get("temperature", 0.7))
1092
+
1093
+ result = llm(prompt=prompt, max_tokens=max_tokens, temperature=temperature)
1094
+ return JSONResponse(result)
1095
+
1096
+
1097
+ # ─── ChatGPT Export → dApp ──────────────────────────────────────────────────
1098
+
1099
+ def _parse_chatgpt_export(data: Any) -> dict[str, Any]:
1100
+ """Parse a ChatGPT export conversations.json structure."""
1101
+ conversations = []
1102
+ raw = data if isinstance(data, list) else data.get("conversations", data) if isinstance(data, dict) else []
1103
+
1104
+ for conv in raw:
1105
+ if not isinstance(conv, dict):
1106
+ continue
1107
+ title = conv.get("title", "Untitled")
1108
+ conv_id = conv.get("id", conv.get("uuid", str(uuid.uuid4())))
1109
+ create_time = conv.get("create_time", 0)
1110
+ update_time = conv.get("update_time", 0)
1111
+
1112
+ messages = []
1113
+ mapping = conv.get("mapping", {})
1114
+ if isinstance(mapping, dict):
1115
+ for node_id, node in mapping.items():
1116
+ if not isinstance(node, dict):
1117
+ continue
1118
+ msg = node.get("message")
1119
+ if not msg or not isinstance(msg, dict):
1120
+ continue
1121
+ author = msg.get("author", {})
1122
+ role = author.get("role", "unknown")
1123
+ content = msg.get("content", {})
1124
+ parts = content.get("parts", [])
1125
+ text = ""
1126
+ for p in parts:
1127
+ if isinstance(p, str):
1128
+ text += p
1129
+ elif isinstance(p, dict):
1130
+ text += p.get("text", str(p))
1131
+ if text.strip():
1132
+ messages.append({
1133
+ "role": role,
1134
+ "text": text[:50000],
1135
+ "create_time": msg.get("create_time", 0),
1136
+ })
1137
+
1138
+ messages.sort(key=lambda m: m.get("create_time", 0) or 0)
1139
+ conversations.append({
1140
+ "id": str(conv_id),
1141
+ "title": title,
1142
+ "message_count": len(messages),
1143
+ "create_time": create_time,
1144
+ "update_time": update_time,
1145
+ "messages": messages,
1146
+ })
1147
+
1148
+ conversations.sort(key=lambda c: c.get("create_time", 0) or 0, reverse=True)
1149
+ return {"conversations": conversations, "total": len(conversations)}
1150
+
1151
+
1152
+ def _generate_dapp_html(parsed: dict[str, Any], title: str) -> str:
1153
+ """Generate a self-contained dApp HTML for browsing ChatGPT conversations."""
1154
+ convs = parsed["conversations"]
1155
+ convs_json = json.dumps(convs[:500]) # Limit to 500 conversations
1156
+
1157
+ return f'''<!DOCTYPE html>
1158
+ <html lang="en">
1159
+ <head>
1160
+ <meta charset="UTF-8">
1161
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1162
+ <title>{title} — ChatGPT Archive dApp</title>
1163
+ <style>
1164
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
1165
+ body {{ background: #0a0a0f; color: #e2e2e8; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; height: 100vh; display: flex; overflow: hidden; }}
1166
+ .sidebar {{ width: 300px; min-width: 300px; background: #14141a; border-right: 1px solid #2a2a35; overflow-y: auto; display: flex; flex-direction: column; }}
1167
+ .sidebar-header {{ padding: 16px; border-bottom: 1px solid #2a2a35; }}
1168
+ .sidebar-header h2 {{ font-size: 1rem; margin-bottom: 4px; }}
1169
+ .sidebar-header .count {{ font-size: 0.8rem; color: #888898; }}
1170
+ .search {{ padding: 12px 16px; border-bottom: 1px solid #2a2a35; }}
1171
+ .search input {{ width: 100%; background: #0a0a0f; border: 1px solid #2a2a35; color: #e2e2e8; padding: 8px 12px; border-radius: 8px; font-size: 0.85rem; outline: none; }}
1172
+ .search input:focus {{ border-color: #6366f1; }}
1173
+ .conv-list {{ flex: 1; overflow-y: auto; }}
1174
+ .conv-item {{ padding: 12px 16px; border-bottom: 1px solid #1c1c24; cursor: pointer; transition: background 0.15s; }}
1175
+ .conv-item:hover {{ background: #1c1c24; }}
1176
+ .conv-item.active {{ background: rgba(99,102,241,0.15); border-left: 3px solid #6366f1; }}
1177
+ .conv-item .title {{ font-size: 0.88rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
1178
+ .conv-item .meta {{ font-size: 0.75rem; color: #5a5a68; margin-top: 2px; }}
1179
+ .main {{ flex: 1; overflow-y: auto; padding: 24px 32px; }}
1180
+ .welcome {{ display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #5a5a68; }}
1181
+ .welcome .icon {{ font-size: 3rem; margin-bottom: 12px; }}
1182
+ .welcome h3 {{ font-size: 1.1rem; margin-bottom: 4px; color: #888898; }}
1183
+ .msg {{ margin-bottom: 20px; max-width: 800px; }}
1184
+ .msg .role {{ font-size: 0.78rem; font-weight: 600; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.05em; }}
1185
+ .msg .role.user {{ color: #6366f1; }}
1186
+ .msg .role.assistant {{ color: #22c55e; }}
1187
+ .msg .role.system {{ color: #eab300; }}
1188
+ .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; }}
1189
+ .msg .bubble.user {{ border-left: 3px solid #6366f1; }}
1190
+ .msg .bubble.assistant {{ border-left: 3px solid #22c55e; }}
1191
+ .empty {{ text-align: center; padding: 40px; color: #5a5a68; }}
1192
+ </style>
1193
+ </head>
1194
+ <body>
1195
+ <div class="sidebar">
1196
+ <div class="sidebar-header">
1197
+ <h2>⬡ {title}</h2>
1198
+ <div class="count">{parsed["total"]} conversations</div>
1199
+ </div>
1200
+ <div class="search">
1201
+ <input type="text" id="search" placeholder="Search conversations..." oninput="filterConvs()">
1202
+ </div>
1203
+ <div class="conv-list" id="conv-list"></div>
1204
+ </div>
1205
+ <div class="main" id="main">
1206
+ <div class="welcome">
1207
+ <div class="icon">💬</div>
1208
+ <h3>ChatGPT Archive dApp</h3>
1209
+ <p>Select a conversation to browse</p>
1210
+ </div>
1211
+ </div>
1212
+ <script>
1213
+ const CONVS = {convs_json};
1214
+ let activeId = null;
1215
+
1216
+ function renderConvList(filter) {{
1217
+ filter = (filter || "").toLowerCase();
1218
+ const list = document.getElementById("conv-list");
1219
+ const filtered = CONVS.filter(c => c.title.toLowerCase().includes(filter));
1220
+ list.innerHTML = filtered.map(c => `
1221
+ <div class="conv-item ${{c.id === activeId ? 'active' : ''}}" onclick="selectConv('${{c.id}}')">
1222
+ <div class="title">${{escHtml(c.title)}}</div>
1223
+ <div class="meta">${{c.message_count}} messages</div>
1224
+ </div>
1225
+ `).join('') || '<div class="empty">No matches</div>';
1226
+ }}
1227
+
1228
+ function selectConv(id) {{
1229
+ activeId = id;
1230
+ const conv = CONVS.find(c => c.id === id);
1231
+ if (!conv) return;
1232
+ const main = document.getElementById("main");
1233
+ 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>` +
1234
+ conv.messages.map(m => `
1235
+ <div class="msg">
1236
+ <div class="role ${{m.role}}">${{m.role}}</div>
1237
+ <div class="bubble ${{m.role}}">${{escHtml(m.text)}}</div>
1238
+ </div>
1239
+ `).join('');
1240
+ renderConvList(document.getElementById("search").value);
1241
+ main.scrollTop = 0;
1242
+ }}
1243
+
1244
+ function filterConvs() {{
1245
+ renderConvList(document.getElementById("search").value);
1246
+ }}
1247
+
1248
+ function escHtml(s) {{
1249
+ if (!s) return '';
1250
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1251
+ }}
1252
+
1253
+ renderConvList();
1254
+ </script>
1255
+ </body>
1256
+ </html>'''
1257
+
1258
+
1259
+ @app.post("/api/import/chatgpt")
1260
+ async def import_chatgpt(file: UploadFile = File(...)) -> JSONResponse:
1261
+ """Import a ChatGPT export (conversations.json) and deploy as a browsable dApp."""
1262
+ if not file.filename:
1263
+ return JSONResponse({"error": "No filename provided."}, status_code=400)
1264
+
1265
+ fname = file.filename.lower()
1266
+ if not (fname.endswith(".json") or fname.endswith(".zip")):
1267
+ return JSONResponse({"error": "Only .json or .zip ChatGPT exports are accepted."}, status_code=400)
1268
+
1269
+ app_id = str(uuid.uuid4())
1270
+ slug = _slugify(file.filename) or f"chatgpt-archive-{app_id[:8]}"
1271
+
1272
+ # Save the uploaded file
1273
+ if fname.endswith(".zip"):
1274
+ zip_path = UPLOAD_DIR / f"{app_id}.zip"
1275
+ with open(zip_path, "wb") as f:
1276
+ while True:
1277
+ chunk = await file.read(65536)
1278
+ if not chunk:
1279
+ break
1280
+ f.write(chunk)
1281
+ # Extract and find conversations.json
1282
+ extract_to = EXTRACT_DIR / app_id
1283
+ extract_to.mkdir(parents=True, exist_ok=True)
1284
+ try:
1285
+ with zipfile.ZipFile(zip_path, "r") as zf:
1286
+ zf.extractall(extract_to)
1287
+ except zipfile.BadZipFile:
1288
+ shutil.rmtree(extract_to)
1289
+ zip_path.unlink(missing_ok=True)
1290
+ return JSONResponse({"error": "Invalid ZIP file."}, status_code=400)
1291
+
1292
+ # Find conversations.json
1293
+ conv_file = None
1294
+ for f in extract_to.rglob("conversations.json"):
1295
+ conv_file = f
1296
+ break
1297
+ if not conv_file:
1298
+ shutil.rmtree(extract_to)
1299
+ return JSONResponse({"error": "No conversations.json found in ZIP export."}, status_code=400)
1300
+ raw_data = json.loads(conv_file.read_text(encoding="utf-8"))
1301
+ sha256 = _hash_file(zip_path)
1302
+ stored_size = zip_path.stat().st_size
1303
+ else:
1304
+ # Direct JSON upload
1305
+ json_path = UPLOAD_DIR / f"{app_id}.json"
1306
+ with open(json_path, "wb") as f:
1307
+ while True:
1308
+ chunk = await file.read(65536)
1309
+ if not chunk:
1310
+ break
1311
+ f.write(chunk)
1312
+ try:
1313
+ raw_data = json.loads(json_path.read_text(encoding="utf-8"))
1314
+ except json.JSONDecodeError:
1315
+ json_path.unlink(missing_ok=True)
1316
+ return JSONResponse({"error": "Invalid JSON file."}, status_code=400)
1317
+ sha256 = _hash_file(json_path)
1318
+ stored_size = json_path.stat().st_size
1319
+
1320
+ # Parse the ChatGPT export
1321
+ parsed = _parse_chatgpt_export(raw_data)
1322
+ if parsed["total"] == 0:
1323
+ return JSONResponse({"error": "No conversations found in export."}, status_code=400)
1324
+
1325
+ # Generate dApp HTML
1326
+ title = f"ChatGPT Archive ({parsed['total']} conversations)"
1327
+ dapp_html = _generate_dapp_html(parsed, title)
1328
+
1329
+ # Deploy as a site
1330
+ dapp_root = DAPPS_DIR / app_id
1331
+ dapp_root.mkdir(parents=True, exist_ok=True)
1332
+ (dapp_root / "index.html").write_text(dapp_html, encoding="utf-8")
1333
+
1334
+ entry = {
1335
+ "app_id": app_id,
1336
+ "slug": slug,
1337
+ "filename": file.filename,
1338
+ "file_type": "chatgpt",
1339
+ "size": stored_size,
1340
+ "size_human": _format_bytes(stored_size),
1341
+ "sha256": sha256,
1342
+ "dapp_url": f"/dapp/{app_id}/",
1343
+ "conversation_count": parsed["total"],
1344
+ "total_messages": sum(c["message_count"] for c in parsed["conversations"]),
1345
+ "has_preview": True,
1346
+ "preview_entry": "index.html",
1347
+ "opened": True,
1348
+ "created_at": time.time(),
1349
+ }
1350
+ _store[app_id] = entry
1351
+ _save_db()
1352
+
1353
+ return JSONResponse({
1354
+ "app": entry,
1355
+ "message": f"ChatGPT archive deployed as dApp. {parsed['total']} conversations, {entry['total_messages']} messages.",
1356
+ "dapp_url": entry["dapp_url"],
1357
+ }, status_code=201)
1358
+
1359
+
1360
+ @app.get("/dapp/{app_id}/", response_class=HTMLResponse)
1361
+ @app.get("/dapp/{app_id}", response_class=HTMLResponse)
1362
+ def serve_dapp(app_id: str) -> HTMLResponse:
1363
+ """Serve a deployed ChatGPT dApp."""
1364
+ entry = _store.get(app_id)
1365
+ if not entry or entry.get("file_type") != "chatgpt":
1366
+ return HTMLResponse("<h1>dApp not found</h1>", status_code=404)
1367
+
1368
+ dapp_root = DAPPS_DIR / app_id
1369
+ if not dapp_root.exists():
1370
+ return HTMLResponse("<h1>dApp files not found. Storage may have been cleared.</h1>", status_code=404)
1371
+
1372
+ index = dapp_root / "index.html"
1373
+ if not index.exists():
1374
+ return HTMLResponse("<h1>dApp index not found</h1>", status_code=404)
1375
+
1376
+ return HTMLResponse(content=index.read_text(encoding="utf-8"))
1377
+
1378
+
1379
+ @app.get("/dapp/{app_id}/{path:path}")
1380
+ def serve_dapp_file(app_id: str, path: str):
1381
+ """Serve any file from a deployed dApp."""
1382
+ entry = _store.get(app_id)
1383
+ if not entry or entry.get("file_type") != "chatgpt":
1384
+ return JSONResponse({"error": "dApp not found"}, status_code=404)
1385
+
1386
+ dapp_root = DAPPS_DIR / app_id
1387
+ if not dapp_root.exists():
1388
+ return JSONResponse({"error": "dApp storage cleared"}, status_code=404)
1389
+
1390
+ target = dapp_root / path
1391
+ try:
1392
+ target.resolve().relative_to(dapp_root.resolve())
1393
+ except ValueError:
1394
+ return JSONResponse({"error": "Access denied"}, status_code=403)
1395
+
1396
+ if not target.exists():
1397
+ return JSONResponse({"error": "File not found"}, status_code=404)
1398
+
1399
+ return FileResponse(path=target)
1400
+
1401
+
1402
+ @app.get("/api/storage/status")
1403
+ def storage_status() -> JSONResponse:
1404
+ """Check if persistent storage is attached."""
1405
+ persistent = str(DATA_DIR).startswith("/data")
1406
+ return JSONResponse({
1407
+ "persistent": persistent,
1408
+ "data_dir": str(DATA_DIR),
1409
+ "has_uploads": UPLOAD_DIR.exists(),
1410
+ "has_extracted": EXTRACT_DIR.exists(),
1411
+ "has_sites": SITES_DIR.exists(),
1412
+ "has_dapps": DAPPS_DIR.exists(),
1413
+ "inference_available": HAS_LLAMA,
1414
+ "artifact_count": len(_store),
1415
+ })
1416
+
1417
+
1418
  @app.delete("/api/apps/{app_id}")
1419
  def delete_app(app_id: str) -> JSONResponse:
1420
  entry = _store.pop(app_id, None)
1421
  if entry:
1422
+ # Clean up uploads (all possible extensions)
1423
+ for suffix in [".dmg", ".gguf", ".zip", ".json"]:
1424
+ f = UPLOAD_DIR / f"{app_id}{suffix}"
1425
+ if f.exists():
1426
+ f.unlink()
1427
+ # Clean up extracted files
1428
  extracted = EXTRACT_DIR / app_id
1429
  if extracted.exists():
1430
  shutil.rmtree(extracted)
1431
+ # Clean up deployed sites
1432
+ site = SITES_DIR / app_id
1433
+ if site.exists():
1434
+ shutil.rmtree(site)
1435
+ # Clean up dapps
1436
+ dapp = DAPPS_DIR / app_id
1437
+ if dapp.exists():
1438
+ shutil.rmtree(dapp)
1439
+ # Unload model from inference cache
1440
+ _llm_cache.pop(app_id, None)
1441
  _save_db()
1442
  return JSONResponse({"ok": True})