README: React frontend structure, build/deploy, adding a component; theme_check fail-closed customCss check

This commit is contained in:
leora 2026-10-08 19:49:25 +08:00
parent b30f8ef1e2
commit 89f18c27ef
2 changed files with 72 additions and 7 deletions

View File

@ -11,7 +11,7 @@ sell products or services.
## How it works ## 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 ▲ renders UI actions │ or the rule-based fallback agent
│ ├── /api/payments/* ── mock test-mode provider ── signed webhook │ ├── /api/payments/* ── mock test-mode provider ── signed webhook
└── card form posts straight ────────┘ └── 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) ### 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: 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 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 | | 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. 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 ## Run
```bash ```bash
AGENT_MARKET_PORT=8796 AGENT_MARKET_DATA=./data python3 server.py 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. 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 ## Agent API quick start
```bash ```bash
@ -135,12 +199,13 @@ curl -s $BASE/api/actions -H 'X-Agent-Id: agent:my-bot' -H 'Content-Type: applic
## Tests ## Tests
```bash ```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 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/nl_check.py http://127.0.0.1:8795 # real model: 6 natural-language prompts
python3 tests/test_theme.py # theme spec + customCss sanitiser python3 tests/test_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=... 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 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) ## Known gaps (demo)

View File

@ -134,7 +134,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const hv = await page.evaluate(() => ({ stage: getComputedStyle(document.getElementById("stage")).display, css: document.getElementById("user-css").textContent, 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 })); 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("hidden results shown again for agent panels", hv.stage !== "none" && /Results section shown again/.test(hv.toast) && !hv.hidden.includes("results"), hv.toast);
check("stored customCss sanitised (scoped to #page, no !important / custom properties)", /^@layer user/.test(hv.css) && !/!important|--p-ink/.test(hv.css) && /#page \.card\{border-width:3px\}/.test(hv.css.replace(/\s+/g, " ").replace(/ \{/g, "{")), hv.css.replace(/\s+/g, " ").slice(0, 120)); 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 guardrails("hostile stored theme");
await page.click("#resetLook"); await sleep(300); 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, const rs = await page.evaluate(() => ({ ls: localStorage.getItem("am_theme_v1"), h: document.getElementById("headline").textContent, css: document.getElementById("user-css").textContent,