Typed chat: hosted free models, then the local model, nothing else; busy reply + Retry; llama-server watchdog
- Remove the keyword-rules agent from typed chat: fallback_agent, parse_look_command (+ tables), the copy-edit parser and _rules_check are gone. If every model fails: 'The assistant is busy, please try again in a moment.' with a Retry button (agent 'unavailable', no actions). Clicks, forms and payments stay deterministic (fallback_event); the landing view is a deterministic featured-listings panel; the Sell button opens the empty form. - Local model via llama.cpp llama-server directly (ops/local-llm-watchdog.sh, loopback only, restart on exit/hang, crash backoff, log rotation; deploy.sh ensures it runs). Lean: no vision projector, no weight repack (mmapped), ctx 6144, 1 slot, q8 KV, 6 threads at nice 10, poll 0, 1 GiB prompt cache. Idle 0% CPU, 2.9 GB RSS (0.6 GB private). - Local requests use a JSON GBNF grammar, worked examples in the cached system prompt, and a labelled visitor message. 10/10 correct on the real prompt, warm 4-13 s. - Primer re-primes after a model-server restart (llama-server /metrics counter) and otherwise stays idle. - Fix: hosted calls kept the Authorization header off when a local model was configured (cfg['local'] is a dict on hosted configs); now only the local server itself skips the key. Test added. - Badge/chat header: 'AI: busy', 'AI: quota used up', 'AI: unavailable'; no 'built-in'/'checked by rules' texts. - Tests: proxy checks expect the busy reply (never rules), clicks still work while busy, no-model server, local grammar/ prompt/no key; browser checks for busy + Retry; e2e_http accepts busy for typed turns unless REQUIRE_LLM=1; test_copyedit.py replaced by test_copy_defaults.py; README updated.
This commit is contained in:
parent
e4552f5649
commit
d785091d64
101
README.md
101
README.md
@ -11,8 +11,8 @@ sell products or services.
|
||||
## How it works
|
||||
|
||||
```
|
||||
browser (React SPA) ─────/api/chat──▶ tiny server ──▶ hosted LLM (OpenAI-compatible)
|
||||
▲ renders UI actions │ or the rule-based fallback agent
|
||||
browser (React SPA) ─────/api/chat──▶ tiny server ──▶ free hosted LLMs (OpenAI-compatible)
|
||||
▲ renders UI actions │ then the local model on this box (llama-server)
|
||||
│ ├── /api/payments/* ── mock test-mode provider ── signed webhook
|
||||
└── card form posts straight ────────┘
|
||||
to the payment endpoint (never to the LLM)
|
||||
@ -66,13 +66,10 @@ never changes the page for anyone else. **Reset look** in the header, or saying
|
||||
- Protected panels use the theme's surface colour as a solid fill, with black or white text, whichever contrasts more, so they always clear 4.5:1. The banner and badge use fixed high-contrast colours.
|
||||
- Text sitting directly on the page background switches to readable ink. If no ink clears 4.5:1 against every gradient stop, it gets a scrim behind it.
|
||||
- **Page text.** The logo (top-left brand text), headline, subtitle, footer, quick-action buttons and bullets are editable
|
||||
through `copy`. The built-in agent (`market/copyedit.py`) understands "replace/change/rename/swap A to/with/into B",
|
||||
locations such as top left, header, logo, brand, site name, title, headline, subtitle and footer, quoted or unquoted text,
|
||||
and a generic find-and-replace when A is the current text of exactly one slot. It keeps the case you type for B; text typed
|
||||
all in lowercase takes the style of the text it replaces ("new internet" replacing "Agent Market" gives "New Internet").
|
||||
When it can't tell what to change, it says what it understood and offers the closest option. The model prompt gets the
|
||||
same slot map plus the visitor's current page text (`PAGE_TEXT`).
|
||||
- If the model is unavailable, the built-in agent understands the common look commands: presets, light/dark, background colours, gradients and patterns, accent and text colour, fonts, bigger or smaller text, corners, density, card style, list/grid, columns, chat position and size, hiding or showing sections, headline, logo and subtitle, animations, and reset.
|
||||
through `copy`. The prompt maps where each slot is ("top left" = `copy.logo`, title, subtitle, footer, ...) and includes the
|
||||
visitor's current page text (`PAGE_TEXT`, from `market/copyedit.py`), so "replace agent market to new internet on the top
|
||||
left corner" becomes `{"copy":{"logo":"New Internet"}}`. There is no rule-based text or look editing: every typed request
|
||||
is answered by a model (see below).
|
||||
|
||||
### Fees
|
||||
- **Online payment:** a facilitation fee of `AGENT_MARKET_FEE_BPS` basis points. The default is 500, which is 5%.
|
||||
@ -104,7 +101,9 @@ key stays on the server: it never goes to the browser, never into the repo, and
|
||||
`deploy.sh` loads `~/.config/agent-market/llm.env` and `~/.config/agent-market/<env>.env` if they exist. Both
|
||||
sit outside the repo. The box runs OpenRouter free models:
|
||||
|
||||
`nvidia/nemotron-3-super-120b-a12b:free` → `apodex/apodex-1.1-mini:free` → `google/gemma-4-31b-it:free` → **local `gemma3:4b`** → rule-based agent
|
||||
`nvidia/nemotron-3-super-120b-a12b:free` → `apodex/apodex-1.1-mini:free` → `google/gemma-4-31b-it:free` → **local `gemma3:4b`**
|
||||
|
||||
That is the whole chain. There are no keyword rules for typed messages.
|
||||
|
||||
The order came from a benchmark of natural-language prompts, scored on valid UI actions and latency. In that run:
|
||||
- **nemotron-3-super**: 8/8 valid, about 2–3 s.
|
||||
@ -113,20 +112,32 @@ The order came from a benchmark of natural-language prompts, scored on valid UI
|
||||
- **inkling**: 403, because it's limited to agentic harnesses.
|
||||
- **nemotron-3.5-lightning**: 24–55 s, and returned non-JSON.
|
||||
|
||||
If a model returns 429, it times out, or it returns invalid JSON, the server moves on to the next model in the chain. When every model fails,
|
||||
the labelled rule-based agent answers, so the page never breaks. The header badge shows the model that answered.
|
||||
If a model returns 429, it times out, or it returns invalid JSON, the server moves on to the next model in the chain. When every
|
||||
model fails (or none is configured), the reply is an honest **"The assistant is busy, please try again in a moment."** with a
|
||||
**Retry** button that resends the same message (`agent: "unavailable"`, no actions; the badge says `AI: busy`). Typed messages
|
||||
never get rule-based answers. The header badge shows the model that answered.
|
||||
|
||||
### Local backup model
|
||||
A small model on the box runs through [Ollama](https://ollama.com). The server tries it after the hosted chain and before
|
||||
the keyword rules. It uses the same prompt and OpenAI-compatible endpoint (`/v1/chat/completions` with JSON mode), with no key.
|
||||
### Local model (last link of the chain)
|
||||
A small model runs on the box with llama.cpp's `llama-server` (the build that ships inside Ollama v0.40.1 under
|
||||
`~/ollama/lib/ollama/`; any llama.cpp build works). The server tries it after the hosted chain, through the same
|
||||
OpenAI-compatible endpoint (`/v1/chat/completions`), with no key. For the local model the request differs in three ways:
|
||||
- a GBNF grammar (`JSON_GRAMMAR` in `market/agent.py`) constrains the output to one JSON object;
|
||||
- the system prompt adds a block of worked examples (`LOCAL_EXAMPLES`: text edits, hide sections, reset, light mode,
|
||||
list layout, chat position, fonts, gradients, presets, a listing form). It sits before the catalog, so it's cached;
|
||||
- the visitor's message is labelled (`VISITOR MESSAGE ...`), because Gemma's chat template folds the system prompt into the
|
||||
first user turn.
|
||||
|
||||
No chat history is sent to it by default (small models copy earlier answers).
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `AGENT_MARKET_LOCAL_LLM_URL` | e.g. `http://127.0.0.1:11434/v1` (loopback only) |
|
||||
| `AGENT_MARKET_LOCAL_LLM_MODEL` | e.g. `gemma3:4b` |
|
||||
| `AGENT_MARKET_LOCAL_LLM_MODEL` | e.g. `gemma3:4b` (the server's `--alias`) |
|
||||
| `AGENT_MARKET_LOCAL_LLM_TIMEOUT` / `_MIN` | its own cap (45 s; CPU inference is slower) and the minimum it always gets (25 s) |
|
||||
| `AGENT_MARKET_LLM_TOTAL_BUDGET` | hosted chain + local model per turn (60 s); the local model gets `min(45, max(25, 60 - time already spent))` |
|
||||
| `AGENT_MARKET_LOCAL_LLM_CONCURRENCY` | 2 = one running + one queued; more simultaneous turns go to the keyword rules instead of waiting |
|
||||
| `AGENT_MARKET_LOCAL_LLM_CONCURRENCY` | 2 = one running + one queued; more simultaneous turns get the busy reply instead of waiting |
|
||||
| `AGENT_MARKET_LOCAL_LLM_HISTORY` | chat turns sent to the local model (0) |
|
||||
| `AGENT_MARKET_LOCAL_LLM_PRIME` | 1 = warm the prompt cache in the background (see below) |
|
||||
|
||||
`deploy.sh` loads these from `~/.config/agent-market/local-llm.env`. The behaviour:
|
||||
- **Quota stop.** When OpenRouter reports the daily free quota is used up, every hosted model is parked until the reset
|
||||
@ -134,17 +145,29 @@ the keyword rules. It uses the same prompt and OpenAI-compatible endpoint (`/v1/
|
||||
directory (`llm_quota.json`, keyed by the base URL, capped at 36 h), so a restart doesn't spend a call to rediscover it.
|
||||
- **Badge.** It honestly shows `Local model (gemma3:4b)` when the local model answered (chat header: `Local model · gemma3:4b · 8.2s`),
|
||||
and also on page load while the hosted quota is used up.
|
||||
- **Running it.** `ops/ollama-watchdog.sh` keeps `ollama serve` on `127.0.0.1:11434` with `OLLAMA_KEEP_ALIVE=-1`, 8k context,
|
||||
one loaded model and a q8 KV cache. It pre-loads the configured model, and restarts Ollama if it exits or stops answering
|
||||
(3 failed health checks, 20 s apart). Install Ollama under `~/ollama` (no root), pull the model (`ollama pull gemma3:4b`), then
|
||||
`nohup setsid ~/ollama/watchdog.sh >> ~/ollama/watchdog.log 2>&1 &`.
|
||||
- **Model choice.** Benchmarked on this box (8 vCPU Xeon with AVX-512/AMX, no GPU, 16 GB RAM) with the real prompt and
|
||||
catalog. The first call after the prompt prefix changes processes about 2.3k prompt tokens (about 30 s); later calls reuse
|
||||
Ollama's prompt cache.
|
||||
- **Prompt cache.** The first call with a new prompt prefix processes about 2.4k prompt tokens on the CPU (about 30–40 s).
|
||||
A background primer sends the current prompt once (1 output token) at start-up, whenever the catalog changes, and after
|
||||
the model server restarts (detected from llama-server's `/metrics` counter). Otherwise it only reads `/metrics` every
|
||||
30 s, so an idle server stays at 0% CPU. `--swa-full` keeps Gemma's sliding-window layers reusable, so a warm turn only
|
||||
processes the new tokens (about 0.3 s). `--cache-ram 1024` keeps a few prompt states (demo and staging) in RAM.
|
||||
- **Running it.** `ops/local-llm-watchdog.sh --ensure` (idempotent; `deploy.sh` calls it) starts a private copy of the
|
||||
watchdog under `~/llm/`. It keeps `llama-server` on `127.0.0.1:11434` only, restarts it if it exits (backing off to 30 s
|
||||
if it keeps crashing) or fails 3 health checks 15 s apart, and rotates its log. Stop it with `kill $(cat ~/llm/watchdog.pid)`.
|
||||
Lean flags so the rest of the box keeps headroom: text weights only (`--no-mmproj`, the 0.8 GB vision projector is never
|
||||
loaded), mmapped and not repacked (`--no-repack`, no second copy of the weights), `-c 6144 -np 1`, q8 KV cache,
|
||||
flash attention, `-t 6 -tb 6` at `nice 10`, `--poll 0` (threads sleep when idle). Overrides go in
|
||||
`~/.config/agent-market/local-llm-server.env`. Model file: the gemma3:4b Q4_K_M text GGUF (2.4 GB), linked as
|
||||
`~/llm/gemma3-4b-it-Q4_K_M.gguf`.
|
||||
- **Resources (measured on this box: 8 vCPU, no GPU, 16 GB RAM).** Idle: 0% CPU, 2.9 GB RSS, of which 0.6 GB is private
|
||||
and 2.4 GB is the mmapped model file (clean page cache the kernel can reclaim). While answering: about 6 cores at nice 10
|
||||
for 4–13 s, RSS unchanged.
|
||||
- **Latency (real chain, handle_chat, warm cache).** Exact logo phrase 5 s, other text and look edits 4–6 s, search 9 s,
|
||||
compare 10 s, prefilled listing form 13 s (10/10 correct). Cold (no primer yet): about 43 s for the first turn.
|
||||
- **Model choice.** Benchmarked on this box with the real prompt and catalog:
|
||||
|
||||
| model | correct | warm latency | notes |
|
||||
|---|---|---|---|
|
||||
| **gemma3:4b** (chosen) | 15/15 | 4–10 s (median 8 s), cold 28–33 s | about 3.4 GB RAM loaded |
|
||||
| **gemma3:4b** (chosen) | 15/15 (Ollama), 10/10 (llama-server + examples) | 4–13 s, cold 30–45 s | 2.9 GB RSS |
|
||||
| qwen2.5:3b | 7/15 | 3–8 s | dropped actions, invented ids, flat payloads |
|
||||
| phi4-mini | 4/5 | 10–25 s | missed the logo edit |
|
||||
| qwen3:4b (no think) | 4/5 | 12–22 s | slow, missed the logo edit |
|
||||
@ -152,12 +175,14 @@ the keyword rules. It uses the same prompt and OpenAI-compatible endpoint (`/v1/
|
||||
| qwen2.5:1.5b | 0/5 | 3–6 s | no usable plans |
|
||||
|
||||
7B models were left out: they need about 5 GB more RAM, and on this CPU they would be about 2–3x slower.
|
||||
Ollama itself was dropped in favour of running `llama-server` directly: Ollama loaded the vision projector, a 16k context
|
||||
with 2 slots and an 8 GiB prompt cache, which grew to about 10 GB RSS.
|
||||
|
||||
**Free text goes to the LLM. Structured UI events stay deterministic.** Button clicks, form submits and payment results
|
||||
each have exactly one outcome, so the server handles them deterministically. That keeps them instant and saves free-tier quota.
|
||||
The landing view doesn't call the model either. Set `AGENT_MARKET_LLM_EVENTS=1` to send events to the LLM as well.
|
||||
|
||||
With no LLM configured, the demo runs the **rule-based fallback agent**. The page labels this.
|
||||
With no model configured at all, clicks, forms and payments still work, and typed messages get the busy reply.
|
||||
|
||||
## Frontend (React + Vite + TypeScript)
|
||||
|
||||
@ -189,7 +214,8 @@ reproducible) **before** it stops the running server, so a failed build leaves t
|
||||
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).
|
||||
**How the page renders.** Typed messages go to `/api/chat` (a hosted model, then the local model; if both fail, a busy
|
||||
reply with a Retry button).
|
||||
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
|
||||
@ -205,7 +231,7 @@ theme object. The theme is stored per browser in `localStorage["am_theme_v1"]`;
|
||||
### 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.
|
||||
and add a case to `fallback_event` if a click or form should open it deterministically.
|
||||
2. **Component.** Create it in `web/src/components/`, e.g.
|
||||
```tsx
|
||||
export function Reviews({ payload }: { payload: { heading?: string; reviews?: Review[] } }) {
|
||||
@ -228,7 +254,8 @@ The first run seeds `data/store.json` with fake listings. Delete that file to re
|
||||
`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.
|
||||
(plus a dummy key file); the chain parks on the fake daily-quota 429 and the local model answers (or, without one, the
|
||||
busy reply).
|
||||
|
||||
## Agent API quick start
|
||||
```bash
|
||||
@ -243,15 +270,15 @@ 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 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/e2e_http.py http://127.0.0.1:8795 # HTTP end-to-end incl. SPA shell + bundle (REQUIRE_LLM=1: typed turns must reach a model)
|
||||
bash tests/llm_proxy_check.sh # fake LLM: 429 chain, cooldown, local model, busy reply (never rules), key handling, rate limit
|
||||
python3 tests/nl_check.py http://127.0.0.1:8795 # real model (hosted or local): 6 natural-language prompts
|
||||
python3 tests/test_theme.py # theme spec + customCss sanitiser
|
||||
python3 tests/test_copyedit.py # built-in copy edits (logo/headline/... phrasings)
|
||||
python3 tests/test_agent_units.py # local-model output normalisers (result focus, preset+reset, listing-form shape)
|
||||
python3 tests/test_copy_defaults.py # page-text defaults in sync with the app, prompt shows PAGE_TEXT, no rule code left
|
||||
python3 tests/test_agent_units.py # local-model normalisers (result focus, preset+reset, listing-form shape), primer restart detection
|
||||
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
|
||||
NODE_PATH=/path/to/node_modules node tests/browser_e2e.js http://127.0.0.1:8795 ./screenshots # headless Chrome, needs a model (16 checks)
|
||||
NODE_PATH=/path/to/node_modules bash tests/fake_model_browser.sh # React UI vs fake model: badges, busy reply + Retry, quota, rate-limit toast, sanitised set_theme
|
||||
```
|
||||
|
||||
## Known gaps (demo)
|
||||
@ -259,5 +286,5 @@ NODE_PATH=/path/to/node_modules bash tests/fake_model_browser.sh # React UI vs
|
||||
- There's no real messaging between buyer and seller for cash meetups.
|
||||
- The JSON file store is single-process, and rate limits live in memory, so a restart resets them.
|
||||
- Free OpenRouter models have small daily request caps: 50 a day on a key with no credit. They also get rate-limited upstream. When the daily cap hits,
|
||||
the server parks the whole model chain until OpenRouter's reset time, and the built-in agent answers, labelled as such.
|
||||
the server parks the hosted chain until OpenRouter's reset time, and the local model answers, labelled as such.
|
||||
- The mock payment provider is the only one. A real provider would need its own test keys and webhook secret.
|
||||
|
||||
@ -20,13 +20,17 @@ echo "deploying $ENVN @ $(git rev-parse --short HEAD) on :$PORT"
|
||||
CONF=$HOME/.config/agent-market
|
||||
set -a
|
||||
[ -f "$CONF/llm.env" ] && . "$CONF/llm.env"
|
||||
# optional local backup model (Ollama on this box, see README "Local backup model")
|
||||
# optional local model, the last link of the chain (llama-server on this box, see README "Local model")
|
||||
[ -f "$CONF/local-llm.env" ] && . "$CONF/local-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
|
||||
# The local model answers when the hosted free models can't, so make sure its watchdog is running (idempotent).
|
||||
if [ -n "${AGENT_MARKET_LOCAL_LLM_URL:-}" ] && [[ "$AGENT_MARKET_LOCAL_LLM_URL" == http://127.0.0.1:${LLM_PORT:-11434}/* ]]; then
|
||||
ops/local-llm-watchdog.sh --ensure || echo "warning: local model watchdog not started"
|
||||
fi
|
||||
# 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; }
|
||||
|
||||
344
market/agent.py
344
market/agent.py
@ -210,12 +210,32 @@ def run_tools(store, tools):
|
||||
return results
|
||||
|
||||
|
||||
# ---------- deterministic fallback (no LLM key) ----------
|
||||
# ---------- deterministic handlers for clicks, forms and payments (typed messages always go to a model) ----------
|
||||
|
||||
def _search_q(store, q, kind="any", limit=6):
|
||||
return run_tools(store, [{"name": "search", "args": {"q": q, "kind": kind, "limit": limit}}])[0]["listings"]
|
||||
|
||||
|
||||
def featured_listings(store, limit=8):
|
||||
"""Landing view: the first active listings, no model involved."""
|
||||
hits = _search_q(store, "", limit=limit)
|
||||
if not hits:
|
||||
return {"reply": "No listings yet. Say what you want to sell and I'll set up the form.", "actions": []}
|
||||
return {"reply": f"Here are {len(hits)} listings. Tell me what you're after, or click a card.",
|
||||
"actions": [{"type": "show_cards", "payload": {"heading": "Featured listings", "listing_ids": [h["id"] for h in hits]}}]}
|
||||
|
||||
|
||||
def sell_form():
|
||||
"""The "Sell something" button: an empty listing form (the user fills it in and presses Publish)."""
|
||||
return {"reply": "Fill in the listing form. I'll publish it as test data. The online fee only applies when a buyer pays online.",
|
||||
"actions": [{"type": "show_listing_form", "payload": {"defaults": {
|
||||
"title": "", "kind": "product", "price": 2500, "description": "", "category": "misc", "area": "Demo Town",
|
||||
"payment_options": ["online", "cash"], "emoji": "📦"}}}]}
|
||||
|
||||
|
||||
BUSY_REPLY = "The assistant is busy, please try again in a moment."
|
||||
|
||||
|
||||
def fallback_event(store, ev: dict, actor: str):
|
||||
t = ev.get("type")
|
||||
lid = ev.get("listing_id")
|
||||
@ -246,7 +266,7 @@ def fallback_event(store, ev: dict, actor: str):
|
||||
"actions": [{"type": "show_compare", "payload": {"listing_ids": ids, "heading": "Compare"}}]}
|
||||
return {"reply": "Pick at least two listings to compare.", "actions": []}
|
||||
if t == "sell":
|
||||
return fallback_agent(store, "i want to sell", [], actor)
|
||||
return sell_form()
|
||||
if t == "submit_listing":
|
||||
f = ev.get("fields") or {}
|
||||
try:
|
||||
@ -282,155 +302,6 @@ def fallback_event(store, ev: dict, actor: str):
|
||||
return {"reply": "I didn't recognise that action.", "actions": []}
|
||||
|
||||
|
||||
def fallback_agent(store, message: str, history, actor: str = "demo-buyer", look=None):
|
||||
m = (message or "").strip()
|
||||
low = m.lower()
|
||||
actions = []
|
||||
reply = ""
|
||||
|
||||
from .theme import parse_look_command
|
||||
th = parse_look_command(m, look if isinstance(look, dict) else {})
|
||||
if th:
|
||||
# payload None = understood a copy edit but couldn't apply it; the reply says why (no listings, no silent no-op)
|
||||
return {"reply": th[0], "actions": [{"type": "set_theme", "payload": th[1]}] if th[1] else []}
|
||||
|
||||
# welcome / help
|
||||
if not m or low in ("hi", "hello", "hey", "help", "start"):
|
||||
reply = ("Welcome to Agent Market (TEST DEMO). I drive the page: ask me to search, "
|
||||
"compare, list something for sale, check out online (test card 4242…), or arrange a cash meetup. "
|
||||
f"Online fee is {FEE_RATE*100:.1f}%; cash in person is free.")
|
||||
actions = [{"type": "show_hero", "payload": {
|
||||
"title": "Agent Market",
|
||||
"subtitle": "An AI agent runs this page. List, buy, or meet for cash — all demo data.",
|
||||
"bullets": [
|
||||
"Chat to search, list, buy, or arrange a meetup",
|
||||
f"Online payments: test mode + {FEE_RATE*100:.1f}% facilitation fee",
|
||||
"Cash in person: $0 fee — we just connect you",
|
||||
"Agents can buy and sell via the open JSON API",
|
||||
],
|
||||
}}]
|
||||
return {"reply": reply, "actions": actions, "agent": "fallback"}
|
||||
|
||||
# sell / list
|
||||
if any(w in low for w in ("sell", "list something", "create a listing", "post a listing", "i want to sell")):
|
||||
reply = "Fill in the listing form. I'll publish it as test data. Online fee only applies when a buyer pays online."
|
||||
actions = [{"type": "show_listing_form", "payload": {"defaults": {
|
||||
"title": "", "kind": "product", "price": 2500, "description": "",
|
||||
"category": "misc", "area": "Demo Town", "payment_options": ["online", "cash"], "emoji": "📦",
|
||||
}}}]
|
||||
return {"reply": reply, "actions": actions, "agent": "fallback"}
|
||||
|
||||
# cash meetup
|
||||
if "cash" in low or "meetup" in low or "meet in person" in low or "in person" in low:
|
||||
listings = _search_q(store, "")
|
||||
cashable = [L for L in listings if "cash" in (L.get("payment_options") or [])]
|
||||
# try to pick by keyword
|
||||
pick = None
|
||||
for L in cashable:
|
||||
if any(t in low for t in (L.get("tags") or []) + [L.get("title", "").lower().split()[0]]):
|
||||
pick = L
|
||||
break
|
||||
if not pick and cashable:
|
||||
pick = cashable[0]
|
||||
if not pick:
|
||||
return {"reply": "No cash-friendly listings right now. Try searching first.", "actions": [], "agent": "fallback"}
|
||||
res = run_tools(store, [{"name": "start_cash_deal", "args": {
|
||||
"listing_id": pick["id"], "buyer": actor,
|
||||
"place": pick.get("area") or "Demo Town square",
|
||||
"time": "Saturday afternoon",
|
||||
}}])[0]
|
||||
deal = res["deal"]
|
||||
reply = (f"Cash deal arranged for “{pick['title']}” at {money(pick['price'])}. "
|
||||
f"Fee: $0. Meet at {deal['place']} — {deal['time']}. (TEST DEMO, no real meetup.)")
|
||||
actions = [{"type": "show_cash_meetup", "payload": {
|
||||
"listing_id": pick["id"], "deal_id": deal["id"],
|
||||
"suggested_place": deal["place"], "suggested_time": deal["time"],
|
||||
"fee_cents": 0, "price_cents": pick["price"],
|
||||
}}]
|
||||
return {"reply": reply, "actions": actions, "agent": "fallback", "tools": res}
|
||||
|
||||
# buy / checkout
|
||||
if any(w in low for w in ("buy", "checkout", "purchase", "pay for", "i'll take")):
|
||||
listings = store.all("listings")
|
||||
pick = None
|
||||
# explicit id
|
||||
m_id = re.search(r"lst_[a-f0-9]+", low)
|
||||
if m_id:
|
||||
pick = store.get("listings", m_id.group(0))
|
||||
if not pick:
|
||||
for L in listings:
|
||||
title_words = [w for w in L.get("title", "").lower().split() if len(w) > 3]
|
||||
if any(w in low for w in title_words[:3]) or any(t in low for t in (L.get("tags") or [])):
|
||||
if "online" in (L.get("payment_options") or []):
|
||||
pick = L
|
||||
break
|
||||
if not pick:
|
||||
online = [L for L in listings if "online" in (L.get("payment_options") or []) and L.get("status") == "active"]
|
||||
pick = online[0] if online else None
|
||||
if not pick:
|
||||
return {"reply": "I couldn't match a listing to buy. Try “show bikes” first.", "actions": [], "agent": "fallback"}
|
||||
res = run_tools(store, [{"name": "start_checkout", "args": {"listing_id": pick["id"], "buyer": actor}}])[0]
|
||||
if not res.get("ok"):
|
||||
return {"reply": f"Can't start online checkout: {res.get('error')}. Try cash instead.", "actions": [], "agent": "fallback"}
|
||||
intent = res["intent"]
|
||||
reply = (f"Checkout for “{pick['title']}”. Subtotal {money(intent['subtotal_cents'])} + "
|
||||
f"{FEE_RATE*100:.1f}% fee {money(intent['fee_cents'])} = {money(intent['total_cents'])}. "
|
||||
"TEST MODE: use card 4242 4242 4242 4242.")
|
||||
actions = [{"type": "show_checkout", "payload": {
|
||||
"listing_id": pick["id"], "intent_id": intent["id"], "quantity": 1,
|
||||
"subtotal_cents": intent["subtotal_cents"], "fee_cents": intent["fee_cents"],
|
||||
"fee_bps": FEE_BPS, "total_cents": intent["total_cents"],
|
||||
}}]
|
||||
return {"reply": reply, "actions": actions, "agent": "fallback"}
|
||||
|
||||
# compare
|
||||
if "compare" in low:
|
||||
q = re.sub(r"compare|vs|versus|and", " ", low)
|
||||
hits = _search_q(store, q.strip() or "bike", limit=4)
|
||||
if len(hits) < 2:
|
||||
hits = _search_q(store, "bike", limit=3) or _search_q(store, "", limit=3)
|
||||
ids = [h["id"] for h in hits[:3]]
|
||||
reply = f"Comparing {len(ids)} listings."
|
||||
actions = [{"type": "show_compare", "payload": {"listing_ids": ids, "heading": "Compare"}}]
|
||||
return {"reply": reply, "actions": actions, "agent": "fallback"}
|
||||
|
||||
# search / show
|
||||
q = low
|
||||
for prefix in ("show me ", "show ", "find ", "search for ", "search ", "looking for ", "list "):
|
||||
if q.startswith(prefix):
|
||||
q = q[len(prefix):]
|
||||
break
|
||||
kind = "any"
|
||||
if "service" in q:
|
||||
kind = "service"
|
||||
q = q.replace("services", "").replace("service", "")
|
||||
if "product" in q:
|
||||
kind = "product"
|
||||
q = q.replace("products", "").replace("product", "")
|
||||
max_price = None
|
||||
mp = re.search(r"(?:under|below|less than|max)\s*\$?(\d+(?:\.\d+)?)", q)
|
||||
if mp:
|
||||
max_price = int(float(mp.group(1)) * 100)
|
||||
q = q[:mp.start()] + q[mp.end():]
|
||||
stop = {"everything", "all", "anything", "me", "a", "an", "the", "some", "any", "for", "i", "want", "need", "find", "show", "please"}
|
||||
q = " ".join(w for w in re.sub(r"[^a-z0-9 ]", " ", q).split() if w not in stop)
|
||||
q = " ".join(w[:-1] if w.endswith("s") and len(w) > 4 and w not in ("glass",) else w for w in q.split())
|
||||
hits = _search_q(store, q.strip(), kind=kind, limit=8)
|
||||
if not hits and q.strip():
|
||||
# broader: any token
|
||||
hits = _search_q(store, "", kind=kind, limit=8)
|
||||
hits = [h for h in hits if any(tok in (h.get("title","")+h.get("description","")).lower() for tok in q.split() if len(tok)>2)][:6]
|
||||
if max_price is not None:
|
||||
hits = [h for h in hits if h["price"] <= max_price]
|
||||
if hits:
|
||||
reply = f"Found {len(hits)} listing(s). Click a card, or say “buy the bike” / “cash meetup for the lamp”."
|
||||
actions = [{"type": "show_cards", "payload": {"heading": f"Results for “{q.strip() or 'everything'}”", "listing_ids": [h["id"] for h in hits]}}]
|
||||
else:
|
||||
reply = "Nothing matched. Try “bikes”, “tutoring”, “lamp”, or “lawn”."
|
||||
actions = [{"type": "toast", "payload": {"level": "warn", "text": "No matches"}}]
|
||||
return {"reply": reply, "actions": actions, "agent": "fallback"}
|
||||
|
||||
|
||||
# ---------- LLM path ----------
|
||||
|
||||
LLM_TIMEOUT = float(os.environ.get("AGENT_MARKET_LLM_TIMEOUT", "12")) # hard cap per model attempt
|
||||
@ -440,7 +311,7 @@ COOLDOWN_429 = float(os.environ.get("AGENT_MARKET_LLM_COOLDOWN", "60"))
|
||||
_cooldown = {} # model -> unix time until which we skip it after a 429
|
||||
LAST = {"model": None, "at": None, "error": None, "local": False}
|
||||
QUOTA = {"until": 0}
|
||||
# Local backup model (e.g. Ollama on this box, OpenAI-compatible): tried after the hosted chain, before the keyword rules.
|
||||
# Local model (llama.cpp llama-server on this box, OpenAI-compatible): the last link of the chain, after the hosted models.
|
||||
LOCAL_TIMEOUT = float(os.environ.get("AGENT_MARKET_LOCAL_LLM_TIMEOUT", "45")) # its own cap (CPU inference is slower)
|
||||
LOCAL_MIN = float(os.environ.get("AGENT_MARKET_LOCAL_LLM_MIN", "25")) # always give the local model at least this long
|
||||
TOTAL_BUDGET = float(os.environ.get("AGENT_MARKET_LLM_TOTAL_BUDGET", "60")) # hosted chain + local model, per turn
|
||||
@ -525,15 +396,28 @@ def _llm_config():
|
||||
return cfg
|
||||
|
||||
|
||||
# GBNF for "exactly one JSON object" (llama.cpp grammar syntax). Used instead of response_format for the local model:
|
||||
# it is cheap to sample with and the object ends at the closing brace (no trailing whitespace run).
|
||||
JSON_GRAMMAR = r'''root ::= "{" ws ( string ":" ws value ("," ws string ":" ws value)* )? "}"
|
||||
value ::= object | array | string | number | ("true" | "false" | "null") ws
|
||||
object ::= "{" ws ( string ":" ws value ("," ws string ":" ws value)* )? "}" ws
|
||||
array ::= "[" ws ( value ("," ws value)* )? "]" ws
|
||||
string ::= "\"" ( [^"\\\x7F\x00-\x1F] | "\\" (["\\bfnrt] | "u" [0-9a-fA-F]{4}) )* "\"" ws
|
||||
number ::= ("-"? ([0-9] | [1-9] [0-9]{0,15})) ("." [0-9]+)? ([eE] [-+]? [0-9]{1,3})? ws
|
||||
ws ::= | " " | "\n" [ ]{0,12}
|
||||
'''
|
||||
|
||||
|
||||
def _call_llm(cfg, model, messages, json_mode=True, timeout=None):
|
||||
payload = {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 1100}
|
||||
if json_mode:
|
||||
payload["response_format"] = {"type": "json_object"} # Ollama maps this to JSON mode
|
||||
headers = {"Content-Type": "application/json", "X-Title": "Agent Market demo"}
|
||||
if cfg.get("local"):
|
||||
if cfg.get("local") is True: # the local server itself (a hosted cfg carries its local backup as a dict)
|
||||
payload["max_tokens"] = 700
|
||||
payload["keep_alive"] = -1 # Ollama: keep the model loaded (the server default is set too)
|
||||
if json_mode:
|
||||
payload["grammar"] = JSON_GRAMMAR # llama-server: constrain the output to one JSON object
|
||||
else:
|
||||
if json_mode:
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
headers["Authorization"] = "Bearer " + _read_key()
|
||||
req = urllib.request.Request(cfg["url"], data=json.dumps(payload).encode(), method="POST", headers=headers)
|
||||
try:
|
||||
@ -546,7 +430,7 @@ def _call_llm(cfg, model, messages, json_mode=True, timeout=None):
|
||||
if "per-day" in body or "daily" in body:
|
||||
raise LLMError("daily_quota", m.group(1) if m else "")
|
||||
raise LLMError("rate_limited", body[:300])
|
||||
if e.code == 400 and json_mode and "response_format" in body:
|
||||
if e.code == 400 and json_mode and ("response_format" in body or "grammar" in body or "sampler" in body):
|
||||
return _call_llm(cfg, model, messages, json_mode=False, timeout=timeout)
|
||||
raise LLMError(f"http_{e.code}", body[:300])
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||
@ -599,33 +483,74 @@ def _page_text(look):
|
||||
"chips": [x.get("label") for x in c["chips"]], "bullets": c["bullets"]}, ensure_ascii=False)
|
||||
|
||||
|
||||
def _system_prompt(store, look=""):
|
||||
# stable parts first (schema, catalog) so a local model's prompt cache can reuse them across turns
|
||||
return (UI_SCHEMA + "\nCATALOG (all active listings):\n" + json.dumps(_catalog(store))[:9000] +
|
||||
LOCAL_EXAMPLES = """
|
||||
EXAMPLES (visitor message => your whole answer). Follow the pattern; use the visitor's own words and CATALOG ids.
|
||||
"change the name in the top left to Bazaar Hub" => {"reply":"Done: the top-left logo now says Bazaar Hub.","actions":[{"type":"set_theme","payload":{"copy":{"logo":"Bazaar Hub"}}}]}
|
||||
"swap Agent Market for Corner Shop in the header" => {"reply":"The header now says Corner Shop.","actions":[{"type":"set_theme","payload":{"copy":{"logo":"Corner Shop"}}}]}
|
||||
"make the big title say Hello neighbours" => {"reply":"Headline updated.","actions":[{"type":"set_theme","payload":{"copy":{"headline":"Hello neighbours"}}}]}
|
||||
"get rid of the bullet points" => {"reply":"Bullet points hidden.","actions":[{"type":"set_theme","payload":{"sections":{"hidden":["bullets"]}}}]}
|
||||
"go back to the original look" => {"reply":"Back to the default look.","actions":[{"type":"set_theme","payload":{"reset":true}}]}
|
||||
"light theme please, with an orange accent" => {"reply":"Light mode with an orange accent.","actions":[{"type":"set_theme","payload":{"mode":"light","palette":{"accent":"#f76b15"}}}]}
|
||||
"one item per row instead of a grid" => {"reply":"Results now show as a list.","actions":[{"type":"set_theme","payload":{"layout":"list"}}]}
|
||||
"put the chat on the left side" => {"reply":"Chat moved to the left.","actions":[{"type":"set_theme","payload":{"chat":{"position":"left"}}}]}
|
||||
"use a mono font, slightly larger" => {"reply":"Mono font, a bit larger.","actions":[{"type":"set_theme","payload":{"font":"mono","fontScale":1.15}}]}
|
||||
"a teal to purple gradient behind everything" => {"reply":"Teal to purple gradient background.","actions":[{"type":"set_theme","payload":{"background":{"kind":"gradient","colors":["#12a594","#8e4ec6"],"angle":135}}}]}
|
||||
"make it neon" => {"reply":"Neon look applied.","actions":[{"type":"set_theme","payload":{"preset":"neon"}}]}
|
||||
"I'd like to sell my old sofa for $60, cash only" => {"reply":"I've filled in the listing form. Check it and press Publish.","actions":[{"type":"show_listing_form","payload":{"defaults":{"title":"Old sofa","kind":"product","price":6000,"description":"Used sofa in good condition.","category":"home","area":"Demo Town","payment_options":["cash"]}}}]}
|
||||
"<a search>" => {"reply":"<one short line>","actions":[{"type":"show_cards","payload":{"heading":"<what was searched>","listing_ids":["<only CATALOG ids that match>"]}}]}
|
||||
Only change what the visitor asked for. A visual or text request returns set_theme only (no listings).
|
||||
"""
|
||||
|
||||
|
||||
def _system_prompt(store, look="", local=False):
|
||||
# stable parts first (schema, examples, catalog) so a local model's prompt cache can reuse them across turns
|
||||
return (UI_SCHEMA + (LOCAL_EXAMPLES if local else "") + "\nCATALOG (all active listings):\n" + json.dumps(_catalog(store))[:9000] +
|
||||
"\nCURRENT_LOOK (this visitor's theme overrides; {} = default dark look):\n" + (look or "{}") +
|
||||
"\nPAGE_TEXT (what the editable text slots show right now):\n" + _page_text(look))
|
||||
|
||||
|
||||
_PRIMED = {"key": None}
|
||||
_PRIMED = {"key": None, "decodes": None}
|
||||
|
||||
|
||||
def _local_restarted(local):
|
||||
"""True when the local server is down or was restarted since we last looked (its llama-server /metrics counter went
|
||||
back down), so its prompt cache is cold again and must be re-primed. Unknown servers (no /metrics) -> False."""
|
||||
root = local["url"].split("/v1/")[0]
|
||||
try:
|
||||
with urllib.request.urlopen(root + "/metrics", timeout=2) as r:
|
||||
m = re.search(r"^llamacpp:n_decode_total (\d+)", r.read().decode(errors="replace"), re.M)
|
||||
except urllib.error.HTTPError:
|
||||
return False
|
||||
except Exception:
|
||||
_PRIMED["decodes"] = None
|
||||
return True
|
||||
if not m:
|
||||
return False
|
||||
n, last = int(m.group(1)), _PRIMED["decodes"]
|
||||
_PRIMED["decodes"] = n
|
||||
return last is not None and n < last
|
||||
|
||||
|
||||
def _prime_local_once(store):
|
||||
"""Process the current system prompt on the local model in the background (1 output token), so the next visitor
|
||||
turn reuses Ollama's prompt cache instead of paying ~30 s of CPU prompt processing."""
|
||||
turn reuses llama-server's prompt cache instead of paying ~30 s of CPU prompt processing."""
|
||||
local = _local_config()
|
||||
if not local:
|
||||
return False
|
||||
sp = _system_prompt(store, "")
|
||||
if _local_restarted(local):
|
||||
_PRIMED["key"] = None
|
||||
sp = _system_prompt(store, "", local=True)
|
||||
key = hash(sp)
|
||||
if key == _PRIMED["key"] or not _local_slots.acquire(blocking=False):
|
||||
return False
|
||||
try:
|
||||
body = {"model": local["model"], "messages": [{"role": "system", "content": sp}, {"role": "user", "content": "hi"}],
|
||||
"max_tokens": 1, "temperature": 0, "keep_alive": -1}
|
||||
body = {"model": local["model"], "messages": [{"role": "system", "content": sp}, {"role": "user", "content": _visitor("hi")}],
|
||||
"max_tokens": 1, "temperature": 0}
|
||||
req = urllib.request.Request(local["url"], data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=180) as r:
|
||||
r.read()
|
||||
_PRIMED["key"] = key
|
||||
_local_restarted(local) # remember the counter after our own call
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@ -634,7 +559,8 @@ def _prime_local_once(store):
|
||||
|
||||
|
||||
def start_local_primer(store, every=30):
|
||||
"""Background thread: prime at start-up and whenever the catalog (and so the prompt) changes."""
|
||||
"""Background thread: prime at start-up, whenever the catalog (and so the prompt) changes, and after the local server
|
||||
restarts. Between those it only reads /metrics every 30 s, so an idle server stays idle."""
|
||||
if not _local_config() or os.environ.get("AGENT_MARKET_LOCAL_LLM_PRIME", "1") != "1":
|
||||
return
|
||||
|
||||
@ -648,13 +574,19 @@ def start_local_primer(store, every=30):
|
||||
threading.Thread(target=loop, name="local-llm-primer", daemon=True).start()
|
||||
|
||||
|
||||
def _visitor(message):
|
||||
# small models (and chat templates that fold the system prompt into the first user turn) need a clear boundary
|
||||
return "\n\nVISITOR MESSAGE (answer this one with the JSON object):\n" + message
|
||||
|
||||
|
||||
def _llm_once(store, cfg, model, message, history, look="", timeout=None):
|
||||
sys_prompt = _system_prompt(store, look)
|
||||
local = cfg.get("local") is True
|
||||
sys_prompt = _system_prompt(store, look, local=local)
|
||||
msgs = [{"role": "system", "content": sys_prompt}]
|
||||
for h in (history or [])[-6:]:
|
||||
if h.get("role") in ("user", "assistant") and h.get("content"):
|
||||
msgs.append({"role": h["role"], "content": str(h["content"])[:600]})
|
||||
msgs.append({"role": "user", "content": message})
|
||||
msgs.append({"role": "user", "content": _visitor(message) if local else message})
|
||||
t_end = time.time() + (timeout or LLM_TIMEOUT)
|
||||
content = _call_llm(cfg, model, msgs, timeout=timeout)
|
||||
plan = _parse_agent_json(content)
|
||||
@ -706,31 +638,9 @@ def _focus_results(store, message, actions):
|
||||
LOCAL_HISTORY = int(os.environ.get("AGENT_MARKET_LOCAL_LLM_HISTORY", "0")) # small models copy earlier turns; 0 = no history
|
||||
|
||||
|
||||
def _rules_check(message, look, out):
|
||||
"""Look/copy commands are where a ~4B model is least reliable (it copies presets, misplaces keys, or replies without
|
||||
the action). When the deterministic parser recognises the message as a look or text edit, its validated patch
|
||||
wins and the answer is marked rules_checked (the chat header says so)."""
|
||||
try:
|
||||
cur = json.loads(look) if look else {}
|
||||
except ValueError:
|
||||
cur = {}
|
||||
from .theme import parse_look_command
|
||||
r = parse_look_command(message, cur if isinstance(cur, dict) else {})
|
||||
if not r:
|
||||
return out
|
||||
reply, payload = r
|
||||
if payload is None: # a text edit it couldn't place: keep the model's answer if it did something
|
||||
if not out["actions"]:
|
||||
out["reply"], out["rules_checked"] = reply, True
|
||||
return out
|
||||
out["actions"] = [{"type": "set_theme", "payload": payload}]
|
||||
out["reply"], out["rules_checked"] = reply, True
|
||||
return out
|
||||
|
||||
|
||||
def llm_agent(store, message, history, cfg, look=""):
|
||||
"""Hosted models in order (skipping ones cooling down after a 429, and all of them while the daily free quota is
|
||||
used up), then the local backup model with its own timeout. Raises if everything fails (keyword rules answer then)."""
|
||||
used up), then the local model with its own timeout. Raises if everything fails (the visitor gets BUSY_REPLY)."""
|
||||
errors = []
|
||||
now_t = time.time()
|
||||
start = now_t
|
||||
@ -793,8 +703,6 @@ def llm_agent(store, message, history, cfg, look=""):
|
||||
except concurrent.futures.TimeoutError:
|
||||
raise LLMError("timeout", f">{cap:.0f}s")
|
||||
out["actions"] = _focus_results(store, message, out["actions"])
|
||||
if not message.startswith("UI_EVENT "):
|
||||
out = _rules_check(message, look, out)
|
||||
out.update(agent="llm", provider="local", local=True, model=local["model"],
|
||||
latency_ms=int((time.time() - t0) * 1000), fallbacks=errors)
|
||||
LAST.update(model=local["model"], at=time.time(), error=None, local=True)
|
||||
@ -813,35 +721,37 @@ def llm_agent(store, message, history, cfg, look=""):
|
||||
LLM_EVENTS = os.environ.get("AGENT_MARKET_LLM_EVENTS") == "1"
|
||||
|
||||
|
||||
def busy_reply(errors=None, err="no_model", ms=0):
|
||||
"""Every model failed (or none is configured): say so honestly. Typed messages never get rule-based answers."""
|
||||
return {"reply": BUSY_REPLY, "actions": [], "agent": "unavailable", "busy": True, "retry": True,
|
||||
"llm_error": err, "fallbacks": errors or [], "latency_ms": ms}
|
||||
|
||||
|
||||
def handle_chat(store, message: str, history=None, event=None, actor="demo-buyer", look=""):
|
||||
actor = (actor or "demo-buyer")[:60]
|
||||
t0 = time.time()
|
||||
try:
|
||||
look_d = json.loads(look) if look else {}
|
||||
except ValueError:
|
||||
look_d = {}
|
||||
if event and (event.get("type") == "landing" or not LLM_EVENTS):
|
||||
if event.get("type") == "landing":
|
||||
out = fallback_agent(store, "show me everything", [], actor)
|
||||
else:
|
||||
out = fallback_event(store, event, actor)
|
||||
out = featured_listings(store) if event.get("type") == "landing" else fallback_event(store, event, actor)
|
||||
out.update(agent="ui-event", latency_ms=int((time.time() - t0) * 1000))
|
||||
return out
|
||||
cfg = _llm_config()
|
||||
if cfg:
|
||||
text = message if not event else "UI_EVENT " + json.dumps(event)[:2000] + f"\n(actor/buyer id: {actor})"
|
||||
try:
|
||||
return llm_agent(store, text, history or [], cfg, look)
|
||||
except Exception as e:
|
||||
out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor, look_d)
|
||||
out.update(agent="fallback", llm_error=getattr(e, "kind", type(e).__name__),
|
||||
fallbacks=json.loads(str(e).split(": ", 1)[1]) if getattr(e, "kind", "") == "all_models_failed" else [],
|
||||
latency_ms=int((time.time() - t0) * 1000))
|
||||
out["reply"] = "(AI model unavailable right now; the built-in agent answered.) " + out["reply"]
|
||||
if not cfg:
|
||||
if event: # AGENT_MARKET_LLM_EVENTS=1 but no model: clicks still work deterministically
|
||||
out = fallback_event(store, event, actor)
|
||||
out.update(agent="ui-event", latency_ms=int((time.time() - t0) * 1000))
|
||||
return out
|
||||
out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor, look_d)
|
||||
out.update(agent="fallback", latency_ms=int((time.time() - t0) * 1000))
|
||||
return out
|
||||
return busy_reply(err="no_model_configured", ms=int((time.time() - t0) * 1000))
|
||||
text = message if not event else "UI_EVENT " + json.dumps(event)[:2000] + f"\n(actor/buyer id: {actor})"
|
||||
try:
|
||||
return llm_agent(store, text, history or [], cfg, look)
|
||||
except Exception as e:
|
||||
kind = getattr(e, "kind", type(e).__name__)
|
||||
errors = json.loads(str(e).split(": ", 1)[1]) if kind == "all_models_failed" else []
|
||||
if event:
|
||||
out = fallback_event(store, event, actor)
|
||||
out.update(agent="ui-event", llm_error=kind, fallbacks=errors, latency_ms=int((time.time() - t0) * 1000))
|
||||
return out
|
||||
return busy_reply(errors, kind, int((time.time() - t0) * 1000))
|
||||
|
||||
|
||||
_LOCAL_PING = {"at": 0, "ok": None}
|
||||
@ -871,5 +781,7 @@ def agent_status():
|
||||
"last_model": LAST["model"], "last_local": LAST["local"], "last_error": LAST["error"],
|
||||
"ui_events": "llm" if LLM_EVENTS else "deterministic",
|
||||
"daily_quota_exhausted_until_utc": time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime(QUOTA["until"])) if QUOTA["until"] > now_t else None}
|
||||
return {"mode": "fallback", "provider": "deterministic", "model": None,
|
||||
"note": "No hosted LLM configured (set AGENT_MARKET_LLM_API_KEY_FILE or AGENT_MARKET_LLM_API_KEY, plus AGENT_MARKET_LLM_BASE_URL and AGENT_MARKET_LLM_MODELS). Using the labelled rule-based fallback agent."}
|
||||
return {"mode": "unavailable", "provider": None, "model": None, "chain": [],
|
||||
"note": "No model configured (set AGENT_MARKET_LLM_API_KEY_FILE + AGENT_MARKET_LLM_BASE_URL + AGENT_MARKET_LLM_MODELS "
|
||||
"and/or AGENT_MARKET_LOCAL_LLM_URL + AGENT_MARKET_LOCAL_LLM_MODEL). Typed messages get the busy reply; "
|
||||
"clicks, forms and payments still work."}
|
||||
|
||||
@ -1,16 +1,11 @@
|
||||
"""Rule-based UI copy edits for the built-in agent (used when no hosted/local model answers).
|
||||
"""Editable page text (copy slots): the defaults and what a visitor currently sees.
|
||||
|
||||
Understands e.g. "replace agent market to new internet on the top left corner", "rename the logo to X",
|
||||
"call the site X", "top left should say X", "change the title to X", "replace A with B" (generic find-and-replace
|
||||
across the editable copy slots: logo, headline, subtitle, footer, chip labels, bullets).
|
||||
Returns (reply, patch) where patch is a set_theme patch, or (reply, None) when it understood a copy edit but
|
||||
couldn't apply it (the reply says what it understood and offers the closest option), or None (not a copy edit).
|
||||
Used to tell the model what each text slot shows right now (PAGE_TEXT in the prompt). Text edits themselves are
|
||||
made by the model with set_theme copy; there is no rule-based text editing.
|
||||
"""
|
||||
import difflib
|
||||
import os
|
||||
import re
|
||||
|
||||
# Keep in sync with copyDefaults in web/src/App.tsx (tests/test_copyedit.py checks this).
|
||||
# Keep in sync with copyDefaults in web/src/App.tsx (tests/test_copy_defaults.py checks this).
|
||||
DEFAULT_COPY = {
|
||||
"logo": "Agent Market",
|
||||
"headline": "A marketplace you can just talk to.",
|
||||
@ -23,30 +18,11 @@ DEFAULT_COPY = {
|
||||
"bullets": ["{fee}% 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)"],
|
||||
}
|
||||
LIMITS = {"logo": 40, "headline": 120, "subhead": 240, "footer": 160, "chip": 40, "bullet": 140}
|
||||
NAMES = {"logo": "top-left logo text", "headline": "headline", "subhead": "subtitle", "footer": "footer"}
|
||||
|
||||
# location phrases -> slot (word-bounded; order matters: specific before generic)
|
||||
LOCS = [
|
||||
("subhead", r"sub-?title|sub-?head(?:ing|line)?|tag-?line|intro(?:duction)? text|(?:text|line) (?:under|below) the (?:headline|title|heading)|description"),
|
||||
("logo", r"top[- ]?left(?: corner)?(?: text)?|upper[- ]?left(?: corner)?(?: text)?|left[- ]?top|logo(?: text| name)?|brand(?:ing| name| text)?|"
|
||||
r"site[- ]?(?:name|title)|website (?:name|title)|name of (?:the |this )?(?:site|website|page|store|shop|marketplace|market)|"
|
||||
r"(?:store|shop|marketplace|market|app) name|header(?: text| title| name)?|word-?mark|nav(?:bar)? (?:text|title|name)|"
|
||||
r"(?:corner|top) text|title (?:in|at|on) the (?:top[- ]?left|header|corner|top)"),
|
||||
("footer", r"footer(?: text| line)?|bottom(?: text| line)?|text at the bottom(?: of the page)?|(?:last|bottom) line"),
|
||||
("headline", r"(?:main |big |page |hero )?(?:headline|heading|title|header text)|big text|main text|hero(?: text)?|h1"),
|
||||
]
|
||||
LOC_RE = "|".join(f"(?:{p})" for _, p in LOCS)
|
||||
STRONG = r"replace|rename|swap|re-?word|retitle|re-?label"
|
||||
WEAK = r"change|switch|turn|update|edit|set|make|alter|modify|put|write"
|
||||
SEPS = r"to|with|into|for|by|->|→|=>|=|as"
|
||||
STYLE_WORDS = r"background|colou?r|font|mode|accent|gradient|theme|look|style|layout|chat|column|corners?|radius|dark|light|preset|" \
|
||||
r"pattern|spacing|density|animation|cards?|grid|list|size|bigger|smaller|order|card"
|
||||
|
||||
|
||||
def current_copy(theme, fee_bps=None):
|
||||
fee_bps = fee_bps if fee_bps is not None else int(os.environ.get("AGENT_MARKET_FEE_BPS", "500"))
|
||||
"""The text each editable slot shows right now for this visitor (defaults + their overrides)."""
|
||||
fee_bps = fee_bps if fee_bps is not None else int(os.environ.get("AGENT_MARKET_FEE_BPS", "500"))
|
||||
fee = f"{fee_bps / 100:.{1 if fee_bps % 100 else 0}f}"
|
||||
c = {k: (list(v) if isinstance(v, list) else v) for k, v in DEFAULT_COPY.items()}
|
||||
c["bullets"] = [b.replace("{fee}", fee) for b in c["bullets"]]
|
||||
@ -59,206 +35,3 @@ def current_copy(theme, fee_bps=None):
|
||||
elif k == "bullets" and isinstance(v, list):
|
||||
c["bullets"] = [x for x in v if isinstance(x, str)]
|
||||
return c
|
||||
|
||||
|
||||
def _slot_of(phrase):
|
||||
"""'the top left corner' -> 'logo'; returns None unless the phrase is (only) a location."""
|
||||
p = re.sub(r"^(?:the |this |that |our |my |your |its )?(?:(?:text|words?|wording|copy|writing|label|name)\s+(?:in|on|at|of|for)\s+(?:the\s+)?)?", "",
|
||||
phrase.strip().lower())
|
||||
p = re.sub(r"\s+(?:text|area|section|part|bit|corner)$", "", p).strip()
|
||||
p = re.sub(r"^(?:in|on|at)\s+(?:the\s+)?", "", p)
|
||||
for slot, pat in LOCS:
|
||||
if re.fullmatch(f"(?:{pat})(?: of (?:the |this )?(?:page|site|screen|website))?", p):
|
||||
return slot
|
||||
return None
|
||||
|
||||
|
||||
def _strip_loc_suffix(b):
|
||||
"""'new internet on the top left corner' -> ('new internet', 'logo')."""
|
||||
m = re.search(r"\s+(?:on|in|at|for|up in|over in|inside|within)\s+(?:the\s+|this\s+)?(" + LOC_RE + r")"
|
||||
r"(?:\s+(?:corner|area|section|bar|part|text))?(?:\s+of\s+(?:the\s+|this\s+)?(?:page|site|screen|website))?\s*$", b, re.I)
|
||||
if m:
|
||||
return b[:m.start()].rstrip(), _slot_of(m.group(1))
|
||||
return b, None
|
||||
|
||||
|
||||
def _clean(s):
|
||||
s = s.strip()
|
||||
s = re.sub(r"\s+(?:please|pls|thanks|thank you|instead|now|for me)\s*[.!]*$", "", s, flags=re.I).strip()
|
||||
q = re.fullmatch(r"[\"“”'‘’`](.+)[\"“”'‘’`]", s)
|
||||
if q:
|
||||
return q.group(1).strip(), True
|
||||
return s.rstrip(" .!?"), False
|
||||
|
||||
|
||||
def _match_case(new, old):
|
||||
"""Keep the case the user typed for the new text; only an all-lowercase entry adopts the replaced text's style
|
||||
(so 'new internet' replacing 'Agent Market' becomes 'New Internet')."""
|
||||
if not new or new != new.lower() or not re.search(r"[A-Za-z]", old or ""):
|
||||
return new
|
||||
words = [w for w in re.findall(r"[A-Za-z][\w'’-]*", old)]
|
||||
if old.isupper() and len(old) > 1:
|
||||
return new.upper()
|
||||
if words and all(w[0].isupper() for w in words):
|
||||
return re.sub(r"(^|(?<=[\s\-/]))([a-z])", lambda m: m.group(1) + m.group(2).upper(), new)
|
||||
if words and words[0][0].isupper():
|
||||
return new[:1].upper() + new[1:]
|
||||
return new
|
||||
|
||||
|
||||
def _norm(s):
|
||||
return re.sub(r"\s+", " ", re.sub(r"[\"“”'‘’`.!?]+$", "", (s or "").strip().lower()))
|
||||
|
||||
|
||||
def _slots(cur):
|
||||
out = [("logo", None, cur["logo"]), ("headline", None, cur["headline"]), ("subhead", None, cur["subhead"]), ("footer", None, cur["footer"])]
|
||||
out += [("chip", i, ch["label"]) for i, ch in enumerate(cur["chips"])]
|
||||
out += [("bullet", i, b) for i, b in enumerate(cur["bullets"])]
|
||||
return out
|
||||
|
||||
|
||||
def _label(slot, i, text):
|
||||
"""Name of a slot for replies, with its article ("the headline", "the “📐 Tutoring” button")."""
|
||||
if slot == "chip":
|
||||
return f"the “{text}” button"
|
||||
if slot == "bullet":
|
||||
return f"bullet {i + 1}"
|
||||
return "the " + NAMES[slot]
|
||||
|
||||
|
||||
def _apply(cur, slot, i, new, old_fragment=None):
|
||||
"""Build the patch. old_fragment set => replace that substring (case-insensitive) inside the slot's text."""
|
||||
def sub(text):
|
||||
if old_fragment is None:
|
||||
return _match_case(new, text)
|
||||
rx = re.compile(re.escape(old_fragment), re.I)
|
||||
return rx.sub(lambda m: _match_case(new, m.group(0)), text)
|
||||
if slot in ("logo", "headline", "subhead", "footer"):
|
||||
val = sub(cur[slot])[:LIMITS[slot]]
|
||||
return {"copy": {slot: val}}, val
|
||||
if slot == "chip":
|
||||
chips = [dict(c) for c in cur["chips"]]
|
||||
chips[i]["label"] = sub(chips[i]["label"])[:LIMITS["chip"]]
|
||||
return {"copy": {"chips": chips}}, chips[i]["label"]
|
||||
bullets = list(cur["bullets"])
|
||||
bullets[i] = sub(bullets[i])[:LIMITS["bullet"]]
|
||||
return {"copy": {"bullets": bullets}}, bullets[i]
|
||||
|
||||
|
||||
def _find(cur, a):
|
||||
"""Where does text A appear? Exact whole-slot matches first, then substring matches."""
|
||||
na = _norm(a)
|
||||
if not na:
|
||||
return [], []
|
||||
exact = [(s, i, t) for s, i, t in _slots(cur) if _norm(t) == na or _norm(re.sub(r"^\W+", "", t)) == na]
|
||||
sub = [(s, i, t) for s, i, t in _slots(cur) if na in (t or "").lower() and (s, i, t) not in exact]
|
||||
return exact, sub
|
||||
|
||||
|
||||
def _closest(cur, a):
|
||||
best, score = None, 0.0
|
||||
for s, i, t in _slots(cur):
|
||||
r = difflib.SequenceMatcher(None, _norm(a), _norm(t)).ratio()
|
||||
if r > score:
|
||||
best, score = (s, i, t), r
|
||||
return best if score >= 0.45 else None
|
||||
|
||||
|
||||
def _candidates(text):
|
||||
"""Yield (A, B, kind) readings of the message; A may be a location or literal text."""
|
||||
t = re.sub(r"^(?:hey|hi|ok|okay|so)[,!]?\s+", "", text.strip(), flags=re.I)
|
||||
t = re.sub(r"^(?:please|pls|can you|could you|would you|will you|i(?:'d| would) like (?:you )?to|i want (?:you )?to|let'?s)\s+", "", t, flags=re.I)
|
||||
t = re.sub(r"^(?:please)\s+", "", t, flags=re.I)
|
||||
# two quoted strings: replace "A" with "B"
|
||||
q = re.findall(r"[\"“‘'`]([^\"“”‘’'`]+)[\"”’'`]", t)
|
||||
if len(q) >= 2 and re.match(rf"(?:{STRONG}|{WEAK})\b", t, re.I):
|
||||
yield q[0], q[1], "pair"
|
||||
# verb A sep B (try every separator position; caller picks the reading that resolves)
|
||||
m = re.match(rf"(?P<v>{STRONG}|{WEAK})\s+(?P<rest>.+)$", t, re.I | re.S)
|
||||
if m:
|
||||
rest = m.group("rest")
|
||||
for sm in re.finditer(rf"\s+(?:{SEPS})\s+|\s*(?:->|→|=>|=)\s*", rest, re.I):
|
||||
a, b = rest[:sm.start()], rest[sm.end():]
|
||||
if a.strip() and b.strip():
|
||||
yield a, b, m.group("v").lower()
|
||||
# LOC should say / make LOC say / I want LOC to say / LOC: B
|
||||
m = re.match(rf"(?:make|have|let|get)?\s*(?:the\s+)?(?P<loc>.+?)\s+(?:should|must|needs? to|has to|to|will|shall)?\s*"
|
||||
rf"(?:say|says|read|reads|show|shows|display|displays|be|become|state)\s*:?\s+(?P<b>.+)$", t, re.I)
|
||||
if m and _slot_of(m.group("loc")):
|
||||
yield m.group("loc"), m.group("b"), "say"
|
||||
m = re.match(rf"(?:i want|i'd like)\s+(?:the\s+)?(?P<loc>.+?)\s+to\s+(?:say|read|show|be)\s+(?P<b>.+)$", text.strip(), re.I)
|
||||
if m and _slot_of(m.group("loc")):
|
||||
yield m.group("loc"), m.group("b"), "say"
|
||||
m = re.match(r"(?P<loc>[^:]{2,40}?)\s*:\s*(?P<b>.+)$", t)
|
||||
if m and _slot_of(m.group("loc")):
|
||||
yield m.group("loc"), m.group("b"), "say"
|
||||
# call the site X / name it X / rename the site X / retitle the site X
|
||||
m = re.match(r"(?:call|name|rename|retitle|title|brand)\s+(?:the |this |our |my )?(?:site|website|page|store|shop|marketplace|market|app|it)"
|
||||
r"\s+(?:to\s+|as\s+)?(?P<b>.+)$", t, re.I)
|
||||
if m:
|
||||
yield "logo", m.group("b"), "call"
|
||||
m = re.match(r"use\s+(?P<b>.+?)\s+as\s+(?:the\s+)?(?P<loc>.+)$", t, re.I)
|
||||
if m and _slot_of(m.group("loc")):
|
||||
yield m.group("loc"), m.group("b"), "say"
|
||||
|
||||
|
||||
def parse_copy_edit(text, theme=None, fee_bps=None):
|
||||
fee_bps = fee_bps if fee_bps is not None else int(os.environ.get("AGENT_MARKET_FEE_BPS", "500"))
|
||||
if not text or len(text) > 400:
|
||||
return None
|
||||
cur = current_copy(theme, fee_bps)
|
||||
low = text.lower()
|
||||
fallback_understood = None
|
||||
for a_raw, b_raw, kind in _candidates(text):
|
||||
b, hint = _strip_loc_suffix(_clean(b_raw)[0]) if kind != "pair" else (b_raw.strip(), None)
|
||||
b, quoted = _clean(b) if kind != "pair" else (b, True)
|
||||
a_loc_text, hint_a = _strip_loc_suffix(a_raw.strip())
|
||||
a, a_quoted = _clean(a_loc_text) if kind != "pair" else (a_raw, True)
|
||||
hint = hint or hint_a
|
||||
if not b or len(b) > 240:
|
||||
continue
|
||||
loc = "logo" if kind == "call" else (_slot_of(a) if not a_quoted else None)
|
||||
if loc:
|
||||
patch, val = _apply(cur, loc, None, b)
|
||||
return f"Done: the {NAMES[loc]} now says “{val}”. Say “reset the look” to undo.", patch
|
||||
exact, sub = _find(cur, a)
|
||||
if hint:
|
||||
# a location was named: replace inside that slot (whole text if it matches, else the matching words)
|
||||
here = [x for x in exact + sub if x[0] == hint]
|
||||
if here:
|
||||
s, i, t = here[0]
|
||||
frag = None if _norm(t) == _norm(a) else a
|
||||
patch, val = _apply(cur, s, i, b, frag)
|
||||
return f"Done: the {NAMES[s]} now says “{val}”. Say “reset the look” to undo.", patch
|
||||
if kind in ("say",) or not (exact or sub):
|
||||
patch, val = _apply(cur, hint, None, b)
|
||||
return (f"Done: the {NAMES[hint]} now says “{val}” (I couldn't find “{a}” there, so I replaced the whole text). "
|
||||
"Say “reset the look” to undo."), patch
|
||||
if len(exact) == 1 or (not exact and len(sub) == 1):
|
||||
s, i, t = (exact or sub)[0]
|
||||
# whole-text match replaces the slot; a partial match (or a chip label minus its emoji) replaces just those words
|
||||
frag = None if exact and _norm(t) == _norm(a) else a
|
||||
patch, val = _apply(cur, s, i, b, frag)
|
||||
return f"Done: {_label(s, i, t)} now says “{val}”. Say “reset the look” to undo.", patch
|
||||
if len(exact) + len(sub) > 1:
|
||||
where = ", ".join(_label(s, i, t) for s, i, t in (exact + sub)[:4]).replace("the ", "")
|
||||
fallback_understood = fallback_understood or (
|
||||
f"I understood: change “{a}” to “{b}”, but that text appears in more than one place ({where}). "
|
||||
f"Say where, e.g. “change the {NAMES.get((exact + sub)[0][0], 'headline')} to {b}”.")
|
||||
continue
|
||||
# A not on the page: only answer (instead of falling through to other commands) for clear copy-edit wording
|
||||
if fallback_understood is None and (re.match(rf"\s*(?:please\s+)?(?:{STRONG})\b", low) or kind == "pair") \
|
||||
and not re.search(rf"\b(?:{STYLE_WORDS})\b", low) and not re.search(r"\b(?:buy|sell|order|cart|checkout|pay)\b", a.lower()):
|
||||
near = _closest(cur, a)
|
||||
if near:
|
||||
s, i, t = near
|
||||
fallback_understood = (f"I understood: change “{a}” to “{b}”, but I can't find “{a}” on the page. "
|
||||
f"Closest is {_label(s, i, t)} (“{t[:60]}”). Say “change the {NAMES.get(s, 'headline')} to {b}” "
|
||||
"if that's what you meant.")
|
||||
else:
|
||||
fallback_understood = (f"I understood: change “{a}” to “{b}”, but I can't find “{a}” on the page. I can edit the "
|
||||
f"logo (“{cur['logo']}”), headline, subtitle, footer, quick-action buttons and bullets, "
|
||||
f"e.g. “change the logo to {b}”.")
|
||||
if fallback_understood:
|
||||
return fallback_understood, None
|
||||
return None
|
||||
|
||||
134
market/theme.py
134
market/theme.py
@ -284,137 +284,3 @@ def compact_current(theme):
|
||||
t.pop("customCss", None)
|
||||
s = json.dumps(t, separators=(",", ":"))
|
||||
return s if len(s) <= 900 else s[:900]
|
||||
|
||||
|
||||
# ---------- rule-based look commands (used when the hosted model is unavailable) ----------
|
||||
NAMED = {"red": "#e5484d", "green": "#2fb36d", "blue": "#3d7bfd", "purple": "#8e4ec6", "violet": "#8e4ec6", "pink": "#e93d82",
|
||||
"orange": "#f76b15", "yellow": "#ffd23f", "teal": "#12a594", "cyan": "#00b8d9", "black": "#0b0b0f", "white": "#ffffff",
|
||||
"grey": "#8b8d98", "gray": "#8b8d98", "navy": "#1b2a4a", "gold": "#d4a72c", "brown": "#8d5a3b", "beige": "#efe3cf",
|
||||
"cream": "#fff8e7", "mint": "#9de0c4", "lavender": "#c9b8f0", "coral": "#ff7f6e", "lime": "#9ae600", "magenta": "#d62fd6",
|
||||
"maroon": "#6d1a2b", "olive": "#7a8c3c", "sky": "#8ecdf7", "peach": "#ffcfa8", "turquoise": "#2ad4c4", "indigo": "#4b4bd6"}
|
||||
GRADIENTS = {"sunset": ["#ff7e5f", "#feb47b", "#6a3093"], "ocean": ["#0f2027", "#203a43", "#2c5364"], "forest": ["#134e5e", "#71b280"],
|
||||
"candy": ["#f9a8d4", "#a5b4fc"], "fire": ["#f12711", "#f5af19"], "night": ["#0f0c29", "#302b63", "#24243e"],
|
||||
"peach": ["#ffecd2", "#fcb69f"], "aurora": ["#00c9a7", "#845ec2", "#0081cf"], "rainbow": ["#ff5f6d", "#ffc371", "#47e891", "#3d7bfd", "#8e4ec6"]}
|
||||
SECTION_WORDS = {"hero": "hero", "headline": "hero", "title": "hero", "chips": "chips", "quick actions": "chips", "quick buttons": "chips",
|
||||
"buttons": "chips", "bullets": "bullets", "bullet points": "bullets", "facts": "bullets", "results": "results",
|
||||
"listings": "results", "footer": "footer"}
|
||||
_CW = "|".join(sorted(NAMED, key=len, reverse=True))
|
||||
|
||||
|
||||
def parse_look_command(text, current=None):
|
||||
"""Map a plain-English look request to a set_theme payload. Returns (reply, payload) or None."""
|
||||
low = (text or "").lower().strip()
|
||||
cur = current if isinstance(current, dict) else {}
|
||||
if re.search(r"\breset\b.*\b(look|theme|style|design|page|colou?rs?)\b|\b(default|original) (look|theme)\b|^reset$", low):
|
||||
return "Done: the page is back to its default look.", {"reset": True}
|
||||
# UI copy edits first ("change the headline to Retro vibes" is text, not the retro preset)
|
||||
from .copyedit import parse_copy_edit
|
||||
ce = parse_copy_edit(text, cur)
|
||||
if ce:
|
||||
return ce[0], (sanitize_patch(ce[1]) if ce[1] else None)
|
||||
for name in list(PRESETS) + list(PRESET_ALIASES):
|
||||
if re.search(r"\b" + re.escape(name) + r"\b", low) and re.search(r"look|theme|style|make it|switch|preset|vibe|feel", low) \
|
||||
and not re.search(r"background|gradient", low):
|
||||
real = PRESET_ALIASES.get(name, name)
|
||||
return f"Applied the {real} look. Say “reset the look” to go back.", {"preset": real}
|
||||
patch, said = {}, []
|
||||
if re.search(r"\blight (mode|theme)\b|\bmake it light\b", low):
|
||||
patch["mode"] = "light"; said.append("light mode")
|
||||
elif re.search(r"\bdark (mode|theme)\b|\bmake it dark\b", low):
|
||||
patch["mode"] = "dark"; said.append("dark mode")
|
||||
if "background" in low or "gradient" in low or re.search(r"\b(dots|polka|stripes|checks|checkered|grid pattern|waves)\b", low):
|
||||
g = next((k for k in GRADIENTS if k in low), None)
|
||||
cols = re.findall(r"\b(" + _CW + r")\b", low)
|
||||
pat = next((p for k, p in (("polka", "dots"), ("dot", "dots"), ("stripe", "stripes"), ("check", "checks"),
|
||||
("diagonal", "diagonal"), ("wave", "waves"), ("grid", "grid")) if k in low), None)
|
||||
if g:
|
||||
patch["background"] = {"kind": "gradient", "colors": GRADIENTS[g], "angle": 180}; said.append(f"{g} gradient background")
|
||||
elif pat and "background" in low or pat and "pattern" in low:
|
||||
c = [NAMED[x] for x in cols[:2]] or ["#1d212a", "#2a2f3a"]
|
||||
patch["background"] = {"kind": "pattern", "pattern": pat, "colors": c if len(c) > 1 else [c[0], "#ffffff22"]}; said.append(f"{pat} pattern background")
|
||||
elif "gradient" in low and cols:
|
||||
c = [NAMED[x] for x in cols[:4]]
|
||||
patch["background"] = {"kind": "gradient", "colors": c if len(c) > 1 else [c[0], "#000000"], "angle": 135}; said.append("gradient background")
|
||||
elif cols:
|
||||
patch["background"] = {"kind": "solid", "colors": [NAMED[cols[0]]]}; said.append(f"{cols[0]} background")
|
||||
m = re.search(r"\b(" + _CW + r")\s+accent|accent(?: colou?r)?\s+(?:to\s+)?(" + _CW + r")\b", low)
|
||||
if m:
|
||||
c = m.group(1) or m.group(2); patch.setdefault("palette", {})["accent"] = NAMED[c]; said.append(f"{c} accent")
|
||||
m = re.search(r"\b(" + _CW + r")\s+text\b|\btext\s+(?:colou?r\s+)?(?:to\s+)?(" + _CW + r")\b", low)
|
||||
if m:
|
||||
c = m.group(1) or m.group(2); patch.setdefault("palette", {})["text"] = NAMED[c]; said.append(f"{c} text")
|
||||
for name in sorted(FONTS, key=len, reverse=True):
|
||||
if name != "system" and re.search(r"\b" + re.escape(name.lower()) + r"\b", low) \
|
||||
and (re.search(r"\b(font|typeface|type|lettering|letters|text)\b", low) or " " in name):
|
||||
patch["font"] = name; said.append(f"{name} font"); break
|
||||
else:
|
||||
for alias, name in (("monospace", "mono"), ("serif", "serif"), ("handwrit", "Pacifico"), ("script", "Pacifico"),
|
||||
("pixel", "Press Start 2P"), ("comic", "Comic Neue"), ("typewriter", "Space Mono"), ("sans", "Inter")):
|
||||
if alias in low and "font" in low and not ("sans" in low and alias == "serif"):
|
||||
patch["font"] = name; said.append(f"{name} font"); break
|
||||
scale = float(cur.get("fontScale") or 1)
|
||||
if re.search(r"(bigger|larger|increase|bump up).{0,12}(text|font)|(text|font).{0,12}(bigger|larger)", low):
|
||||
patch["fontScale"] = round(min(1.75, scale + 0.15), 2); said.append(f"text size {patch['fontScale']}×")
|
||||
elif re.search(r"(smaller|decrease|reduce).{0,12}(text|font)|(text|font).{0,12}smaller", low):
|
||||
patch["fontScale"] = round(max(0.75, scale - 0.15), 2); said.append(f"text size {patch['fontScale']}×")
|
||||
if re.search(r"sharp|square corners|no rounding", low):
|
||||
patch["radius"] = 0; said.append("sharp corners")
|
||||
elif re.search(r"pill|very rounded|super rounded", low):
|
||||
patch["radius"] = 30; said.append("pill corners")
|
||||
elif re.search(r"rounded|round corners|softer corners", low):
|
||||
patch["radius"] = 22; said.append("rounded corners")
|
||||
if re.search(r"compact|denser|tighter|less spac", low):
|
||||
patch["density"] = "compact"; said.append("compact spacing")
|
||||
elif re.search(r"spacious|airy|more space|roomier|comfy", low):
|
||||
patch["density"] = "comfy"; said.append("roomier spacing")
|
||||
for k, v in (("flat", "flat"), ("outlined", "outlined"), ("outline", "outlined"), ("shadow", "elevated"), ("elevated", "elevated"), ("glass", "glass")):
|
||||
if re.search(r"\b" + k + r"\b", low) and ("card" in low or "style" in low):
|
||||
patch["cardStyle"] = v; said.append(f"{v} cards"); break
|
||||
if re.search(r"as a list|list (view|layout)|not cards|as rows", low):
|
||||
patch["layout"] = "list"; said.append("list layout")
|
||||
elif re.search(r"grid (view|layout)|as cards|as a grid", low):
|
||||
patch["layout"] = "grid"; said.append("grid layout")
|
||||
m = re.search(r"\b([1-6]|one|two|three|four|five|six)\s+columns?\b", low)
|
||||
if m:
|
||||
n = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6}.get(m.group(1)) or int(m.group(1))
|
||||
patch["columns"] = n; patch.setdefault("layout", "grid"); said.append(f"{n} columns")
|
||||
if "chat" in low:
|
||||
ch = {}
|
||||
if re.search(r"\bleft\b", low):
|
||||
ch["position"] = "left"
|
||||
elif re.search(r"\bright\b", low):
|
||||
ch["position"] = "right"
|
||||
if re.search(r"(bigger|larger|wider|taller)", low):
|
||||
ch.update(width=480, height=680)
|
||||
elif re.search(r"(smaller|narrower)", low):
|
||||
ch.update(width=320, height=420)
|
||||
if ch:
|
||||
patch["chat"] = ch; said.append("chat " + ", ".join(f"{k} {v}" for k, v in ch.items()))
|
||||
if re.search(r"(no|stop|turn off|disable|without) animations?|animations? off", low):
|
||||
patch["animations"] = False; said.append("animations off")
|
||||
elif re.search(r"(turn on|enable) animations?|animations? on", low):
|
||||
patch["animations"] = True; said.append("animations on")
|
||||
hide = re.search(r"\b(hide|remove|get rid of)\b(.+)", low)
|
||||
show = re.search(r"\b(show|unhide|bring back)\b(.+)", low)
|
||||
hidden = list((cur.get("sections") or {}).get("hidden") or [])
|
||||
for verb, mm in (("hide", hide), ("show", show)):
|
||||
if not mm:
|
||||
continue
|
||||
for w, sec in SECTION_WORDS.items():
|
||||
if re.search(r"\b" + w + r"\b", mm.group(2)):
|
||||
if verb == "hide" and sec not in hidden:
|
||||
hidden.append(sec); said.append(f"{sec} hidden"); patch["sections"] = {"hidden": hidden}
|
||||
elif verb == "show" and sec in hidden:
|
||||
hidden.remove(sec); said.append(f"{sec} shown"); patch["sections"] = {"hidden": hidden}
|
||||
break
|
||||
m = re.search(r"move (?:the )?(\w+(?: \w+)?) to the (top|bottom)", low)
|
||||
if m and "chat" not in m.group(1):
|
||||
sec = next((s for w, s in SECTION_WORDS.items() if w in m.group(1)), None)
|
||||
if sec:
|
||||
order = [x for x in ((cur.get("sections") or {}).get("order") or SECTIONS) if x != sec]
|
||||
order = [sec] + order if m.group(2) == "top" else order + [sec]
|
||||
patch.setdefault("sections", {})["order"] = order; said.append(f"{sec} moved to the {m.group(2)}")
|
||||
patch = sanitize_patch(patch)
|
||||
if not patch:
|
||||
return None
|
||||
return "Done: " + ", ".join(said) + ".", patch
|
||||
|
||||
64
ops/local-llm-watchdog.sh
Executable file
64
ops/local-llm-watchdog.sh
Executable file
@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local LLM for Agent Market (the last link of the model chain): keeps llama.cpp's llama-server running on loopback only
|
||||
# and restarts it if it exits or stops answering. Lean on purpose so the box keeps headroom for everything else:
|
||||
# text-only (no vision projector), weights mmapped and not repacked (no second copy in RAM), 6k context, 1 slot,
|
||||
# q8 KV cache, 6 threads at nice 10, 1 GiB prompt-cache RAM.
|
||||
# start / ensure running (idempotent): ops/local-llm-watchdog.sh --ensure
|
||||
# foreground: ops/local-llm-watchdog.sh
|
||||
# stop: kill $(cat ~/llm/watchdog.pid) (the trap stops llama-server too)
|
||||
# Settings can be overridden in ~/.config/agent-market/local-llm-server.env (outside the repo).
|
||||
set -u
|
||||
DIR=${LLM_DIR:-$HOME/llm}
|
||||
mkdir -p "$DIR"
|
||||
[ -f "$HOME/.config/agent-market/local-llm-server.env" ] && . "$HOME/.config/agent-market/local-llm-server.env"
|
||||
LLAMA_SERVER=${LLAMA_SERVER:-$HOME/ollama/lib/ollama/llama-server} # any llama.cpp llama-server build works
|
||||
LLAMA_LIBS=${LLAMA_LIBS:-$(dirname "$LLAMA_SERVER")}
|
||||
MODEL_GGUF=${MODEL_GGUF:-$DIR/gemma3-4b-it-Q4_K_M.gguf} # gemma3 4B instruct, Q4_K_M, text weights only
|
||||
MODEL_ALIAS=${MODEL_ALIAS:-gemma3:4b}
|
||||
PORT=${LLM_PORT:-11434}
|
||||
CTX=${LLM_CTX:-6144}
|
||||
THREADS=${LLM_THREADS:-6}
|
||||
NICE=${LLM_NICE:-10}
|
||||
CACHE_RAM_MIB=${LLM_CACHE_RAM_MIB:-1024}
|
||||
LOG=$DIR/llama-server.log
|
||||
|
||||
[ -x "$LLAMA_SERVER" ] && [ -f "$MODEL_GGUF" ] || { echo "missing $LLAMA_SERVER or $MODEL_GGUF (see README: Local model)"; exit 1; }
|
||||
|
||||
if [ "${1:-}" = "--ensure" ]; then
|
||||
if [ -f "$DIR/watchdog.pid" ] && kill -0 "$(cat "$DIR/watchdog.pid")" 2>/dev/null; then echo "local LLM watchdog running (pid $(cat "$DIR/watchdog.pid"))"; exit 0; fi
|
||||
# run a private copy: a later deploy rewriting the repo file must not change a script bash is still reading
|
||||
install -m 755 "$0" "$DIR/watchdog.sh"
|
||||
nohup setsid "$DIR/watchdog.sh" >> "$DIR/watchdog.log" 2>&1 < /dev/null &
|
||||
echo "local LLM watchdog started (pid $!)"; exit 0
|
||||
fi
|
||||
|
||||
exec 9> "$DIR/watchdog.lock"
|
||||
flock -n 9 || { echo "another watchdog holds $DIR/watchdog.lock"; exit 0; }
|
||||
echo $$ > "$DIR/watchdog.pid"
|
||||
child=0
|
||||
trap '[ $child -gt 0 ] && kill $child 2>/dev/null; exit 0' TERM INT
|
||||
while true; do
|
||||
# wait for a previous instance to release the port
|
||||
for i in $(seq 1 15); do ss -ltn 2>/dev/null | grep -q "127.0.0.1:$PORT " || break; sleep 1; done
|
||||
[ -f "$LOG" ] && [ "$(stat -c %s "$LOG")" -gt 20000000 ] && mv -f "$LOG" "$LOG.1"
|
||||
LD_LIBRARY_PATH="$LLAMA_LIBS${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" nice -n "$NICE" "$LLAMA_SERVER" \
|
||||
-m "$MODEL_GGUF" --alias "$MODEL_ALIAS" --host 127.0.0.1 --port "$PORT" \
|
||||
-c "$CTX" -np 1 -t "$THREADS" -tb "$THREADS" -b 512 -ub 256 \
|
||||
-ctk q8_0 -ctv q8_0 -fa on --swa-full --no-mmproj --no-repack --cache-ram "$CACHE_RAM_MIB" \
|
||||
--poll 0 --threads-http 2 --no-webui --jinja --metrics >> "$LOG" 2>&1 &
|
||||
child=$!; started=$(date +%s)
|
||||
echo "$(date '+%F %T') started llama-server pid $child ($MODEL_ALIAS, ctx $CTX, $THREADS threads, nice $NICE)"
|
||||
for i in $(seq 1 120); do curl -sf -m 2 "http://127.0.0.1:$PORT/health" >/dev/null && break; kill -0 $child 2>/dev/null || break; sleep 1; done
|
||||
fails=0
|
||||
while kill -0 $child 2>/dev/null; do
|
||||
sleep 15
|
||||
# /health is served by its own HTTP threads, so it answers while a reply is being generated
|
||||
if curl -sf -m 10 "http://127.0.0.1:$PORT/health" >/dev/null; then fails=0; else fails=$((fails+1)); fi
|
||||
if [ $fails -ge 3 ]; then echo "$(date '+%F %T') llama-server unresponsive, restarting"; kill $child; sleep 5; kill -9 $child 2>/dev/null; fi
|
||||
[ -f "$LOG" ] && [ "$(stat -c %s "$LOG")" -gt 50000000 ] && : > "$LOG"
|
||||
done
|
||||
wait $child 2>/dev/null
|
||||
st=$?; pause=3; [ $(( $(date +%s) - started )) -lt 60 ] && pause=30 # crashing right away: back off
|
||||
echo "$(date '+%F %T') llama-server exited (status $st); restarting in ${pause}s"
|
||||
sleep $pause
|
||||
done
|
||||
@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local LLM for Agent Market: keeps `ollama serve` running on localhost only and restarts it if it dies or hangs.
|
||||
# Start: nohup setsid ~/ollama/watchdog.sh >> ~/ollama/watchdog.log 2>&1 &
|
||||
# Stop: kill $(cat ~/ollama/watchdog.pid) (the trap stops ollama too)
|
||||
set -u
|
||||
export OLLAMA_HOST=127.0.0.1:11434 # loopback only, never exposed
|
||||
export OLLAMA_MODELS=$HOME/ollama/models
|
||||
export OLLAMA_KEEP_ALIVE=-1 # keep the model in RAM
|
||||
export OLLAMA_NUM_PARALLEL=2 OLLAMA_MAX_LOADED_MODELS=1 OLLAMA_MAX_QUEUE=8
|
||||
export OLLAMA_CONTEXT_LENGTH=${OLLAMA_CONTEXT_LENGTH:-8192}
|
||||
export OLLAMA_FLASH_ATTENTION=1 OLLAMA_KV_CACHE_TYPE=q8_0
|
||||
export OLLAMA_NOPRUNE=1
|
||||
# model to pre-load after each (re)start, from the Agent Market config (outside the repo)
|
||||
WARM=$(sed -n 's/^AGENT_MARKET_LOCAL_LLM_MODEL=//p' "$HOME/.config/agent-market/local-llm.env" 2>/dev/null | tr -d '"' | head -1)
|
||||
echo $$ > "$HOME/ollama/watchdog.pid"
|
||||
child=0
|
||||
trap '[ $child -gt 0 ] && kill $child 2>/dev/null; exit 0' TERM INT
|
||||
while true; do
|
||||
# wait for a previous instance to release the port
|
||||
for i in $(seq 1 15); do ss -ltn 2>/dev/null | grep -q '127.0.0.1:11434 ' || break; sleep 1; done
|
||||
"$HOME/ollama/bin/ollama" serve >> "$HOME/ollama/ollama.log" 2>&1 &
|
||||
child=$!
|
||||
echo "$(date '+%F %T') started ollama pid $child"
|
||||
for i in $(seq 1 30); do curl -sf -m 2 http://127.0.0.1:11434/api/version >/dev/null && break; sleep 1; done
|
||||
[ -n "$WARM" ] && curl -s -m 300 http://127.0.0.1:11434/api/generate -d "{\"model\":\"$WARM\",\"keep_alive\":-1}" >/dev/null &
|
||||
fails=0
|
||||
while kill -0 $child 2>/dev/null; do
|
||||
sleep 20
|
||||
if curl -sf -m 10 http://127.0.0.1:11434/api/version >/dev/null; then fails=0; else fails=$((fails+1)); fi
|
||||
if [ $fails -ge 3 ]; then echo "$(date '+%F %T') ollama unresponsive, restarting"; kill $child; sleep 3; kill -9 $child 2>/dev/null; fi
|
||||
done
|
||||
echo "$(date '+%F %T') ollama exited; restarting in 3s"
|
||||
sleep 3
|
||||
done
|
||||
@ -17,14 +17,14 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
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|Local model/.test(badge), badge);
|
||||
check("agent mode badge shown", /^AI: |^Local model \(/.test(badge), badge);
|
||||
|
||||
// ask(): type into the chat popup and wait for the agent's reply (LLM replies take a few seconds)
|
||||
const ask = async (text) => {
|
||||
const n = (await page.$$(".msg.bot")).length;
|
||||
await page.type("#chatInput", text);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForFunction((n) => document.querySelectorAll(".msg.bot").length > n, { timeout: 60000 }, n);
|
||||
await page.waitForFunction((n) => document.querySelectorAll(".msg.bot").length > n, { timeout: 90000 }, n);
|
||||
await sleep(300);
|
||||
};
|
||||
// chat search
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end HTTP checks against a running Agent Market. Usage: e2e_http.py http://127.0.0.1:8795"""
|
||||
import json, sys, urllib.request, urllib.error
|
||||
"""End-to-end HTTP checks against a running Agent Market. Usage: e2e_http.py http://127.0.0.1:8795
|
||||
Typed messages always go to a model (hosted free models, then the local model). With REQUIRE_LLM=1 they must be answered
|
||||
by one; otherwise an honest busy reply is accepted for them. Clicks, forms and payments are deterministic and always checked."""
|
||||
import json, os, sys, urllib.request, urllib.error
|
||||
|
||||
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8795").rstrip("/")
|
||||
results = []
|
||||
@ -11,7 +13,7 @@ def call(method, path, body=None, headers=None, raw=False):
|
||||
h = {"Content-Type": "application/json", **(headers or {})}
|
||||
req = urllib.request.Request(BASE + path, data=data, method=method, headers=h)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
b = r.read().decode()
|
||||
return r.status, (b if raw else json.loads(b))
|
||||
except urllib.error.HTTPError as e:
|
||||
@ -38,21 +40,37 @@ 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"]
|
||||
|
||||
s, out = call("POST", "/api/chat", {"message": "hi"})
|
||||
check("chat agent returns UI actions", s == 200 and out["actions"] and out["actions"][0]["type"] in ("show_hero", "show_cards"), out.get("agent"))
|
||||
REQUIRE_LLM = os.environ.get("REQUIRE_LLM") == "1"
|
||||
BUSY = "The assistant is busy, please try again in a moment."
|
||||
|
||||
|
||||
def typed(name, out, ok_fn, detail=""):
|
||||
"""A typed message: answered by a model (then ok_fn must hold) or, unless REQUIRE_LLM, the honest busy reply."""
|
||||
if out.get("agent") == "llm":
|
||||
check(name + f" (model {out.get('model')}{', local' if out.get('local') else ''})", ok_fn(out), detail)
|
||||
else:
|
||||
check(name + " -> busy reply (no model available)", not REQUIRE_LLM and out.get("agent") == "unavailable" and
|
||||
out.get("reply") == BUSY and out.get("actions") == [] and out.get("retry") is True, f"agent={out.get('agent')}")
|
||||
|
||||
|
||||
s, out = call("POST", "/api/chat", {"event": {"type": "landing"}})
|
||||
check("landing view (deterministic featured listings)", s == 200 and out["agent"] == "ui-event" and out["actions"][0]["type"] == "show_cards"
|
||||
and len(out["actions"][0]["payload"]["listings"]) >= 3)
|
||||
s, out = call("POST", "/api/chat", {"message": "show me bikes"})
|
||||
cards = [a for a in out["actions"] if a["type"] == "show_cards"]
|
||||
titles = [l["title"] for l in cards[0]["payload"]["listings"]] if cards else []
|
||||
check("agent search renders cards (hydrated listings)", cards and any("bike" in t.lower() for t in titles), ", ".join(titles))
|
||||
bike = next((l for l in cards[0]["payload"]["listings"] if l["kind"] == "product" and "online" in l["payment_options"]), None) if cards else None
|
||||
typed("typed search renders hydrated cards", out, lambda o: cards and any("bike" in t.lower() for t in titles), ", ".join(titles))
|
||||
s, r = call("POST", "/api/actions", {"action": "search", "args": {"q": "bike"}}, {"X-Agent-Id": "test-buyer-e2e"})
|
||||
bike = next((l for l in r.get("listings", []) if l["kind"] == "product" and "online" in l["payment_options"] and l["status"] == "active"), None)
|
||||
if not bike: # earlier runs may have sold the seed bike; list a fresh test-only one
|
||||
s, r = call("POST", "/api/actions", {"action": "create_listing", "args": {"title": "Test-only commuter bike", "kind": "product", "price": 18000,
|
||||
"description": "Fake bike for automated tests", "payment_options": ["online", "cash"]}}, {"X-Agent-Id": "test-seller-e2e"})
|
||||
bike = r["listing"]
|
||||
|
||||
s, out = call("POST", "/api/chat", {"message": "I want to sell something"})
|
||||
check("agent opens listing form", any(a["type"] == "show_listing_form" for a in out["actions"]))
|
||||
s, out = call("POST", "/api/chat", {"message": "I want to sell my old desk lamp for $20, cash only"})
|
||||
typed("typed sell request opens a prefilled listing form", out, lambda o: any(a["type"] == "show_listing_form" for a in o["actions"]))
|
||||
s, out = call("POST", "/api/chat", {"event": {"type": "sell"}})
|
||||
check("Sell button opens the listing form (deterministic)", out["agent"] == "ui-event" and out["actions"][0]["type"] == "show_listing_form")
|
||||
s, out = call("POST", "/api/chat", {"event": {"type": "submit_listing", "fields": {"title": "Test-only desk lamp", "kind": "product", "price": "20", "description": "Fake test listing", "payment_options": ["online", "cash"]}}, "actor": "guest-e2e"})
|
||||
new = [a for a in out["actions"] if a["type"] == "show_cards"]
|
||||
newl = new[0]["payload"]["listings"][0] if new else {}
|
||||
@ -81,8 +99,8 @@ s, r = call("POST", "/api/payments/webhook", {"type": "payment_intent.succeeded"
|
||||
check("forged webhook rejected", s == 401)
|
||||
|
||||
# Cash in person: zero fee.
|
||||
s, out = call("POST", "/api/chat", {"message": "show lamp"})
|
||||
lamps = [l for a in out["actions"] if a["type"] == "show_cards" for l in a["payload"]["listings"] if "cash" in l["payment_options"] and l["kind"] == "product"]
|
||||
s, r = call("POST", "/api/actions", {"action": "search", "args": {"q": "lamp"}}, {"X-Agent-Id": "test-buyer-e2e"})
|
||||
lamps = [l for l in r.get("listings", []) if "cash" in l["payment_options"] and l["kind"] == "product" and l["status"] == "active"]
|
||||
if not lamps:
|
||||
s, r = call("POST", "/api/actions", {"action": "create_listing", "args": {"title": "Test-only lamp", "kind": "product", "price": 3000,
|
||||
"description": "Fake lamp for automated tests", "payment_options": ["online", "cash"]}}, {"X-Agent-Id": "test-seller-e2e"})
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
// 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>
|
||||
// React UI against the fake OpenAI-compatible model (tests/mock_llm.py): model badge + chat mode text, busy reply with
|
||||
// retry button (never rule-based answers), 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> <local> <mock-url>
|
||||
const puppeteer = require("puppeteer-core");
|
||||
const [OK, BUSY, DAILY, RL, LOCAL] = process.argv.slice(2, 7);
|
||||
const [OK, BUSY, DAILY, RL, LOCAL, MOCK] = process.argv.slice(2, 8);
|
||||
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));
|
||||
@ -43,23 +43,36 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
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
|
||||
// 3) every model rate-limited -> honest busy message with a retry button (no rule-based answer, no cards)
|
||||
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,
|
||||
const cards0 = await p.$$eval("#stage .card h3", (ns) => ns.map((n) => n.textContent).join("|"));
|
||||
const busy = await ask(p, "show me bikes");
|
||||
check("busy reply + retry button, nothing rendered by rules",
|
||||
/^The assistant is busy, please try again in a moment\./.test(busy) && (await p.$$(".msg.bot.busy .retryBtn")).length === 1 &&
|
||||
(await p.$$eval("#stage .card h3", (ns) => ns.map((n) => n.textContent).join("|"))) === cards0,
|
||||
`${busy} | ${await text(p, "#agentBadge")} | ${await text(p, "#chatMode")}`);
|
||||
check("busy badge + chat mode text", (await text(p, "#agentBadge")) === "AI: busy" && /^Assistant busy/.test(await text(p, "#chatMode")),
|
||||
`${await text(p, "#agentBadge")} | ${await text(p, "#chatMode")}`);
|
||||
// 4) daily quota used up -> after reload the badge says so (from /api/status)
|
||||
const calls = async () => { const c = (await (await fetch(MOCK)).json()).calls; return (c["busy-model"] || 0) + (c["busy-model-2"] || 0); };
|
||||
const c0 = await calls();
|
||||
const users0 = (await p.$$(".msg.user")).length;
|
||||
await p.click(".msg.bot.busy .retryBtn");
|
||||
await p.waitForFunction(() => !document.querySelector(".msg.sys") && document.querySelectorAll(".msg.bot.busy").length === 1, { timeout: 30000 });
|
||||
await sleep(300);
|
||||
check("Retry resends the same message (one busy message left, no duplicate user bubble)",
|
||||
(await calls()) === c0 + 1 && (await p.$$(".msg.user")).length === users0 && (await p.$$(".msg.bot.busy")).length === 1, `calls ${c0} -> ${await calls()}`);
|
||||
// 4) daily quota used up, no local model -> busy reply; after reload the badge says the quota is used up
|
||||
p = await open(DAILY);
|
||||
await ask(p, "show me bikes");
|
||||
check("quota used up -> busy reply", /^The assistant is busy/.test(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"));
|
||||
check("quota badge after reload", (await text(p, "#agentBadge")) === "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("|"));
|
||||
|
||||
// 6) hosted quota used up -> local backup model answers; badge says so honestly
|
||||
// 6) hosted quota used up -> the local model answers; badge says so honestly
|
||||
if (LOCAL) {
|
||||
p = await open(LOCAL);
|
||||
await ask(p, "show me bikes");
|
||||
|
||||
@ -13,4 +13,4 @@ S1=$(start 18804 mock-model 0 6); S2=$(start 18805 busy-model,busy-model-2 0 6);
|
||||
S5=$(AGENT_MARKET_LOCAL_LLM_URL=http://127.0.0.1:18803/v1 AGENT_MARKET_LOCAL_LLM_MODEL=local-model start 18811 daily-model 0 6)
|
||||
trap 'kill $M $S1 $S2 $S3 $S4 $S5 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 http://127.0.0.1:18811
|
||||
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 http://127.0.0.1:18811 http://127.0.0.1:18803/
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# LLM proxy path against a local fake OpenAI-compatible server (no real key needed):
|
||||
# tool call -> hydrated cards, key stays server-side, 429 fallback chain, rule-based last resort, per-IP rate limit.
|
||||
# tool call -> hydrated cards, key stays server-side, 429 fallback chain, local model last, busy reply (never rules), per-IP rate limit.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
D=$(mktemp -d); KEYF=$D/key; printf 'test-dummy-key' > $KEYF; chmod 600 $KEYF
|
||||
@ -9,14 +9,16 @@ start() { # port models rpm
|
||||
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=3 \
|
||||
AGENT_MARKET_LLM_BASE_URL=http://127.0.0.1:18798/v1 AGENT_MARKET_LLM_MODELS=$2 python3 server.py >/dev/null 2>&1 & echo $!; }
|
||||
S1=$(start 18799 busy-model,mock-model 0); S2=$(start 18800 busy-model,busy-model-2 0); S3=$(start 18801 mock-model 2); S4=$(start 18802 daily-model,mock-model 0)
|
||||
# local backup model (same fake server standing in for Ollama's OpenAI-compatible endpoint)
|
||||
# local model (same fake server standing in for llama-server's OpenAI-compatible endpoint)
|
||||
local_start() { # port hosted-models local-model timeout [nokey]
|
||||
local K=$KEYF; [ -n "${5:-}" ] && K=$D/no-such-key
|
||||
AGENT_MARKET_PORT=$1 AGENT_MARKET_DATA=$D/d$1 AGENT_MARKET_LLM_API_KEY_FILE=$K AGENT_MARKET_CHAT_RPM=0 \
|
||||
AGENT_MARKET_LLM_BASE_URL=http://127.0.0.1:18798/v1 AGENT_MARKET_LLM_MODELS=$2 AGENT_MARKET_LOCAL_LLM_URL=http://127.0.0.1:18798/v1 \
|
||||
AGENT_MARKET_LOCAL_LLM_MODEL=$3 AGENT_MARKET_LOCAL_LLM_TIMEOUT=$4 AGENT_MARKET_LOCAL_LLM_MIN=$4 python3 server.py >/dev/null 2>&1 & echo $!; }
|
||||
S5=$(local_start 18808 daily-model local-model 30); S6=$(local_start 18809 busy-model slow-local 1); S7=$(local_start 18810 x local-model 30 nokey)
|
||||
trap 'kill $M $S1 $S2 $S3 $S4 $S5 $S6 $S7 2>/dev/null; rm -rf $D' EXIT
|
||||
# no model at all (no key, no local model)
|
||||
AGENT_MARKET_PORT=18811 AGENT_MARKET_DATA=$D/d18811 AGENT_MARKET_LLM_API_KEY_FILE=$D/no-such-key AGENT_MARKET_CHAT_RPM=0 python3 server.py >/dev/null 2>&1 & S8=$!
|
||||
trap 'kill $M $S1 $S2 $S3 $S4 $S5 $S6 $S7 $S8 2>/dev/null; rm -rf $D' EXIT
|
||||
sleep 1.5
|
||||
chat() { curl -s localhost:$1/api/chat -H 'Content-Type: application/json' -d '{"message":"show me bikes"}'; }
|
||||
OUT=$(chat 18799)
|
||||
@ -30,10 +32,20 @@ assert all("bike" in l["title"].lower() for l in d["actions"][0]["payload"]["lis
|
||||
print("PASS 429 on first model -> second model answered; cards hydrated; disallowed component dropped")'
|
||||
if echo "$OUT" | grep -q test-dummy-key; then echo "FAIL key leaked to browser"; exit 1; else echo "PASS key never sent to browser"; fi
|
||||
test "$(curl -s localhost:18798/ | python3 -c 'import json,sys;print(json.load(sys.stdin)["auth_ok"])')" -ge 2 && echo "PASS key read from file and sent upstream only"
|
||||
chat 18800 | python3 -c '
|
||||
import json,sys; d=json.load(sys.stdin)
|
||||
assert d["agent"]=="fallback" and d["llm_error"]=="all_models_failed" and d["actions"], d
|
||||
print("PASS all models rate-limited -> rule-based agent still renders", [a["type"] for a in d["actions"]])'
|
||||
BUSY='import json,sys; d=json.load(sys.stdin)
|
||||
assert d["agent"]=="unavailable" and d["reply"]=="The assistant is busy, please try again in a moment." and d["actions"]==[] and d["retry"] is True, d'
|
||||
chat 18800 | python3 -c "$BUSY"'
|
||||
assert d["llm_error"]=="all_models_failed" and [f["error"] for f in d["fallbacks"]]==["rate_limited","rate_limited"], d
|
||||
print("PASS all models rate-limited -> honest busy reply with retry, no rule-based answer")'
|
||||
curl -s localhost:18800/api/chat -H 'Content-Type: application/json' -d '{"message":"replace agent market to new internet on the top left corner","theme":{}}' | python3 -c "$BUSY"'
|
||||
print("PASS look/text commands are never answered by rules either (busy reply)")'
|
||||
curl -s localhost:18800/api/chat -H 'Content-Type: application/json' -d '{"event":{"type":"sell"}}' | python3 -c '
|
||||
import json,sys; d=json.load(sys.stdin); assert d["agent"]=="ui-event" and d["actions"][0]["type"]=="show_listing_form", d
|
||||
print("PASS clicks stay deterministic while the models are busy (Sell -> listing form)")'
|
||||
curl -s localhost:18800/api/chat -H 'Content-Type: application/json' -d '{"event":{"type":"landing"}}' | python3 -c '
|
||||
import json,sys; d=json.load(sys.stdin); a=d["actions"]
|
||||
assert d["agent"]=="ui-event" and a[0]["type"]=="show_cards" and len(a[0]["payload"]["listings"])>=3, d
|
||||
print("PASS landing view is deterministic (featured listings)")'
|
||||
curl -s localhost:18800/api/status | python3 -c '
|
||||
import json,sys; a=json.load(sys.stdin)["agent"]; assert set(a["cooling_down"])=={"busy-model","busy-model-2"}, a; print("PASS 429ed models put on cooldown")'
|
||||
CODES=$(for i in 1 2 3 4 5; do curl -s -o /dev/null -w '%{http_code} ' localhost:18801/api/chat -H 'Content-Type: application/json' -H 'CF-Connecting-IP: 203.0.113.9' -d '{"message":"show me bikes"}'; done)
|
||||
@ -52,19 +64,25 @@ import json,sys; d=json.load(sys.stdin); a=d["actions"]
|
||||
assert d["agent"]=="llm" and a and a[0]["type"]=="set_theme" and a[0]["payload"]["patch"]["copy"]=={"logo":"New Internet"}, d
|
||||
print("PASS model path: prompt explains top-left = logo + current page text -> copy.logo set")'
|
||||
curl -s localhost:18802/api/chat -H 'Content-Type: application/json' -d '{"message":"show me bikes"}' >/dev/null
|
||||
curl -s localhost:18802/api/chat -H 'Content-Type: application/json' -d '{"message":"switch to light mode with a green accent"}' | python3 -c '
|
||||
import json,sys; d=json.load(sys.stdin)
|
||||
assert d["agent"]=="fallback" and d["actions"][0]["type"]=="set_theme" and d["actions"][0]["payload"]["patch"]["mode"]=="light", d
|
||||
print("PASS daily quota -> whole chain parked, built-in agent still restyles:", d["reply"][:80])'
|
||||
curl -s localhost:18802/api/chat -H 'Content-Type: application/json' -d '{"message":"switch to light mode with a green accent"}' | python3 -c "$BUSY"'
|
||||
assert d["fallbacks"]==[{"model":"*","error":"daily_quota","ms":0}], d["fallbacks"]
|
||||
print("PASS daily quota, no local model -> whole chain parked, busy reply (no rules)")'
|
||||
curl -s localhost:18802/api/status | python3 -c '
|
||||
import json,sys; a=json.load(sys.stdin)["agent"]; assert a["daily_quota_exhausted_until_utc"], a; print("PASS status reports quota reset", a["daily_quota_exhausted_until_utc"])'
|
||||
calls() { curl -s localhost:18798/ | python3 -c "import json,sys;print(json.load(sys.stdin)['calls'].get('$1',0))"; }
|
||||
C0=$(calls daily-model)
|
||||
auth() { curl -s localhost:18798/ | python3 -c "import json,sys;print(json.load(sys.stdin)['auth'].get('$1',0))"; }
|
||||
C0=$(calls daily-model); A0=$(auth daily-model)
|
||||
chat 18808 | python3 -c '
|
||||
import json,sys; d=json.load(sys.stdin)
|
||||
assert d["agent"]=="llm" and d.get("local") and d["provider"]=="local" and d["model"]=="local-model", d
|
||||
assert d["fallbacks"][0]["model"]=="daily-model" and d["fallbacks"][0]["error"]=="daily_quota", d["fallbacks"]
|
||||
print("PASS hosted daily quota -> local backup model answered (hosted -> local -> rules)")'
|
||||
print("PASS hosted daily quota -> local model answered (hosted -> local)")'
|
||||
[ "$(auth daily-model)" = "$((A0+1))" ] && echo "PASS hosted calls still carry the key when a local model is configured" || { echo "FAIL hosted call without key"; exit 1; }
|
||||
curl -s localhost:18798/ | python3 -c '
|
||||
import json,sys; s=json.load(sys.stdin)
|
||||
assert s["grammar"].get("local-model",0)>=1 and s["auth"].get("local-model",0)==0 and s["local_prompt"].get("local-model",0)>=1, s
|
||||
assert s["grammar"].get("mock-model",0)==0 and s["local_prompt"].get("mock-model",0)==0, s
|
||||
print("PASS local requests: JSON grammar, examples + visitor-message prompt, never the hosted key")'
|
||||
chat 18808 | python3 -c '
|
||||
import json,sys; d=json.load(sys.stdin); assert d.get("local") and d["fallbacks"]==[{"model":"*","error":"daily_quota","ms":0}], d'
|
||||
[ "$(calls daily-model)" = "$((C0+1))" ] && echo "PASS quota parked: next turn goes straight to the local model (no hosted call)" || { echo "FAIL hosted called again: $(calls daily-model)"; exit 1; }
|
||||
@ -77,15 +95,22 @@ curl -s localhost:18808/api/status | python3 -c '
|
||||
import json,sys; a=json.load(sys.stdin)["agent"]; assert a["daily_quota_exhausted_until_utc"], a'
|
||||
chat 18808 >/dev/null
|
||||
[ "$(calls daily-model)" = "$((C0+1))" ] && echo "PASS quota stop survives a restart (no call spent to rediscover it)" || { echo "FAIL hosted called after restart"; exit 1; }
|
||||
chat 18809 | python3 -c '
|
||||
import json,sys; d=json.load(sys.stdin)
|
||||
assert d["agent"]=="fallback" and d["actions"] and any(f["model"]=="slow-local" and f["error"]=="timeout" for f in d["fallbacks"]), d
|
||||
print("PASS local model over its own timeout -> keyword rules answer (last resort)")'
|
||||
chat 18809 | python3 -c "$BUSY"'
|
||||
assert [(f["model"], f["error"]) for f in d["fallbacks"]]==[("busy-model","rate_limited"),("slow-local","timeout")], d["fallbacks"]
|
||||
print("PASS hosted 429 + local model over its own timeout -> busy reply with retry (no rules)")'
|
||||
chat 18810 | python3 -c '
|
||||
import json,sys; d=json.load(sys.stdin); assert d["agent"]=="llm" and d.get("local") and d["fallbacks"]==[], d
|
||||
print("PASS local model works with no hosted key configured")'
|
||||
curl -s localhost:18810/api/chat -H 'Content-Type: application/json' -d '{"message":"show me bikes"}' | grep -q test-dummy-key && { echo "FAIL key"; exit 1; } || true
|
||||
curl -s localhost:18810/api/chat -H 'Content-Type: application/json' -d '{"message":"hide the bullet points","theme":{}}' | python3 -c '
|
||||
curl -s localhost:18810/api/chat -H 'Content-Type: application/json' -d '{"message":"replace agent market to new internet on the top left corner","theme":{}}' | python3 -c '
|
||||
import json,sys; d=json.load(sys.stdin); a=d["actions"]
|
||||
assert d["agent"]=="llm" and d.get("local") and d.get("rules_checked") and [x["type"] for x in a]==["set_theme"] and a[0]["payload"]["patch"]["sections"]["hidden"]==["bullets"], d
|
||||
print("PASS local model look command checked by the rules (validated patch wins, marked rules_checked)")'
|
||||
assert d["agent"]=="llm" and d.get("local") and "rules_checked" not in d and a[0]["payload"]["patch"]["copy"]=={"logo":"New Internet"}, d
|
||||
print("PASS local model edits page text itself (exact logo phrase; no rules involved)")'
|
||||
chat 18811 | python3 -c "$BUSY"'
|
||||
assert d["llm_error"]=="no_model_configured", d
|
||||
print("PASS no model configured -> busy reply for typed messages")'
|
||||
curl -s localhost:18811/api/chat -H 'Content-Type: application/json' -d '{"event":{"type":"landing"}}' | python3 -c '
|
||||
import json,sys; d=json.load(sys.stdin); assert d["agent"]=="ui-event" and d["actions"][0]["type"]=="show_cards", d'
|
||||
curl -s localhost:18811/api/status | python3 -c '
|
||||
import json,sys; a=json.load(sys.stdin)["agent"]; assert a["mode"]=="unavailable" and a["chain"]==[], a
|
||||
print("PASS no model configured: landing still works, status says unavailable")'
|
||||
|
||||
@ -4,7 +4,7 @@ Round 1 asks for a search tool; round 2 returns show_cards with ids from the too
|
||||
import json, re, sys, time
|
||||
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
||||
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8798
|
||||
SEEN = {"auth_ok": 0, "calls": {}}
|
||||
SEEN = {"auth_ok": 0, "calls": {}, "auth": {}, "grammar": {}, "local_prompt": {}}
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a): pass
|
||||
@ -19,8 +19,14 @@ class H(BaseHTTPRequestHandler):
|
||||
b = b'{"error":{"message":"rate-limited upstream","code":429}}'
|
||||
self.send_response(429); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(b)
|
||||
return
|
||||
mdl = body.get("model")
|
||||
if self.headers.get("Authorization") == "Bearer test-dummy-key":
|
||||
SEEN["auth_ok"] += 1
|
||||
SEEN["auth"][mdl] = SEEN["auth"].get(mdl, 0) + 1
|
||||
if body.get("grammar"):
|
||||
SEEN["grammar"][mdl] = SEEN["grammar"].get(mdl, 0) + 1
|
||||
if "EXAMPLES" in body["messages"][0]["content"] and "VISITOR MESSAGE" in body["messages"][-1]["content"]:
|
||||
SEEN["local_prompt"][mdl] = SEEN["local_prompt"].get(mdl, 0) + 1
|
||||
if body.get("model") == "daily-model":
|
||||
b = json.dumps({"error": {"message": "Rate limit exceeded: free-models-per-day.", "code": 429,
|
||||
"metadata": {"headers": {"X-RateLimit-Limit": "50", "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": "4102444800000"}}}}).encode()
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Natural-language checks against the real hosted model. Usage: nl_check.py BASE_URL
|
||||
Each prompt should be answered by the LLM (not the fallback) with the expected panel."""
|
||||
"""Natural-language checks against a real model (hosted, or the local model). Usage: nl_check.py BASE_URL
|
||||
Each prompt should be answered by a model (not the busy reply) with the expected panel."""
|
||||
import json, sys, time, urllib.request, urllib.error
|
||||
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8795").rstrip("/")
|
||||
fee_bps = json.load(urllib.request.urlopen(BASE + "/api/status"))["fee_bps"]
|
||||
|
||||
@ -33,9 +33,23 @@ h = server.hydrate([{"type": "show_listing_form", "payload": {"defaults": {"titl
|
||||
check("listing form: normal shape untouched", h[0]["payload"]["defaults"] == {"title": "Lamp", "price": 2500})
|
||||
|
||||
cfg = agent._llm_config()
|
||||
check("no hosted key and no local model -> built-in agent only", cfg is None)
|
||||
check("no hosted key and no local model -> no chain (typed messages get the busy reply)", cfg is None)
|
||||
os.environ.update(AGENT_MARKET_LOCAL_LLM_URL="http://127.0.0.1:9/v1", AGENT_MARKET_LOCAL_LLM_MODEL="m")
|
||||
cfg = agent._llm_config()
|
||||
check("local model alone is a valid chain", cfg and cfg["models"] == [] and cfg["local"]["model"] == "m" and cfg["local"]["url"].endswith("/v1/chat/completions"))
|
||||
# primer: re-prime after the local server restarts (llama-server /metrics counter goes back down) or is down
|
||||
import http.server, threading
|
||||
M = {"n": 500}
|
||||
class _H(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *a): pass
|
||||
def do_GET(self):
|
||||
b = f"# HELP x\nllamacpp:n_decode_total {M['n']}\n".encode(); self.send_response(200); self.end_headers(); self.wfile.write(b)
|
||||
srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _H); threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
loc = {"url": f"http://127.0.0.1:{srv.server_port}/v1/chat/completions"}
|
||||
agent._PRIMED["decodes"] = None
|
||||
first = agent._local_restarted(loc); M["n"] = 520; grew = agent._local_restarted(loc); M["n"] = 7; dropped = agent._local_restarted(loc)
|
||||
srv.shutdown(); down = agent._local_restarted(loc)
|
||||
check("primer: restart detected from /metrics, normal use is not a restart, down = re-prime",
|
||||
(first, grew, dropped, down) == (False, False, True, True), (first, grew, dropped, down))
|
||||
print(f"\n{sum(results)}/{len(results)} passed")
|
||||
sys.exit(0 if all(results) else 1)
|
||||
|
||||
28
tests/test_copy_defaults.py
Normal file
28
tests/test_copy_defaults.py
Normal file
@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Page-text slots: server defaults match the React app, and the model prompt shows them. No server or model needed.
|
||||
(Text edits are made by the model with set_theme copy; there is no rule-based text editing.)"""
|
||||
import os, sys
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
os.environ.pop("AGENT_MARKET_LLM_API_KEY_FILE", None)
|
||||
from market.copyedit import DEFAULT_COPY, current_copy
|
||||
from market import agent
|
||||
|
||||
results = []
|
||||
def check(name, ok, d=""):
|
||||
results.append(bool(ok)); print(("PASS " if ok else "FAIL ") + name + (f" [{d}]" if d else ""))
|
||||
|
||||
app = open(os.path.join(os.path.dirname(__file__), "..", "web", "src", "App.tsx"), encoding="utf-8").read()
|
||||
ok = all(s in app for s in (DEFAULT_COPY["logo"], DEFAULT_COPY["headline"], DEFAULT_COPY["subhead"])) and \
|
||||
all(c["label"] in app for c in DEFAULT_COPY["chips"]) and all(b.split("{fee}% ")[-1] in app for b in DEFAULT_COPY["bullets"])
|
||||
check("DEFAULT_COPY in sync with web/src/App.tsx", ok)
|
||||
check("model prompt explains top-left = logo and includes PAGE_TEXT", "TOP-LEFT" in agent.UI_SCHEMA and '"logo": "Agent Market"' in agent._page_text("{}"))
|
||||
check("PAGE_TEXT follows the visitor's overrides", '"logo": "New Internet"' in agent._page_text('{"copy":{"logo":"New Internet"}}'))
|
||||
check("fee in bullets follows FEE_BPS", current_copy({}, 250)["bullets"][0].startswith("2.5% "))
|
||||
check("local prompt carries examples (incl. a top-left text edit), hosted prompt doesn't",
|
||||
"EXAMPLES" in agent.LOCAL_EXAMPLES and '"copy":{"logo"' in agent.LOCAL_EXAMPLES and "EXAMPLES (" not in agent.UI_SCHEMA)
|
||||
gone = [n for n in ("fallback_agent", "_rules_check") if hasattr(agent, n)]
|
||||
import market.theme as th, market.copyedit as ce
|
||||
gone += [n for n in ("parse_look_command",) if hasattr(th, n)] + [n for n in ("parse_copy_edit",) if hasattr(ce, n)]
|
||||
check("no rule-based typed-message code left", not gone, gone)
|
||||
print(f"\n{sum(results)}/{len(results)} passed")
|
||||
sys.exit(0 if all(results) else 1)
|
||||
@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Built-in agent: UI copy edits (logo/headline/subtitle/footer/chips/bullets). No server or model needed."""
|
||||
import json, os, re, sys
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
os.environ.pop("AGENT_MARKET_LLM_API_KEY_FILE", None)
|
||||
from market.theme import parse_look_command
|
||||
from market.copyedit import DEFAULT_COPY
|
||||
from market import agent
|
||||
|
||||
results = []
|
||||
def check(name, ok, d=""):
|
||||
results.append(bool(ok)); print(("PASS " if ok else "FAIL ") + name + (f" [{d}]" if d else ""))
|
||||
|
||||
def copy_of(text, theme=None):
|
||||
r = parse_look_command(text, theme or {})
|
||||
return (r[0], (r[1] or {}).get("copy")) if r else (None, None)
|
||||
|
||||
LOGO = [
|
||||
"replace agent market to new internet on the top left corner", # the user's exact phrasing
|
||||
"change Agent Market to New Internet in the top left", "rename the logo to New Internet", "call the site New Internet",
|
||||
"top left should say New Internet", "replace Agent Market with New Internet", "change the top left text to New Internet",
|
||||
"make the logo say new internet", "I want the header to say New Internet", "logo: New Internet", "use New Internet as the site name",
|
||||
"change the brand to new internet please", "rename the website to new internet", "name the site new internet",
|
||||
"change agent market into new internet", "swap Agent Market for New Internet", 'replace "Agent Market" with "New Internet"',
|
||||
"Please change the site name to New Internet.", "the top-left corner should read New Internet",
|
||||
]
|
||||
for p in LOGO:
|
||||
reply, c = copy_of(p)
|
||||
check(f"logo: {p}", c == {"logo": "New Internet"}, f"{c} / {reply}")
|
||||
|
||||
cases = [
|
||||
("change the title to Hello World", {"headline": "Hello World"}),
|
||||
("change the headline to Retro vibes", {"headline": "Retro vibes"}),
|
||||
("replace A marketplace you can just talk to. with Buy and sell anything nearby", {"headline": "Buy and sell anything nearby"}),
|
||||
("replace marketplace with bazaar", {"headline": "A bazaar you can just talk to."}),
|
||||
("set the subtitle to Fast and free", {"subhead": "Fast and free"}),
|
||||
("swap the tagline with Shop by chatting", {"subhead": "Shop by chatting"}),
|
||||
("swap the footer to Made for testing", {"footer": "Made for testing"}),
|
||||
("replace market with Internet in the logo", {"logo": "Agent Internet"}),
|
||||
("the top left should read NEW INTERNET", {"logo": "NEW INTERNET"}),
|
||||
("change the logo to new Internet", {"logo": "new Internet"}), # mixed case typed -> kept exactly
|
||||
]
|
||||
for p, want in cases:
|
||||
reply, c = copy_of(p)
|
||||
check(f"copy: {p}", c == want, f"{c}")
|
||||
reply, c = copy_of("rename Tutoring to Lessons")
|
||||
check("chip label edited, emoji + action kept", c and c["chips"][1] == {"label": "📐 Lessons", "say": "find tutoring services"} and c["chips"][3].get("event") == "sell", c and c["chips"][:4])
|
||||
reply, c = copy_of("replace $0 fee with no fee")
|
||||
check("bullet edited by find-and-replace", c and c["bullets"][1].startswith("no fee when you meet"), c)
|
||||
reply, c = copy_of("replace New Internet with Agent Hub", {"copy": {"logo": "New Internet"}})
|
||||
check("find-and-replace uses the visitor's current text", c == {"logo": "Agent Hub"}, c)
|
||||
|
||||
# unclear -> says what it understood + closest option, never silently nothing / listings
|
||||
r = parse_look_command("replace agent with robot", {})
|
||||
check("ambiguous text -> asks where", r and r[1] is None and "more than one place" in r[0], r and r[0])
|
||||
r = parse_look_command("replace agnet markt with New Internet", {})
|
||||
check("typo -> offers the closest slot", r and r[1] is None and "Closest is the top-left logo text" in r[0], r and r[0])
|
||||
r = parse_look_command("replace foo bar with baz", {})
|
||||
check("not found -> lists what can be edited", r and r[1] is None and "can't find" in r[0], r and r[0])
|
||||
|
||||
# no hijacking of look commands, search or selling
|
||||
for p, key in (("change the background to a sunset gradient", "background"), ("switch to light mode with a green accent", "mode"),
|
||||
("use a serif font and make the text bigger", "font"), ("replace the background with blue", "background"),
|
||||
("move the chat to the left", "chat"), ("hide the bullet points", "sections")):
|
||||
r = parse_look_command(p, {})
|
||||
check(f"look command still works: {p}", r and r[1] and key in r[1] and "copy" not in r[1], r and r[1])
|
||||
for p in ("show me bikes", "change my order to cash", "I'd like to sell my lamp for $20", "find tutoring services"):
|
||||
check(f"not a copy edit: {p}", parse_look_command(p, {}) is None)
|
||||
r = parse_look_command("make it look retro", {})
|
||||
check("preset still works", r and r[1] == {"preset": "retro"})
|
||||
r = parse_look_command("use the Inter font", {})
|
||||
check("font names are word-bounded (Inter font still works)", r and r[1].get("font") == "Inter")
|
||||
check("'internet' is not the Inter font", "font" not in (parse_look_command("make the headline say internet rocks", {})[1] or {}))
|
||||
|
||||
# end to end through the built-in agent (what /api/chat returns when no model answers)
|
||||
out = agent.fallback_agent(None, "replace agent market to new internet on the top left corner", [], "t", {})
|
||||
check("built-in agent: user's exact phrasing -> set_theme copy.logo, no listings",
|
||||
[a["type"] for a in out["actions"]] == ["set_theme"] and out["actions"][0]["payload"]["copy"] == {"logo": "New Internet"}, out["reply"])
|
||||
out = agent.fallback_agent(None, "replace agent with robot", [], "t", {})
|
||||
check("built-in agent: unclear edit -> explanation only", out["actions"] == [] and "agent" in out["reply"])
|
||||
|
||||
# server copy defaults match the React app's defaults
|
||||
app = open(os.path.join(os.path.dirname(__file__), "..", "web", "src", "App.tsx"), encoding="utf-8").read()
|
||||
ok = all(s in app for s in (DEFAULT_COPY["logo"], DEFAULT_COPY["headline"], DEFAULT_COPY["subhead"]) ) and \
|
||||
all(c["label"] in app for c in DEFAULT_COPY["chips"]) and all(b.split("{fee}% ")[-1] in app for b in DEFAULT_COPY["bullets"])
|
||||
check("DEFAULT_COPY in sync with web/src/App.tsx", ok)
|
||||
check("model prompt explains top-left = logo and includes PAGE_TEXT", "TOP-LEFT" in agent.UI_SCHEMA and '"logo": "Agent Market"' in agent._page_text("{}"))
|
||||
|
||||
print(f"\n{sum(results)}/{len(results)} passed")
|
||||
sys.exit(0 if all(results) else 1)
|
||||
@ -24,7 +24,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
const n = (await page.$$(".msg.bot")).length; const t0 = Date.now();
|
||||
await page.type("#chatInput", text); await page.keyboard.press("Enter");
|
||||
await page.waitForFunction((n) => document.querySelectorAll(".msg.bot").length > n, { timeout: 60000 }, n);
|
||||
await page.waitForFunction((n) => document.querySelectorAll(".msg.bot").length > n, { timeout: 90000 }, n);
|
||||
await sleep(500);
|
||||
const last = await page.$$eval(".msg.bot", (ns) => ns[ns.length - 1].textContent);
|
||||
if (/sending messages quickly/.test(last)) { const s = +(last.match(/(\d+)s/) || [0, 6])[1]; await sleep((s + 1) * 1000); continue; }
|
||||
|
||||
@ -48,20 +48,24 @@ export default function App() {
|
||||
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 say = useCallback((role: Msg["role"], text: string, retry?: () => void) => { const id = ++seq; setMsgs((m) => [...m, { id, role, text, retry }]); return id; }, []);
|
||||
const unsay = useCallback((id: number) => setMsgs((m) => m.filter((x) => x.id !== id)), []);
|
||||
const setModel = useCallback((model?: string, ms?: number, local = false, checked = false) => {
|
||||
const setModel = useCallback((model?: string, ms?: number, local = false) => {
|
||||
const short = (model || "").replace(/:free$/, "");
|
||||
const secs = ms ? " · " + (ms / 1000).toFixed(1) + "s" : "";
|
||||
if (local) {
|
||||
// the local backup model running on this server (used when the free hosted models are unavailable)
|
||||
setBadge({ text: `Local model (${short})`, title: `${model} running on this server (local backup; slower than the hosted models)` });
|
||||
setChatMode(`Local model · ${short}${secs}${checked ? " · look checked by rules" : ""}`);
|
||||
// the model running on this server (answers when the free hosted models are unavailable)
|
||||
setBadge({ text: `Local model (${short})`, title: `${model} running on this server (used when the free hosted models are unavailable; slower)` });
|
||||
setChatMode(`Local model · ${short}${secs}`);
|
||||
return;
|
||||
}
|
||||
setBadge({ text: "AI: " + short, title: (model || "") + " via OpenRouter (free tier)" });
|
||||
setChatMode(`AI agent · ${short}${secs}`);
|
||||
}, []);
|
||||
const setBusy = useCallback(() => {
|
||||
setBadge({ text: "AI: busy", title: "The free hosted models and the local model are all unavailable right now" });
|
||||
setChatMode("Assistant busy · try again in a moment");
|
||||
}, []);
|
||||
|
||||
// Render agent actions through the registry: effects run, panels replace the results area.
|
||||
const render = useCallback((actions: UIAction[]) => {
|
||||
@ -87,25 +91,28 @@ export default function App() {
|
||||
setTimeout(() => stageRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 0);
|
||||
}, [toast]);
|
||||
|
||||
const talk = useCallback(async ({ message, event, echo }: Talk) => {
|
||||
const talk = useCallback(async ({ message, event, echo }: Talk): Promise<void> => {
|
||||
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 (out.agent === "unavailable") {
|
||||
// every model failed: an honest busy message with a retry button (no rule-based answer, nothing kept in history)
|
||||
const id = say("bot", out.reply || "The assistant is busy, please try again in a moment.", () => { unsay(id); talk({ message, event }); });
|
||||
setBusy();
|
||||
return;
|
||||
}
|
||||
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, !!out.local, !!out.rules_checked);
|
||||
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");
|
||||
if (out.agent === "llm") setModel(out.model, out.latency_ms, !!out.local);
|
||||
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]);
|
||||
}, [say, unsay, setModel, setBusy, toast, render]);
|
||||
|
||||
// Boot: status (fee + badge), then the deterministic landing view and greeting.
|
||||
useEffect(() => {
|
||||
@ -115,9 +122,9 @@ export default function App() {
|
||||
if (a.mode === "llm" && a.daily_quota_exhausted_until_utc && a.local_model && a.local_ok !== false)
|
||||
setBadge({ text: `Local model (${a.local_model})`, title: `Hosted free quota used up (resets ${a.daily_quota_exhausted_until_utc}); a local model on this server answers` });
|
||||
else if (a.mode === "llm" && a.daily_quota_exhausted_until_utc)
|
||||
setBadge({ text: "agent: built-in (AI quota used up)", title: "Hosted model free quota resets " + a.daily_quota_exhausted_until_utc });
|
||||
setBadge({ text: "AI: quota used up", title: "Hosted model free quota resets " + a.daily_quota_exhausted_until_utc + "; typed messages may get a busy reply" });
|
||||
else if (a.mode === "llm") setModel(a.last_model || a.model, undefined, a.last_model ? !!a.last_local : !a.chain?.some((m) => !m.startsWith("local:")));
|
||||
else setBadge({ text: "agent: rule-based fallback", title: s.agent.note || "" });
|
||||
else setBadge({ text: "AI: unavailable", title: s.agent.note || "" });
|
||||
});
|
||||
api<ChatReply>("/api/chat", { event: { type: "landing" }, actor }).then((out) => {
|
||||
render(out.actions || []);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface Msg { id: number; role: "user" | "bot" | "sys"; text: string }
|
||||
export interface Msg { id: number; role: "user" | "bot" | "sys"; text: string; retry?: () => void }
|
||||
|
||||
/** 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 }: {
|
||||
@ -19,7 +19,9 @@ export function Chat({ open, setOpen, mode, msgs, onSend }: {
|
||||
<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>)}
|
||||
{msgs.map((m) => m.retry
|
||||
? <div key={m.id} className={"msg " + m.role + " busy"}>{m.text} <button className="retryBtn" onClick={m.retry}>↻ Retry</button></div>
|
||||
: <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)} />
|
||||
|
||||
@ -95,3 +95,4 @@ body.no-anim *::after{animation:none !important;transition:none !important}
|
||||
[data-component=checkout] a,[data-component=cash-meetup] a{color:var(--p-ink);text-decoration:underline}
|
||||
[data-component=receipt] h2.ok{color:var(--p-ink)}
|
||||
}
|
||||
.msg.busy{border:1px dashed color-mix(in srgb,var(--chat-ink) 35%,transparent)}.retryBtn{margin-left:6px;padding:2px 9px;border-radius:9px;border:1px solid var(--chat-acc);background:transparent;color:var(--chat-ink);cursor:pointer;font:inherit;font-size:12px}
|
||||
|
||||
@ -15,14 +15,14 @@ export interface Order {
|
||||
}
|
||||
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;
|
||||
provider?: string; local?: boolean; rules_checked?: boolean;
|
||||
reply?: string; actions?: UIAction[]; agent?: "llm" | "unavailable" | "ui-event" | "rate-limit"; model?: string; latency_ms?: number;
|
||||
provider?: string; local?: boolean; busy?: boolean; retry?: boolean;
|
||||
}
|
||||
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;
|
||||
mode: "llm" | "unavailable"; model?: string; last_model?: string; note?: string; daily_quota_exhausted_until_utc?: string | null;
|
||||
local_model?: string | null; local_ok?: boolean | null; last_local?: boolean; chain?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user