diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6049a59..5c31913 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,157 +1,339 @@ import { useEffect, useMemo, useState } from "react"; -import { - callSkill, - loadAgentConfig, - loadSession, - sampleArgs, - signInUrl, -} from "./a2a.js"; +import { callSkill, fileToBrowserDocument, loadAgentConfig, loadSession, signInUrl } from "./a2a.js"; + +const DEFAULT_QUOTES = [ + { 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 DEFAULT_WEIGHTS = { price: 50, delivery: 30, warranty: 20 }; + +function blankQuote() { + return { vendor: "", quantity: 1, unit_price: 0, delivery_days: 0, warranty_months: 0 }; +} + +function normalizeQuote(quote) { + return { + 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), + }; +} + +function parsePastedQuotes(text) { + const trimmed = text.trim(); + if (!trimmed) return []; + try { + const parsed = JSON.parse(trimmed); + const rows = Array.isArray(parsed) ? parsed : parsed.quotes; + if (Array.isArray(rows)) return rows.map(normalizeQuote); + } catch { + // Fall through to CSV-ish parsing. + } + const lines = trimmed.split(/\r?\n/).filter(Boolean); + const hasHeader = /vendor|supplier/i.test(lines[0] || ""); + const rows = hasHeader ? lines.slice(1) : lines; + return rows.map((line) => { + const parts = line.split(/[,;|\t]/).map((part) => part.trim()); + return normalizeQuote({ + vendor: parts[0], + quantity: parts[1], + unit_price: parts[2], + delivery_days: parts[3], + warranty_months: parts[4], + }); + }); +} + +function validateQuotes(quotes, weights) { + const complete = quotes.map(normalizeQuote).filter((quote) => quote.vendor); + if (complete.length < 2) return "Add at least two vendor quotes before comparing."; + for (const quote of complete) { + if (!Number.isFinite(quote.quantity) || quote.quantity <= 0) return `${quote.vendor}: quantity must be greater than 0.`; + if (!Number.isFinite(quote.unit_price) || quote.unit_price < 0) return `${quote.vendor}: unit price must be 0 or greater.`; + if (!Number.isFinite(quote.delivery_days) || quote.delivery_days < 0) return `${quote.vendor}: delivery days must be 0 or greater.`; + if (!Number.isFinite(quote.warranty_months) || quote.warranty_months < 0) return `${quote.vendor}: warranty must be 0 or greater.`; + } + const totalWeight = Number(weights.price) + Number(weights.delivery) + Number(weights.warranty); + if (!Number.isFinite(totalWeight) || totalWeight <= 0) return "Set at least one positive weight."; + return null; +} 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(DEFAULT_QUOTES); + const [weights, setWeights] = useState(DEFAULT_WEIGHTS); + const [pasteText, setPasteText] = useState(""); const [result, setResult] = useState(null); - const [error, setError] = useState(null); + const [history, setHistory] = useState([]); + const [status, setStatus] = useState(""); + const [error, setError] = useState(""); 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], - ); + useEffect(() => { + try { + const saved = JSON.parse(window.localStorage.getItem("quotejudge.history") || "[]"); + if (Array.isArray(saved)) setHistory(saved.slice(0, 8)); + } catch { + setHistory([]); + } + }, []); - const needsSignIn = Boolean( - config?.auth?.invokeRequiresSession && session && !session.authenticated, - ); - const signInHref = useMemo( - () => (config && needsSignIn ? signInUrl(config) : null), - [config, needsSignIn], - ); + const needsSignIn = Boolean(config?.auth?.invokeRequiresSession && session && !session.authenticated); + const authHref = useMemo(() => (config ? signInUrl(config) : null), [config]); + const validation = validateQuotes(quotes, weights); + const totalWeight = Number(weights.price) + Number(weights.delivery) + Number(weights.warranty); - 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) { - event.preventDefault(); - if (!config || !selectedSkill) return; - setRunning(true); - setError(null); - setResult(null); + function remember(id) { + const next = [id, ...history.filter((item) => item !== id)].slice(0, 8); + setHistory(next); + window.localStorage.setItem("quotejudge.history", JSON.stringify(next)); + } + + function applyPastedQuotes() { + setError(""); try { - const args = JSON.parse(argsText || "{}"); - const data = await callSkill(config, selectedSkill.name, args); - setResult(data); + const parsed = parsePastedQuotes(pasteText); + if (parsed.length < 2) { + setError("Paste JSON, CSV, or pipe-separated text with at least two quotes."); + return; + } + setQuotes(parsed); + setStatus(`Loaded ${parsed.length} pasted quotes.`); } catch (err) { - setError(err.message || String(err)); + setError(err.message || "Could not parse pasted quotes."); + } + } + + async function compare(event) { + event.preventDefault(); + if (!config) return; + setError(""); + setStatus(""); + const message = validateQuotes(quotes, weights); + if (message) { + setError(message); + return; + } + setRunning(true); + try { + const data = await callSkill(config, "compare_quotes", { + comparison_id: comparisonId, + quotes: quotes.map(normalizeQuote).filter((quote) => quote.vendor), + weights: { + price: Number(weights.price), + delivery: Number(weights.delivery), + warranty: Number(weights.warranty), + }, + }); + setResult(data); + if (data.ok) { + remember(data.comparison_id); + setStatus("Comparison saved. You can refresh and reopen it by ID."); + } else { + setError(data.message || data.code || "Comparison failed."); + } + } catch (err) { + if (err.status === 401 && authHref) setError("Session expired. Sign in again to run QuoteJudge."); + else setError(err.message || String(err)); } finally { setRunning(false); } } + async function reopen(id = comparisonId) { + if (!config || !id) return; + setRunning(true); + setError(""); + setStatus(""); + try { + const data = await callSkill(config, "get_comparison", { comparison_id: id }); + setResult(data); + setComparisonId(id); + if (data.ok) { + remember(id); + setStatus("Saved comparison reopened from managed Postgres."); + } else { + setError(data.message || "No saved comparison found for this ID."); + } + } catch (err) { + if (err.status === 401 && authHref) setError("Session expired. Sign in again to reopen comparisons."); + else setError(err.message || String(err)); + } finally { + setRunning(false); + } + } + + async function handleFiles(event) { + if (!config) return; + const files = Array.from(event.target.files || []); + if (!files.length) return; + setRunning(true); + setError(""); + setStatus(""); + try { + const documents = await Promise.all(files.map((file) => fileToBrowserDocument(file))); + const data = await callSkill(config, "compare_uploaded_quotes_browser", { + comparison_id: comparisonId, + documents, + weights: { + price: Number(weights.price), + delivery: Number(weights.delivery), + warranty: Number(weights.warranty), + }, + }); + setResult(data); + if (data.ok) { + setQuotes(data.quotes.map(({ vendor, quantity, unit_price, delivery_days, warranty_months }) => ({ vendor, quantity, unit_price, delivery_days, warranty_months }))); + remember(data.comparison_id); + setStatus("Uploaded quotes parsed, compared, and saved."); + } else { + setError(data.message || data.code || "Upload comparison failed."); + } + } catch (err) { + setError(err.message || String(err)); + } finally { + event.target.value = ""; + setRunning(false); + } + } + if (!config) { - return ( -
-

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

-
- ); + return

{loadError || "Loading QuoteJudge…"}

; } return (
- - -
-
+
+
-

Skill runner

-

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

+ {session?.authenticated ? "Signed in" : "Sign-in required to run"} +

{session?.user?.email || session?.org?.slug || "Platform auth protects saved comparisons."}

- - Docs - -
+ {needsSignIn && authHref ? Sign in : null} + +
- {selectedSkill ? ( -
-