- Remove the keyword-rules agent from typed chat: fallback_agent, parse_look_command (+ tables), the copy-edit parser and _rules_check are gone. If every model fails: 'The assistant is busy, please try again in a moment.' with a Retry button (agent 'unavailable', no actions). Clicks, forms and payments stay deterministic (fallback_event); the landing view is a deterministic featured-listings panel; the Sell button opens the empty form. - Local model via llama.cpp llama-server directly (ops/local-llm-watchdog.sh, loopback only, restart on exit/hang, crash backoff, log rotation; deploy.sh ensures it runs). Lean: no vision projector, no weight repack (mmapped), ctx 6144, 1 slot, q8 KV, 6 threads at nice 10, poll 0, 1 GiB prompt cache. Idle 0% CPU, 2.9 GB RSS (0.6 GB private). - Local requests use a JSON GBNF grammar, worked examples in the cached system prompt, and a labelled visitor message. 10/10 correct on the real prompt, warm 4-13 s. - Primer re-primes after a model-server restart (llama-server /metrics counter) and otherwise stays idle. - Fix: hosted calls kept the Authorization header off when a local model was configured (cfg['local'] is a dict on hosted configs); now only the local server itself skips the key. Test added. - Badge/chat header: 'AI: busy', 'AI: quota used up', 'AI: unavailable'; no 'built-in'/'checked by rules' texts. - Tests: proxy checks expect the busy reply (never rules), clicks still work while busy, no-model server, local grammar/ prompt/no key; browser checks for busy + Retry; e2e_http accepts busy for typed turns unless REQUIRE_LLM=1; test_copyedit.py replaced by test_copy_defaults.py; README updated.
287 lines
15 KiB
Python
287 lines
15 KiB
Python
"""Per-visitor look & feel: a validated JSON theme spec (design tokens) the agent can patch.
|
|
|
|
The server never stores themes; it only validates what the model proposes. The browser keeps the
|
|
theme in localStorage, so one visitor's restyle never affects anyone else. No raw HTML/JS is ever
|
|
accepted; the optional customCss escape hatch is sanitised and scoped (see sanitize_css)."""
|
|
import re
|
|
|
|
COLOR_RE = re.compile(r"^(#[0-9a-fA-F]{3,4}|#[0-9a-fA-F]{6}|#[0-9a-fA-F]{8}|"
|
|
r"rgba?\(\s*[\d.]+%?\s*,\s*[\d.]+%?\s*,\s*[\d.]+%?\s*(,\s*[\d.]+%?\s*)?\)|"
|
|
r"hsla?\(\s*[\d.]+(deg)?\s*,\s*[\d.]+%\s*,\s*[\d.]+%\s*(,\s*[\d.]+%?\s*)?\)|"
|
|
r"[a-zA-Z]{3,24})$")
|
|
FONTS = { # name -> (css stack, google family or None)
|
|
"system": ("system-ui,-apple-system,Segoe UI,Roboto,sans-serif", None),
|
|
"serif": ("Georgia,'Times New Roman',serif", None),
|
|
"mono": ("ui-monospace,Menlo,Consolas,monospace", None),
|
|
"Inter": ("'Inter',system-ui,sans-serif", "Inter:wght@400;600;700"),
|
|
"Poppins": ("'Poppins',system-ui,sans-serif", "Poppins:wght@400;600;700"),
|
|
"Montserrat": ("'Montserrat',system-ui,sans-serif", "Montserrat:wght@400;600;700"),
|
|
"Nunito": ("'Nunito',system-ui,sans-serif", "Nunito:wght@400;700"),
|
|
"Space Grotesk": ("'Space Grotesk',system-ui,sans-serif", "Space+Grotesk:wght@400;700"),
|
|
"Lora": ("'Lora',Georgia,serif", "Lora:wght@400;700"),
|
|
"Merriweather": ("'Merriweather',Georgia,serif", "Merriweather:wght@400;700"),
|
|
"Playfair Display": ("'Playfair Display',Georgia,serif", "Playfair+Display:wght@400;700"),
|
|
"Roboto Slab": ("'Roboto Slab',Georgia,serif", "Roboto+Slab:wght@400;700"),
|
|
"Fira Code": ("'Fira Code',ui-monospace,monospace", "Fira+Code:wght@400;600"),
|
|
"Space Mono": ("'Space Mono',ui-monospace,monospace", "Space+Mono:wght@400;700"),
|
|
"VT323": ("'VT323',ui-monospace,monospace", "VT323"),
|
|
"Press Start 2P": ("'Press Start 2P',ui-monospace,monospace", "Press+Start+2P"),
|
|
"Bebas Neue": ("'Bebas Neue',Impact,sans-serif", "Bebas+Neue"),
|
|
"Pacifico": ("'Pacifico',cursive", "Pacifico"),
|
|
"Comic Neue": ("'Comic Neue','Comic Sans MS',cursive", "Comic+Neue:wght@400;700"),
|
|
}
|
|
_FONT_ALIASES = {"sans": "system", "sans-serif": "system", "default": "system", "georgia": "serif", "times": "serif",
|
|
"monospace": "mono", "courier": "mono", "pixel": "Press Start 2P", "retro": "VT323", "script": "Pacifico",
|
|
"comic": "Comic Neue", "comic sans": "Comic Neue", "playfair": "Playfair Display"}
|
|
PATTERNS = ["dots", "grid", "stripes", "diagonal", "checks", "waves"]
|
|
IMAGE_HOSTS = {"images.unsplash.com", "picsum.photos", "upload.wikimedia.org"}
|
|
SECTIONS = ["hero", "chips", "bullets", "results", "footer"]
|
|
PALETTE_KEYS = ["bg", "surface", "card", "text", "muted", "accent", "border"]
|
|
|
|
PRESETS = {
|
|
"retro": {"mode": "light", "background": {"kind": "pattern", "pattern": "checks", "colors": ["#f4e3c1", "#ead2a6"]},
|
|
"palette": {"surface": "#fff6e3", "card": "#fffaf0", "text": "#3a2a1a", "muted": "#7a5c3e", "accent": "#d9480f", "border": "#3a2a1a"},
|
|
"font": "VT323", "headingFont": "Press Start 2P", "fontScale": 1.15, "radius": 0, "cardStyle": "outlined", "density": "normal"},
|
|
"minimal": {"mode": "light", "background": {"kind": "solid", "colors": ["#ffffff"]},
|
|
"palette": {"surface": "#ffffff", "card": "#ffffff", "text": "#111111", "muted": "#666666", "accent": "#111111", "border": "#e6e6e6"},
|
|
"font": "Inter", "headingFont": "Inter", "radius": 4, "cardStyle": "flat", "density": "comfy", "animations": False},
|
|
"neon": {"mode": "dark", "background": {"kind": "gradient", "colors": ["#05010f", "#1a0033", "#00111f"], "angle": 160},
|
|
"palette": {"surface": "#0d0620", "card": "#130a2b", "text": "#f5f0ff", "muted": "#b9a6ff", "accent": "#39ff14", "border": "#ff00e6"},
|
|
"font": "Space Grotesk", "headingFont": "Bebas Neue", "radius": 12, "cardStyle": "glass",
|
|
"customCss": ".card{box-shadow:0 0 12px #ff00e6aa} h1{text-shadow:0 0 18px #39ff14}"},
|
|
"pastel": {"mode": "light", "background": {"kind": "gradient", "colors": ["#fde2e4", "#e2ece9", "#dfe7fd"], "angle": 135},
|
|
"palette": {"surface": "#fffafc", "card": "#ffffff", "text": "#3d3a4b", "muted": "#7d7a8c", "accent": "#b388eb", "border": "#ecdff5"},
|
|
"font": "Nunito", "headingFont": "Nunito", "radius": 22, "cardStyle": "elevated", "density": "comfy"},
|
|
"high contrast": {"mode": "dark", "background": {"kind": "solid", "colors": ["#000000"]},
|
|
"palette": {"surface": "#000000", "card": "#000000", "text": "#ffffff", "muted": "#ffffff", "accent": "#ffff00", "border": "#ffffff"},
|
|
"font": "system", "fontScale": 1.2, "radius": 2, "cardStyle": "outlined", "animations": False},
|
|
"sunset": {"mode": "dark", "background": {"kind": "gradient", "colors": ["#ff7e5f", "#feb47b", "#6a3093"], "angle": 180},
|
|
"palette": {"surface": "#2b1531", "card": "#3a1d40", "text": "#fff4ec", "muted": "#ffd2b8", "accent": "#ff9966", "border": "#6a3093"},
|
|
"radius": 16, "cardStyle": "glass"},
|
|
"ocean": {"mode": "dark", "background": {"kind": "gradient", "colors": ["#0f2027", "#203a43", "#2c5364"], "angle": 180},
|
|
"palette": {"surface": "#10262f", "card": "#163542", "text": "#e6f6ff", "muted": "#9cc7d9", "accent": "#4fd1c5", "border": "#2c5364"},
|
|
"font": "Montserrat", "radius": 14},
|
|
}
|
|
PRESET_ALIASES = {"high-contrast": "high contrast", "contrast": "high contrast", "accessible": "high contrast",
|
|
"80s": "retro", "vintage": "retro", "clean": "minimal", "cyberpunk": "neon", "soft": "pastel"}
|
|
|
|
|
|
def _color(v):
|
|
return v.strip() if isinstance(v, str) and COLOR_RE.match(v.strip()) else None
|
|
|
|
|
|
def _num(v, lo, hi, cast=float):
|
|
try:
|
|
x = cast(v)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return max(lo, min(hi, x))
|
|
|
|
|
|
def _text(v, n):
|
|
if not isinstance(v, str):
|
|
return None
|
|
v = re.sub(r"[<>\x00-\x1f]", "", v).strip()
|
|
return v[:n] if v else None
|
|
|
|
|
|
def _font(v):
|
|
if not isinstance(v, str):
|
|
return None
|
|
k = v.strip()
|
|
for name in FONTS:
|
|
if name.lower() == k.lower():
|
|
return name
|
|
return _FONT_ALIASES.get(k.lower())
|
|
|
|
|
|
CSS_PROPS = { # allow-list for the customCss escape hatch (no positioning, transforms, content or variables)
|
|
"color", "background", "background-color", "background-image", "background-size", "background-position",
|
|
"background-repeat", "background-attachment", "border", "border-color", "border-width", "border-style",
|
|
"border-top", "border-bottom", "border-left", "border-right", "border-radius", "box-shadow", "outline",
|
|
"outline-color", "font", "font-family", "font-size", "font-weight", "font-style", "letter-spacing", "line-height",
|
|
"text-align", "text-transform", "text-decoration", "text-shadow", "padding", "padding-top", "padding-bottom",
|
|
"padding-left", "padding-right", "margin", "margin-top", "margin-bottom", "margin-left", "margin-right", "gap",
|
|
"opacity", "filter", "backdrop-filter", "width", "max-width", "min-width", "min-height", "display",
|
|
"flex-direction", "flex-wrap", "justify-content", "align-items", "grid-template-columns", "cursor",
|
|
"transition", "accent-color", "caret-color", "word-spacing", "font-variant",
|
|
}
|
|
|
|
|
|
def _url_ok(m):
|
|
host = re.match(r"https://([^/'\")\s]+)", m.group(1).strip("'\" "))
|
|
return m.group(0) if host and host.group(1).lower() in IMAGE_HOSTS else "none"
|
|
|
|
|
|
def sanitize_css(css):
|
|
"""Return sanitised CSS rules (selectors unscoped; the browser prefixes them with #page)."""
|
|
if not isinstance(css, str):
|
|
return ""
|
|
css = css[:3000]
|
|
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
|
if re.search(r"<|>\s*<|\\|@|expression\s*\(|javascript:|behavior\s*:|-moz-binding|vbscript:", css, re.I):
|
|
css = re.sub(r"<[^>]*>?|\\|@[^;{}]*(;|\{[^}]*\})?|expression\s*\([^)]*\)|javascript:|vbscript:|behavior\s*:[^;}]*|-moz-binding[^;}]*", "", css, flags=re.I)
|
|
out = []
|
|
for sel, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css):
|
|
sel = sel.strip()
|
|
if not sel or len(sel) > 200 or re.search(r"[<>@\\]|^\s*(html|body|:root)\b", sel, re.I):
|
|
continue
|
|
decls = []
|
|
for d in body.split(";"):
|
|
if ":" not in d:
|
|
continue
|
|
prop, val = d.split(":", 1)
|
|
prop, val = prop.strip().lower(), val.strip()
|
|
if prop not in CSS_PROPS or not val or len(val) > 300:
|
|
continue
|
|
val = re.sub(r"!\s*important", "", val, flags=re.I).strip()
|
|
val = re.sub(r"url\(([^)]*)\)", _url_ok, val, flags=re.I)
|
|
if re.search(r"var\(|attr\(|expression|javascript", val, re.I) or val.count("(") != val.count(")"):
|
|
continue
|
|
if prop.startswith("margin") and "-" in val:
|
|
continue # no negative margins (could overlap protected panels)
|
|
decls.append(f"{prop}:{val}")
|
|
if decls:
|
|
out.append(f"{sel}{{{';'.join(decls)}}}")
|
|
return "\n".join(out)[:3000]
|
|
|
|
|
|
def sanitize_patch(p, depth=0):
|
|
"""Validate a theme patch from the model. Unknown keys/values are dropped silently."""
|
|
if not isinstance(p, dict):
|
|
return {}
|
|
out = {}
|
|
if p.get("mode") in ("light", "dark"):
|
|
out["mode"] = p["mode"]
|
|
bg = p.get("background")
|
|
if isinstance(bg, str):
|
|
bg = {"kind": "solid", "colors": [bg]}
|
|
if isinstance(bg, dict):
|
|
b = {}
|
|
kind = bg.get("kind") or bg.get("type")
|
|
if kind in ("solid", "gradient", "pattern", "image"):
|
|
b["kind"] = kind
|
|
cols = bg.get("colors") or ([bg["color"]] if bg.get("color") else [])
|
|
cols = [c for c in (_color(c) for c in (cols if isinstance(cols, list) else [])) if c][:5]
|
|
if cols:
|
|
b["colors"] = cols
|
|
a = _num(bg.get("angle"), 0, 360, int)
|
|
if a is not None:
|
|
b["angle"] = a
|
|
if bg.get("pattern") in PATTERNS:
|
|
b["pattern"] = bg["pattern"]
|
|
url = bg.get("url") or bg.get("image")
|
|
if isinstance(url, str):
|
|
m = re.match(r"^https://([^/\s'\"()]+)/[^\s'\"()<>]*$", url.strip())
|
|
if m and m.group(1).lower() in IMAGE_HOSTS:
|
|
b["url"] = url.strip()
|
|
if b.get("kind") == "image" and "url" not in b:
|
|
b.pop("kind")
|
|
if b:
|
|
out["background"] = b
|
|
pal = p.get("palette")
|
|
if isinstance(pal, dict):
|
|
aliases = {"background": "bg", "panel": "surface", "ink": "text", "foreground": "text", "mute": "muted", "line": "border"}
|
|
clean = {}
|
|
for k, v in pal.items():
|
|
k = aliases.get(k, k)
|
|
if k in PALETTE_KEYS and _color(v):
|
|
clean[k] = _color(v)
|
|
if clean:
|
|
out["palette"] = clean
|
|
for k in ("font", "headingFont"):
|
|
f = _font(p.get(k))
|
|
if f:
|
|
out[k] = f
|
|
for k, lo, hi, cast in (("fontScale", 0.75, 1.75, float), ("radius", 0, 32, int), ("columns", 0, 6, int)):
|
|
v = _num(p.get(k), lo, hi, cast)
|
|
if v is not None and p.get(k) is not None:
|
|
out[k] = round(v, 2) if cast is float else v
|
|
for k, allowed in (("density", ("compact", "normal", "comfy")), ("cardStyle", ("flat", "outlined", "elevated", "glass")),
|
|
("layout", ("grid", "list"))):
|
|
if p.get(k) in allowed:
|
|
out[k] = p[k]
|
|
if isinstance(p.get("animations"), bool):
|
|
out["animations"] = p["animations"]
|
|
copy = p.get("copy")
|
|
if isinstance(copy, dict):
|
|
c = {}
|
|
for k, n in (("logo", 40), ("headline", 120), ("subhead", 240), ("footer", 160)):
|
|
t = _text(copy.get(k), n)
|
|
if t:
|
|
c[k] = t
|
|
if isinstance(copy.get("chips"), list):
|
|
chips = []
|
|
for ch in copy["chips"][:6]:
|
|
if isinstance(ch, str):
|
|
ch = {"label": ch, "say": ch}
|
|
if isinstance(ch, dict) and _text(ch.get("label"), 40):
|
|
if ch.get("event") in ("sell",):
|
|
chips.append({"label": _text(ch["label"], 40), "event": ch["event"]})
|
|
else:
|
|
chips.append({"label": _text(ch["label"], 40), "say": _text(ch.get("say") or ch["label"], 120)})
|
|
c["chips"] = chips
|
|
if isinstance(copy.get("bullets"), list):
|
|
c["bullets"] = [t for t in (_text(b, 140) for b in copy["bullets"][:6]) if t]
|
|
if c:
|
|
out["copy"] = c
|
|
sec = p.get("sections")
|
|
if isinstance(sec, dict):
|
|
s = {}
|
|
if isinstance(sec.get("order"), list):
|
|
order = [x for x in sec["order"] if x in SECTIONS]
|
|
s["order"] = list(dict.fromkeys(order + [x for x in SECTIONS if x not in order]))
|
|
if isinstance(sec.get("hidden"), list):
|
|
s["hidden"] = [x for x in dict.fromkeys(sec["hidden"]) if x in SECTIONS]
|
|
if s:
|
|
out["sections"] = s
|
|
chat = p.get("chat")
|
|
if isinstance(chat, dict):
|
|
c = {}
|
|
if chat.get("position") in ("left", "right"):
|
|
c["position"] = chat["position"]
|
|
for k, lo, hi in (("width", 280, 600), ("height", 320, 820)):
|
|
v = _num(chat.get(k), lo, hi, int)
|
|
if v is not None:
|
|
c[k] = v
|
|
for k in ("bg", "accent", "text"):
|
|
if _color(chat.get(k)):
|
|
c[k] = _color(chat[k])
|
|
if c:
|
|
out["chat"] = c
|
|
if p.get("customCss"):
|
|
css = sanitize_css(p["customCss"])
|
|
if css:
|
|
out["customCss"] = css
|
|
return out
|
|
|
|
|
|
def build_theme_action(payload):
|
|
"""Turn a model's set_theme payload into a client action: {reset?, base?, patch}."""
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
name = str(payload.get("preset") or "").strip().lower()
|
|
if payload.get("reset") and not name and not [k for k in payload if k not in ("reset", "patch")]:
|
|
return {"reset": True, "patch": sanitize_patch(payload.get("patch") or {})}
|
|
# {"preset": "sunset", "reset": true} means "start over from that preset" (a preset base already replaces the look)
|
|
name = PRESET_ALIASES.get(name, name)
|
|
patch = payload.get("patch") if isinstance(payload.get("patch"), dict) else {k: v for k, v in payload.items() if k not in ("preset", "reset")}
|
|
out = {"patch": sanitize_patch(patch)}
|
|
if payload.get("reset"):
|
|
out["reset"] = True
|
|
if name in PRESETS:
|
|
out["preset"] = name
|
|
out["base"] = sanitize_patch(PRESETS[name])
|
|
if not out["patch"] and "base" not in out:
|
|
return None
|
|
return out
|
|
|
|
|
|
def compact_current(theme):
|
|
"""Short current-look summary for the prompt (keeps free-tier calls lean)."""
|
|
import json
|
|
t = sanitize_patch(theme) if isinstance(theme, dict) else {}
|
|
t.pop("customCss", None)
|
|
s = json.dumps(t, separators=(",", ":"))
|
|
return s if len(s) <= 900 else s[:900]
|