README: React frontend structure, build/deploy, adding a component; theme_check fail-closed customCss check
This commit is contained in:
parent
b30f8ef1e2
commit
89f18c27ef
77
README.md
77
README.md
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user