diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6049a59..025093f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,157 +1,306 @@ import { useEffect, useMemo, useState } from "react"; -import { - callSkill, - loadAgentConfig, - loadSession, - sampleArgs, - signInUrl, -} from "./a2a.js"; +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)} +
+ ); +} export function App() { const [config, setConfig] = useState(null); const [session, setSession] = useState(null); - const [selectedSkillName, setSelectedSkillName] = useState(""); - const [argsText, setArgsText] = useState("{}"); + const [comparisonId, setComparisonId] = useState("studio-quote-v1"); + const [quotes, setQuotes] = useState(DEFAULT_QUOTES); + const [weights, setWeights] = useState(DEFAULT_WEIGHTS); 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((data) => { + .then(async (data) => { setConfig(data); - const firstSkill = data.skills?.[0]; - if (firstSkill) { - setSelectedSkillName(firstSkill.name); - setArgsText(JSON.stringify(sampleArgs(firstSkill), null, 2)); + setStatus(null); + try { + setSession(await loadSession(data)); + } catch { + setSession({ authenticated: false }); } - return loadSession(data).catch(() => null); }) - .then((sessionData) => setSession(sessionData)) - .catch((err) => setError(err.message || String(err))); + .catch((err) => { + setError(err.message || String(err)); + setStatus(null); + }); }, []); - const selectedSkill = useMemo( - () => config?.skills?.find((skill) => skill.name === selectedSkillName), - [config, selectedSkillName], - ); + 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 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 updateQuote(index, field, value) { + setQuotes((items) => items.map((quote, i) => (i === index ? { ...quote, [field]: value } : quote))); } - async function runSkill(event) { + 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) { event.preventDefault(); - if (!config || !selectedSkill) return; + if (!config) return; + const cleanQuotes = normalizedQuotes(); + const validation = validateForm(comparisonId, cleanQuotes, weights); + if (validation) { + setError(validation); + return; + } setRunning(true); setError(null); - setResult(null); + setStatus("Comparing quotes and saving recommendation..."); try { - const args = JSON.parse(argsText || "{}"); - const data = await callSkill(config, selectedSkill.name, args); + 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) }, + }); + } 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 ( -
-

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

-
- ); + return

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

; } return ( -
- - -
-
-
-

Skill runner

-

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

+
+
+
+

1 · Input

Quotes

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