diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6049a59..dcd07a3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,158 +1,277 @@ import { useEffect, useMemo, useState } from "react"; import { callSkill, + fileToBrowserDocument, loadAgentConfig, loadSession, - sampleArgs, + maybeAuthorize, signInUrl, } from "./a2a.js"; +const MAX_FILE_BYTES = 64 * 1024; +const MAX_FILES = 4; +const ACCEPT = ".json,.csv,.txt,application/json,text/csv,text/plain"; + +const SUCCESS_FIXTURE = [ + { vendor: "Acme Bearings", quantity: 500, unit_price: 10.75, delivery_days: 12, warranty_months: 12 }, + { vendor: "Beta Industrial", quantity: 500, unit_price: 9.1, delivery_days: 8, warranty_months: 18 }, +]; + +const EMPTY_QUOTE = { vendor: "", quantity: 1, unit_price: 0, delivery_days: 0, warranty_months: 0 }; +const DEFAULT_WEIGHTS = { price: 50, delivery: 30, warranty: 20 }; + export function App() { const [config, setConfig] = useState(null); const [session, setSession] = useState(null); - const [selectedSkillName, setSelectedSkillName] = useState(""); - const [argsText, setArgsText] = useState("{}"); + const [loadError, setLoadError] = useState(null); + const [comparisonId, setComparisonId] = useState("studio-quote-v1"); + const [quotes, setQuotes] = useState(SUCCESS_FIXTURE); + const [weights, setWeights] = useState(DEFAULT_WEIGHTS); + const [files, setFiles] = useState([]); const [result, setResult] = useState(null); + const [historyResult, setHistoryResult] = useState(null); const [error, setError] = useState(null); const [running, setRunning] = useState(false); 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)); + 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) => setLoadError(err.message || String(err))); }, []); - 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 totalWeight = Number(weights.price || 0) + Number(weights.delivery || 0) + Number(weights.warranty || 0); - 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((current) => current.map((quote, i) => (i === index ? { ...quote, [field]: value } : quote))); } - async function runSkill(event) { + function addQuote() { + if (quotes.length < 10) setQuotes((current) => [...current, { ...EMPTY_QUOTE }]); + } + + function removeQuote(index) { + setQuotes((current) => current.filter((_, i) => i !== index)); + } + + function normalizeQuotes() { + return quotes.map((quote) => ({ + vendor: String(quote.vendor || "").trim(), + quantity: Number(quote.quantity), + unit_price: Number(quote.unit_price), + delivery_days: Number(quote.delivery_days), + warranty_months: Number(quote.warranty_months), + })); + } + + async function runCompare(event) { event.preventDefault(); - if (!config || !selectedSkill) return; + if (!config) return; + if (needsSignIn) return maybeAuthorize(config); setRunning(true); setError(null); setResult(null); try { - const args = JSON.parse(argsText || "{}"); - const data = await callSkill(config, selectedSkill.name, args); + let data; + if (files.length) { + const docs = await Promise.all(files.map(fileToBrowserDocument)); + data = await callSkill(config, "compare_quotes_from_browser_upload", { + comparison_id: comparisonId, + documents: docs, + weights: coerceWeights(weights), + }); + } else { + data = await callSkill(config, "compare_quotes", { + comparison_id: comparisonId, + quotes: normalizeQuotes(), + weights: coerceWeights(weights), + }); + } + if (!data.ok) setError(data.code || "Quote comparison failed validation."); setResult(data); } catch (err) { + if (err.status === 401) return maybeAuthorize(config); setError(err.message || String(err)); } finally { setRunning(false); } } - if (!config) { - return ( -
-

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

-
- ); + async function reloadComparison() { + if (!config) return; + if (needsSignIn) return maybeAuthorize(config); + setRunning(true); + setError(null); + try { + const data = await callSkill(config, "get_comparison", { comparison_id: comparisonId }); + if (!data.ok) setError(data.code || "Comparison not found."); + setHistoryResult(data); + if (data.ok) setResult(data); + } catch (err) { + if (err.status === 401) return maybeAuthorize(config); + setError(err.message || String(err)); + } finally { + setRunning(false); + } } + function useFixture(kind) { + setFiles([]); + setComparisonId(kind === "failure" ? "studio-quote-invalid" : "studio-quote-v1"); + setQuotes(kind === "failure" ? [ + { vendor: "Only Vendor", quantity: 1, unit_price: 10, delivery_days: 5, warranty_months: 6 }, + ] : SUCCESS_FIXTURE); + setWeights(DEFAULT_WEIGHTS); + setResult(null); + setHistoryResult(null); + setError(null); + } + + function onFilesSelected(event) { + const picked = Array.from(event.target.files || []); + const tooLarge = picked.find((file) => file.size > MAX_FILE_BYTES); + if (picked.length > MAX_FILES) return setError(`Upload at most ${MAX_FILES} files.`); + if (tooLarge) return setError(`${tooLarge.name} exceeds 64 KiB.`); + setFiles(picked); + setError(null); + } + + if (loadError) return

{loadError}

; + if (!config) return

Loading QuoteJudge...

; + return (
- - -
-
+
+
+
+ {session?.authenticated ? "Signed in" : "Signed out"} + {needsSignIn && signInHref ? Sign in to run : null} +
+ +
+
+

Weighted vendor quote decisions

+

Pick the best quote, save the evidence, reopen it later.

+

+ Upload or paste supplier quotes, tune price/delivery/warranty weights, and persist a per-user production receipt without sending secrets to the browser. +

+
+ + + +
+
+
+ Current recommendation + {result?.recommendation?.vendor || "Run a comparison"} +

{result?.recommendation?.rationale || "Beta wins the bundled fixture because it is cheaper, faster, and has a longer warranty."}

+
+
+
- {selectedSkill ? ( -
-