Compare commits

...

4 Commits

29 changed files with 2690 additions and 473 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ __pycache__/
node_modules/
screenshots/
.env
static/dist/

View File

@ -11,7 +11,7 @@ sell products or services.
## How it works
```
browser (static HTML/JS) ──/api/chat──▶ tiny server ──▶ hosted LLM (OpenAI-compatible)
browser (React SPA) ─────/api/chat──▶ tiny server ──▶ hosted LLM (OpenAI-compatible)
▲ renders UI actions │ or the rule-based fallback agent
│ ├── /api/payments/* ── mock test-mode provider ── signed webhook
└── card form posts straight ────────┘
@ -38,7 +38,7 @@ The server stays small on purpose. It does three things:
### 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`).
(`market/theme.py`), and the browser applies the result (`web/src/theme/engine.ts`).
| Area | Spec keys |
|---|---|
@ -115,12 +115,76 @@ The landing view doesn't call the model either. Set `AGENT_MARKET_LLM_EVENTS=1`
With no LLM configured, the demo runs the **rule-based fallback agent**. The page labels this.
## Frontend (React + Vite + TypeScript)
The page is a React single-page app in `web/`. The Python server and API are unchanged, except that `/` now serves
the built `static/dist/index.html` and `/static/dist/assets/*` serves the hashed JS/CSS bundles.
```
web/
index.html # declares `@layer base, user, protected;` first, then <style id="user-css">, then #root
vite.config.ts # base /static/dist/, output ../static/dist, dev proxy to :8795
src/
main.tsx # mounts <App/> and imports styles.css
App.tsx # state (chat, panels, badge, theme, compare, toasts), talk() -> /api/chat, render() via registry
context.ts # useAgent(): talk(), feeBps, compare set, toast() for components
api.ts, types.ts # fetch helper (X-Agent-Id) and API types
components/
registry.tsx # UI action type -> React component (PANELS) or side effect (EFFECTS)
Listings.tsx # show_hero, show_cards (cards/list view), show_compare, ListingCard, BuyButtons
ListingForm.tsx # show_listing_form
Payments.tsx # show_checkout, show_cash_meetup, show_receipt (protected panels)
Chat.tsx # chat popup
theme/engine.ts # theme spec -> CSS variables/body classes/fonts/scoped customCss, copy + sections, storage
styles.css # @layer base (themeable) and @layer protected (banner, badge, fees, card form)
```
**Build and deploy.** `static/dist/` is a build artefact and is git-ignored. `deploy.sh` runs
`npm ci && npm run build` in `web/` (exact versions from the committed `web/package-lock.json`, so builds are
reproducible) **before** it stops the running server, so a failed build leaves the current version up. It needs Node 20+.
Locally: `cd web && npm ci && npm run build`, then run `server.py`. For hot reload, run the server on :8795 and
`npm run dev` (Vite proxies `/api`, `/agent.json`, `/openapi.json` to it).
**How the page renders.** Typed messages go to `/api/chat` (the model, or the built-in agent when it's unavailable).
Clicks, form submits and payment results are sent as events that the server handles deterministically. The reply's
`actions` go through `render()`: every action type is looked up in the registry. Effects (`set_theme`, `toast`,
`clear_panel`) run first; the panels of one reply replace the results area together. If the visitor's theme hides
the results section, rendering a panel shows it again (with a toast). The card form posts straight to
`/api/payments/{id}/confirm` and never goes to the agent.
**Theme.** `engine.ts` is a straight port of the old `theme.js`: CSS variables on `<html>`, body classes
(`scrim`, `layout-list`, `card-*`, `chat-left`, `no-anim`), fonts, the scoped `customCss` in `<style id="user-css">`
inside `@layer user`, and `--p-bg`/`--p-ink` for protected panels (black or white, whichever contrasts more).
React renders the copy (logo, headline, subhead, chips, bullets, footer) and section order/visibility from the same
theme object. The theme is stored per browser in `localStorage["am_theme_v1"]`; Reset look removes it.
### Adding a UI component
1. **Server.** Add the action type to `ALLOWED_ACTIONS` and describe its payload in `UI_SCHEMA` in `market/agent.py`.
If the payload references store objects, resolve them in `hydrate()` in `server.py` (never trust model-made ids or prices),
and add a rule to `fallback_agent`/`fallback_event` if the built-in agent should be able to show it.
2. **Component.** Create it in `web/src/components/`, e.g.
```tsx
export function Reviews({ payload }: { payload: { heading?: string; reviews?: Review[] } }) {
const { talk } = useAgent(); // send follow-up events, e.g. talk({ event: { type: "..." }, echo: "..." })
return <section className="panel" data-component="reviews">...</section>;
}
```
Use the `panel` class and design tokens (`var(--ink)`, `var(--acc)`, ...) so themes apply. Anything that shows money, fees
or card inputs must also go in `@layer protected` in `styles.css` and use `--p-bg`/`--p-ink`.
3. **Register it** in `web/src/components/registry.tsx`: `PANELS.show_reviews = Reviews` for a panel, or an `EFFECTS`
entry for a side effect with no panel.
4. **Test.** Add a check to `tests/browser_e2e.js` (and `tests/e2e_http.py` for the server side), then `npm run build`.
## Run
```bash
AGENT_MARKET_PORT=8796 AGENT_MARKET_DATA=./data python3 server.py
```
Build the frontend first (`cd web && npm ci && npm run build`); without it `/` returns 503 `frontend_not_built`.
The first run seeds `data/store.json` with fake listings. Delete that file to reseed.
`deploy.sh <staging|demo>` checks out a ref and restarts the service on the box. See the script for details.
`deploy.sh <staging|demo> [ref]` checks out a ref, builds the frontend, and restarts the service on the box. See the script for details.
For test runs without real model calls, point the LLM at the fake model: run `python3 tests/mock_llm.py 18797` and pass
`AM_EXTRA_ENV=<file>` with `AGENT_MARKET_LLM_BASE_URL=http://127.0.0.1:18797/v1` and `AGENT_MARKET_LLM_MODELS=daily-model`
(plus a dummy key file); the chain parks on the fake daily-quota 429 and the built-in agent answers.
## Agent API quick start
```bash
@ -135,12 +199,13 @@ curl -s $BASE/api/actions -H 'X-Agent-Id: agent:my-bot' -H 'Content-Type: applic
## Tests
```bash
python3 tests/e2e_http.py http://127.0.0.1:8795 # HTTP end-to-end (23 checks)
python3 tests/e2e_http.py http://127.0.0.1:8795 # HTTP end-to-end incl. SPA shell + bundle (24 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
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
```
## Known gaps (demo)

View File

@ -21,7 +21,15 @@ CONF=$HOME/.config/agent-market
set -a
[ -f "$CONF/llm.env" ] && . "$CONF/llm.env"
[ -f "$CONF/$ENVN.env" ] && . "$CONF/$ENVN.env"
# Optional extra env file for offline test runs, e.g. point the LLM at tests/mock_llm.py so no real model calls are made:
# AM_EXTRA_ENV=/path/offline.env ./deploy.sh staging my-branch
[ -n "${AM_EXTRA_ENV:-}" ] && . "$AM_EXTRA_ENV"
set +a
# Build the React frontend (web/ -> static/dist) BEFORE stopping the running server, so a failed build
# leaves the current version up. Reproducible: exact versions from web/package-lock.json via `npm ci`.
command -v npm >/dev/null || { echo "npm not found: Node 20+ is needed to build the frontend"; exit 1; }
( cd web && npm ci --no-audit --no-fund --loglevel=error && npm run build --silent ) || { echo "frontend build failed; still running the previous version"; exit 1; }
[ -f static/dist/index.html ] || { echo "build produced no static/dist/index.html"; exit 1; }
PID=$(ss -ltnp 2>/dev/null | grep ":$PORT " | grep -o 'pid=[0-9]*' | cut -d= -f2 || true)
[ -n "$PID" ] && kill "$PID" && sleep 0.5
AGENT_MARKET_PORT=$PORT AGENT_MARKET_ENV=$ENVN AGENT_MARKET_DATA=$ROOT/data \

View File

@ -14,7 +14,9 @@ HOST = os.environ.get("AGENT_MARKET_HOST", "127.0.0.1")
PORT = int(os.environ.get("AGENT_MARKET_PORT", "8796"))
DATA_DIR = os.environ.get("AGENT_MARKET_DATA", os.path.join(os.path.dirname(os.path.abspath(__file__)), "data"))
ENV_LABEL = os.environ.get("AGENT_MARKET_ENV", "demo")
# Frontend: the React/Vite build in static/dist (built from web/ by deploy.sh; see README "Frontend").
STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
INDEX = os.path.join("dist", "index.html")
WEBHOOK_SECRET = os.environ.get("AGENT_MARKET_WEBHOOK_SECRET") or secrets.token_hex(16) # per-process if unset
MAX_BODY = 64 * 1024
CHAT_RPM = int(os.environ.get("AGENT_MARKET_CHAT_RPM", "12")) # per-IP chat requests per minute
@ -264,10 +266,13 @@ class H(BaseHTTPRequestHandler):
def _static(self, rel):
path = os.path.normpath(os.path.join(STATIC, rel))
if not path.startswith(STATIC) or not os.path.isfile(path):
if not path.startswith(STATIC + os.sep) or not os.path.isfile(path):
if rel == INDEX:
return self._json(503, {"error": "frontend_not_built", "hint": "cd web && npm ci && npm run build"})
return self._json(404, {"error": "not_found"})
ctype = {".html": "text/html; charset=utf-8", ".js": "application/javascript", ".css": "text/css",
".svg": "image/svg+xml", ".png": "image/png"}.get(os.path.splitext(path)[1], "application/octet-stream")
".svg": "image/svg+xml", ".png": "image/png", ".json": "application/json",
".ico": "image/x-icon", ".woff2": "font/woff2"}.get(os.path.splitext(path)[1], "application/octet-stream")
with open(path, "rb") as f:
b = f.read()
self.send_response(200)
@ -281,7 +286,7 @@ class H(BaseHTTPRequestHandler):
u = urllib.parse.urlparse(self.path)
path, qs = u.path, dict(urllib.parse.parse_qsl(u.query))
if path in ("/", "/index.html"):
return self._static("index.html")
return self._static(INDEX)
if path.startswith("/static/"):
return self._static(path[len("/static/"):])
if path in ("/agent.json", "/.well-known/agent.json"):

View File

@ -1,238 +0,0 @@
// Agent Market frontend: renders UI actions chosen by the agent. Card data goes only to the test payment endpoint.
(() => {
const $ = (s) => document.querySelector(s);
const stage = $("#stage"), log = $("#log");
const actor = localStorage.getItem("am_actor") || ("guest-" + Math.random().toString(36).slice(2, 7));
localStorage.setItem("am_actor", actor);
const history = [];
let feeBps = 500;
const compareSet = new Set();
const money = (c) => "$" + (c / 100).toFixed(2);
function el(tag, attrs = {}, ...kids) {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") n.className = v;
else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
else if (v !== undefined && v !== null) n.setAttribute(k, v);
}
for (const k of kids.flat()) if (k !== null && k !== undefined) n.append(k.nodeType ? k : document.createTextNode(String(k)));
return n;
}
function toast(text, level = "info") {
const t = el("div", { class: "toast " + level }, text);
$("#toasts").append(t); setTimeout(() => t.remove(), 3500);
}
function say(role, text) {
log.append(el("div", { class: "msg " + role }, text)); log.scrollTop = log.scrollHeight;
}
function openChat() { $("#chat").classList.remove("hidden"); $("#chatLauncher").style.display = "none"; }
async function api(path, body) {
const r = await fetch(path, body ? { method: "POST", headers: { "Content-Type": "application/json", "X-Agent-Id": actor }, body: JSON.stringify(body) } : { headers: { "X-Agent-Id": actor } });
return r.json();
}
function setModel(model, ms) {
const short = (model || "").replace(/:free$/, "");
$("#agentBadge").textContent = "AI: " + short;
$("#agentBadge").title = (model || "") + " via OpenRouter (free tier)";
$("#chatMode").textContent = `AI agent · ${short}${ms ? " · " + (ms / 1000).toFixed(1) + "s" : ""}`;
}
async function talk({ message, event, echo }) {
openChat();
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(-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) });
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 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); }
}
// ---------- component renderers ----------
const R = {
show_hero(p) {
return el("section", { class: "panel hero" }, el("h2", {}, p.title || "Agent Market"),
el("p", {}, p.subtitle || ""), el("ul", {}, (p.bullets || []).map((b) => el("li", {}, b))));
},
show_cards(p) {
return el("section", { class: "panel", "data-component": "cards" }, el("h2", {}, p.heading || "Listings"),
el("div", { class: "by" }, "Rendered by the agent"),
el("div", { class: "grid" }, (p.listings || []).map(card)));
},
show_compare(p) {
const L = p.listings || [], Q = p.quotes || [];
const row = (label, f) => el("tr", {}, el("th", {}, label), L.map((x, i) => el("td", {}, f(x, Q[i] || {}))));
return el("section", { class: "panel", "data-component": "compare" }, el("h2", {}, p.heading || "Compare"),
el("table", { class: "cmp" },
el("tr", {}, el("th", {}, ""), L.map((x) => el("th", {}, x.emoji + " " + x.title))),
row("Type", (x) => x.kind), row("Price", (x) => money(x.price)),
row("Pay online (test)", (x, q) => q.online && q.online.available ? `${money(q.online.total_cents)} incl. ${money(q.online.fee_cents)} fee` : "not offered"),
row("Cash in person", (x, q) => q.cash_in_person && q.cash_in_person.available ? `${money(q.cash_in_person.total_cents)} · $0 fee` : "not offered"),
row("Seller", (x) => x.seller + (x.seller_type === "agent" ? " 🤖" : "")),
row("Area", (x) => x.area), row("", (x) => buyButtons(x))));
},
show_listing_form(p) {
const d = p.defaults || {};
const payOpts = Array.isArray(d.payment_options) && d.payment_options.length ? d.payment_options : ["online", "cash"];
const f = el("form", { class: "form", "data-component": "listing-form" },
el("label", { class: "full" }, "Title", el("input", { name: "title", required: "", value: d.title || "", placeholder: "e.g. Vintage desk chair" })),
el("label", {}, "Type", (() => { const s = el("select", { name: "kind" }, el("option", { value: "product" }, "Product"), el("option", { value: "service" }, "Service")); s.value = d.kind || "product"; return s; })()),
el("label", {}, "Price (USD)", el("input", { name: "price", type: "number", step: "0.01", min: "0", required: "", value: d.price ? (d.price / 100).toFixed(2) : "" })),
el("label", {}, "Category", el("input", { name: "category", value: d.category || "" })),
el("label", {}, "Area (approximate)", el("input", { name: "area", value: d.area || "Demo Town" })),
el("label", { class: "full" }, "Description", el("textarea", { name: "description", rows: "3", required: "" }, d.description || "")),
el("label", {}, el("span", {}, el("input", { type: "checkbox", name: "online", checked: payOpts.includes("online") ? "" : null }), ` Accept online payment (${feeBps / 100}% fee, paid by buyer)`)),
el("label", {}, el("span", {}, el("input", { type: "checkbox", name: "cash", checked: payOpts.includes("cash") ? "" : null }), " Accept cash in person (free)")),
el("div", { class: "full" }, el("button", { class: "btn", type: "submit" }, "Publish test listing")));
f.addEventListener("submit", (e) => {
e.preventDefault();
const fd = new FormData(f);
const fields = { title: fd.get("title"), kind: fd.get("kind"), price: fd.get("price"), category: fd.get("category"),
area: fd.get("area"), description: fd.get("description"),
payment_options: [fd.get("online") && "online", fd.get("cash") && "cash"].filter(Boolean) };
talk({ event: { type: "submit_listing", fields }, echo: `Publish “${fields.title}”` });
});
return el("section", { class: "panel" }, el("h2", {}, "List something"), el("div", { class: "by" }, "Form opened by the agent"), f);
},
show_checkout(p) {
const it = p.intent, L = p.listing || {};
const status = el("div", { class: "meta" });
const card = el("input", { name: "card", value: "4242 4242 4242 4242", inputmode: "numeric", "aria-label": "Test card number" });
const exp = el("input", { value: "12/34", "aria-label": "Expiry" }), cvc = el("input", { value: "123", "aria-label": "CVC" });
const payBtn = el("button", { class: "btn", type: "submit" }, `Pay ${money(it.total_cents)} (test)`);
const form = el("form", {}, el("div", { class: "testcard" }, "TEST MODE: no real charge. Success: 4242 4242 4242 4242 · Decline: 4000 0000 0000 0002. Other numbers are refused."),
el("div", { class: "cardform" }, card, exp, cvc), el("div", { style: "margin-top:10px" }, payBtn), status);
form.addEventListener("submit", async (e) => {
e.preventDefault(); payBtn.disabled = true; status.textContent = "Contacting test payment provider…";
const r = await api(`/api/payments/${it.id}/confirm`, { card_number: card.value });
status.textContent = r.status === "succeeded" ? "Paid (test)." : "Declined: " + (r.error || "unknown");
status.className = "meta " + (r.status === "succeeded" ? "ok" : "warn");
payBtn.disabled = r.status === "succeeded";
talk({ event: { type: "payment_result", intent_id: it.id, status: r.status, order_id: r.order && r.order.id, error: r.error } });
});
return el("section", { class: "panel", "data-component": "checkout" }, el("h2", {}, "Checkout"),
el("div", { class: "by" }, `${L.emoji || ""} ${it.title} · sold by ${L.seller || "?"}`),
el("table", { class: "breakdown" },
el("tr", {}, el("td", {}, `Price${it.quantity > 1 ? " × " + it.quantity : ""}`), el("td", {}, money(it.subtotal_cents))),
el("tr", {}, el("td", {}, `Facilitation fee (${(it.fee_bps / 100).toFixed(1)}%, online payments only)`), el("td", { "data-testid": "fee" }, money(it.fee_cents))),
el("tr", { class: "total" }, el("td", {}, "You pay"), el("td", { "data-testid": "total" }, money(it.total_cents))),
el("tr", {}, el("td", { class: "meta" }, "Seller receives"), el("td", { class: "meta" }, money(it.subtotal_cents)))),
form,
"cash" in Object.fromEntries((L.payment_options || []).map((x) => [x, 1])) ?
el("p", { class: "meta" }, "Prefer to skip the fee? ", el("a", { href: "#", onclick: (e) => { e.preventDefault(); talk({ event: { type: "buy_cash", listing_id: L.id }, echo: "Pay cash in person instead" }); } }, "Meet and pay cash in person ($0 fee)")) : null);
},
show_cash_meetup(p) {
const d = p.deal, L = p.listing || {};
const done = el("button", { class: "btn cash" }, "Mark paid in cash");
done.addEventListener("click", () => { done.disabled = true; talk({ event: { type: "cash_completed", deal_id: d.id }, echo: "We met and I paid cash" }); });
return el("section", { class: "panel", "data-component": "cash-meetup" }, el("h2", {}, "Cash in person: no fee"),
el("div", { class: "by" }, `${L.emoji || ""} ${d.title} · seller ${d.seller}`),
el("table", { class: "breakdown" },
el("tr", {}, el("td", {}, "Price (pay the seller directly)"), el("td", {}, money(d.price_cents))),
el("tr", {}, el("td", {}, "Platform fee"), el("td", { "data-testid": "cash-fee" }, money(d.fee_cents))),
el("tr", { class: "total" }, el("td", {}, "Total"), el("td", {}, money(d.price_cents)))),
el("p", {}, `Suggested meetup: ${d.place} · ${d.time}`),
el("p", { class: "meta" }, "We only connect buyer and seller. Meet somewhere public. (Demo: no real meetup or messaging.)"),
el("div", { class: "meta" }, "Deal " + d.id + " · status " + d.status), el("div", { style: "margin-top:10px" }, done));
},
show_receipt(p) {
const o = p.order;
return el("section", { class: "panel", "data-component": "receipt" }, el("h2", { class: "ok" }, o.method === "cash_in_person" ? "Cash deal completed" : "Payment confirmed (test)"),
el("table", { class: "breakdown" },
el("tr", {}, el("td", {}, o.title), el("td", {}, money(o.subtotal_cents))),
el("tr", {}, el("td", {}, "Facilitation fee"), el("td", { "data-testid": "receipt-fee" }, money(o.fee_cents))),
el("tr", { class: "total" }, el("td", {}, "Total"), el("td", {}, money(o.total_cents))),
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; },
};
function buyButtons(x) {
const opts = x.payment_options || [];
if (x.status !== "active") return el("span", { class: "meta" }, "Sold");
return el("div", { class: "actions" },
opts.includes("online") ? el("button", { class: "btn", onclick: () => talk({ event: { type: "buy_online", listing_id: x.id }, echo: `Buy “${x.title}” online` }) }, "Buy online") : null,
opts.includes("cash") ? el("button", { class: "btn cash", onclick: () => talk({ event: { type: "buy_cash", listing_id: x.id }, echo: `Meet & pay cash for “${x.title}”` }) }, "Cash meetup") : null);
}
function card(x) {
const cb = el("input", { type: "checkbox", "aria-label": "compare" });
cb.checked = compareSet.has(x.id);
cb.addEventListener("change", () => { cb.checked ? compareSet.add(x.id) : compareSet.delete(x.id); updateCompareBar(); });
return el("div", { class: "card" + (x.status !== "active" ? " sold" : ""), "data-listing": x.id },
el("div", { class: "emoji" }, x.emoji || "📦"), el("h3", {}, x.title),
el("div", { class: "price" }, money(x.price), el("span", { class: "meta" }, x.kind === "service" ? " / session" : "")),
el("div", {}, el("span", { class: "tag" }, x.kind), x.seller_type === "agent" ? el("span", { class: "tag agent-tag" }, "sold by an agent") : null,
el("span", { class: "tag" }, "test listing")),
el("div", { class: "meta" }, x.area + " · " + x.seller),
el("label", { class: "meta" }, cb, " compare"), buyButtons(x));
}
const cmpbar = el("div", { class: "cmpbar" }); document.body.append(cmpbar);
function updateCompareBar() {
cmpbar.innerHTML = ""; cmpbar.style.display = compareSet.size ? "flex" : "none";
cmpbar.append(`${compareSet.size} selected`, el("button", { class: "btn", onclick: () => talk({ event: { type: "compare", listing_ids: [...compareSet] }, echo: "Compare selected" }) }, "Compare"));
}
function render(actions) {
let first = true;
for (const a of actions) {
const fn = R[a.type]; if (!fn) continue;
const node = fn(a.payload || {});
if (!node) continue;
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" });
}
// ---------- wiring ----------
document.addEventListener("click", (e) => {
const b = e.target.closest("[data-say],[data-event]"); if (!b) return;
if (b.dataset.say) talk({ message: b.dataset.say, echo: b.dataset.say });
else talk({ event: { type: b.dataset.event }, echo: b.textContent.trim() });
});
$("#chatLauncher").addEventListener("click", openChat);
$("#chatClose").addEventListener("click", () => { $("#chat").classList.add("hidden"); $("#chatLauncher").style.display = ""; });
$("#chatForm").addEventListener("submit", (e) => {
e.preventDefault(); const v = $("#chatInput").value.trim(); if (!v) return;
$("#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; AMTheme.init(copyDefaults(feeBps));
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.
(async () => {
const out = await api("/api/chat", { event: { type: "landing" }, actor });
render(out.actions || []);
say("bot", "Hi! I run this page. Ask me to find, compare, sell, or buy. Payments are test mode only.");
})();
})();

View File

@ -1,51 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agent Market (demo)</title>
<link rel="stylesheet" href="/static/styles.css">
<style id="user-css"></style>
</head>
<body>
<!-- Protected: the test banner, the agent badge and the checkout/fee panels can't be restyled by themes. -->
<div class="testbar" data-protected>TEST DEMO: all listings are fake and payments run in test mode. No real money moves.</div>
<header class="top">
<div class="brand"><span class="logo">◆</span> <span id="logoText">Agent Market</span></div>
<nav>
<button class="link" data-say="show me everything">Browse</button>
<button class="link" data-event="sell">Sell</button>
<a class="link" href="/agent.json" target="_blank">For agents</a>
<button class="link" id="resetLook" title="Restore the default look">Reset look</button>
<span id="agentBadge" class="badge" data-protected>agent: …</span>
</nav>
</header>
<main id="page">
<section class="hero-sec" data-section="hero">
<h1 id="headline">A marketplace you can just talk to.</h1>
<p id="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.</p>
</section>
<div class="chips" data-section="chips" id="chips"></div>
<ul class="facts" data-section="bullets" id="bullets"></ul>
<section id="stage" data-section="results" aria-live="polite"></section>
<footer class="foot" data-section="footer" id="footer">Agent Market demo · fake test data · test-mode payments · <a href="/openapi.json" target="_blank">open API</a></footer>
</main>
<button id="chatLauncher" class="launcher" aria-label="Open the market agent">💬 Ask the agent</button>
<aside id="chat" class="chat hidden" aria-label="Market agent chat">
<div class="chat-head">
<div><b>Market Guide</b><div class="sub" id="chatMode">AI agent</div></div>
<button id="chatClose" aria-label="Close">✕</button>
</div>
<div id="log" class="log"></div>
<form id="chatForm" class="chat-form">
<input id="chatInput" autocomplete="off" placeholder="e.g. find a bike, or: make it look retro">
<button>Send</button>
</form>
</aside>
<div id="toasts"></div>
<script src="/static/theme.js"></script>
<script src="/static/app.js"></script>
</body>
</html>

View File

@ -1,174 +0,0 @@
// 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))");
// 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)));
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
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,
};
})();

View File

@ -15,6 +15,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await page.goto(BASE + "/", { waitUntil: "networkidle0" });
await page.waitForSelector(".card", { timeout: 15000 });
check("landing renders agent-chosen cards", (await page.$$(".card")).length >= 4);
check("React app mounted in #root", await page.evaluate(() => !!document.querySelector("#root > .testbar") && !!document.querySelector("#root main#page #stage [data-component=cards]")));
const badge = await page.$eval("#agentBadge", (n) => n.textContent);
check("agent mode badge shown", /fallback|built-in|AI:|LLM/.test(badge), badge);
@ -82,6 +83,21 @@ 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);
// 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()));
const bar = await page.$eval(".cmpbar", (n) => ({ t: n.textContent, d: getComputedStyle(n).display }));
await page.click(".cmpbar button");
await page.waitForFunction(() => document.querySelectorAll("[data-component=compare] tr:first-child th").length === 3, { timeout: 15000 }).catch(() => {});
check("compare bar: 2 selected -> compare view", bar.d === "flex" && /^2 selected/.test(bar.t) && (await page.$$("[data-component=compare] tr:first-child th")).length === 3, bar.t);
// chat popup close / reopen
await page.click("#chatClose");
const closed = await page.evaluate(() => document.getElementById("chat").classList.contains("hidden") && getComputedStyle(document.getElementById("chatLauncher")).display !== "none");
await page.click("#chatLauncher");
const reopened = await page.evaluate(() => !document.getElementById("chat").classList.contains("hidden") && document.querySelectorAll("#log .msg").length > 5);
check("chat popup closes and reopens with history", closed && reopened);
check("no page JS errors", errors.length === 0, errors.join("; "));
await browser.close();
const passed = results.filter(Boolean).length;

View File

@ -28,7 +28,12 @@ def check(name, cond, detail=""):
s, html = call("GET", "/", raw=True)
check("landing page loads", s == 200 and "Agent Market" in html and "chatLauncher" in html)
import re
check("landing page loads (React SPA shell)", s == 200 and "Agent Market" in html and '<div id="root">' in html)
assets = re.findall(r'(?:src|href)="(/static/dist/assets/[^"]+)"', html)
js = [a for a in assets if a.endswith(".js")]
bundle = call("GET", js[0], raw=True) if js else (0, "")
check("built JS bundle served", bundle[0] == 200 and "chatLauncher" in bundle[1] and "show_checkout" in bundle[1], f"assets={len(assets)}")
s, st = call("GET", "/api/status")
check("status endpoint", s == 200 and st["payments"]["mode"] == "test", f"agent={st['agent']['mode']} fee_bps={st['fee_bps']}")
fee_bps = st["fee_bps"]

View File

@ -0,0 +1,67 @@
// React UI against the fake OpenAI-compatible model (tests/mock_llm.py): model badge + chat mode text, fallback badge
// texts, quota badge, rate-limit toast, and a model-sent set_theme that the server sanitised. No real model calls.
// Usage: NODE_PATH=<dir with puppeteer-core> node tests/fake_model_browser.js <mock-model> <busy-chain> <daily-quota> <rate-limited>
const puppeteer = require("puppeteer-core");
const [OK, BUSY, DAILY, RL] = process.argv.slice(2, 6);
const results = [];
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-fake-model-" + Date.now()] });
const errors = [];
const open = async (base) => {
const ctx = await browser.createBrowserContext(); const page = await ctx.newPage();
await page.setViewport({ width: 1280, height: 860 }); page.on("pageerror", (e) => errors.push(e.message));
await page.goto(base + "/", { waitUntil: "networkidle0" }); await page.waitForSelector(".card", { timeout: 15000 });
return page;
};
const ask = async (page, text) => {
const n = (await page.$$(".msg.bot")).length;
if (await page.$eval("#chat", (c) => c.classList.contains("hidden"))) await page.click("#chatLauncher");
await page.type("#chatInput", text); await page.keyboard.press("Enter");
await page.waitForFunction((n) => document.querySelectorAll(".msg.bot").length > n, { timeout: 30000 }, n);
await sleep(300);
return page.$$eval(".msg.bot", (ns) => ns[ns.length - 1].textContent);
};
const text = (page, sel) => page.$eval(sel, (n) => n.textContent);
// 1) model answers: badge + chat mode show the model; tool-hydrated cards render; unknown component ignored
let p = await open(OK);
check("badge shows configured model on load", (await text(p, "#agentBadge")) === "AI: mock-model", await text(p, "#agentBadge"));
await ask(p, "show me bikes");
const mode = await text(p, "#chatMode"), badge = await text(p, "#agentBadge"), title = await p.$eval("#agentBadge", (n) => n.title);
check("model reply: badge + chat mode", badge === "AI: mock-model" && /^AI agent · mock-model · \d+\.\ds$/.test(mode) && title === "mock-model via OpenRouter (free tier)", `${badge} | ${mode} | ${title}`);
const titles = await p.$$eval("#stage .card h3", (ns) => ns.map((n) => n.textContent));
check("model-chosen cards rendered through the registry", titles.length > 0 && titles.every((t) => /bike/i.test(t)) && (await p.$$("#stage > section")).length === 1, titles.join(" | "));
// 2) model restyle: server-sanitised set_theme applied; HTML in copy rendered as text; customCss scoped
const reply = await ask(p, "make the background a sunset gradient");
const st = await p.evaluate(() => ({ bg: getComputedStyle(document.body).backgroundImage, img: !!document.querySelector("#headline img"),
h: document.getElementById("headline").textContent, css: document.getElementById("user-css").textContent,
toast: [...document.querySelectorAll("#toasts .toast")].map((t) => t.textContent).join("|"), ls: JSON.parse(localStorage.getItem("am_theme_v1") || "{}") }));
check("model set_theme applied (gradient, toast, saved)", /gradient/.test(st.bg) && /Look updated/.test(st.toast) && st.ls.background, st.bg.slice(0, 60));
check("model copy/CSS sanitised in the page", !st.img && /Sunset/.test(st.h) && !/</.test(st.h) && /@layer user/.test(st.css) && /#page/.test(st.css) && !/@import|position/.test(st.css), st.css.replace(/\s+/g, " ").slice(0, 90));
check("no internal ids in the model reply", !/lst_/.test(reply), reply);
// 3) every model rate-limited -> built-in agent answers, fallback badge text
p = await open(BUSY);
await ask(p, "show me bikes");
check("fallback badge + chat mode text", (await text(p, "#agentBadge")) === "agent: built-in (AI unavailable)" && (await text(p, "#chatMode")) === "built-in agent answered (AI model unavailable)" && (await p.$$("#stage .card")).length > 0,
`${await text(p, "#agentBadge")} | ${await text(p, "#chatMode")}`);
// 4) daily quota used up -> after reload the badge says so (from /api/status)
p = await open(DAILY);
await ask(p, "show me bikes");
await p.reload({ waitUntil: "networkidle0" }); await sleep(300);
check("quota badge after reload", (await text(p, "#agentBadge")) === "agent: built-in (AI quota used up)" && /resets/.test(await p.$eval("#agentBadge", (n) => n.title)), await text(p, "#agentBadge"));
// 5) per-IP rate limit -> warning toast
p = await open(RL);
await ask(p, "show me bikes"); await ask(p, "show me bikes");
const toasts = await p.$$eval("#toasts .toast.warn", (ns) => ns.map((n) => n.textContent));
check("rate-limit warning toast", toasts.some((t) => /quickly/.test(t)), toasts.join("|"));
check("no page JS errors", errors.length === 0, errors.join("; "));
await browser.close();
const passed = results.filter(Boolean).length;
console.log(`\n${passed}/${results.length} passed`);
process.exit(passed === results.length ? 0 : 1);
})().catch((e) => { console.error(e); process.exit(2); });

15
tests/fake_model_browser.sh Executable file
View File

@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Browser checks of the React UI against the fake model (no real key, no real model calls). Needs a built frontend
# (cd web && npm ci && npm run build) and puppeteer-core on NODE_PATH.
set -euo pipefail
cd "$(dirname "$0")/.."
[ -f static/dist/index.html ] || { echo "build the frontend first (cd web && npm run build)"; exit 1; }
D=$(mktemp -d); KEYF=$D/key; printf 'test-dummy-key' > $KEYF; chmod 600 $KEYF
python3 tests/mock_llm.py 18803 & M=$!
start() { # port models rpm burst
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=$4 \
AGENT_MARKET_LLM_BASE_URL=http://127.0.0.1:18803/v1 AGENT_MARKET_LLM_MODELS=$2 python3 server.py >/dev/null 2>&1 & echo $!; }
S1=$(start 18804 mock-model 0 6); S2=$(start 18805 busy-model,busy-model-2 0 6); S3=$(start 18806 daily-model 0 6); S4=$(start 18807 mock-model 1 1)
trap 'kill $M $S1 $S2 $S3 $S4 2>/dev/null; rm -rf $D' EXIT
sleep 1.5
node tests/fake_model_browser.js http://127.0.0.1:18804 http://127.0.0.1:18805 http://127.0.0.1:18806 http://127.0.0.1:18807

View File

@ -109,6 +109,11 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const h = await page.$eval("#headline", (n) => n.textContent);
check("headline changed", /buy and sell anything nearby/i.test(h), h);
// The look is stored per browser: a reload keeps it.
await page.reload({ waitUntil: "networkidle0" }); await page.waitForSelector(".card", { timeout: 15000 });
check("look persists across reload (localStorage)", /buy and sell anything nearby/i.test(await page.$eval("#headline", (n) => n.textContent)) && (await cs("#bullets", "display")) === "none");
await page.click("#chatLauncher");
// 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" });
@ -120,6 +125,22 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
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));
// A hostile stored theme (tampered localStorage) can't hide or recolour protected parts, and a hidden results
// section is shown again when the agent renders a panel. Then the "Reset look" button restores the default.
await page.evaluate(() => localStorage.setItem("am_theme_v1", JSON.stringify({
palette: { bg: "#ffffff", surface: "#fefefe", text: "#fdfdfd", muted: "#fefefe", accent: "#ffff00" }, sections: { hidden: ["results"] },
customCss: ".testbar{display:none!important;opacity:0} #agentBadge{visibility:hidden;color:#fff} [data-component=checkout] *{color:#fff!important;background:#fff} body{--p-ink:#fff} .card{border-width:3px}" })));
await page.reload({ waitUntil: "networkidle0" }); await page.waitForSelector(".card", { timeout: 15000 }); await sleep(300);
const hv = await page.evaluate(() => ({ stage: getComputedStyle(document.getElementById("stage")).display, css: document.getElementById("user-css").textContent,
toast: [...document.querySelectorAll("#toasts .toast")].map((t) => t.textContent).join("|"), hidden: (JSON.parse(localStorage.getItem("am_theme_v1") || "{}").sections || {}).hidden }));
check("hidden results shown again for agent panels", hv.stage !== "none" && /Results section shown again/.test(hv.toast) && !hv.hidden.includes("results"), hv.toast);
check("tampered stored customCss dropped by the client (fail-closed)", /^@layer user/.test(hv.css) && !/testbar|important|--p-ink|agentBadge/.test(hv.css), hv.css.replace(/\s+/g, " ").slice(0, 120));
await guardrails("hostile stored theme");
await page.click("#resetLook"); await sleep(300);
const rs = await page.evaluate(() => ({ ls: localStorage.getItem("am_theme_v1"), h: document.getElementById("headline").textContent, css: document.getElementById("user-css").textContent,
toast: [...document.querySelectorAll("#toasts .toast")].map((t) => t.textContent).join("|") }));
check("Reset look button restores the default", rs.ls === null && /just talk to/.test(rs.h) && rs.css === "" && /Look reset to default/.test(rs.toast), JSON.stringify(rs).slice(0, 120));
check("no internal ids in replies", replies.every((x) => !/\b(lst|pi|cash|ord)_[0-9a-f]{6,}/.test(x.reply)));
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);

16
web/index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agent Market (demo)</title>
<!-- Cascade-layer order must be declared before any layered CSS: base < user (customCss) < protected (banner, badge, fee panels). -->
<style>@layer base, user, protected;</style>
<!-- Sanitised, scoped per-visitor customCss lands here inside @layer user (see src/theme/engine.ts). -->
<style id="user-css"></style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1722
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
web/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "agent-market-web",
"private": true,
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"devDependencies": {
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^5.2.0",
"typescript": "5.9",
"vite": "^7.3.7"
}
}

178
web/src/App.tsx Normal file
View File

@ -0,0 +1,178 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { actor, api } from "./api";
import { Ctx, type AgentCtx, type Talk } from "./context";
import { EFFECTS, PANELS } from "./components/registry";
import { Chat, type Msg } from "./components/Chat";
import {
applyThemeToDocument, contrast, loadTheme, reduceThemeAction, resolveCopy, resolveSections, saveTheme,
type Copy, type Section, type Theme, type ThemeAction,
} from "./theme/engine";
import type { ChatReply, Status, UIAction } from "./types";
declare global { interface Window { __amContrast: typeof contrast } }
window.__amContrast = contrast; // used by the guardrail tests
const copyDefaults = (bps: number): 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.",
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)"],
});
interface Panel { key: string; type: string; payload: any }
interface Toast { id: number; text: string; level: string }
let seq = 0;
export default function App() {
const [theme, setTheme] = useState<Theme>(loadTheme);
const themeRef = useRef(theme); themeRef.current = theme;
const [feeBps, setFeeBps] = useState(500);
const [panels, setPanels] = useState<Panel[]>([]);
const [msgs, setMsgs] = useState<Msg[]>([]);
const [chatOpen, setChatOpen] = useState(false);
const [chatMode, setChatMode] = useState("AI agent");
const [badge, setBadge] = useState({ text: "agent: …", title: "" });
const [toasts, setToasts] = useState<Toast[]>([]);
const [compareIds, setCompareIds] = useState<string[]>([]);
const history = useRef<{ role: string; content: string }[]>([]);
const stageRef = useRef<HTMLElement>(null);
// Theme: tokens/classes/customCss go onto the document; copy + sections are rendered below.
useLayoutEffect(() => { applyThemeToDocument(theme); saveTheme(theme); }, [theme]);
const resetTheme = useCallback(() => { themeRef.current = {}; setTheme({}); }, []);
const toast = useCallback((text: string, level: "info" | "ok" | "warn" = "info") => {
const id = ++seq; setToasts((ts) => [...ts, { id, text, level }]);
setTimeout(() => setToasts((ts) => ts.filter((t) => t.id !== id)), 3500);
}, []);
const say = useCallback((role: Msg["role"], text: string) => { const id = ++seq; setMsgs((m) => [...m, { id, role, text }]); return id; }, []);
const unsay = useCallback((id: number) => setMsgs((m) => m.filter((x) => x.id !== id)), []);
const setModel = useCallback((model?: string, ms?: number) => {
const short = (model || "").replace(/:free$/, "");
setBadge({ text: "AI: " + short, title: (model || "") + " via OpenRouter (free tier)" });
setChatMode(`AI agent · ${short}${ms ? " · " + (ms / 1000).toFixed(1) + "s" : ""}`);
}, []);
// Render agent actions through the registry: effects run, panels replace the results area.
const render = useCallback((actions: UIAction[]) => {
// Theme changes in this batch are folded locally so a panel later in the same batch sees them
// (e.g. a set_theme that hides "results" followed by a checkout un-hides it again, like before).
let t = themeRef.current;
const fx = { toast, applyTheme: (a: ThemeAction) => { t = reduceThemeAction(t, a); }, clearStage: () => setPanels([]) };
const batch: Panel[] = [];
for (const a of actions) {
if (EFFECTS[a.type]) EFFECTS[a.type](a.payload || {}, fx);
else if (PANELS[a.type]) batch.push({ key: `${++seq}`, type: a.type, payload: a.payload || {} });
}
if (batch.length) {
const hidden = t.sections?.hidden;
if (Array.isArray(hidden) && hidden.includes("results")) {
t = { ...t, sections: { ...t.sections, hidden: hidden.filter((x: string) => x !== "results") } };
toast("Results section shown again so you can see this", "info");
}
}
if (t !== themeRef.current) { themeRef.current = t; setTheme(t); }
if (!batch.length) return;
setPanels(batch);
setTimeout(() => stageRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 0);
}, [toast]);
const talk = useCallback(async ({ message, event, echo }: Talk) => {
setChatOpen(true);
if (echo) say("user", echo);
const thinking = say("sys", "agent is thinking…");
try {
const out = await api<ChatReply>("/api/chat", { message, event, history: history.current.slice(-6), actor, theme: themeRef.current });
unsay(thinking);
if (message) history.current.push({ role: "user", content: message });
if (event) history.current.push({ role: "user", content: "UI_EVENT " + JSON.stringify(event) });
history.current.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") {
setChatMode("built-in agent answered (AI model unavailable)");
setBadge({ text: "agent: built-in (AI unavailable)", 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) { unsay(thinking); say("sys", "Agent error: " + (e as Error).message); }
}, [say, unsay, setModel, toast, render]);
// Boot: status (fee + badge), then the deterministic landing view and greeting.
useEffect(() => {
api<Status>("/api/status").then((s) => {
setFeeBps(s.fee_bps);
if (s.agent.mode === "llm" && s.agent.daily_quota_exhausted_until_utc)
setBadge({ text: "agent: built-in (AI quota used up)", 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 setBadge({ text: "agent: rule-based fallback", title: s.agent.note || "" });
});
api<ChatReply>("/api/chat", { event: { type: "landing" }, actor }).then((out) => {
render(out.actions || []);
say("bot", "Hi! I run this page. Ask me to find, compare, sell, or buy. Payments are test mode only.");
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const ctx: AgentCtx = useMemo(() => ({
talk, feeBps, toast,
compare: {
has: (id) => compareIds.includes(id),
toggle: (id, on) => setCompareIds((ids) => (on ? [...new Set([...ids, id])] : ids.filter((x) => x !== id))),
},
}), [talk, feeBps, toast, compareIds]);
const copy = resolveCopy(theme, copyDefaults(feeBps));
const { order, hidden } = resolveSections(theme);
const sec = (s: Section) => ({ "data-section": s, style: { order: order[s] }, className: hidden.has(s) ? "is-hidden" : undefined });
const cls = (base: string, s: Section) => [base, hidden.has(s) ? "is-hidden" : ""].filter(Boolean).join(" ");
return (
<Ctx.Provider value={ctx}>
{/* Protected: the test banner, the agent badge and the checkout/fee panels can't be restyled by themes. */}
<div className="testbar" data-protected>TEST DEMO: all listings are fake and payments run in test mode. No real money moves.</div>
<header className="top">
<div className="brand"><span className="logo">◆</span> <span id="logoText">{copy.logo}</span></div>
<nav>
<button className="link" data-say="show me everything" onClick={() => talk({ message: "show me everything", echo: "show me everything" })}>Browse</button>
<button className="link" data-event="sell" onClick={() => talk({ event: { type: "sell" }, echo: "Sell" })}>Sell</button>
<a className="link" href="/agent.json" target="_blank">For agents</a>
<button className="link" id="resetLook" title="Restore the default look" onClick={() => { resetTheme(); toast("Look reset to default", "ok"); }}>Reset look</button>
<span id="agentBadge" className="badge" data-protected title={badge.title}>{badge.text}</span>
</nav>
</header>
<main id="page">
<section {...sec("hero")} className={cls("hero-sec", "hero")}>
<h1 id="headline">{copy.headline}</h1>
<p id="subhead">{copy.subhead}</p>
</section>
<div {...sec("chips")} className={cls("chips", "chips")} id="chips">
{copy.chips.map((c, i) => (
<button key={i} className="chip" data-say={c.event ? undefined : String(c.say || c.label).slice(0, 120)} data-event={c.event}
onClick={() => c.event ? talk({ event: { type: c.event }, echo: c.label.slice(0, 40) }) : talk({ message: String(c.say || c.label).slice(0, 120), echo: String(c.say || c.label).slice(0, 120) })}>
{c.label.slice(0, 40)}</button>
))}
</div>
<ul {...sec("bullets")} className={cls("facts", "bullets")} id="bullets">
{copy.bullets.map((b, i) => <li key={i}>{b.slice(0, 140)}</li>)}
</ul>
<section {...sec("results")} className={hidden.has("results") ? "is-hidden" : undefined} id="stage" ref={stageRef} aria-live="polite">
{panels.map((p) => { const C = PANELS[p.type]; return <C key={p.key} payload={p.payload} />; })}
</section>
<footer {...sec("footer")} className={cls("foot", "footer")} id="footer">
{copy.footer || <>Agent Market demo · fake test data · test-mode payments · <a href="/openapi.json" target="_blank">open API</a></>}
</footer>
</main>
<Chat open={chatOpen} setOpen={setChatOpen} mode={chatMode} msgs={msgs}
onSend={(v) => talk({ message: v, echo: v })} />
<div id="toasts">{toasts.map((t) => <div key={t.id} className={"toast " + t.level}>{t.text}</div>)}</div>
<div className="cmpbar" style={{ display: compareIds.length ? "flex" : "none" }}>
{compareIds.length} selected
<button className="btn" onClick={() => talk({ event: { type: "compare", listing_ids: compareIds }, echo: "Compare selected" })}>Compare</button>
</div>
</Ctx.Provider>
);
}

13
web/src/api.ts Normal file
View File

@ -0,0 +1,13 @@
// Thin fetch wrapper. X-Agent-Id identifies this browser's demo actor (same as the pre-React app).
export const actor: string = (() => {
const a = localStorage.getItem("am_actor") || "guest-" + Math.random().toString(36).slice(2, 7);
localStorage.setItem("am_actor", a);
return a;
})();
export async function api<T = any>(path: string, body?: unknown): Promise<T> {
const r = await fetch(path, body !== undefined
? { method: "POST", headers: { "Content-Type": "application/json", "X-Agent-Id": actor }, body: JSON.stringify(body) }
: { headers: { "X-Agent-Id": actor } });
return r.json();
}

View File

@ -0,0 +1,31 @@
import { useEffect, useRef, useState } from "react";
export interface Msg { id: number; role: "user" | "bot" | "sys"; text: string }
/** Chat popup. Typed messages go to the model via /api/chat; position/size/colours come from the theme. */
export function Chat({ open, setOpen, mode, msgs, onSend }: {
open: boolean; setOpen: (o: boolean) => void; mode: string; msgs: Msg[]; onSend: (text: string) => void;
}) {
const [value, setValue] = useState("");
const logRef = useRef<HTMLDivElement>(null);
useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [msgs, open]);
return (
<>
<button id="chatLauncher" className="launcher" aria-label="Open the market agent" style={{ display: open ? "none" : undefined }}
onClick={() => setOpen(true)}>💬 Ask the agent</button>
<aside id="chat" className={"chat" + (open ? "" : " hidden")} aria-label="Market agent chat">
<div className="chat-head">
<div><b>Market Guide</b><div className="sub" id="chatMode">{mode}</div></div>
<button id="chatClose" aria-label="Close" onClick={() => setOpen(false)}>✕</button>
</div>
<div id="log" className="log" ref={logRef}>
{msgs.map((m) => <div key={m.id} className={"msg " + m.role}>{m.text}</div>)}
</div>
<form id="chatForm" className="chat-form" onSubmit={(e) => { e.preventDefault(); const v = value.trim(); if (!v) return; setValue(""); onSend(v); }}>
<input id="chatInput" autoComplete="off" placeholder="e.g. find a bike, or: make it look retro" value={value} onChange={(e) => setValue(e.target.value)} />
<button>Send</button>
</form>
</aside>
</>
);
}

View File

@ -0,0 +1,37 @@
import { useAgent } from "../context";
interface Defaults { title?: string; kind?: string; price?: number; category?: string; area?: string; description?: string; payment_options?: string[] }
/** show_listing_form — prefilled by the agent; Publish sends a deterministic submit_listing event. */
export function ListingForm({ payload }: { payload: { defaults?: Defaults } }) {
const { talk, feeBps } = useAgent();
const d = payload.defaults || {};
const pay = Array.isArray(d.payment_options) && d.payment_options.length ? d.payment_options : ["online", "cash"];
const submit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const fields = {
title: fd.get("title"), kind: fd.get("kind"), price: fd.get("price"), category: fd.get("category"),
area: fd.get("area"), description: fd.get("description"),
payment_options: [fd.get("online") && "online", fd.get("cash") && "cash"].filter(Boolean),
};
talk({ event: { type: "submit_listing", fields }, echo: `Publish “${fields.title}”` });
};
return (
<section className="panel">
<h2>List something</h2>
<div className="by">Form opened by the agent</div>
<form className="form" data-component="listing-form" onSubmit={submit}>
<label className="full">Title<input name="title" required defaultValue={d.title || ""} placeholder="e.g. Vintage desk chair" /></label>
<label>Type<select name="kind" defaultValue={d.kind === "service" ? "service" : "product"}><option value="product">Product</option><option value="service">Service</option></select></label>
<label>Price (USD)<input name="price" type="number" step="0.01" min="0" required defaultValue={d.price ? (d.price / 100).toFixed(2) : ""} /></label>
<label>Category<input name="category" defaultValue={d.category || ""} /></label>
<label>Area (approximate)<input name="area" defaultValue={d.area || "Demo Town"} /></label>
<label className="full">Description<textarea name="description" rows={3} required defaultValue={d.description || ""} /></label>
<label><span><input type="checkbox" name="online" defaultChecked={pay.includes("online")} /> Accept online payment ({feeBps / 100}% fee, paid by buyer)</span></label>
<label><span><input type="checkbox" name="cash" defaultChecked={pay.includes("cash")} /> Accept cash in person (free)</span></label>
<div className="full"><button className="btn" type="submit">Publish test listing</button></div>
</form>
</section>
);
}

View File

@ -0,0 +1,87 @@
import { useState } from "react";
import { useAgent } from "../context";
import { money, type Listing, type Quote } from "../types";
export function BuyButtons({ x }: { x: Listing }) {
const { talk } = useAgent();
const opts = x.payment_options || [];
if (x.status !== "active") return <span className="meta">Sold</span>;
return (
<div className="actions">
{opts.includes("online") && (
<button className="btn" onClick={() => talk({ event: { type: "buy_online", listing_id: x.id }, echo: `Buy “${x.title}” online` })}>Buy online</button>
)}
{opts.includes("cash") && (
<button className="btn cash" onClick={() => talk({ event: { type: "buy_cash", listing_id: x.id }, echo: `Meet & pay cash for “${x.title}”` })}>Cash meetup</button>
)}
</div>
);
}
export function ListingCard({ x }: { x: Listing }) {
const { compare } = useAgent();
const [checked, setChecked] = useState(compare.has(x.id));
return (
<div className={"card" + (x.status !== "active" ? " sold" : "")} data-listing={x.id}>
<div className="emoji">{x.emoji || "📦"}</div>
<h3>{x.title}</h3>
<div className="price">{money(x.price)}<span className="meta">{x.kind === "service" ? " / session" : ""}</span></div>
<div>
<span className="tag">{x.kind}</span>
{x.seller_type === "agent" && <span className="tag agent-tag">sold by an agent</span>}
<span className="tag">test listing</span>
</div>
<div className="meta">{x.area} · {x.seller}</div>
<label className="meta">
<input type="checkbox" aria-label="compare" checked={checked}
onChange={(e) => { setChecked(e.target.checked); compare.toggle(x.id, e.target.checked); }} /> compare
</label>
<BuyButtons x={x} />
</div>
);
}
/** show_cards — results grid. The theme's `layout: "list"` turns the same grid into rows via CSS. */
export function Results({ payload }: { payload: { heading?: string; listings?: Listing[] } }) {
return (
<section className="panel" data-component="cards">
<h2>{payload.heading || "Listings"}</h2>
<div className="by">Rendered by the agent</div>
<div className="grid">{(payload.listings || []).map((x) => <ListingCard key={x.id} x={x} />)}</div>
</section>
);
}
/** show_compare — side-by-side table with online-vs-cash pricing. */
export function Compare({ payload }: { payload: { heading?: string; listings?: Listing[]; quotes?: Quote[] } }) {
const L = payload.listings || [], Q = payload.quotes || [];
const row = (label: string, f: (x: Listing, q: Quote) => React.ReactNode) => (
<tr><th>{label}</th>{L.map((x, i) => <td key={x.id}>{f(x, Q[i] || {})}</td>)}</tr>
);
return (
<section className="panel" data-component="compare">
<h2>{payload.heading || "Compare"}</h2>
<table className="cmp"><tbody>
<tr><th></th>{L.map((x) => <th key={x.id}>{x.emoji} {x.title}</th>)}</tr>
{row("Type", (x) => x.kind)}
{row("Price", (x) => money(x.price))}
{row("Pay online (test)", (_, q) => q.online?.available ? `${money(q.online.total_cents)} incl. ${money(q.online.fee_cents)} fee` : "not offered")}
{row("Cash in person", (_, q) => q.cash_in_person?.available ? `${money(q.cash_in_person.total_cents)} · $0 fee` : "not offered")}
{row("Seller", (x) => x.seller + (x.seller_type === "agent" ? " 🤖" : ""))}
{row("Area", (x) => x.area)}
{row("", (x) => <BuyButtons x={x} />)}
</tbody></table>
</section>
);
}
/** show_hero — welcome panel inside the results area. */
export function HeroPanel({ payload }: { payload: { title?: string; subtitle?: string; bullets?: string[] } }) {
return (
<section className="panel hero" data-component="hero">
<h2>{payload.title || "Agent Market"}</h2>
<p>{payload.subtitle || ""}</p>
<ul>{(payload.bullets || []).map((b, i) => <li key={i}>{b}</li>)}</ul>
</section>
);
}

View File

@ -0,0 +1,93 @@
// PROTECTED components: checkout, cash meetup and receipt. Their fee lines, totals, test-card notice and
// card inputs sit in the `protected` CSS layer (styles.css) and use --p-bg/--p-ink, which the theme engine
// always sets to a solid colour + black/white ink (>= 4.5:1). Keep the data-component / data-testid hooks.
import { useState } from "react";
import { api } from "../api";
import { useAgent } from "../context";
import { money, type Deal, type Intent, type Listing, type Order } from "../types";
/** show_checkout — card details go straight to the test payment endpoint, never to the agent/LLM. */
export function Checkout({ payload }: { payload: { intent: Intent; listing?: Listing } }) {
const { talk } = useAgent();
const it = payload.intent, L = payload.listing;
const [card, setCard] = useState("4242 4242 4242 4242");
const [status, setStatus] = useState<{ text: string; cls: string }>({ text: "", cls: "meta" });
const [busy, setBusy] = useState(false);
const pay = async (e: React.FormEvent) => {
e.preventDefault(); setBusy(true); setStatus({ text: "Contacting test payment provider…", cls: "meta" });
const r = await api(`/api/payments/${it.id}/confirm`, { card_number: card });
const ok = r.status === "succeeded";
setStatus({ text: ok ? "Paid (test)." : "Declined: " + (r.error || "unknown"), cls: "meta " + (ok ? "ok" : "warn") });
setBusy(ok);
talk({ event: { type: "payment_result", intent_id: it.id, status: r.status, order_id: r.order?.id, error: r.error } });
};
return (
<section className="panel" data-component="checkout">
<h2>Checkout</h2>
<div className="by">{L?.emoji || ""} {it.title} · sold by {L?.seller || "?"}</div>
<table className="breakdown"><tbody>
<tr><td>Price{it.quantity > 1 ? " × " + it.quantity : ""}</td><td>{money(it.subtotal_cents)}</td></tr>
<tr><td>Facilitation fee ({(it.fee_bps / 100).toFixed(1)}%, online payments only)</td><td data-testid="fee">{money(it.fee_cents)}</td></tr>
<tr className="total"><td>You pay</td><td data-testid="total">{money(it.total_cents)}</td></tr>
<tr><td className="meta">Seller receives</td><td className="meta">{money(it.subtotal_cents)}</td></tr>
</tbody></table>
<form onSubmit={pay}>
<div className="testcard">TEST MODE: no real charge. Success: 4242 4242 4242 4242 · Decline: 4000 0000 0000 0002. Other numbers are refused.</div>
<div className="cardform">
<input name="card" value={card} onChange={(e) => setCard(e.target.value)} inputMode="numeric" aria-label="Test card number" />
<input defaultValue="12/34" aria-label="Expiry" />
<input defaultValue="123" aria-label="CVC" />
</div>
<div style={{ marginTop: 10 }}><button className="btn" type="submit" disabled={busy}>Pay {money(it.total_cents)} (test)</button></div>
<div className={status.cls}>{status.text}</div>
</form>
{L?.payment_options?.includes("cash") && (
<p className="meta">Prefer to skip the fee?{" "}
<a href="#" onClick={(e) => { e.preventDefault(); talk({ event: { type: "buy_cash", listing_id: L.id }, echo: "Pay cash in person instead" }); }}>
Meet and pay cash in person ($0 fee)</a></p>
)}
</section>
);
}
/** show_cash_meetup — $0 platform fee; the site only connects buyer and seller. */
export function CashMeetup({ payload }: { payload: { deal: Deal; listing?: Listing } }) {
const { talk } = useAgent();
const d = payload.deal, L = payload.listing;
const [done, setDone] = useState(false);
return (
<section className="panel" data-component="cash-meetup">
<h2>Cash in person: no fee</h2>
<div className="by">{L?.emoji || ""} {d.title} · seller {d.seller}</div>
<table className="breakdown"><tbody>
<tr><td>Price (pay the seller directly)</td><td>{money(d.price_cents)}</td></tr>
<tr><td>Platform fee</td><td data-testid="cash-fee">{money(d.fee_cents)}</td></tr>
<tr className="total"><td>Total</td><td>{money(d.price_cents)}</td></tr>
</tbody></table>
<p>Suggested meetup: {d.place} · {d.time}</p>
<p className="meta">We only connect buyer and seller. Meet somewhere public. (Demo: no real meetup or messaging.)</p>
<div className="meta">Deal {d.id} · status {d.status}</div>
<div style={{ marginTop: 10 }}>
<button className="btn cash" disabled={done}
onClick={() => { setDone(true); talk({ event: { type: "cash_completed", deal_id: d.id }, echo: "We met and I paid cash" }); }}>Mark paid in cash</button>
</div>
</section>
);
}
/** show_receipt */
export function Receipt({ payload }: { payload: { order: Order } }) {
const o = payload.order;
return (
<section className="panel" data-component="receipt">
<h2 className="ok">{o.method === "cash_in_person" ? "Cash deal completed" : "Payment confirmed (test)"}</h2>
<table className="breakdown"><tbody>
<tr><td>{o.title}</td><td>{money(o.subtotal_cents)}</td></tr>
<tr><td>Facilitation fee</td><td data-testid="receipt-fee">{money(o.fee_cents)}</td></tr>
<tr className="total"><td>Total</td><td>{money(o.total_cents)}</td></tr>
<tr><td className="meta">Seller receives</td><td className="meta">{money(o.seller_receives_cents)}</td></tr>
</tbody></table>
<div className="meta">Order {o.id} · {o.method}{o.card_last4 ? " · test card •••• " + o.card_last4 : ""} · {o.status}</div>
</section>
);
}

View File

@ -0,0 +1,33 @@
// Component registry: every UI action type the agent can send maps to either
// - a PANEL component, rendered into the results area (#stage), or
// - an EFFECT, a side effect with no panel (theme change, toast, clear).
// To add a component, see "Adding a UI component" in README.md.
import type { ComponentType } from "react";
import type { AgentCtx } from "../context";
import type { ThemeAction } from "../theme/engine";
import { Compare, HeroPanel, Results } from "./Listings";
import { ListingForm } from "./ListingForm";
import { CashMeetup, Checkout, Receipt } from "./Payments";
export const PANELS: Record<string, ComponentType<{ payload: any }>> = {
show_hero: HeroPanel,
show_cards: Results,
show_compare: Compare,
show_listing_form: ListingForm,
show_checkout: Checkout,
show_cash_meetup: CashMeetup,
show_receipt: Receipt,
};
export interface EffectApi extends Pick<AgentCtx, "toast"> {
applyTheme: (a: ThemeAction) => void;
clearStage: () => void;
}
export const EFFECTS: Record<string, (payload: any, fx: EffectApi) => void> = {
set_theme: (p: ThemeAction, fx) => {
fx.applyTheme(p);
fx.toast(p.reset && !p.base && !Object.keys(p.patch || {}).length ? "Look reset to default" : "Look updated", "ok");
},
toast: (p, fx) => fx.toast(p.text || "", p.level || "info"),
clear_panel: (_p, fx) => fx.clearStage(),
};

16
web/src/context.ts Normal file
View File

@ -0,0 +1,16 @@
import { createContext, useContext } from "react";
import type { ChatEvent } from "./types";
export interface Talk { message?: string; event?: ChatEvent; echo?: string }
export interface AgentCtx {
talk: (t: Talk) => Promise<void>;
feeBps: number;
compare: { has: (id: string) => boolean; toggle: (id: string, on: boolean) => void };
toast: (text: string, level?: "info" | "ok" | "warn") => void;
}
export const Ctx = createContext<AgentCtx | null>(null);
export const useAgent = () => {
const c = useContext(Ctx);
if (!c) throw new Error("useAgent outside provider");
return c;
};

6
web/src/main.tsx Normal file
View File

@ -0,0 +1,6 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);

170
web/src/theme/engine.ts Normal file
View File

@ -0,0 +1,170 @@
// Per-visitor theme engine (port of the pre-React static/theme.js, same behaviour).
// 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. The server validates every
// set_theme action first (market/theme.py); everything here re-validates defensively.
export type Theme = Record<string, any>;
export interface ThemeAction { reset?: boolean; base?: Theme; patch?: Theme; preset?: string }
export interface Copy { logo: string; headline: string; subhead: string; footer?: string; chips: Chip[]; bullets: string[] }
export interface Chip { label: string; say?: string; event?: string }
export const THEME_KEY = "am_theme_v1";
export const SECTIONS = ["hero", "chips", "bullets", "results", "footer"] as const;
export type Section = (typeof SECTIONS)[number];
const FONTS: Record<string, [string, string?]> = {
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: Record<"dark" | "light", Record<string, string>> = {
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 IMAGE_HOSTS = ["images.unsplash.com", "picsum.photos", "upload.wikimedia.org"];
export const isObj = (o: unknown): o is Record<string, any> => !!o && typeof o === "object" && !Array.isArray(o);
export const merge = (a: Theme, b: Theme): Theme => {
const o: Theme = { ...(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: unknown): c is string => typeof c === "string" && c.length < 60 && !/[;{}<>]/.test(c) && CSS.supports("color", c);
// ---- colour maths for guaranteed contrast on protected elements ----
function rgb(c: string) {
const probe = document.createElement("span");
probe.style.color = c; document.body.append(probe);
const m = (getComputedStyle(probe).color.match(/[\d.]+/g) || ["0", "0", "0", "1"]).map(Number); 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: number; g: number; b: number }) => [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);
export const contrast = (a: string, b: string) => { 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: string) => { const v = rgb(c); return `rgb(${v.r},${v.g},${v.b})`; };
const bestInk = (bg: string) => (contrast(bg, "#111111") >= contrast(bg, "#ffffff") ? "#111111" : "#ffffff");
function backgroundCss(bg: Theme, pal: Record<string, string>) {
const cols = (Array.isArray(bg.colors) ? 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 map: Record<string, string> = {
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[bg.pattern] || 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 { /* invalid URL */ }
return c0;
}
default: return c0;
}
}
function loadFont(name: string) {
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];
}
export function scopeCss(css: unknown) {
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 { return false; } });
if (!urlOk) return "";
return [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(([, sel, body]) =>
sel.split(",").map((s) => "#page " + s.trim()).join(", ") + "{" + body + "}").join("\n");
}
export function loadTheme(): Theme {
try { const t = JSON.parse(localStorage.getItem(THEME_KEY) || "{}"); return isObj(t) ? t : {}; } catch { return {}; }
}
export function saveTheme(t: Theme) {
if (Object.keys(t).length) localStorage.setItem(THEME_KEY, JSON.stringify(t)); else localStorage.removeItem(THEME_KEY);
}
export function reduceThemeAction(current: Theme, a: ThemeAction): Theme {
let t = current;
if (a.reset) t = {};
if (a.base) t = JSON.parse(JSON.stringify(a.base));
return merge(t, a.patch || {});
}
/** Apply design tokens to <html>/<body> (CSS variables, classes, fonts, scoped customCss). Copy and
* section order/visibility are rendered by React from the same theme (see resolveCopy/resolveSections). */
export function applyThemeToDocument(t: Theme) {
const 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(isObj(t.palette) ? t.palette : {})) if (k in pal && okColor(v)) pal[k] = v;
const set = (k: string, v: string) => 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 } as Record<string, number>)[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 sitting directly on the page background must clear 4.5:1 against every background stop; if no
// ink can (busy gradients), a readability scrim goes behind it.
const stops = isObj(t.background) && Array.isArray(t.background.colors) ? t.background.colors.filter(okColor) : [];
const bgStops: string[] = stops.length ? stops.map(solid) : [solid(pal.bg)];
const minC = (ink: string) => Math.min(...bgStops.map((b) => contrast(ink, b)));
let pageInk = minC(pal.text) >= 4.5 ? pal.text : (minC("#111111") >= minC("#ffffff") ? "#111111" : "#ffffff");
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 (checkout / cash / receipt): solid surface colour + black or white ink, 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);
const userCss = document.getElementById("user-css");
if (userCss) userCss.textContent = t.customCss ? "@layer user {\n" + scopeCss(t.customCss) + "\n}" : "";
}
export function resolveCopy(t: Theme, defaults: Copy): Copy {
const c = isObj(t.copy) ? t.copy : {};
const str = (v: unknown, d: string, n: number) => (typeof v === "string" && v.trim() ? v : d).slice(0, n);
return {
logo: str(c.logo, defaults.logo, 40), headline: str(c.headline, defaults.headline, 120), subhead: str(c.subhead, defaults.subhead, 240),
footer: typeof c.footer === "string" ? c.footer.slice(0, 160) : undefined,
chips: (Array.isArray(c.chips) ? c.chips : defaults.chips).filter((x: any) => isObj(x) && typeof x.label === "string").slice(0, 6),
bullets: (Array.isArray(c.bullets) ? c.bullets : defaults.bullets).filter((x: any) => typeof x === "string").slice(0, 6),
};
}
export function resolveSections(t: Theme): { order: Record<Section, number>; hidden: Set<Section> } {
const sec = isObj(t.sections) ? t.sections : {};
const order = Array.isArray(sec.order) ? sec.order.filter((x: any) => (SECTIONS as readonly string[]).includes(x)) : [];
const full = [...new Set([...order, ...SECTIONS])] as Section[];
const hidden = new Set<Section>((Array.isArray(sec.hidden) ? sec.hidden : []).filter((x: any) => (SECTIONS as readonly string[]).includes(x)));
return { order: Object.fromEntries(full.map((s, i) => [s, i])) as Record<Section, number>, hidden };
}

26
web/src/types.ts Normal file
View File

@ -0,0 +1,26 @@
export interface Listing {
id: string; title: string; kind: "product" | "service"; price: number; currency?: string; description?: string;
category?: string; tags?: string[]; emoji?: string; seller: string; seller_type?: "human" | "agent"; area?: string;
payment_options?: ("online" | "cash")[]; status: string;
}
export interface Quote {
online?: { available: boolean; total_cents: number; fee_cents: number };
cash_in_person?: { available: boolean; total_cents: number };
}
export interface Intent { id: string; title: string; quantity: number; subtotal_cents: number; fee_cents: number; fee_bps: number; total_cents: number }
export interface Deal { id: string; title: string; seller: string; price_cents: number; fee_cents: number; place: string; time: string; status: string }
export interface Order {
id: string; title: string; method: string; status: string; subtotal_cents: number; fee_cents: number; total_cents: number;
seller_receives_cents: number; card_last4?: string;
}
export interface UIAction { type: string; payload?: any }
export interface ChatReply {
reply?: string; actions?: UIAction[]; agent?: "llm" | "fallback" | "ui-event" | "rate-limit"; model?: string; latency_ms?: number;
}
export interface ChatEvent { type: string; [k: string]: unknown }
export interface Status {
env: string; fee_bps: number;
agent: { mode: "llm" | "fallback"; model?: string; last_model?: string; note?: string; daily_quota_exhausted_until_utc?: string | null };
}
export const money = (c: number) => "$" + (c / 100).toFixed(2);

17
web/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

10
web/vite.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// The Python server serves the build from /static/dist/ (see server.py and deploy.sh).
export default defineConfig({
plugins: [react()],
base: "/static/dist/",
build: { outDir: "../static/dist", emptyOutDir: true, sourcemap: false },
server: { proxy: { "/api": "http://127.0.0.1:8795", "/agent.json": "http://127.0.0.1:8795", "/openapi.json": "http://127.0.0.1:8795" } },
});