Files
agents-marketplace/buyer/src/ui/components/ChatForm.tsx
david 2f7b0194a5 feat(supplier,gateway,buyer): N-slot chat suppliers + entry-ticket settle mode
Two structural fixes for demo concurrency (3 single-slot suppliers thrashed
under Open WebUI's ~3 conversations per message):

Multi-slot suppliers (MAX_CHAT_SESSIONS, default 1):
- SupplierState is now capacity-aware: a Set of active escrow refs with
  free-until-full status semantics, ref-scoped release(ref) (also fixes the
  latent bug where handler catch-alls released another request's slot), and
  a walletMutex serializing every wallet-spending chain op — Claim, Submit,
  and wallet-health consolidation queue instead of racing the single
  working/collateral UTxO. /status gains active_sessions/max_sessions.

Entry-ticket settle mode (CHAT_SETTLE_MODE=ticket, default full, both sides):
- The buyer's escrow post at session open is the ONLY chain op. The supplier
  verifies the Open escrow as an entry ticket but never Claims/Submits
  (/v1/chat/start → {status:"ticket"}; /v1/chat/end → receipt-less
  {status:"ended"}); no hard-cap timer, so sessions may outlive deliver_by.
  Used tickets can't reopen sessions (records retained; 409 session_exists).
- Buyer SDK: startChat/endChat are settle-mode aware, driven by the
  supplier's response — mixed-mode rollout windows settle correctly.
- Gateway: ticket closes bill cost 0; the sweeper Reclaims Open tickets
  after deliver_by WITHOUT closing live sessions (the conversation and its
  affinity mirror survive their reclaimed ticket), gates reclaims on
  deliver_by, and keeps an in-process settled-refs memory so terminal
  indexer rows aren't retried every sweep forever.

Deployment (kimi-chat trio + gateway): ticket mode, 8 slots per supplier,
idle policy gateway 10 min / supplier 15 min (fixes the inversion where the
supplier idle-closed before the gateway janitor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:48:52 +02:00

393 lines
14 KiB
TypeScript

/**
* buyer/src/ui/components/ChatForm.tsx — capability form for `llm.chat.v1`
* (multi-turn chat sessions).
*
* Two modes, controlled by whether `advertRef` is provided (mirrors
* PiperTTSForm's demo-vs-marketplace split):
*
* - **Demo mode** (no advertRef): the synthetic "Kimi K2.6 (demo)" tile.
* No escrow, no payment, no Start/End. Every turn streams straight from
* OpenRouter via /v1/chat-demo/message. Fully concurrent + free.
*
* - **Marketplace mode** (advertRef + payment_lovelace): the paid chat type.
* "Start chat" opens the escrow (POST /v1/chat/start → PostEscrow + supplier
* Claim). Turns stream off-chain via /v1/chat/message (zero chain per turn).
* "End chat" settles (POST /v1/chat/end → supplier Submit + buyer Accept),
* which is when the user is actually charged.
*
* Both paths stream a uniform SSE wire format over fetch+ReadableStream:
* data: {"type":"token","value":"<delta>"}\n\n
* data: {"type":"done"}\n\n
* data: {"type":"error","message":"…"}\n\n
*/
import { useEffect, useRef, useState } from "react";
import type { OutputReference } from "@marketplace/shared/chain";
export interface ChatFormProps {
/** When set, runs the paid marketplace lifecycle; when undefined, demo mode. */
advertRef?: OutputReference;
/** Required iff advertRef is set; matches the supplier's advertised price. */
payment_lovelace?: bigint;
}
interface ChatTurn {
role: "user" | "assistant";
content: string;
}
interface Session {
escrowRef: string;
sessionNonce: string;
}
function ap3x(lovelace: bigint | string): string {
return (Number(lovelace) / 1e6).toFixed(2);
}
/** Stream a uniform SSE response, invoking onToken per content delta.
* Resolves when the `done` frame arrives; rejects on `error` frame or
* transport failure. */
async function streamChat(
url: string,
body: unknown,
onToken: (delta: string) => void,
): Promise<void> {
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok || !resp.body) {
let msg = `${resp.status} ${resp.statusText}`;
try {
const j = (await resp.json()) as { error?: string; message?: string };
if (j.error || j.message) msg = `${j.error ?? "error"}: ${j.message ?? ""}`;
} catch { /* keep status fallback */ }
throw new Error(msg);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const handleFrame = (payload: string): boolean => {
const trimmed = payload.trim();
if (trimmed.length === 0) return false;
let frame: { type?: string; value?: string; message?: string };
try {
frame = JSON.parse(trimmed);
} catch {
return false;
}
if (frame.type === "token" && typeof frame.value === "string") {
onToken(frame.value);
} else if (frame.type === "done") {
return true;
} else if (frame.type === "error") {
throw new Error(frame.message ?? "stream error");
}
return false;
};
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sepIdx: number;
while ((sepIdx = buffer.indexOf("\n\n")) >= 0) {
const block = buffer.slice(0, sepIdx);
buffer = buffer.slice(sepIdx + 2);
for (const line of block.split("\n")) {
if (line.startsWith("data:") && handleFrame(line.slice(5))) return;
}
}
}
}
/** Animated "Thinking …" placeholder shown while waiting for the model's
* first token (the assistant bubble exists but no content has streamed yet). */
function ThinkingDots(): JSX.Element {
return (
<span className="inline-flex items-center gap-1 text-gray-500" data-testid="chat-thinking">
Thinking
<span className="inline-flex items-end gap-0.5 pb-0.5">
{[0, 150, 300].map((d) => (
<span
key={d}
className="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400"
style={{ animationDelay: `${d}ms` }}
/>
))}
</span>
</span>
);
}
export default function ChatForm({ advertRef, payment_lovelace }: ChatFormProps = {}): JSX.Element {
const isPaid = advertRef !== undefined && payment_lovelace !== undefined;
const advertRefStr = advertRef ? `${advertRef.txHash}#${advertRef.index}` : null;
const [turns, setTurns] = useState<ChatTurn[]>([]);
const [streaming, setStreaming] = useState<string | null>(null); // in-progress assistant text
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Paid-only session + finalization state.
const [session, setSession] = useState<Session | null>(null);
const [starting, setStarting] = useState(false);
const [ending, setEnding] = useState(false);
const [charged, setCharged] = useState<string | null>(null);
// Live supplier availability (paid mode): null = unknown/checking, true = free, false = busy/offline.
const [available, setAvailable] = useState<boolean | null>(null);
const sessionRef = useRef<Session | null>(null);
sessionRef.current = session;
const active = isPaid ? session !== null && charged === null : true;
// Poll the supplier's status while the Start button is showing, so a second
// user can't start a chat while one is already active. The supplier is
// single-slot (status "working" between Claim and Submit); we disable Start
// until it's "free" again. (startChat also does an authoritative pre-flight
// /status check, so this is the UX layer over that guard.)
useEffect(() => {
if (!isPaid || !advertRefStr || session !== null || charged !== null) return;
let cancelled = false;
const check = async (): Promise<void> => {
try {
const r = await fetch("/v1/indexer/suppliers?capability_id=llm.chat.v1");
if (!r.ok) throw new Error(String(r.status));
const rows = (await r.json()) as Array<{ utxo_ref: string; status: string }>;
const row = rows.find((x) => x.utxo_ref === advertRefStr);
// Unknown (row missing / indexer hiccup) → null, which does NOT hard-block
// (the pre-flight check still guards funds); only an explicit non-free
// status disables the button.
if (!cancelled) setAvailable(row ? row.status === "free" : null);
} catch {
if (!cancelled) setAvailable(null);
}
};
void check();
const id = setInterval(check, 8000);
return () => {
cancelled = true;
clearInterval(id);
};
}, [isPaid, advertRefStr, session, charged]);
async function startChat(): Promise<void> {
if (!advertRef || payment_lovelace === undefined) return;
setStarting(true);
setError(null);
try {
const resp = await fetch("/v1/chat/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
advert_ref: `${advertRef.txHash}#${advertRef.index}`,
payment_lovelace: payment_lovelace.toString(),
}),
});
if (!resp.ok) {
let msg = `${resp.status} ${resp.statusText}`;
try {
const j = (await resp.json()) as { error?: string; message?: string };
if (resp.status === 409) msg = "Supplier is busy with another chat — try again shortly.";
else if (j.error || j.message) msg = `${j.error ?? "error"}: ${j.message ?? ""}`;
} catch { /* keep fallback */ }
throw new Error(msg);
}
const j = (await resp.json()) as { escrow_ref: string; session_nonce: string };
setSession({ escrowRef: j.escrow_ref, sessionNonce: j.session_nonce });
setTurns([]);
setCharged(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setStarting(false);
}
}
async function sendMessage(): Promise<void> {
const content = input.trim();
if (content.length === 0 || busy) return;
setBusy(true);
setError(null);
setInput("");
const nextTurns: ChatTurn[] = [...turns, { role: "user", content }];
setTurns(nextTurns);
setStreaming("");
let acc = "";
try {
if (isPaid) {
const s = sessionRef.current;
if (!s) throw new Error("no active chat session");
await streamChat(
"/v1/chat/message",
{ escrow_ref: s.escrowRef, content },
(delta) => { acc += delta; setStreaming(acc); },
);
} else {
// Demo: send the full running transcript (no server-side state).
const demoMessages = nextTurns.map((t) => ({ role: t.role, content: t.content }));
await streamChat(
"/v1/chat-demo/message",
{ messages: demoMessages },
(delta) => { acc += delta; setStreaming(acc); },
);
}
setTurns([...nextTurns, { role: "assistant", content: acc }]);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
// Keep the user's turn but drop the half-streamed assistant bubble.
} finally {
setStreaming(null);
setBusy(false);
}
}
async function endChat(): Promise<void> {
const s = sessionRef.current;
if (!s) return;
setEnding(true);
setError(null);
try {
const resp = await fetch("/v1/chat/end", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
escrow_ref: s.escrowRef,
session_nonce: s.sessionNonce,
transcript: turns,
}),
});
if (!resp.ok) {
let msg = `${resp.status} ${resp.statusText}`;
try {
const j = (await resp.json()) as { error?: string; message?: string };
if (j.error || j.message) msg = `${j.error ?? "error"}: ${j.message ?? ""}`;
} catch { /* keep fallback */ }
throw new Error(msg);
}
let endBody: { settle_mode?: string } = {};
try {
endBody = (await resp.json()) as { settle_mode?: string };
} catch { /* older buyer-app responses stay full-settle */ }
// Ticket sessions never charge — the escrow returns via reclaim.
setCharged(
endBody.settle_mode === "ticket"
? "0 (ticket session — escrow reclaimed)"
: payment_lovelace !== undefined
? ap3x(payment_lovelace)
: "0",
);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setEnding(false);
}
}
return (
<div className="space-y-4 rounded border border-gray-200 bg-white p-4" data-testid="chat-form">
{isPaid && session === null && (
<div className="space-y-2">
<button
type="button"
onClick={startChat}
disabled={starting || available === false}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:bg-gray-400"
data-testid="chat-start"
>
{starting
? "Opening escrow…"
: available === false
? "Supplier busy"
: "Start chat"}
</button>
{available === false && (
<p className="text-sm text-amber-700" data-testid="chat-busy">
Another chat is in progress on this supplier. The button re-enables when it ends.
</p>
)}
</div>
)}
{(active || streaming !== null || turns.length > 0) && (
<div className="space-y-3">
<div className="max-h-96 space-y-2 overflow-auto" data-testid="chat-transcript">
{turns.map((t, i) => (
<div key={i} className={t.role === "user" ? "text-right" : "text-left"}>
<span
className={
"inline-block max-w-[85%] whitespace-pre-wrap rounded px-3 py-2 text-sm " +
(t.role === "user"
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-900")
}
>
{t.content}
</span>
</div>
))}
{streaming !== null && (
<div className="text-left">
<span className="inline-block max-w-[85%] whitespace-pre-wrap rounded bg-gray-100 px-3 py-2 text-sm text-gray-900">
{streaming.length > 0 ? streaming : <ThinkingDots />}
</span>
</div>
)}
</div>
{active && (
<form
onSubmit={(e) => { e.preventDefault(); void sendMessage(); }}
className="flex gap-2"
>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message…"
disabled={busy}
className="flex-1 rounded border border-gray-300 p-2 text-sm focus:border-blue-500 focus:outline-none"
data-testid="chat-input"
/>
<button
type="submit"
disabled={busy || input.trim().length === 0}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:bg-gray-400"
data-testid="chat-send"
>
{busy ? "…" : "Send"}
</button>
</form>
)}
{isPaid && active && (
<button
type="button"
onClick={endChat}
disabled={ending || busy}
className="rounded bg-gray-700 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 disabled:bg-gray-400"
data-testid="chat-end"
>
{ending ? "Finalizing payment…" : "End chat"}
</button>
)}
</div>
)}
{charged !== null && (
<div className="rounded border border-green-300 bg-green-50 p-3 text-sm text-green-700" data-testid="chat-charged">
Chat ended — charged {charged} AP3X, bond refunded.
</div>
)}
{error && (
<div className="rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700" data-testid="chat-error">
{error}
</div>
)}
</div>
);
}