diff --git a/buyer/src/sdk/Marketplace.ts b/buyer/src/sdk/Marketplace.ts index 835fd51..2fb8e91 100644 --- a/buyer/src/sdk/Marketplace.ts +++ b/buyer/src/sdk/Marketplace.ts @@ -822,6 +822,30 @@ export class Marketplace extends EventEmitterBase { } const advertDatum = decodeAdvertDatum(advertUtxo.datumHex); + const supplierHttp = new HttpClient({ baseUrl: advertDatum.endpoint_url, fetch: this.fetchImpl }); + + // Pre-flight: refuse to lock any funds if the supplier is already serving + // another chat. The supplier is single-slot, so a second concurrent escrow + // would only 409 at Claim and be left stranded Open with funds locked. + // Checking /status first keeps the buyer's funds unlocked. Best-effort: if + // /status is unreachable we proceed, and the supplier's Claim still guards + // the slot. + try { + const statusRes = await supplierHttp.getJson("/status"); + const liveStatus = statusRes.body && typeof statusRes.body === "object" + ? (statusRes.body as { status?: string }).status + : undefined; + if (liveStatus === "working") { + throw new SupplierError("supplier_busy", { + status: 409, + message: "supplier is busy with another chat session", + }); + } + } catch (err) { + if (err instanceof SupplierError) throw err; + /* /status unreachable — proceed; Claim still enforces the single slot */ + } + const sessionNonce = randomNonce(); let escrowResult; try { @@ -850,7 +874,6 @@ export class Marketplace extends EventEmitterBase { /* mock providers / tests */ } - const supplierHttp = new HttpClient({ baseUrl: advertDatum.endpoint_url, fetch: this.fetchImpl }); let startRes; try { startRes = await supplierHttp.postJson( diff --git a/buyer/src/ui/components/ChatForm.tsx b/buyer/src/ui/components/ChatForm.tsx index 08bfec9..e094dea 100644 --- a/buyer/src/ui/components/ChatForm.tsx +++ b/buyer/src/ui/components/ChatForm.tsx @@ -21,7 +21,7 @@ * data: {"type":"error","message":"…"}\n\n */ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { OutputReference } from "@marketplace/shared/chain"; export interface ChatFormProps { @@ -104,6 +104,7 @@ async function streamChat( 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([]); const [streaming, setStreaming] = useState(null); // in-progress assistant text @@ -116,11 +117,43 @@ export default function ChatForm({ advertRef, payment_lovelace }: ChatFormProps const [starting, setStarting] = useState(false); const [ending, setEnding] = useState(false); const [charged, setCharged] = useState(null); + // Live supplier availability (paid mode): null = unknown/checking, true = free, false = busy/offline. + const [available, setAvailable] = useState(null); const sessionRef = useRef(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 => { + 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 { if (!advertRef || payment_lovelace === undefined) return; setStarting(true); @@ -226,15 +259,26 @@ export default function ChatForm({ advertRef, payment_lovelace }: ChatFormProps return (
{isPaid && session === null && ( - +
+ + {available === false && ( +

+ Another chat is in progress on this supplier. The button re-enables when it ends. +

+ )} +
)} {(active || streaming !== null || turns.length > 0) && (