From 2e10619b95b130f2fec447ca33c7174cab15ead4 Mon Sep 17 00:00:00 2001 From: leora Date: Thu, 8 Oct 2026 19:37:27 +0800 Subject: [PATCH 1/6] Agent controls the whole look: validated theme spec (tokens, presets, layout, copy, sections, chat), sanitised scoped customCss, protected banner/badge/fee panels with guaranteed contrast, per-browser theme, no ids in replies --- market/agent.py | 81 +++++++++++-- market/theme.py | 280 +++++++++++++++++++++++++++++++++++++++++++ server.py | 9 +- static/app.js | 18 ++- static/index.html | 39 +++--- static/styles.css | 111 ++++++++++++----- static/theme.js | 163 +++++++++++++++++++++++++ tests/test_theme.py | 44 +++++++ tests/theme_check.js | 126 +++++++++++++++++++ 9 files changed, 802 insertions(+), 69 deletions(-) create mode 100644 market/theme.py create mode 100644 static/theme.js create mode 100644 tests/test_theme.py create mode 100644 tests/theme_check.js diff --git a/market/agent.py b/market/agent.py index 465da6d..b1c9085 100644 --- a/market/agent.py +++ b/market/agent.py @@ -30,7 +30,34 @@ Allowed actions (use only these types; listing ids must come from CATALOG below, - {"type":"show_hero","payload":{"title":"...","subtitle":"...","bullets":["..."]}} welcome / help - {"type":"toast","payload":{"level":"info|ok|warn","text":"..."}} +- {"type":"set_theme","payload":{...}} change how the page LOOKS (see LOOK & LAYOUT). Applies instantly, only for this visitor. + +LOOK & LAYOUT: you control the entire look of the page. Never say you can't change something visual; use set_theme. +set_theme payload = any subset of this spec (it is merged into the current look): + {"preset":"retro|minimal|neon|pastel|high contrast|sunset|ocean", // starts from that preset, other keys override + "reset":true, // back to the default look + "mode":"dark|light", + "background":{"kind":"solid|gradient|pattern|image","colors":["#hex",...],"angle":0-360, + "pattern":"dots|grid|stripes|diagonal|checks|waves","url":"https://images.unsplash.com/... or picsum.photos"}, + "palette":{"bg","surface","card","text","muted","accent","border"}, // CSS colours (#hex, rgb(), names) + "font":"FONT","headingFont":"FONT","fontScale":0.75-1.75 (1=default), + "radius":0-32 (0=sharp corners, 24+=pill-ish),"density":"compact|normal|comfy", + "cardStyle":"flat|outlined|elevated|glass","layout":"grid|list","columns":0-6 (0=auto), + "copy":{"logo","headline","subhead","footer","chips":[{"label","say"}],"bullets":["..."]}, + "sections":{"order":["hero","chips","bullets","results","footer"],"hidden":["bullets",...]}, + "chat":{"position":"left|right","width":280-600,"height":320-820,"bg","accent","text"}, + "animations":true|false, + "customCss":""} + FONT = system | serif | mono | Inter | Poppins | Montserrat | Nunito | Space Grotesk | Lora | Merriweather | + Playfair Display | Roboto Slab | Fira Code | Space Mono | VT323 | Press Start 2P | Bebas Neue | Pacifico | Comic Neue + Relative requests use CURRENT_LOOK (e.g. "bigger text" => raise fontScale from its current value, default 1). + Light mode usually needs only {"mode":"light"} plus any colours asked for; "green accent" => palette.accent. + Out of bounds (say so briefly and offer the closest option): the TEST DEMO banner, the agent badge, checkout fee + lines/amounts and the payment form are fixed for safety; no scripts or HTML; images only from the hosts above. + For a purely visual request return ONLY set_theme (do not re-show listings), and confirm what changed in "reply". + Rules: +- Never put internal ids (lst_..., pi_..., cash_..., ord_...) in "reply"; refer to items by their title. - Interpret needs, not just keywords: e.g. "get my kid to school" => a kids' bike; "garden" => lawn/yard services. - Respect budgets (prices in CATALOG are integer cents: 5500 = $55.00). - If nothing fits, say so and show the closest options with show_cards. @@ -42,7 +69,8 @@ Rules: ALLOWED_ACTIONS = {"show_hero", "show_cards", "show_listing_form", "show_compare", - "show_checkout", "show_cash_meetup", "show_receipt", "clear_panel", "toast"} + "show_checkout", "show_cash_meetup", "show_receipt", "clear_panel", "toast", "set_theme"} +ID_RE = re.compile(r"\s*\(?\b(?:lst|pi|cash|ord|evt)_[0-9a-f]{6,}\b\)?") def money(cents: int) -> str: @@ -246,12 +274,45 @@ def fallback_event(store, ev: dict, actor: str): return {"reply": "I didn't recognise that action.", "actions": []} +def fallback_theme(low): + """Basic look commands for when the LLM is unavailable.""" + from .theme import PRESETS, PRESET_ALIASES + if re.search(r"\breset\b.*\b(look|theme|style|design|page)\b|\bdefault (look|theme)\b", low): + return {"reply": "Done: the page is back to its default look.", "actions": [{"type": "set_theme", "payload": {"reset": True}}]} + for name in list(PRESETS) + list(PRESET_ALIASES): + if re.search(r"\b" + re.escape(name) + r"\b", low) and re.search(r"look|theme|style|make it|switch|mode|preset|vibe", low): + real = PRESET_ALIASES.get(name, name) + return {"reply": f"Applied the {real} look.", "actions": [{"type": "set_theme", "payload": {"preset": real}}]} + patch, said = {}, [] + if re.search(r"\blight (mode|theme)\b", low): + patch["mode"] = "light"; said.append("light mode") + elif re.search(r"\bdark (mode|theme)\b", low): + patch["mode"] = "dark"; said.append("dark mode") + if re.search(r"as a list|list (view|layout)|not cards", low): + patch["layout"] = "list"; said.append("list layout") + elif re.search(r"grid (view|layout)|as cards", low): + patch["layout"] = "grid"; said.append("grid layout") + if re.search(r"chat.*\bleft\b", low): + patch["chat"] = {"position": "left"}; said.append("chat on the left") + elif re.search(r"chat.*\bright\b", low): + patch["chat"] = {"position": "right"}; said.append("chat on the right") + if re.search(r"(no|stop|turn off|disable) animation", low): + patch["animations"] = False; said.append("animations off") + if not patch: + return None + return {"reply": "Done: " + ", ".join(said) + ".", "actions": [{"type": "set_theme", "payload": patch}]} + + def fallback_agent(store, message: str, history, actor: str = "demo-buyer"): m = (message or "").strip() low = m.lower() actions = [] reply = "" + th = fallback_theme(low) + if th: + return th + # welcome / help if not m or low in ("hi", "hello", "hey", "help", "start"): reply = ("Welcome to Agent Market (TEST DEMO). I drive the page: ask me to search, " @@ -437,7 +498,7 @@ def _llm_config(): def _call_llm(cfg, model, messages, json_mode=True): - payload = {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 900} + payload = {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 1100} if json_mode: payload["response_format"] = {"type": "json_object"} req = urllib.request.Request(cfg["url"], data=json.dumps(payload).encode(), method="POST", headers={ @@ -492,8 +553,9 @@ def _catalog(store): for L in store.all("listings") if L.get("status") == "active"][:60] -def _llm_once(store, cfg, model, message, history): - sys_prompt = UI_SCHEMA + "\nCATALOG (all active listings):\n" + json.dumps(_catalog(store))[:9000] +def _llm_once(store, cfg, model, message, history, look=""): + sys_prompt = (UI_SCHEMA + "\nCATALOG (all active listings):\n" + json.dumps(_catalog(store))[:9000] + + "\nCURRENT_LOOK (this visitor's theme overrides; {} = default dark look):\n" + (look or "{}")) msgs = [{"role": "system", "content": sys_prompt}] for h in (history or [])[-6:]: if h.get("role") in ("user", "assistant") and h.get("content"): @@ -513,10 +575,11 @@ def _llm_once(store, cfg, model, message, history): reply = str(plan.get("reply") or "").strip() if not reply and not actions: raise LLMError("empty_plan", "") - return {"reply": (reply or "Here you go.")[:600], "actions": actions, "tools": tool_results} + reply = ID_RE.sub("", reply).strip() + return {"reply": (reply or "Done.")[:600], "actions": actions, "tools": tool_results} -def llm_agent(store, message, history, cfg): +def llm_agent(store, message, history, cfg, look=""): """Try each model in the chain; skip models cooling down after a 429. Raises if all fail.""" errors = [] now_t = time.time() @@ -529,7 +592,7 @@ def llm_agent(store, message, history, cfg): errors.append({"model": model, "error": "skipped_budget", "ms": 0}) continue try: - fut = _pool.submit(_llm_once, store, cfg, model, message, history) + fut = _pool.submit(_llm_once, store, cfg, model, message, history, look) try: out = fut.result(timeout=min(LLM_TIMEOUT, remaining)) except concurrent.futures.TimeoutError: @@ -554,7 +617,7 @@ def llm_agent(store, message, history, cfg): LLM_EVENTS = os.environ.get("AGENT_MARKET_LLM_EVENTS") == "1" -def handle_chat(store, message: str, history=None, event=None, actor="demo-buyer"): +def handle_chat(store, message: str, history=None, event=None, actor="demo-buyer", look=""): actor = (actor or "demo-buyer")[:60] t0 = time.time() if event and (event.get("type") == "landing" or not LLM_EVENTS): @@ -568,7 +631,7 @@ def handle_chat(store, message: str, history=None, event=None, actor="demo-buyer if cfg: text = message if not event else "UI_EVENT " + json.dumps(event)[:2000] + f"\n(actor/buyer id: {actor})" try: - return llm_agent(store, text, history or [], cfg) + return llm_agent(store, text, history or [], cfg, look) except Exception as e: out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor) out.update(agent="fallback", llm_error=getattr(e, "kind", type(e).__name__), diff --git a/market/theme.py b/market/theme.py new file mode 100644 index 0000000..df3c07d --- /dev/null +++ b/market/theme.py @@ -0,0 +1,280 @@ +"""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): + 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 + if payload.get("reset"): + return {"reset": True, "patch": sanitize_patch(payload.get("patch") or {})} + name = str(payload.get("preset") or "").strip().lower() + 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 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] diff --git a/server.py b/server.py index d9b8b40..b6510e1 100755 --- a/server.py +++ b/server.py @@ -8,6 +8,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from market.store import Store, now, new_id from market import agent as A from market import payments +from market import theme as TH HOST = os.environ.get("AGENT_MARKET_HOST", "127.0.0.1") PORT = int(os.environ.get("AGENT_MARKET_PORT", "8796")) @@ -144,6 +145,11 @@ def hydrate(actions, actor): continue deal = r["deal"] p.update(deal=deal, listing=store.get("listings", deal["listing_id"]), deal_id=deal["id"]) + elif t == "set_theme": + act = TH.build_theme_action(p) + if not act: + continue + p = act elif t == "show_receipt": o = store.get("orders", p.get("order_id") or "") if not o: @@ -322,7 +328,8 @@ class H(BaseHTTPRequestHandler): msg = str(body.get("message") or "")[:2000] ev = body.get("event") if isinstance(body.get("event"), dict) else None hist = body.get("history") if isinstance(body.get("history"), list) else [] - out = A.handle_chat(store, msg, hist, ev, actor) + look = TH.compact_current(body.get("theme")) if isinstance(body.get("theme"), dict) else "" + out = A.handle_chat(store, msg, hist, ev, actor, look) out["actions"] = hydrate(out.get("actions"), actor) out.pop("tools", None) return self._json(200, out) diff --git a/static/app.js b/static/app.js index 9c28a7b..5c6e718 100644 --- a/static/app.js +++ b/static/app.js @@ -44,7 +44,7 @@ if (echo) say("user", echo); const thinking = el("div", { class: "msg sys" }, "agent is thinking…"); log.append(thinking); try { - const out = await api("/api/chat", { message, event, history: history.slice(-8), actor }); + const out = await api("/api/chat", { message, event, history: history.slice(-6), actor, theme: AMTheme.get() }); thinking.remove(); if (message) history.push({ role: "user", content: message }); if (event) history.push({ role: "user", content: "UI_EVENT " + JSON.stringify(event) }); @@ -154,6 +154,7 @@ el("tr", {}, el("td", { class: "meta" }, "Seller receives"), el("td", { class: "meta" }, money(o.seller_receives_cents)))), el("div", { class: "meta" }, `Order ${o.id} · ${o.method}${o.card_last4 ? " · test card •••• " + o.card_last4 : ""} · ${o.status}`)); }, + set_theme(p) { AMTheme.applyAction(p); toast(p.reset && !p.base && !Object.keys(p.patch || {}).length ? "Look reset to default" : "Look updated", "ok"); return null; }, clear_panel() { stage.innerHTML = ""; return null; }, toast(p) { toast(p.text || "", p.level || "info"); return null; }, }; @@ -189,7 +190,7 @@ const fn = R[a.type]; if (!fn) continue; const node = fn(a.payload || {}); if (!node) continue; - if (first) { stage.innerHTML = ""; first = false; } + if (first) { stage.innerHTML = ""; first = false; if (AMTheme.ensureResultsVisible()) toast("Results section shown again so you can see this", "info"); } stage.append(node); } if (!first) stage.scrollIntoView({ behavior: "smooth", block: "start" }); @@ -208,8 +209,19 @@ $("#chatInput").value = ""; talk({ message: v, echo: v }); }); + const copyDefaults = (bps) => ({ + logo: "Agent Market", headline: "A marketplace you can just talk to.", + subhead: "Tell the agent what you want. It searches, compares, lists your stuff and runs the checkout, right here on the page. You can also restyle the whole page from the chat.", + chips: [{ label: "🚲 Show me bikes", say: "show me bikes" }, { label: "📐 Tutoring", say: "find tutoring services" }, + { label: "⚖️ Compare tutoring", say: "compare the two tutoring options" }, { label: "➕ Sell something", event: "sell" }, + { label: "🎨 Make it look retro", say: "make it look retro" }], + bullets: [`${(bps / 100).toFixed(bps % 100 ? 1 : 0)}% facilitation fee when you pay online (test mode)`, "$0 fee when you meet and pay cash in person", + "Agents welcome: other AI agents buy and sell through the open API (see For agents)"], + }); + AMTheme.init(copyDefaults(feeBps)); + $("#resetLook").addEventListener("click", () => { AMTheme.reset(); toast("Look reset to default", "ok"); }); api("/api/status").then((s) => { - feeBps = s.fee_bps; $("#feePct").textContent = (feeBps / 100).toFixed(feeBps % 100 ? 1 : 0) + "%"; + feeBps = s.fee_bps; AMTheme.init(copyDefaults(feeBps)); if (s.agent.mode === "llm") setModel(s.agent.last_model || s.agent.model); else { $("#agentBadge").textContent = "agent: rule-based fallback"; $("#agentBadge").title = s.agent.note || ""; } }); diff --git a/static/index.html b/static/index.html index 223946a..126ab40 100644 --- a/static/index.html +++ b/static/index.html @@ -5,39 +5,31 @@ Agent Market (demo) + -
TEST DEMO: all listings are fake and payments run in test mode. No real money moves.
+ +
TEST DEMO: all listings are fake and payments run in test mode. No real money moves.
-
Agent Market
+
Agent Market
-
-
-
-

