From 3db09b4d0303a44668a62e94b58b7517be625f38 Mon Sep 17 00:00:00 2001 From: leora Date: Thu, 8 Oct 2026 20:49:15 +0800 Subject: [PATCH 1/2] Built-in agent: robust UI copy edits (replace/rename/change A to B, top-left/logo/brand/site name, generic find-and-replace, clear reply when unsure); word-bounded font names; prompt explains page text slots --- market/agent.py | 26 +++- market/copyedit.py | 264 +++++++++++++++++++++++++++++++++++++++ market/theme.py | 24 ++-- tests/browser_e2e.js | 8 ++ tests/llm_proxy_check.sh | 4 + tests/mock_llm.py | 9 ++ tests/test_copyedit.py | 90 +++++++++++++ 7 files changed, 409 insertions(+), 16 deletions(-) create mode 100644 market/copyedit.py create mode 100644 tests/test_copyedit.py diff --git a/market/agent.py b/market/agent.py index 8a6e2b6..db22754 100644 --- a/market/agent.py +++ b/market/agent.py @@ -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"): diff --git a/market/copyedit.py b/market/copyedit.py new file mode 100644 index 0000000..bb1f77f --- /dev/null +++ b/market/copyedit.py @@ -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{STRONG}|{WEAK})\s+(?P.+)$", 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.+?)\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.+)$", 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.+?)\s+to\s+(?:say|read|show|be)\s+(?P.+)$", 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[^:]{2,40}?)\s*:\s*(?P.+)$", 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.+)$", t, re.I) + if m: + yield "logo", m.group("b"), "call" + m = re.match(r"use\s+(?P.+?)\s+as\s+(?:the\s+)?(?P.+)$", 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 diff --git a/market/theme.py b/market/theme.py index dff1502..0c633f8 100644 --- a/market/theme.py +++ b/market/theme.py @@ -216,7 +216,10 @@ 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): - chips.append({"label": _text(ch["label"], 40), "say": _text(ch.get("say") or ch["label"], 120)}) + if ch.get("event") in ("sell",): + chips.append({"label": _text(ch["label"], 40), "event": ch["event"]}) + else: + chips.append({"label": _text(ch["label"], 40), "say": _text(ch.get("say") or ch["label"], 120)}) c["chips"] = chips if isinstance(copy.get("bullets"), list): c["bullets"] = [t for t in (_text(b, 140) for b in copy["bullets"][:6]) if t] @@ -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"), diff --git a/tests/browser_e2e.js b/tests/browser_e2e.js index 0390896..ef5e48f 100644 --- a/tests/browser_e2e.js +++ b/tests/browser_e2e.js @@ -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())); diff --git a/tests/llm_proxy_check.sh b/tests/llm_proxy_check.sh index 2eda74b..a6eb5eb 100755 --- a/tests/llm_proxy_check.sh +++ b/tests/llm_proxy_check.sh @@ -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) diff --git a/tests/mock_llm.py b/tests/mock_llm.py index 5828730..2f00155 100755 --- a/tests/mock_llm.py +++ b/tests/mock_llm.py @@ -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}, diff --git a/tests/test_copyedit.py b/tests/test_copyedit.py new file mode 100644 index 0000000..7206552 --- /dev/null +++ b/tests/test_copyedit.py @@ -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) From 0c021dfdc9670814f4d93a7f07047f121e1e5742 Mon Sep 17 00:00:00 2001 From: leora Date: Thu, 8 Oct 2026 20:50:27 +0800 Subject: [PATCH 2/2] README: page text editing --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 270a3b3..5e9a78c 100644 --- a/README.md +++ b/README.md @@ -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