a2a-source-edit: write frontend/src/App.jsx
This commit is contained in:
@@ -1,66 +1,114 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
callSkill,
|
callSkill,
|
||||||
|
fileToBrowserDocument,
|
||||||
loadAgentConfig,
|
loadAgentConfig,
|
||||||
loadSession,
|
loadSession,
|
||||||
sampleArgs,
|
|
||||||
signInUrl,
|
signInUrl,
|
||||||
|
successFixture,
|
||||||
} from "./a2a.js";
|
} from "./a2a.js";
|
||||||
|
|
||||||
|
const emptyQuote = { vendor: "", unit_price: 0, quantity: 1, delivery_days: 1, warranty_months: 0 };
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [config, setConfig] = useState(null);
|
const [config, setConfig] = useState(null);
|
||||||
const [session, setSession] = useState(null);
|
const [session, setSession] = useState(null);
|
||||||
const [selectedSkillName, setSelectedSkillName] = useState("");
|
const [comparisonId, setComparisonId] = useState("studio-quote-v1");
|
||||||
const [argsText, setArgsText] = useState("{}");
|
const [quotes, setQuotes] = useState(successFixture().quotes);
|
||||||
|
const [weights, setWeights] = useState(successFixture().weights);
|
||||||
|
const [documents, setDocuments] = useState([]);
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
|
const [history, setHistory] = useState([]);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAgentConfig()
|
loadAgentConfig()
|
||||||
.then((data) => {
|
.then(async (data) => {
|
||||||
setConfig(data);
|
setConfig(data);
|
||||||
const firstSkill = data.skills?.[0];
|
try {
|
||||||
if (firstSkill) {
|
setSession(await loadSession(data));
|
||||||
setSelectedSkillName(firstSkill.name);
|
} catch {
|
||||||
setArgsText(JSON.stringify(sampleArgs(firstSkill), null, 2));
|
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)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectedSkill = useMemo(
|
const needsSignIn = Boolean(config?.auth?.invokeRequiresSession && session && !session.authenticated);
|
||||||
() => config?.skills?.find((skill) => skill.name === selectedSkillName),
|
const signInHref = useMemo(() => (config ? signInUrl(config) : null), [config]);
|
||||||
[config, selectedSkillName],
|
const totalWeight = Number(weights.price) + Number(weights.delivery) + Number(weights.warranty);
|
||||||
);
|
|
||||||
|
|
||||||
const needsSignIn = Boolean(
|
function setQuote(index, field, value) {
|
||||||
config?.auth?.invokeRequiresSession && session && !session.authenticated,
|
setQuotes((items) => items.map((quote, i) => (i === index ? { ...quote, [field]: value } : quote)));
|
||||||
);
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runSkill(event) {
|
function normalizeQuote(quote) {
|
||||||
|
return {
|
||||||
|
vendor: quote.vendor.trim(),
|
||||||
|
unit_price: Number(quote.unit_price),
|
||||||
|
quantity: Number.parseInt(quote.quantity, 10),
|
||||||
|
delivery_days: Number.parseInt(quote.delivery_days, 10),
|
||||||
|
warranty_months: Number.parseInt(quote.warranty_months, 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWeights() {
|
||||||
|
return {
|
||||||
|
price: Number(weights.price),
|
||||||
|
delivery: Number(weights.delivery),
|
||||||
|
warranty: Number(weights.warranty),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
if (needsSignIn) return "Sign in to run QuoteJudge.";
|
||||||
|
if (!comparisonId.trim()) return "Comparison ID is required.";
|
||||||
|
if (documents.length === 0 && quotes.length < 2) return "Enter at least two quotes.";
|
||||||
|
if (totalWeight <= 0) return "At least one weight must be greater than zero.";
|
||||||
|
for (const quote of quotes) {
|
||||||
|
if (documents.length === 0 && !quote.vendor.trim()) return "Every quote needs a vendor name.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runComparison(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!config || !selectedSkill) return;
|
if (!config) return;
|
||||||
|
const problem = validate();
|
||||||
|
if (problem) {
|
||||||
|
setError(problem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
try {
|
try {
|
||||||
const args = JSON.parse(argsText || "{}");
|
const skill = documents.length ? "compare_quotes_browser_upload" : "compare_quotes";
|
||||||
const data = await callSkill(config, selectedSkill.name, args);
|
const args = documents.length
|
||||||
|
? { comparison_id: comparisonId.trim(), weights: normalizeWeights(), documents }
|
||||||
|
: { comparison_id: comparisonId.trim(), quotes: quotes.map(normalizeQuote), weights: normalizeWeights() };
|
||||||
|
const data = await callSkill(config, skill, args);
|
||||||
|
if (!data?.ok) throw new Error(data?.message || data?.code || "Comparison failed.");
|
||||||
setResult(data);
|
setResult(data);
|
||||||
|
setHistory((items) => [data, ...items.filter((item) => item.comparison_id !== data.comparison_id)].slice(0, 5));
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 401 && signInHref) setError("Session expired. Sign in again to continue.");
|
||||||
|
else setError(err.message || String(err));
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadComparison(id = comparisonId) {
|
||||||
|
if (!config) return;
|
||||||
|
setRunning(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await callSkill(config, "get_comparison", { comparison_id: id.trim() });
|
||||||
|
if (!data?.ok) throw new Error(data?.message || data?.code || "No saved comparison found.");
|
||||||
|
setResult(data);
|
||||||
|
setHistory((items) => [data, ...items.filter((item) => item.comparison_id !== data.comparison_id)].slice(0, 5));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || String(err));
|
setError(err.message || String(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -68,90 +116,134 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onFilesSelected(event) {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const docs = [];
|
||||||
|
for (const file of files.slice(0, 3)) docs.push(await fileToBrowserDocument(file));
|
||||||
|
setDocuments(docs);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || String(err));
|
||||||
|
setDocuments([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return (
|
return <main className="loading"><p>{error || "Loading QuoteJudge Studio..."}</p></main>;
|
||||||
<main className="loading">
|
|
||||||
<p>{error || "Loading A2A agent contract..."}</p>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app-shell">
|
<main className="app-shell">
|
||||||
<aside className="sidebar">
|
<section className="hero">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">A2A packed app</p>
|
<p className="eyebrow">QuoteJudge Studio · {config.agent.version}</p>
|
||||||
<h1>quote-judge-studio-v1</h1>
|
<h1>Choose the best vendor quote with auditable weights.</h1>
|
||||||
<p className="agent-meta">
|
<p>
|
||||||
{config.agent.name} v{config.agent.version}
|
Paste quotes or upload a small CSV/JSON file, balance price, delivery, and warranty,
|
||||||
|
then persist the recommendation in managed Postgres for your signed-in user.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="session-card">
|
||||||
<nav className="skill-list" aria-label="Agent skills">
|
<span>{session?.authenticated ? "Signed in" : "Signed out"}</span>
|
||||||
{(config.skills || []).map((skill) => (
|
<strong>{session?.user?.email || session?.org?.slug || "A2A Cloud user"}</strong>
|
||||||
<button
|
{needsSignIn && signInHref ? <a href={signInHref}>Sign in to run</a> : null}
|
||||||
className={skill.name === selectedSkillName ? "skill active" : "skill"}
|
|
||||||
key={skill.name}
|
|
||||||
onClick={() => selectSkill(skill)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span>{skill.name}</span>
|
|
||||||
<small>{skill.stream ? "streaming" : "request"}</small>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="session">
|
|
||||||
<span>{session?.authenticated ? "Signed in" : needsSignIn ? "Signed out" : "Local session"}</span>
|
|
||||||
<strong>{session?.user?.email || session?.org?.slug || "dev@example.local"}</strong>
|
|
||||||
{/* 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 ? (
|
|
||||||
<a className="signin" href={signInHref}>Sign in to run</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section className="workbench">
|
|
||||||
<header className="toolbar">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Skill runner</p>
|
|
||||||
<h2>{selectedSkill?.name || "No skills found"}</h2>
|
|
||||||
</div>
|
|
||||||
<a href={config.docs.base} rel="noreferrer" target="_blank">
|
|
||||||
Docs
|
|
||||||
</a>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{selectedSkill ? (
|
|
||||||
<form className="runner" onSubmit={runSkill}>
|
|
||||||
<label>
|
|
||||||
Arguments JSON
|
|
||||||
<textarea
|
|
||||||
spellCheck="false"
|
|
||||||
value={argsText}
|
|
||||||
onChange={(event) => setArgsText(event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button disabled={running} type="submit">
|
|
||||||
{running ? "Running..." : `Run ${selectedSkill.name}`}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<p className="empty">Add a `@a2a.tool` to your agent to make it callable here.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section className="panels">
|
|
||||||
<div className="panel">
|
|
||||||
<h3>Input schema</h3>
|
|
||||||
<pre>{JSON.stringify(selectedSkill?.input_schema || {}, null, 2)}</pre>
|
|
||||||
</div>
|
|
||||||
<div className="panel">
|
|
||||||
<h3>Result</h3>
|
|
||||||
<pre>{error || JSON.stringify(result, null, 2) || "No run yet."}</pre>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<form className="workspace" onSubmit={runComparison}>
|
||||||
|
<section className="card input-card">
|
||||||
|
<div className="row-between">
|
||||||
|
<h2>1. Quote inputs</h2>
|
||||||
|
<button type="button" onClick={() => { const f = successFixture(); setComparisonId(f.comparison_id); setQuotes(f.quotes); setWeights(f.weights); setDocuments([]); }}>
|
||||||
|
Load fixture
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Comparison ID
|
||||||
|
<input value={comparisonId} onChange={(event) => setComparisonId(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="upload-box">
|
||||||
|
<label htmlFor="quote-file">Upload CSV/JSON instead</label>
|
||||||
|
<input id="quote-file" type="file" accept=".csv,.json,text/csv,text/plain,application/json" multiple onChange={onFilesSelected} />
|
||||||
|
<small>Browser bridge sends bounded base64 JSON; API clients can use typed FileUpload.</small>
|
||||||
|
{documents.length ? <p>{documents.length} file(s) ready. Manual quote rows will be ignored for this run.</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="quotes-list">
|
||||||
|
{quotes.map((quote, index) => (
|
||||||
|
<fieldset key={index}>
|
||||||
|
<legend>Quote {index + 1}</legend>
|
||||||
|
<label>Vendor<input value={quote.vendor} onChange={(e) => setQuote(index, "vendor", e.target.value)} /></label>
|
||||||
|
<label>Unit price<input type="number" step="0.01" value={quote.unit_price} onChange={(e) => setQuote(index, "unit_price", e.target.value)} /></label>
|
||||||
|
<label>Quantity<input type="number" value={quote.quantity} onChange={(e) => setQuote(index, "quantity", e.target.value)} /></label>
|
||||||
|
<label>Delivery days<input type="number" value={quote.delivery_days} onChange={(e) => setQuote(index, "delivery_days", e.target.value)} /></label>
|
||||||
|
<label>Warranty months<input type="number" value={quote.warranty_months} onChange={(e) => setQuote(index, "warranty_months", e.target.value)} /></label>
|
||||||
|
</fieldset>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="button-row">
|
||||||
|
<button type="button" onClick={() => setQuotes((items) => [...items, { ...emptyQuote }])}>Add quote</button>
|
||||||
|
<button type="button" onClick={() => setQuotes((items) => items.slice(0, Math.max(2, items.length - 1)))}>Remove last</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card weight-card">
|
||||||
|
<h2>2. Weights</h2>
|
||||||
|
{Object.keys(weights).map((key) => (
|
||||||
|
<label className="slider" key={key}>
|
||||||
|
<span>{key} ({weights[key]})</span>
|
||||||
|
<input type="range" min="0" max="100" value={weights[key]} onChange={(e) => setWeights((w) => ({ ...w, [key]: Number(e.target.value) }))} />
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<p className={totalWeight > 0 ? "hint" : "error-text"}>Total weight: {totalWeight}</p>
|
||||||
|
<button disabled={running || needsSignIn} type="submit">{running ? "Running..." : "Compare and save"}</button>
|
||||||
|
<button disabled={running || needsSignIn} type="button" onClick={() => reloadComparison()}>Reopen saved result</button>
|
||||||
|
{error ? <p className="error-text" role="alert">{error}</p> : null}
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section className="results-grid">
|
||||||
|
<article className="card result-card">
|
||||||
|
<h2>Recommendation</h2>
|
||||||
|
{!result ? <p className="empty">No result yet. Run the fixture to produce the acceptance receipt.</p> : (
|
||||||
|
<>
|
||||||
|
<div className="winner">
|
||||||
|
<span>Winner</span>
|
||||||
|
<strong>{result.recommendation?.vendor}</strong>
|
||||||
|
</div>
|
||||||
|
<p>{result.recommendation?.rationale}</p>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Score</dt><dd>{result.recommendation?.score}</dd></div>
|
||||||
|
<div><dt>Total</dt><dd>{result.recommendation?.total_price}</dd></div>
|
||||||
|
<div><dt>Delivery</dt><dd>{result.recommendation?.delivery_days} days</dd></div>
|
||||||
|
<div><dt>Warranty</dt><dd>{result.recommendation?.warranty_months} months</dd></div>
|
||||||
|
</dl>
|
||||||
|
<h3>Ranked quotes</h3>
|
||||||
|
<ol>
|
||||||
|
{(result.ranked_quotes || []).map((quote) => <li key={quote.vendor}>{quote.vendor}: {quote.score} pts</li>)}
|
||||||
|
</ol>
|
||||||
|
<h3>Execution receipt</h3>
|
||||||
|
<code>{result.execution_receipt?.payload_sha256 || "persisted by gateway/runtime"}</code>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<aside className="card history-card">
|
||||||
|
<h2>Recent reopen list</h2>
|
||||||
|
{history.length === 0 ? <p className="empty">Saved comparisons appear here after a run or reload.</p> : history.map((item) => (
|
||||||
|
<button type="button" key={item.comparison_id} onClick={() => reloadComparison(item.comparison_id)}>
|
||||||
|
<span>{item.comparison_id}</span>
|
||||||
|
<strong>{item.recommendation?.vendor}</strong>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<details>
|
||||||
|
<summary>Live backend contract</summary>
|
||||||
|
<pre>{JSON.stringify((config.skills || []).map((skill) => ({ name: skill.name, input_schema: skill.input_schema })), null, 2)}</pre>
|
||||||
|
</details>
|
||||||
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user