deploy
This commit is contained in:
143
frontend/src/App.jsx
Normal file
143
frontend/src/App.jsx
Normal 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
65
frontend/src/a2a.js
Normal 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
10
frontend/src/main.jsx
Normal 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
244
frontend/src/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user