buyer: indexer-driven /tasks page, path-B TTS demo, server-side error logging
Three buyer-app changes that all landed during the same browser-debug
session and were verified end-to-end on Vector testnet:
UX-3-history — /tasks page is now indexer-driven (M1-F-5 followup)
- TaskHistory.tsx fetches /v1/indexer/escrows?buyer=<pkh> from the
buyer-app proxy, groups rows by (posted_at, supplier_pkh) into
lifecycle cards, and renders newest-first with state pill,
capability/model/price, and the tx-hash chain
- main.tsx drops the LocalStorageTaskHistoryStore (chain is the source
of truth; SDK uses MemoryTaskHistoryStore for in-flight only)
Path-B PiperTTS demo — capability-routed UI without escrow yet
- buyer/src/server.ts: POST /v1/synth-speech proxies to the openedai-
speech-min host; validates {text, voice, format, speed} and returns
audio bytes with the upstream Content-Type
- buyer/src/config.ts: TTS_PIPER_BASE_URL env (optional; absence keeps
/v1/synth-speech disabled with a clean 503)
- buyer/src/ui/components/PiperTTSForm.tsx: capability-specific form
(text + voice + format + speed slider) with inline <audio controls>
and a Download .mp3 anchor; blob URLs revoked on each submit + unmount
- buyer/src/ui/pages/Dashboard.tsx: capability registry — switch on
selected.capability_id to PromptForm vs PiperTTSForm; appends a
synthetic "PiperTTS (apex-simulation)" demo card so the capability
routing is exercisable before the on-chain TTS supplier ships
- deploy/testnet/docker-compose.buyer.yml: TTS_PIPER_BASE_URL points
at https://tts-piper.vector.testnet.apexfusion.org
Server-side error logging on /v1/submit-prompt
- buyer/src/server.ts: catch block now logs reason + message + stack
(the demo session lost ~30 minutes to a "<!DOCTYPE..." JSON-parse
error caused by Cloudflare replacing origin's clean 502+JSON with
its own HTML page; the actual upstream cause was a "wallet must
hold a pure-ADA UTxO >=5 ADA" lucid-evolution error that was
invisible because the Express handler had no logger)
Tests: 10 new for /v1/synth-speech (validation, defaults, success
shape, upstream 5xx, fetch-throw); existing buyer-ui-smoke supplier
count expectation updated for the synthetic Piper card (N+1)
This commit is contained in:
@@ -11,6 +11,10 @@
|
|||||||
* OGMIOS_URL — Ogmios HTTP endpoint (required iff LIVE_CHAIN=1)
|
* OGMIOS_URL — Ogmios HTTP endpoint (required iff LIVE_CHAIN=1)
|
||||||
* LIVE_CHAIN — "1" opts in to LiveOgmiosProvider (real submitTx).
|
* LIVE_CHAIN — "1" opts in to LiveOgmiosProvider (real submitTx).
|
||||||
* Default off → ReadOnlyOgmiosProvider (safe).
|
* Default off → ReadOnlyOgmiosProvider (safe).
|
||||||
|
* TTS_PIPER_BASE_URL — Base URL of the openedai-speech-min PiperTTS host.
|
||||||
|
* When unset, /v1/synth-speech responds 503 (the
|
||||||
|
* capability stays disabled — the rest of the
|
||||||
|
* buyer-app boots normally).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||||
@@ -23,6 +27,7 @@ export interface BuyerConfig {
|
|||||||
networkId: 0 | 1;
|
networkId: 0 | 1;
|
||||||
ogmiosUrl: string;
|
ogmiosUrl: string;
|
||||||
liveChain: boolean;
|
liveChain: boolean;
|
||||||
|
ttsPiperBaseUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireField(env: Record<string, string | undefined>, name: string): string {
|
function requireField(env: Record<string, string | undefined>, name: string): string {
|
||||||
@@ -70,5 +75,10 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
|
|||||||
throw new Error("loadConfig: OGMIOS_URL is required when LIVE_CHAIN=1");
|
throw new Error("loadConfig: OGMIOS_URL is required when LIVE_CHAIN=1");
|
||||||
}
|
}
|
||||||
|
|
||||||
return { privKeyHex, indexerUrl, port, networkId, ogmiosUrl, liveChain };
|
// TTS_PIPER_BASE_URL is purely optional — empty string means the
|
||||||
|
// /v1/synth-speech endpoint stays disabled. We don't validate the URL
|
||||||
|
// shape here; an unreachable URL surfaces as a clear 502 from the proxy.
|
||||||
|
const ttsPiperBaseUrl = env.TTS_PIPER_BASE_URL ?? "";
|
||||||
|
|
||||||
|
return { privKeyHex, indexerUrl, port, networkId, ogmiosUrl, liveChain, ttsPiperBaseUrl };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
|
|||||||
walletKey,
|
walletKey,
|
||||||
indexerUrl: config.indexerUrl,
|
indexerUrl: config.indexerUrl,
|
||||||
marketplace,
|
marketplace,
|
||||||
|
ttsPiperBaseUrl: config.ttsPiperBaseUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
const server = app.listen(config.port, () => {
|
const server = app.listen(config.port, () => {
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ export interface AppDeps {
|
|||||||
* The browser SPA calls fetch('/v1/submit-prompt') instead of touching
|
* The browser SPA calls fetch('/v1/submit-prompt') instead of touching
|
||||||
* the SDK directly, so the chain stub in the SPA never has to fire. */
|
* the SDK directly, so the chain stub in the SPA never has to fire. */
|
||||||
marketplace?: Marketplace;
|
marketplace?: Marketplace;
|
||||||
|
/** Base URL of the openedai-speech-min PiperTTS deployment that
|
||||||
|
* `/v1/synth-speech` proxies to. Falls back to the public Vector-testnet
|
||||||
|
* host when not set. Endpoint stays disabled (503) when this is empty. */
|
||||||
|
ttsPiperBaseUrl?: string;
|
||||||
/** Injectable fetch for tests. Defaults to globalThis.fetch. */
|
/** Injectable fetch for tests. Defaults to globalThis.fetch. */
|
||||||
fetchImpl?: typeof globalThis.fetch;
|
fetchImpl?: typeof globalThis.fetch;
|
||||||
}
|
}
|
||||||
@@ -306,10 +310,115 @@ export function createApp(deps: AppDeps): Express {
|
|||||||
const reason = err instanceof Error && "reason" in err
|
const reason = err instanceof Error && "reason" in err
|
||||||
? String((err as { reason: unknown }).reason)
|
? String((err as { reason: unknown }).reason)
|
||||||
: "submit_prompt_failed";
|
: "submit_prompt_failed";
|
||||||
|
// Surface the failure to logs so 502 responses aren't a silent black
|
||||||
|
// hole during deploy debugging. SDK errors carry .reason; raw Errors
|
||||||
|
// carry stack — we want both.
|
||||||
|
console.error(
|
||||||
|
`[buyer] /v1/submit-prompt failed reason=${reason} message=${message}`,
|
||||||
|
err instanceof Error && err.stack ? `\n${err.stack}` : "",
|
||||||
|
);
|
||||||
return res.status(502).json({ error: reason, message });
|
return res.status(502).json({ error: reason, message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── POST /v1/synth-speech — PiperTTS-specific proxy.
|
||||||
|
// Body: { text, voice, format, speed }
|
||||||
|
// Returns: audio bytes (Content-Type from upstream, e.g. audio/mpeg).
|
||||||
|
//
|
||||||
|
// Path B of the multi-capability story: the SPA's `PiperTTSForm` component
|
||||||
|
// hits this directly (no escrow yet) so we can demo the audio.synthesize.
|
||||||
|
// piper.v1 capability without waiting for the on-chain TTS-supplier
|
||||||
|
// adapter to land. The endpoint is intentionally locked to PiperTTS' OpenAI-
|
||||||
|
// shape `/v1/audio/speech` — different TTS providers (xtts, coqui, …) get
|
||||||
|
// their own endpoint so each form's parameter space is explicit.
|
||||||
|
//
|
||||||
|
// Knobs limited to what openedai-speech-min actually honours:
|
||||||
|
// voice ∈ alloy|echo|fable|onyx|nova|shimmer|lessac
|
||||||
|
// format ∈ mp3|wav|opus|aac|flac
|
||||||
|
// speed ∈ [0.5, 1.5]
|
||||||
|
const ALLOWED_VOICES = new Set([
|
||||||
|
"alloy", "echo", "fable", "onyx", "nova", "shimmer", "lessac",
|
||||||
|
]);
|
||||||
|
const ALLOWED_FORMATS = new Set(["mp3", "wav", "opus", "aac", "flac"]);
|
||||||
|
const ttsPiperBaseUrl = (deps.ttsPiperBaseUrl ?? "").replace(/\/+$/, "");
|
||||||
|
const ttsFetch = deps.fetchImpl ?? globalThis.fetch;
|
||||||
|
|
||||||
|
app.post("/v1/synth-speech", async (req: Request, res: Response) => {
|
||||||
|
if (!ttsPiperBaseUrl) {
|
||||||
|
return jsonError(
|
||||||
|
res,
|
||||||
|
503,
|
||||||
|
"service_unavailable",
|
||||||
|
"buyer-app booted without TTS_PIPER_BASE_URL; /v1/synth-speech disabled",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = readBody(req.body);
|
||||||
|
const text = typeof body.text === "string" ? body.text.trim() : "";
|
||||||
|
if (text.length === 0) {
|
||||||
|
return jsonError(res, 400, "text_required",
|
||||||
|
"body.text must be a non-empty string");
|
||||||
|
}
|
||||||
|
if (text.length > 4000) {
|
||||||
|
return jsonError(res, 400, "text_too_long",
|
||||||
|
"body.text must be ≤ 4000 chars (Piper softcap; longer texts can be split client-side)");
|
||||||
|
}
|
||||||
|
const voice = typeof body.voice === "string" ? body.voice : "nova";
|
||||||
|
if (!ALLOWED_VOICES.has(voice)) {
|
||||||
|
return jsonError(res, 400, "voice_invalid",
|
||||||
|
`voice must be one of: ${[...ALLOWED_VOICES].join(", ")}`);
|
||||||
|
}
|
||||||
|
const format = typeof body.format === "string" ? body.format : "mp3";
|
||||||
|
if (!ALLOWED_FORMATS.has(format)) {
|
||||||
|
return jsonError(res, 400, "format_invalid",
|
||||||
|
`format must be one of: ${[...ALLOWED_FORMATS].join(", ")}`);
|
||||||
|
}
|
||||||
|
const speedRaw = body.speed;
|
||||||
|
const speed = typeof speedRaw === "number" ? speedRaw
|
||||||
|
: typeof speedRaw === "string" ? Number(speedRaw)
|
||||||
|
: 1.0;
|
||||||
|
if (!Number.isFinite(speed) || speed < 0.5 || speed > 1.5) {
|
||||||
|
return jsonError(res, 400, "speed_out_of_range",
|
||||||
|
"speed must be a finite number in [0.5, 1.5]");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const upstream = await ttsFetch(`${ttsPiperBaseUrl}/v1/audio/speech`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "tts-1",
|
||||||
|
input: text,
|
||||||
|
voice,
|
||||||
|
response_format: format,
|
||||||
|
speed,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!upstream.ok) {
|
||||||
|
const detail = await upstream.text().catch(() => "");
|
||||||
|
return jsonError(
|
||||||
|
res,
|
||||||
|
502,
|
||||||
|
"tts_upstream_error",
|
||||||
|
`TTS upstream ${upstream.status} ${upstream.statusText}: ${detail.slice(0, 200)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const contentType = upstream.headers.get("content-type") ?? `audio/${format}`;
|
||||||
|
const buf = Buffer.from(await upstream.arrayBuffer());
|
||||||
|
res.status(200);
|
||||||
|
res.setHeader("Content-Type", contentType);
|
||||||
|
res.setHeader("Content-Length", String(buf.length));
|
||||||
|
// Hint a sensible filename for the SPA's download anchor.
|
||||||
|
res.setHeader("Content-Disposition",
|
||||||
|
`inline; filename="speech.${format}"`);
|
||||||
|
return res.send(buf);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[buyer] /v1/synth-speech failed: ${message}`);
|
||||||
|
return jsonError(res, 502, "tts_unreachable", message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/v1/accept", async (req: Request, res: Response) => {
|
app.post("/v1/accept", async (req: Request, res: Response) => {
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return jsonError(
|
return jsonError(
|
||||||
|
|||||||
214
buyer/src/ui/components/PiperTTSForm.tsx
Normal file
214
buyer/src/ui/components/PiperTTSForm.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* buyer/src/ui/components/PiperTTSForm.tsx — capability-specific form
|
||||||
|
* for `audio.synthesize.piper.v1` suppliers.
|
||||||
|
*
|
||||||
|
* The form posts directly to the buyer-app's `/v1/synth-speech` proxy,
|
||||||
|
* which forwards to the openedai-speech-min PiperTTS host. Path B of the
|
||||||
|
* multi-capability story: we render a Piper-shaped UI now (text + voice +
|
||||||
|
* format + speed) without going through escrow. When the marketplace TTS-
|
||||||
|
* supplier adapter lands, this same component is reused unchanged — only
|
||||||
|
* the server endpoint swaps from /v1/synth-speech to a /v1/submit-tts.
|
||||||
|
*
|
||||||
|
* UX notes:
|
||||||
|
* - Voices and formats are hard-coded to what openedai-speech-min actually
|
||||||
|
* honours. If a future Piper deploy adds voices, expose them via a
|
||||||
|
* /v1/synth-speech-voices listing call instead of free-typing here.
|
||||||
|
* - We render a native <audio controls> on success so the user can play
|
||||||
|
* without leaving the page, plus a Download anchor whose href is the
|
||||||
|
* same blob URL (the audio buffer is materialised once and reused).
|
||||||
|
* - Blob URLs are revoked on unmount + on each new submit to avoid
|
||||||
|
* accumulating leaked memory across many demo runs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const VOICES = [
|
||||||
|
{ value: "nova", label: "Nova (default — feminine, warm)" },
|
||||||
|
{ value: "alloy", label: "Alloy" },
|
||||||
|
{ value: "echo", label: "Echo" },
|
||||||
|
{ value: "fable", label: "Fable (British male)" },
|
||||||
|
{ value: "onyx", label: "Onyx" },
|
||||||
|
{ value: "shimmer", label: "Shimmer" },
|
||||||
|
{ value: "lessac", label: "Lessac (high-quality female)" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const FORMATS = [
|
||||||
|
{ value: "mp3", label: "mp3" },
|
||||||
|
{ value: "wav", label: "wav" },
|
||||||
|
{ value: "opus", label: "opus" },
|
||||||
|
{ value: "aac", label: "aac" },
|
||||||
|
{ value: "flac", label: "flac" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface AudioResult {
|
||||||
|
blobUrl: string;
|
||||||
|
format: string;
|
||||||
|
byteLength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PiperTTSForm(): JSX.Element {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [voice, setVoice] = useState("nova");
|
||||||
|
const [format, setFormat] = useState("mp3");
|
||||||
|
const [speed, setSpeed] = useState(1.0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<AudioResult | null>(null);
|
||||||
|
|
||||||
|
// Track the active blob URL so we can revoke it before creating a new one
|
||||||
|
// and on unmount. Without this, every successful synth leaks the previous
|
||||||
|
// blob into the page until the user reloads.
|
||||||
|
const activeBlobUrl = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
return (): void => {
|
||||||
|
if (activeBlobUrl.current) URL.revokeObjectURL(activeBlobUrl.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent): Promise<void> => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (text.trim().length === 0) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (activeBlobUrl.current) {
|
||||||
|
URL.revokeObjectURL(activeBlobUrl.current);
|
||||||
|
activeBlobUrl.current = null;
|
||||||
|
}
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/v1/synth-speech", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ text: text.trim(), voice, format, speed }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
// Try to parse JSON error; fall through to status text on non-JSON.
|
||||||
|
let errMsg = `${resp.status} ${resp.statusText}`;
|
||||||
|
try {
|
||||||
|
const j = (await resp.json()) as { error?: string; message?: string };
|
||||||
|
if (j.error || j.message) errMsg = `${j.error ?? "error"}: ${j.message ?? ""}`;
|
||||||
|
} catch {
|
||||||
|
/* keep status fallback */
|
||||||
|
}
|
||||||
|
throw new Error(errMsg);
|
||||||
|
}
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
activeBlobUrl.current = url;
|
||||||
|
setResult({ blobUrl: url, format, byteLength: blob.size });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="space-y-4 rounded border border-gray-200 bg-white p-4"
|
||||||
|
data-testid="piper-tts-form"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
Text to synthesize
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
maxLength={4000}
|
||||||
|
placeholder="Type the words you want spoken…"
|
||||||
|
className="mt-1 block w-full rounded border border-gray-300 p-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||||
|
data-testid="piper-tts-text"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">{text.length} / 4000</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Voice</label>
|
||||||
|
<select
|
||||||
|
value={voice}
|
||||||
|
onChange={(e) => setVoice(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded border border-gray-300 p-2 text-sm"
|
||||||
|
data-testid="piper-tts-voice"
|
||||||
|
>
|
||||||
|
{VOICES.map((v) => (
|
||||||
|
<option key={v.value} value={v.value}>{v.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Format</label>
|
||||||
|
<select
|
||||||
|
value={format}
|
||||||
|
onChange={(e) => setFormat(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded border border-gray-300 p-2 text-sm"
|
||||||
|
data-testid="piper-tts-format"
|
||||||
|
>
|
||||||
|
{FORMATS.map((f) => (
|
||||||
|
<option key={f.value} value={f.value}>{f.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
Speed: <span className="font-mono">{speed.toFixed(2)}×</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0.5}
|
||||||
|
max={1.5}
|
||||||
|
step={0.05}
|
||||||
|
value={speed}
|
||||||
|
onChange={(e) => setSpeed(Number(e.target.value))}
|
||||||
|
className="mt-1 block w-full"
|
||||||
|
data-testid="piper-tts-speed"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-gray-500">
|
||||||
|
<span>0.5×</span><span>1.0×</span><span>1.5×</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || text.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="piper-tts-submit"
|
||||||
|
>
|
||||||
|
{loading ? "Synthesizing…" : "Generate audio"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700"
|
||||||
|
data-testid="piper-tts-error">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="space-y-2 rounded border border-green-300 bg-green-50 p-3"
|
||||||
|
data-testid="piper-tts-result">
|
||||||
|
<audio controls src={result.blobUrl} className="w-full" />
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-700">
|
||||||
|
<span>{(result.byteLength / 1024).toFixed(1)} KB · {result.format}</span>
|
||||||
|
<a
|
||||||
|
href={result.blobUrl}
|
||||||
|
download={`speech.${result.format}`}
|
||||||
|
className="rounded bg-gray-700 px-3 py-1 text-white hover:bg-gray-800"
|
||||||
|
data-testid="piper-tts-download"
|
||||||
|
>
|
||||||
|
Download .{result.format}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,7 +20,13 @@ import { createRoot } from "react-dom/client";
|
|||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import App from "./App.js";
|
import App from "./App.js";
|
||||||
import { MarketplaceProvider } from "./state/MarketplaceContext.js";
|
import { MarketplaceProvider } from "./state/MarketplaceContext.js";
|
||||||
import { Marketplace, MemoryTaskHistoryStore, LocalStorageTaskHistoryStore } from "../sdk/index.js";
|
// MemoryTaskHistoryStore is a no-op placeholder for the browser-side SDK
|
||||||
|
// instance — submitPrompt now runs server-side, so the browser never
|
||||||
|
// records anything to a history store. The /tasks page reads directly
|
||||||
|
// from the indexer (chain is the source of truth). LocalStorageTaskHistoryStore
|
||||||
|
// was the previous browser-only persistence path; dropped because it
|
||||||
|
// always rendered empty post UX-2b.
|
||||||
|
import { Marketplace, MemoryTaskHistoryStore } from "../sdk/index.js";
|
||||||
import type { ChainProvider } from "@marketplace/shared/chain";
|
import type { ChainProvider } from "@marketplace/shared/chain";
|
||||||
|
|
||||||
interface BuyerBoot {
|
interface BuyerBoot {
|
||||||
@@ -49,9 +55,7 @@ const chainStub: ChainProvider = {
|
|||||||
awaitTx: () => { throw new Error("browser SPA must not call chain.awaitTx — go through /v1/*"); },
|
awaitTx: () => { throw new Error("browser SPA must not call chain.awaitTx — go through /v1/*"); },
|
||||||
};
|
};
|
||||||
|
|
||||||
const historyStore = typeof window !== "undefined" && window.localStorage
|
const historyStore = new MemoryTaskHistoryStore();
|
||||||
? new LocalStorageTaskHistoryStore()
|
|
||||||
: new MemoryTaskHistoryStore();
|
|
||||||
const marketplace = new Marketplace({
|
const marketplace = new Marketplace({
|
||||||
chain: chainStub,
|
chain: chainStub,
|
||||||
// Same-origin proxy: SDK calls land on /v1/indexer/<path> on the buyer
|
// Same-origin proxy: SDK calls land on /v1/indexer/<path> on the buyer
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* buyer/src/ui/pages/Dashboard.tsx — supplier list + new task form.
|
* buyer/src/ui/pages/Dashboard.tsx — supplier list + capability-routed task form.
|
||||||
*
|
*
|
||||||
* Calls marketplace.discoverSuppliers() on mount; renders a SupplierCard
|
* Calls marketplace.discoverSuppliers() on mount; renders a SupplierCard
|
||||||
* per result. PromptForm requires a selected supplier; until a supplier
|
* per result. Once a supplier is selected, the form rendered below is
|
||||||
* is chosen it is not rendered.
|
* picked from `CAPABILITY_FORMS` based on the supplier's `capability_id`.
|
||||||
|
*
|
||||||
|
* Capabilities (current registry):
|
||||||
|
*
|
||||||
|
* `llm.text.generate.v1` → PromptForm (chat-completion, full escrow lifecycle)
|
||||||
|
* `audio.synthesize.piper.v1` → PiperTTSForm (PiperTTS proxy via /v1/synth-speech)
|
||||||
|
*
|
||||||
|
* The Piper supplier is currently injected as a SYNTHETIC, client-side
|
||||||
|
* entry — the real openedai-speech-min host on apex-simulation is not yet
|
||||||
|
* a marketplace-registered supplier (no on-chain advert, no bond, no signed
|
||||||
|
* receipts). Once the supplier-side adapter ships and posts a proper advert,
|
||||||
|
* delete the `PIPER_DEMO_SUPPLIER` constant; the real entry will appear in
|
||||||
|
* `discoverSuppliers()` automatically and the dispatch table below resolves it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMarketplace } from "../state/MarketplaceContext.js";
|
import { useMarketplace } from "../state/MarketplaceContext.js";
|
||||||
import SupplierCard from "../components/SupplierCard.js";
|
import SupplierCard from "../components/SupplierCard.js";
|
||||||
import PromptForm from "../components/PromptForm.js";
|
import PromptForm from "../components/PromptForm.js";
|
||||||
|
import PiperTTSForm from "../components/PiperTTSForm.js";
|
||||||
import type { SupplierView } from "../../sdk/types.js";
|
import type { SupplierView } from "../../sdk/types.js";
|
||||||
|
|
||||||
function parseUtxoRef(ref: string): { txHash: string; index: number } | null {
|
function parseUtxoRef(ref: string): { txHash: string; index: number } | null {
|
||||||
@@ -21,6 +34,33 @@ function parseUtxoRef(ref: string): { txHash: string; index: number } | null {
|
|||||||
return { txHash, index };
|
return { txHash, index };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Synthetic supplier entry — see file-header. Marker `utxo_ref` starts with
|
||||||
|
// "demo:" so anything that tries to parse it as `<txhash>#<index>` cleanly
|
||||||
|
// rejects it (parseUtxoRef returns null), keeping the SDK paths honest.
|
||||||
|
const PIPER_DEMO_SUPPLIER: SupplierView = {
|
||||||
|
utxo_ref: "demo:piper-tts",
|
||||||
|
supplier_pkh: "",
|
||||||
|
capability_id: "audio.synthesize.piper.v1",
|
||||||
|
model: "PiperTTS (apex-simulation)",
|
||||||
|
max_output_tokens: 0,
|
||||||
|
max_processing_ms: 0,
|
||||||
|
// price=0 reflects "no escrow yet" — the demo proxy bypasses payment.
|
||||||
|
// Once a real on-chain advert arrives, that entry's price_lovelace
|
||||||
|
// overrides this and the synthetic stub goes away.
|
||||||
|
price_lovelace: "0",
|
||||||
|
supplier_bond_lovelace: "0",
|
||||||
|
buyer_bond_lovelace: "0",
|
||||||
|
endpoint_url: "https://tts-piper.vector.testnet.apexfusion.org",
|
||||||
|
detail_uri: "",
|
||||||
|
detail_hash: "",
|
||||||
|
advertised_at: 0,
|
||||||
|
status: "demo",
|
||||||
|
advert_status: "demo",
|
||||||
|
current_escrow_ref: null,
|
||||||
|
last_seen_iso: null,
|
||||||
|
created_slot: 0,
|
||||||
|
};
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const marketplace = useMarketplace();
|
const marketplace = useMarketplace();
|
||||||
const [suppliers, setSuppliers] = useState<SupplierView[]>([]);
|
const [suppliers, setSuppliers] = useState<SupplierView[]>([]);
|
||||||
@@ -32,18 +72,28 @@ export default function Dashboard() {
|
|||||||
marketplace
|
marketplace
|
||||||
.discoverSuppliers()
|
.discoverSuppliers()
|
||||||
.then((list) => {
|
.then((list) => {
|
||||||
if (!cancelled) setSuppliers(list);
|
if (!cancelled) {
|
||||||
|
// Append demo supplier so it's visually grouped with real ones.
|
||||||
|
// If a real audio.synthesize.piper.v1 supplier appears in `list`,
|
||||||
|
// the synthetic entry still shows but the real one wins selection
|
||||||
|
// (cards are deduped by utxo_ref, and "demo:..." can't collide).
|
||||||
|
setSuppliers([...list, PIPER_DEMO_SUPPLIER]);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((err: Error) => {
|
.catch((err: Error) => {
|
||||||
if (!cancelled) setError(err.message);
|
if (!cancelled) {
|
||||||
|
setError(err.message);
|
||||||
|
// Even when the indexer is unreachable, expose the demo so the
|
||||||
|
// capability path is testable in isolation.
|
||||||
|
setSuppliers([PIPER_DEMO_SUPPLIER]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [marketplace]);
|
}, [marketplace]);
|
||||||
|
|
||||||
const advertRef =
|
const advertRef = selected && parseUtxoRef(selected.utxo_ref);
|
||||||
selected && parseUtxoRef(selected.utxo_ref);
|
|
||||||
const payment_lovelace = selected ? BigInt(selected.price_lovelace) : 0n;
|
const payment_lovelace = selected ? BigInt(selected.price_lovelace) : 0n;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -64,14 +114,61 @@ export default function Dashboard() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selected && advertRef && (
|
{selected && (
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-semibold mb-2">
|
<h2 className="text-xl font-semibold mb-2">
|
||||||
New prompt → {selected.model}
|
New task → {selected.model}
|
||||||
|
<span className="ml-2 text-sm font-normal text-gray-500">
|
||||||
|
({selected.capability_id})
|
||||||
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
<PromptForm advertRef={advertRef} payment_lovelace={payment_lovelace} />
|
{renderCapabilityForm(selected, advertRef, payment_lovelace)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Picks the form component that matches the selected supplier's capability.
|
||||||
|
* Each capability owns its own props shape (PromptForm needs advertRef +
|
||||||
|
* payment_lovelace; PiperTTSForm takes none — it bypasses escrow), so the
|
||||||
|
* match-block is the simplest expression. As more capabilities land, add a
|
||||||
|
* new case rather than building an abstract dispatcher; the explicit list
|
||||||
|
* stays readable until there's a real reason to generalise. */
|
||||||
|
function renderCapabilityForm(
|
||||||
|
supplier: SupplierView,
|
||||||
|
advertRef: { txHash: string; index: number } | null,
|
||||||
|
payment_lovelace: bigint,
|
||||||
|
): JSX.Element {
|
||||||
|
switch (supplier.capability_id) {
|
||||||
|
case "llm.text.generate.v1":
|
||||||
|
if (!advertRef) {
|
||||||
|
return (
|
||||||
|
<UnsupportedCapability
|
||||||
|
capabilityId={supplier.capability_id}
|
||||||
|
reason="supplier has no parseable advert UTxO ref"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <PromptForm advertRef={advertRef} payment_lovelace={payment_lovelace} />;
|
||||||
|
|
||||||
|
case "audio.synthesize.piper.v1":
|
||||||
|
return <PiperTTSForm />;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <UnsupportedCapability capabilityId={supplier.capability_id} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnsupportedCapability({
|
||||||
|
capabilityId,
|
||||||
|
reason,
|
||||||
|
}: { capabilityId: string; reason?: string }): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-900"
|
||||||
|
data-testid="unsupported-capability">
|
||||||
|
<strong>No form registered for capability:</strong> <code>{capabilityId}</code>
|
||||||
|
{reason && <div className="mt-1 text-xs text-yellow-800">({reason})</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,42 +1,247 @@
|
|||||||
/**
|
/**
|
||||||
* buyer/src/ui/pages/TaskHistory.tsx — task history list.
|
* buyer/src/ui/pages/TaskHistory.tsx — full lifecycle history for this buyer.
|
||||||
|
*
|
||||||
|
* Source of truth: the indexer. We fetch every escrow row for the buyer's
|
||||||
|
* pkh (proxied through the buyer-app's /v1/indexer/* passthrough), group by
|
||||||
|
* `posted_at` (which is preserved across state transitions by the
|
||||||
|
* `expect_unchanged_modulo_state` validator invariant), and render one card
|
||||||
|
* per lifecycle.
|
||||||
|
*
|
||||||
|
* Why server-side / indexer-driven: the SPA previously read from a
|
||||||
|
* LocalStorageTaskHistoryStore that was written by `marketplace.submitPrompt`.
|
||||||
|
* After UX-2b moved submitPrompt server-side, the browser SDK no longer
|
||||||
|
* writes anything, so localStorage was always empty. The chain itself is the
|
||||||
|
* canonical history; rendering directly from indexer rows means a fresh
|
||||||
|
* browser session immediately sees every prior lifecycle this buyer has
|
||||||
|
* participated in, on any device.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { useMarketplace } from "../state/MarketplaceContext.js";
|
import { useMarketplace } from "../state/MarketplaceContext.js";
|
||||||
import TaskRow from "../components/TaskRow.js";
|
|
||||||
import type { TaskRecord } from "../../sdk/types.js";
|
interface IndexerEscrowRow {
|
||||||
|
utxo_ref: string;
|
||||||
|
buyer_pkh: string;
|
||||||
|
supplier_pkh: string;
|
||||||
|
advert_ref: string;
|
||||||
|
capability_id: string;
|
||||||
|
prompt_hash: string;
|
||||||
|
payment_lovelace: string;
|
||||||
|
buyer_bond_lovelace: string;
|
||||||
|
supplier_bond_lovelace: string;
|
||||||
|
posted_at: number;
|
||||||
|
submitted_at: number | null;
|
||||||
|
result_receipt_hash: string | null;
|
||||||
|
state: string;
|
||||||
|
created_slot: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A lifecycle = all escrow rows that share posted_at (one buyer/supplier pair). */
|
||||||
|
interface Lifecycle {
|
||||||
|
posted_at: number;
|
||||||
|
supplier_pkh: string;
|
||||||
|
capability_id: string;
|
||||||
|
payment_lovelace: string;
|
||||||
|
buyer_bond_lovelace: string;
|
||||||
|
supplier_bond_lovelace: string;
|
||||||
|
advert_ref: string;
|
||||||
|
prompt_hash: string;
|
||||||
|
rows: IndexerEscrowRow[]; // sorted ascending by created_slot
|
||||||
|
currentState: string; // = rows[rows.length-1].state
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_PRIORITY: Record<string, number> = {
|
||||||
|
Open: 0,
|
||||||
|
Claimed: 1,
|
||||||
|
Submitted: 2,
|
||||||
|
Accepted: 3,
|
||||||
|
Reclaimed: 3,
|
||||||
|
Released: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
function txHashFromRef(ref: string): string {
|
||||||
|
const hash = ref.split("#")[0] ?? "";
|
||||||
|
return hash.length >= 12 ? `${hash.slice(0, 8)}…${hash.slice(-4)}` : hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtAda(lovelace: string): string {
|
||||||
|
try {
|
||||||
|
const n = BigInt(lovelace);
|
||||||
|
const ada = Number(n) / 1_000_000;
|
||||||
|
return `${ada.toFixed(2)} AP3X`;
|
||||||
|
} catch {
|
||||||
|
return lovelace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(ms: number): string {
|
||||||
|
if (!ms) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(ms).toISOString().replace("T", " ").slice(0, 19) + " UTC";
|
||||||
|
} catch {
|
||||||
|
return String(ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statePill(state: string): string {
|
||||||
|
switch (state) {
|
||||||
|
case "Accepted": return "bg-green-100 text-green-800";
|
||||||
|
case "Submitted": return "bg-indigo-100 text-indigo-800";
|
||||||
|
case "Claimed": return "bg-blue-100 text-blue-800";
|
||||||
|
case "Open": return "bg-yellow-100 text-yellow-800";
|
||||||
|
case "Reclaimed": return "bg-orange-100 text-orange-800";
|
||||||
|
case "Released": return "bg-red-100 text-red-800";
|
||||||
|
default: return "bg-gray-200 text-gray-700";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByLifecycle(rows: IndexerEscrowRow[]): Lifecycle[] {
|
||||||
|
const buckets = new Map<string, IndexerEscrowRow[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
// posted_at + supplier_pkh disambiguates if a buyer happens to post two
|
||||||
|
// escrows in the exact same ms (extremely unlikely on testnet but
|
||||||
|
// cheap defensive grouping).
|
||||||
|
const key = `${r.posted_at}:${r.supplier_pkh}`;
|
||||||
|
const arr = buckets.get(key);
|
||||||
|
if (arr) arr.push(r);
|
||||||
|
else buckets.set(key, [r]);
|
||||||
|
}
|
||||||
|
const out: Lifecycle[] = [];
|
||||||
|
for (const [, lcRows] of buckets) {
|
||||||
|
lcRows.sort((a, b) => a.created_slot - b.created_slot);
|
||||||
|
const last = lcRows[lcRows.length - 1];
|
||||||
|
// currentState is whichever row has the highest STATE_PRIORITY (handles
|
||||||
|
// out-of-order arrivals from the indexer; we don't rely on slot order
|
||||||
|
// alone).
|
||||||
|
const top = lcRows.reduce((acc, r) =>
|
||||||
|
(STATE_PRIORITY[r.state] ?? 0) > (STATE_PRIORITY[acc.state] ?? 0) ? r : acc,
|
||||||
|
last);
|
||||||
|
out.push({
|
||||||
|
posted_at: last.posted_at,
|
||||||
|
supplier_pkh: last.supplier_pkh,
|
||||||
|
capability_id: last.capability_id,
|
||||||
|
payment_lovelace: last.payment_lovelace,
|
||||||
|
buyer_bond_lovelace: last.buyer_bond_lovelace,
|
||||||
|
supplier_bond_lovelace: last.supplier_bond_lovelace,
|
||||||
|
advert_ref: last.advert_ref,
|
||||||
|
prompt_hash: last.prompt_hash,
|
||||||
|
rows: lcRows,
|
||||||
|
currentState: top.state,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Newest first.
|
||||||
|
out.sort((a, b) => b.posted_at - a.posted_at);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export default function TaskHistory() {
|
export default function TaskHistory() {
|
||||||
const marketplace = useMarketplace();
|
const marketplace = useMarketplace();
|
||||||
const [tasks, setTasks] = useState<TaskRecord[]>([]);
|
const buyerPkh = marketplace.getWalletKey().pubKeyHash;
|
||||||
|
const [rows, setRows] = useState<IndexerEscrowRow[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const load = async (): Promise<void> => {
|
||||||
setTasks(marketplace.getTaskHistory());
|
if (!buyerPkh) {
|
||||||
}, [marketplace]);
|
setError("buyer pkh missing — boot script not injected");
|
||||||
|
setRows([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/v1/indexer/escrows?buyer=${buyerPkh}`);
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`indexer responded ${resp.status} ${resp.statusText}`);
|
||||||
|
}
|
||||||
|
const body = (await resp.json()) as IndexerEscrowRow[];
|
||||||
|
setRows(Array.isArray(body) ? body : []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
setRows([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { void load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [buyerPkh]);
|
||||||
|
|
||||||
|
const lifecycles = useMemo<Lifecycle[]>(
|
||||||
|
() => (rows === null ? [] : groupByLifecycle(rows)),
|
||||||
|
[rows],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4" data-testid="task-history-page">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold">Tasks</h1>
|
<h1 className="text-2xl font-semibold">Tasks</h1>
|
||||||
{tasks.length === 0 ? (
|
<button
|
||||||
<p className="text-gray-500">No tasks yet.</p>
|
type="button"
|
||||||
|
onClick={() => void load()}
|
||||||
|
className="rounded border border-gray-300 bg-white px-3 py-1 text-sm hover:bg-gray-50"
|
||||||
|
data-testid="task-history-refresh"
|
||||||
|
>
|
||||||
|
refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700" role="alert">
|
||||||
|
Failed to load history: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rows === null ? (
|
||||||
|
<p className="text-sm text-gray-500">loading…</p>
|
||||||
|
) : lifecycles.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500" data-testid="task-history-empty">
|
||||||
|
No on-chain lifecycles yet for this buyer. Submit a prompt from the
|
||||||
|
Dashboard to start one.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="min-w-full text-sm">
|
<ul className="space-y-3">
|
||||||
<thead>
|
{lifecycles.map((lc) => (
|
||||||
<tr className="border-b border-gray-200 text-left">
|
<li
|
||||||
<th className="px-2 py-2">Posted</th>
|
key={`${lc.posted_at}:${lc.supplier_pkh}`}
|
||||||
<th className="px-2 py-2">Supplier</th>
|
data-testid="task-row"
|
||||||
<th className="px-2 py-2">Capability</th>
|
className="rounded-md bg-white p-4 shadow-sm"
|
||||||
<th className="px-2 py-2">Status</th>
|
>
|
||||||
<th className="px-2 py-2">Prompt</th>
|
<div className="flex flex-wrap items-baseline justify-between gap-2 mb-2">
|
||||||
</tr>
|
<div className="text-xs font-mono text-gray-500">
|
||||||
</thead>
|
{fmtTime(lc.posted_at)}
|
||||||
<tbody>
|
</div>
|
||||||
{tasks.map((t) => (
|
<span
|
||||||
<TaskRow key={t.escrow_ref} task={t} />
|
className={`rounded px-2 py-0.5 text-xs font-medium ${statePill(lc.currentState)}`}
|
||||||
|
>
|
||||||
|
{lc.currentState}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-700 mb-2">
|
||||||
|
<span className="font-medium">{lc.capability_id}</span>
|
||||||
|
<span className="text-gray-500">·</span>
|
||||||
|
<span>{fmtAda(lc.payment_lovelace)} payment</span>
|
||||||
|
<span className="text-gray-500">·</span>
|
||||||
|
<span>{fmtAda(lc.buyer_bond_lovelace)} bond</span>
|
||||||
|
<span className="text-gray-500">·</span>
|
||||||
|
<span className="font-mono text-xs text-gray-500">
|
||||||
|
supplier {lc.supplier_pkh.slice(0, 12)}…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 border-t border-gray-100 pt-2">
|
||||||
|
{lc.rows.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.utxo_ref}
|
||||||
|
className="flex flex-wrap items-center gap-x-3 text-xs"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`rounded px-1.5 py-0.5 font-medium ${statePill(r.state)}`}
|
||||||
|
>
|
||||||
|
{r.state}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-gray-600">{txHashFromRef(r.utxo_ref)}</span>
|
||||||
|
<span className="font-mono text-gray-400">slot {r.created_slot}</span>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ services:
|
|||||||
OGMIOS_URL: http://ogmios:1337
|
OGMIOS_URL: http://ogmios:1337
|
||||||
LIVE_CHAIN: "1"
|
LIVE_CHAIN: "1"
|
||||||
NETWORK_ID: "0"
|
NETWORK_ID: "0"
|
||||||
|
# Path-B PiperTTS proxy target. Public CF hostname so the buyer-app
|
||||||
|
# reaches it via the internet (we don't share a docker network with
|
||||||
|
# apex-simulation). When this is unset, /v1/synth-speech responds 503
|
||||||
|
# and the SPA's PiperTTSForm shows a clear error.
|
||||||
|
TTS_PIPER_BASE_URL: "https://tts-piper.vector.testnet.apexfusion.org"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8070/healthz >/dev/null || exit 1"]
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8070/healthz >/dev/null || exit 1"]
|
||||||
interval: 20s
|
interval: 20s
|
||||||
|
|||||||
169
tests/unit/buyer-server-synth-speech.test.ts
Normal file
169
tests/unit/buyer-server-synth-speech.test.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* buyer-server-synth-speech.test.ts — coverage for the PiperTTS proxy
|
||||||
|
* endpoint (POST /v1/synth-speech) added under the path-B multi-capability
|
||||||
|
* story.
|
||||||
|
*
|
||||||
|
* The endpoint forwards to an injected `fetchImpl` so we never hit the
|
||||||
|
* real openedai-speech-min host in tests. We assert:
|
||||||
|
* - 503 when ttsPiperBaseUrl is not configured
|
||||||
|
* - validation 400s for missing/oversized text, bad voice, bad format,
|
||||||
|
* and out-of-range speed
|
||||||
|
* - 200 with audio bytes + correct Content-Type when upstream succeeds
|
||||||
|
* - 502 when upstream returns non-2xx OR throws
|
||||||
|
* - request body forwarded to upstream as OpenAI-shape JSON (model,
|
||||||
|
* input, voice, response_format, speed)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { createApp } from "../../buyer/src/server.js";
|
||||||
|
|
||||||
|
const baseUrl = "http://tts-piper.fake/";
|
||||||
|
|
||||||
|
/** Build a fetchImpl that records calls + returns a configurable response. */
|
||||||
|
function makeStubFetch(opts: {
|
||||||
|
status?: number;
|
||||||
|
body?: ArrayBuffer | string;
|
||||||
|
contentType?: string;
|
||||||
|
throwError?: Error;
|
||||||
|
}) {
|
||||||
|
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||||
|
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
calls.push({ url: String(input), init: init ?? {} });
|
||||||
|
if (opts.throwError) throw opts.throwError;
|
||||||
|
const status = opts.status ?? 200;
|
||||||
|
const body = opts.body ?? new ArrayBuffer(0);
|
||||||
|
const contentType = opts.contentType ?? "audio/mpeg";
|
||||||
|
return new Response(body, {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": contentType },
|
||||||
|
});
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
return { fetchImpl, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POST /v1/synth-speech", () => {
|
||||||
|
it("returns 503 when ttsPiperBaseUrl is not configured", async () => {
|
||||||
|
const app = createApp({});
|
||||||
|
const { default: request } = await import("supertest");
|
||||||
|
const res = await request(app).post("/v1/synth-speech").send({
|
||||||
|
text: "hi", voice: "nova", format: "mp3", speed: 1.0,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(res.body.error).toBe("service_unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty text with 400 text_required", async () => {
|
||||||
|
const app = createApp({ ttsPiperBaseUrl: baseUrl, fetchImpl: makeStubFetch({}).fetchImpl });
|
||||||
|
const { default: request } = await import("supertest");
|
||||||
|
const res = await request(app).post("/v1/synth-speech").send({ text: " " });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe("text_required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects oversized text with 400 text_too_long", async () => {
|
||||||
|
const app = createApp({ ttsPiperBaseUrl: baseUrl, fetchImpl: makeStubFetch({}).fetchImpl });
|
||||||
|
const { default: request } = await import("supertest");
|
||||||
|
const res = await request(app).post("/v1/synth-speech").send({ text: "x".repeat(4001) });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe("text_too_long");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown voice with 400 voice_invalid", async () => {
|
||||||
|
const app = createApp({ ttsPiperBaseUrl: baseUrl, fetchImpl: makeStubFetch({}).fetchImpl });
|
||||||
|
const { default: request } = await import("supertest");
|
||||||
|
const res = await request(app).post("/v1/synth-speech")
|
||||||
|
.send({ text: "hi", voice: "not-a-voice" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe("voice_invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown format with 400 format_invalid", async () => {
|
||||||
|
const app = createApp({ ttsPiperBaseUrl: baseUrl, fetchImpl: makeStubFetch({}).fetchImpl });
|
||||||
|
const { default: request } = await import("supertest");
|
||||||
|
const res = await request(app).post("/v1/synth-speech")
|
||||||
|
.send({ text: "hi", voice: "nova", format: "ogg" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe("format_invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects speed below 0.5 / above 1.5 with 400 speed_out_of_range", async () => {
|
||||||
|
const app = createApp({ ttsPiperBaseUrl: baseUrl, fetchImpl: makeStubFetch({}).fetchImpl });
|
||||||
|
const { default: request } = await import("supertest");
|
||||||
|
// NaN is not in this list because JSON.stringify({speed:NaN}) → "null",
|
||||||
|
// which the endpoint correctly treats as "field omitted" (defaults to 1.0).
|
||||||
|
// Out-of-range numerics + non-numeric strings are the real attack surface.
|
||||||
|
for (const bad of [0.4, 1.6, "fast"]) {
|
||||||
|
const res = await request(app).post("/v1/synth-speech")
|
||||||
|
.send({ text: "hi", voice: "nova", format: "mp3", speed: bad });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe("speed_out_of_range");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 audio bytes with upstream Content-Type on success", async () => {
|
||||||
|
// 8 dummy bytes — supertest will deliver them as a Buffer, length verifies
|
||||||
|
// that the proxy forwarded the body unchanged.
|
||||||
|
const audio = new Uint8Array([0xff, 0xfb, 0x90, 0x44, 0, 0, 0, 0]);
|
||||||
|
const { fetchImpl, calls } = makeStubFetch({
|
||||||
|
body: audio.buffer,
|
||||||
|
contentType: "audio/mpeg",
|
||||||
|
});
|
||||||
|
const app = createApp({ ttsPiperBaseUrl: baseUrl, fetchImpl });
|
||||||
|
const { default: request } = await import("supertest");
|
||||||
|
const res = await request(app).post("/v1/synth-speech")
|
||||||
|
.send({ text: "hello", voice: "nova", format: "mp3", speed: 1.0 });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers["content-type"]).toMatch(/audio\/mpeg/);
|
||||||
|
expect(res.body).toBeInstanceOf(Buffer);
|
||||||
|
expect(res.body.length).toBe(8);
|
||||||
|
|
||||||
|
// Verify the OpenAI-shape body was forwarded exactly.
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0].url).toBe("http://tts-piper.fake/v1/audio/speech"); // trailing-slash stripped
|
||||||
|
const sent = JSON.parse(String(calls[0].init.body));
|
||||||
|
expect(sent).toEqual({
|
||||||
|
model: "tts-1",
|
||||||
|
input: "hello",
|
||||||
|
voice: "nova",
|
||||||
|
response_format: "mp3",
|
||||||
|
speed: 1.0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults voice=nova, format=mp3, speed=1.0 when fields are omitted", async () => {
|
||||||
|
const { fetchImpl, calls } = makeStubFetch({ body: new ArrayBuffer(1) });
|
||||||
|
const app = createApp({ ttsPiperBaseUrl: baseUrl, fetchImpl });
|
||||||
|
const { default: request } = await import("supertest");
|
||||||
|
const res = await request(app).post("/v1/synth-speech").send({ text: "hi" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const sent = JSON.parse(String(calls[0].init.body));
|
||||||
|
expect(sent.voice).toBe("nova");
|
||||||
|
expect(sent.response_format).toBe("mp3");
|
||||||
|
expect(sent.speed).toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 502 tts_upstream_error on non-2xx upstream", async () => {
|
||||||
|
const { fetchImpl } = makeStubFetch({
|
||||||
|
status: 500,
|
||||||
|
body: "internal piper error",
|
||||||
|
contentType: "text/plain",
|
||||||
|
});
|
||||||
|
const app = createApp({ ttsPiperBaseUrl: baseUrl, fetchImpl });
|
||||||
|
const { default: request } = await import("supertest");
|
||||||
|
const res = await request(app).post("/v1/synth-speech").send({ text: "hi" });
|
||||||
|
expect(res.status).toBe(502);
|
||||||
|
expect(res.body.error).toBe("tts_upstream_error");
|
||||||
|
expect(res.body.message).toContain("500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 502 tts_unreachable on fetch throw", async () => {
|
||||||
|
const { fetchImpl } = makeStubFetch({ throwError: new Error("ENOTFOUND") });
|
||||||
|
const app = createApp({ ttsPiperBaseUrl: baseUrl, fetchImpl });
|
||||||
|
const { default: request } = await import("supertest");
|
||||||
|
const res = await request(app).post("/v1/synth-speech").send({ text: "hi" });
|
||||||
|
expect(res.status).toBe(502);
|
||||||
|
expect(res.body.error).toBe("tts_unreachable");
|
||||||
|
expect(res.body.message).toContain("ENOTFOUND");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -122,12 +122,18 @@ describe("<Dashboard /> smoke", () => {
|
|||||||
makeSampleSupplierView({ supplier_pkh: "b".repeat(56) }),
|
makeSampleSupplierView({ supplier_pkh: "b".repeat(56) }),
|
||||||
]);
|
]);
|
||||||
render(wrap(<Dashboard />, mp));
|
render(wrap(<Dashboard />, mp));
|
||||||
// Wait for async discover to complete and cards to render
|
// Wait for async discover to complete and cards to render. Dashboard
|
||||||
|
// appends a synthetic Piper-TTS demo card to whatever the indexer
|
||||||
|
// returned, so the count is N+1 until the on-chain TTS supplier ships.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Each card should render something with the supplier_pkh visible or a test-id
|
|
||||||
const cards = document.querySelectorAll("[data-testid='supplier-card']");
|
const cards = document.querySelectorAll("[data-testid='supplier-card']");
|
||||||
expect(cards.length).toBe(2);
|
expect(cards.length).toBe(3);
|
||||||
});
|
});
|
||||||
|
// Sanity: one of the cards is the demo Piper supplier.
|
||||||
|
const piperBadges = Array.from(
|
||||||
|
document.querySelectorAll("[data-testid='supplier-card']"),
|
||||||
|
).filter((c) => c.textContent?.includes("audio.synthesize.piper.v1"));
|
||||||
|
expect(piperBadges.length).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -237,14 +243,68 @@ describe("<PromptForm /> smoke", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("<TaskHistory /> smoke", () => {
|
describe("<TaskHistory /> smoke", () => {
|
||||||
it("calls getTaskHistory() and renders a row for each task", async () => {
|
// TaskHistory now fetches /v1/indexer/escrows?buyer=<pkh> via the buyer-app
|
||||||
|
// server proxy and groups rows by posted_at. Each lifecycle (Open → Claimed
|
||||||
|
// → Submitted → terminal) shares one posted_at value, so we mock the
|
||||||
|
// indexer with a couple of fully-populated rows representing two distinct
|
||||||
|
// lifecycles in different states and assert one task-row per lifecycle.
|
||||||
|
it("fetches /v1/indexer/escrows and renders one row per distinct lifecycle (posted_at)", async () => {
|
||||||
const mp = makeMockMarketplace();
|
const mp = makeMockMarketplace();
|
||||||
vi.spyOn(mp, "getTaskHistory").mockReturnValue(ALL_SAMPLE_TASK_RECORDS);
|
const buyerPkh = mp.getWalletKey().pubKeyHash;
|
||||||
|
const baseRow = (overrides: Record<string, unknown>) => ({
|
||||||
|
utxo_ref: "a".repeat(64) + "#0",
|
||||||
|
buyer_pkh: buyerPkh,
|
||||||
|
supplier_pkh: "b".repeat(56),
|
||||||
|
advert_ref: "c".repeat(64) + "#0",
|
||||||
|
capability_id: "llm.text.generate.v1",
|
||||||
|
prompt_hash: "d".repeat(64),
|
||||||
|
payment_lovelace: "2000000",
|
||||||
|
buyer_bond_lovelace: "1000000",
|
||||||
|
supplier_bond_lovelace: "1000000",
|
||||||
|
posted_at: 1700000000000,
|
||||||
|
submitted_at: null,
|
||||||
|
result_receipt_hash: null,
|
||||||
|
state: "Submitted",
|
||||||
|
created_slot: 1000,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
// Lifecycle A: posted_at=1700000000000 — Open + Submitted rows (two
|
||||||
|
// chain rows in same lifecycle).
|
||||||
|
// Lifecycle B: posted_at=1700000099999 — Open + Accepted (different lifecycle).
|
||||||
|
const rows = [
|
||||||
|
baseRow({ utxo_ref: "1".repeat(64) + "#0", state: "Open", created_slot: 1000 }),
|
||||||
|
baseRow({ utxo_ref: "2".repeat(64) + "#0", state: "Submitted", created_slot: 1010 }),
|
||||||
|
baseRow({ utxo_ref: "3".repeat(64) + "#0", state: "Open", created_slot: 2000, posted_at: 1700000099999 }),
|
||||||
|
baseRow({ utxo_ref: "4".repeat(64) + "#0", state: "Accepted", created_slot: 2020, posted_at: 1700000099999 }),
|
||||||
|
];
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = typeof input === "string" ? input : (input instanceof URL ? input.toString() : input.url);
|
||||||
|
if (!url.includes(`/v1/indexer/escrows?buyer=${buyerPkh}`)) {
|
||||||
|
throw new Error(`unexpected fetch: ${url}`);
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify(rows), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}));
|
||||||
render(wrap(<TaskHistory />, mp));
|
render(wrap(<TaskHistory />, mp));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const rows = document.querySelectorAll("[data-testid='task-row']");
|
const taskRows = document.querySelectorAll("[data-testid='task-row']");
|
||||||
expect(rows.length).toBe(ALL_SAMPLE_TASK_RECORDS.length);
|
expect(taskRows.length).toBe(2); // 2 distinct lifecycles
|
||||||
});
|
});
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an empty-state when the indexer returns no rows", async () => {
|
||||||
|
const mp = makeMockMarketplace();
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () =>
|
||||||
|
new Response(JSON.stringify([]), { status: 200, headers: { "Content-Type": "application/json" } })
|
||||||
|
));
|
||||||
|
render(wrap(<TaskHistory />, mp));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("task-history-empty")).toBeTruthy();
|
||||||
|
});
|
||||||
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user