Compare commits

...

3 Commits

8 changed files with 417 additions and 16 deletions

View File

@ -65,6 +65,13 @@ never changes the page for anyone else. **Reset look** in the header, or saying
- 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.
- **Page text.** The logo (top-left brand text), headline, subtitle, footer, quick-action buttons and bullets are editable
through `copy`. The built-in agent (`market/copyedit.py`) understands "replace/change/rename/swap A to/with/into B",
locations such as top left, header, logo, brand, site name, title, headline, subtitle and footer, quoted or unquoted text,
and a generic find-and-replace when A is the current text of exactly one slot. It keeps the case you type for B; text typed
all in lowercase takes the style of the text it replaces ("new internet" replacing "Agent Market" gives "New Internet").
When it can't tell what to change, it says what it understood and offers the closest option. The model prompt gets the
same slot map plus the visitor's current page text (`PAGE_TEXT`).
- 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
@ -203,6 +210,7 @@ python3 tests/e2e_http.py http://127.0.0.1:8795 # HTTP end-to-end incl
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
python3 tests/test_copyedit.py # built-in copy edits (logo/headline/... phrasings)
NODE_PATH=... node tests/theme_check.js BASE ./screenshots # chat restyles, guardrail contrast, persistence, hostile stored theme, 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 (14 checks)
NODE_PATH=/path/to/node_modules bash tests/fake_model_browser.sh # React UI vs fake model: badges, fallback text, quota, rate-limit toast, sanitised set_theme

View File

@ -43,7 +43,7 @@ set_theme payload = any subset of this spec (it is merged into the current look)
"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":["..."]},
"copy":{"logo","headline","subhead","footer","chips":[{"label","say"}],"bullets":["..."]}, // page TEXT, see PAGE_TEXT
"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,
@ -55,6 +55,13 @@ set_theme payload = any subset of this spec (it is merged into the current look)
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".
PAGE TEXT is editable with set_theme copy (current values are in PAGE_TEXT below). Where things are:
copy.logo = the brand/site name in the TOP-LEFT corner of the header (a.k.a. logo, brand, site name, header text, "top left").
copy.headline = the big title, copy.subhead = the line under it (subtitle/tagline), copy.footer = the bottom line,
copy.chips = the quick-action buttons, copy.bullets = the bullet points (send the whole list when changing one item).
"replace/change/rename A to/with B": find A in PAGE_TEXT and change that slot (only the words A if A is part of it).
Keep the user's capitalisation for B (if they typed it all lowercase, match the style of the text it replaces).
e.g. "replace agent market to new internet on the top left corner" => {"type":"set_theme","payload":{"copy":{"logo":"New Internet"}}}
Rules:
- Never put internal ids (lst_..., pi_..., cash_..., ord_...) in "reply"; refer to items by their title.
@ -283,7 +290,8 @@ def fallback_agent(store, message: str, history, actor: str = "demo-buyer", look
from .theme import parse_look_command
th = parse_look_command(m, look if isinstance(look, dict) else {})
if th:
return {"reply": th[0], "actions": [{"type": "set_theme", "payload": th[1]}]}
# payload None = understood a copy edit but couldn't apply it; the reply says why (no listings, no silent no-op)
return {"reply": th[0], "actions": [{"type": "set_theme", "payload": th[1]}] if th[1] else []}
# welcome / help
if not m or low in ("hi", "hello", "hey", "help", "start"):
@ -529,9 +537,21 @@ def _catalog(store):
for L in store.all("listings") if L.get("status") == "active"][:60]
def _page_text(look):
from .copyedit import current_copy
try:
t = json.loads(look) if look else {}
except Exception:
t = {}
c = current_copy(t if isinstance(t, dict) else {}, FEE_BPS)
return json.dumps({"logo": c["logo"], "headline": c["headline"], "subhead": c["subhead"][:120], "footer": c["footer"],
"chips": [x.get("label") for x in c["chips"]], "bullets": c["bullets"]}, ensure_ascii=False)
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 "{}"))
"\nCURRENT_LOOK (this visitor's theme overrides; {} = default dark look):\n" + (look or "{}") +
"\nPAGE_TEXT (what the editable text slots show right now):\n" + _page_text(look))
msgs = [{"role": "system", "content": sys_prompt}]
for h in (history or [])[-6:]:
if h.get("role") in ("user", "assistant") and h.get("content"):

