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
```
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

@ -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,
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("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 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,