This commit is contained in:
a2a-platform
2026-06-01 00:28:39 +00:00
commit 4f4da2a7f3
11 changed files with 857 additions and 0 deletions

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>chart-agent app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

19
frontend/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "chart-agent-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^5.4.0"
}
}

143
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,143 @@
import { useEffect, useMemo, useState } from "react";
import {
callSkill,
loadAgentConfig,
loadSession,
sampleArgs,
} from "./a2a.js";
export function App() {
const [config, setConfig] = useState(null);
const [session, setSession] = useState(null);
const [selectedSkillName, setSelectedSkillName] = useState("");
const [argsText, setArgsText] = useState("{}");
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const [running, setRunning] = useState(false);
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)));
}, []);
const selectedSkill = useMemo(
() => config?.skills?.find((skill) => skill.name === selectedSkillName),
[config, selectedSkillName],
);
function selectSkill(skill) {
setSelectedSkillName(skill.name);
setArgsText(JSON.stringify(sampleArgs(skill), null, 2));
setResult(null);
setError(null);
}
async function runSkill(event) {
event.preventDefault();
if (!config || !selectedSkill) return;
setRunning(true);
setError(null);
setResult(null);
try {
const args = JSON.parse(argsText || "{}");
const data = await callSkill(config, selectedSkill.name, args);
setResult(data);
} catch (err) {
setError(err.message || String(err));
} finally {
setRunning(false);
}
}
if (!config) {
return (
<main className="loading">
<p>{error || "Loading A2A agent contract..."}</p>
</main>
);
}
return (
<main className="app-shell">
<aside className="sidebar">
<div>
<p className="eyebrow">A2A packed app</p>
<h1>chart-agent</h1>
<p className="agent-meta">
{config.agent.name} v{config.agent.version}
</p>
</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" : "Local session"}</span>
<strong>{session?.user?.email || session?.org?.slug || "dev@example.local"}</strong>
</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 `@skill` 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>
</section>
</section>
</main>
);
}

65
frontend/src/a2a.js Normal file
View File

@@ -0,0 +1,65 @@
export const CONFIG_URL = import.meta.env.DEV ? "/app/config.json" : "./config.json";
export async function requestJson(url, init = {}) {
const response = await fetch(url, { credentials: "same-origin", ...init });
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (!response.ok) {
const detail = data && (data.detail || data.message);
throw new Error(detail || `request failed: ${response.status}`);
}
return data;
}
export async function loadAgentConfig() {
return requestJson(CONFIG_URL);
}
export async function loadSession(config) {
return requestJson(config.endpoints.session);
}
export async function callSkill(config, skillName, args) {
if (config.auth?.invokeRequiresSession) {
const session = await loadSession(config);
if (!session?.authenticated) {
throw new Error("sign in required");
}
}
return requestJson(
`${config.endpoints.invoke}/${encodeURIComponent(skillName)}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ arguments: args }),
},
);
}
export function sampleValue(schema) {
if (!schema || typeof schema !== "object") return null;
if ("default" in schema) return schema.default;
if ("const" in schema) return schema.const;
if (Array.isArray(schema.enum) && schema.enum.length) return schema.enum[0];
const type = Array.isArray(schema.type)
? schema.type.find((item) => item !== "null") || schema.type[0]
: schema.type;
if (type === "array") return [];
if (type === "boolean") return false;
if (type === "integer" || type === "number") return 0;
if (type === "string") return "";
const props = schema.properties || {};
if (type === "object" || Object.keys(props).length) {
const required = Array.isArray(schema.required) ? schema.required : Object.keys(props);
return Object.fromEntries(
required.map((key) => [key, sampleValue(props[key])]),
);
}
return null;
}
export function sampleArgs(skill) {
const schema = skill?.input_schema || skill?.inputSchema || {};
const value = sampleValue(schema);
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.jsx";
import "./style.css";
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

244
frontend/src/style.css Normal file
View File

@@ -0,0 +1,244 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #090909;
color: #f5f5f5;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: #090909;
}
button,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
.loading {
min-height: 100vh;
display: grid;
place-items: center;
color: #b8b8b8;
}
.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(260px, 340px) 1fr;
}
.sidebar {
min-height: 100vh;
border-right: 1px solid #222;
background: #0d0d0d;
padding: 28px;
display: grid;
align-content: start;
gap: 28px;
}
.eyebrow {
margin: 0 0 10px;
color: #8bd8c6;
font-size: 12px;
letter-spacing: 0;
text-transform: uppercase;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin-bottom: 10px;
font-size: clamp(32px, 5vw, 56px);
line-height: 0.95;
letter-spacing: 0;
}
h2 {
margin-bottom: 0;
font-size: 30px;
letter-spacing: 0;
}
h3 {
margin-bottom: 12px;
color: #d7d7d7;
font-size: 14px;
letter-spacing: 0;
}
.agent-meta {
margin-bottom: 0;
color: #a6a6a6;
}
.skill-list {
display: grid;
gap: 10px;
}
.skill {
width: 100%;
min-height: 58px;
border: 1px solid #252525;
border-radius: 8px;
background: #121212;
color: #f3f3f3;
padding: 12px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
text-align: left;
}
.skill:hover,
.skill.active {
border-color: #5bb7a7;
background: #15201d;
}
.skill span {
overflow-wrap: anywhere;
}
.skill small {
color: #9d9d9d;
}
.session {
border: 1px solid #252525;
border-radius: 8px;
padding: 14px;
color: #a6a6a6;
display: grid;
gap: 4px;
}
.session strong {
color: #f3f3f3;
overflow-wrap: anywhere;
}
.workbench {
padding: 28px;
display: grid;
align-content: start;
gap: 22px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.toolbar a,
.runner button {
border: 1px solid #e8e8e8;
border-radius: 7px;
background: #f4f4f4;
color: #111;
padding: 10px 14px;
text-decoration: none;
white-space: nowrap;
}
.runner {
display: grid;
gap: 12px;
}
.runner label {
display: grid;
gap: 10px;
color: #d7d7d7;
}
.runner textarea {
width: 100%;
min-height: 170px;
resize: vertical;
border: 1px solid #252525;
border-radius: 8px;
background: #070707;
color: #eeeeee;
padding: 14px;
line-height: 1.5;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.runner button {
justify-self: start;
}
.runner button:disabled {
cursor: wait;
opacity: 0.72;
}
.panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.panel {
min-width: 0;
border: 1px solid #252525;
border-radius: 8px;
padding: 16px;
background: #0d0d0d;
}
pre {
min-height: 260px;
max-height: 420px;
margin: 0;
overflow: auto;
color: #dcdcdc;
line-height: 1.5;
font-size: 13px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.empty {
color: #aaa;
}
@media (max-width: 820px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
min-height: auto;
border-right: 0;
border-bottom: 1px solid #222;
}
.panels {
grid-template-columns: 1fr;
}
.toolbar {
align-items: flex-start;
flex-direction: column;
}
}

18
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,18 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
const agent = process.env.A2A_DEV_AGENT_URL || "http://127.0.0.1:8000";
export default defineConfig({
base: "./",
plugins: [react()],
server: {
proxy: {
"/app": agent,
"/invoke": agent,
"/auth": agent,
"/mcp": agent,
"/.well-known": agent,
},
},
});