264
market/copyedit.py Normal file
View File

@ -0,0 +1,264 @@
"""Rule-based UI copy edits for the built-in agent (used when no hosted/local model answers).
Understands e.g. "replace agent market to new internet on the top left corner", "rename the logo to X",
"call the site X", "top left should say X", "change the title to X", "replace A with B" (generic find-and-replace
across the editable copy slots: logo, headline, subtitle, footer, chip labels, bullets).
Returns (reply, patch) where patch is a set_theme patch, or (reply, None) when it understood a copy edit but
couldn't apply it (the reply says what it understood and offers the closest option), or None (not a copy edit).
"""
import difflib
import os
import re
# Keep in sync with copyDefaults in web/src/App.tsx (tests/test_copyedit.py checks this).
DEFAULT_COPY = {
"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.",
"footer": "Agent Market demo · fake test data · test-mode payments · open API",
"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": ["{fee}% 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)"],
}
LIMITS = {"logo": 40, "headline": 120, "subhead": 240, "footer": 160, "chip": 40, "bullet": 140}
NAMES = {"logo": "top-left logo text", "headline": "headline", "subhead": "subtitle", "footer": "footer"}
# location phrases -> slot (word-bounded; order matters: specific before generic)
LOCS = [
("subhead", r"sub-?title|sub-?head(?:ing|line)?|tag-?line|intro(?:duction)? text|(?:text|line) (?:under|below) the (?:headline|title|heading)|description"),
("logo", r"top[- ]?left(?: corner)?(?: text)?|upper[- ]?left(?: corner)?(?: text)?|left[- ]?top|logo(?: text| name)?|brand(?:ing| name| text)?|"
r"site[- ]?(?:name|title)|website (?:name|title)|name of (?:the |this )?(?:site|website|page|store|shop|marketplace|market)|"
r"(?:store|shop|marketplace|market|app) name|header(?: text| title| name)?|word-?mark|nav(?:bar)? (?:text|title|name)|"
r"(?:corner|top) text|title (?:in|at|on) the (?:top[- ]?left|header|corner|top)"),
("footer", r"footer(?: text| line)?|bottom(?: text| line)?|text at the bottom(?: of the page)?|(?:last|bottom) line"),
("headline", r"(?:main |big |page |hero )?(?:headline|heading|title|header text)|big text|main text|hero(?: text)?|h1"),
]
LOC_RE = "|".join(f"(?:{p})" for _, p in LOCS)
STRONG = r"replace|rename|swap|re-?word|retitle|re-?label"
WEAK = r"change|switch|turn|update|edit|set|make|alter|modify|put|write"
SEPS = r"to|with|into|for|by|->|→|=>|=|as"
STYLE_WORDS = r"background|colou?r|font|mode|accent|gradient|theme|look|style|layout|chat|column|corners?|radius|dark|light|preset|" \
r"pattern|spacing|density|animation|cards?|grid|list|size|bigger|smaller|order|card"
def current_copy(theme, fee_bps=None):
fee_bps = fee_bps if fee_bps is not None else int(os.environ.get("AGENT_MARKET_FEE_BPS", "500"))
"""The text each editable slot shows right now for this visitor (defaults + their overrides)."""
fee = f"{fee_bps / 100:.{1 if fee_bps % 100 else 0}f}"
c = {k: (list(v) if isinstance(v, list) else v) for k, v in DEFAULT_COPY.items()}
c["bullets"] = [b.replace("{fee}", fee) for b in c["bullets"]]
over = (theme or {}).get("copy") if isinstance(theme, dict) else None
for k, v in (over or {}).items() if isinstance(over, dict) else ():
if k in ("logo", "headline", "subhead", "footer") and isinstance(v, str) and v.strip():
c[k] = v
elif k == "chips" and isinstance(v, list):
c["chips"] = [x for x in v if isinstance(x, dict) and isinstance(x.get("label"), str)]
elif k == "bullets" and isinstance(v, list):
c["bullets"] = [x for x in v if isinstance(x, str)]
return c
def _slot_of(phrase):
"""'the top left corner' -> 'logo'; returns None unless the phrase is (only) a location."""
p = re.sub(r"^(?:the |this |that |our |my |your |its )?(?:(?:text|words?|wording|copy|writing|label|name)\s+(?:in|on|at|of|for)\s+(?:the\s+)?)?", "",
phrase.strip().lower())
p = re.sub(r"\s+(?:text|area|section|part|bit|corner)$", "", p).strip()
p = re.sub(r"^(?:in|on|at)\s+(?:the\s+)?", "", p)
for slot, pat in LOCS:
if re.fullmatch(f"(?:{pat})(?: of (?:the |this )?(?:page|site|screen|website))?", p):
return slot
return None
def _strip_loc_suffix(b):
"""'new internet on the top left corner' -> ('new internet', 'logo')."""
m = re.search(r"\s+(?:on|in|at|for|up in|over in|inside|within)\s+(?:the\s+|this\s+)?(" + LOC_RE + r")"
r"(?:\s+(?:corner|area|section|bar|part|text))?(?:\s+of\s+(?:the\s+|this\s+)?(?:page|site|screen|website))?\s*$", b, re.I)
if m:
return b[:m.start()].rstrip(), _slot_of(m.group(1))
return b, None
def _clean(s):
s = s.strip()
s = re.sub(r"\s+(?:please|pls|thanks|thank you|instead|now|for me)\s*[.!]*$", "", s, flags=re.I).strip()
q = re.fullmatch(r"[\"“”'‘’`](.+)[\"“”'‘’`]", s)
if q:
return q.group(1).strip(), True
return s.rstrip(" .!?"), False
def _match_case(new, old):
"""Keep the case the user typed for the new text; only an all-lowercase entry adopts the replaced text's style
(so 'new internet' replacing 'Agent Market' becomes 'New Internet')."""
if not new or new != new.lower() or not re.search(r"[A-Za-z]", old or ""):
return new
words = [w for w in re.findall(r"[A-Za-z][\w'’-]*", old)]
if old.isupper() and len(old) > 1:
return new.upper()
if words and all(w[0].isupper() for w in words):
return re.sub(r"(^|(?<=[\s\-/]))([a-z])", lambda m: m.group(1) + m.group(2).upper(), new)
if words and words[0][0].isupper():
return new[:1].upper() + new[1:]
return new
def _norm(s):
return re.sub(r"\s+", " ", re.sub(r"[\"“”'‘’`.!?]+$", "", (s or "").strip().lower()))
def _slots(cur):
out = [("logo", None, cur["logo"]), ("headline", None, cur["headline"]), ("subhead", None, cur["subhead"]), ("footer", None, cur["footer"])]
out += [("chip", i, ch["label"]) for i, ch in enumerate(cur["chips"])]
out += [("bullet", i, b) for i, b in enumerate(cur["bullets"])]
return out
def _label(slot, i, text):
"""Name of a slot for replies, with its article ("the headline", "the “📐 Tutoring” button")."""
if slot == "chip":
return f"the “{text}” button"
if slot == "bullet":
return f"bullet {i + 1}"
return "the " + NAMES[slot]
def _apply(cur, slot, i, new, old_fragment=None):
"""Build the patch. old_fragment set => replace that substring (case-insensitive) inside the slot's text."""
def sub(text):
if old_fragment is None:
return _match_case(new, text)
rx = re.compile(re.escape(old_fragment), re.I)
return rx.sub(lambda m: _match_case(new, m.group(0)), text)
if slot in ("logo", "headline", "subhead", "footer"):
val = sub(cur[slot])[:LIMITS[slot]]
return {"copy": {slot: val}}, val
if slot == "chip":
chips = [dict(c) for c in cur["chips"]]
chips[i]["label"] = sub(chips[i]["label"])[:LIMITS["chip"]]
return {"copy": {"chips": chips}}, chips[i]["label"]
bullets = list(cur["bullets"])
bullets[i] = sub(bullets[i])[:LIMITS["bullet"]]
return {"copy": {"bullets": bullets}}, bullets[i]
def _find(cur, a):
"""Where does text A appear? Exact whole-slot matches first, then substring matches."""
na = _norm(a)
if not na:
return [], []
exact = [(s, i, t) for s, i, t in _slots(cur) if _norm(t) == na or _norm(re.sub(r"^\W+", "", t)) == na]
sub = [(s, i, t) for s, i, t in _slots(cur) if na in (t or "").lower() and (s, i, t) not in exact]
return exact, sub
def _closest(cur, a):
best, score = None, 0.0
for s, i, t in _slots(cur):
r = difflib.SequenceMatcher(None, _norm(a), _norm(t)).ratio()
if r > score:
best, score = (s, i, t), r
return best if score >= 0.45 else None
def _candidates(text):
"""Yield (A, B, kind) readings of the message; A may be a location or literal text."""
t = re.sub(r"^(?:hey|hi|ok|okay|so)[,!]?\s+", "", text.strip(), flags=re.I)
t = re.sub(r"^(?:please|pls|can you|could you|would you|will you|i(?:'d| would) like (?:you )?to|i want (?:you )?to|let'?s)\s+", "", t, flags=re.I)
t = re.sub(r"^(?:please)\s+", "", t, flags=re.I)
# two quoted strings: replace "A" with "B"
q = re.findall(r"[\"“‘'`]([^\"“”‘’'`]+)[\"”’'`]", t)
if len(q) >= 2 and re.match(rf"(?:{STRONG}|{WEAK})\b", t, re.I):
yield q[0], q[1], "pair"
# verb A sep B (try every separator position; caller picks the reading that resolves)
m = re.match(rf"(?P<v>{STRONG}|{WEAK})\s+(?P<rest>.+)$", t, re.I | re.S)
if m:
rest = m.group("rest")
for sm in re.finditer(rf"\s+(?:{SEPS})\s+|\s*(?:->|→|=>|=)\s*", rest, re.I):
a, b = rest[:sm.start()], rest[sm.end():]
if a.strip() and b.strip():
yield a, b, m.group("v").lower()
# LOC should say / make LOC say / I want LOC to say / LOC: B
m = re.match(rf"(?:make|have|let|get)?\s*(?:the\s+)?(?P<loc>.+?)\s+(?:should|must|needs? to|has to|to|will|shall)?\s*"
rf"(?:say|says|read|reads|show|shows|display|displays|be|become|state)\s*:?\s+(?P<b>.+)$", t, re.I)
if m and _slot_of(m.group("loc")):
yield m.group("loc"), m.group("b"), "say"
m = re.match(rf"(?:i want|i'd like)\s+(?:the\s+)?(?P<loc>.+?)\s+to\s+(?:say|read|show|be)\s+(?P<b>.+)$", text.strip(), re.I)
if m and _slot_of(m.group("loc")):
yield m.group("loc"), m.group("b"), "say"
m = re.match(r"(?P<loc>[^:]{2,40}?)\s*:\s*(?P<b>.+)$", t)
if m and _slot_of(m.group("loc")):
yield m.group("loc"), m.group("b"), "say"
# call the site X / name it X / rename the site X / retitle the site X
m = re.match(r"(?:call|name|rename|retitle|title|brand)\s+(?:the |this |our |my )?(?:site|website|page|store|shop|marketplace|market|app|it)"
r"\s+(?:to\s+|as\s+)?(?P<b>.+)$", t, re.I)
if m:
yield "logo", m.group("b"), "call"
m = re.match(r"use\s+(?P<b>.+?)\s+as\s+(?:the\s+)?(?P<loc>.+)$", t, re.I)
if m and _slot_of(m.group("loc")):
yield m.group("loc"), m.group("b"), "say"
def parse_copy_edit(text, theme=None, fee_bps=None):
fee_bps = fee_bps if fee_bps is not None else int(os.environ.get("AGENT_MARKET_FEE_BPS", "500"))
if not text or len(text) > 400:
return None
cur = current_copy(theme, fee_bps)
low = text.lower()
fallback_understood = None
for a_raw, b_raw, kind in _candidates(text):
b, hint = _strip_loc_suffix(_clean(b_raw)[0]) if kind != "pair" else (b_raw.strip(), None)
b, quoted = _clean(b) if kind != "pair" else (b, True)
a_loc_text, hint_a = _strip_loc_suffix(a_raw.strip())
a, a_quoted = _clean(a_loc_text) if kind != "pair" else (a_raw, True)
hint = hint or hint_a
if not b or len(b) > 240:
continue
loc = "logo" if kind == "call" else (_slot_of(a) if not a_quoted else None)
if loc:
patch, val = _apply(cur, loc, None, b)
return f"Done: the {NAMES[loc]} now says “{val}”. Say “reset the look” to undo.", patch
exact, sub = _find(cur, a)
if hint:
# a location was named: replace inside that slot (whole text if it matches, else the matching words)
here = [x for x in exact + sub if x[0] == hint]
if here:
s, i, t = here[0]
frag = None if _norm(t) == _norm(a) else a
patch, val = _apply(cur, s, i, b, frag)
return f"Done: the {NAMES[s]} now says “{val}”. Say “reset the look” to undo.", patch
if kind in ("say",) or not (exact or sub):
patch, val = _apply(cur, hint, None, b)
return (f"Done: the {NAMES[hint]} now says “{val}” (I couldn't find “{a}” there, so I replaced the whole text). "
"Say “reset the look” to undo."), patch
if len(exact) == 1 or (not exact and len(sub) == 1):
s, i, t = (exact or sub)[0]
# whole-text match replaces the slot; a partial match (or a chip label minus its emoji) replaces just those words
frag = None if exact and _norm(t) == _norm(a) else a
patch, val = _apply(cur, s, i, b, frag)
return f"Done: {_label(s, i, t)} now says “{val}”. Say “reset the look” to undo.", patch
if len(exact) + len(sub) > 1:
where = ", ".join(_label(s, i, t) for s, i, t in (exact + sub)[:4]).replace("the ", "")
fallback_understood = fallback_understood or (
f"I understood: change “{a}” to “{b}”, but that text appears in more than one place ({where}). "
f"Say where, e.g. “change the {NAMES.get((exact + sub)[0][0], 'headline')} to {b}”.")
continue
# A not on the page: only answer (instead of falling through to other commands) for clear copy-edit wording
if fallback_understood is None and (re.match(rf"\s*(?:please\s+)?(?:{STRONG})\b", low) or kind == "pair") \
and not re.search(rf"\b(?:{STYLE_WORDS})\b", low) and not re.search(r"\b(?:buy|sell|order|cart|checkout|pay)\b", a.lower()):
near = _closest(cur, a)
if near:
s, i, t = near
fallback_understood = (f"I understood: change “{a}” to “{b}”, but I can't find “{a}” on the page. "
f"Closest is {_label(s, i, t)} (“{t[:60]}”). Say “change the {NAMES.get(s, 'headline')} to {b}” "
"if that's what you meant.")
else:
fallback_understood = (f"I understood: change “{a}” to “{b}”, but I can't find “{a}” on the page. I can edit the "
f"logo (“{cur['logo']}”), headline, subtitle, footer, quick-action buttons and bullets, "
f"e.g. “change the logo to {b}”.")
if fallback_understood:
return fallback_understood, None
return None

