a2a-source-edit: write frontend/src/App.jsx

This commit is contained in:
a2a-cloud
2026-07-18 07:38:52 +00:00
parent 36cb166d6d
commit f5043d37aa

View File

@@ -1,41 +1,37 @@
import { useEffect, useMemo, useState } from "react";
import {
callSkill,
loadAgentConfig,
loadSession,
sampleArgs,
signInUrl,
} from "./a2a.js";
import { callSkill, loadAgentConfig, loadSession, signInUrl } from "./a2a.js";
const SUCCESS_FIXTURE =
"This Agreement renews automatically on 2026-12-31 unless either party gives at least 60 days written notice. Invoices are due 30 days after receipt.";
const FAILURE_FIXTURE = "Agreement renews sometime next spring.";
const MAX_UPLOAD_BYTES = 512000;
const ACCEPTED_TYPES = ["text/plain", "text/markdown", "application/octet-stream", ""];
export function App() {
const [config, setConfig] = useState(null);
const [session, setSession] = useState(null);
const [selectedSkillName, setSelectedSkillName] = useState("");
const [argsText, setArgsText] = useState("{}");
const [contractId, setContractId] = useState("studio-contract-v1");
const [title, setTitle] = useState("Studio Renewal Agreement");
const [text, setText] = useState(SUCCESS_FIXTURE);
const [selectedFile, setSelectedFile] = useState(null);
const [result, setResult] = useState(null);
const [reloadResult, setReloadResult] = useState(null);
const [historyId, setHistoryId] = useState("studio-contract-v1");
const [error, setError] = useState(null);
const [running, setRunning] = useState(false);
const [loadingSession, setLoadingSession] = useState(true);
useEffect(() => {
loadAgentConfig()
.then((data) => {
setConfig(data);
const firstSkill = data.skills?.[0];
if (firstSkill) {
setSelectedSkillName(firstSkill.name);
setArgsText(JSON.stringify(sampleArgs(firstSkill), null, 2));
}
return loadSession(data).catch(() => null);
})
.then((sessionData) => setSession(sessionData))
.catch((err) => setError(err.message || String(err)));
.catch((err) => setError(err.message || String(err)))
.finally(() => setLoadingSession(false));
}, []);
const selectedSkill = useMemo(
() => config?.skills?.find((skill) => skill.name === selectedSkillName),
[config, selectedSkillName],
);
const needsSignIn = Boolean(
config?.auth?.invokeRequiresSession && session && !session.authenticated,
);
@@ -44,23 +40,30 @@ export function App() {
[config, needsSignIn],
);
function selectSkill(skill) {
setSelectedSkillName(skill.name);
setArgsText(JSON.stringify(sampleArgs(skill), null, 2));
setResult(null);
setError(null);
}
async function runSkill(event) {
async function analyze(event) {
event.preventDefault();
if (!config || !selectedSkill) return;
if (!config || running) return;
setRunning(true);
setError(null);
setResult(null);
try {
const args = JSON.parse(argsText || "{}");
const data = await callSkill(config, selectedSkill.name, args);
let data;
if (selectedFile) {
const document = await fileToBrowserDocument(selectedFile);
data = await callSkill(config, "analyze_contract_upload", {
contract_id: contractId.trim(),
title: title.trim() || selectedFile.name,
document,
});
} else {
data = await callSkill(config, "analyze_contract", {
contract_id: contractId.trim(),
title: title.trim(),
text,
});
}
setResult(data);
if (data?.ok) setHistoryId(data.contract_id);
} catch (err) {
setError(err.message || String(err));
} finally {
@@ -68,91 +71,234 @@ export function App() {
}
}
async function reloadTimeline(event) {
event.preventDefault();
if (!config || running) return;
setRunning(true);
setError(null);
setReloadResult(null);
try {
const data = await callSkill(config, "get_contract_deadlines", {
contract_id: historyId.trim(),
});
setReloadResult(data);
} catch (err) {
setError(err.message || String(err));
} finally {
setRunning(false);
}
}
function loadFailureFixture() {
setContractId("studio-contract-invalid");
setTitle("Ambiguous Agreement");
setText(FAILURE_FIXTURE);
setSelectedFile(null);
setResult(null);
}
function loadSuccessFixture() {
setContractId("studio-contract-v1");
setTitle("Studio Renewal Agreement");
setText(SUCCESS_FIXTURE);
setSelectedFile(null);
setResult(null);
}
if (!config) {
return (
<main className="loading">
<p>{error || "Loading A2A agent contract..."}</p>
<p>{error || "Loading ContractClock Studio..."}</p>
</main>
);
}
return (
<main className="app-shell">
<aside className="sidebar">
<main className="studio-shell">
<section className="hero">
<div>
<p className="eyebrow">A2A packed app</p>
<h1>contract-clock-studio-v1</h1>
<p className="agent-meta">
{config.agent.name} v{config.agent.version}
<p className="eyebrow">ContractClock Studio</p>
<h1>Extract explicit renewal and notice deadlines.</h1>
<p className="lede">
Paste or upload a contract, save dated obligations to user-scoped
managed Postgres, then reopen the deadline timeline later from the
same A2A backend.
</p>
<div className="meta-row">
<span>{config.agent.name}</span>
<span>v{config.agent.version}</span>
<span>{session?.authenticated ? "Signed in" : "Session required to run"}</span>
</div>
</div>
<nav className="skill-list" aria-label="Agent skills">
{(config.skills || []).map((skill) => (
<button
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. */}
<aside className="auth-card">
<strong>{session?.user?.email || session?.org?.slug || "A2A Cloud user"}</strong>
<p>
{loadingSession
? "Checking session..."
: session?.authenticated
? "Your timelines are partitioned to your platform user."
: "Sign in to analyze or reload saved contracts."}
</p>
{needsSignIn && signInHref ? (
<a className="signin" href={signInHref}>Sign in to run</a>
<a className="signin" href={signInHref}>Sign in to run ContractClock</a>
) : null}
</div>
</aside>
</aside>
</section>
<section className="workbench">
<header className="toolbar">
<div>
<p className="eyebrow">Skill runner</p>
<h2>{selectedSkill?.name || "No skills found"}</h2>
<section className="grid">
<form className="card form-card" onSubmit={analyze}>
<div className="card-header">
<div>
<p className="eyebrow">Workflow</p>
<h2>Analyze contract</h2>
</div>
<div className="button-row">
<button type="button" className="ghost" onClick={loadSuccessFixture}>Success fixture</button>
<button type="button" className="ghost" onClick={loadFailureFixture}>Ambiguous fixture</button>
</div>
</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>
)}
<label>
Contract ID
<input
value={contractId}
maxLength={120}
required
onChange={(event) => setContractId(event.target.value)}
/>
</label>
<label>
Title
<input
value={title}
maxLength={200}
required
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label>
Contract text
<textarea
value={text}
maxLength={60000}
disabled={Boolean(selectedFile)}
onChange={(event) => setText(event.target.value)}
/>
</label>
<label className="file-box">
Optional text upload
<input
type="file"
accept=".txt,.md,text/plain,text/markdown,application/octet-stream"
onChange={(event) => {
setError(null);
const file = event.target.files?.[0] || null;
if (file && !ACCEPTED_TYPES.includes(file.type)) {
setError("Upload a UTF-8 text or markdown file.");
event.target.value = "";
return;
}
if (file && file.size > MAX_UPLOAD_BYTES) {
setError("Contract uploads are limited to 512 KB.");
event.target.value = "";
return;
}
setSelectedFile(file);
}}
/>
{selectedFile ? <small>{selectedFile.name} ({selectedFile.size} bytes)</small> : null}
</label>
<button disabled={running || needsSignIn} type="submit">
{running ? "Working..." : selectedFile ? "Analyze upload" : "Analyze pasted text"}
</button>
</form>
<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>
<section className="card result-card" aria-live="polite">
<div className="card-header">
<div>
<p className="eyebrow">Result</p>
<h2>Deadline timeline</h2>
</div>
</div>
{error ? <div className="alert">{error}</div> : null}
<Timeline result={result} empty="No analysis run yet." />
</section>
</section>
<section className="card reload-card">
<form onSubmit={reloadTimeline}>
<div>
<p className="eyebrow">Persistence / MCP-safe read</p>
<h2>Reopen saved timeline</h2>
<p>
Calls <code>get_contract_deadlines</code>, the same typed tool exposed to MCP.
</p>
</div>
<label>
Saved contract ID
<input
value={historyId}
maxLength={120}
required
onChange={(event) => setHistoryId(event.target.value)}
/>
</label>
<button disabled={running || needsSignIn} type="submit">Reload timeline</button>
</form>
<Timeline result={reloadResult} empty="Reload a contract after a successful analysis." />
</section>
</main>
);
}
function Timeline({ result, empty }) {
if (!result) return <p className="empty">{empty}</p>;
if (!result.ok) {
return (
<div className="failure">
<strong>{result.code || "error"}</strong>
<p>{result.message || "ContractClock could not extract a supported explicit deadline."}</p>
<pre>{JSON.stringify(result, null, 2)}</pre>
</div>
);
}
return (
<div className="timeline">
<h3>{result.title || result.contract_id}</h3>
<ol>
{(result.deadlines || []).map((deadline, index) => (
<li key={`${deadline.kind}-${deadline.date}-${index}`}>
<span>{deadline.kind}</span>
<time dateTime={deadline.date}>{deadline.date}</time>
</li>
))}
</ol>
{result.rationale ? <p>{result.rationale}</p> : null}
{result.receipt?.input_hash ? (
<small>Receipt persisted · {result.receipt.input_hash.slice(0, 16)}</small>
) : null}
<details>
<summary>Structured response</summary>
<pre>{JSON.stringify(result, null, 2)}</pre>
</details>
</div>
);
}
async function fileToBrowserDocument(file) {
if (file.size > MAX_UPLOAD_BYTES) {
throw new Error("Contract uploads are limited to 512 KB.");
}
if (!ACCEPTED_TYPES.includes(file.type)) {
throw new Error("Upload a UTF-8 text or markdown file.");
}
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
return {
filename: file.name,
media_type: file.type || "application/octet-stream",
data_base64: btoa(binary),
};
}