agent-market/web/src/App.tsx
leora d785091d64 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.
2026-10-08 21:58:41 +08:00

196 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { actor, api } from "./api";
import { Ctx, type AgentCtx, type Talk } from "./context";
import { EFFECTS, PANELS } from "./components/registry";
import { Chat, type Msg } from "./components/Chat";
import {
applyThemeToDocument, contrast, loadTheme, reduceThemeAction, resolveCopy, resolveSections, saveTheme,
type Copy, type Section, type Theme, type ThemeAction,
} from "./theme/engine";
import type { ChatReply, Status, UIAction } from "./types";
declare global { interface Window { __amContrast: typeof contrast } }
window.__amContrast = contrast; // used by the guardrail tests
const copyDefaults = (bps: number): Copy => ({
logo: "Agent Market", headline: "A marketplace you can just talk to.",
subhead: "Tell the agent what you want. It searches, compares, lists your stuff and runs the checkout, right here on the page. You can also restyle the whole page from the chat.",
chips: [{ label: "🚲 Show me bikes", say: "show me bikes" }, { label: "📐 Tutoring", say: "find tutoring services" },
{ label: "⚖️ Compare tutoring", say: "compare the two tutoring options" }, { label: "➕ Sell something", event: "sell" },
{ label: "🎨 Make it look retro", say: "make it look retro" }],
bullets: [`${(bps / 100).toFixed(bps % 100 ? 1 : 0)}% 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)"],
});
interface Panel { key: string; type: string; payload: any }
interface Toast { id: number; text: string; level: string }
let seq = 0;
export default function App() {
const [theme, setTheme] = useState<Theme>(loadTheme);
const themeRef = useRef(theme); themeRef.current = theme;
const [feeBps, setFeeBps] = useState(500);
const [panels, setPanels] = useState<Panel[]>([]);
const [msgs, setMsgs] = useState<Msg[]>([]);
const [chatOpen, setChatOpen] = useState(false);
const [chatMode, setChatMode] = useState("AI agent");
const [badge, setBadge] = useState({ text: "agent: …", title: "" });
const [toasts, setToasts] = useState<Toast[]>([]);
const [compareIds, setCompareIds] = useState<string[]>([]);
const history = useRef<{ role: string; content: string }[]>([]);
const stageRef = useRef<HTMLElement>(null);
// Theme: tokens/classes/customCss go onto the document; copy + sections are rendered below.
useLayoutEffect(() => { applyThemeToDocument(theme); saveTheme(theme); }, [theme]);
const resetTheme = useCallback(() => { themeRef.current = {}; setTheme({}); }, []);
const toast = useCallback((text: string, level: "info" | "ok" | "warn" = "info") => {
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, 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) => {
const short = (model || "").replace(/:free$/, "");
const secs = ms ? " · " + (ms / 1000).toFixed(1) + "s" : "";
if (local) {
// 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[]) => {
// Theme changes in this batch are folded locally so a panel later in the same batch sees them
// (e.g. a set_theme that hides "results" followed by a checkout un-hides it again, like before).
let t = themeRef.current;
const fx = { toast, applyTheme: (a: ThemeAction) => { t = reduceThemeAction(t, a); }, clearStage: () => setPanels([]) };
const batch: Panel[] = [];
for (const a of actions) {
if (EFFECTS[a.type]) EFFECTS[a.type](a.payload || {}, fx);
else if (PANELS[a.type]) batch.push({ key: `${++seq}`, type: a.type, payload: a.payload || {} });
}
if (batch.length) {
const hidden = t.sections?.hidden;
if (Array.isArray(hidden) && hidden.includes("results")) {
t = { ...t, sections: { ...t.sections, hidden: hidden.filter((x: string) => x !== "results") } };
toast("Results section shown again so you can see this", "info");
}
}
if (t !== themeRef.current) { themeRef.current = t; setTheme(t); }
if (!batch.length) return;
setPanels(batch);
setTimeout(() => stageRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 0);
}, [toast]);
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);
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, setBusy, toast, render]);
// Boot: status (fee + badge), then the deterministic landing view and greeting.
useEffect(() => {
api<Status>("/api/status").then((s) => {
setFeeBps(s.fee_bps);
const a = s.agent;
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: "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: "AI: unavailable", title: s.agent.note || "" });
});
api<ChatReply>("/api/chat", { event: { type: "landing" }, actor }).then((out) => {
render(out.actions || []);
say("bot", "Hi! I run this page. Ask me to find, compare, sell, or buy. Payments are test mode only.");
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const ctx: AgentCtx = useMemo(() => ({
talk, feeBps, toast,
compare: {
has: (id) => compareIds.includes(id),
toggle: (id, on) => setCompareIds((ids) => (on ? [...new Set([...ids, id])] : ids.filter((x) => x !== id))),
},
}), [talk, feeBps, toast, compareIds]);
const copy = resolveCopy(theme, copyDefaults(feeBps));
const { order, hidden } = resolveSections(theme);
const sec = (s: Section) => ({ "data-section": s, style: { order: order[s] }, className: hidden.has(s) ? "is-hidden" : undefined });
const cls = (base: string, s: Section) => [base, hidden.has(s) ? "is-hidden" : ""].filter(Boolean).join(" ");
return (
<Ctx.Provider value={ctx}>
{/* Protected: the test banner, the agent badge and the checkout/fee panels can't be restyled by themes. */}
<div className="testbar" data-protected>TEST DEMO: all listings are fake and payments run in test mode. No real money moves.</div>
<header className="top">
<div className="brand"><span className="logo">◆</span> <span id="logoText">{copy.logo}</span></div>
<nav>
<button className="link" data-say="show me everything" onClick={() => talk({ message: "show me everything", echo: "show me everything" })}>Browse</button>
<button className="link" data-event="sell" onClick={() => talk({ event: { type: "sell" }, echo: "Sell" })}>Sell</button>
<a className="link" href="/agent.json" target="_blank">For agents</a>
<button className="link" id="resetLook" title="Restore the default look" onClick={() => { resetTheme(); toast("Look reset to default", "ok"); }}>Reset look</button>
<span id="agentBadge" className="badge" data-protected title={badge.title}>{badge.text}</span>
</nav>
</header>
<main id="page">
<section {...sec("hero")} className={cls("hero-sec", "hero")}>
<h1 id="headline">{copy.headline}</h1>
<p id="subhead">{copy.subhead}</p>
</section>
<div {...sec("chips")} className={cls("chips", "chips")} id="chips">
{copy.chips.map((c, i) => (
<button key={i} className="chip" data-say={c.event ? undefined : String(c.say || c.label).slice(0, 120)} data-event={c.event}
onClick={() => c.event ? talk({ event: { type: c.event }, echo: c.label.slice(0, 40) }) : talk({ message: String(c.say || c.label).slice(0, 120), echo: String(c.say || c.label).slice(0, 120) })}>
{c.label.slice(0, 40)}</button>
))}
</div>
<ul {...sec("bullets")} className={cls("facts", "bullets")} id="bullets">
{copy.bullets.map((b, i) => <li key={i}>{b.slice(0, 140)}</li>)}
</ul>
<section {...sec("results")} className={hidden.has("results") ? "is-hidden" : undefined} id="stage" ref={stageRef} aria-live="polite">
{panels.map((p) => { const C = PANELS[p.type]; return <C key={p.key} payload={p.payload} />; })}
</section>
<footer {...sec("footer")} className={cls("foot", "footer")} id="footer">
{copy.footer || <>Agent Market demo · fake test data · test-mode payments · <a href="/openapi.json" target="_blank">open API</a></>}
</footer>
</main>
<Chat open={chatOpen} setOpen={setChatOpen} mode={chatMode} msgs={msgs}
onSend={(v) => talk({ message: v, echo: v })} />
<div id="toasts">{toasts.map((t) => <div key={t.id} className={"toast " + t.level}>{t.text}</div>)}</div>
<div className="cmpbar" style={{ display: compareIds.length ? "flex" : "none" }}>
{compareIds.length} selected
<button className="btn" onClick={() => talk({ event: { type: "compare", listing_ids: compareIds }, echo: "Compare selected" })}>Compare</button>
</div>
</Ctx.Provider>
);
}