View File

@ -216,6 +216,9 @@ def sanitize_patch(p, depth=0):
if isinstance(ch, str):
ch = {"label": ch, "say": ch}
if isinstance(ch, dict) and _text(ch.get("label"), 40):
if ch.get("event") in ("sell",):
chips.append({"label": _text(ch["label"], 40), "event": ch["event"]})
else:
chips.append({"label": _text(ch["label"], 40), "say": _text(ch.get("say") or ch["label"], 120)})
c["chips"] = chips
if isinstance(copy.get("bullets"), list):
@ -301,23 +304,17 @@ def parse_look_command(text, current=None):
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}
# UI copy edits first ("change the headline to Retro vibes" is text, not the retro preset)
from .copyedit import parse_copy_edit
ce = parse_copy_edit(text, cur)
if ce:
return ce[0], (sanitize_patch(ce[1]) if ce[1] else None)
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):
@ -344,7 +341,8 @@ def parse_look_command(text, current=None):
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":
if name != "system" and re.search(r"\b" + re.escape(name.lower()) + r"\b", low) \
and (re.search(r"\b(font|typeface|type|lettering|letters|text)\b", low) or " " in name):
patch["font"] = name; said.append(f"{name} font"); break
else:
for alias, name in (("monospace", "mono"), ("serif", "serif"), ("handwrit", "Pacifico"), ("script", "Pacifico"),

View File

@ -83,6 +83,14 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await page.waitForSelector("[data-component=compare]", { timeout: 15000 });
check("compare view renders", (await page.$$("[data-component=compare] th")).length >= 3);
// UI copy edit: the user's exact phrasing renames the top-left logo; then reset the look
await ask("replace agent market to new internet on the top left corner");
const logo = await page.$eval("#logoText", (n) => n.textContent);
check("copy edit: top-left logo says New Internet", logo === "New Internet", logo);
if (SHOTS) { await page.evaluate(() => window.scrollTo(0, 0)); await sleep(600); await page.screenshot({ path: SHOTS + "/11-logo-new-internet.png" }); }
await ask("reset the look");
check("reset restores the logo", (await page.$eval("#logoText", (n) => n.textContent)) === "Agent Market");
// compare via card checkboxes + compare bar
await ask("show me everything");
await page.$$eval("#stage .card input[aria-label=compare]", (ns) => ns.slice(0, 2).forEach((n) => n.click()));

View File

@ -40,6 +40,10 @@ assert a["type"]=="set_theme" and p["background"]["colors"]==["#ff7e5f","#feb47b
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:18799/api/chat -H 'Content-Type: application/json' -d '{"message":"replace agent market to new internet on the top left corner","theme":{}}' | python3 -c '
import json,sys; d=json.load(sys.stdin); a=d["actions"]
assert d["agent"]=="llm" and a and a[0]["type"]=="set_theme" and a[0]["payload"]["patch"]["copy"]=={"logo":"New Internet"}, d
print("PASS model path: prompt explains top-left = logo + current page text -> copy.logo set")'
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)

View File

@ -24,6 +24,15 @@ class H(BaseHTTPRequestHandler):
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 "new internet" in last.lower():
# answers like a model that read the prompt: it only knows "top left" = copy.logo if the prompt says so
sysmsg = body["messages"][0]["content"]
knows = "TOP-LEFT" in sysmsg and '"logo": "Agent Market"' in sysmsg
out = ({"reply": "Renamed the top-left logo to New Internet.", "actions": [{"type": "set_theme", "payload": {"copy": {"logo": "New Internet"}}}]}
if knows else {"reply": "I can't change that text.", "actions": []})
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 "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},

90
tests/test_copyedit.py Normal file
View File

@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Built-in agent: UI copy edits (logo/headline/subtitle/footer/chips/bullets). No server or model needed."""
import json, os, re, sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.pop("AGENT_MARKET_LLM_API_KEY_FILE", None)
from market.theme import parse_look_command
from market.copyedit import DEFAULT_COPY
from market import agent
results = []
def check(name, ok, d=""):
results.append(bool(ok)); print(("PASS " if ok else "FAIL ") + name + (f" [{d}]" if d else ""))
def copy_of(text, theme=None):
r = parse_look_command(text, theme or {})
return (r[0], (r[1] or {}).get("copy")) if r else (None, None)
LOGO = [
"replace agent market to new internet on the top left corner", # the user's exact phrasing
"change Agent Market to New Internet in the top left", "rename the logo to New Internet", "call the site New Internet",
"top left should say New Internet", "replace Agent Market with New Internet", "change the top left text to New Internet",
"make the logo say new internet", "I want the header to say New Internet", "logo: New Internet", "use New Internet as the site name",
"change the brand to new internet please", "rename the website to new internet", "name the site new internet",
"change agent market into new internet", "swap Agent Market for New Internet", 'replace "Agent Market" with "New Internet"',
"Please change the site name to New Internet.", "the top-left corner should read New Internet",
]
for p in LOGO:
reply, c = copy_of(p)
check(f"logo: {p}", c == {"logo": "New Internet"}, f"{c} / {reply}")
cases = [
("change the title to Hello World", {"headline": "Hello World"}),
("change the headline to Retro vibes", {"headline": "Retro vibes"}),
("replace A marketplace you can just talk to. with Buy and sell anything nearby", {"headline": "Buy and sell anything nearby"}),
("replace marketplace with bazaar", {"headline": "A bazaar you can just talk to."}),
("set the subtitle to Fast and free", {"subhead": "Fast and free"}),
("swap the tagline with Shop by chatting", {"subhead": "Shop by chatting"}),
("swap the footer to Made for testing", {"footer": "Made for testing"}),
("replace market with Internet in the logo", {"logo": "Agent Internet"}),
("the top left should read NEW INTERNET", {"logo": "NEW INTERNET"}),
("change the logo to new Internet", {"logo": "new Internet"}), # mixed case typed -> kept exactly
]
for p, want in cases:
reply, c = copy_of(p)
check(f"copy: {p}", c == want, f"{c}")
reply, c = copy_of("rename Tutoring to Lessons")
check("chip label edited, emoji + action kept", c and c["chips"][1] == {"label": "📐 Lessons", "say": "find tutoring services"} and c["chips"][3].get("event") == "sell", c and c["chips"][:4])
reply, c = copy_of("replace $0 fee with no fee")
check("bullet edited by find-and-replace", c and c["bullets"][1].startswith("no fee when you meet"), c)
reply, c = copy_of("replace New Internet with Agent Hub", {"copy": {"logo": "New Internet"}})
check("find-and-replace uses the visitor's current text", c == {"logo": "Agent Hub"}, c)
# unclear -> says what it understood + closest option, never silently nothing / listings
r = parse_look_command("replace agent with robot", {})
check("ambiguous text -> asks where", r and r[1] is None and "more than one place" in r[0], r and r[0])
r = parse_look_command("replace agnet markt with New Internet", {})
check("typo -> offers the closest slot", r and r[1] is None and "Closest is the top-left logo text" in r[0], r and r[0])
r = parse_look_command("replace foo bar with baz", {})
check("not found -> lists what can be edited", r and r[1] is None and "can't find" in r[0], r and r[0])
# no hijacking of look commands, search or selling
for p, key in (("change the background to a sunset gradient", "background"), ("switch to light mode with a green accent", "mode"),
("use a serif font and make the text bigger", "font"), ("replace the background with blue", "background"),
("move the chat to the left", "chat"), ("hide the bullet points", "sections")):
r = parse_look_command(p, {})
check(f"look command still works: {p}", r and r[1] and key in r[1] and "copy" not in r[1], r and r[1])
for p in ("show me bikes", "change my order to cash", "I'd like to sell my lamp for $20", "find tutoring services"):
check(f"not a copy edit: {p}", parse_look_command(p, {}) is None)
r = parse_look_command("make it look retro", {})
check("preset still works", r and r[1] == {"preset": "retro"})
r = parse_look_command("use the Inter font", {})
check("font names are word-bounded (Inter font still works)", r and r[1].get("font") == "Inter")
check("'internet' is not the Inter font", "font" not in (parse_look_command("make the headline say internet rocks", {})[1] or {}))
# end to end through the built-in agent (what /api/chat returns when no model answers)
out = agent.fallback_agent(None, "replace agent market to new internet on the top left corner", [], "t", {})
check("built-in agent: user's exact phrasing -> set_theme copy.logo, no listings",
[a["type"] for a in out["actions"]] == ["set_theme"] and out["actions"][0]["payload"]["copy"] == {"logo": "New Internet"}, out["reply"])
out = agent.fallback_agent(None, "replace agent with robot", [], "t", {})
check("built-in agent: unclear edit -> explanation only", out["actions"] == [] and "agent" in out["reply"])
# server copy defaults match the React app's defaults
app = open(os.path.join(os.path.dirname(__file__), "..", "web", "src", "App.tsx"), encoding="utf-8").read()
ok = all(s in app for s in (DEFAULT_COPY["logo"], DEFAULT_COPY["headline"], DEFAULT_COPY["subhead"]) ) and \
all(c["label"] in app for c in DEFAULT_COPY["chips"]) and all(b.split("{fee}% ")[-1] in app for b in DEFAULT_COPY["bullets"])
check("DEFAULT_COPY in sync with web/src/App.tsx", ok)
check("model prompt explains top-left = logo and includes PAGE_TEXT", "TOP-LEFT" in agent.UI_SCHEMA and '"logo": "Agent Market"' in agent._page_text("{}"))
print(f"\n{sum(results)}/{len(results)} passed")
sys.exit(0 if all(results) else 1)