From 7bc37f7f3b807eee17ea22f5d7e4b52a9e1adb8c Mon Sep 17 00:00:00 2001 From: a2a-cloud Date: Sat, 18 Jul 2026 03:10:50 +0000 Subject: [PATCH] a2a-source-edit: write frontend/src/App.jsx --- frontend/src/App.jsx | 371 +++++++++++++------------------------------ 1 file changed, 111 insertions(+), 260 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 025093f..6049a59 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,306 +1,157 @@ import { useEffect, useMemo, useState } from "react"; -import { callSkill, loadAgentConfig, loadSession, signInUrl } from "./a2a.js"; - -const MAX_UPLOAD_BYTES = 64 * 1024; -const DEFAULT_QUOTES = [ - { vendor: "Acme Bearings", unit_price: 10.75, quantity: 500, delivery_days: 12, warranty_months: 12 }, - { vendor: "Beta Industrial", unit_price: 9.1, quantity: 500, delivery_days: 8, warranty_months: 18 }, -]; -const DEFAULT_WEIGHTS = { price: 50, delivery: 30, warranty: 20 }; - -function emptyQuote(index) { - return { vendor: `Vendor ${index + 1}`, unit_price: 0, quantity: 1, delivery_days: 0, warranty_months: 0 }; -} - -function toNumber(value) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; -} - -function csvToQuotes(text) { - const lines = text.split(/\r?\n/).filter((line) => line.trim()); - if (lines.length < 2) throw new Error("CSV needs a header row and at least two quote rows."); - const headers = lines[0].split(",").map((item) => item.trim()); - const required = ["vendor", "unit_price", "quantity", "delivery_days", "warranty_months"]; - for (const key of required) { - if (!headers.includes(key)) throw new Error(`CSV missing ${key} header.`); - } - return lines.slice(1).map((line) => { - const cells = line.split(",").map((item) => item.trim()); - const row = Object.fromEntries(headers.map((header, index) => [header, cells[index] ?? ""])); - return { - vendor: row.vendor, - unit_price: toNumber(row.unit_price), - quantity: Math.trunc(toNumber(row.quantity)), - delivery_days: Math.trunc(toNumber(row.delivery_days)), - warranty_months: Math.trunc(toNumber(row.warranty_months)), - }; - }); -} - -function validateForm(comparisonId, quotes, weights) { - if (!comparisonId.trim()) return "Comparison ID is required."; - if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,79}$/.test(comparisonId.trim())) { - return "Comparison ID must be 1-80 safe characters (letters, numbers, dash, underscore, colon, dot)."; - } - if (quotes.length < 2) return "Add at least two quotes."; - if (quotes.length > 25) return "Compare at most 25 quotes."; - const vendors = new Set(); - for (const quote of quotes) { - if (!quote.vendor.trim()) return "Every quote needs a vendor name."; - const key = quote.vendor.trim().toLowerCase(); - if (vendors.has(key)) return "Vendor names must be unique."; - vendors.add(key); - if (toNumber(quote.unit_price) <= 0) return `${quote.vendor} needs a positive unit price.`; - if (Math.trunc(toNumber(quote.quantity)) <= 0) return `${quote.vendor} needs a positive quantity.`; - if (Math.trunc(toNumber(quote.delivery_days)) < 0) return `${quote.vendor} delivery days cannot be negative.`; - if (Math.trunc(toNumber(quote.warranty_months)) < 0) return `${quote.vendor} warranty cannot be negative.`; - } - if (toNumber(weights.price) + toNumber(weights.delivery) + toNumber(weights.warranty) <= 0) { - return "At least one weight must be greater than zero."; - } - return null; -} - -async function fileToBase64(file) { - const buffer = await file.arrayBuffer(); - let binary = ""; - const bytes = new Uint8Array(buffer); - for (let i = 0; i < bytes.byteLength; i += 1) binary += String.fromCharCode(bytes[i]); - return btoa(binary); -} - -function ScoreBar({ label, value }) { - const safe = Math.max(0, Math.min(100, Number(value) || 0)); - return ( -
- {label} -
- {safe.toFixed(1)} -
- ); -} +import { + callSkill, + loadAgentConfig, + loadSession, + sampleArgs, + signInUrl, +} from "./a2a.js"; export function App() { const [config, setConfig] = useState(null); const [session, setSession] = useState(null); - const [comparisonId, setComparisonId] = useState("studio-quote-v1"); - const [quotes, setQuotes] = useState(DEFAULT_QUOTES); - const [weights, setWeights] = useState(DEFAULT_WEIGHTS); + const [selectedSkillName, setSelectedSkillName] = useState(""); + const [argsText, setArgsText] = useState("{}"); const [result, setResult] = useState(null); - const [status, setStatus] = useState("Loading QuoteJudge..."); const [error, setError] = useState(null); const [running, setRunning] = useState(false); - const [uploadFile, setUploadFile] = useState(null); useEffect(() => { loadAgentConfig() - .then(async (data) => { + .then((data) => { setConfig(data); - setStatus(null); - try { - setSession(await loadSession(data)); - } catch { - setSession({ authenticated: false }); + const firstSkill = data.skills?.[0]; + if (firstSkill) { + setSelectedSkillName(firstSkill.name); + setArgsText(JSON.stringify(sampleArgs(firstSkill), null, 2)); } + return loadSession(data).catch(() => null); }) - .catch((err) => { - setError(err.message || String(err)); - setStatus(null); - }); + .then((sessionData) => setSession(sessionData)) + .catch((err) => setError(err.message || String(err))); }, []); - const needsSignIn = Boolean(config?.auth?.invokeRequiresSession && session && !session.authenticated); - const signInHref = useMemo(() => (config ? signInUrl(config) : null), [config]); - const winner = result?.ok ? result.recommendation : null; + const selectedSkill = useMemo( + () => config?.skills?.find((skill) => skill.name === selectedSkillName), + [config, selectedSkillName], + ); - function updateQuote(index, field, value) { - setQuotes((items) => items.map((quote, i) => (i === index ? { ...quote, [field]: value } : quote))); + const needsSignIn = Boolean( + config?.auth?.invokeRequiresSession && session && !session.authenticated, + ); + const signInHref = useMemo( + () => (config && needsSignIn ? signInUrl(config) : null), + [config, needsSignIn], + ); + + function selectSkill(skill) { + setSelectedSkillName(skill.name); + setArgsText(JSON.stringify(sampleArgs(skill), null, 2)); + setResult(null); + setError(null); } - function normalizedQuotes() { - return quotes.map((quote) => ({ - vendor: quote.vendor.trim(), - unit_price: toNumber(quote.unit_price), - quantity: Math.trunc(toNumber(quote.quantity)), - delivery_days: Math.trunc(toNumber(quote.delivery_days)), - warranty_months: Math.trunc(toNumber(quote.warranty_months)), - })); - } - - async function runCompare(event) { + async function runSkill(event) { event.preventDefault(); - if (!config) return; - const cleanQuotes = normalizedQuotes(); - const validation = validateForm(comparisonId, cleanQuotes, weights); - if (validation) { - setError(validation); - return; - } + if (!config || !selectedSkill) return; setRunning(true); setError(null); - setStatus("Comparing quotes and saving recommendation..."); + setResult(null); try { - let data; - if (uploadFile) { - if (uploadFile.size > MAX_UPLOAD_BYTES) throw new Error(`Upload must be at most ${MAX_UPLOAD_BYTES} bytes.`); - const mediaType = uploadFile.type || (uploadFile.name.endsWith(".json") ? "application/json" : "text/csv"); - if (!["text/csv", "text/plain", "application/json"].includes(mediaType)) { - throw new Error("Upload must be CSV, plain text CSV, or JSON."); - } - data = await callSkill(config, "compare_quotes_upload", { - comparison_id: comparisonId.trim(), - upload: { filename: uploadFile.name, media_type: mediaType, data_base64: await fileToBase64(uploadFile) }, - weights: { price: toNumber(weights.price), delivery: toNumber(weights.delivery), warranty: toNumber(weights.warranty) }, - }); - } else { - data = await callSkill(config, "compare_quotes", { - comparison_id: comparisonId.trim(), - quotes: cleanQuotes, - weights: { price: toNumber(weights.price), delivery: toNumber(weights.delivery), warranty: toNumber(weights.warranty) }, - }); - } + const args = JSON.parse(argsText || "{}"); + const data = await callSkill(config, selectedSkill.name, args); setResult(data); - if (!data.ok) setError(data.message || data.code || "Comparison failed."); - } catch (err) { - if (/401|sign in/i.test(err.message || "")) setError("Session required or expired. Use Sign in to run, then retry."); - else setError(err.message || String(err)); - } finally { - setRunning(false); - setStatus(null); - } - } - - async function reloadComparison() { - if (!config) return; - setRunning(true); - setError(null); - setStatus("Reopening saved comparison..."); - try { - const data = await callSkill(config, "get_comparison", { comparison_id: comparisonId.trim() }); - setResult(data); - if (!data.ok) setError(data.message || data.code || "Not found."); } catch (err) { setError(err.message || String(err)); } finally { setRunning(false); - setStatus(null); - } - } - - async function handleUpload(file) { - setUploadFile(file || null); - setError(null); - if (!file) return; - if (file.size > MAX_UPLOAD_BYTES) { - setError(`Upload must be at most ${MAX_UPLOAD_BYTES} bytes.`); - return; - } - const text = await file.text(); - try { - let parsed; - if ((file.type || "").includes("json") || file.name.endsWith(".json")) { - const raw = JSON.parse(text); - parsed = Array.isArray(raw) ? raw : raw.quotes; - } else { - parsed = csvToQuotes(text); - } - if (Array.isArray(parsed) && parsed.length) setQuotes(parsed); - } catch (err) { - setError(`Preview parse failed: ${err.message || String(err)}. You can still clear the file and paste rows manually.`); } } if (!config) { - return

{status || error || "Loading..."}

; + return ( +
+

{error || "Loading A2A agent contract..."}

+
+ ); } return ( -
-
+
+
-
-
-
-

1 · Input

Quotes

- -
- - - -
- - {uploadFile ? : null} -
- -
-
- VendorUnit priceQtyDelivery daysWarranty months -
- {quotes.map((quote, index) => ( -
- updateQuote(index, "vendor", event.target.value)} /> - updateQuote(index, "unit_price", event.target.value)} /> - updateQuote(index, "quantity", event.target.value)} /> - updateQuote(index, "delivery_days", event.target.value)} /> - updateQuote(index, "warranty_months", event.target.value)} /> - -
- ))} -
-
- -
-

2 · Priorities

Weights

- {Object.keys(weights).map((key) => ( - +
-
+ -
-

3 · Recommendation

{winner ? winner.vendor : "No comparison yet"}

{result?.reloaded ? Reloaded from Postgres : null}
- {result?.ok ? ( -
-
{winner.vendor}{winner.weighted_score.toFixed(1)} weighted score

{winner.rationale}

-
- {result.quotes.map((quote) => )} -
- - - {result.quotes.map((quote) => )} -
VendorTotal priceDeliveryWarrantyWeighted
{quote.vendor}{quote.total_price.toLocaleString(undefined, { style: "currency", currency: "BRL" })}{quote.delivery_days} days{quote.warranty_months} mo{quote.weighted_score.toFixed(1)}
-

Receipt persisted: {String(result.receipt?.persisted)} · comparison_id={result.comparison_id}

+
+ {session?.authenticated ? "Signed in" : needsSignIn ? "Signed out" : "Local session"} + {session?.user?.email || session?.org?.slug || "dev@example.local"} + {/* Skills that spend the caller's LLM budget need a signed-in + visitor even when the page itself is public, so offer the way in + rather than failing at the first click. */} + {needsSignIn && signInHref ? ( + Sign in to run + ) : null} +
+ + +
+
+
+

Skill runner

+

{selectedSkill?.name || "No skills found"}

+ + Docs + +
+ + {selectedSkill ? ( +
+