smolcode / engine /preview.py
seanpoyner's picture
Upload folder using huggingface_hub
daea45b verified
Raw
History Blame Contribute Delete
7.02 kB
"""Live-preview rendering for smolbuilder.
Turns the agent's workspace (a `path -> content` dict of a small static web app)
into a single self-contained HTML document, then into a sandboxed iframe that
Gradio can drop straight into a `gr.HTML`. This is the "Replit/Lovable" preview:
what the tiny model just built, running live in the browser.
Deliberately dependency-free (stdlib only) so it can be unit-tested without
Gradio or the Rust engine, and so the rendering logic stays trivially auditable.
Design choices:
- We inline locally-referenced `<link rel=stylesheet>` and `<script src=...>`
from sibling files, so a model that splits style.css / script.js out of
index.html still previews correctly — but we never touch absolute/CDN URLs.
- The iframe is loaded via `srcdoc=` (not a `data:` URI). A `data:` URL has an
*opaque origin*, where `localStorage`/`sessionStorage` throw `SecurityError` —
so any app that persists state (a notepad, a to-do list) dies on load before it
can wire up its buttons. A `srcdoc` frame inherits the embedder's (Gradio's)
origin, so storage and scripts work the way the model expects.
- SECURITY TRADE-OFF: `sandbox="allow-scripts allow-same-origin ..."` is required
for storage to work, but that combination also lets the framed (model-written)
code reach the parent page. This is acceptable for a *local, single-user*
builder — the framed code is the same user's own request, on a page holding no
one else's secrets. Do NOT reuse this wrapper to embed untrusted third-party
apps on an origin that holds other users' data; the isolation-preserving fix is
to serve the preview from a separate origin (out of scope here).
- The same wrapper (`PREVIEW_SANDBOX`/`_escape_srcdoc`) is reused by the headless
verification check (engine/browsercheck.py) so the agent tests *exactly* what
the user sees.
"""
from __future__ import annotations
import html
import re
# Sandbox flags shared by the live preview and the verification check.
# allow-same-origin is required so srcdoc inherits the parent origin and web
# storage works; combined with allow-scripts it weakens isolation (see docstring).
PREVIEW_SANDBOX = "allow-scripts allow-same-origin allow-modals allow-popups allow-forms"
# Files we know how to treat as the app entrypoint, best first.
_ENTRY_CANDIDATES = ("index.html", "main.html", "app.html")
_LINK_RE = re.compile(
r"""<link\b[^>]*?\brel\s*=\s*['"]?stylesheet['"]?[^>]*?>""", re.I | re.S)
_SCRIPT_SRC_RE = re.compile(
r"""<script\b[^>]*?\bsrc\s*=\s*['"]([^'"]+)['"][^>]*?>\s*</script>""", re.I | re.S)
_HREF_RE = re.compile(r"""\bhref\s*=\s*['"]([^'"]+)['"]""", re.I)
def find_entry(files: dict[str, str]) -> str | None:
"""Pick the HTML entrypoint to preview, or None if there's nothing webby."""
lower = {p.lower(): p for p in files}
for cand in _ENTRY_CANDIDATES:
if cand in lower:
return lower[cand]
# Fall back to any .html file (shallowest path wins for determinism).
htmls = sorted((p for p in files if p.lower().endswith(".html")),
key=lambda p: (p.count("/"), p))
return htmls[0] if htmls else None
def _is_local(url: str) -> bool:
"""True for a same-app relative reference we can inline (not a CDN/data URI)."""
u = url.strip()
if not u:
return False
return not re.match(r"^(?:[a-z]+:)?//|^https?:|^data:|^mailto:|^#", u, re.I)
def _lookup(files: dict[str, str], ref: str) -> str | None:
"""Resolve a relative href/src against the workspace file map."""
ref = ref.split("?", 1)[0].split("#", 1)[0].lstrip("./").lstrip("/")
if ref in files:
return files[ref]
# Case-insensitive / basename fallback so '/style.css' finds 'style.css'.
base = ref.rsplit("/", 1)[-1].lower()
for path, content in files.items():
if path.lower() == ref.lower() or path.rsplit("/", 1)[-1].lower() == base:
return content
return None
def inline_app(files: dict[str, str]) -> str:
"""Return one self-contained HTML document for the app in `files`.
If there's no HTML entrypoint, render a friendly placeholder (e.g. the model
has only written notes or a not-yet-web file).
"""
entry = find_entry(files)
if entry is None:
return _placeholder(files)
doc = files[entry]
def _inline_css(match: re.Match) -> str:
tag = match.group(0)
href_m = _HREF_RE.search(tag)
if not href_m or not _is_local(href_m.group(1)):
return tag
css = _lookup(files, href_m.group(1))
if css is None:
return tag
return f"<style>\n{css}\n</style>"
def _inline_js(match: re.Match) -> str:
src = match.group(1)
if not _is_local(src):
return match.group(0)
js = _lookup(files, src)
if js is None:
return match.group(0)
# Guard against the inlined body prematurely closing the script element.
safe = js.replace("</script>", "<\\/script>")
return f"<script>\n{safe}\n</script>"
doc = _LINK_RE.sub(_inline_css, doc)
doc = _SCRIPT_SRC_RE.sub(_inline_js, doc)
return doc
def _escape_srcdoc(doc: str) -> str:
"""Escape an HTML document for a double-quoted `srcdoc="..."` attribute.
Only `&` and `"` are significant inside a double-quoted attribute value, and
`&` must go first (so the `&` we introduce for `"` isn't re-escaped). `<`,
`>` and even a literal `</script>` are FINE here — the parser is in
attribute-value state, not script-data state — so we must NOT touch them
(html.escape would corrupt the rendered document).
"""
return doc.replace("&", "&amp;").replace('"', "&quot;")
def preview_iframe(files: dict[str, str], *, height: int = 540) -> str:
"""Render the app as a sandboxed `srcdoc` iframe ready for `gr.HTML`."""
srcdoc = _escape_srcdoc(inline_app(files))
return (
f'<iframe title="smolbuilder preview" '
f'style="width:100%;height:{height}px;border:0;border-radius:12px;'
f'background:#fff;box-shadow:0 1px 0 rgba(0,0,0,.06)" '
f'sandbox="{PREVIEW_SANDBOX}" '
f'srcdoc="{srcdoc}"></iframe>'
)
def _placeholder(files: dict[str, str]) -> str:
listing = "".join(
f"<li><code>{html.escape(p)}</code></li>" for p in sorted(files)
) or "<li><em>workspace is empty</em></li>"
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<style>body{font:15px/1.5 system-ui,sans-serif;color:#475569;"
"background:#f8fafc;padding:2rem}h2{color:#7c3aed;margin:.2rem 0 1rem}"
"code{background:#ede9fe;color:#5b21b6;padding:1px 6px;border-radius:6px}"
"</style></head><body>"
"<h2>No preview yet</h2>"
"<p>smolbuilder previews the app's <code>index.html</code>. "
"Describe a web app on the left and it'll appear here, live.</p>"
f"<p>Files in the workspace:</p><ul>{listing}</ul>"
"</body></html>"
)