A marketplace you can just talk to.

-

Tell the agent what you want. It searches, compares, lists your stuff and runs the checkout, right here on the page.

-
- - - - -
-
    -
  • 5% facilitation fee when you pay online (test mode)
  • -
  • $0 fee when you meet and pay cash in person
  • -
  • Agents welcome: other AI agents buy and sell through the open API
  • -
-
+
+
+

A marketplace you can just talk to.

+

Tell the agent what you want. It searches, compares, lists your stuff and runs the checkout, right here on the page. You can also restyle the whole page from the chat.

- -
+
+
    +
    +
    Agent Market demo · fake test data · test-mode payments · open API
    @@ -48,11 +40,12 @@
    - +
    + diff --git a/static/styles.css b/static/styles.css index 67e1b27..aa53bfe 100644 --- a/static/styles.css +++ b/static/styles.css @@ -1,46 +1,91 @@ -:root{--bg:#0f1115;--panel:#171a21;--card:#1d212a;--ink:#e9ecf1;--mute:#9aa3b2;--acc:#7c9cff;--ok:#38c172;--warn:#f6ad55;--line:#2a2f3a} -*{box-sizing:border-box}body{margin:0;font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink)} +/* Layer order: base (themeable via CSS variables) < user (agent's sanitised customCss) < protected. + customCss is stripped of !important and custom properties, so it can never beat the protected layer. */ +@layer base, user, protected; + +@layer base { +:root{--bg:#0f1115;--page-bg:#0f1115;--panel:#171a21;--card:#1d212a;--ink:#e9ecf1;--mute:#9aa3b2;--acc:#7c9cff;--acc-ink:#0b0e16;--line:#2a2f3a;--ok:#38c172;--warn:#f6ad55; + --font:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;--hfont:var(--font);--scale:1;--radius:14px;--space:1;--cols:auto-fill; + --grid-cols:repeat(auto-fill,minmax(220px,1fr));--chat-bg:var(--panel);--chat-ink:var(--ink);--chat-acc:var(--acc);--chat-acc-ink:var(--acc-ink);--chat-w:370px;--chat-h:520px; + --p-bg:#171a21;--p-ink:#ffffff} +*{box-sizing:border-box} +body{margin:0;font-family:var(--font);font-size:calc(15px * var(--scale));line-height:1.5;background:var(--page-bg);background-attachment:fixed;color:var(--ink);min-height:100vh} +h1,h2,h3{font-family:var(--hfont)} a{color:var(--acc)}button{font:inherit;cursor:pointer} -.testbar{background:#3b2f0b;color:#ffd970;text-align:center;font-size:13px;padding:6px} -.top{display:flex;justify-content:space-between;align-items:center;padding:14px 28px;border-bottom:1px solid var(--line)} -.brand{font-weight:700;font-size:18px}.logo{color:var(--acc)} +.top{display:flex;justify-content:space-between;align-items:center;padding:calc(14px * var(--space)) 28px;border-bottom:1px solid var(--line)} +.brand{font-weight:700;font-size:1.2em}.logo{color:var(--acc)} nav{display:flex;gap:14px;align-items:center}.link{background:none;border:0;color:var(--ink);text-decoration:none;padding:4px} -.badge{font-size:12px;border:1px solid var(--line);border-radius:999px;padding:3px 10px;color:var(--mute)} -main{max-width:1100px;margin:0 auto;padding:28px} -.landing{padding:28px 0 8px}.landing h1{font-size:40px;line-height:1.1;margin:0 0 10px}.landing p{color:var(--mute);max-width:640px} -.chips{display:flex;flex-wrap:wrap;gap:10px;margin:18px 0}.chip{background:var(--card);border:1px solid var(--line);color:var(--ink);padding:8px 14px;border-radius:999px} -.chip:hover{border-color:var(--acc)}.facts{color:var(--mute);padding-left:18px} -#stage{margin-top:22px;display:flex;flex-direction:column;gap:18px} -.panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;animation:in .25s ease} +main{max-width:1100px;margin:0 auto;padding:calc(28px * var(--space)) 28px;display:flex;flex-direction:column;gap:calc(14px * var(--space))} +.hero-sec h1{font-size:2.65em;line-height:1.1;margin:calc(20px * var(--space)) 0 10px}.hero-sec p{color:var(--mute);max-width:660px;margin:0} +.chips{display:flex;flex-wrap:wrap;gap:10px}.chip{background:var(--card);border:1px solid var(--line);color:var(--ink);padding:8px 14px;border-radius:999px} +.chip:hover{border-color:var(--acc)}.facts{color:var(--mute);padding-left:18px;margin:0} +.foot{color:var(--mute);font-size:.85em;border-top:1px solid var(--line);padding-top:calc(14px * var(--space));margin-top:calc(20px * var(--space))} +.is-hidden{display:none !important} +#stage{display:flex;flex-direction:column;gap:calc(18px * var(--space))} +#stage:empty{display:none} +.panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:calc(20px * var(--space));animation:in .25s ease} @keyframes in{from{opacity:0;transform:translateY(6px)}to{opacity:1}} -.panel h2{margin:0 0 12px;font-size:20px}.panel .by{font-size:12px;color:var(--mute);margin-bottom:10px} -.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px} -.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;display:flex;flex-direction:column;gap:6px} -.card .emoji{font-size:30px}.card h3{margin:0;font-size:16px}.price{font-weight:700;font-size:18px} -.meta{font-size:12px;color:var(--mute)}.tag{display:inline-block;font-size:11px;border:1px solid var(--line);border-radius:6px;padding:1px 6px;margin-right:4px} +.panel h2{margin:0 0 12px;font-size:1.35em}.panel .by{font-size:.8em;color:var(--mute);margin-bottom:10px} +.grid{display:grid;grid-template-columns:var(--grid-cols);gap:calc(14px * var(--space))} +.card{background:var(--card);border:1px solid var(--line);border-radius:calc(var(--radius) * .85);padding:calc(14px * var(--space));display:flex;flex-direction:column;gap:6px} +.card .emoji{font-size:2em}.card h3{margin:0;font-size:1.07em}.price{font-weight:700;font-size:1.2em} +body.card-flat .card{border-color:transparent;background:var(--card)} +body.card-outlined .card{background:transparent;border:2px solid var(--line)} +body.card-elevated .card{border-color:transparent;box-shadow:0 10px 28px rgba(0,0,0,.25)} +body.card-glass .card,body.card-glass .panel{background:color-mix(in srgb,var(--card) 55%,transparent);backdrop-filter:blur(10px);border-color:color-mix(in srgb,var(--line) 70%,transparent)} +body.layout-list .grid{grid-template-columns:1fr} +body.layout-list .card{flex-direction:row;align-items:center;gap:16px;flex-wrap:wrap} +body.layout-list .card h3{flex:1 1 220px}body.layout-list .card .actions{margin-top:0} +.meta{font-size:.8em;color:var(--mute)}.tag{display:inline-block;font-size:.73em;border:1px solid var(--line);border-radius:6px;padding:1px 6px;margin-right:4px} .agent-tag{border-color:#5a4bd1;color:#b7adff}.sold{opacity:.55} .actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto} -.btn{background:var(--acc);color:#0b0e16;border:0;border-radius:8px;padding:7px 11px;font-weight:600} -.btn.ghost{background:transparent;color:var(--ink);border:1px solid var(--line)}.btn.cash{background:var(--ok)} -.hero{background:linear-gradient(135deg,#1c2340,#171a21)}.hero ul{color:var(--mute)} +.btn{background:var(--acc);color:var(--acc-ink);border:0;border-radius:calc(var(--radius) * .55);padding:7px 11px;font-weight:600} +.btn.ghost{background:transparent;color:var(--ink);border:1px solid var(--line)}.btn.cash{background:var(--ok);color:#06210f} +.hero{background:linear-gradient(135deg,color-mix(in srgb,var(--acc) 22%,var(--panel)),var(--panel))}.hero ul{color:var(--mute)} table.cmp{width:100%;border-collapse:collapse}table.cmp td,table.cmp th{border-bottom:1px solid var(--line);padding:8px;text-align:left;vertical-align:top} -.form{display:grid;grid-template-columns:1fr 1fr;gap:12px}.form label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var(--mute)} -.form .full{grid-column:1/-1}input,textarea,select{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:9px;font:inherit} +.form{display:grid;grid-template-columns:1fr 1fr;gap:12px}.form label{display:flex;flex-direction:column;gap:4px;font-size:.87em;color:var(--mute)} +.form .full{grid-column:1/-1}input,textarea,select{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:calc(var(--radius) * .55);padding:9px;font:inherit} .breakdown{width:100%;max-width:440px;border-collapse:collapse;margin:8px 0 14px}.breakdown td{padding:5px 0}.breakdown td:last-child{text-align:right} -.breakdown tr.total td{border-top:1px solid var(--line);font-weight:700;padding-top:8px} -.testcard{background:#2a230c;border:1px dashed #a88a2a;color:#ffd970;border-radius:8px;padding:8px 10px;font-size:13px;margin-bottom:10px} +.breakdown tr.total td{border-top:1px solid currentColor;font-weight:700;padding-top:8px} .cardform{display:grid;grid-template-columns:2fr 1fr 1fr;gap:8px;max-width:440px} .ok{color:var(--ok)}.warn{color:var(--warn)} -.launcher{position:fixed;right:22px;bottom:22px;background:var(--acc);color:#0b0e16;border:0;border-radius:999px;padding:12px 18px;font-weight:700;box-shadow:0 8px 30px #0008} -.chat{position:fixed;right:22px;bottom:22px;width:370px;max-width:calc(100vw - 30px);height:520px;max-height:calc(100vh - 60px);background:var(--panel);border:1px solid var(--line);border-radius:16px;display:flex;flex-direction:column;box-shadow:0 12px 40px #000a} +.launcher{position:fixed;right:22px;bottom:22px;background:var(--chat-acc);color:var(--chat-acc-ink);border:0;border-radius:999px;padding:12px 18px;font-weight:700;box-shadow:0 8px 30px #0008} +.chat{position:fixed;right:22px;bottom:22px;width:var(--chat-w);max-width:calc(100vw - 30px);height:var(--chat-h);max-height:calc(100vh - 60px);background:var(--chat-bg);color:var(--chat-ink);border:1px solid var(--line);border-radius:16px;display:flex;flex-direction:column;box-shadow:0 12px 40px #000a;z-index:5} +body.chat-left .chat,body.chat-left .launcher{right:auto;left:22px} +body.chat-left .cmpbar{left:auto;right:22px} .chat.hidden{display:none}.chat-head{display:flex;justify-content:space-between;align-items:center;padding:12px 14px;border-bottom:1px solid var(--line)} -.chat-head button{background:none;border:0;color:var(--mute);font-size:16px}.sub{font-size:11px;color:var(--mute)} +.chat-head button{background:none;border:0;color:inherit;opacity:.7;font-size:16px}.sub{font-size:11px;opacity:.75} .log{flex:1;overflow:auto;padding:12px;display:flex;flex-direction:column;gap:8px} -.msg{padding:8px 11px;border-radius:12px;max-width:85%;white-space:pre-wrap}.msg.user{align-self:flex-end;background:var(--acc);color:#0b0e16} -.msg.bot{align-self:flex-start;background:var(--card)}.msg.sys{align-self:center;font-size:12px;color:var(--mute)} -.chat-form{display:flex;gap:8px;padding:10px;border-top:1px solid var(--line)}.chat-form input{flex:1} -.chat-form button{background:var(--acc);border:0;border-radius:8px;padding:0 14px;font-weight:600} +.msg{padding:8px 11px;border-radius:12px;max-width:85%;white-space:pre-wrap}.msg.user{align-self:flex-end;background:var(--chat-acc);color:var(--chat-acc-ink)} +.msg.bot{align-self:flex-start;background:color-mix(in srgb,var(--chat-ink) 10%,var(--chat-bg));color:var(--chat-ink)}.msg.sys{align-self:center;font-size:12px;opacity:.75} +.chat-form{display:flex;gap:8px;padding:10px;border-top:1px solid var(--line)}.chat-form input{flex:1;background:color-mix(in srgb,var(--chat-ink) 6%,var(--chat-bg));color:var(--chat-ink)} +.chat-form button{background:var(--chat-acc);color:var(--chat-acc-ink);border:0;border-radius:8px;padding:0 14px;font-weight:600} #toasts{position:fixed;left:50%;top:50px;transform:translateX(-50%);display:flex;flex-direction:column;gap:6px;z-index:9} -.toast{background:var(--card);border:1px solid var(--line);padding:8px 14px;border-radius:8px}.toast.ok{border-color:var(--ok)}.toast.warn{border-color:var(--warn)} +.toast{background:var(--card);color:var(--ink);border:1px solid var(--line);padding:8px 14px;border-radius:8px}.toast.ok{border-color:var(--ok)}.toast.warn{border-color:var(--warn)} .cmpbar{position:fixed;left:22px;bottom:22px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:8px 12px;display:none;gap:8px;align-items:center} -@media(max-width:640px){.landing h1{font-size:28px}.form{grid-template-columns:1fr}.top{padding:12px}main{padding:16px}} +body.no-anim *,body.no-anim *::before,body.no-anim *::after{animation:none !important;transition:none !important} +@media(max-width:640px){.hero-sec h1{font-size:1.9em}.form{grid-template-columns:1fr}.top{padding:12px}main{padding:16px}} +} + +@layer protected { +/* Guardrails: fixed, high-contrast colours; nothing in a theme or customCss can change these. */ +.testbar{display:block;visibility:visible;opacity:1;filter:none;background:#3b2f0b;color:#ffd970;text-align:center; + font:600 13px/1.4 system-ui,sans-serif;padding:6px;letter-spacing:normal;text-transform:none;text-shadow:none;margin:0;border:0} +.badge{display:inline-block;visibility:visible;opacity:1;filter:none;background:#10131a;color:#ffffff;border:1px solid #5b6475; + border-radius:999px;padding:3px 10px;font:500 12px/1.4 system-ui,sans-serif;text-shadow:none;letter-spacing:normal;text-transform:none} +#stage{visibility:visible;opacity:1;filter:none;max-height:none;overflow:visible} +[data-component=checkout],[data-component=receipt],[data-component=cash-meetup]{ + display:block;visibility:visible;opacity:1;filter:none;backdrop-filter:none;max-height:none;height:auto;overflow:visible; + background:var(--p-bg);color:var(--p-ink);border:2px solid var(--p-ink);text-shadow:none;letter-spacing:normal; + font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:15px;margin:0} +[data-component=checkout] *,[data-component=receipt] *,[data-component=cash-meetup] *{ + visibility:visible;opacity:1;filter:none;text-shadow:none;letter-spacing:normal;text-transform:none;font-family:inherit;color:inherit} +[data-component=checkout] .meta,[data-component=receipt] .meta,[data-component=cash-meetup] .meta{color:var(--p-ink);font-size:13px} +[data-component=checkout] .breakdown,[data-component=receipt] .breakdown,[data-component=cash-meetup] .breakdown{display:table;font-size:15px;background:transparent} +.breakdown tr{display:table-row}.breakdown td{display:table-cell;font-size:inherit;color:var(--p-ink);background:transparent} +[data-component=checkout] .testcard{display:block;background:#2a230c;color:#ffd970;border:1px dashed #a88a2a;font-size:13px} +[data-component=checkout] .cardform{display:grid} +[data-component=checkout] input{display:block;background:#ffffff;color:#111111;border:1px solid #555555;font-size:15px} +[data-component=checkout] .btn,[data-component=cash-meetup] .btn{display:inline-block;background:var(--p-ink);color:var(--p-bg);border:0} +[data-component=checkout] a,[data-component=cash-meetup] a{color:var(--p-ink);text-decoration:underline} +[data-component=receipt] h2.ok{color:var(--p-ink)} +} diff --git a/static/theme.js b/static/theme.js new file mode 100644 index 0000000..e7203c4 --- /dev/null +++ b/static/theme.js @@ -0,0 +1,163 @@ +// Per-visitor theme engine. The theme is a validated JSON spec of design tokens stored in localStorage +// (never on the server), so one visitor's restyle never changes the page for anyone else. +(() => { + const KEY = "am_theme_v1"; + const FONTS = { + system: ["system-ui,-apple-system,Segoe UI,Roboto,sans-serif"], serif: ["Georgia,'Times New Roman',serif"], mono: ["ui-monospace,Menlo,Consolas,monospace"], + 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"], + }; + const MODES = { + dark: { bg: "#0f1115", surface: "#171a21", card: "#1d212a", text: "#e9ecf1", muted: "#9aa3b2", accent: "#7c9cff", border: "#2a2f3a" }, + light: { bg: "#f5f6fa", surface: "#ffffff", card: "#ffffff", text: "#151821", muted: "#5b6475", accent: "#3d5afe", border: "#dde1ea" }, + }; + const SECTIONS = ["hero", "chips", "bullets", "results", "footer"]; + const IMAGE_HOSTS = ["images.unsplash.com", "picsum.photos", "upload.wikimedia.org"]; + const isObj = (o) => o && typeof o === "object" && !Array.isArray(o); + const merge = (a, b) => { const o = { ...(a || {}) }; for (const [k, v] of Object.entries(b || {})) o[k] = isObj(v) && isObj(o[k]) ? merge(o[k], v) : v; return o; }; + const okColor = (c) => typeof c === "string" && c.length < 60 && !/[;{}<>]/.test(c) && CSS.supports("color", c); + + // ---- colour maths for guaranteed contrast on protected elements ---- + const probe = document.createElement("span"); + function rgb(c) { + probe.style.color = ""; probe.style.color = c; document.body.append(probe); + const m = getComputedStyle(probe).color.match(/[\d.]+/g) || [0, 0, 0, 1]; probe.remove(); + return { r: +m[0], g: +m[1], b: +m[2], a: m[3] === undefined ? 1 : +m[3] }; + } + const lum = ({ r, g, b }) => [r, g, b].map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }) + .reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0); + const contrast = (a, b) => { const x = lum(rgb(a)), y = lum(rgb(b)); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); }; + const solid = (c) => { const v = rgb(c); return `rgb(${v.r},${v.g},${v.b})`; }; + const bestInk = (bg) => (contrast(bg, "#111111") >= contrast(bg, "#ffffff") ? "#111111" : "#ffffff"); + window.__amContrast = contrast; + + function backgroundCss(bg, pal) { + const cols = (bg.colors || []).filter(okColor); + const c0 = cols[0] || pal.bg, c1 = cols[1] || pal.surface; + switch (bg.kind) { + case "gradient": return `linear-gradient(${Number(bg.angle ?? 135)}deg, ${(cols.length > 1 ? cols : [c0, c1]).join(", ")})`; + case "pattern": { + const p = bg.pattern || "dots"; + const map = { + dots: `radial-gradient(${c1} 1.5px, transparent 1.6px) 0 0/18px 18px, ${c0}`, + grid: `linear-gradient(${c1} 1px, transparent 1px) 0 0/24px 24px, linear-gradient(90deg, ${c1} 1px, transparent 1px) 0 0/24px 24px, ${c0}`, + stripes: `repeating-linear-gradient(90deg, ${c0} 0 18px, ${c1} 18px 36px)`, + diagonal: `repeating-linear-gradient(45deg, ${c0} 0 14px, ${c1} 14px 28px)`, + checks: `conic-gradient(${c1} 25%, ${c0} 0 50%, ${c1} 0 75%, ${c0} 0) 0 0/40px 40px`, + waves: `radial-gradient(circle at 50% 0, transparent 18px, ${c1} 19px 21px, transparent 22px) 0 0/40px 24px, ${c0}`, + }; + return map[p] || map.dots; + } + case "image": { + try { const u = new URL(bg.url); if (u.protocol === "https:" && IMAGE_HOSTS.includes(u.hostname)) return `center/cover no-repeat url("${u.href.replace(/["\\()]/g, "")}"), ${c0}`; } catch (e) {} + return c0; + } + default: return c0; + } + } + + function loadFont(name) { + const f = FONTS[name]; if (!f) return FONTS.system[0]; + if (f[1] && !document.querySelector(`link[data-font="${name}"]`)) { + const l = document.createElement("link"); l.rel = "stylesheet"; l.dataset.font = name; + l.href = `https://fonts.googleapis.com/css2?family=${f[1]}&display=swap`; document.head.append(l); + } + return f[0]; + } + + function scopeCss(css) { + if (typeof css !== "string" || /[<\\@]|expression\s*\(|javascript:|!\s*important|--[\w-]+\s*:/i.test(css)) return ""; + const urlOk = [...css.matchAll(/url\(([^)]*)\)/gi)].every((m) => { try { return IMAGE_HOSTS.includes(new URL(m[1].replace(/["']/g, "").trim()).hostname); } catch (e) { return false; } }); + if (!urlOk) return ""; + return [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(([, sel, body]) => + sel.split(",").map((s) => "#page " + s.trim()).join(", ") + "{" + body + "}").join("\n"); + } + + let theme = {}; + try { theme = JSON.parse(localStorage.getItem(KEY) || "{}") || {}; } catch (e) { theme = {}; } + const defaults = { copy: {} }; + + function apply() { + const t = theme, root = document.documentElement.style, body = document.body; + const mode = t.mode === "light" ? "light" : "dark"; + const pal = { ...MODES[mode] }; + for (const [k, v] of Object.entries(t.palette || {})) if (k in pal && okColor(v)) pal[k] = v; + const set = (k, v) => root.setProperty(k, v); + set("--bg", pal.bg); set("--panel", pal.surface); set("--card", pal.card); set("--ink", pal.text); + set("--mute", pal.muted); set("--acc", pal.accent); set("--line", pal.border); set("--acc-ink", bestInk(pal.accent)); + set("--page-bg", backgroundCss(isObj(t.background) ? t.background : { kind: "solid", colors: [pal.bg] }, pal)); + set("--font", loadFont(FONTS[t.font] ? t.font : "system")); + set("--hfont", FONTS[t.headingFont] ? loadFont(t.headingFont) : "var(--font)"); + set("--scale", String(Math.min(1.75, Math.max(0.75, Number(t.fontScale) || 1)))); + set("--radius", Math.min(32, Math.max(0, Number(t.radius ?? 14))) + "px"); + set("--space", String({ compact: 0.65, normal: 1, comfy: 1.4 }[t.density] || 1)); + const cols = Math.min(6, Math.max(0, parseInt(t.columns) || 0)); + set("--grid-cols", cols ? `repeat(${cols}, minmax(0, 1fr))` : "repeat(auto-fill, minmax(220px, 1fr))"); + // protected panels: solid surface colour + whichever of black/white reads best (always >= 4.5:1) + const pbg = solid(pal.surface); set("--p-bg", pbg); set("--p-ink", bestInk(pbg)); + // chat popup + const ch = isObj(t.chat) ? t.chat : {}; + const cbg = okColor(ch.bg) ? ch.bg : pal.surface, cacc = okColor(ch.accent) ? ch.accent : pal.accent; + set("--chat-bg", solid(cbg)); set("--chat-ink", okColor(ch.text) && contrast(ch.text, solid(cbg)) >= 4.5 ? ch.text : bestInk(solid(cbg))); + set("--chat-acc", cacc); set("--chat-acc-ink", bestInk(cacc)); + set("--chat-w", Math.min(600, Math.max(280, Number(ch.width) || 370)) + "px"); + set("--chat-h", Math.min(820, Math.max(320, Number(ch.height) || 520)) + "px"); + body.classList.toggle("chat-left", ch.position === "left"); + for (const s of ["flat", "outlined", "elevated", "glass"]) body.classList.toggle("card-" + s, t.cardStyle === s); + body.classList.toggle("layout-list", t.layout === "list"); + body.classList.toggle("no-anim", t.animations === false); + // copy + const c = { ...defaults.copy, ...(isObj(t.copy) ? t.copy : {}) }; + document.getElementById("logoText").textContent = String(c.logo || "Agent Market").slice(0, 40); + document.getElementById("headline").textContent = String(c.headline).slice(0, 120); + document.getElementById("subhead").textContent = String(c.subhead).slice(0, 240); + const chips = document.getElementById("chips"); chips.innerHTML = ""; + for (const chip of (Array.isArray(c.chips) ? c.chips : []).slice(0, 6)) { + const b = document.createElement("button"); b.className = "chip"; + if (chip.event) b.dataset.event = chip.event; else b.dataset.say = String(chip.say || chip.label).slice(0, 120); + b.textContent = String(chip.label).slice(0, 40); chips.append(b); + } + const ul = document.getElementById("bullets"); ul.innerHTML = ""; + for (const line of (Array.isArray(c.bullets) ? c.bullets : []).slice(0, 6)) { const li = document.createElement("li"); li.textContent = String(line).slice(0, 140); ul.append(li); } + if (t.copy && t.copy.footer) document.getElementById("footer").textContent = String(t.copy.footer).slice(0, 160); + else if (defaults.footerHtml) document.getElementById("footer").innerHTML = defaults.footerHtml; + // sections + const sec = isObj(t.sections) ? t.sections : {}; + const order = Array.isArray(sec.order) ? sec.order.filter((x) => SECTIONS.includes(x)) : []; + const full = [...new Set([...order, ...SECTIONS])]; + const hidden = Array.isArray(sec.hidden) ? sec.hidden : []; + for (const name of SECTIONS) { + const n = document.querySelector(`[data-section="${name}"]`); + n.style.order = String(full.indexOf(name)); + n.classList.toggle("is-hidden", hidden.includes(name)); + } + document.getElementById("user-css").textContent = t.customCss ? "@layer user {\n" + scopeCss(t.customCss) + "\n}" : ""; + } + + window.AMTheme = { + init(copyDefaults) { defaults.copy = copyDefaults; defaults.footerHtml = document.getElementById("footer").innerHTML; apply(); }, + get: () => theme, + // action from the agent: {reset?, base?, patch} + applyAction(a) { + if (a.reset) theme = {}; + if (a.base) theme = JSON.parse(JSON.stringify(a.base)); + theme = merge(theme, a.patch || {}); + localStorage.setItem(KEY, JSON.stringify(theme)); apply(); + }, + reset() { theme = {}; localStorage.removeItem(KEY); apply(); }, + // a panel that must be visible (checkout etc.) un-hides the results section + ensureResultsVisible() { + if (theme.sections && Array.isArray(theme.sections.hidden) && theme.sections.hidden.includes("results")) { + theme.sections.hidden = theme.sections.hidden.filter((x) => x !== "results"); localStorage.setItem(KEY, JSON.stringify(theme)); apply(); return true; + } + return false; + }, + contrast, + }; +})(); diff --git a/tests/test_theme.py b/tests/test_theme.py new file mode 100644 index 0000000..22b91be --- /dev/null +++ b/tests/test_theme.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Unit checks for theme validation and the customCss sanitiser.""" +import os, sys +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +from market.theme import sanitize_css, sanitize_patch, build_theme_action, PRESETS + +fails = 0 +def check(name, cond): + global fails + fails += not cond + print(("PASS " if cond else "FAIL ") + name) + +evil = ("@import url(https://evil.example/x.css); .card{color:red!important;position:fixed;z-index:99;--p-ink:transparent;" + "background:url(https://evil.example/t.png)} body{display:none} :root{--acc:red} h1{background:url(https://images.unsplash.com/a.jpg);margin:-500px}" + " .x{color:expression(alert(1))} .y{behavior:url(x.htc)} .z::before{content:'x'}" + " .t{transform:scale(0)} .k{color:blue}") +out = sanitize_css(evil) +check("css: @import stripped", "@" not in out and "evil.example/x.css" not in out) +check("css: !important stripped", "important" not in out) +check("css: position/z-index/content/transform dropped", not any(k in out for k in ("position", "z-index", "content", "transform"))) +check("css: custom properties dropped", "--" not in out) +check("css: non-allowlisted url() neutralised", "evil.example" not in out and "url(https://images.unsplash.com/a.jpg)" in out) +check("css: body/:root selectors dropped", "body" not in out and ":root" not in out) +check("css: script/expression/behavior gone", not any(k in out.lower() for k in ("Hi there", "chips": ["Bikes", {"label": "Garden", "say": "garden stuff"}]}, + "sections": {"hidden": ["bullets", "nope"], "order": ["results", "hero"]}, "evil": "x", + "background": {"kind": "image", "url": "https://evil.example/bg.jpg"}}) +check("patch: bad colour dropped, good kept", p["palette"] == {"accent": "green"}) +check("patch: font alias resolved", p["font"] == "Comic Neue") +check("patch: numbers clamped", p["fontScale"] == 1.75 and p["radius"] == 0 and p["columns"] == 6 and p["chat"]["width"] == 600) +check("patch: html stripped from copy", "<" not in p["copy"]["headline"]) +check("patch: sections validated", p["sections"]["hidden"] == ["bullets"] and p["sections"]["order"][:2] == ["results", "hero"]) +check("patch: unknown keys and non-allowlisted image dropped", "evil" not in p and "background" not in p) +a = build_theme_action({"preset": "Retro"}) +check("preset: retro expands", a["preset"] == "retro" and a["base"]["font"] == "VT323") +check("preset: alias", build_theme_action({"preset": "cyberpunk"})["preset"] == "neon") +check("reset action", build_theme_action({"reset": True})["reset"] is True) +check("all presets validate cleanly", all(sanitize_patch(v) for v in PRESETS.values())) +sys.exit(1 if fails else 0) diff --git a/tests/theme_check.js b/tests/theme_check.js new file mode 100644 index 0000000..8a35283 --- /dev/null +++ b/tests/theme_check.js @@ -0,0 +1,126 @@ +// Chat-driven restyle walkthrough + guardrail checks. Usage: +// NODE_PATH= node tests/theme_check.js BASE_URL [screenshot_dir] +const puppeteer = require("puppeteer-core"); +const BASE = (process.argv[2] || "http://127.0.0.1:8795").replace(/\/$/, ""); +const SHOTS = process.argv[3] || ""; +const results = []; const replies = []; +const check = (name, ok, d = "") => { results.push(!!ok); console.log((ok ? "PASS " : "FAIL ") + name + (d ? ` [${d}]` : "")); }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +(async () => { + const browser = await puppeteer.launch({ executablePath: process.env.CHROME || "/usr/bin/google-chrome", headless: "new", + args: ["--no-sandbox", "--disable-gpu", "--user-data-dir=/tmp/am-theme-profile-" + Date.now()] }); + const ctx = await browser.createBrowserContext(); + const page = await ctx.newPage(); + await page.setViewport({ width: 1280, height: 860 }); + const errors = []; page.on("pageerror", (e) => errors.push(e.message)); + await page.goto(BASE + "/", { waitUntil: "networkidle0" }); + await page.evaluate(() => localStorage.removeItem("am_theme_v1")); + await page.reload({ waitUntil: "networkidle0" }); + await page.waitForSelector(".card", { timeout: 20000 }); + await page.click("#chatLauncher"); + + const ask = async (text) => { + for (let attempt = 0; attempt < 4; attempt++) { + const n = (await page.$$(".msg.bot")).length; const t0 = Date.now(); + await page.type("#chatInput", text); await page.keyboard.press("Enter"); + await page.waitForFunction((n) => document.querySelectorAll(".msg.bot").length > n, { timeout: 60000 }, n); + await sleep(500); + const last = await page.$$eval(".msg.bot", (ns) => ns[ns.length - 1].textContent); + if (/sending messages quickly/.test(last)) { const s = +(last.match(/(\d+)s/) || [0, 6])[1]; await sleep((s + 1) * 1000); continue; } + const mode = await page.$eval("#chatMode", (n) => n.textContent); + replies.push({ text, reply: last, ms: Date.now() - t0, mode }); + console.log(` > ${text}\n ${mode} · ${Date.now() - t0}ms · "${last.slice(0, 160)}"`); + return last; + } + }; + const theme = () => page.evaluate(() => JSON.parse(localStorage.getItem("am_theme_v1") || "{}")); + const cs = (sel, prop) => page.$eval(sel, (n, p) => getComputedStyle(n)[p], prop); + + // Guardrails: banner, badge and checkout fee lines must stay visible and >= 4.5:1 contrast. + async function guardrails(label) { + // open a checkout for the first online-payable item via the deterministic click path + await page.evaluate(() => { const b = [...document.querySelectorAll("#stage .card button.btn:not(.cash)")][0]; if (b) b.click(); }); + const hasCheckout = await page.waitForSelector("[data-component=checkout]", { timeout: 15000 }).then(() => true, () => false); + const r = await page.evaluate(() => { + const eff = (n) => { while (n) { const c = getComputedStyle(n).backgroundColor; if (c && !/rgba\(0, 0, 0, 0\)|transparent/.test(c)) return c; n = n.parentElement; } return "rgb(255,255,255)"; }; + const vis = (n) => { const s = getComputedStyle(n); const r = n.getBoundingClientRect(); return s.display !== "none" && s.visibility === "visible" && +s.opacity === 1 && r.width > 0 && r.height > 0; }; + const out = {}; + for (const [k, sel] of [["banner", ".testbar"], ["badge", "#agentBadge"], ["fee", "[data-testid=fee]"], ["total", "[data-testid=total]"], ["testcard", ".testcard"]]) { + const n = document.querySelector(sel); if (!n) { out[k] = null; continue; } + out[k] = { c: window.__amContrast(getComputedStyle(n).color, eff(n)), vis: vis(n), text: n.textContent.trim().slice(0, 40), fs: parseFloat(getComputedStyle(n).fontSize) }; + } + out.input = (() => { const i = document.querySelector("[data-component=checkout] input"); return i && { vis: vis(i), c: window.__amContrast(getComputedStyle(i).color, getComputedStyle(i).backgroundColor) }; })(); + return out; + }); + const ok = hasCheckout && ["banner", "badge", "fee", "total", "testcard"].every((k) => r[k] && r[k].vis && r[k].c >= 4.5 && r[k].fs >= 12) && r.input && r.input.vis && r.input.c >= 4.5; + check(`guardrails intact under ${label}`, ok, ["banner", "badge", "fee", "total"].map((k) => r[k] ? `${k} ${r[k].c.toFixed(1)}:1` : `${k} missing`).join(", ") + (r.fee ? ` fee="${r.fee.text}"` : "")); + const feeOk = r.fee && r.total && /^\$\d+\.\d\d$/.test(r.fee.text) && r.banner && /TEST DEMO/.test(r.banner.text); + check(`fee amount + banner text unchanged under ${label}`, feeOk); + } + + await ask("show me bikes"); + await page.waitForSelector("#stage .card", { timeout: 15000 }); + + await ask("make the background a sunset gradient"); + const bg1 = await cs("body", "backgroundImage"); + check("sunset gradient background", /gradient/.test(bg1), bg1.slice(0, 90)); + if (SHOTS) await page.screenshot({ path: SHOTS + "/6-theme-sunset-gradient.png" }); + await guardrails("sunset gradient"); + await ask("show me everything"); + + await ask("switch to light mode with a green accent"); + const t2 = await theme(); + const acc = await page.evaluate(() => { const s = document.createElement("i"); s.style.color = getComputedStyle(document.documentElement).getPropertyValue("--acc"); document.body.append(s); const c = getComputedStyle(s).color; s.remove(); return c; }); + const [r, g, b] = acc.match(/\d+/g).map(Number); + check("light mode with green accent", t2.mode === "light" && g > r && g > b, `mode=${t2.mode} accent=${acc}`); + if (SHOTS) await page.screenshot({ path: SHOTS + "/7-theme-light-green.png" }); + await guardrails("light + green"); + + await ask("use a serif font and make the text bigger"); + const ff = await cs("body", "fontFamily"); const t3 = await theme(); + check("serif font + bigger text", /Georgia|serif|Lora|Merriweather|Playfair|Roboto Slab/i.test(ff) && (t3.fontScale || 1) > 1, `${ff.slice(0, 40)} scale=${t3.fontScale}`); + + await ask("show results as a list, not cards"); + await ask("show me everything"); + const cols = await page.$eval("#stage .grid", (n) => getComputedStyle(n).gridTemplateColumns.split(" ").length).catch(() => 0); + check("results as a list", (await page.$eval("body", (n) => n.classList.contains("layout-list"))) && cols === 1, `columns=${cols}`); + + await ask("move the chat to the left"); + const left = await page.$eval("#chat", (n) => n.getBoundingClientRect().left); + check("chat moved to the left", left < 100, `left=${left}`); + + await ask("make it look retro"); + const ff2 = await cs("body", "fontFamily"); const t4 = await theme(); + check("retro preset applied", /VT323|Press Start|Space Mono|mono|Courier/i.test(ff2) || /retro/i.test(JSON.stringify(t4)), ff2.slice(0, 50)); + if (SHOTS) { await ask("show me everything"); await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: SHOTS + "/8-theme-retro.png" }); } + await guardrails("retro"); + + await ask("hide the bullet points"); + check("bullets hidden", (await cs("#bullets", "display")) === "none"); + + await ask("change the headline to Buy and sell anything nearby"); + const h = await page.$eval("#headline", (n) => n.textContent); + check("headline changed", /buy and sell anything nearby/i.test(h), h); + + // A second, fresh session must still see the default look. + const ctx2 = await browser.createBrowserContext(); const p2 = await ctx2.newPage(); + await p2.goto(BASE + "/", { waitUntil: "networkidle0" }); + const fresh = await p2.evaluate(() => ({ h: document.getElementById("headline").textContent, bg: getComputedStyle(document.body).backgroundImage, bul: getComputedStyle(document.getElementById("bullets")).display, ls: localStorage.getItem("am_theme_v1") })); + check("fresh session sees default look", /just talk to/.test(fresh.h) && fresh.bg === "none" && fresh.bul !== "none" && !fresh.ls, JSON.stringify(fresh).slice(0, 120)); + await ctx2.close(); + + await ask("reset the look"); + const t5 = await theme(); const h2 = await page.$eval("#headline", (n) => n.textContent); + check("reset the look", Object.keys(t5).length === 0 && /just talk to/.test(h2), JSON.stringify(t5)); + + check("no internal ids in replies", replies.every((x) => !/\b(lst|pi|cash|ord)_[0-9a-f]{6,}/.test(x.reply))); + check("all restyles answered by the real model", replies.every((x) => /AI agent/.test(x.mode)), [...new Set(replies.map((x) => x.mode.split(" · ")[1]))].join(",")); + check("no page JS errors", errors.length === 0, errors.join("; ")); + const ms = replies.map((x) => x.ms).sort((a, b) => a - b); + console.log(`\nmodel replies: median ${(ms[ms.length >> 1] / 1000).toFixed(1)}s, max ${(ms[ms.length - 1] / 1000).toFixed(1)}s`); + await browser.close(); + const passed = results.filter(Boolean).length; + console.log(`${passed}/${results.length} passed`); + process.exit(passed === results.length ? 0 : 1); +})().catch((e) => { console.error(e); process.exit(2); }); From b396a01bc48df91aa779b2310aff395090339e6b Mon Sep 17 00:00:00 2001 From: leora Date: Thu, 8 Oct 2026 19:39:02 +0800 Subject: [PATCH 2/6] Rule-based look commands for when the model is unavailable; daily-quota-aware cooldown; honest badge when the built-in agent answers; theme tests --- market/agent.py | 70 +++++++++----------- market/theme.py | 139 +++++++++++++++++++++++++++++++++++++++ static/app.js | 8 ++- tests/e2e_http.py | 13 +++- tests/llm_proxy_check.sh | 17 ++++- tests/mock_llm.py | 13 ++++ tests/theme_check.js | 4 +- 7 files changed, 218 insertions(+), 46 deletions(-) diff --git a/market/agent.py b/market/agent.py index b1c9085..8a6e2b6 100644 --- a/market/agent.py +++ b/market/agent.py @@ -274,44 +274,16 @@ def fallback_event(store, ev: dict, actor: str): return {"reply": "I didn't recognise that action.", "actions": []} -def fallback_theme(low): - """Basic look commands for when the LLM is unavailable.""" - from .theme import PRESETS, PRESET_ALIASES - if re.search(r"\breset\b.*\b(look|theme|style|design|page)\b|\bdefault (look|theme)\b", low): - return {"reply": "Done: the page is back to its default look.", "actions": [{"type": "set_theme", "payload": {"reset": True}}]} - for name in list(PRESETS) + list(PRESET_ALIASES): - if re.search(r"\b" + re.escape(name) + r"\b", low) and re.search(r"look|theme|style|make it|switch|mode|preset|vibe", low): - real = PRESET_ALIASES.get(name, name) - return {"reply": f"Applied the {real} look.", "actions": [{"type": "set_theme", "payload": {"preset": real}}]} - patch, said = {}, [] - if re.search(r"\blight (mode|theme)\b", low): - patch["mode"] = "light"; said.append("light mode") - elif re.search(r"\bdark (mode|theme)\b", low): - patch["mode"] = "dark"; said.append("dark mode") - if re.search(r"as a list|list (view|layout)|not cards", low): - patch["layout"] = "list"; said.append("list layout") - elif re.search(r"grid (view|layout)|as cards", low): - patch["layout"] = "grid"; said.append("grid layout") - if re.search(r"chat.*\bleft\b", low): - patch["chat"] = {"position": "left"}; said.append("chat on the left") - elif re.search(r"chat.*\bright\b", low): - patch["chat"] = {"position": "right"}; said.append("chat on the right") - if re.search(r"(no|stop|turn off|disable) animation", low): - patch["animations"] = False; said.append("animations off") - if not patch: - return None - return {"reply": "Done: " + ", ".join(said) + ".", "actions": [{"type": "set_theme", "payload": patch}]} - - -def fallback_agent(store, message: str, history, actor: str = "demo-buyer"): +def fallback_agent(store, message: str, history, actor: str = "demo-buyer", look=None): m = (message or "").strip() low = m.lower() actions = [] reply = "" - th = fallback_theme(low) + from .theme import parse_look_command + th = parse_look_command(m, look if isinstance(look, dict) else {}) if th: - return th + return {"reply": th[0], "actions": [{"type": "set_theme", "payload": th[1]}]} # welcome / help if not m or low in ("hi", "hello", "hey", "help", "start"): @@ -458,6 +430,7 @@ _pool = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix= COOLDOWN_429 = float(os.environ.get("AGENT_MARKET_LLM_COOLDOWN", "60")) _cooldown = {} # model -> unix time until which we skip it after a 429 LAST = {"model": None, "at": None, "error": None} +QUOTA = {"until": 0} class LLMError(Exception): @@ -510,12 +483,15 @@ def _call_llm(cfg, model, messages, json_mode=True): with urllib.request.urlopen(req, timeout=LLM_TIMEOUT) as r: data = json.loads(r.read().decode()) except urllib.error.HTTPError as e: - body = e.read().decode(errors="replace")[:300] + body = e.read().decode(errors="replace")[:2000] if e.code == 429: - raise LLMError("rate_limited", body) + m = re.search(r'"X-RateLimit-Reset":\s*"?(\d{12,})', body) + if "per-day" in body or "daily" in body: + raise LLMError("daily_quota", m.group(1) if m else "") + raise LLMError("rate_limited", body[:300]) if e.code == 400 and json_mode and "response_format" in body: return _call_llm(cfg, model, messages, json_mode=False) - raise LLMError(f"http_{e.code}", body) + raise LLMError(f"http_{e.code}", body[:300]) except (urllib.error.URLError, TimeoutError, OSError) as e: raise LLMError("timeout_or_network", type(e).__name__) if isinstance(data, dict) and data.get("error"): @@ -583,6 +559,8 @@ def llm_agent(store, message, history, cfg, look=""): """Try each model in the chain; skip models cooling down after a 429. Raises if all fail.""" errors = [] now_t = time.time() + if QUOTA["until"] > now_t: + raise LLMError("all_models_failed", json.dumps([{"model": "*", "error": "daily_quota", "ms": 0}])) chain = [m for m in cfg["models"] if _cooldown.get(m, 0) <= now_t] or cfg["models"][-1:] start = time.time() for model in chain: @@ -604,6 +582,15 @@ def llm_agent(store, message, history, cfg, look=""): except LLMError as e: if e.kind == "rate_limited": _cooldown[model] = time.time() + COOLDOWN_429 + elif e.kind == "daily_quota": + # the free-tier daily cap is per key: park every model until the reset (or 15 min if unknown) + d = str(e).split(": ", 1)[-1] + until = int(d) / 1000 if d.isdigit() else time.time() + 900 + for mm in cfg["models"]: + _cooldown[mm] = max(_cooldown.get(mm, 0), until) + QUOTA["until"] = until + errors.append({"model": model, "error": e.kind, "ms": int((time.time() - t0) * 1000)}) + break errors.append({"model": model, "error": e.kind, "ms": int((time.time() - t0) * 1000)}) except Exception as e: # never let the page break errors.append({"model": model, "error": type(e).__name__, "ms": int((time.time() - t0) * 1000)}) @@ -620,6 +607,10 @@ LLM_EVENTS = os.environ.get("AGENT_MARKET_LLM_EVENTS") == "1" def handle_chat(store, message: str, history=None, event=None, actor="demo-buyer", look=""): actor = (actor or "demo-buyer")[:60] t0 = time.time() + try: + look_d = json.loads(look) if look else {} + except ValueError: + look_d = {} if event and (event.get("type") == "landing" or not LLM_EVENTS): if event.get("type") == "landing": out = fallback_agent(store, "show me everything", [], actor) @@ -633,13 +624,13 @@ def handle_chat(store, message: str, history=None, event=None, actor="demo-buyer try: return llm_agent(store, text, history or [], cfg, look) except Exception as e: - out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor) + out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor, look_d) out.update(agent="fallback", llm_error=getattr(e, "kind", type(e).__name__), fallbacks=json.loads(str(e).split(": ", 1)[1]) if getattr(e, "kind", "") == "all_models_failed" else [], latency_ms=int((time.time() - t0) * 1000)) - out["reply"] = "(The AI model is busy right now, so the simple built-in agent answered.) " + out["reply"] + out["reply"] = "(AI model unavailable right now; the built-in agent answered.) " + out["reply"] return out - out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor) + out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor, look_d) out.update(agent="fallback", latency_ms=int((time.time() - t0) * 1000)) return out @@ -650,6 +641,7 @@ def agent_status(): now_t = time.time() return {"mode": "llm", "provider": cfg["provider"], "model": cfg["models"][0], "chain": cfg["models"], "cooling_down": [m for m in cfg["models"] if _cooldown.get(m, 0) > now_t], - "last_model": LAST["model"], "last_error": LAST["error"], "ui_events": "llm" if LLM_EVENTS else "deterministic"} + "last_model": LAST["model"], "last_error": LAST["error"], "ui_events": "llm" if LLM_EVENTS else "deterministic", + "daily_quota_exhausted_until_utc": time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime(QUOTA["until"])) if QUOTA["until"] > now_t else None} return {"mode": "fallback", "provider": "deterministic", "model": None, "note": "No hosted LLM configured (set AGENT_MARKET_LLM_API_KEY_FILE or AGENT_MARKET_LLM_API_KEY, plus AGENT_MARKET_LLM_BASE_URL and AGENT_MARKET_LLM_MODELS). Using the labelled rule-based fallback agent."} diff --git a/market/theme.py b/market/theme.py index df3c07d..dff1502 100644 --- a/market/theme.py +++ b/market/theme.py @@ -278,3 +278,142 @@ def compact_current(theme): t.pop("customCss", None) s = json.dumps(t, separators=(",", ":")) return s if len(s) <= 900 else s[:900] + + +# ---------- rule-based look commands (used when the hosted model is unavailable) ---------- +NAMED = {"red": "#e5484d", "green": "#2fb36d", "blue": "#3d7bfd", "purple": "#8e4ec6", "violet": "#8e4ec6", "pink": "#e93d82", + "orange": "#f76b15", "yellow": "#ffd23f", "teal": "#12a594", "cyan": "#00b8d9", "black": "#0b0b0f", "white": "#ffffff", + "grey": "#8b8d98", "gray": "#8b8d98", "navy": "#1b2a4a", "gold": "#d4a72c", "brown": "#8d5a3b", "beige": "#efe3cf", + "cream": "#fff8e7", "mint": "#9de0c4", "lavender": "#c9b8f0", "coral": "#ff7f6e", "lime": "#9ae600", "magenta": "#d62fd6", + "maroon": "#6d1a2b", "olive": "#7a8c3c", "sky": "#8ecdf7", "peach": "#ffcfa8", "turquoise": "#2ad4c4", "indigo": "#4b4bd6"} +GRADIENTS = {"sunset": ["#ff7e5f", "#feb47b", "#6a3093"], "ocean": ["#0f2027", "#203a43", "#2c5364"], "forest": ["#134e5e", "#71b280"], + "candy": ["#f9a8d4", "#a5b4fc"], "fire": ["#f12711", "#f5af19"], "night": ["#0f0c29", "#302b63", "#24243e"], + "peach": ["#ffecd2", "#fcb69f"], "aurora": ["#00c9a7", "#845ec2", "#0081cf"], "rainbow": ["#ff5f6d", "#ffc371", "#47e891", "#3d7bfd", "#8e4ec6"]} +SECTION_WORDS = {"hero": "hero", "headline": "hero", "title": "hero", "chips": "chips", "quick actions": "chips", "quick buttons": "chips", + "buttons": "chips", "bullets": "bullets", "bullet points": "bullets", "facts": "bullets", "results": "results", + "listings": "results", "footer": "footer"} +_CW = "|".join(sorted(NAMED, key=len, reverse=True)) + + +def parse_look_command(text, current=None): + """Map a plain-English look request to a set_theme payload. Returns (reply, payload) or None.""" + low = (text or "").lower().strip() + cur = current if isinstance(current, dict) else {} + if re.search(r"\breset\b.*\b(look|theme|style|design|page|colou?rs?)\b|\b(default|original) (look|theme)\b|^reset$", low): + return "Done: the page is back to its default look.", {"reset": True} + for name in list(PRESETS) + list(PRESET_ALIASES): + if re.search(r"\b" + re.escape(name) + r"\b", low) and re.search(r"look|theme|style|make it|switch|preset|vibe|feel", low) \ + and not re.search(r"background|gradient", low): + real = PRESET_ALIASES.get(name, name) + return f"Applied the {real} look. Say “reset the look” to go back.", {"preset": real} + patch, said = {}, [] + m = re.search(r"(headline|title|heading)\s+(?:to|say|says|reads?|:)\s+[\"“']?(.+?)[\"”']?$", text or "", re.I) + if m: + patch.setdefault("copy", {})["headline"] = m.group(2).strip()[:120]; said.append("new headline") + m = re.search(r"(subtitle|subhead|tagline)\s+(?:to|:)\s+[\"“']?(.+?)[\"”']?$", text or "", re.I) + if m: + patch.setdefault("copy", {})["subhead"] = m.group(2).strip()[:240]; said.append("new subtitle") + m = re.search(r"(logo|site name|brand(?: name)?)\s+(?:to|:)\s+[\"“']?(.+?)[\"”']?$", text or "", re.I) + if m: + patch.setdefault("copy", {})["logo"] = m.group(2).strip()[:40]; said.append("new logo text") + if patch.get("copy"): + return "Done: " + ", ".join(said) + ".", patch + if re.search(r"\blight (mode|theme)\b|\bmake it light\b", low): + patch["mode"] = "light"; said.append("light mode") + elif re.search(r"\bdark (mode|theme)\b|\bmake it dark\b", low): + patch["mode"] = "dark"; said.append("dark mode") + if "background" in low or "gradient" in low or re.search(r"\b(dots|polka|stripes|checks|checkered|grid pattern|waves)\b", low): + g = next((k for k in GRADIENTS if k in low), None) + cols = re.findall(r"\b(" + _CW + r")\b", low) + pat = next((p for k, p in (("polka", "dots"), ("dot", "dots"), ("stripe", "stripes"), ("check", "checks"), + ("diagonal", "diagonal"), ("wave", "waves"), ("grid", "grid")) if k in low), None) + if g: + patch["background"] = {"kind": "gradient", "colors": GRADIENTS[g], "angle": 180}; said.append(f"{g} gradient background") + elif pat and "background" in low or pat and "pattern" in low: + c = [NAMED[x] for x in cols[:2]] or ["#1d212a", "#2a2f3a"] + patch["background"] = {"kind": "pattern", "pattern": pat, "colors": c if len(c) > 1 else [c[0], "#ffffff22"]}; said.append(f"{pat} pattern background") + elif "gradient" in low and cols: + c = [NAMED[x] for x in cols[:4]] + patch["background"] = {"kind": "gradient", "colors": c if len(c) > 1 else [c[0], "#000000"], "angle": 135}; said.append("gradient background") + elif cols: + patch["background"] = {"kind": "solid", "colors": [NAMED[cols[0]]]}; said.append(f"{cols[0]} background") + m = re.search(r"\b(" + _CW + r")\s+accent|accent(?: colou?r)?\s+(?:to\s+)?(" + _CW + r")\b", low) + if m: + c = m.group(1) or m.group(2); patch.setdefault("palette", {})["accent"] = NAMED[c]; said.append(f"{c} accent") + m = re.search(r"\b(" + _CW + r")\s+text\b|\btext\s+(?:colou?r\s+)?(?:to\s+)?(" + _CW + r")\b", low) + if m: + c = m.group(1) or m.group(2); patch.setdefault("palette", {})["text"] = NAMED[c]; said.append(f"{c} text") + for name in sorted(FONTS, key=len, reverse=True): + if name.lower() in low and name != "system": + patch["font"] = name; said.append(f"{name} font"); break + else: + for alias, name in (("monospace", "mono"), ("serif", "serif"), ("handwrit", "Pacifico"), ("script", "Pacifico"), + ("pixel", "Press Start 2P"), ("comic", "Comic Neue"), ("typewriter", "Space Mono"), ("sans", "Inter")): + if alias in low and "font" in low and not ("sans" in low and alias == "serif"): + patch["font"] = name; said.append(f"{name} font"); break + scale = float(cur.get("fontScale") or 1) + if re.search(r"(bigger|larger|increase|bump up).{0,12}(text|font)|(text|font).{0,12}(bigger|larger)", low): + patch["fontScale"] = round(min(1.75, scale + 0.15), 2); said.append(f"text size {patch['fontScale']}×") + elif re.search(r"(smaller|decrease|reduce).{0,12}(text|font)|(text|font).{0,12}smaller", low): + patch["fontScale"] = round(max(0.75, scale - 0.15), 2); said.append(f"text size {patch['fontScale']}×") + if re.search(r"sharp|square corners|no rounding", low): + patch["radius"] = 0; said.append("sharp corners") + elif re.search(r"pill|very rounded|super rounded", low): + patch["radius"] = 30; said.append("pill corners") + elif re.search(r"rounded|round corners|softer corners", low): + patch["radius"] = 22; said.append("rounded corners") + if re.search(r"compact|denser|tighter|less spac", low): + patch["density"] = "compact"; said.append("compact spacing") + elif re.search(r"spacious|airy|more space|roomier|comfy", low): + patch["density"] = "comfy"; said.append("roomier spacing") + for k, v in (("flat", "flat"), ("outlined", "outlined"), ("outline", "outlined"), ("shadow", "elevated"), ("elevated", "elevated"), ("glass", "glass")): + if re.search(r"\b" + k + r"\b", low) and ("card" in low or "style" in low): + patch["cardStyle"] = v; said.append(f"{v} cards"); break + if re.search(r"as a list|list (view|layout)|not cards|as rows", low): + patch["layout"] = "list"; said.append("list layout") + elif re.search(r"grid (view|layout)|as cards|as a grid", low): + patch["layout"] = "grid"; said.append("grid layout") + m = re.search(r"\b([1-6]|one|two|three|four|five|six)\s+columns?\b", low) + if m: + n = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6}.get(m.group(1)) or int(m.group(1)) + patch["columns"] = n; patch.setdefault("layout", "grid"); said.append(f"{n} columns") + if "chat" in low: + ch = {} + if re.search(r"\bleft\b", low): + ch["position"] = "left" + elif re.search(r"\bright\b", low): + ch["position"] = "right" + if re.search(r"(bigger|larger|wider|taller)", low): + ch.update(width=480, height=680) + elif re.search(r"(smaller|narrower)", low): + ch.update(width=320, height=420) + if ch: + patch["chat"] = ch; said.append("chat " + ", ".join(f"{k} {v}" for k, v in ch.items())) + if re.search(r"(no|stop|turn off|disable|without) animations?|animations? off", low): + patch["animations"] = False; said.append("animations off") + elif re.search(r"(turn on|enable) animations?|animations? on", low): + patch["animations"] = True; said.append("animations on") + hide = re.search(r"\b(hide|remove|get rid of)\b(.+)", low) + show = re.search(r"\b(show|unhide|bring back)\b(.+)", low) + hidden = list((cur.get("sections") or {}).get("hidden") or []) + for verb, mm in (("hide", hide), ("show", show)): + if not mm: + continue + for w, sec in SECTION_WORDS.items(): + if re.search(r"\b" + w + r"\b", mm.group(2)): + if verb == "hide" and sec not in hidden: + hidden.append(sec); said.append(f"{sec} hidden"); patch["sections"] = {"hidden": hidden} + elif verb == "show" and sec in hidden: + hidden.remove(sec); said.append(f"{sec} shown"); patch["sections"] = {"hidden": hidden} + break + m = re.search(r"move (?:the )?(\w+(?: \w+)?) to the (top|bottom)", low) + if m and "chat" not in m.group(1): + sec = next((s for w, s in SECTION_WORDS.items() if w in m.group(1)), None) + if sec: + order = [x for x in ((cur.get("sections") or {}).get("order") or SECTIONS) if x != sec] + order = [sec] + order if m.group(2) == "top" else order + [sec] + patch.setdefault("sections", {})["order"] = order; said.append(f"{sec} moved to the {m.group(2)}") + patch = sanitize_patch(patch) + if not patch: + return None + return "Done: " + ", ".join(said) + ".", patch diff --git a/static/app.js b/static/app.js index 5c6e718..2ee48c8 100644 --- a/static/app.js +++ b/static/app.js @@ -51,7 +51,10 @@ history.push({ role: "assistant", content: out.reply || "" }); say("bot", out.reply || "(no reply)"); if (out.agent === "llm") setModel(out.model, out.latency_ms); - else if (out.agent === "fallback") $("#chatMode").textContent = "built-in fallback agent answered (AI model busy)"; + else if (out.agent === "fallback") { + $("#chatMode").textContent = "built-in agent answered (AI model unavailable)"; + $("#agentBadge").textContent = "agent: built-in (AI unavailable)"; $("#agentBadge").title = "The hosted model is rate-limited or out of free quota"; + } else if (out.agent === "rate-limit") toast(out.reply, "warn"); render(out.actions || []); } catch (e) { thinking.remove(); say("sys", "Agent error: " + e.message); } @@ -222,7 +225,8 @@ $("#resetLook").addEventListener("click", () => { AMTheme.reset(); toast("Look reset to default", "ok"); }); api("/api/status").then((s) => { feeBps = s.fee_bps; AMTheme.init(copyDefaults(feeBps)); - if (s.agent.mode === "llm") setModel(s.agent.last_model || s.agent.model); + if (s.agent.mode === "llm" && s.agent.daily_quota_exhausted_until_utc) { $("#agentBadge").textContent = "agent: built-in (AI quota used up)"; $("#agentBadge").title = "Hosted model free quota resets " + s.agent.daily_quota_exhausted_until_utc; } + else if (s.agent.mode === "llm") setModel(s.agent.last_model || s.agent.model); else { $("#agentBadge").textContent = "agent: rule-based fallback"; $("#agentBadge").title = s.agent.note || ""; } }); // Landing: show featured listings via the agent. diff --git a/tests/e2e_http.py b/tests/e2e_http.py index 2140aa2..869ff79 100755 --- a/tests/e2e_http.py +++ b/tests/e2e_http.py @@ -40,7 +40,11 @@ s, out = call("POST", "/api/chat", {"message": "show me bikes"}) cards = [a for a in out["actions"] if a["type"] == "show_cards"] titles = [l["title"] for l in cards[0]["payload"]["listings"]] if cards else [] check("agent search renders cards (hydrated listings)", cards and any("bike" in t.lower() for t in titles), ", ".join(titles)) -bike = next(l for l in cards[0]["payload"]["listings"] if l["kind"] == "product" and "online" in l["payment_options"]) +bike = next((l for l in cards[0]["payload"]["listings"] if l["kind"] == "product" and "online" in l["payment_options"]), None) if cards else None +if not bike: # earlier runs may have sold the seed bike; list a fresh test-only one + s, r = call("POST", "/api/actions", {"action": "create_listing", "args": {"title": "Test-only commuter bike", "kind": "product", "price": 18000, + "description": "Fake bike for automated tests", "payment_options": ["online", "cash"]}}, {"X-Agent-Id": "test-seller-e2e"}) + bike = r["listing"] s, out = call("POST", "/api/chat", {"message": "I want to sell something"}) check("agent opens listing form", any(a["type"] == "show_listing_form" for a in out["actions"])) @@ -73,7 +77,12 @@ check("forged webhook rejected", s == 401) # Cash in person: zero fee. s, out = call("POST", "/api/chat", {"message": "show lamp"}) -lamp = [l for a in out["actions"] if a["type"] == "show_cards" for l in a["payload"]["listings"] if "cash" in l["payment_options"]][0] +lamps = [l for a in out["actions"] if a["type"] == "show_cards" for l in a["payload"]["listings"] if "cash" in l["payment_options"] and l["kind"] == "product"] +if not lamps: + s, r = call("POST", "/api/actions", {"action": "create_listing", "args": {"title": "Test-only lamp", "kind": "product", "price": 3000, + "description": "Fake lamp for automated tests", "payment_options": ["online", "cash"]}}, {"X-Agent-Id": "test-seller-e2e"}) + lamps = [r["listing"]] +lamp = lamps[0] s, out = call("POST", "/api/chat", {"event": {"type": "buy_cash", "listing_id": lamp["id"]}, "actor": "guest-e2e"}) cm = [a for a in out["actions"] if a["type"] == "show_cash_meetup"] deal = cm[0]["payload"]["deal"] if cm else {} diff --git a/tests/llm_proxy_check.sh b/tests/llm_proxy_check.sh index c4a6d34..2eda74b 100755 --- a/tests/llm_proxy_check.sh +++ b/tests/llm_proxy_check.sh @@ -8,8 +8,8 @@ python3 tests/mock_llm.py 18798 & M=$! start() { # port models rpm AGENT_MARKET_PORT=$1 AGENT_MARKET_DATA=$D/d$1 AGENT_MARKET_LLM_API_KEY_FILE=$KEYF AGENT_MARKET_CHAT_RPM=$3 AGENT_MARKET_CHAT_BURST=3 \ AGENT_MARKET_LLM_BASE_URL=http://127.0.0.1:18798/v1 AGENT_MARKET_LLM_MODELS=$2 python3 server.py >/dev/null 2>&1 & echo $!; } -S1=$(start 18799 busy-model,mock-model 0); S2=$(start 18800 busy-model,busy-model-2 0); S3=$(start 18801 mock-model 2) -trap 'kill $M $S1 $S2 $S3 2>/dev/null; rm -rf $D' EXIT +S1=$(start 18799 busy-model,mock-model 0); S2=$(start 18800 busy-model,busy-model-2 0); S3=$(start 18801 mock-model 2); S4=$(start 18802 daily-model,mock-model 0) +trap 'kill $M $S1 $S2 $S3 $S4 2>/dev/null; rm -rf $D' EXIT sleep 1.5 chat() { curl -s localhost:$1/api/chat -H 'Content-Type: application/json' -d '{"message":"show me bikes"}'; } OUT=$(chat 18799) @@ -34,3 +34,16 @@ OTHER=$(curl -s -o /dev/null -w '%{http_code}' localhost:18801/api/chat -H 'Cont EV=$(curl -s -o /dev/null -w '%{http_code}' localhost:18801/api/chat -H 'Content-Type: application/json' -H 'CF-Connecting-IP: 203.0.113.9' -d '{"event":{"type":"landing"}}') [[ "$EV" == "200" ]] && echo "PASS UI events (no LLM call) not rate-limited" || { echo "FAIL event limited: $EV"; exit 1; } [[ "$CODES" == "200 200 200 429 429 " && "$OTHER" == "200" ]] && echo "PASS per-IP rate limit (burst 3: $CODES; other IP $OTHER)" || { echo "FAIL rate limit: $CODES / $OTHER"; exit 1; } +curl -s localhost:18799/api/chat -H 'Content-Type: application/json' -d '{"message":"make the background a sunset gradient"}' | python3 -c ' +import json,sys; d=json.load(sys.stdin); a=d["actions"][0]; p=a["payload"]["patch"] +assert a["type"]=="set_theme" and p["background"]["colors"]==["#ff7e5f","#feb47b"], a +assert "@import" not in p["customCss"] and "position" not in p["customCss"] and "border-width:3px" in p["customCss"], p +assert "<" not in p["copy"]["headline"] and "lst_" not in d["reply"], (p, d["reply"]) +print("PASS model set_theme is validated (bad colour, @import, position, HTML dropped; ids scrubbed from reply)")' +curl -s localhost:18802/api/chat -H 'Content-Type: application/json' -d '{"message":"show me bikes"}' >/dev/null +curl -s localhost:18802/api/chat -H 'Content-Type: application/json' -d '{"message":"switch to light mode with a green accent"}' | python3 -c ' +import json,sys; d=json.load(sys.stdin) +assert d["agent"]=="fallback" and d["actions"][0]["type"]=="set_theme" and d["actions"][0]["payload"]["patch"]["mode"]=="light", d +print("PASS daily quota -> whole chain parked, built-in agent still restyles:", d["reply"][:80])' +curl -s localhost:18802/api/status | python3 -c ' +import json,sys; a=json.load(sys.stdin)["agent"]; assert a["daily_quota_exhausted_until_utc"], a; print("PASS status reports quota reset", a["daily_quota_exhausted_until_utc"])' diff --git a/tests/mock_llm.py b/tests/mock_llm.py index 4e3a48d..5828730 100755 --- a/tests/mock_llm.py +++ b/tests/mock_llm.py @@ -18,7 +18,20 @@ class H(BaseHTTPRequestHandler): return if self.headers.get("Authorization") == "Bearer test-dummy-key": SEEN["auth_ok"] += 1 + if body.get("model") == "daily-model": + b = json.dumps({"error": {"message": "Rate limit exceeded: free-models-per-day.", "code": 429, + "metadata": {"headers": {"X-RateLimit-Limit": "50", "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": "4102444800000"}}}}).encode() + self.send_response(429); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(b) + return last = body["messages"][-1]["content"] + if "background" in last: + out = {"reply": "Done! Sunset gradient applied (lst_deadbeef).", "actions": [{"type": "set_theme", "payload": { + "background": {"kind": "gradient", "colors": ["#ff7e5f", "#feb47b", "javascript:alert(1)"], "angle": 180}, + "palette": {"accent": "orange"}, "customCss": "@import url(https://evil.example/x.css); .card{border-width:3px;position:fixed}", + "copy": {"headline": "Sunset"}}}]} + b = json.dumps({"choices": [{"message": {"role": "assistant", "content": json.dumps(out)}}]}).encode() + self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(b) + return if last.startswith("TOOL_RESULTS"): ids = re.findall(r'"id": "(lst_[a-f0-9]+)"', last)[:3] out = {"reply": "Here are bikes I found (mock LLM). Payments are TEST MODE ONLY.", diff --git a/tests/theme_check.js b/tests/theme_check.js index 8a35283..eb972fb 100644 --- a/tests/theme_check.js +++ b/tests/theme_check.js @@ -115,7 +115,9 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); check("reset the look", Object.keys(t5).length === 0 && /just talk to/.test(h2), JSON.stringify(t5)); check("no internal ids in replies", replies.every((x) => !/\b(lst|pi|cash|ord)_[0-9a-f]{6,}/.test(x.reply))); - check("all restyles answered by the real model", replies.every((x) => /AI agent/.test(x.mode)), [...new Set(replies.map((x) => x.mode.split(" · ")[1]))].join(",")); + const who = [...new Set(replies.map((x) => (/AI agent/.test(x.mode) ? x.mode.split(" · ")[1] : "built-in agent")))].join(", "); + if (process.env.REQUIRE_LLM) check("all restyles answered by the real model", replies.every((x) => /AI agent/.test(x.mode)), who); + else console.log("answered by: " + who); check("no page JS errors", errors.length === 0, errors.join("; ")); const ms = replies.map((x) => x.ms).sort((a, b) => a - b); console.log(`\nmodel replies: median ${(ms[ms.length >> 1] / 1000).toFixed(1)}s, max ${(ms[ms.length - 1] / 1000).toFixed(1)}s`); From f91dd9df432acf28c32344cbcbcf0a67d79ebc0e Mon Sep 17 00:00:00 2001 From: leora Date: Thu, 8 Oct 2026 19:39:41 +0800 Subject: [PATCH 3/6] Keep page-level text readable on gradients/patterns; test fixes --- static/styles.css | 10 +++++----- static/theme.js | 7 +++++++ tests/browser_e2e.js | 1 + tests/theme_check.js | 11 ++++++++--- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/static/styles.css b/static/styles.css index aa53bfe..c279edd 100644 --- a/static/styles.css +++ b/static/styles.css @@ -6,19 +6,19 @@ :root{--bg:#0f1115;--page-bg:#0f1115;--panel:#171a21;--card:#1d212a;--ink:#e9ecf1;--mute:#9aa3b2;--acc:#7c9cff;--acc-ink:#0b0e16;--line:#2a2f3a;--ok:#38c172;--warn:#f6ad55; --font:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;--hfont:var(--font);--scale:1;--radius:14px;--space:1;--cols:auto-fill; --grid-cols:repeat(auto-fill,minmax(220px,1fr));--chat-bg:var(--panel);--chat-ink:var(--ink);--chat-acc:var(--acc);--chat-acc-ink:var(--acc-ink);--chat-w:370px;--chat-h:520px; - --p-bg:#171a21;--p-ink:#ffffff} + --p-bg:#171a21;--p-ink:#ffffff;--page-ink:var(--ink);--page-mute:var(--mute)} *{box-sizing:border-box} body{margin:0;font-family:var(--font);font-size:calc(15px * var(--scale));line-height:1.5;background:var(--page-bg);background-attachment:fixed;color:var(--ink);min-height:100vh} h1,h2,h3{font-family:var(--hfont)} a{color:var(--acc)}button{font:inherit;cursor:pointer} .top{display:flex;justify-content:space-between;align-items:center;padding:calc(14px * var(--space)) 28px;border-bottom:1px solid var(--line)} -.brand{font-weight:700;font-size:1.2em}.logo{color:var(--acc)} +.top,.top .link{color:var(--page-ink)}.brand{font-weight:700;font-size:1.2em}.logo{color:var(--acc)} nav{display:flex;gap:14px;align-items:center}.link{background:none;border:0;color:var(--ink);text-decoration:none;padding:4px} main{max-width:1100px;margin:0 auto;padding:calc(28px * var(--space)) 28px;display:flex;flex-direction:column;gap:calc(14px * var(--space))} -.hero-sec h1{font-size:2.65em;line-height:1.1;margin:calc(20px * var(--space)) 0 10px}.hero-sec p{color:var(--mute);max-width:660px;margin:0} +.hero-sec h1{font-size:2.65em;line-height:1.1;margin:calc(20px * var(--space)) 0 10px;color:var(--page-ink)}.hero-sec p{color:var(--page-mute);max-width:660px;margin:0} .chips{display:flex;flex-wrap:wrap;gap:10px}.chip{background:var(--card);border:1px solid var(--line);color:var(--ink);padding:8px 14px;border-radius:999px} -.chip:hover{border-color:var(--acc)}.facts{color:var(--mute);padding-left:18px;margin:0} -.foot{color:var(--mute);font-size:.85em;border-top:1px solid var(--line);padding-top:calc(14px * var(--space));margin-top:calc(20px * var(--space))} +.chip:hover{border-color:var(--acc)}.facts{color:var(--page-mute);padding-left:18px;margin:0} +.foot{color:var(--page-mute);font-size:.85em;border-top:1px solid var(--line);padding-top:calc(14px * var(--space));margin-top:calc(20px * var(--space))} .is-hidden{display:none !important} #stage{display:flex;flex-direction:column;gap:calc(18px * var(--space))} #stage:empty{display:none} diff --git a/static/theme.js b/static/theme.js index e7203c4..8ae9f01 100644 --- a/static/theme.js +++ b/static/theme.js @@ -99,6 +99,13 @@ set("--space", String({ compact: 0.65, normal: 1, comfy: 1.4 }[t.density] || 1)); const cols = Math.min(6, Math.max(0, parseInt(t.columns) || 0)); set("--grid-cols", cols ? `repeat(${cols}, minmax(0, 1fr))` : "repeat(auto-fill, minmax(220px, 1fr))"); + // text that sits directly on the page background (header, hero, bullets, footer) must stay readable on + // gradients/patterns/images too: keep the theme colour only if it clears 4.5:1 against every background stop + const stops = (isObj(t.background) && Array.isArray(t.background.colors) ? t.background.colors.filter(okColor) : []); + const bgStops = stops.length ? stops.map(solid) : [solid(pal.bg)]; + const minC = (ink) => Math.min(...bgStops.map((b) => contrast(ink, b))); + const pageInk = minC(pal.text) >= 4.5 ? pal.text : (minC("#111111") >= minC("#ffffff") ? "#111111" : "#ffffff"); + set("--page-ink", pageInk); set("--page-mute", minC(pal.muted) >= 4.5 ? pal.muted : pageInk); // protected panels: solid surface colour + whichever of black/white reads best (always >= 4.5:1) const pbg = solid(pal.surface); set("--p-bg", pbg); set("--p-ink", bestInk(pbg)); // chat popup diff --git a/tests/browser_e2e.js b/tests/browser_e2e.js index fc5c0a6..6c22591 100644 --- a/tests/browser_e2e.js +++ b/tests/browser_e2e.js @@ -70,6 +70,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); await page.click("nav [data-event=sell]"); await page.waitForSelector("[data-component=listing-form]", { timeout: 15000 }); await page.type("[name=title]", "Test-only bookshelf"); + await page.$eval("[name=price]", (n) => (n.value = "")); await page.type("[name=price]", "30"); await page.type("[name=description]", "Fake listing created by the browser test"); await page.click("[data-component=listing-form] button[type=submit]"); diff --git a/tests/theme_check.js b/tests/theme_check.js index eb972fb..e2db54b 100644 --- a/tests/theme_check.js +++ b/tests/theme_check.js @@ -65,8 +65,13 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); await ask("make the background a sunset gradient"); const bg1 = await cs("body", "backgroundImage"); check("sunset gradient background", /gradient/.test(bg1), bg1.slice(0, 90)); - if (SHOTS) await page.screenshot({ path: SHOTS + "/6-theme-sunset-gradient.png" }); + if (SHOTS) { await page.evaluate(() => window.scrollTo(0, 0)); await sleep(3800); await page.screenshot({ path: SHOTS + "/6-theme-sunset-gradient.png" }); } await guardrails("sunset gradient"); + const heroC = await page.evaluate(() => { + const t = JSON.parse(localStorage.getItem("am_theme_v1") || "{}"); const stops = ((t.background || {}).colors || []); + return ["headline", "subhead"].map((id) => Math.min(...stops.map((c) => window.__amContrast(getComputedStyle(document.getElementById(id)).color, c)))); + }); + check("hero text readable on the gradient (>= 4.5:1 vs every stop)", heroC.every((c) => c >= 4.5), heroC.map((c) => c.toFixed(1) + ":1").join(", ")); await ask("show me everything"); await ask("switch to light mode with a green accent"); @@ -74,7 +79,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const acc = await page.evaluate(() => { const s = document.createElement("i"); s.style.color = getComputedStyle(document.documentElement).getPropertyValue("--acc"); document.body.append(s); const c = getComputedStyle(s).color; s.remove(); return c; }); const [r, g, b] = acc.match(/\d+/g).map(Number); check("light mode with green accent", t2.mode === "light" && g > r && g > b, `mode=${t2.mode} accent=${acc}`); - if (SHOTS) await page.screenshot({ path: SHOTS + "/7-theme-light-green.png" }); + if (SHOTS) { await page.evaluate(() => window.scrollTo(0, 0)); await sleep(3800); await page.screenshot({ path: SHOTS + "/7-theme-light-green.png" }); } await guardrails("light + green"); await ask("use a serif font and make the text bigger"); @@ -93,7 +98,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); await ask("make it look retro"); const ff2 = await cs("body", "fontFamily"); const t4 = await theme(); check("retro preset applied", /VT323|Press Start|Space Mono|mono|Courier/i.test(ff2) || /retro/i.test(JSON.stringify(t4)), ff2.slice(0, 50)); - if (SHOTS) { await ask("show me everything"); await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: SHOTS + "/8-theme-retro.png" }); } + if (SHOTS) { await ask("show me everything"); await page.evaluate(() => window.scrollTo(0, 0)); await sleep(3800); await page.screenshot({ path: SHOTS + "/8-theme-retro.png" }); } await guardrails("retro"); await ask("hide the bullet points"); From 9853c39ef0b42f8586e8292ea6115955224bc337 Mon Sep 17 00:00:00 2001 From: leora Date: Thu, 8 Oct 2026 19:40:24 +0800 Subject: [PATCH 4/6] Readability scrim behind page text on high-variance gradients --- static/styles.css | 8 +++++++- static/theme.js | 8 ++++++-- tests/browser_e2e.js | 2 +- tests/theme_check.js | 3 ++- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/static/styles.css b/static/styles.css index c279edd..908de7c 100644 --- a/static/styles.css +++ b/static/styles.css @@ -62,7 +62,13 @@ body.chat-left .cmpbar{left:auto;right:22px} #toasts{position:fixed;left:50%;top:50px;transform:translateX(-50%);display:flex;flex-direction:column;gap:6px;z-index:9} .toast{background:var(--card);color:var(--ink);border:1px solid var(--line);padding:8px 14px;border-radius:8px}.toast.ok{border-color:var(--ok)}.toast.warn{border-color:var(--warn)} .cmpbar{position:fixed;left:22px;bottom:22px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:8px 12px;display:none;gap:8px;align-items:center} -body.no-anim *,body.no-anim *::before,body.no-anim *::after{animation:none !important;transition:none !important} +body.scrim .hero-sec,body.scrim .facts,body.scrim .foot,body.scrim .top{background:var(--scrim);border-radius:var(--radius);padding:calc(10px * var(--space)) 16px} +body.scrim .facts{padding-left:34px}body.scrim .top{border-radius:0} +body.no-anim *,body.scrim .hero-sec,body.scrim .facts,body.scrim .foot,body.scrim .top{background:var(--scrim);border-radius:var(--radius);padding:calc(10px * var(--space)) 16px} +body.scrim .facts{padding-left:34px}body.scrim .top{border-radius:0} +body.no-anim *::before,body.scrim .hero-sec,body.scrim .facts,body.scrim .foot,body.scrim .top{background:var(--scrim);border-radius:var(--radius);padding:calc(10px * var(--space)) 16px} +body.scrim .facts{padding-left:34px}body.scrim .top{border-radius:0} +body.no-anim *::after{animation:none !important;transition:none !important} @media(max-width:640px){.hero-sec h1{font-size:1.9em}.form{grid-template-columns:1fr}.top{padding:12px}main{padding:16px}} } diff --git a/static/theme.js b/static/theme.js index 8ae9f01..4c6ef6c 100644 --- a/static/theme.js +++ b/static/theme.js @@ -104,8 +104,12 @@ const stops = (isObj(t.background) && Array.isArray(t.background.colors) ? t.background.colors.filter(okColor) : []); const bgStops = stops.length ? stops.map(solid) : [solid(pal.bg)]; const minC = (ink) => Math.min(...bgStops.map((b) => contrast(ink, b))); - const pageInk = minC(pal.text) >= 4.5 ? pal.text : (minC("#111111") >= minC("#ffffff") ? "#111111" : "#ffffff"); - set("--page-ink", pageInk); set("--page-mute", minC(pal.muted) >= 4.5 ? pal.muted : pageInk); + let pageInk = minC(pal.text) >= 4.5 ? pal.text : (minC("#111111") >= minC("#ffffff") ? "#111111" : "#ffffff"); + // busy gradients (light and dark stops) can't be fixed by text colour alone: put a readability scrim behind page text + const scrim = minC(pageInk) < 4.5; + body.classList.toggle("scrim", scrim); + if (scrim) { set("--scrim", solid(pal.surface)); pageInk = bestInk(solid(pal.surface)); } + set("--page-ink", pageInk); set("--page-mute", !scrim && minC(pal.muted) >= 4.5 ? pal.muted : pageInk); // protected panels: solid surface colour + whichever of black/white reads best (always >= 4.5:1) const pbg = solid(pal.surface); set("--p-bg", pbg); set("--p-ink", bestInk(pbg)); // chat popup diff --git a/tests/browser_e2e.js b/tests/browser_e2e.js index 6c22591..392bee8 100644 --- a/tests/browser_e2e.js +++ b/tests/browser_e2e.js @@ -55,7 +55,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); check("test payment confirms, receipt shows fee", rfee === money(feeC), rfee); // cash meetup: first card offering cash - await ask("show me everything that I can pay for in cash"); + await ask("show me everything"); const cashId = await page.evaluate(() => { const c = [...document.querySelectorAll("#stage .card")].find((c) => c.querySelector("button.cash")); return c && c.dataset.listing; }); await page.click(`.card[data-listing="${cashId}"] button.cash`); await page.waitForSelector("[data-component=cash-meetup]", { timeout: 15000 }); diff --git a/tests/theme_check.js b/tests/theme_check.js index e2db54b..31e34e3 100644 --- a/tests/theme_check.js +++ b/tests/theme_check.js @@ -69,7 +69,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); await guardrails("sunset gradient"); const heroC = await page.evaluate(() => { const t = JSON.parse(localStorage.getItem("am_theme_v1") || "{}"); const stops = ((t.background || {}).colors || []); - return ["headline", "subhead"].map((id) => Math.min(...stops.map((c) => window.__amContrast(getComputedStyle(document.getElementById(id)).color, c)))); + const eff = (n) => { while (n && n !== document.body) { const c = getComputedStyle(n).backgroundColor; if (c && !/rgba\(0, 0, 0, 0\)|transparent/.test(c)) return [c]; n = n.parentElement; } return stops; }; + return ["headline", "subhead"].map((id) => { const n = document.getElementById(id); return Math.min(...eff(n).map((c) => window.__amContrast(getComputedStyle(n).color, c))); }); }); check("hero text readable on the gradient (>= 4.5:1 vs every stop)", heroC.every((c) => c >= 4.5), heroC.map((c) => c.toFixed(1) + ":1").join(", ")); await ask("show me everything"); From 2fdd46af9dadc1d5790a9f5de17c3279252fbd95 Mon Sep 17 00:00:00 2001 From: leora Date: Thu, 8 Oct 2026 19:41:14 +0800 Subject: [PATCH 5/6] tests: accept built-in badge --- tests/browser_e2e.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/browser_e2e.js b/tests/browser_e2e.js index 392bee8..bb2d09c 100644 --- a/tests/browser_e2e.js +++ b/tests/browser_e2e.js @@ -16,7 +16,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); await page.waitForSelector(".card", { timeout: 15000 }); check("landing renders agent-chosen cards", (await page.$$(".card")).length >= 4); const badge = await page.$eval("#agentBadge", (n) => n.textContent); - check("agent mode badge shown", /fallback|AI:|LLM/.test(badge), badge); + check("agent mode badge shown", /fallback|built-in|AI:|LLM/.test(badge), badge); // ask(): type into the chat popup and wait for the agent's reply (LLM replies take a few seconds) const ask = async (text) => { From a47bd648ac0d1f84ae26ad81c596810451c5767e Mon Sep 17 00:00:00 2001 From: leora Date: Thu, 8 Oct 2026 19:41:32 +0800 Subject: [PATCH 6/6] README: look & layout control, guardrails --- README.md | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 88b15b2..f386a0a 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,38 @@ The server stays small on purpose. It does three things: `show_hero`, `show_cards`, `show_listing_form`, `show_compare`, `show_checkout`, `show_cash_meetup`, `show_receipt`, `toast`, `clear_panel`. +### Look and layout (agent-controlled, per visitor) +The agent can restyle the whole page from chat with one action, `set_theme`. Its payload is a JSON theme spec: +design tokens applied as CSS variables. The model never sends raw HTML or JS. The server checks every value +(`market/theme.py`), and the browser applies the result (`static/theme.js`). + +| Area | Spec keys | +|---|---| +| Background | `background.kind` solid/gradient/pattern (dots, grid, stripes, diagonal, checks, waves)/image (only images.unsplash.com, picsum.photos, upload.wikimedia.org), `colors`, `angle` | +| Colours | `mode` light/dark, `palette.{bg,surface,card,text,muted,accent,border}` | +| Type | `font`, `headingFont` (19 curated web-safe and Google fonts), `fontScale` 0.75 to 1.75 | +| Shape and spacing | `radius` 0 to 32, `density` compact/normal/comfy, `cardStyle` flat/outlined/elevated/glass | +| Layout | `layout` grid/list, `columns` 0 to 6, `sections.order` / `sections.hidden` (hero, chips, bullets, results, footer) | +| Copy | `copy.{logo,headline,subhead,footer,chips[],bullets[]}` | +| Chat | `chat.{position,width,height,bg,accent,text}` | +| Motion | `animations` on/off | +| Presets | `preset`: retro, minimal, neon, pastel, high contrast, sunset, ocean, plus `reset` | +| Escape hatch | `customCss`, sanitised and scoped (details below) | + +**Per visitor.** The theme lives in the browser's `localStorage`. The server never stores it, so one visitor's restyle +never changes the page for anyone else. **Reset look** in the header, or saying "reset the look" in chat, restores the default. + +**Guardrails.** +- `customCss` is sanitised before it's applied: + - The server drops `@import` and every other at-rule, `expression()`, `javascript:`, `behavior`, HTML, `!important`, custom properties, and negative margins. + - It neutralises `url()` pointing anywhere outside the image allowlist. + - Only an allowlist of properties survives. Positioning, z-index, `content` and transforms aren't on it. + - Rules are scoped under `#page`, and the browser checks them again. +- The CSS uses cascade layers in the order `base < user < protected`. The TEST DEMO banner, the agent badge, and the checkout, cash and receipt panels (fee lines, totals, the test-card notice and the card inputs) live in `protected`. No theme can hide, recolour or shrink them. +- Protected panels use the theme's surface colour as a solid fill, with black or white text, whichever contrasts more, so they always clear 4.5:1. The banner and badge use fixed high-contrast colours. +- Text sitting directly on the page background switches to readable ink. If no ink clears 4.5:1 against every gradient stop, it gets a scrim behind it. +- If the model is unavailable, the built-in agent understands the common look commands: presets, light/dark, background colours, gradients and patterns, accent and text colour, fonts, bigger or smaller text, corners, density, card style, list/grid, columns, chat position and size, hiding or showing sections, headline, logo and subtitle, animations, and reset. + ### Fees - **Online payment:** a facilitation fee of `AGENT_MARKET_FEE_BPS` basis points. The default is 500, which is 5%. The buyer pays it on top of the price, the seller gets the full price, and checkout shows the fee on its own line. @@ -106,6 +138,8 @@ curl -s $BASE/api/actions -H 'X-Agent-Id: agent:my-bot' -H 'Content-Type: applic python3 tests/e2e_http.py http://127.0.0.1:8795 # HTTP end-to-end (23 checks) bash tests/llm_proxy_check.sh # fake LLM: 429 chain, cooldown, fallback, key handling, rate limit python3 tests/nl_check.py http://127.0.0.1:8795 # real model: 6 natural-language prompts +python3 tests/test_theme.py # theme spec + customCss sanitiser +NODE_PATH=... node tests/theme_check.js BASE ./screenshots # chat restyles, guardrail contrast, fresh session, reset (REQUIRE_LLM=1 to insist on the model) NODE_PATH=/path/to/node_modules node tests/browser_e2e.js http://127.0.0.1:8795 ./screenshots # headless Chrome ``` @@ -113,5 +147,6 @@ NODE_PATH=/path/to/node_modules node tests/browser_e2e.js http://127.0.0.1:8795 - Agent identity (`X-Agent-Id`) isn't verified, so anyone can claim any id. - There's no real messaging between buyer and seller for cash meetups. - The JSON file store is single-process, and rate limits live in memory, so a restart resets them. -- Free OpenRouter models have small daily request caps and get rate-limited upstream. Once those run out, the rule-based agent answers. +- Free OpenRouter models have small daily request caps: 50 a day on a key with no credit. They also get rate-limited upstream. When the daily cap hits, + the server parks the whole model chain until OpenRouter's reset time, and the built-in agent answers, labelled as such. - The mock payment provider is the only one. A real provider would need its own test keys and webhook secret.