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(loadTheme); const themeRef = useRef(theme); themeRef.current = theme; const [feeBps, setFeeBps] = useState(500); const [panels, setPanels] = useState([]); const [msgs, setMsgs] = useState([]); const [chatOpen, setChatOpen] = useState(false); const [chatMode, setChatMode] = useState("AI agent"); const [badge, setBadge] = useState({ text: "agent: …", title: "" }); const [toasts, setToasts] = useState([]); const [compareIds, setCompareIds] = useState([]); const history = useRef<{ role: string; content: string }[]>([]); const stageRef = useRef(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 => { setChatOpen(true); if (echo) say("user", echo); const thinking = say("sys", "agent is thinking…"); try { const out = await api("/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("/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("/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 ( {/* Protected: the test banner, the agent badge and the checkout/fee panels can't be restyled by themes. */}
TEST DEMO: all listings are fake and payments run in test mode. No real money moves.
◆ {copy.logo}

{copy.headline}

{copy.subhead}

{copy.chips.map((c, i) => ( ))}
    {copy.bullets.map((b, i) =>
  • {b.slice(0, 140)}
  • )}
{panels.map((p) => { const C = PANELS[p.type]; return ; })}
{copy.footer || <>Agent Market demo · fake test data · test-mode payments · open API}
talk({ message: v, echo: v })} />
{toasts.map((t) =>
{t.text}
)}
{compareIds.length} selected
); }