React + Vite frontend with a component registry (feature parity) #5
77
README.md
77
README.md
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user