chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
# `_parity/` — integration-demo parity tooling
Keeps `examples/integrations/*` demos aligned to a single north-star so
drift doesn't pile up as the canonical demo evolves.
**North-star (v1):** `langgraph-python` — the richest demo (todos state, 5
tools, polished prompt, full frontend canvas). Every other integration demo
should track it.
## What gets tracked
Declared in [`manifest.json`](./manifest.json):
- **verbatim files** — copied byte-for-byte from north-star to each instance
(frontend components, hooks, lib, public assets, shared Docker files,
`docker-compose.test.yml`, `entrypoint.sh`, `postcss.config.mjs`, etc.)
- **package.json keys** — tracked dependency versions and script names
(`@copilotkit/*`, `next`, `react`, shared dev scripts). Per-instance
overrides in manifest (`packageJsonOverrides`) win where the instance
legitimately differs (e.g. `dev:agent` runs `npm install` in JS but
`uv sync` in FastAPI).
- **canonical prompt** — `_parity/canonical/PROMPT.md`. Each agent
**inlines** the prompt as a string literal in its source (matching the
north-star's `main.py` pattern). Verifier greps the canonical prompt's
first non-blank line against instance agent source — drift = error.
- **agent surface** — tool names + state keys expected to appear in each
instance's agent source. Grep-level check — doesn't validate call-site
correctness, that's the aimock fixture tests' job.
## What doesn't get tracked (allowed divergence)
Per-instance `allowedDivergence` list in `manifest.json`:
- `agent/**` — agents are written in different languages/runtimes (Python
create_agent, TS StateGraph, Python StateGraph+FastAPI). Human-authored.
- `src/app/api/copilotkit/**` — north-star uses `LangGraphAgent`, Docker
instances use `HttpAgent`. Different routes.
- `Dockerfile`, `docker/Dockerfile.agent`, `serve.py`, `scripts/**`
language-specific build/run tooling.
Anything outside both `tracked` and `allowedDivergence` is "no-op" — the
verifier neither checks nor touches it.
## Commands
From the repo root:
```bash
# Sync a single instance to north-star (copies verbatim files, rewrites
# package.json keys, writes canonical prompt to agent/PROMPT.md)
pnpm parity:sync --target=langgraph-js
# Dry-run: show what would change without writing
pnpm parity:sync --target=langgraph-js --dry-run
# Sync every non-north-star instance
pnpm parity:sync --all
# Verify — exits non-zero on unexpected drift
pnpm parity:verify
pnpm parity:verify --target=langgraph-js
# CI invocation (same as verify, no color)
pnpm parity:check
```
## Typical workflows
### North-star changed — sync instances
```bash
pnpm parity:sync --all
pnpm parity:verify
# fix any agent-surface drift manually in the relevant agent/src/ files
git add . && git commit
```
### Adding a new instance
1. Create the new demo under `examples/integrations/<name>/` with a
Next.js frontend at the root and an `agent/` dir.
2. Add an entry to `manifest.json` under `instances`:
```json
"new-demo": {
"role": "instance",
"agent": { "language": "python", "runtime": "..." },
"allowedDivergence": ["agent/**", "src/app/api/copilotkit/**",
"Dockerfile", "docker/Dockerfile.agent",
"serve.py", "scripts/**"],
"packageJsonOverrides": { "scripts.dev:agent": "..." }
}
```
3. Run `pnpm parity:sync --target=new-demo`.
4. Hand-port the agent code (tools, state, prompt loading) — manifest
`tracked.agentSurface` tells you what tool names and state keys are
required.
5. Run `pnpm parity:verify --target=new-demo` until green.
### North-star's agent surface changed
Edit `manifest.json` → `tracked.agentSurface.toolNames` / `stateKeys`.
The verifier will then flag every instance that hasn't caught up.
### Canonical prompt changed
1. Edit `_parity/canonical/PROMPT.md`.
2. Update north-star's `agent/main.py` to use the new prompt string
(north-star is where humans read; canonical file is what the
verifier reads).
3. Port the new prompt into every instance's agent source (same
manual-merge rules as any agent change).
4. `pnpm parity:verify` — verifier greps first line of canonical against
each instance's agent source. Passes when all instances inline it.
## CI
`.github/workflows/integrations_parity.yml` runs `pnpm parity:check` on
every PR that touches `examples/integrations/**`. Failures link the
contributor back to this README.
## Design notes
- **Declarative, not prescriptive.** The manifest says _what_ is tracked;
the scripts just walk it. Adding a new tracked file = one line change,
not a code change.
- **Allowed-divergence is explicit, not implicit.** Everything is either
tracked, declared-divergent, or ignored — no silent "probably different"
state.
- **Agent surface is grep-level, on purpose.** A real AST check would be
three times the code and still miss semantic drift. The existing aimock
fixture integration tests (under `fixtures/default.json` per instance)
are the real correctness check; this is the "did someone rip out
`manage_todos`" safety net.
- **North-star is read-only to the scripts.** `sync.ts` refuses to write
into the north-star directory even if asked. Verifier never touches
anything.
@@ -0,0 +1,11 @@
You are a polished, professional demo assistant. Keep responses to 1-2 sentences.
Tool guidance:
- Flights: call search_flights to show flight cards with a pre-built schema.
- Dashboards & rich UI: call generate_a2ui to create dashboard UIs with metrics,
charts, tables, and cards. It handles rendering automatically.
- Charts: call query_data first, then render with the chart component.
- Todos: enable app mode first, then manage todos.
- A2UI actions: when you see a log_a2ui_event result (e.g. "view_details"),
respond with a brief confirmation. The UI already updated on the frontend.
+47
View File
@@ -0,0 +1,47 @@
import { createHash } from "node:crypto";
import { readFileSync, statSync } from "node:fs";
export function sha256(buf: Buffer | string): string {
return createHash("sha256").update(buf).digest("hex");
}
export function fileSha256(path: string): string {
return sha256(readFileSync(path));
}
export function fileExists(path: string): boolean {
try {
statSync(path);
return true;
} catch {
return false;
}
}
export function getByPath(obj: unknown, path: string): unknown {
const parts = path.split(".");
let cur: unknown = obj;
for (const part of parts) {
if (cur == null || typeof cur !== "object") return undefined;
cur = (cur as Record<string, unknown>)[part];
}
return cur;
}
export function setByPath(
obj: Record<string, unknown>,
path: string,
value: unknown,
): void {
const parts = path.split(".");
let cur: Record<string, unknown> = obj;
for (let i = 0; i < parts.length - 1; i++) {
const key = parts[i]!;
const next = cur[key];
if (next == null || typeof next !== "object" || Array.isArray(next)) {
cur[key] = {};
}
cur = cur[key] as Record<string, unknown>;
}
cur[parts[parts.length - 1]!] = value;
}
@@ -0,0 +1,96 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
export type InstanceRole = "north-star" | "instance";
export interface AgentSurface {
toolNames: string[];
stateKeys: string[];
modelFamily: string;
}
export interface TrackedSurface {
verbatimFiles: string[];
packageJsonPaths: string[];
agentSurface: AgentSurface;
}
export interface InstanceAgent {
language: "python" | "typescript";
runtime: string;
}
export interface Instance {
role: InstanceRole;
agent: InstanceAgent;
allowedDivergence: string[];
packageJsonOverrides: Record<string, string>;
}
export interface Manifest {
version: number;
northStar: string;
canonicalPromptFile: string;
tracked: TrackedSurface;
instances: Record<string, Instance>;
}
export interface ParityRoot {
/** Absolute path to the parity dir, e.g. …/examples/integrations/_parity */
parityDir: string;
/** Absolute path to the integrations root, e.g. …/examples/integrations */
integrationsDir: string;
manifest: Manifest;
}
export function loadManifest(parityDir: string): ParityRoot {
const manifestPath = resolve(parityDir, "manifest.json");
const raw = readFileSync(manifestPath, "utf8");
const parsed = JSON.parse(raw) as Manifest;
validate(parsed, manifestPath);
return {
parityDir,
integrationsDir: resolve(parityDir, ".."),
manifest: parsed,
};
}
function validate(m: Manifest, source: string): void {
const err = (msg: string): never => {
throw new Error(`[parity] invalid manifest at ${source}: ${msg}`);
};
if (m.version !== 1) err(`unsupported version ${m.version}`);
if (!m.northStar) err("missing northStar");
if (!m.instances?.[m.northStar])
err(`northStar '${m.northStar}' not in instances`);
if (m.instances[m.northStar].role !== "north-star")
err(`northStar '${m.northStar}' must have role=north-star`);
const nonNorthStar = Object.entries(m.instances).filter(
([n]) => n !== m.northStar,
);
for (const [name, inst] of nonNorthStar) {
if (inst.role !== "instance")
err(`instance '${name}' must have role=instance`);
}
if (!m.tracked?.verbatimFiles?.length) err("tracked.verbatimFiles empty");
if (!m.tracked?.packageJsonPaths?.length)
err("tracked.packageJsonPaths empty");
if (!m.tracked?.agentSurface?.toolNames?.length)
err("tracked.agentSurface.toolNames empty");
}
export function instanceDir(root: ParityRoot, name: string): string {
return resolve(root.integrationsDir, name);
}
export function northStarDir(root: ParityRoot): string {
return instanceDir(root, root.manifest.northStar);
}
export function listInstances(root: ParityRoot): string[] {
return Object.keys(root.manifest.instances).filter(
(n) => n !== root.manifest.northStar,
);
}
@@ -0,0 +1,77 @@
export type Severity = "ok" | "warn" | "error";
export interface DriftItem {
severity: Severity;
instance: string;
kind:
| "verbatim-file"
| "package-json"
| "agent-tool"
| "agent-state"
| "prompt"
| "missing-instance";
subject: string;
detail?: string;
expected?: string;
actual?: string;
}
export interface Report {
instance: string;
items: DriftItem[];
}
export function hasErrors(reports: Report[]): boolean {
return reports.some((r) => r.items.some((i) => i.severity === "error"));
}
const RESET = "\x1b[0m";
const RED = "\x1b[31m";
const YEL = "\x1b[33m";
const GRN = "\x1b[32m";
const BOLD = "\x1b[1m";
const DIM = "\x1b[2m";
function color(enabled: boolean, code: string, s: string): string {
return enabled ? `${code}${s}${RESET}` : s;
}
export function printReports(reports: Report[], useColor = true): void {
for (const r of reports) {
const errors = r.items.filter((i) => i.severity === "error");
const warns = r.items.filter((i) => i.severity === "warn");
const oks = r.items.filter((i) => i.severity === "ok");
const header = color(
useColor,
BOLD,
`\n${r.instance}${oks.length} ok, ${warns.length} warn, ${errors.length} error`,
);
process.stdout.write(header + "\n");
for (const item of [...errors, ...warns]) {
const tag =
item.severity === "error"
? color(useColor, RED, " ✗")
: color(useColor, YEL, " !");
const kindStr = color(useColor, DIM, `[${item.kind}]`);
process.stdout.write(`${tag} ${kindStr} ${item.subject}\n`);
if (item.detail) process.stdout.write(` ${item.detail}\n`);
if (item.expected !== undefined || item.actual !== undefined) {
if (item.expected !== undefined)
process.stdout.write(` expected: ${truncate(item.expected)}\n`);
if (item.actual !== undefined)
process.stdout.write(` actual: ${truncate(item.actual)}\n`);
}
}
if (errors.length === 0 && warns.length === 0) {
process.stdout.write(color(useColor, GRN, " ✓ no drift\n"));
}
}
}
function truncate(s: string, max = 120): string {
if (s.length <= max) return s;
return s.slice(0, max) + "…";
}
+237
View File
@@ -0,0 +1,237 @@
{
"version": 1,
"northStar": "langgraph-python",
"canonicalPromptFile": "_parity/canonical/PROMPT.md",
"tracked": {
"verbatimFiles": [
"postcss.config.mjs",
"tsconfig.json",
"next.config.ts",
"next-env.d.ts",
"public/copilotkit-logo-mark.svg",
"public/copilotkit-logo.svg",
"public/file.svg",
"public/globe.svg",
"public/next.svg",
"public/vercel.svg",
"public/window.svg",
"src/app/favicon.ico",
"src/app/globals.css",
"src/app/layout.tsx",
"src/app/page.tsx",
"src/app/declarative-generative-ui/definitions.ts",
"src/app/declarative-generative-ui/renderers.tsx",
"src/components/example-canvas/index.tsx",
"src/components/example-canvas/todo-card.tsx",
"src/components/example-canvas/todo-column.tsx",
"src/components/example-canvas/todo-list.tsx",
"src/components/example-layout/index.tsx",
"src/components/example-layout/mode-toggle.tsx",
"src/components/generative-ui/charts/bar-chart.tsx",
"src/components/generative-ui/charts/config.ts",
"src/components/generative-ui/charts/pie-chart.tsx",
"src/components/generative-ui/meeting-time-picker.tsx",
"src/components/headless-chat.tsx",
"src/components/tool-rendering.tsx",
"src/components/threads-drawer/index.tsx",
"src/components/threads-drawer/locked-state.tsx",
"src/components/threads-drawer/threads-drawer.module.css",
"src/components/threads-drawer/threads-drawer.tsx",
"src/components/ui/badge.tsx",
"src/components/ui/button.tsx",
"src/components/ui/card.tsx",
"src/components/ui/checkbox.tsx",
"src/components/ui/input.tsx",
"src/components/ui/separator.tsx",
"src/components/ui/spinner.tsx",
"src/hooks/index.ts",
"src/hooks/use-example-suggestions.tsx",
"src/hooks/use-generative-ui-examples.tsx",
"src/hooks/use-theme.tsx",
"src/lib/a2ui-theme.css",
"src/lib/utils.ts",
"Dockerfile",
"docker/Dockerfile.app",
"docker/Dockerfile.agent",
"docker-compose.test.yml",
"docker-route-override.ts",
"entrypoint.sh",
"serve.py",
"showcase.json"
],
"packageJsonPaths": [
"scripts.dev",
"scripts.dev:debug",
"scripts.dev:ui",
"scripts.build",
"scripts.start",
"scripts.postinstall",
"dependencies.@copilotkit/react-core",
"dependencies.@copilotkit/react-ui",
"dependencies.@copilotkit/runtime",
"dependencies.@copilotkit/shared",
"dependencies.@copilotkit/a2ui-renderer",
"dependencies.@radix-ui/react-checkbox",
"dependencies.@radix-ui/react-label",
"dependencies.@radix-ui/react-separator",
"dependencies.class-variance-authority",
"dependencies.clsx",
"dependencies.hono",
"dependencies.lucide-react",
"dependencies.next",
"dependencies.react",
"dependencies.react-dom",
"dependencies.react-rnd",
"dependencies.react18-json-view",
"dependencies.recharts",
"dependencies.tailwind-merge",
"dependencies.zod",
"devDependencies.@tailwindcss/postcss",
"devDependencies.@types/node",
"devDependencies.@types/react",
"devDependencies.@types/react-dom",
"devDependencies.concurrently",
"devDependencies.tailwindcss",
"devDependencies.typescript"
],
"agentSurface": {
"toolNames": ["query_data", "generate_a2ui", "search_flights"],
"stateKeys": ["todos", "messages"],
"modelFamily": "openai"
}
},
"instances": {
"langgraph-python": {
"role": "north-star",
"agent": {
"language": "python",
"runtime": "langgraph-create-agent"
},
"allowedDivergence": [],
"packageJsonOverrides": {}
},
"langgraph-js": {
"role": "instance",
"agent": {
"language": "typescript",
"runtime": "langgraph-stategraph"
},
"allowedDivergence": [
"agent/**",
"src/app/api/copilotkit/**",
"Dockerfile",
"docker/Dockerfile.agent",
"docker-compose.test.yml",
"entrypoint.sh",
"serve.py",
"scripts/**",
"src/components/threads-drawer/**",
"src/app/page.tsx",
"next.config.ts"
],
"packageJsonOverrides": {
"scripts.dev:agent": "./scripts/run-agent.sh || scripts\\run-agent.bat",
"scripts.install:agent": "cd agent && npm install"
}
},
"langgraph-fastapi": {
"role": "instance",
"agent": {
"language": "python",
"runtime": "fastapi-langgraph"
},
"allowedDivergence": [
"agent/**",
"src/app/api/copilotkit/**",
"docker/Dockerfile.agent",
"docker-compose.test.yml",
"scripts/**"
],
"packageJsonOverrides": {
"scripts.dev:agent": "cd agent && uv run main.py",
"scripts.install:agent": "cd agent && uv sync"
}
},
"strands-python": {
"role": "instance",
"agent": {
"language": "python",
"runtime": "strands-ag-ui"
},
"allowedDivergence": [
"agent/**",
"src/app/api/copilotkit/**",
"Dockerfile",
"docker/Dockerfile.agent",
"docker-compose.test.yml",
"entrypoint.sh",
"serve.py",
"scripts/**"
],
"packageJsonOverrides": {
"scripts.dev:agent": "./scripts/run-agent.sh || scripts\\run-agent.bat",
"scripts.install:agent": "./scripts/setup-agent.sh || scripts\\setup-agent.bat"
}
},
"claude-sdk-python": {
"role": "instance",
"agent": {
"language": "python",
"runtime": "claude-agent-sdk"
},
"allowedDivergence": [
"agent/**",
"src/app/api/copilotkit/**",
"Dockerfile",
"docker/Dockerfile.agent",
"docker-compose.test.yml",
"entrypoint.sh",
"serve.py",
"scripts/**",
"docker-route-override.ts",
"src/app/declarative-generative-ui/renderers.tsx",
"src/components/example-canvas/index.tsx",
"src/components/example-canvas/todo-list.tsx",
"src/components/example-layout/mode-toggle.tsx",
"src/components/generative-ui/charts/bar-chart.tsx",
"src/components/headless-chat.tsx",
"src/components/tool-rendering.tsx",
"src/components/ui/spinner.tsx"
],
"packageJsonOverrides": {
"scripts.dev:agent": "./scripts/run-agent.sh || scripts\\run-agent.bat",
"scripts.install:agent": "./scripts/setup-agent.sh || scripts\\setup-agent.bat"
}
},
"claude-sdk-typescript": {
"role": "instance",
"agent": {
"language": "typescript",
"runtime": "claude-agent-sdk"
},
"allowedDivergence": [
"agent/**",
"src/app/api/copilotkit/**",
"Dockerfile",
"docker/Dockerfile.agent",
"docker-compose.test.yml",
"entrypoint.sh",
"serve.py",
"scripts/**",
"docker-route-override.ts",
"src/app/declarative-generative-ui/renderers.tsx",
"src/components/example-canvas/index.tsx",
"src/components/example-canvas/todo-list.tsx",
"src/components/example-layout/mode-toggle.tsx",
"src/components/generative-ui/charts/bar-chart.tsx",
"src/components/headless-chat.tsx",
"src/components/tool-rendering.tsx",
"src/components/ui/spinner.tsx"
],
"packageJsonOverrides": {
"scripts.dev:agent": "./scripts/run-agent.sh || scripts\\run-agent.bat",
"scripts.install:agent": "./scripts/setup-agent.sh || scripts\\setup-agent.bat"
}
}
}
}
+317
View File
@@ -0,0 +1,317 @@
/**
* Integration-demo parity sync.
*
* Copies verbatim files from the north-star integration demo to one or more
* instance demos, and rewrites tracked package.json keys so scripts and
* dependency pins stay aligned. Does NOT touch agent code or the api route —
* those are the manual-merge zones per `_parity/manifest.json`.
*
* Usage (from repo root or anywhere):
* pnpm tsx examples/integrations/_parity/sync.ts --target=langgraph-js
* pnpm tsx examples/integrations/_parity/sync.ts --target=langgraph-js --dry-run
* pnpm tsx examples/integrations/_parity/sync.ts --all
*
* Exit codes:
* 0 — sync applied (or dry-run reported only)
* 1 — unexpected error (missing source file, unwritable target)
* 2 — invalid CLI input
*/
import {
copyFileSync,
mkdirSync,
readFileSync,
writeFileSync,
readdirSync,
statSync,
} from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import type { ParityRoot } from "./lib/manifest.js";
import {
loadManifest,
instanceDir,
northStarDir,
listInstances,
} from "./lib/manifest.js";
import { fileExists, getByPath, setByPath } from "./lib/diff.js";
interface CliOpts {
target?: string;
all: boolean;
dryRun: boolean;
}
function parseCli(argv: string[]): CliOpts {
const opts: CliOpts = { all: false, dryRun: false };
for (const arg of argv) {
if (arg === "--all") opts.all = true;
else if (arg === "--dry-run") opts.dryRun = true;
else if (arg.startsWith("--target="))
opts.target = arg.slice("--target=".length);
else if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
} else {
process.stderr.write(`unknown arg: ${arg}\n`);
printHelp();
process.exit(2);
}
}
if (!opts.all && !opts.target) {
process.stderr.write("required: --target=<name> or --all\n");
printHelp();
process.exit(2);
}
return opts;
}
function printHelp(): void {
process.stderr.write(
[
"sync integration demos to north-star.",
"",
" --target=<name> sync a single instance (e.g. langgraph-js)",
" --all sync every non-north-star instance",
" --dry-run print changes without writing",
"",
].join("\n"),
);
}
function resolveParityDir(): string {
return dirname(fileURLToPath(import.meta.url));
}
interface SyncResult {
instance: string;
filesCopied: number;
filesSkipped: number;
pkgKeysRewritten: string[];
}
function syncInstance(
root: ParityRoot,
instance: string,
dryRun: boolean,
): SyncResult {
const manifest = root.manifest;
const from = northStarDir(root);
const to = instanceDir(root, instance);
const inst = manifest.instances[instance]!;
if (!fileExists(to)) {
throw new Error(`target directory missing: ${to}`);
}
let filesCopied = 0;
let filesSkipped = 0;
for (const pattern of manifest.tracked.verbatimFiles) {
const matches = expandPattern(from, pattern);
if (matches.length === 0) {
process.stderr.write(
`[parity] warn: verbatim pattern matched nothing in north-star: ${pattern}\n`,
);
continue;
}
for (const relPath of matches) {
const src = join(from, relPath);
const dst = join(to, relPath);
if (isAllowedDivergence(relPath, inst.allowedDivergence)) {
filesSkipped++;
continue;
}
const changed = copyIfChanged(src, dst, dryRun);
if (changed) filesCopied++;
}
}
// Canonical prompt: not copied as a file. Each agent inlines the prompt
// string in source. sync.ts used to write PROMPT.md per instance, but that
// file was never loaded at runtime — it was cosmetic. Verifier now
// grep-checks the canonical prompt's first line against instance source.
// package.json key rewrite
const pkgSrcPath = join(from, "package.json");
const pkgDstPath = join(to, "package.json");
const pkgSrc = JSON.parse(readFileSync(pkgSrcPath, "utf8")) as Record<
string,
unknown
>;
const pkgDst = JSON.parse(readFileSync(pkgDstPath, "utf8")) as Record<
string,
unknown
>;
const rewritten: string[] = [];
for (const keyPath of manifest.tracked.packageJsonPaths) {
const override = inst.packageJsonOverrides[keyPath];
const value =
override !== undefined ? override : getByPath(pkgSrc, keyPath);
if (value === undefined) continue; // source doesn't declare it — leave instance alone
const existing = getByPath(pkgDst, keyPath);
if (existing !== value) {
setByPath(pkgDst, keyPath, value);
rewritten.push(keyPath);
}
}
// Apply any override keys the source doesn't have (e.g. dev:agent differs
// across instances and is NOT in packageJsonPaths)
for (const [keyPath, override] of Object.entries(inst.packageJsonOverrides)) {
if (manifest.tracked.packageJsonPaths.includes(keyPath)) continue;
const existing = getByPath(pkgDst, keyPath);
if (existing !== override) {
setByPath(pkgDst, keyPath, override);
rewritten.push(keyPath);
}
}
if (rewritten.length > 0 && !dryRun) {
writeFileSync(pkgDstPath, JSON.stringify(pkgDst, null, 2) + "\n");
}
return {
instance,
filesCopied,
filesSkipped,
pkgKeysRewritten: rewritten,
};
}
function copyIfChanged(src: string, dst: string, dryRun: boolean): boolean {
if (!fileExists(src)) {
throw new Error(`missing source: ${src}`);
}
const srcBuf = readFileSync(src);
if (fileExists(dst)) {
const dstBuf = readFileSync(dst);
if (srcBuf.equals(dstBuf)) return false;
}
if (dryRun) return true;
mkdirSync(dirname(dst), { recursive: true });
copyFileSync(src, dst);
return true;
}
/**
* Minimal glob expansion. Supports literal paths, `dir/**` (recursive), and
* `dir/**\/*.ext` (recursive with extension). Intentionally NOT a full glob —
* manifest patterns are curated, not user input.
*/
export function expandPattern(baseDir: string, pattern: string): string[] {
if (!pattern.includes("*")) {
return fileExists(join(baseDir, pattern)) ? [pattern] : [];
}
if (pattern.endsWith("/**")) {
const prefix = pattern.slice(0, -3);
return walk(baseDir, prefix).map((p) => p);
}
const doubleStarExt = pattern.match(/^(.+)\/\*\*\/\*\.([a-zA-Z0-9]+)$/);
if (doubleStarExt) {
const prefix = doubleStarExt[1]!;
const ext = doubleStarExt[2]!;
return walk(baseDir, prefix).filter((p) => p.endsWith(`.${ext}`));
}
throw new Error(`unsupported pattern: ${pattern}`);
}
function walk(baseDir: string, rel: string): string[] {
const full = join(baseDir, rel);
if (!fileExists(full)) return [];
const out: string[] = [];
const stack = [full];
while (stack.length > 0) {
const dir = stack.pop()!;
const entries = readdirSync(dir);
for (const entry of entries) {
const abs = join(dir, entry);
const st = statSync(abs);
if (st.isDirectory()) stack.push(abs);
else out.push(relative(baseDir, abs));
}
}
return out;
}
function isAllowedDivergence(relPath: string, patterns: string[]): boolean {
for (const pattern of patterns) {
if (pattern.endsWith("/**")) {
const prefix = pattern.slice(0, -3);
if (relPath === prefix || relPath.startsWith(prefix + "/")) return true;
} else if (pattern === relPath) {
return true;
}
}
return false;
}
function printSyncResult(r: SyncResult, dryRun: boolean): void {
const verb = dryRun ? "would sync" : "synced";
process.stdout.write(`\n${r.instance}: ${verb}\n`);
process.stdout.write(` files copied: ${r.filesCopied}\n`);
process.stdout.write(
` files skipped: ${r.filesSkipped} (allowed divergence)\n`,
);
process.stdout.write(
` package.json: ${r.pkgKeysRewritten.length} key(s) rewritten\n`,
);
if (r.pkgKeysRewritten.length > 0) {
for (const k of r.pkgKeysRewritten) {
process.stdout.write(` - ${k}\n`);
}
}
}
function main(): void {
const opts = parseCli(process.argv.slice(2));
const parityDir = resolveParityDir();
const root = loadManifest(parityDir);
const targets = opts.all
? listInstances(root)
: opts.target
? [opts.target]
: [];
if (opts.target && !root.manifest.instances[opts.target]) {
process.stderr.write(`unknown instance: ${opts.target}\n`);
process.stderr.write(
`known: ${Object.keys(root.manifest.instances).join(", ")}\n`,
);
process.exit(2);
}
let totalFiles = 0;
let totalKeys = 0;
for (const t of targets) {
if (t === root.manifest.northStar) continue; // paranoia: never write to north-star
const result = syncInstance(root, t, opts.dryRun);
printSyncResult(result, opts.dryRun);
totalFiles += result.filesCopied;
totalKeys += result.pkgKeysRewritten.length;
}
const mode = opts.dryRun ? "(dry-run) " : "";
process.stdout.write(
`\n${mode}total: ${totalFiles} file change(s), ${totalKeys} package.json key(s) across ${targets.length} instance(s)\n`,
);
process.stdout.write(
`\nmanual-merge reminder: agent/ dir and api/copilotkit route are NOT synced.\n` +
`run \`pnpm parity:verify\` to check agent-surface and prompt alignment.\n`,
);
}
// Only run main() when invoked as script, not when imported by tests.
const isMain =
import.meta.url === `file://${process.argv[1]}` ||
fileURLToPath(import.meta.url) === process.argv[1];
if (isMain) {
try {
main();
} catch (e) {
process.stderr.write(`[parity] sync failed: ${(e as Error).message}\n`);
process.exit(1);
}
}
+399
View File
@@ -0,0 +1,399 @@
/**
* Integration-demo parity verifier.
*
* Reads the parity manifest and checks each instance against the north-star:
* - verbatim files byte-equal (modulo allowedDivergence)
* - tracked package.json keys equal (or equal to instance override)
* - canonical prompt present and byte-equal at <instance>/agent/PROMPT.md
* - agent tool names + state keys declared in manifest present in the
* instance's agent source (grep-level, not AST — see `scanAgentSurface`)
*
* Exit codes:
* 0 — no errors
* 1 — one or more errors (drift)
* 2 — invalid CLI input
* 3 — unreadable (missing file, unreadable dir)
*
* Usage:
* pnpm tsx examples/integrations/_parity/verify.ts
* pnpm tsx examples/integrations/_parity/verify.ts --target=langgraph-js
* pnpm tsx examples/integrations/_parity/verify.ts --json
*/
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { ParityRoot } from "./lib/manifest.js";
import {
loadManifest,
instanceDir,
northStarDir,
listInstances,
} from "./lib/manifest.js";
import { fileExists, fileSha256, getByPath } from "./lib/diff.js";
import type { DriftItem, Report } from "./lib/report.js";
import { printReports, hasErrors } from "./lib/report.js";
import { expandPattern } from "./sync.js";
interface CliOpts {
target?: string;
json: boolean;
noColor: boolean;
}
function parseCli(argv: string[]): CliOpts {
const opts: CliOpts = { json: false, noColor: false };
for (const arg of argv) {
if (arg === "--json") opts.json = true;
else if (arg === "--no-color") opts.noColor = true;
else if (arg.startsWith("--target="))
opts.target = arg.slice("--target=".length);
else if (arg === "--help" || arg === "-h") {
process.stderr.write(
[
"verify integration-demo parity.",
"",
" --target=<name> verify a single instance",
" --json emit machine-readable report",
" --no-color disable ANSI color",
"",
].join("\n"),
);
process.exit(0);
} else {
process.stderr.write(`unknown arg: ${arg}\n`);
process.exit(2);
}
}
return opts;
}
function resolveParityDir(): string {
return dirname(fileURLToPath(import.meta.url));
}
function verifyInstance(root: ParityRoot, instance: string): Report {
const items: DriftItem[] = [];
const manifest = root.manifest;
const from = northStarDir(root);
const to = instanceDir(root, instance);
const inst = manifest.instances[instance]!;
if (!fileExists(to)) {
items.push({
severity: "error",
instance,
kind: "missing-instance",
subject: instance,
detail: `directory not found: ${to}`,
});
return { instance, items };
}
// Verbatim files
for (const pattern of manifest.tracked.verbatimFiles) {
const matches = expandPattern(from, pattern);
if (matches.length === 0) {
items.push({
severity: "warn",
instance,
kind: "verbatim-file",
subject: pattern,
detail: "pattern matched no files in north-star",
});
continue;
}
for (const relPath of matches) {
if (isAllowedDivergence(relPath, inst.allowedDivergence)) continue;
const src = join(from, relPath);
const dst = join(to, relPath);
if (!fileExists(dst)) {
items.push({
severity: "error",
instance,
kind: "verbatim-file",
subject: relPath,
detail: "missing in instance",
});
continue;
}
const srcSha = fileSha256(src);
const dstSha = fileSha256(dst);
if (srcSha !== dstSha) {
items.push({
severity: "error",
instance,
kind: "verbatim-file",
subject: relPath,
detail: "content differs from north-star",
expected: srcSha,
actual: dstSha,
});
} else {
items.push({
severity: "ok",
instance,
kind: "verbatim-file",
subject: relPath,
});
}
}
}
// package.json tracked keys
const pkgSrc = JSON.parse(
readFileSync(join(from, "package.json"), "utf8"),
) as Record<string, unknown>;
const pkgDst = JSON.parse(
readFileSync(join(to, "package.json"), "utf8"),
) as Record<string, unknown>;
for (const keyPath of manifest.tracked.packageJsonPaths) {
const override = inst.packageJsonOverrides[keyPath];
const expected =
override !== undefined ? override : getByPath(pkgSrc, keyPath);
if (expected === undefined) continue;
const actual = getByPath(pkgDst, keyPath);
if (actual === undefined) {
items.push({
severity: "error",
instance,
kind: "package-json",
subject: keyPath,
detail: "missing in instance package.json",
expected: String(expected),
});
continue;
}
if (actual !== expected) {
items.push({
severity: "error",
instance,
kind: "package-json",
subject: keyPath,
detail: "value differs from expected",
expected: String(expected),
actual: String(actual),
});
} else {
items.push({
severity: "ok",
instance,
kind: "package-json",
subject: keyPath,
});
}
}
// Canonical prompt: grep agent source for the first non-blank line of
// the canonical prompt. Full-text match is too brittle across triple-quote
// indentation; first line is a deterministic, high-signal marker.
const promptSrc = resolve(root.integrationsDir, manifest.canonicalPromptFile);
if (!fileExists(promptSrc)) {
items.push({
severity: "error",
instance,
kind: "prompt",
subject: manifest.canonicalPromptFile,
detail: "canonical prompt missing in repo",
});
} else {
const canonicalFirstLine = readFirstNonBlankLine(promptSrc);
if (canonicalFirstLine === null) {
items.push({
severity: "error",
instance,
kind: "prompt",
subject: manifest.canonicalPromptFile,
detail: "canonical prompt is empty",
});
} else {
const agentTextForPrompt = readAgentText(to);
if (agentTextForPrompt === null) {
items.push({
severity: "warn",
instance,
kind: "prompt",
subject: "agent/",
detail: "agent source not readable — skipping prompt check",
});
} else if (!agentTextForPrompt.includes(canonicalFirstLine)) {
items.push({
severity: "error",
instance,
kind: "prompt",
subject: "canonical prompt",
detail: `first line not found in agent source: "${truncate(canonicalFirstLine, 80)}"`,
});
} else {
items.push({
severity: "ok",
instance,
kind: "prompt",
subject: "canonical prompt",
});
}
}
}
// Agent surface: tool names + state keys grep-check.
// Intentional shallow check: verifier confirms the declared identifiers
// appear somewhere in the agent source. It does NOT validate call-site
// correctness — that's the aimock fixture integration test's job.
const agentText = readAgentText(to);
if (agentText === null) {
items.push({
severity: "warn",
instance,
kind: "agent-tool",
subject: "agent/",
detail: "agent source not readable — skipping surface check",
});
} else {
for (const tool of manifest.tracked.agentSurface.toolNames) {
if (!agentText.includes(tool)) {
items.push({
severity: "error",
instance,
kind: "agent-tool",
subject: tool,
detail: "tool name not found in agent source",
});
} else {
items.push({
severity: "ok",
instance,
kind: "agent-tool",
subject: tool,
});
}
}
for (const key of manifest.tracked.agentSurface.stateKeys) {
if (!agentText.includes(key)) {
items.push({
severity: "warn",
instance,
kind: "agent-state",
subject: key,
detail: "state key not found in agent source",
});
} else {
items.push({
severity: "ok",
instance,
kind: "agent-state",
subject: key,
});
}
}
}
return { instance, items };
}
function readAgentText(instanceRoot: string): string | null {
const agentDir = join(instanceRoot, "agent");
if (!fileExists(agentDir)) return null;
// Recursively read .py, .ts, .js files and concatenate. Cheap enough; the
// agent tree is small (< 1MB).
const parts: string[] = [];
const stack = [agentDir];
while (stack.length > 0) {
const dir = stack.pop()!;
let entries: string[];
try {
entries = require("node:fs").readdirSync(dir);
} catch {
return null;
}
for (const entry of entries) {
if (
entry === "node_modules" ||
entry === ".venv" ||
entry === ".langgraph_api" ||
entry === "__pycache__" ||
entry === "dist" ||
entry === "build" ||
entry === ".next"
)
continue;
const abs = join(dir, entry);
const st = require("node:fs").statSync(abs);
if (st.isDirectory()) {
stack.push(abs);
} else if (/\.(py|ts|tsx|js|mjs)$/.test(entry)) {
try {
parts.push(readFileSync(abs, "utf8"));
} catch {
/* unreadable file — skip */
}
}
}
}
return parts.join("\n");
}
function readFirstNonBlankLine(path: string): string | null {
const text = readFileSync(path, "utf8");
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.length > 0) return trimmed;
}
return null;
}
function truncate(s: string, max: number): string {
if (s.length <= max) return s;
return s.slice(0, max) + "…";
}
function isAllowedDivergence(relPath: string, patterns: string[]): boolean {
for (const pattern of patterns) {
if (pattern.endsWith("/**")) {
const prefix = pattern.slice(0, -3);
if (relPath === prefix || relPath.startsWith(prefix + "/")) return true;
} else if (pattern === relPath) {
return true;
}
}
return false;
}
function main(): void {
const opts = parseCli(process.argv.slice(2));
const parityDir = resolveParityDir();
const root = loadManifest(parityDir);
const targets = opts.target ? [opts.target] : listInstances(root);
if (opts.target && !root.manifest.instances[opts.target]) {
process.stderr.write(`unknown instance: ${opts.target}\n`);
process.exit(2);
}
const reports: Report[] = [];
for (const t of targets) {
if (t === root.manifest.northStar) continue;
reports.push(verifyInstance(root, t));
}
if (opts.json) {
process.stdout.write(JSON.stringify(reports, null, 2) + "\n");
} else {
printReports(reports, !opts.noColor);
}
const failed = hasErrors(reports);
process.exit(failed ? 1 : 0);
}
const isMain =
import.meta.url === `file://${process.argv[1]}` ||
fileURLToPath(import.meta.url) === process.argv[1];
if (isMain) {
try {
main();
} catch (e) {
process.stderr.write(`[parity] verify failed: ${(e as Error).message}\n`);
process.exit(3);
}
}
@@ -0,0 +1,7 @@
OPENAI_API_KEY=your-api-key-here
# --- copilotkit:intelligence (optional local threads stack) ---
# COPILOTKIT_LICENSE_TOKEN=
# INTELLIGENCE_API_KEY=
# INTELLIGENCE_API_URL=http://localhost:4201
# INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
+143
View File
@@ -0,0 +1,143 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
next-env.d.ts
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d46cf4769d80326d37a138944110aca23179451101e4874f8dac1392feaabf02
size 29036612
+121
View File
@@ -0,0 +1,121 @@
# CopilotKit <> A2A + A2UI Starter
This is a starter template for building AI agents that use [A2UI](https://a2ui.org) and [CopilotKit](https://copilotkit.ai). It provides a modern Next.js application with an integrated restaurant finder agent that can find restaurants and book reservations
![Demo](Demo.gif)
## Prerequisites
- Gemeni API Key (for the ADK/A2A agent)
- Python 3.12+
- uv
- Node.js 20+
- Any of the following package managers:
- npm (default)
- [pnpm](https://pnpm.io/installation)
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/)
- [bun](https://bun.sh/)
## Getting Started
1. Install dependencies using your preferred package manager:
```bash
# Using npm (default)
npm install
# Using pnpm
pnpm install
# Using yarn
yarn install
# Using bun
bun install
```
> **Note:** This will automatically setup the Python environment as well.
>
> If you have manual issues, you can run:
>
> ```sh
> npm run install:agent
> ```
3. Set up your Gemeni API key:
Create a `.env` file inside the `agent` folder with the following content:
```
GEMENI_API_KEY=sk-...your-openai-key-here...
```
4. Start the development server:
```bash
# Using npm (default)
npm run dev
# Using pnpm
pnpm dev
# Using yarn
yarn dev
# Using bun
bun run dev
```
This will start both the UI and agent servers concurrently.
## Available Scripts
The following scripts can also be run using your preferred package manager:
- `dev` - Starts both UI and agent servers in development mode
- `dev:debug` - Starts development servers with debug logging enabled
- `dev:ui` - Starts only the Next.js UI server
- `dev:agent` - Starts only the A2A agent server
- `build` - Builds the Next.js application for production
- `start` - Starts the production server
- `install:agent` - Installs Python dependencies for the agent
## Documentation
The main UI component is in `app/page.tsx`, but most of the UI comes from from the agent in the form of A2UI declarative components. To see and edit the components it can generate, look in `agent/prompt_builder.py`.
To generate new components, try the [A2UI Composer](https://a2ui-editor.ag-ui.com)
## 📚 Documentation
- [A2UI + CopilotKit Documentation](https://docs.copilotkit.ai/a2a) - Learn more about how to use A2UI with CopilotKit
- [A2UI Documentation](https://a2ui.org) - Learn more about A2UI and its capabilities
- [CopilotKit Documentation](https://docs.copilotkit.ai) - Explore CopilotKit's capabilities
- [Next.js Documentation](https://nextjs.org/docs) - Learn about Next.js features and API
## Contributing
Feel free to submit issues and enhancement requests! This starter is designed to be easily extensible.
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Troubleshooting
### Agent Connection Issues
If you see "I'm having trouble connecting to my tools", make sure:
1. The ADK agent is running on port 10002
2. Your Gemini API key is set correctly
3. Both servers started successfully
### Python Dependencies
If you encounter Python import errors:
```bash
cd agent
uv sync
uv run .
```
@@ -0,0 +1,216 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
# Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
# poetry.lock
# poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
# pdm.lock
# pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
# pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# Redis
*.rdb
*.aof
*.pid
# RabbitMQ
mnesia/
rabbitmq/
rabbitmq-data/
# ActiveMQ
activemq-data/
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
# .idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml
@@ -0,0 +1,11 @@
# A2UI Extension Implementation
This is the Python implementation of the a2ui extension.
## Disclaimer
Important: The sample code provided is for demonstration purposes and illustrates the mechanics of the Agent-to-Agent (A2A) protocol. When building production applications, it is critical to treat any agent operating outside of your direct control as a potentially untrusted entity.
All data received from an external agent—including but not limited to its AgentCard, messages, artifacts, and task statuses—should be handled as untrusted input. For example, a malicious agent could provide an AgentCard containing crafted data in its fields (e.g., description, name, skills.description). If this data is used without sanitization to construct prompts for a Large Language Model (LLM), it could expose your application to prompt injection attacks. Failure to properly validate and sanitize this data before use can introduce security vulnerabilities into your application.
Developers are responsible for implementing appropriate security measures, such as input validation and secure handling of credentials to protect their systems and users.
@@ -0,0 +1,11 @@
[project]
name = "a2ui"
version = "0.1.0"
description = "A2UI Extension"
readme = "README.md"
requires-python = ">=3.10"
dependencies = ["a2a-sdk>=0.3.0"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
@@ -0,0 +1,13 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
@@ -0,0 +1,118 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
from typing import Any, Optional
from a2a.server.agent_execution import RequestContext
from a2a.types import AgentExtension, Part, DataPart
logger = logging.getLogger(__name__)
A2UI_EXTENSION_URI = "https://a2ui.org/a2a-extension/a2ui/v0.8"
MIME_TYPE_KEY = "mimeType"
A2UI_MIME_TYPE = "application/json+a2ui"
A2UI_CLIENT_CAPABILITIES_KEY = "a2uiClientCapabilities"
SUPPORTED_CATALOG_IDS_KEY = "supportedCatalogIds"
INLINE_CATALOGS_KEY = "inlineCatalogs"
STANDARD_CATALOG_ID = "https://raw.githubusercontent.com/google/A2UI/refs/heads/main/specification/0.8/json/standard_catalog_definition.json"
def create_a2ui_part(a2ui_data: dict[str, Any]) -> Part:
"""Creates an A2A Part containing A2UI data.
Args:
a2ui_data: The A2UI data dictionary.
Returns:
An A2A Part with a DataPart containing the A2UI data.
"""
return Part(
root=DataPart(
data=a2ui_data,
metadata={
MIME_TYPE_KEY: A2UI_MIME_TYPE,
},
)
)
def is_a2ui_part(part: Part) -> bool:
"""Checks if an A2A Part contains A2UI data.
Args:
part: The A2A Part to check.
Returns:
True if the part contains A2UI data, False otherwise.
"""
return (
isinstance(part.root, DataPart)
and part.root.metadata
and part.root.metadata.get(MIME_TYPE_KEY) == A2UI_MIME_TYPE
)
def get_a2ui_datapart(part: Part) -> Optional[DataPart]:
"""Extracts the DataPart containing A2UI data from an A2A Part, if present.
Args:
part: The A2A Part to extract A2UI data from.
Returns:
The DataPart containing A2UI data if present, None otherwise.
"""
if is_a2ui_part(part):
return part.root
return None
def get_a2ui_agent_extension(
accepts_inline_custom_catalog: bool = False,
) -> AgentExtension:
"""Creates the A2UI AgentExtension configuration.
Args:
accepts_inline_custom_catalog: Whether the agent accepts inline custom catalogs.
Returns:
The configured A2UI AgentExtension.
"""
params = {}
if accepts_inline_custom_catalog:
params["acceptsInlineCustomCatalog"] = True # Only set if not default of False
return AgentExtension(
uri=A2UI_EXTENSION_URI,
description="Provides agent driven UI using the A2UI JSON format.",
params=params if params else None,
)
def try_activate_a2ui_extension(context: RequestContext) -> bool:
"""Activates the A2UI extension if requested.
Args:
context: The request context to check.
Returns:
True if activated, False otherwise.
"""
if A2UI_EXTENSION_URI in context.requested_extensions:
context.add_activated_extension(A2UI_EXTENSION_URI)
return True
return False
@@ -0,0 +1,13 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
@@ -0,0 +1,91 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from a2a.server.agent_execution import RequestContext
from a2a.types import DataPart, TextPart, Part
from a2ui import a2ui_extension
from unittest.mock import MagicMock
def test_a2ui_part_serialization():
a2ui_data = {"beginRendering": {"surfaceId": "test-surface", "root": "root-column"}}
part = a2ui_extension.create_a2ui_part(a2ui_data)
assert a2ui_extension.is_a2ui_part(part), "Should be identified as A2UI part"
data_part = a2ui_extension.get_a2ui_datapart(part)
assert data_part is not None, "Should contain DataPart"
assert a2ui_data == data_part.data, "Deserialized data should match original"
def test_non_a2ui_data_part():
part = Part(
root=DataPart(
data={"foo": "bar"},
metadata={"mimeType": "application/json"}, # Not A2UI
)
)
assert not a2ui_extension.is_a2ui_part(part), (
"Should not be identified as A2UI part"
)
assert a2ui_extension.get_a2ui_datapart(part) is None, (
"Should not return A2UI DataPart"
)
def test_non_a2ui_part():
text_part = TextPart(text="this is some text")
part = Part(root=text_part)
assert not a2ui_extension.is_a2ui_part(part), (
"Should not be identified as A2UI part"
)
assert a2ui_extension.get_a2ui_datapart(part) is None, (
"Should not return A2UI DataPart"
)
def test_get_a2ui_agent_extension():
agent_extension = a2ui_extension.get_a2ui_agent_extension()
assert agent_extension.uri == a2ui_extension.A2UI_EXTENSION_URI
assert agent_extension.params is None
def test_get_a2ui_agent_extension_with_inline_custom_catalog():
agent_extension = a2ui_extension.get_a2ui_agent_extension(
accepts_inline_custom_catalog=True
)
assert agent_extension.uri == a2ui_extension.A2UI_EXTENSION_URI
assert agent_extension.params is not None
def test_try_activate_a2ui_extension():
context = MagicMock(spec=RequestContext)
context.requested_extensions = [a2ui_extension.A2UI_EXTENSION_URI]
assert a2ui_extension.try_activate_a2ui_extension(context)
context.add_activated_extension.assert_called_once_with(
a2ui_extension.A2UI_EXTENSION_URI
)
def test_try_activate_a2ui_extension_not_requested():
context = MagicMock(spec=RequestContext)
context.requested_extensions = []
assert not a2ui_extension.try_activate_a2ui_extension(context)
context.add_activated_extension.assert_not_called()
@@ -0,0 +1,216 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
# Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
# poetry.lock
# poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
# pdm.lock
# pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
# pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# Redis
*.rdb
*.aof
*.pid
# RabbitMQ
mnesia/
rabbitmq/
rabbitmq-data/
# ActiveMQ
activemq-data/
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
# .idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml
@@ -0,0 +1,37 @@
# A2UI Restaurant finder and table reservation agent sample.
This sample uses the Agent Development Kit (ADK) along with the A2A protocol to create a simple "Restaurant finder and table reservation" agent that is hosted as an A2A server.
## Prerequisites
- Python 3.9 or higher
- [UV](https://docs.astral.sh/uv/)
- Access to an LLM and API Key
## Running the Sample
1. Navigate to the samples directory:
```bash
cd a2a_samples/a2ui_restaurant_finder
```
2. Create an environment file with your API key:
```bash
echo "GEMINI_API_KEY=your_api_key_here" > .env
```
3. Run the agent server:
```bash
uv run .
```
## Disclaimer
Important: The sample code provided is for demonstration purposes and illustrates the mechanics of the Agent-to-Agent (A2A) protocol. When building production applications, it is critical to treat any agent operating outside of your direct control as a potentially untrusted entity.
All data received from an external agent—including but not limited to its AgentCard, messages, artifacts, and task statuses—should be handled as untrusted input. For example, a malicious agent could provide an AgentCard containing crafted data in its fields (e.g., description, name, skills.description). If this data is used without sanitization to construct prompts for a Large Language Model (LLM), it could expose your application to prompt injection attacks. Failure to properly validate and sanitize this data before use can introduce security vulnerabilities into your application.
Developers are responsible for implementing appropriate security measures, such as input validation and secure handling of credentials to protect their systems and users.
@@ -0,0 +1,15 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from . import agent
@@ -0,0 +1,113 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
import os
import click
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import AgentCapabilities, AgentCard, AgentSkill
from a2ui.a2ui_extension import get_a2ui_agent_extension
from agent import RestaurantAgent
from agent_executor import RestaurantAgentExecutor
from dotenv import load_dotenv
from starlette.middleware.cors import CORSMiddleware
from starlette.staticfiles import StaticFiles
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class MissingAPIKeyError(Exception):
"""Exception for missing API key."""
@click.command()
@click.option("--host", default="localhost")
@click.option("--port", default=10002)
def main(host, port):
try:
# Check for API key only if Vertex AI is not configured
if not os.getenv("GOOGLE_GENAI_USE_VERTEXAI") == "TRUE":
if not os.getenv("GEMINI_API_KEY"):
raise MissingAPIKeyError(
"GEMINI_API_KEY environment variable not set and GOOGLE_GENAI_USE_VERTEXAI is not TRUE."
)
capabilities = AgentCapabilities(
streaming=True,
extensions=[get_a2ui_agent_extension()],
)
skill = AgentSkill(
id="find_restaurants",
name="Find Restaurants Tool",
description="Helps find restaurants based on user criteria (e.g., cuisine, location).",
tags=["restaurant", "finder"],
examples=["Find me the top 10 chinese restaurants in the US"],
)
base_url = f"http://{host}:{port}"
agent_card = AgentCard(
name="Restaurant Agent",
description="This agent helps find restaurants based on user criteria.",
url=base_url, # <-- Use base_url here
version="1.0.0",
default_input_modes=RestaurantAgent.SUPPORTED_CONTENT_TYPES,
default_output_modes=RestaurantAgent.SUPPORTED_CONTENT_TYPES,
capabilities=capabilities,
skills=[skill],
)
agent_executor = RestaurantAgentExecutor(base_url=base_url)
request_handler = DefaultRequestHandler(
agent_executor=agent_executor,
task_store=InMemoryTaskStore(),
)
server = A2AStarletteApplication(
agent_card=agent_card, http_handler=request_handler
)
import uvicorn
app = server.build()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Use absolute path based on this script's location
script_dir = os.path.dirname(os.path.abspath(__file__))
images_dir = os.path.join(script_dir, "images")
app.mount("/static", StaticFiles(directory=images_dir), name="static")
uvicorn.run(app, host=host, port=port)
except MissingAPIKeyError as e:
logger.error(f"Error: {e}")
exit(1)
except Exception as e:
logger.error(f"An error occurred during server startup: {e}")
exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,303 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import logging
import os
from collections.abc import AsyncIterable
from typing import Any
import jsonschema
from google.adk.agents.llm_agent import LlmAgent
from google.adk.artifacts import InMemoryArtifactService
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
from google.adk.models.lite_llm import LiteLlm
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types
from prompt_builder import (
A2UI_SCHEMA,
RESTAURANT_UI_EXAMPLES,
get_text_prompt,
get_ui_prompt,
)
from tools import get_restaurants
logger = logging.getLogger(__name__)
AGENT_INSTRUCTION = """
You are a helpful restaurant finding assistant. Your goal is to help users find and book restaurants using a rich UI.
To achieve this, you MUST follow this logic:
1. **For finding restaurants:**
a. You MUST call the `get_restaurants` tool. Extract the cuisine, location, and a specific number (`count`) of restaurants from the user's query (e.g., for "top 5 chinese places", count is 5).
b. After receiving the data, you MUST follow the instructions precisely to generate the final a2ui UI JSON, using the appropriate UI example from the `prompt_builder.py` based on the number of restaurants.
2. **For booking a table (when you receive a query like 'USER_WANTS_TO_BOOK...'):**
a. You MUST use the appropriate UI example from `prompt_builder.py` to generate the UI, populating the `dataModelUpdate.contents` with the details from the user's query.
3. **For confirming a booking (when you receive a query like 'User submitted a booking...'):**
a. You MUST use the appropriate UI example from `prompt_builder.py` to generate the confirmation UI, populating the `dataModelUpdate.contents` with the final booking details.
"""
class RestaurantAgent:
"""An agent that finds restaurants based on user criteria."""
SUPPORTED_CONTENT_TYPES = ["text", "text/plain"]
def __init__(self, base_url: str, use_ui: bool = False):
self.base_url = base_url
self.use_ui = use_ui
self._agent = self._build_agent(use_ui)
self._user_id = "remote_agent"
self._runner = Runner(
app_name=self._agent.name,
agent=self._agent,
artifact_service=InMemoryArtifactService(),
session_service=InMemorySessionService(),
memory_service=InMemoryMemoryService(),
)
# --- MODIFICATION: Wrap the schema ---
# Load the A2UI_SCHEMA string into a Python object for validation
try:
# First, load the schema for a *single message*
single_message_schema = json.loads(A2UI_SCHEMA)
# The prompt instructs the LLM to return a *list* of messages.
# Therefore, our validation schema must be an *array* of the single message schema.
self.a2ui_schema_object = {"type": "array", "items": single_message_schema}
logger.info(
"A2UI_SCHEMA successfully loaded and wrapped in an array validator."
)
except json.JSONDecodeError as e:
logger.error(f"CRITICAL: Failed to parse A2UI_SCHEMA: {e}")
self.a2ui_schema_object = None
# --- END MODIFICATION ---
def get_processing_message(self) -> str:
return "Finding restaurants that match your criteria..."
def _build_agent(self, use_ui: bool) -> LlmAgent:
"""Builds the LLM agent for the restaurant agent."""
LITELLM_MODEL = os.getenv("LITELLM_MODEL", "gemini/gemini-2.5-flash")
if use_ui:
# Construct the full prompt with UI instructions, examples, and schema
instruction = AGENT_INSTRUCTION + get_ui_prompt(
self.base_url, RESTAURANT_UI_EXAMPLES
)
else:
instruction = get_text_prompt()
return LlmAgent(
model=LiteLlm(model=LITELLM_MODEL),
name="restaurant_agent",
description="An agent that finds restaurants and helps book tables.",
instruction=instruction,
tools=[get_restaurants],
)
async def stream(self, query, session_id) -> AsyncIterable[dict[str, Any]]:
session_state = {"base_url": self.base_url}
session = await self._runner.session_service.get_session(
app_name=self._agent.name,
user_id=self._user_id,
session_id=session_id,
)
if session is None:
session = await self._runner.session_service.create_session(
app_name=self._agent.name,
user_id=self._user_id,
state=session_state,
session_id=session_id,
)
elif "base_url" not in session.state:
session.state["base_url"] = self.base_url
# --- Begin: UI Validation and Retry Logic ---
max_retries = 1 # Total 2 attempts
attempt = 0
current_query_text = query
# Ensure schema was loaded
if self.use_ui and self.a2ui_schema_object is None:
logger.error(
"--- RestaurantAgent.stream: A2UI_SCHEMA is not loaded. "
"Cannot perform UI validation. ---"
)
yield {
"is_task_complete": True,
"content": (
"I'm sorry, I'm facing an internal configuration error with my UI components. "
"Please contact support."
),
}
return
while attempt <= max_retries:
attempt += 1
logger.info(
f"--- RestaurantAgent.stream: Attempt {attempt}/{max_retries + 1} "
f"for session {session_id} ---"
)
current_message = types.Content(
role="user", parts=[types.Part.from_text(text=current_query_text)]
)
final_response_content = None
async for event in self._runner.run_async(
user_id=self._user_id,
session_id=session.id,
new_message=current_message,
):
logger.info(f"Event from runner: {event}")
if event.is_final_response():
if (
event.content
and event.content.parts
and event.content.parts[0].text
):
final_response_content = "\n".join(
[p.text for p in event.content.parts if p.text]
)
break # Got the final response, stop consuming events
else:
logger.info(f"Intermediate event: {event}")
# Yield intermediate updates on every attempt
yield {
"is_task_complete": False,
"updates": self.get_processing_message(),
}
if final_response_content is None:
logger.warning(
f"--- RestaurantAgent.stream: Received no final response content from runner "
f"(Attempt {attempt}). ---"
)
if attempt <= max_retries:
current_query_text = (
"I received no response. Please try again."
f"Please retry the original request: '{query}'"
)
continue # Go to next retry
else:
# Retries exhausted on no-response
final_response_content = "I'm sorry, I encountered an error and couldn't process your request."
# Fall through to send this as a text-only error
is_valid = False
error_message = ""
if self.use_ui:
logger.info(
f"--- RestaurantAgent.stream: Validating UI response (Attempt {attempt})... ---"
)
try:
if "---a2ui_JSON---" not in final_response_content:
raise ValueError("Delimiter '---a2ui_JSON---' not found.")
text_part, json_string = final_response_content.split(
"---a2ui_JSON---", 1
)
if not json_string.strip():
raise ValueError("JSON part is empty.")
json_string_cleaned = (
json_string.strip().lstrip("```json").rstrip("```").strip()
)
if not json_string_cleaned:
raise ValueError("Cleaned JSON string is empty.")
# --- New Validation Steps ---
# 1. Check if it's parsable JSON
parsed_json_data = json.loads(json_string_cleaned)
# 2. Check if it validates against the A2UI_SCHEMA
# This will raise jsonschema.exceptions.ValidationError if it fails
logger.info(
"--- RestaurantAgent.stream: Validating against A2UI_SCHEMA... ---"
)
jsonschema.validate(
instance=parsed_json_data, schema=self.a2ui_schema_object
)
# --- End New Validation Steps ---
logger.info(
f"--- RestaurantAgent.stream: UI JSON successfully parsed AND validated against schema. "
f"Validation OK (Attempt {attempt}). ---"
)
is_valid = True
except (
ValueError,
json.JSONDecodeError,
jsonschema.exceptions.ValidationError,
) as e:
logger.warning(
f"--- RestaurantAgent.stream: A2UI validation failed: {e} (Attempt {attempt}) ---"
)
logger.warning(
f"--- Failed response content: {final_response_content[:500]}... ---"
)
error_message = f"Validation failed: {e}."
else: # Not using UI, so text is always "valid"
is_valid = True
if is_valid:
logger.info(
f"--- RestaurantAgent.stream: Response is valid. Sending final response (Attempt {attempt}). ---"
)
logger.info(f"Final response: {final_response_content}")
yield {
"is_task_complete": True,
"content": final_response_content,
}
return # We're done, exit the generator
# --- If we're here, it means validation failed ---
if attempt <= max_retries:
logger.warning(
f"--- RestaurantAgent.stream: Retrying... ({attempt}/{max_retries + 1}) ---"
)
# Prepare the query for the retry
current_query_text = (
f"Your previous response was invalid. {error_message} "
"You MUST generate a valid response that strictly follows the A2UI JSON SCHEMA. "
"The response MUST be a JSON list of A2UI messages. "
"Ensure the response is split by '---a2ui_JSON---' and the JSON part is well-formed. "
f"Please retry the original request: '{query}'"
)
# Loop continues...
# --- If we're here, it means we've exhausted retries ---
logger.error(
"--- RestaurantAgent.stream: Max retries exhausted. Sending text-only error. ---"
)
yield {
"is_task_complete": True,
"content": (
"I'm sorry, I'm having trouble generating the interface for that request right now. "
"Please try again in a moment."
),
}
# --- End: UI Validation and Retry Logic ---
@@ -0,0 +1,197 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import logging
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.server.tasks import TaskUpdater
from a2a.types import (
DataPart,
Part,
Task,
TaskState,
TextPart,
UnsupportedOperationError,
)
from a2a.utils import (
new_agent_parts_message,
new_agent_text_message,
new_task,
)
from a2a.utils.errors import ServerError
from a2ui.a2ui_extension import create_a2ui_part, try_activate_a2ui_extension
from agent import RestaurantAgent
logger = logging.getLogger(__name__)
class RestaurantAgentExecutor(AgentExecutor):
"""Restaurant AgentExecutor Example."""
def __init__(self, base_url: str):
# Instantiate two agents: one for UI and one for text-only.
# The appropriate one will be chosen at execution time.
self.ui_agent = RestaurantAgent(base_url=base_url, use_ui=True)
self.text_agent = RestaurantAgent(base_url=base_url, use_ui=False)
async def execute(
self,
context: RequestContext,
event_queue: EventQueue,
) -> None:
query = ""
ui_event_part = None
action = None
logger.info(
f"--- Client requested extensions: {context.requested_extensions} ---"
)
use_ui = try_activate_a2ui_extension(context)
# Determine which agent to use based on whether the a2ui extension is active.
if use_ui:
agent = self.ui_agent
logger.info(
"--- AGENT_EXECUTOR: A2UI extension is active. Using UI agent. ---"
)
else:
agent = self.text_agent
logger.info(
"--- AGENT_EXECUTOR: A2UI extension is not active. Using text agent. ---"
)
if context.message and context.message.parts:
logger.info(
f"--- AGENT_EXECUTOR: Processing {len(context.message.parts)} message parts ---"
)
for i, part in enumerate(context.message.parts):
if isinstance(part.root, DataPart):
if "userAction" in part.root.data:
logger.info(f" Part {i}: Found a2ui UI ClientEvent payload.")
ui_event_part = part.root.data["userAction"]
else:
logger.info(f" Part {i}: DataPart (data: {part.root.data})")
elif isinstance(part.root, TextPart):
logger.info(f" Part {i}: TextPart (text: {part.root.text})")
else:
logger.info(f" Part {i}: Unknown part type ({type(part.root)})")
if ui_event_part:
logger.info(f"Received a2ui ClientEvent: {ui_event_part}")
action = ui_event_part.get("actionName")
ctx = ui_event_part.get("context", {})
if action == "book_restaurant":
restaurant_name = ctx.get("restaurantName", "Unknown Restaurant")
address = ctx.get("address", "Address not provided")
image_url = ctx.get("imageUrl", "")
query = f"USER_WANTS_TO_BOOK: {restaurant_name}, Address: {address}, ImageURL: {image_url}"
elif action == "submit_booking":
restaurant_name = ctx.get("restaurantName", "Unknown Restaurant")
party_size = ctx.get("partySize", "Unknown Size")
reservation_time = ctx.get("reservationTime", "Unknown Time")
dietary_reqs = ctx.get("dietary", "None")
image_url = ctx.get("imageUrl", "")
query = f"User submitted a booking for {restaurant_name} for {party_size} people at {reservation_time} with dietary requirements: {dietary_reqs}. The image URL is {image_url}"
else:
query = f"User submitted an event: {action} with data: {ctx}"
else:
logger.info("No a2ui UI event part found. Falling back to text input.")
query = context.get_user_input()
logger.info(f"--- AGENT_EXECUTOR: Final query for LLM: '{query}' ---")
task = context.current_task
if not task:
task = new_task(context.message)
await event_queue.enqueue_event(task)
updater = TaskUpdater(event_queue, task.id, task.context_id)
async for item in agent.stream(query, task.context_id):
is_task_complete = item["is_task_complete"]
if not is_task_complete:
await updater.update_status(
TaskState.working,
new_agent_text_message(item["updates"], task.context_id, task.id),
)
continue
final_state = (
TaskState.completed
if action == "submit_booking"
else TaskState.input_required
)
content = item["content"]
final_parts = []
if "---a2ui_JSON---" in content:
logger.info("Splitting final response into text and UI parts.")
text_content, json_string = content.split("---a2ui_JSON---", 1)
if text_content.strip():
final_parts.append(Part(root=TextPart(text=text_content.strip())))
if json_string.strip():
try:
json_string_cleaned = (
json_string.strip().lstrip("```json").rstrip("```").strip()
)
# The new protocol sends a stream of JSON objects.
# For this example, we'll assume they are sent as a list in the final response.
json_data = json.loads(json_string_cleaned)
if isinstance(json_data, list):
logger.info(
f"Found {len(json_data)} messages. Creating individual DataParts."
)
for message in json_data:
final_parts.append(create_a2ui_part(message))
else:
# Handle the case where a single JSON object is returned
logger.info(
"Received a single JSON object. Creating a DataPart."
)
final_parts.append(create_a2ui_part(json_data))
except json.JSONDecodeError as e:
logger.error(f"Failed to parse UI JSON: {e}")
final_parts.append(Part(root=TextPart(text=json_string)))
else:
final_parts.append(Part(root=TextPart(text=content.strip())))
logger.info("--- FINAL PARTS TO BE SENT ---")
for i, part in enumerate(final_parts):
logger.info(f" - Part {i}: Type = {type(part.root)}")
if isinstance(part.root, TextPart):
logger.info(f" - Text: {part.root.text[:200]}...")
elif isinstance(part.root, DataPart):
logger.info(f" - Data: {str(part.root.data)[:200]}...")
logger.info("-----------------------------")
await updater.update_status(
final_state,
new_agent_parts_message(final_parts, task.context_id, task.id),
final=(final_state == TaskState.completed),
)
break
async def cancel(
self, request: RequestContext, event_queue: EventQueue
) -> Task | None:
raise ServerError(error=UnsupportedOperationError())
@@ -0,0 +1 @@
../../../../images/restaurant-food/Spaghetti Carbonara.png
@@ -0,0 +1 @@
../../../../images/restaurant-food/beefbroccoli.jpeg
@@ -0,0 +1 @@
../../../../images/restaurant-food/classic tiramasu.png
@@ -0,0 +1 @@
../../../../images/restaurant-food/italian restaurant 2.png
@@ -0,0 +1 @@
../../../../images/restaurant-food/italian restaurant1.png
@@ -0,0 +1 @@
../../../../images/restaurant-food/italian1.png
@@ -0,0 +1 @@
../../../../images/restaurant-food/italian2.png
@@ -0,0 +1 @@
../../../../images/restaurant-food/italian3.png
@@ -0,0 +1 @@
../../../../images/restaurant-food/kungpao.jpeg
@@ -0,0 +1 @@
../../../../images/restaurant-food/lasagna.png
+1
View File
@@ -0,0 +1 @@
../../../../images/restaurant-food/logo.png
@@ -0,0 +1 @@
../../../../images/restaurant-food/mapotofu.jpeg
@@ -0,0 +1 @@
../../../../images/restaurant-food/margharita.png
@@ -0,0 +1 @@
../../../../images/restaurant-food/saffron risotto.png
@@ -0,0 +1 @@
../../../../images/restaurant-food/shrimpchowmein.jpeg
@@ -0,0 +1 @@
../../../../images/restaurant-food/springrolls.jpeg
@@ -0,0 +1 @@
../../../../images/restaurant-food/sweetsourpork.jpeg
@@ -0,0 +1 @@
../../../../images/restaurant-food/vegfriedrice.jpeg
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
[project]
name = "a2ui-restaurant-finder"
version = "0.1.0"
description = "Sample Google ADK-based Restaurant finder agent that uses a2ui UI and is hosted as an A2A server agent."
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"a2a-sdk>=0.3.0",
"click>=8.1.8",
"google-adk>=1.8.0",
"google-genai>=1.27.0",
"python-dotenv>=1.1.0",
"litellm",
"a2ui",
"jsonschema>=4.0.0",
]
[tool.hatch.build.targets.wheel]
packages = ["."]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.uv.sources]
a2ui = { workspace = true }
@@ -0,0 +1,66 @@
[
{
"name": "Xi'an Famous Foods",
"detail": "Spicy and savory hand-pulled noodles.",
"imageUrl": "http://localhost:10002/static/shrimpchowmein.jpeg",
"rating": "★★★★☆",
"infoLink": "[More Info](https://www.xianfoods.com/)",
"address": "81 St Marks Pl, New York, NY 10003"
},
{
"name": "Han Dynasty",
"detail": "Authentic Szechuan cuisine.",
"imageUrl": "http://localhost:10002/static/mapotofu.jpeg",
"rating": "★★★★☆",
"infoLink": "[More Info](https://www.handynasty.net/)",
"address": "90 3rd Ave, New York, NY 10003"
},
{
"name": "RedFarm",
"detail": "Modern Chinese with a farm-to-table approach.",
"imageUrl": "http://localhost:10002/static/beefbroccoli.jpeg",
"rating": "★★★★☆",
"infoLink": "[More Info](https://www.redfarmnyc.com/)",
"address": "529 Hudson St, New York, NY 10014"
},
{
"name": "Mott 32",
"detail": "Upscale Cantonese dining.",
"imageUrl": "http://localhost:10002/static/springrolls.jpeg",
"rating": "★★★★★",
"infoLink": "[More Info](https://mott32.com/newyork/)",
"address": "111 W 57th St, New York, NY 10019"
},
{
"name": "Hwa Yuan Szechuan",
"detail": "Famous for its cold noodles with sesame sauce.",
"imageUrl": "http://localhost:10002/static/kungpao.jpeg",
"rating": "★★★★☆",
"infoLink": "[More Info](https://hwayuannyc.com/)",
"address": "40 E Broadway, New York, NY 10002"
},
{
"name": "Cafe China",
"detail": "Szechuan food in a 1930s Shanghai setting.",
"imageUrl": "http://localhost:10002/static/mapotofu.jpeg",
"rating": "★★★★☆",
"infoLink": "[More Info](https://www.cafechinanyc.com/)",
"address": "59 W 37th St, New York, NY 10018"
},
{
"name": "Philippe Chow",
"detail": "High-end Beijing-style cuisine.",
"imageUrl": "http://localhost:10002/static/beefbroccoli.jpeg",
"rating": "★★★★☆",
"infoLink": "[More Info](https://www.philippechow.com/)",
"address": "33 E 60th St, New York, NY 10022"
},
{
"name": "Chinese Tuxedo",
"detail": "Contemporary Chinese in a former opera house.",
"imageUrl": "http://localhost:10002/static/mapotofu.jpeg",
"rating": "★★★★☆",
"infoLink": "[More Info](https://chinesetuxedo.com/)",
"address": "5 Doyers St, New York, NY 10013"
}
]
@@ -0,0 +1,59 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import logging
import os
from google.adk.tools.tool_context import ToolContext
logger = logging.getLogger(__name__)
def get_restaurants(
cuisine: str, location: str, tool_context: ToolContext, count: int = 5
) -> str:
"""Call this tool to get a list of restaurants based on a cuisine and location.
'count' is the number of restaurants to return.
"""
logger.info(f"--- TOOL CALLED: get_restaurants (count: {count}) ---")
logger.info(f" - Cuisine: {cuisine}")
logger.info(f" - Location: {location}")
items = []
if "new york" in location.lower() or "ny" in location.lower():
try:
script_dir = os.path.dirname(__file__)
file_path = os.path.join(script_dir, "restaurant_data.json")
with open(file_path) as f:
restaurant_data_str = f.read()
if base_url := tool_context.state.get("base_url"):
restaurant_data_str = restaurant_data_str.replace(
"http://localhost:10002", base_url
)
logger.info(f"Updated base URL from tool context: {base_url}")
all_items = json.loads(restaurant_data_str)
# Slice the list to return only the requested number of items
items = all_items[:count]
logger.info(
f" - Success: Found {len(all_items)} restaurants, returning {len(items)}."
)
except FileNotFoundError:
logger.error(f" - Error: restaurant_data.json not found at {file_path}")
except json.JSONDecodeError:
logger.error(f" - Error: Failed to decode JSON from {file_path}")
return json.dumps(items)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,162 @@
:root {
--n-100: #ffffff;
--n-99: #fcfcfc;
--n-98: #f9f9f9;
--n-95: #f1f1f1;
--n-90: #e2e2e2;
--n-80: #c6c6c6;
--n-70: #ababab;
--n-60: #919191;
--n-50: #777777;
--n-40: #5e5e5e;
--n-35: #525252;
--n-30: #474747;
--n-25: #3b3b3b;
--n-20: #303030;
--n-15: #262626;
--n-10: #1b1b1b;
--n-5: #111111;
--n-0: #000000;
--p-100: #ffffff;
--p-99: #fffbff;
--p-98: #fcf8ff;
--p-95: #f2efff;
--p-90: #e1e0ff;
--p-80: #c0c1ff;
--p-70: #a0a3ff;
--p-60: #8487ea;
--p-50: #6a6dcd;
--p-40: #5154b3;
--p-35: #4447a6;
--p-30: #383b99;
--p-25: #2c2e8d;
--p-20: #202182;
--p-15: #131178;
--p-10: #06006c;
--p-5: #03004d;
--p-0: #000000;
--s-100: #ffffff;
--s-99: #fffbff;
--s-98: #fcf8ff;
--s-95: #f2efff;
--s-90: #e2e0f9;
--s-80: #c6c4dd;
--s-70: #aaa9c1;
--s-60: #8f8fa5;
--s-50: #75758b;
--s-40: #5d5c72;
--s-35: #515165;
--s-30: #454559;
--s-25: #393a4d;
--s-20: #2e2f42;
--s-15: #242437;
--s-10: #191a2c;
--s-5: #0f0f21;
--s-0: #000000;
--t-100: #ffffff;
--t-99: #fffbff;
--t-98: #fff8f9;
--t-95: #ffecf4;
--t-90: #ffd8ec;
--t-80: #e9b9d3;
--t-70: #cc9eb8;
--t-60: #af849d;
--t-50: #946b83;
--t-40: #79536a;
--t-35: #6c475d;
--t-30: #5f3c51;
--t-25: #523146;
--t-20: #46263a;
--t-15: #3a1b2f;
--t-10: #2e1125;
--t-5: #22071a;
--t-0: #000000;
--nv-100: #ffffff;
--nv-99: #fffbff;
--nv-98: #fcf8ff;
--nv-95: #f2effa;
--nv-90: #e4e1ec;
--nv-80: #c8c5d0;
--nv-70: #acaab4;
--nv-60: #918f9a;
--nv-50: #777680;
--nv-40: #5e5d67;
--nv-35: #52515b;
--nv-30: #46464f;
--nv-25: #3b3b43;
--nv-20: #303038;
--nv-15: #25252d;
--nv-10: #1b1b23;
--nv-5: #101018;
--nv-0: #000000;
--e-100: #ffffff;
--e-99: #fffbff;
--e-98: #fff8f7;
--e-95: #ffedea;
--e-90: #ffdad6;
--e-80: #ffb4ab;
--e-70: #ff897d;
--e-60: #ff5449;
--e-50: #de3730;
--e-40: #ba1a1a;
--e-35: #a80710;
--e-30: #93000a;
--e-25: #7e0007;
--e-20: #690005;
--e-15: #540003;
--e-10: #410002;
--e-5: #2d0001;
--e-0: #000000;
--primary: #137fec;
--text-color: #fff;
--background-light: #f6f7f8;
--background-dark: #101922;
--border-color: oklch(
from var(--background-light) l c h / calc(alpha * 0.15)
);
--elevated-background-light: oklch(
from var(--background-light) l c h / calc(alpha * 0.05)
);
--bb-grid-size: 4px;
--bb-grid-size-2: calc(var(--bb-grid-size) * 2);
--bb-grid-size-3: calc(var(--bb-grid-size) * 3);
--bb-grid-size-4: calc(var(--bb-grid-size) * 4);
--bb-grid-size-5: calc(var(--bb-grid-size) * 5);
--bb-grid-size-6: calc(var(--bb-grid-size) * 6);
--bb-grid-size-7: calc(var(--bb-grid-size) * 7);
--bb-grid-size-8: calc(var(--bb-grid-size) * 8);
--bb-grid-size-9: calc(var(--bb-grid-size) * 9);
--bb-grid-size-10: calc(var(--bb-grid-size) * 10);
--bb-grid-size-11: calc(var(--bb-grid-size) * 11);
--bb-grid-size-12: calc(var(--bb-grid-size) * 12);
--bb-grid-size-13: calc(var(--bb-grid-size) * 13);
--bb-grid-size-14: calc(var(--bb-grid-size) * 14);
--bb-grid-size-15: calc(var(--bb-grid-size) * 15);
--bb-grid-size-16: calc(var(--bb-grid-size) * 16);
}
* {
box-sizing: border-box;
}
html,
body {
--font-family: "Google Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-family-flex:
"Google Sans Flex", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-family-mono:
"Google Sans Code", "Helvetica Neue", Helvetica, Arial, sans-serif;
background: var(--background-light);
font-family: var(--font-family);
margin: 0;
padding: 0;
width: 100svw;
height: 100svh;
}
@@ -0,0 +1,114 @@
import {
CopilotRuntime,
CopilotKitIntelligence,
createCopilotEndpoint,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import type {
AgentSubscriber,
RunAgentInput,
RunAgentParameters,
RunAgentResult,
} from "@ag-ui/client";
import { handle } from "hono/vercel";
import { A2AAgent } from "@ag-ui/a2a";
import type { A2AAgentConfig } from "@ag-ui/a2a";
import { A2AClient } from "@a2a-js/sdk/client";
const a2aClient = new A2AClient("http://localhost:10002");
type RuntimeRunAgentInput = RunAgentParameters &
Partial<Pick<RunAgentInput, "messages" | "state" | "threadId">>;
class RuntimeA2AAgent extends A2AAgent {
private readonly client: A2AClient;
constructor(config: A2AAgentConfig) {
super(config);
this.client = config.a2aClient;
}
async runAgent(
parameters: RuntimeRunAgentInput = {},
subscriber?: AgentSubscriber,
): Promise<RunAgentResult> {
const isolatedAgent = new A2AAgent({
a2aClient: this.client,
agentId: this.agentId,
debug: this.debug,
description: this.description,
initialMessages: this.messages,
initialState: this.state,
threadId: this.threadId,
});
if (parameters.threadId) {
isolatedAgent.threadId = parameters.threadId;
}
if (parameters.state) {
isolatedAgent.setState(parameters.state);
}
if (parameters.messages) {
isolatedAgent.setMessages(parameters.messages);
}
return isolatedAgent.runAgent(
{
context: parameters.context,
forwardedProps: parameters.forwardedProps,
runId: parameters.runId,
tools: parameters.tools,
},
subscriber,
);
}
clone(): RuntimeA2AAgent {
return new RuntimeA2AAgent({
a2aClient: this.client,
agentId: this.agentId,
debug: this.debug,
description: this.description,
initialMessages: this.messages,
initialState: this.state,
threadId: this.threadId,
});
}
}
const agent = new RuntimeA2AAgent({ a2aClient });
const runtime = new CopilotRuntime({
agents: {
default: agent,
},
a2ui: {},
// --- copilotkit:intelligence (remove this block to opt out) ---
...(process.env.COPILOTKIT_LICENSE_TOKEN
? {
intelligence: new CopilotKitIntelligence({
apiKey: process.env.INTELLIGENCE_API_KEY ?? "",
apiUrl: process.env.INTELLIGENCE_API_URL ?? "http://localhost:4201",
wsUrl:
process.env.INTELLIGENCE_GATEWAY_WS_URL ?? "ws://localhost:4401",
}),
// Demo stub — replace with your own auth-derived user identity (e.g. OIDC)
// before any multi-user deployment, or all users share one thread history.
identifyUser: () => ({ id: "demo-user", name: "Demo User" }),
licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN,
}
: { runner: new InMemoryAgentRunner() }),
// --- /copilotkit:intelligence ---
});
const app = createCopilotEndpoint({
runtime,
basePath: "/api/copilotkit",
});
export const GET = handle(app);
export const POST = handle(app);
export const PATCH = handle(app);
export const DELETE = handle(app);
@@ -0,0 +1,40 @@
"use client";
import { useEffect, useState } from "react";
import * as UI from "@a2ui/lit/ui";
type Status = "pending" | "success" | "failure";
const TARGET_TAG = "a2ui-root";
export function A2UIStatus() {
const [status, setStatus] = useState<Status>("pending");
useEffect(() => {
try {
const hasCustomElement =
typeof customElements !== "undefined" &&
!!customElements.get(TARGET_TAG);
const hasExport = typeof UI?.Root === "function";
if (hasCustomElement && hasExport) {
setStatus("success");
} else {
setStatus("failure");
}
} catch {
setStatus("failure");
}
}, []);
if (status === "pending") {
return null;
}
return status === "success" ? (
<span aria-live="polite"> All set</span>
) : (
<span aria-live="assertive"> Failed</span>
);
}
@@ -0,0 +1,187 @@
"use client";
import type { ReactActivityMessageRenderer } from "@copilotkit/react-core/v2";
import { z } from "zod";
type A2UIOperation = {
beginRendering?: { surfaceId?: string };
surfaceUpdate?: {
components?: Array<{
id?: string;
component?: {
Text?: {
text?: { literalString?: string };
};
};
}>;
};
dataModelUpdate?: {
contents?: A2UIDataEntry[];
};
};
type A2UIDataEntry = {
key: string;
valueString?: string;
valueMap?: A2UIDataEntry[];
};
type Restaurant = {
name?: string;
rating?: string;
detail?: string;
infoLink?: string;
imageUrl?: string;
address?: string;
};
function getOperations(content: unknown): A2UIOperation[] {
if (!content || typeof content !== "object") {
return [];
}
const payload = content as {
a2ui_operations?: A2UIOperation[];
operations?: A2UIOperation[];
};
const operations = payload.a2ui_operations ?? payload.operations;
if (Array.isArray(operations)) {
return operations;
}
if (!operations || typeof operations !== "object") {
return [];
}
if (
"beginRendering" in operations ||
"surfaceUpdate" in operations ||
"dataModelUpdate" in operations
) {
return [operations as A2UIOperation];
}
return Object.values(operations).filter(
(operation): operation is A2UIOperation =>
!!operation && typeof operation === "object",
);
}
function getTitle(operations: A2UIOperation[]): string {
for (const operation of operations) {
const title = operation.surfaceUpdate?.components?.find(
(component) => component.id === "title-heading",
)?.component?.Text?.text?.literalString;
if (title) {
return title;
}
}
return "Top Restaurants";
}
function dataEntriesToObject(entries: A2UIDataEntry[] = []): Restaurant {
return Object.fromEntries(
entries.map((entry) => [entry.key, entry.valueString ?? ""]),
);
}
function getRestaurants(operations: A2UIOperation[]): Restaurant[] {
const dataModel = operations.find(
(operation) => operation.dataModelUpdate,
)?.dataModelUpdate;
const itemsEntry = dataModel?.contents?.find(
(entry) => entry.key === "items",
);
return (itemsEntry?.valueMap ?? []).map((entry) =>
dataEntriesToObject(entry.valueMap),
);
}
function readableInfoLink(infoLink: string | undefined): string | null {
if (!infoLink) {
return null;
}
const match = infoLink.match(/\[([^\]]+)\]\(([^)]+)\)/);
return match?.[2] ?? infoLink;
}
function A2UIV08Surface({ content }: { content: unknown }) {
const operations = getOperations(content);
const restaurants = getRestaurants(operations);
const title = getTitle(operations);
if (!operations.length) {
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-500">
Generating UI...
</div>
);
}
return (
<div className="flex flex-col gap-4 py-4">
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
<div className="flex flex-col gap-3">
{restaurants.map((restaurant, index) => (
<article
key={`${restaurant.name ?? "restaurant"}-${index}`}
className="grid gap-4 rounded-lg border border-gray-200 bg-white p-4 shadow-sm sm:grid-cols-[144px_1fr]"
>
{restaurant.imageUrl ? (
<img
src={restaurant.imageUrl}
alt={restaurant.name ?? "Restaurant"}
className="h-32 w-full rounded-md object-cover sm:h-full"
/>
) : null}
<div className="flex min-w-0 flex-col gap-2">
<div>
<h3 className="text-base font-semibold text-gray-950">
{restaurant.name}
</h3>
{restaurant.rating ? (
<p className="text-sm text-amber-500">{restaurant.rating}</p>
) : null}
</div>
{restaurant.detail ? (
<p className="text-sm text-gray-600">{restaurant.detail}</p>
) : null}
{restaurant.address ? (
<p className="text-xs text-gray-500">{restaurant.address}</p>
) : null}
<div className="mt-1 flex flex-wrap gap-2">
{readableInfoLink(restaurant.infoLink) ? (
<a
href={readableInfoLink(restaurant.infoLink) ?? undefined}
target="_blank"
rel="noreferrer"
className="inline-flex h-9 items-center rounded-md border border-gray-200 px-3 text-sm font-medium text-gray-700"
>
More Info
</a>
) : null}
<button
type="button"
className="inline-flex h-9 items-center rounded-md bg-[#FF0000] px-3 text-sm font-medium text-white"
>
Book Now
</button>
</div>
</div>
</article>
))}
</div>
</div>
);
}
export const a2uiV08Renderer: ReactActivityMessageRenderer<unknown> = {
activityType: "a2ui-surface",
content: z.unknown(),
render: ({ content }) => <A2UIV08Surface content={content} />,
};
@@ -0,0 +1,45 @@
# Threads Panel — Design Notes (mastra)
These are mastra's **bespoke** copies of the threads panel. They are no longer a
shared/tokenized base component — they are styled to read as one product with
mastra's `CopilotSidebar` (the right-side chat from `@copilotkit/react-core/v2`).
## Design source of truth
All surfaces, borders, radii, and type ramps are lifted from CopilotKit's V2
design system: `@copilotkit/react-core/src/v2/styles/globals.css` plus the chat
components (`CopilotModalHeader`, `CopilotChatSuggestionPill`,
`CopilotChatInput`, `CopilotSidebarView`). The tokens are mirrored verbatim into
`src/app/globals.css`:
| Token | Value (V2 light) | Role in the panel |
| -------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `--card` / `--background` | `oklch(1 0 0)` (white) | Panel + card surfaces |
| `--foreground` | `oklch(0.145 0 0)` | Titles, thread titles, dialog text |
| `--muted` / `--secondary` / `--accent` | `oklch(0.97 0 0)` | Hover/active surfaces, segment track, archived chip, code well |
| `--muted-foreground` | `oklch(0.556 0 0)` | Meta text, idle icons, descriptions, placeholders |
| `--border` / `--input` | `oklch(0.922 0 0)` | All hairline borders |
| `--primary` | `oklch(0.205 0 0)` (near-black) | New-thread pill, selected accent, primary CTA — the V2 sidebar's primary buttons are charcoal/black, **not** a brand accent |
| `--primary-foreground` | `oklch(0.985 0 0)` | Primary button text |
| `--destructive` | `oklch(0.577 0.245 27.325)` | Delete hover |
| `--ring` | `oklch(0.708 0 0)` | Focus rings (2px box-shadow) |
| `--radius` | `0.625rem` (+ sm/md/lg/xl) | Rectangular controls; icon buttons / pills / segments use `999px` to echo the sidebar's close button, suggestion pills, and send button |
## Forced light
mastra's `CopilotSidebar` is always light regardless of OS color scheme. The
panel must match it, so `src/app/globals.css` re-pins `--foreground` and
`--background` to the V2 light values on `.threadsLayout` (the layout wrapper)
and on `body > [role="presentation"]` (the confirm dialog renders in a portal on
`<body>`). The dark-mode `@media (prefers-color-scheme: dark)` block only flips
the bare page `--background`/`--foreground`; the panel overrides win because
they are scoped to the layout/portal roots.
## Typography
Geist (the app font, via `--font-body` / `--font-code`). Sizes/weights track the
sidebar: header title `1rem / 500 / tracking-tight`, thread titles
`0.8125rem / 500`, meta `0.6875rem`, all medium-weight — no heavy `700`s.
Edit these files freely; they are mastra-owned and not shared with other
examples.
@@ -0,0 +1,4 @@
"use client";
export { default as ThreadsDrawer } from "./threads-drawer";
export type { ThreadsDrawerProps } from "./threads-drawer";
@@ -0,0 +1,70 @@
"use client";
import * as React from "react";
import { Lock } from "lucide-react";
import styles from "./threads-drawer.module.css";
export function ThreadsPanelGate({ children }: { children: React.ReactNode }) {
// The Threads drawer reads a client-only external store (useThreads /
// useSyncExternalStore) with no server snapshot, so it must not render during
// SSR/prerender — Next would fail to prerender "/". Defer to client mount.
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
if (process.env.NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED === "true") {
if (!mounted) {
// SSR / first-paint placeholder: matches the open drawer's footprint +
// surface (and collapses to nothing on mobile) so the panel doesn't flash
// a bare-background column or shift the content when the drawer mounts.
return <div className={styles.drawerPlaceholder} aria-hidden />;
}
return <>{children}</>;
}
return (
<aside aria-label="Threads (locked)" className={styles.lockedPanel}>
<div className={styles.drawerHeader}>
<div className={styles.drawerHeaderMain}>
<h2 className={styles.drawerTitle}>Threads</h2>
</div>
</div>
<div className={styles.lockedBody}>
<div className={styles.lockedCard}>
<span aria-hidden className={styles.lockedIcon}>
<Lock size={18} />
</span>
<div className={styles.lockedHeading}>
<h3 className={styles.lockedTitle}>
Threads is a licensed feature
</h3>
<p className={styles.lockedDescription}>
Unlock persistent conversation history, multi-session context, and
thread management with CopilotKit Intelligence.
</p>
</div>
<p className={styles.lockedDescription}>
Add it to your project with:
</p>
<div className={styles.lockedCommand}>
<code className={styles.lockedCommandCode}>
npx copilotkit@latest license
</code>
</div>
<button
type="button"
className={styles.lockedCta}
onClick={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
>
Learn more
</button>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,891 @@
/* Threads panel — a left-side companion to mastra's CopilotSidebar.
Every surface, border, radius, and type ramp here is lifted from CopilotKit's
V2 design system (@copilotkit/react-core/src/v2) so the panel reads as the
same product as the chat on the right:
- surfaces: white card on a white app; borders are the --border hairline
- radius: --radius (0.625rem) for rectangular controls; rounded-full
(999px) for icon buttons, the segmented control, and the New-thread
affordance — echoing the sidebar's close button, suggestion pills, and
send button
- type: 0.875rem base / font-medium / tracking-tight, matching the
sidebar header + pills (no heavy 700 weights)
- primary action color is the sidebar's near-black --primary, NOT a brand
accent — the V2 sidebar's primary buttons render bg-black/text-white */
.layout {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
width: 100%;
overflow: hidden;
}
.drawer {
position: relative;
display: flex;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
background: var(--card);
border-right: 1px solid var(--border);
font-family: var(--font-body);
transition:
width 180ms ease,
box-shadow 180ms ease;
}
.drawerOpen {
width: 18rem;
}
.drawerClosed {
width: 3.5rem;
}
/* First-paint placeholder (rendered by ThreadsPanelGate before the client-only
drawer mounts). Matches the open drawer's footprint + surface so there's no
bare-background column flash and no content shift on mount. On mobile the
real drawer floats (no grid footprint), so the placeholder reserves nothing. */
.drawerPlaceholder {
width: 18rem;
flex-shrink: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
background: var(--card);
border-right: 1px solid var(--border);
}
.drawerSurface {
display: flex;
flex: 1;
height: 100%;
flex-direction: column;
overflow: hidden;
}
/* Header — mirrors CopilotModalHeader: hairline bottom border, generous
px-4 py-4 rhythm, medium-weight tracking-tight title (not bold). */
.drawerHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.drawerHeaderMain {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.25rem;
}
.drawerTitle {
margin: 0;
font-size: 1rem;
font-weight: 500;
line-height: 1;
letter-spacing: -0.01em;
color: var(--foreground);
}
.headerActions {
display: flex;
align-items: center;
gap: 0.375rem;
}
/* Icon button — the sidebar's close button: size-8, rounded-full, muted
foreground, hover lifts to bg-muted + foreground. */
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 0;
border-radius: 999px;
color: var(--muted-foreground);
background: transparent;
transition:
background-color 140ms ease,
color 140ms ease;
cursor: pointer;
}
.iconButton:hover,
.iconButton:focus-visible {
background: var(--muted);
color: var(--foreground);
}
.iconButton:focus-visible,
.threadItem:focus-visible,
.newThreadButton:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
/* New-thread affordance — a compact pill in the sidebar's near-black
--primary, echoing the send button (bg-black, rounded-full, medium text). */
.newThreadButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
height: 2rem;
padding: 0 0.75rem;
border: 0;
border-radius: 999px;
background: var(--primary);
color: var(--primary-foreground);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition:
background-color 140ms ease,
opacity 140ms ease;
}
.newThreadButton:hover {
opacity: 0.9;
}
.filterBar {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
}
/* Segmented control — a single muted track with a white "lifted" active tab,
the same surface relationship the suggestion pills use (bg over muted). */
.segmented {
display: inline-flex;
width: 100%;
padding: 0.1875rem;
border-radius: 999px;
background: var(--muted);
gap: 0.1875rem;
}
.segmentedOption {
flex: 1;
min-height: 1.75rem;
padding: 0.3rem 0.75rem;
border: 0;
border-radius: 999px;
background: transparent;
font-family: var(--font-body);
font-size: 0.75rem;
font-weight: 500;
line-height: 1;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
box-shadow 140ms ease;
}
.segmentedOption:hover {
color: var(--foreground);
}
.segmentedOption:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.segmentedOptionActive {
background: var(--card);
color: var(--foreground);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.08);
}
.drawerContent {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.threadList {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
gap: 0.125rem;
overflow-y: auto;
/* Reserve scrollbar space so the list doesn't shift horizontally
when the scrollbar appears during the thread-enter animation. */
scrollbar-gutter: stable;
padding: 0.25rem 0.5rem 0.75rem;
}
.threadRow {
position: relative;
}
/* Thread row — a quiet hover/selected surface in the sidebar's accent gray,
--radius corners, no hairline rule between rows (the chat list is borderless
too). */
.threadItem {
display: flex;
width: 100%;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.625rem;
border: 0;
border-radius: var(--radius);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition:
background-color 140ms ease,
padding-right 140ms ease;
}
.threadItem:hover,
.threadItem:focus-visible {
background: var(--accent);
}
.threadRow:hover .threadItem,
.threadRow:focus-within .threadItem {
padding-right: 3.5rem;
}
.threadItemSelected,
.threadItemSelected:hover {
background: var(--accent);
}
.threadItemAnimatingIn {
animation: threadItemEnter 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
/* A slim left accent rail; muted by default, fills to --primary when selected
— the same charcoal that drives the primary action buttons. */
.threadAccent {
flex: none;
width: 0.1875rem;
height: 1.5rem;
border-radius: 999px;
background: transparent;
transition: background 140ms ease;
}
.threadItemSelected .threadAccent {
background: var(--primary);
}
.threadBody {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 0.125rem;
}
.threadTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.3;
color: var(--foreground);
}
.threadTitlePlaceholder {
color: var(--muted-foreground);
font-weight: 400;
}
.threadTitleAnimated {
display: inline-block;
animation: generatedTitleReveal 360ms cubic-bezier(0.22, 1, 0.36, 1);
transform-origin: left center;
}
.threadMeta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.6875rem;
line-height: 1.3;
color: var(--muted-foreground);
}
.threadItemArchived .threadTitle {
color: var(--muted-foreground);
font-weight: 400;
}
.threadItemArchived .threadAccent {
opacity: 0.5;
}
/* Archived chip — a muted pill, the same surface/type as the suggestion
pills (bg muted, font-medium, tiny). */
.archivedBadge {
display: inline-block;
margin-left: 0.375rem;
padding: 0.0625rem 0.375rem;
border-radius: 999px;
background: var(--muted);
font-size: 0.625rem;
font-weight: 500;
color: var(--muted-foreground);
vertical-align: middle;
}
/* Load-more — a full-width outline pill, the sidebar's suggestion-pill
treatment (border, bg-background, hover to accent). */
.loadMoreButton {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 2rem;
margin-top: 0.375rem;
padding: 0.4rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--card);
font-size: 0.75rem;
font-weight: 500;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease;
}
.loadMoreButton:hover:not(:disabled) {
background: var(--accent);
color: var(--foreground);
}
.loadMoreButton:disabled {
opacity: 0.6;
cursor: default;
}
.threadActions {
position: absolute;
right: 0.375rem;
top: 50%;
display: flex;
align-items: center;
gap: 0.125rem;
transform: translateY(-50%) scale(0.96);
opacity: 0;
pointer-events: none;
transition:
opacity 140ms ease,
transform 140ms ease;
}
.threadRow:hover .threadActions,
.threadRow:focus-within .threadActions {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scale(1);
}
.threadActionButton {
width: 1.75rem;
height: 1.75rem;
}
.tooltip {
position: relative;
}
/* Tooltip — matches the V2 dropdown/popover surface (white card, hairline
border, soft shadow, rounded-md), not an inverted chip. */
.tooltip::after {
content: attr(data-tooltip);
position: absolute;
/* Render below the trigger: a CSS pseudo-tooltip can't escape the drawer's
overflow containers, and "above" clips on the top row / collapsed rail. */
top: calc(100% + 0.35rem);
left: 50%;
transform: translateX(-50%) translateY(-2px);
padding: 0.25rem 0.5rem;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--card);
color: var(--foreground);
font-family: var(--font-body);
font-size: 0.6875rem;
font-weight: 500;
line-height: 1;
white-space: nowrap;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
opacity: 0;
pointer-events: none;
transition:
opacity 110ms ease 200ms,
transform 110ms ease 200ms;
z-index: 20;
}
.tooltip:hover::after,
.tooltip:focus-visible::after {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.deleteButton {
color: var(--muted-foreground);
}
.deleteButton:hover,
.deleteButton:focus-visible {
background: color-mix(in oklch, var(--destructive) 10%, transparent);
color: var(--destructive);
}
.loadingList {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.25rem 0;
}
.loadingRow {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.625rem;
border-radius: var(--radius);
}
.loadingAccent {
flex: none;
width: 0.1875rem;
height: 1.5rem;
border-radius: 999px;
background: var(--muted);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
}
.loadingBody {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.4rem;
}
.loadingTitleBar {
height: 0.6rem;
width: 60%;
border-radius: 999px;
background: var(--muted);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
}
.loadingMetaBar {
height: 0.45rem;
width: 35%;
border-radius: 999px;
background: var(--muted);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
animation-delay: 140ms;
}
@keyframes threadsDrawerPulse {
0%,
100% {
opacity: 0.45;
}
50% {
opacity: 0.9;
}
}
.emptyState {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
/* Empty/error card — clean white card with hairline border and the V2
radius-lg, same card vocabulary as the locked state. */
.emptyCard {
display: flex;
max-width: 13rem;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 1rem;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
font-family: var(--font-body);
}
.emptyTitle {
margin: 0;
font-size: 0.875rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.emptyMessage {
margin: 0;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
/* Locked state — same panel chrome (white surface, hairline right border,
header rhythm) as the unlocked drawer, with a single clean card that speaks
the V2 card vocabulary: rounded-lg, hairline border, muted icon chip, the
license command in a muted code well, and a near-black primary CTA
pill matching the sidebar's send button. */
.lockedPanel {
display: flex;
width: 20rem;
flex-shrink: 0;
flex-direction: column;
height: 100dvh;
background: var(--card);
border-right: 1px solid var(--border);
font-family: var(--font-body);
}
.lockedBody {
display: flex;
flex: 1;
min-height: 0;
align-items: center;
justify-content: center;
padding: 1rem;
}
.lockedCard {
display: flex;
width: 100%;
flex-direction: column;
gap: 0.875rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
}
.lockedIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
background: var(--muted);
color: var(--muted-foreground);
}
.lockedHeading {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.lockedTitle {
margin: 0;
font-size: 0.9375rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.lockedDescription {
margin: 0;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.lockedCommand {
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--muted);
}
.lockedCommandCode {
font-family: var(--font-code);
font-size: 0.75rem;
white-space: nowrap;
color: var(--secondary-foreground);
}
.lockedCta {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 2.25rem;
padding: 0 0.875rem;
border: 0;
border-radius: 999px;
background: var(--primary);
color: var(--primary-foreground);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: opacity 140ms ease;
}
.lockedCta:hover {
opacity: 0.9;
}
.lockedCta:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.collapsedRail {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem 0.5rem;
}
.mainPanel {
min-width: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
overflow: auto;
}
.dialogOverlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgb(0 0 0 / 0.4);
backdrop-filter: blur(2px);
animation: dialogOverlayEnter 140ms ease-out;
}
/* Confirm dialog — the V2 popover/card surface: white, hairline border,
radius-xl, soft elevated shadow. */
.dialog {
width: 100%;
max-width: 22rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--card);
color: var(--foreground);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.18);
font-family: var(--font-body);
animation: dialogEnter 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.dialogTitle {
margin: 0 0 0.4rem;
font-size: 0.9375rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.dialogDescription {
margin: 0 0 1.1rem;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.dialogActions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.dialogButton {
min-height: 2.25rem;
padding: 0.5rem 0.95rem;
border: 0;
border-radius: var(--radius);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
opacity 140ms ease;
}
.dialogButton:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.dialogButtonSecondary {
background: var(--card);
border: 1px solid var(--border);
color: var(--foreground);
}
.dialogButtonSecondary:hover {
background: var(--accent);
}
.dialogButtonPrimary {
background: var(--primary);
color: var(--primary-foreground);
}
.dialogButtonPrimary:hover {
opacity: 0.9;
}
.dialogButtonDestructive {
background: var(--destructive);
color: var(--destructive-foreground);
}
.dialogButtonDestructive:hover {
opacity: 0.9;
}
@keyframes dialogOverlayEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogEnter {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes threadItemEnter {
0% {
opacity: 0;
transform: translateX(-10px);
background: var(--accent);
}
100% {
opacity: 1;
transform: translateX(0);
background: transparent;
}
}
@keyframes generatedTitleReveal {
0% {
opacity: 0;
filter: blur(6px);
transform: translateY(4px);
}
100% {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
/* Tablet + phone: the threads panel goes off-canvas so the content and the
(full-screen) chat get the whole width instead of squeezing into a column. */
@media (max-width: 1024px) {
.layout {
position: relative;
isolation: isolate;
grid-template-columns: minmax(0, 1fr);
}
/* The mounted drawer floats on mobile, so the first-paint placeholder must
reserve no column (otherwise content shifts left when the drawer mounts). */
.drawerPlaceholder {
display: none;
}
/* No-license locked panel: hide on mobile (no interactive drawer to launch,
and a fixed-width panel leaves a dead column). */
.lockedPanel {
display: none;
}
/* Collapsed: a small floating launcher pinned top-left, above the full-screen
mobile chat (z-index 1200) so threads stay reachable over it. */
.drawer.drawerClosed {
position: fixed;
top: 0.5rem;
left: 0.5rem;
width: auto;
height: auto;
/* Override the base drawer's full-viewport height + chrome so the closed
state shrinks to a small floating launcher. */
min-height: 0;
border-right: 0;
background: transparent;
z-index: 1300;
}
.drawerClosed .collapsedRail {
flex-direction: row;
width: auto;
height: auto;
gap: 0.25rem;
padding: 0.25rem;
overflow: visible;
border-radius: 999px;
background: var(--card);
border: 1px solid var(--border);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.16);
}
/* Open: full-height off-canvas panel from the left, above the chat. */
.drawer.drawerOpen {
position: fixed;
inset: 0 auto 0 0;
z-index: 1300;
width: min(20rem, 92vw);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.25);
}
.mainPanel {
grid-column: 1;
position: relative;
z-index: 1;
}
}
@@ -0,0 +1,586 @@
"use client";
import {
Archive,
ArchiveRestore,
ChevronLeft,
ChevronRight,
Plus,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useThreads } from "@copilotkit/react-core/v2";
import styles from "./threads-drawer.module.css";
export interface ThreadsDrawerProps {
agentId: string;
threadId: string | undefined;
onThreadChange: (threadId: string | undefined) => void;
}
interface DrawerThread {
id: string;
name: string | null;
updatedAt: string;
archived: boolean;
lastRunAt?: string;
}
const THREAD_ENTRY_ANIMATION_MS = 420;
const TITLE_ANIMATION_MS = 360;
const UNTITLED_THREAD_LABEL = "New thread";
const RUNTIME_BASE_PATH = "/api/copilotkit";
function formatThreadTimestamp(updatedAt: string): string {
const timestamp = new Date(updatedAt);
if (Number.isNaN(timestamp.getTime())) return "Updated recently";
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
}).format(timestamp);
}
function cx(...classNames: Array<string | false | undefined>): string {
return classNames.filter(Boolean).join(" ");
}
export default function ThreadsDrawer({
agentId,
threadId,
onThreadChange,
}: ThreadsDrawerProps) {
const [showArchived, setShowArchived] = useState(false);
// Start collapsed on narrow screens (tablet + phone) so the panel — which
// becomes an off-canvas overlay below 1024px — doesn't cover the content +
// chat on load. The drawer is client-mounted, so reading window here is safe
// and won't cause a hydration mismatch.
const [isOpen, setIsOpen] = useState(
() => typeof window === "undefined" || window.innerWidth > 1024,
);
const [pendingDelete, setPendingDelete] = useState<{
id: string;
title: string;
} | null>(null);
const deleteTriggerRef = useRef<HTMLElement | null>(null);
const {
threads,
archiveThread,
deleteThread,
error,
isLoading,
hasMoreThreads,
isFetchingMoreThreads,
fetchMoreThreads,
} = useThreads({
agentId,
includeArchived: showArchived,
limit: 20,
});
const restoreThread = useCallback(
async (id: string) => {
const response = await fetch(
`${RUNTIME_BASE_PATH}/threads/${encodeURIComponent(id)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentId, archived: false }),
},
);
if (!response.ok) {
throw new Error(
`Restore failed: ${response.status} ${response.statusText}`,
);
}
},
[agentId],
);
const hasMountedRef = useRef(false);
const hasLoadedOnceRef = useRef(false);
const stableThreadsRef = useRef<DrawerThread[]>(threads);
const previousThreadIdsRef = useRef<Set<string>>(new Set());
const previousNamesRef = useRef<Map<string, string | null>>(new Map());
const entryTimeoutsRef = useRef<Map<string, number>>(new Map());
const titleTimeoutsRef = useRef<Map<string, number>>(new Map());
if (!isLoading) {
hasLoadedOnceRef.current = true;
stableThreadsRef.current = threads;
}
const displayThreads: DrawerThread[] =
isLoading && hasLoadedOnceRef.current ? stableThreadsRef.current : threads;
const [enteringThreadIds, setEnteringThreadIds] = useState<
Record<string, true>
>({});
const [revealedTitleIds, setRevealedTitleIds] = useState<
Record<string, true>
>({});
useEffect(() => {
return () => {
for (const timeoutId of entryTimeoutsRef.current.values()) {
window.clearTimeout(timeoutId);
}
for (const timeoutId of titleTimeoutsRef.current.values()) {
window.clearTimeout(timeoutId);
}
};
}, []);
useEffect(() => {
// Skip diffing while the store is refetching (e.g. after a filter change
// clears the list). Otherwise every thread would be treated as newly
// added once the new page lands.
if (isLoading) return;
const nextThreadIds = new Set(threads.map((t) => t.id));
if (!hasMountedRef.current) {
hasMountedRef.current = true;
previousThreadIdsRef.current = nextThreadIds;
previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name]));
return;
}
const addedThreadIds = threads
.filter((t) => !previousThreadIdsRef.current.has(t.id))
.map((t) => t.id);
if (addedThreadIds.length > 0) {
setEnteringThreadIds((current) => {
const next = { ...current };
for (const id of addedThreadIds) {
next[id] = true;
const existing = entryTimeoutsRef.current.get(id);
if (existing !== undefined) window.clearTimeout(existing);
const tid = window.setTimeout(() => {
setEnteringThreadIds((s) => {
const updated = { ...s };
delete updated[id];
return updated;
});
entryTimeoutsRef.current.delete(id);
}, THREAD_ENTRY_ANIMATION_MS);
entryTimeoutsRef.current.set(id, tid);
}
return next;
});
}
const renamedThreadIds = threads
.filter((t) => {
// Only reveal when an already-tracked thread's name transitions from
// null → named. Threads appearing for the first time (e.g. on a
// filter switch) already have their final name and should not trigger
// the title reveal animation — that would layer a blur/translateY
// onto the row's enter animation and produce a visible jitter.
if (!previousNamesRef.current.has(t.id)) return false;
const prev = previousNamesRef.current.get(t.id) ?? null;
return prev === null && t.name !== null;
})
.map((t) => t.id);
if (renamedThreadIds.length > 0) {
setRevealedTitleIds((current) => {
const next = { ...current };
for (const id of renamedThreadIds) {
next[id] = true;
const existing = titleTimeoutsRef.current.get(id);
if (existing !== undefined) window.clearTimeout(existing);
const tid = window.setTimeout(() => {
setRevealedTitleIds((s) => {
const updated = { ...s };
delete updated[id];
return updated;
});
titleTimeoutsRef.current.delete(id);
}, TITLE_ANIMATION_MS);
titleTimeoutsRef.current.set(id, tid);
}
return next;
});
}
previousThreadIdsRef.current = nextThreadIds;
previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name]));
}, [threads, isLoading]);
const isInitialLoading = isLoading && !hasLoadedOnceRef.current;
if (error) {
console.error("Unable to load threads", error);
}
if (!isOpen) {
return (
<aside
aria-label="Threads drawer"
className={cx(styles.drawer, styles.drawerClosed)}
>
<div className={styles.collapsedRail}>
{/* Native title here (not the styled ::after): the collapsed rail
sits at the viewport's left edge where a centered tooltip clips. */}
<button
aria-label="Open threads drawer"
title="Expand"
className={styles.iconButton}
type="button"
onClick={() => setIsOpen(true)}
>
<ChevronRight size={18} />
</button>
<button
aria-label="Create thread"
title="New thread"
className={styles.iconButton}
type="button"
onClick={() => onThreadChange(crypto.randomUUID())}
>
<Plus size={18} />
</button>
</div>
</aside>
);
}
const closeDeleteDialog = () => {
setPendingDelete(null);
const trigger = deleteTriggerRef.current;
deleteTriggerRef.current = null;
trigger?.focus?.();
};
return (
<>
<aside
aria-label="Threads drawer"
className={cx(styles.drawer, styles.drawerOpen)}
>
<div className={styles.drawerSurface}>
<div className={styles.drawerHeader}>
<div className={styles.drawerHeaderMain}>
<h2 className={styles.drawerTitle}>Threads</h2>
</div>
<div className={styles.headerActions}>
<button
aria-label="Create thread"
className={styles.newThreadButton}
type="button"
onClick={() => onThreadChange(crypto.randomUUID())}
>
<Plus size={14} />
<span>New thread</span>
</button>
<button
aria-label="Collapse threads drawer"
className={styles.iconButton}
type="button"
onClick={() => setIsOpen(false)}
>
<ChevronLeft size={18} />
</button>
</div>
</div>
<div className={styles.filterBar}>
<div
aria-label="Thread filter"
className={styles.segmented}
role="tablist"
>
<button
aria-selected={!showArchived}
className={cx(
styles.segmentedOption,
!showArchived && styles.segmentedOptionActive,
)}
role="tab"
type="button"
onClick={() => setShowArchived(false)}
>
Active
</button>
<button
aria-selected={showArchived}
className={cx(
styles.segmentedOption,
showArchived && styles.segmentedOptionActive,
)}
role="tab"
type="button"
onClick={() => setShowArchived(true)}
>
All
</button>
</div>
</div>
<div className={styles.drawerContent}>
{error ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>
Couldn&rsquo;t load threads
</p>
<p className={styles.emptyMessage}>
The thread list failed to load. Try reloading the page.
</p>
<button
className={styles.loadMoreButton}
type="button"
onClick={() => window.location.reload()}
>
Reload
</button>
</div>
</div>
) : isInitialLoading ? (
<div
aria-busy="true"
aria-label="Loading threads"
className={styles.loadingList}
role="status"
>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={styles.loadingRow}>
<span className={styles.loadingAccent} />
<span className={styles.loadingBody}>
<span className={styles.loadingTitleBar} />
<span className={styles.loadingMetaBar} />
</span>
</div>
))}
</div>
) : displayThreads.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>No threads yet</p>
<p className={styles.emptyMessage}>
Create a thread to start a fresh conversation.
</p>
</div>
</div>
) : (
<div className={styles.threadList}>
{displayThreads.map((thread) => {
const hasTitle = thread.name !== null;
const title = thread.name ?? UNTITLED_THREAD_LABEL;
return (
<div key={thread.id} className={styles.threadRow}>
<button
aria-current={
threadId === thread.id ? "page" : undefined
}
className={cx(
styles.threadItem,
threadId === thread.id && styles.threadItemSelected,
enteringThreadIds[thread.id] &&
styles.threadItemAnimatingIn,
thread.archived && styles.threadItemArchived,
)}
type="button"
onClick={() => onThreadChange(thread.id)}
>
<span aria-hidden className={styles.threadAccent} />
<span className={styles.threadBody}>
<span
className={cx(
styles.threadTitle,
!hasTitle && styles.threadTitlePlaceholder,
revealedTitleIds[thread.id] &&
styles.threadTitleAnimated,
)}
>
{title}
{thread.archived && (
<span className={styles.archivedBadge}>
Archived
</span>
)}
</span>
<span className={styles.threadMeta}>
{formatThreadTimestamp(
thread.lastRunAt ?? thread.updatedAt,
)}
</span>
</span>
</button>
<div className={styles.threadActions}>
{thread.archived ? (
<button
aria-label={`Restore ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Restore thread"
type="button"
onClick={() => {
restoreThread(thread.id).catch((err: unknown) => {
console.error("Unable to restore thread", err);
});
}}
>
<ArchiveRestore size={14} />
</button>
) : (
<button
aria-label={`Archive ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Archive thread"
type="button"
onClick={() => {
if (threadId === thread.id)
onThreadChange(undefined);
archiveThread(thread.id).catch((err: unknown) => {
console.error("Unable to archive thread", err);
});
}}
>
<Archive size={14} />
</button>
)}
<button
aria-label={`Delete ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.deleteButton,
styles.tooltip,
)}
data-tooltip="Delete thread"
type="button"
onClick={(e) => {
deleteTriggerRef.current = e.currentTarget;
setPendingDelete({ id: thread.id, title });
}}
>
<Trash2 size={14} />
</button>
</div>
</div>
);
})}
{hasMoreThreads && (
<button
className={styles.loadMoreButton}
disabled={isFetchingMoreThreads}
type="button"
onClick={fetchMoreThreads}
>
{isFetchingMoreThreads ? "Loading\u2026" : "Load more"}
</button>
)}
</div>
)}
</div>
</div>
</aside>
{pendingDelete && (
<ConfirmDialog
confirmLabel="Delete"
description={`Delete "${pendingDelete.title}"? This cannot be undone.`}
destructive
title="Delete thread"
onCancel={closeDeleteDialog}
onConfirm={() => {
const { id } = pendingDelete;
closeDeleteDialog();
if (threadId === id) onThreadChange(undefined);
deleteThread(id).catch((err: unknown) => {
console.error("Unable to delete thread", err);
});
}}
/>
)}
</>
);
}
interface ConfirmDialogProps {
title: string;
description: string;
confirmLabel: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function ConfirmDialog({
title,
description,
confirmLabel,
cancelLabel = "Cancel",
destructive = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const titleId = useId();
const descId = useId();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
if (typeof document === "undefined") return null;
return createPortal(
<div
className={styles.dialogOverlay}
role="presentation"
onClick={onCancel}
>
<div
aria-describedby={descId}
aria-labelledby={titleId}
aria-modal="true"
className={styles.dialog}
role="dialog"
onClick={(e) => e.stopPropagation()}
>
<h3 className={styles.dialogTitle} id={titleId}>
{title}
</h3>
<p className={styles.dialogDescription} id={descId}>
{description}
</p>
<div className={styles.dialogActions}>
<button
autoFocus
className={cx(styles.dialogButton, styles.dialogButtonSecondary)}
type="button"
onClick={onCancel}
>
{cancelLabel}
</button>
<button
className={cx(
styles.dialogButton,
destructive
? styles.dialogButtonDestructive
: styles.dialogButtonPrimary,
)}
type="button"
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,58 @@
@import "tailwindcss";
@config "../tailwind.config.ts";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
:root {
--background: #ffffff;
--foreground: #171717;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-body: Arial, Helvetica, sans-serif;
--font-code: "SFMono-Regular", Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
@layer base {
body {
@apply min-h-screen bg-background text-foreground font-sans antialiased;
}
}
.threadsLayout,
body > [role="presentation"] {
--foreground: oklch(0.145 0 0);
--background: oklch(1 0 0);
}
@@ -0,0 +1,43 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import "./a2ui-theme.css";
import "@copilotkit/react-core/v2/styles.css";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<head>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Google+Sans+Code&family=Google+Sans+Flex:opsz,wght,ROND@6..144,1..1000,100&family=Google+Sans:opsz,wght@17..18,400..700&display=block&family=IBM+Plex+Serif:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Google+Symbols:opsz,wght,FILL,GRAD,ROND@20..48,100..700,0..1,-50..200,0..100&display=swap&icon_names=arrow_drop_down,check_circle,close,communication,content_copy,delete,draw,error,info,mobile_layout,pen_size_1,progress_activity,rectangle,send,upload,warning"
/>
</head>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
</body>
</html>
);
}
@@ -0,0 +1,55 @@
"use client";
import {
CopilotChat,
CopilotChatConfigurationProvider,
CopilotKitProvider,
} from "@copilotkit/react-core/v2";
import { useState } from "react";
import { a2uiV08Renderer } from "./components/a2ui-v0-8-renderer";
import { ThreadsDrawer } from "./components/threads-drawer";
import { ThreadsPanelGate } from "./components/threads-drawer/locked-state";
import styles from "./components/threads-drawer/threads-drawer.module.css";
import { theme } from "./theme";
// Disable static optimization for this page
export const dynamic = "force-dynamic";
const activityRenderers = [a2uiV08Renderer];
export default function Home() {
const [threadId, setThreadId] = useState<string | undefined>(undefined);
return (
<CopilotKitProvider
runtimeUrl="/api/copilotkit"
showDevConsole="auto"
useSingleEndpoint={false}
a2ui={{ theme }}
renderActivityMessages={activityRenderers}
>
<div className={`${styles.layout} threadsLayout`}>
<ThreadsPanelGate>
<ThreadsDrawer
agentId="default"
threadId={threadId}
onThreadChange={setThreadId}
/>
</ThreadsPanelGate>
<div className={styles.mainPanel}>
<CopilotChatConfigurationProvider
agentId="default"
threadId={threadId}
>
<main
className="h-full overflow-auto w-screen"
style={{ minHeight: "100dvh" }}
>
<CopilotChat agentId="default" className="h-full" />
</main>
</CopilotChatConfigurationProvider>
</div>
</div>
</CopilotKitProvider>
);
}
+429
View File
@@ -0,0 +1,429 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Styles, type Types } from "@a2ui/lit/0.8";
/** Elements */
const a = {
"typography-f-sf": true,
"typography-fs-n": true,
"typography-w-500": true,
"layout-as-n": true,
"layout-dis-iflx": true,
"layout-al-c": true,
};
const audio = {
"layout-w-100": true,
};
const body = {
"typography-f-s": true,
"typography-fs-n": true,
"typography-w-400": true,
"layout-mt-0": true,
"layout-mb-2": true,
"typography-sz-bm": true,
"color-c-n10": true,
};
const button = {
"typography-f-sf": true,
"typography-fs-n": true,
"typography-w-500": true,
"layout-pt-3": true,
"layout-pb-3": true,
"layout-pl-5": true,
"layout-pr-5": true,
"layout-mb-1": true,
"border-br-16": true,
"border-bw-0": true,
"border-c-n70": true,
"border-bs-s": true,
"color-bgc-s30": true,
"color-c-n100": true,
"behavior-ho-80": true,
};
const heading = {
"typography-f-sf": true,
"typography-fs-n": true,
"typography-w-500": true,
"layout-mt-0": true,
"layout-mb-2": true,
"color-c-n10": true,
};
const h1 = {
...heading,
"typography-sz-tl": true,
};
const h2 = {
...heading,
"typography-sz-tm": true,
};
const h3 = {
...heading,
"typography-sz-ts": true,
};
const h4 = {
...heading,
"typography-sz-bl": true,
};
const h5 = {
...heading,
"typography-sz-bm": true,
};
const iframe = {
"behavior-sw-n": true,
};
const input = {
"typography-f-sf": true,
"typography-fs-n": true,
"typography-w-400": true,
"layout-pl-4": true,
"layout-pr-4": true,
"layout-pt-2": true,
"layout-pb-2": true,
"border-br-6": true,
"border-bw-1": true,
"color-bc-s70": true,
"border-bs-s": true,
"layout-as-n": true,
"color-c-n10": true,
};
const p = {
"typography-f-s": true,
"typography-fs-n": true,
"typography-w-400": true,
"layout-m-0": true,
"typography-sz-bm": true,
"layout-as-n": true,
"color-c-n10": true,
};
const orderedList = {
"typography-f-s": true,
"typography-fs-n": true,
"typography-w-400": true,
"layout-m-0": true,
"typography-sz-bm": true,
"layout-as-n": true,
};
const unorderedList = {
"typography-f-s": true,
"typography-fs-n": true,
"typography-w-400": true,
"layout-m-0": true,
"typography-sz-bm": true,
"layout-as-n": true,
};
const listItem = {
"typography-f-s": true,
"typography-fs-n": true,
"typography-w-400": true,
"layout-m-0": true,
"typography-sz-bm": true,
"layout-as-n": true,
};
const pre = {
"typography-f-c": true,
"typography-fs-n": true,
"typography-w-400": true,
"typography-sz-bm": true,
"typography-ws-p": true,
"layout-as-n": true,
};
const textarea = {
...input,
"layout-r-none": true,
"layout-fs-c": true,
};
const video = {
"layout-el-cv": true,
};
const aLight = Styles.merge(a, { "color-c-n5": true });
const inputLight = Styles.merge(input, { "color-c-n5": true });
const textareaLight = Styles.merge(textarea, { "color-c-n5": true });
const buttonLight = Styles.merge(button, { "color-c-n100": true });
const h1Light = Styles.merge(h1, { "color-c-n5": true });
const h2Light = Styles.merge(h2, { "color-c-n5": true });
const h3Light = Styles.merge(h3, { "color-c-n5": true });
const h4Light = Styles.merge(h4, { "color-c-n5": true });
const h5Light = Styles.merge(h5, { "color-c-n5": true });
const bodyLight = Styles.merge(body, { "color-c-n5": true });
const pLight = Styles.merge(p, { "color-c-n35": true });
const preLight = Styles.merge(pre, { "color-c-n35": true });
const orderedListLight = Styles.merge(orderedList, {
"color-c-n35": true,
});
const unorderedListLight = Styles.merge(unorderedList, {
"color-c-n35": true,
});
const listItemLight = Styles.merge(listItem, {
"color-c-n35": true,
});
export const theme: Types.Theme = {
additionalStyles: {
Button: {
"--n-35": "var(--n-100)",
},
},
components: {
AudioPlayer: {},
Button: {
"layout-pt-2": true,
"layout-pb-2": true,
"layout-pl-3": true,
"layout-pr-3": true,
"border-br-12": true,
"border-bw-0": true,
"border-bs-s": true,
"color-bgc-p30": true,
"color-c-n100": true,
"behavior-ho-70": true,
},
Card: { "border-br-9": true, "color-bgc-p100": true, "layout-p-4": true },
CheckBox: {
element: {
"layout-m-0": true,
"layout-mr-2": true,
"layout-p-2": true,
"border-br-12": true,
"border-bw-1": true,
"border-bs-s": true,
"color-bgc-p100": true,
"color-bc-p60": true,
"color-c-n30": true,
"color-c-p30": true,
},
label: {
"color-c-p30": true,
"typography-f-sf": true,
"typography-v-r": true,
"typography-w-400": true,
"layout-flx-1": true,
"typography-sz-ll": true,
},
container: {
"layout-dsp-iflex": true,
"layout-al-c": true,
},
},
Column: {
"layout-g-2": true,
},
DateTimeInput: {
container: {
"typography-sz-bm": true,
"layout-w-100": true,
"layout-g-2": true,
"layout-dsp-flexhor": true,
"layout-al-c": true,
},
label: {
"layout-flx-0": true,
},
element: {
"layout-pt-2": true,
"layout-pb-2": true,
"layout-pl-3": true,
"layout-pr-3": true,
"border-br-12": true,
"border-bw-1": true,
"border-bs-s": true,
"color-bgc-p100": true,
"color-bc-p60": true,
"color-c-n30": true,
"color-c-p30": true,
},
},
Divider: {},
Image: {
all: {
"border-br-5": true,
"layout-el-cv": true,
"layout-w-100": true,
"layout-h-100": true,
},
avatar: {},
header: {},
icon: {},
largeFeature: {},
mediumFeature: {},
smallFeature: {},
},
Icon: {},
List: {
"layout-g-4": true,
"layout-p-2": true,
},
Modal: {
backdrop: { "color-bbgc-p60_20": true },
element: {
"border-br-2": true,
"color-bgc-p100": true,
"layout-p-4": true,
"border-bw-1": true,
"border-bs-s": true,
"color-bc-p80": true,
},
},
MultipleChoice: {
container: {},
label: {},
element: {},
},
Row: {
"layout-g-4": true,
},
Slider: {
container: {},
label: {},
element: {},
},
Tabs: {
container: {},
controls: { all: {}, selected: {} },
element: {},
},
Text: {
all: {
"layout-w-100": true,
"layout-g-2": true,
"color-c-p30": true,
},
h1: {
"typography-f-sf": true,
"typography-v-r": true,
"typography-w-400": true,
"layout-m-0": true,
"layout-p-0": true,
"typography-sz-tl": true,
},
h2: {
"typography-f-sf": true,
"typography-v-r": true,
"typography-w-400": true,
"layout-m-0": true,
"layout-p-0": true,
"typography-sz-tm": true,
},
h3: {
"typography-f-sf": true,
"typography-v-r": true,
"typography-w-400": true,
"layout-m-0": true,
"layout-p-0": true,
"typography-sz-ts": true,
},
h4: {
"typography-f-sf": true,
"typography-v-r": true,
"typography-w-400": true,
"layout-m-0": true,
"layout-p-0": true,
"typography-sz-bl": true,
},
h5: {
"typography-f-sf": true,
"typography-v-r": true,
"typography-w-400": true,
"layout-m-0": true,
"layout-p-0": true,
"typography-sz-bm": true,
},
body: {},
caption: {},
},
TextField: {
container: {
"typography-sz-bm": true,
"layout-w-100": true,
"layout-g-2": true,
"layout-dsp-flexhor": true,
"layout-al-c": true,
},
label: {
"layout-flx-0": true,
},
element: {
"typography-sz-bm": true,
"layout-pt-2": true,
"layout-pb-2": true,
"layout-pl-3": true,
"layout-pr-3": true,
"border-br-12": true,
"border-bw-1": true,
"border-bs-s": true,
"color-bgc-p100": true,
"color-bc-p60": true,
"color-c-n30": true,
"color-c-p30": true,
},
},
Video: {
"border-br-5": true,
"layout-el-cv": true,
},
},
elements: {
a: aLight,
audio,
body: bodyLight,
button: buttonLight,
h1: h1Light,
h2: h2Light,
h3: h3Light,
h4: h4Light,
h5: h5Light,
iframe,
input: inputLight,
p: pLight,
pre: preLight,
textarea: textareaLight,
video,
},
markdown: {
p: Object.keys(pLight),
h1: Object.keys(h1Light),
h2: Object.keys(h2Light),
h3: Object.keys(h3Light),
h4: Object.keys(h4Light),
h5: Object.keys(h5Light),
ul: Object.keys(unorderedListLight),
ol: Object.keys(orderedListLight),
li: Object.keys(listItemLight),
a: Object.keys(aLight),
strong: [],
em: [],
},
};
@@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
serverExternalPackages: ["@copilotkit/runtime"],
env: {
NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: process.env.COPILOTKIT_LICENSE_TOKEN
? "true"
: "false",
},
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
{
"name": "with-a2a-a2ui",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"npm run dev:ui\" \"npm run dev:agent\" --names ui,agent --prefix-colors blue,green --kill-others",
"dev:debug": "LOG_LEVEL=debug npm run dev",
"dev:agent": "./scripts/run-agent.sh || scripts\\run-agent.bat",
"dev:ui": "next dev --turbopack",
"build": "next build",
"start": "next start",
"install:agent": "./scripts/setup-agent.sh || scripts\\setup-agent.bat",
"postinstall": "npm run install:agent"
},
"dependencies": {
"@a2a-js/sdk": "0.2.5",
"@a2ui/lit": "^0.8.1",
"@ag-ui/a2a": "0.00.6",
"@ag-ui/client": "0.0.57",
"@ag-ui/core": "0.0.57",
"@copilotkit/a2ui-renderer": "1.61.0",
"@copilotkit/react-core": "1.61.0",
"@copilotkit/runtime": "1.61.0",
"hono": "^4.6.18",
"lucide-react": "^0.577.0",
"next": "^16.0.10",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"zod": "^3.25.75"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.13",
"@types/node": "^22.15.3",
"@types/react": "19.2.3",
"@types/react-dom": "19.2.3",
"concurrently": "^9.2.1",
"postcss": "^8.4.49",
"tailwindcss": "^4.1.13",
"typescript": "5.9.2"
}
}
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.5 13.5V6.5V5.41421C14.5 5.149 14.3946 4.89464 14.2071 4.70711L9.79289 0.292893C9.60536 0.105357 9.351 0 9.08579 0H8H3H1.5V1.5V13.5C1.5 14.8807 2.61929 16 4 16H12C13.3807 16 14.5 14.8807 14.5 13.5ZM13 13.5V6.5H9.5H8V5V1.5H3V13.5C3 14.0523 3.44772 14.5 4 14.5H12C12.5523 14.5 13 14.0523 13 13.5ZM9.5 5V2.12132L12.3787 5H9.5ZM5.13 5.00062H4.505V6.25062H5.13H6H6.625V5.00062H6H5.13ZM4.505 8H5.13H11H11.625V9.25H11H5.13H4.505V8ZM5.13 11H4.505V12.25H5.13H11H11.625V11H11H5.13Z" fill="#666666"/>
</svg>

After

Width:  |  Height:  |  Size: 645 B

@@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_868_525)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.268 14.0934C11.9051 13.4838 13.2303 12.2333 13.9384 10.6469C13.1192 10.7941 12.2138 10.9111 11.2469 10.9925C11.0336 12.2005 10.695 13.2621 10.268 14.0934ZM8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16ZM8.48347 14.4823C8.32384 14.494 8.16262 14.5 8 14.5C7.83738 14.5 7.67616 14.494 7.51654 14.4823C7.5132 14.4791 7.50984 14.4759 7.50647 14.4726C7.2415 14.2165 6.94578 13.7854 6.67032 13.1558C6.41594 12.5744 6.19979 11.8714 6.04101 11.0778C6.67605 11.1088 7.33104 11.125 8 11.125C8.66896 11.125 9.32395 11.1088 9.95899 11.0778C9.80021 11.8714 9.58406 12.5744 9.32968 13.1558C9.05422 13.7854 8.7585 14.2165 8.49353 14.4726C8.49016 14.4759 8.4868 14.4791 8.48347 14.4823ZM11.4187 9.72246C12.5137 9.62096 13.5116 9.47245 14.3724 9.28806C14.4561 8.87172 14.5 8.44099 14.5 8C14.5 7.55901 14.4561 7.12828 14.3724 6.71194C13.5116 6.52755 12.5137 6.37904 11.4187 6.27753C11.4719 6.83232 11.5 7.40867 11.5 8C11.5 8.59133 11.4719 9.16768 11.4187 9.72246ZM10.1525 6.18401C10.2157 6.75982 10.25 7.36805 10.25 8C10.25 8.63195 10.2157 9.24018 10.1525 9.81598C9.46123 9.85455 8.7409 9.875 8 9.875C7.25909 9.875 6.53877 9.85455 5.84749 9.81598C5.7843 9.24018 5.75 8.63195 5.75 8C5.75 7.36805 5.7843 6.75982 5.84749 6.18401C6.53877 6.14545 7.25909 6.125 8 6.125C8.74091 6.125 9.46123 6.14545 10.1525 6.18401ZM11.2469 5.00748C12.2138 5.08891 13.1191 5.20593 13.9384 5.35306C13.2303 3.7667 11.9051 2.51622 10.268 1.90662C10.695 2.73788 11.0336 3.79953 11.2469 5.00748ZM8.48347 1.51771C8.4868 1.52089 8.49016 1.52411 8.49353 1.52737C8.7585 1.78353 9.05422 2.21456 9.32968 2.84417C9.58406 3.42562 9.80021 4.12856 9.95899 4.92219C9.32395 4.89118 8.66896 4.875 8 4.875C7.33104 4.875 6.67605 4.89118 6.04101 4.92219C6.19978 4.12856 6.41594 3.42562 6.67032 2.84417C6.94578 2.21456 7.2415 1.78353 7.50647 1.52737C7.50984 1.52411 7.51319 1.52089 7.51653 1.51771C7.67615 1.50597 7.83738 1.5 8 1.5C8.16262 1.5 8.32384 1.50597 8.48347 1.51771ZM5.73202 1.90663C4.0949 2.51622 2.76975 3.7667 2.06159 5.35306C2.88085 5.20593 3.78617 5.08891 4.75309 5.00748C4.96639 3.79953 5.30497 2.73788 5.73202 1.90663ZM4.58133 6.27753C3.48633 6.37904 2.48837 6.52755 1.62761 6.71194C1.54392 7.12828 1.5 7.55901 1.5 8C1.5 8.44099 1.54392 8.87172 1.62761 9.28806C2.48837 9.47245 3.48633 9.62096 4.58133 9.72246C4.52807 9.16768 4.5 8.59133 4.5 8C4.5 7.40867 4.52807 6.83232 4.58133 6.27753ZM4.75309 10.9925C3.78617 10.9111 2.88085 10.7941 2.06159 10.6469C2.76975 12.2333 4.0949 13.4838 5.73202 14.0934C5.30497 13.2621 4.96639 12.2005 4.75309 10.9925Z" fill="#666666"/>
</g>
<defs>
<clipPath id="clip0_868_525">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,19 @@
<svg width="473" height="76" viewBox="0 0 473 76" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M130.998 30.6565V22.3773H91.0977V30.6565H106.16V58.1875H115.935V30.6565H130.998Z" fill="black"/>
<path d="M153.542 58.7362C165.811 58.7362 172.544 52.5018 172.544 42.2275V22.3773H162.768V41.2799C162.768 47.0155 159.776 50.2574 153.542 50.2574C147.307 50.2574 144.315 47.0155 144.315 41.2799V22.3773H134.539V42.2275C134.539 52.5018 141.272 58.7362 153.542 58.7362Z" fill="black"/>
<path d="M187.508 46.3173H197.234L204.914 58.1875H216.136L207.458 45.2699C212.346 43.5243 215.338 39.634 215.338 34.3473C215.338 26.6665 209.603 22.3773 200.874 22.3773H177.732V58.1875H187.508V46.3173ZM187.508 38.5867V30.5568H200.376C203.817 30.5568 205.712 32.053 205.712 34.5967C205.712 36.9907 203.817 38.5867 200.376 38.5867H187.508Z" fill="black"/>
<path d="M219.887 58.1875H245.472C253.452 58.1875 258.041 54.397 258.041 48.0629C258.041 43.8235 255.348 40.9308 252.156 39.634C254.35 38.5867 257.043 36.0929 257.043 32.1528C257.043 25.8187 252.555 22.3773 244.625 22.3773H219.887V58.1875ZM229.263 36.3922V30.3074H243.627C246.32 30.3074 247.817 31.3548 247.817 33.3498C247.817 35.3448 246.32 36.3922 243.627 36.3922H229.263ZM229.263 43.7238H244.525C247.168 43.7238 248.615 45.0205 248.615 46.9657C248.615 48.9108 247.168 50.2075 244.525 50.2075H229.263V43.7238Z" fill="black"/>
<path d="M281.942 21.7788C269.423 21.7788 260.396 29.6092 260.396 40.2824C260.396 50.9557 269.423 58.786 281.942 58.786C294.461 58.786 303.438 50.9557 303.438 40.2824C303.438 29.6092 294.461 21.7788 281.942 21.7788ZM281.942 30.2575C288.525 30.2575 293.463 34.1478 293.463 40.2824C293.463 46.417 288.525 50.3073 281.942 50.3073C275.359 50.3073 270.421 46.417 270.421 40.2824C270.421 34.1478 275.359 30.2575 281.942 30.2575Z" fill="black"/>
<path d="M317.526 46.3173H327.251L334.932 58.1875H346.154L337.476 45.2699C342.364 43.5243 345.356 39.634 345.356 34.3473C345.356 26.6665 339.62 22.3773 330.892 22.3773H307.75V58.1875H317.526V46.3173ZM317.526 38.5867V30.5568H330.394C333.835 30.5568 335.73 32.053 335.73 34.5967C335.73 36.9907 333.835 38.5867 330.394 38.5867H317.526Z" fill="black"/>
<path d="M349.904 22.3773V58.1875H384.717V49.9083H359.48V44.0729H381.874V35.9932H359.48V30.6565H384.717V22.3773H349.904Z" fill="black"/>
<path d="M399.204 46.7662H412.221C420.95 46.7662 426.685 42.5767 426.685 34.5967C426.685 26.5668 420.95 22.3773 412.221 22.3773H389.428V58.1875H399.204V46.7662ZM399.204 38.6365V30.5568H411.673C415.164 30.5568 417.059 32.053 417.059 34.5967C417.059 37.0904 415.164 38.6365 411.673 38.6365H399.204Z" fill="black"/>
<path d="M450.948 21.7788C438.43 21.7788 429.402 29.6092 429.402 40.2824C429.402 50.9557 438.43 58.786 450.948 58.786C463.467 58.786 472.444 50.9557 472.444 40.2824C472.444 29.6092 463.467 21.7788 450.948 21.7788ZM450.948 30.2575C457.532 30.2575 462.469 34.1478 462.469 40.2824C462.469 46.417 457.532 50.3073 450.948 50.3073C444.365 50.3073 439.427 46.417 439.427 40.2824C439.427 34.1478 444.365 30.2575 450.948 30.2575Z" fill="black"/>
<path d="M38.5017 18.0956C27.2499 18.0956 18.0957 27.2498 18.0957 38.5016C18.0957 49.7534 27.2499 58.9076 38.5017 58.9076C49.7535 58.9076 58.9077 49.7534 58.9077 38.5016C58.9077 27.2498 49.7535 18.0956 38.5017 18.0956ZM38.5017 49.0618C32.6687 49.0618 27.9415 44.3346 27.9415 38.5016C27.9415 32.6686 32.6687 27.9414 38.5017 27.9414C44.3347 27.9414 49.0619 32.6686 49.0619 38.5016C49.0619 44.3346 44.3347 49.0618 38.5017 49.0618Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.2115 14.744V7.125C56.7719 8.0104 69.9275 21.7208 69.9275 38.5016C69.9275 55.2824 56.7719 68.989 40.2115 69.8782V62.2592C52.5539 61.3776 62.3275 51.0644 62.3275 38.5016C62.3275 25.9388 52.5539 15.6256 40.2115 14.744ZM20.5048 54.0815C17.233 50.3043 15.124 45.4935 14.7478 40.2115H7.125C7.5202 47.6025 10.4766 54.3095 15.1088 59.4737L20.501 54.0815H20.5048ZM36.7916 69.8782V62.2592C31.5058 61.883 26.695 59.7778 22.9178 56.5022L17.5256 61.8944C22.6936 66.5304 29.4006 69.483 36.7878 69.8782H36.7916Z" fill="url(#paint0_linear_2028_278)"/>
<defs>
<linearGradient id="paint0_linear_2028_278" x1="41.443" y1="11.5372" x2="10.5567" y2="42.4236" gradientUnits="userSpaceOnUse">
<stop stop-color="#0096FF"/>
<stop offset="1" stop-color="#FF1E56"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,19 @@
<svg width="473" height="76" viewBox="0 0 473 76" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M130.998 30.6566V22.3773H91.0977V30.6566H106.16V58.1876H115.935V30.6566H130.998Z" fill="white"/>
<path d="M153.542 58.7362C165.811 58.7362 172.544 52.5018 172.544 42.2276V22.3773H162.768V41.2799C162.768 47.0156 159.776 50.2574 153.542 50.2574C147.307 50.2574 144.315 47.0156 144.315 41.2799V22.3773H134.539V42.2276C134.539 52.5018 141.272 58.7362 153.542 58.7362Z" fill="white"/>
<path d="M187.508 46.3173H197.234L204.914 58.1876H216.136L207.458 45.2699C212.346 43.5243 215.338 39.6341 215.338 34.3473C215.338 26.6666 209.603 22.3773 200.874 22.3773H177.732V58.1876H187.508V46.3173ZM187.508 38.5867V30.5568H200.376C203.817 30.5568 205.712 32.0531 205.712 34.5967C205.712 36.9907 203.817 38.5867 200.376 38.5867H187.508Z" fill="white"/>
<path d="M219.887 58.1876H245.472C253.452 58.1876 258.041 54.3971 258.041 48.0629C258.041 43.8236 255.348 40.9308 252.156 39.6341C254.35 38.5867 257.043 36.0929 257.043 32.1528C257.043 25.8187 252.555 22.3773 244.625 22.3773H219.887V58.1876ZM229.263 36.3922V30.3074H243.627C246.32 30.3074 247.817 31.3548 247.817 33.3498C247.817 35.3448 246.32 36.3922 243.627 36.3922H229.263ZM229.263 43.7238H244.525C247.168 43.7238 248.615 45.0206 248.615 46.9657C248.615 48.9108 247.168 50.2076 244.525 50.2076H229.263V43.7238Z" fill="white"/>
<path d="M281.942 21.7788C269.423 21.7788 260.396 29.6092 260.396 40.2824C260.396 50.9557 269.423 58.7861 281.942 58.7861C294.461 58.7861 303.438 50.9557 303.438 40.2824C303.438 29.6092 294.461 21.7788 281.942 21.7788ZM281.942 30.2576C288.525 30.2576 293.463 34.1478 293.463 40.2824C293.463 46.4171 288.525 50.3073 281.942 50.3073C275.359 50.3073 270.421 46.4171 270.421 40.2824C270.421 34.1478 275.359 30.2576 281.942 30.2576Z" fill="white"/>
<path d="M317.526 46.3173H327.251L334.932 58.1876H346.154L337.476 45.2699C342.364 43.5243 345.356 39.6341 345.356 34.3473C345.356 26.6666 339.62 22.3773 330.892 22.3773H307.75V58.1876H317.526V46.3173ZM317.526 38.5867V30.5568H330.394C333.835 30.5568 335.73 32.0531 335.73 34.5967C335.73 36.9907 333.835 38.5867 330.394 38.5867H317.526Z" fill="white"/>
<path d="M349.904 22.3773V58.1876H384.717V49.9083H359.48V44.0729H381.874V35.9932H359.48V30.6566H384.717V22.3773H349.904Z" fill="white"/>
<path d="M399.204 46.7662H412.221C420.95 46.7662 426.685 42.5767 426.685 34.5967C426.685 26.5668 420.95 22.3773 412.221 22.3773H389.428V58.1876H399.204V46.7662ZM399.204 38.6366V30.5568H411.673C415.164 30.5568 417.059 32.0531 417.059 34.5967C417.059 37.0904 415.164 38.6366 411.673 38.6366H399.204Z" fill="white"/>
<path d="M450.948 21.7788C438.43 21.7788 429.402 29.6092 429.402 40.2824C429.402 50.9557 438.43 58.7861 450.948 58.7861C463.467 58.7861 472.444 50.9557 472.444 40.2824C472.444 29.6092 463.467 21.7788 450.948 21.7788ZM450.948 30.2576C457.532 30.2576 462.469 34.1478 462.469 40.2824C462.469 46.4171 457.532 50.3073 450.948 50.3073C444.365 50.3073 439.427 46.4171 439.427 40.2824C439.427 34.1478 444.365 30.2576 450.948 30.2576Z" fill="white"/>
<path d="M38.5017 18.0956C27.2499 18.0956 18.0957 27.2498 18.0957 38.5016C18.0957 49.7534 27.2499 58.9076 38.5017 58.9076C49.7535 58.9076 58.9077 49.7534 58.9077 38.5016C58.9077 27.2498 49.7535 18.0956 38.5017 18.0956ZM38.5017 49.0618C32.6687 49.0618 27.9415 44.3346 27.9415 38.5016C27.9415 32.6686 32.6687 27.9414 38.5017 27.9414C44.3347 27.9414 49.0619 32.6686 49.0619 38.5016C49.0619 44.3346 44.3347 49.0618 38.5017 49.0618Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.2115 14.744V7.125C56.7719 8.0104 69.9275 21.7208 69.9275 38.5016C69.9275 55.2824 56.7719 68.989 40.2115 69.8782V62.2592C52.5539 61.3776 62.3275 51.0644 62.3275 38.5016C62.3275 25.9388 52.5539 15.6256 40.2115 14.744ZM20.5048 54.0815C17.233 50.3043 15.124 45.4935 14.7478 40.2115H7.125C7.5202 47.6025 10.4766 54.3095 15.1088 59.4737L20.501 54.0815H20.5048ZM36.7916 69.8782V62.2592C31.5058 61.883 26.695 59.7778 22.9178 56.5022L17.5256 61.8944C22.6936 66.5304 29.4006 69.483 36.7878 69.8782H36.7916Z" fill="url(#paint0_linear_2028_477)"/>
<defs>
<linearGradient id="paint0_linear_2028_477" x1="41.443" y1="11.5372" x2="10.5567" y2="42.4236" gradientUnits="userSpaceOnUse">
<stop stop-color="#0096FF"/>
<stop offset="1" stop-color="#FF1E56"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,10 @@
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_977_547)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.5 3L18.5 17H2.5L10.5 3Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_977_547">
<rect width="16" height="16" fill="white" transform="translate(2.5 2)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 367 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5H14.5V12.5C14.5 13.0523 14.0523 13.5 13.5 13.5H2.5C1.94772 13.5 1.5 13.0523 1.5 12.5V2.5ZM0 1H1.5H14.5H16V2.5V12.5C16 13.8807 14.8807 15 13.5 15H2.5C1.11929 15 0 13.8807 0 12.5V2.5V1ZM3.75 5.5C4.16421 5.5 4.5 5.16421 4.5 4.75C4.5 4.33579 4.16421 4 3.75 4C3.33579 4 3 4.33579 3 4.75C3 5.16421 3.33579 5.5 3.75 5.5ZM7 4.75C7 5.16421 6.66421 5.5 6.25 5.5C5.83579 5.5 5.5 5.16421 5.5 4.75C5.5 4.33579 5.83579 4 6.25 4C6.66421 4 7 4.33579 7 4.75ZM8.75 5.5C9.16421 5.5 9.5 5.16421 9.5 4.75C9.5 4.33579 9.16421 4 8.75 4C8.33579 4 8 4.33579 8 4.75C8 5.16421 8.33579 5.5 8.75 5.5Z" fill="#666666"/>
</svg>

After

Width:  |  Height:  |  Size: 750 B

@@ -0,0 +1,13 @@
[project]
name = "with-a2a-a2ui"
version = "0.1.0"
description = "A top-level project to manage python libraries and samples."
dependencies = []
[tool.uv.workspace]
members = ["a2ui_extension", "agent"]
[tool.uv.sources]
a2ui = { path = "a2ui_extension", editable = true }
# The key must match the package name
a2ui-restaurant-finder = { path = "agent", editable = true }
@@ -0,0 +1,6 @@
@echo off
REM Navigate to the agent directory
cd /d %~dp0\..\agent
REM Run the agent using uv
uv run src/main.py
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
# Navigate to the agent directory
cd "$(dirname "$0")/../agent" || exit 1
# Run the agent using uv
uv run .
@@ -0,0 +1,6 @@
@echo off
REM Navigate to the agent directory
cd /d "%~dp0\..\agent" || exit /b 1
REM Install dependencies and create virtual environment using uv
uv sync
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
# Navigate to the agent directory
cd "$(dirname "$0")/../agent" || exit 1
# Install dependencies and create virtual environment using uv
uv sync
@@ -0,0 +1,15 @@
import type { Config } from "tailwindcss";
const config = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"../../packages/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {},
},
plugins: [],
} satisfies Config;
export default config;
@@ -0,0 +1,36 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"incremental": false,
"isolatedModules": true,
"lib": ["es2022", "DOM", "DOM.Iterable"],
"moduleDetection": "force",
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022",
"plugins": [
{
"name": "next"
}
],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
"jsx": "react-jsx",
"noEmit": true
},
"include": [
"**/*.ts",
"**/*.tsx",
"next-env.d.ts",
"next.config.js",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
# ========================================
# API Keys (REQUIRED)
# ========================================
# Google API Key (for ADK agents: Orchestrator, Analysis)
# Get your key from: https://aistudio.google.com/app/apikey
GOOGLE_API_KEY=your_google_api_key_here
# OpenAI API Key (for LangGraph agents: Research)
# Get your key from: https://platform.openai.com/api-keys
OPENAI_API_KEY=your_openai_api_key_here
# ========================================
# Agent URLs (Frontend → Agents)
# Optional - these are the defaults
# ========================================
# Orchestrator (ADK + AG-UI Protocol)
ORCHESTRATOR_URL=http://localhost:9000
# Research Agent (LangGraph + A2A Protocol)
RESEARCH_AGENT_URL=http://localhost:9001
# Analysis Agent (ADK + A2A Protocol)
ANALYSIS_AGENT_URL=http://localhost:9002
# ========================================
# Agent Ports (Python Agents)
# Optional - used by Python agents to bind to specific ports
# ========================================
# Orchestrator
ORCHESTRATOR_PORT=9000
# Research Agent
RESEARCH_PORT=9001
# Analysis Agent
ANALYSIS_PORT=9002
# ========================================
# CopilotKit Intelligence Threads (Optional)
# ========================================
# COPILOTKIT_LICENSE_TOKEN=
# INTELLIGENCE_API_KEY=
# INTELLIGENCE_API_URL=http://localhost:4201
# INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
@@ -0,0 +1,84 @@
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
*.pyc
venv/
env/
ENV/
env.bak/
venv.bak/
# Virtual environments
agents/.venv/
agents/venv/
.venv/
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
lerna-debug.log*
.next/
out/
.turbo
.vercel
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Testing
coverage/
.coverage
.pytest_cache/
.nyc_output/
htmlcov/
# Logs
logs/
*.log
# Temporary files
*.tmp
*.temp
.cache/
.parcel-cache/
# OS
Thumbs.db
Desktop.ini
# Build outputs
*.tsbuildinfo
next-env.d.ts
@@ -0,0 +1,261 @@
# A2A + AG-UI Multi-Agent Starter
A minimal starter template for building multi-agent applications with **A2A Protocol** (Agent-to-Agent) and **AG-UI Protocol** (Agent-UI). This project demonstrates how to coordinate multiple AI agents across different frameworks (LangGraph and Google ADK) to solve tasks collaboratively.
![Screenshot of a demo](demo.png)
## Quick Start
### Prerequisites
- **Node.js** 18+
- **Python** 3.10+
- **Google API Key** - [Get one here](https://aistudio.google.com/app/apikey)
- **OpenAI API Key** - [Get one here](https://platform.openai.com/api-keys)
### Installation
1. **Install frontend dependencies:**
```bash
npm install
```
2. **Install Python dependencies:**
```bash
cd agents
python3 -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install -r requirements.txt
cd ..
```
3. **Set up environment variables:**
```bash
cp .env.example .env
# Edit .env and add your API keys:
# GOOGLE_API_KEY=your_google_api_key
# OPENAI_API_KEY=your_openai_api_key
```
4. **Start all services:**
```bash
npm run dev
```
This will start:
- **UI**: http://localhost:3000
- **Orchestrator**: http://localhost:9000
- **Research Agent**: http://localhost:9001
- **Analysis Agent**: http://localhost:9002
## Usage
Try asking:
- "Research quantum computing"
- "Tell me about artificial intelligence"
- "Research renewable energy"
The orchestrator will:
1. Send your query to the **Research Agent** to gather information
2. Pass the research to the **Analysis Agent** for insights
3. Present a complete summary with both research and analysis
## Development Scripts
```bash
# Start everything
npm run dev
# Start individual services
npm run dev:ui # Next.js UI only
npm run dev:orchestrator # Orchestrator only
npm run dev:research # Research agent only
npm run dev:analysis # Analysis agent only
# Build for production
npm run build
# Lint code
npm run lint
```
## Customization
### Adding New Agents
1. **Create a new Python agent** in `agents/`:
- Implement A2A Protocol (see existing agents as examples)
- Choose a port (e.g., 9003)
- Define agent capabilities and skills
2. **Register in middleware** (`app/api/copilotkit/route.ts`):
```typescript
const newAgentUrl = "http://localhost:9003";
const a2aMiddlewareAgent = new A2AMiddlewareAgent({
agentUrls: [
researchAgentUrl,
analysisAgentUrl,
newAgentUrl, // Add here
],
// ...
});
```
3. **Add run script** in `package.json`:
```json
"dev:newagent": "python3 agents/new_agent.py"
```
4. **Update concurrently command** to include your new agent
### Changing UI
- **Main page**: Edit `app/page.tsx` for layout and result display
- **Chat**: Edit `components/chat.tsx` for chat behavior
- **Styling**: Edit `app/globals.css` and `tailwind.config.ts`
- **A2A badges**: Edit `components/a2a/` components
## What This Demonstrates
This starter shows how specialized agents built with different frameworks can communicate via the A2A protocol:
### Architecture
```
┌──────────────────────────────────────────┐
│ Next.js UI (CopilotKit) │
└────────────┬─────────────────────────────┘
│ AG-UI Protocol
┌────────────┴─────────────────────────────┐
│ A2A Middleware │
│ - Routes messages between agents │
└──────┬───────────────────────────────────┘
│ A2A Protocol
├─────► Research Agent (LangGraph)
│ - Gathers information
│ - Port 9001
└─────► Analysis Agent (ADK)
- Analyzes findings
- Port 9002
┌──────┴──────────┐
│ Orchestrator │
│ (ADK) │
│ Port 9000 │
└─────────────────┘
```
### Agents
1. **Orchestrator (ADK + AG-UI Protocol)**
- Receives requests from the UI
- Coordinates specialized agents
- Port: 9000
2. **Research Agent (LangGraph + A2A Protocol)**
- Gathers and summarizes information
- Returns structured JSON
- Port: 9001
3. **Analysis Agent (ADK + A2A Protocol)**
- Analyzes research findings
- Provides insights and conclusions
- Port: 9002
## Project Structure
```
starter/
├── app/
│ ├── api/copilotkit/route.ts # A2A middleware setup (KEY FILE!)
│ ├── layout.tsx # Root layout
│ ├── globals.css # Styles
│ └── page.tsx # Main UI
├── components/
│ ├── chat.tsx # Chat component with A2A visualization
│ └── a2a/ # A2A message components
│ ├── agent-styles.ts # Agent branding utilities
│ ├── MessageToA2A.tsx # Outgoing message badges
│ └── MessageFromA2A.tsx # Incoming message badges
├── agents/ # Python agents
│ ├── orchestrator.py # Orchestrator (ADK + AG-UI) - Port 9000
│ ├── research_agent.py # Research (LangGraph + A2A) - Port 9001
│ ├── analysis_agent.py # Analysis (ADK + A2A) - Port 9002
│ └── requirements.txt # Python dependencies
├── package.json # Frontend dependencies & scripts
├── .env.example # Environment variables template
└── README.md # This file
```
## Key Concepts
### AG-UI Protocol
The **AG-UI Protocol** standardizes communication between the frontend (CopilotKit) and agents. The orchestrator uses AG-UI to receive messages from the UI.
### A2A Protocol
The **A2A Protocol** standardizes agent-to-agent communication. The Research and Analysis agents use A2A to communicate with the orchestrator.
### A2A Middleware
The **A2A Middleware** (in `app/api/copilotkit/route.ts`) is the magic that connects everything:
- Wraps the orchestrator agent
- Registers A2A agents automatically
- Injects a `send_message_to_a2a_agent` tool into the orchestrator
- Routes messages between agents
## Troubleshooting
### Agents not connecting?
- Verify all services are running: `http://localhost:9000-9002`
- Check console for startup errors
### Missing API keys?
- Ensure `.env` file exists with `GOOGLE_API_KEY` and `OPENAI_API_KEY`
- Restart all services after adding keys
### Python import errors?
- Activate virtual environment: `source agents/.venv/bin/activate`
- Reinstall dependencies: `pip install -r agents/requirements.txt`
### Port conflicts?
- Change ports in `.env` file:
```
ORCHESTRATOR_PORT=9000
RESEARCH_PORT=9001
ANALYSIS_PORT=9002
```
## Learn More
- [AG-UI Protocol Documentation](https://docs.ag-ui.com)
- [A2A Protocol Specification](https://a2a-protocol.org)
- [Google ADK Documentation](https://google.github.io/adk-docs/)
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/)
- [CopilotKit Documentation](https://docs.copilotkit.ai)
## License
MIT
@@ -0,0 +1,232 @@
"""
Analysis Agent - Analyzes research findings using ADK + Gemini.
Exposes A2A Protocol endpoint, returns structured JSON.
"""
import uvicorn
import os
import json
from typing import List
from dotenv import load_dotenv
from pydantic import BaseModel, Field
load_dotenv()
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import (
AgentCapabilities,
AgentCard,
AgentSkill,
)
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.utils import new_agent_text_message
from google.adk.agents.llm_agent import LlmAgent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
from google.adk.artifacts import InMemoryArtifactService
from google.genai import types
class InsightItem(BaseModel):
title: str = Field(description="Title of the insight")
description: str = Field(description="Detailed description of the insight")
importance: str = Field(description="Why this insight matters")
class StructuredAnalysis(BaseModel):
topic: str = Field(description="The topic being analyzed")
overview: str = Field(description="Brief overview of the analysis")
insights: List[InsightItem] = Field(description="List of key insights")
conclusion: str = Field(description="Concluding thoughts")
class AnalysisAgent:
def __init__(self):
self._agent = self._build_agent()
self._user_id = "remote_agent"
self._runner = Runner(
app_name=self._agent.name,
agent=self._agent,
artifact_service=InMemoryArtifactService(),
session_service=InMemorySessionService(),
memory_service=InMemoryMemoryService(),
)
def _build_agent(self) -> LlmAgent:
model_name = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
return LlmAgent(
model=model_name,
name="analysis_agent",
description="An agent that analyzes research findings and provides insights",
instruction="""
You are an analysis agent. Your role is to analyze research findings and provide meaningful insights.
When you receive research data, analyze it thoroughly and create an insightful analysis.
Return ONLY a valid JSON object with this exact structure:
{
"topic": "The topic being analyzed",
"overview": "A brief 2-3 sentence overview of the analysis",
"insights": [
{
"title": "Key Insight 1",
"description": "Detailed explanation of this insight",
"importance": "Why this matters"
},
{
"title": "Key Insight 2",
"description": "Detailed explanation of this insight",
"importance": "Why this matters"
},
{
"title": "Key Insight 3",
"description": "Detailed explanation of this insight",
"importance": "Why this matters"
}
],
"conclusion": "Concluding thoughts and recommendations"
}
Provide 3-5 meaningful insights based on the research.
Make the analysis thoughtful and actionable.
Return ONLY valid JSON, no markdown code blocks, no other text.
""",
tools=[],
)
async def invoke(self, query: str, session_id: str) -> str:
"""Generate analysis and return JSON string."""
session = await self._runner.session_service.get_session(
app_name=self._agent.name,
user_id=self._user_id,
session_id=session_id,
)
content = types.Content(role="user", parts=[types.Part.from_text(text=query)])
if session is None:
session = await self._runner.session_service.create_session(
app_name=self._agent.name,
user_id=self._user_id,
state={},
session_id=session_id,
)
response_text = ""
async for event in self._runner.run_async(
user_id=self._user_id, session_id=session.id, new_message=content
):
if event.is_final_response():
if (
event.content
and event.content.parts
and event.content.parts[0].text
):
response_text = "\n".join(
[p.text for p in event.content.parts if p.text]
)
break
content_str = response_text.strip()
if "```json" in content_str:
content_str = content_str.split("```json")[1].split("```")[0].strip()
elif "```" in content_str:
content_str = content_str.split("```")[1].split("```")[0].strip()
try:
structured_data = json.loads(content_str)
validated_analysis = StructuredAnalysis(**structured_data)
final_response = json.dumps(validated_analysis.model_dump(), indent=2)
print("✅ Successfully created structured analysis")
return final_response
except json.JSONDecodeError as e:
print(f"❌ JSON parsing error: {e}")
print(f"Content: {content_str}")
return json.dumps(
{
"error": "Failed to generate structured analysis",
"raw_content": content_str[:200],
}
)
except Exception as e:
print(f"❌ Validation error: {e}")
return json.dumps({"error": f"Validation failed: {str(e)}"})
# A2A Protocol executor wraps the ADK agent
class AnalysisAgentExecutor(AgentExecutor):
def __init__(self):
self.agent = AnalysisAgent()
async def execute(
self,
context: RequestContext,
event_queue: EventQueue,
) -> None:
query = context.get_user_input()
session_id = getattr(context, "context_id", "default_session")
final_content = await self.agent.invoke(query, session_id)
await event_queue.enqueue_event(new_agent_text_message(final_content))
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
raise Exception("cancel not supported")
port = int(os.getenv("ANALYSIS_PORT", 9002))
skill = AgentSkill(
id="analysis_agent",
name="Analysis Agent",
description="Analyzes research findings and provides meaningful insights using ADK",
tags=["research", "analysis", "insights", "adk"],
examples=[
"Analyze this research about quantum computing",
"What are the key insights from this data?",
"Provide analysis of these research findings",
],
)
public_agent_card = AgentCard(
name="Analysis Agent",
description="ADK-powered agent that analyzes research findings and provides meaningful insights",
url=f"http://localhost:{port}/",
version="1.0.0",
defaultInputModes=["text"],
defaultOutputModes=["text"],
capabilities=AgentCapabilities(streaming=True),
skills=[skill],
supportsAuthenticatedExtendedCard=False,
)
def main():
if not os.getenv("GOOGLE_API_KEY") and not os.getenv("GEMINI_API_KEY"):
print("⚠️ Warning: No API key found!")
print(" Set GOOGLE_API_KEY or GEMINI_API_KEY")
print(" Get a key from: https://aistudio.google.com/app/apikey")
print()
request_handler = DefaultRequestHandler(
agent_executor=AnalysisAgentExecutor(),
task_store=InMemoryTaskStore(),
)
server = A2AStarletteApplication(
agent_card=public_agent_card,
http_handler=request_handler,
extended_agent_card=public_agent_card,
)
print(f"💡 Starting Analysis Agent (ADK + A2A) on http://localhost:{port}")
print(f" Agent: {public_agent_card.name}")
print(f" Description: {public_agent_card.description}")
uvicorn.run(server.build(), host="0.0.0.0", port=port)
if __name__ == "__main__":
main()
@@ -0,0 +1,86 @@
"""
Orchestrator Agent - Coordinates between Research and Analysis agents.
Speaks AG-UI Protocol to the UI, delegates tasks to A2A agents via middleware.
"""
from __future__ import annotations
from dotenv import load_dotenv
load_dotenv()
import os
import uvicorn
from fastapi import FastAPI
from ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint
from google.adk.agents import LlmAgent
orchestrator_agent = LlmAgent(
name="OrchestratorAgent",
model="gemini-2.5-pro",
instruction="""
You are an orchestrator agent that coordinates research and analysis tasks.
AVAILABLE SPECIALIZED AGENTS:
1. **Research Agent** (LangGraph) - Gathers and summarizes information about a topic
2. **Analysis Agent** (ADK) - Analyzes research findings and provides insights
CRITICAL CONSTRAINTS:
- You MUST call agents ONE AT A TIME, never make multiple tool calls simultaneously
- After making a tool call, WAIT for the result before making another tool call
- Do NOT make parallel/concurrent tool calls - this is not supported
WORKFLOW FOR RESEARCH TASKS:
When the user asks to research a topic:
1. **Research Agent** - First, gather information about the topic
- Pass: The user's research query or topic
- Wait for structured JSON response with research findings
2. **Analysis Agent** - Then, analyze the research results
- Pass: The research results from step 1
- Wait for structured JSON with analysis and insights
3. Present the complete research and analysis to the user
IMPORTANT WORKFLOW DETAILS:
- Always call the Research Agent first to gather information
- Then call the Analysis Agent to analyze the findings
- Wait for each agent to complete before calling the next one
- Build your final response using information from both agents
RESPONSE STRATEGY:
- After each agent response, briefly acknowledge what you received
- Build up the complete answer incrementally
- At the end, present a well-organized summary
- Don't just list agent responses - synthesize them into a cohesive answer
IMPORTANT: Once you have received a response from an agent, do NOT call that same
agent again for the same information. Use the information you already have.
""",
)
# Wrap with AG-UI middleware to expose via AG-UI Protocol
adk_orchestrator_agent = ADKAgent(
adk_agent=orchestrator_agent,
app_name="orchestrator_app",
user_id="demo_user",
session_timeout_seconds=3600,
use_in_memory_services=True,
)
app = FastAPI(title="A2A Orchestrator (ADK + AG-UI Protocol)")
add_adk_fastapi_endpoint(app, adk_orchestrator_agent, path="/")
if __name__ == "__main__":
if not os.getenv("GOOGLE_API_KEY"):
print("⚠️ Warning: GOOGLE_API_KEY not set!")
print(" Set it with: export GOOGLE_API_KEY='your-key-here'")
print(" Get a key from: https://aistudio.google.com/app/apikey")
print()
port = int(os.getenv("ORCHESTRATOR_PORT", 9000))
print(f"🚀 Starting Orchestrator Agent (ADK + AG-UI) on http://localhost:{port}")
uvicorn.run(app, host="0.0.0.0", port=port)
@@ -0,0 +1,62 @@
# ============================================================================
# A2A + AG-UI Starter - Python Agent Dependencies
# ============================================================================
# ============================================================================
# AG-UI Protocol Implementation
# ============================================================================
# ag-ui-adk: Allows ADK agents to communicate with the frontend via AG-UI Protocol
# This is used by the orchestrator agent to receive messages from the UI
ag-ui-adk>=0.0.1
# ============================================================================
# A2A Protocol Implementation
# ============================================================================
# a2a: Core A2A Protocol SDK for agent-to-agent communication
# a2a-sdk: Additional utilities including HTTP server support
# These packages enable the Research and Analysis agents to communicate via A2A
a2a>=0.1.0
a2a-sdk[http-server]
# ============================================================================
# Google ADK (Agent Development Kit)
# ============================================================================
# google-adk: Google's framework for building AI agents with Gemini models
# Used by: Orchestrator Agent, Analysis Agent
# litellm: Unified interface for multiple LLM providers (dependency of ADK)
google-adk>=0.1.0
litellm>=1.0.0
# ============================================================================
# LangGraph Framework
# ============================================================================
# langgraph: LangChain's framework for building stateful agent workflows
# langchain: Core LangChain library for LLM applications
# langchain-openai: OpenAI integration for LangChain
# Used by: Research Agent
langgraph>=0.2.0
langchain>=0.3.0
langchain-openai>=0.2.0
# ============================================================================
# Web Server
# ============================================================================
# fastapi: Modern Python web framework for building agent HTTP endpoints
# uvicorn: ASGI server for running FastAPI applications
# All agents expose HTTP endpoints using FastAPI
fastapi>=0.115.0
uvicorn>=0.30.0
# ============================================================================
# Utilities
# ============================================================================
# python-dotenv: Loads environment variables from .env file
# Used to configure API keys (GOOGLE_API_KEY, OPENAI_API_KEY)
python-dotenv>=1.0.0
# ============================================================================
# LLM Providers
# ============================================================================
# openai: OpenAI Python SDK for GPT models
# Used by the Research Agent (LangGraph)
openai>=1.0.0
@@ -0,0 +1,180 @@
"""
Research Agent - Gathers information using LangGraph + OpenAI.
Exposes A2A Protocol endpoint, returns structured JSON.
"""
import uvicorn
import json
import os
from dotenv import load_dotenv
load_dotenv()
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import AgentCapabilities, AgentCard, AgentSkill, Message
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.utils import new_agent_text_message
from langgraph.graph import StateGraph, END
from langchain_openai import ChatOpenAI
from typing import TypedDict, Optional, List
from pydantic import BaseModel, Field
class ResearchFinding(BaseModel):
title: str = Field(description="Title or key point of the finding")
description: str = Field(description="Detailed description of the finding")
class StructuredResearch(BaseModel):
topic: str = Field(description="The research topic")
summary: str = Field(description="Brief summary of the research")
findings: List[ResearchFinding] = Field(description="List of key findings")
sources: str = Field(description="Note about information sources")
class ResearchState(TypedDict):
message: str
research: str
structured_research: Optional[dict]
class ResearchAgent:
def __init__(self):
self.llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.7)
self.graph = self._build_graph()
def _build_graph(self):
workflow = StateGraph(ResearchState)
workflow.add_node("conduct_research", self._conduct_research)
workflow.set_entry_point("conduct_research")
workflow.add_edge("conduct_research", END)
return workflow.compile()
def _conduct_research(self, state: ResearchState) -> ResearchState:
"""Generate research findings using LLM and return structured JSON."""
message = state["message"]
prompt = f"""
Research the following topic and provide comprehensive information.
Topic: {message}
Return ONLY a valid JSON object with this exact structure:
{{
"topic": "The research topic",
"summary": "A brief 2-3 sentence summary of the topic",
"findings": [
{{
"title": "Key Point 1",
"description": "Detailed explanation of this point"
}},
{{
"title": "Key Point 2",
"description": "Detailed explanation of this point"
}},
{{
"title": "Key Point 3",
"description": "Detailed explanation of this point"
}}
],
"sources": "Note about where this information typically comes from"
}}
Include 3-5 key findings about the topic.
Make the research informative and well-structured.
Return ONLY valid JSON, no markdown code blocks, no other text.
"""
response = self.llm.invoke(prompt)
try:
structured_data = json.loads(response.content)
state["structured_research"] = structured_data
state["research"] = json.dumps(structured_data)
except json.JSONDecodeError as e:
state["research"] = f"Error: Failed to parse research results - {str(e)}"
state["structured_research"] = None
return state
async def invoke(self, message: Message) -> str:
"""Process A2A message and return research JSON."""
message_text = message.parts[0].root.text
result = self.graph.invoke(
{"message": message_text, "research": "", "structured_research": None}
)
return result["research"]
# A2A Protocol executor wraps the LangGraph agent
class ResearchAgentExecutor(AgentExecutor):
def __init__(self):
self.agent = ResearchAgent()
async def execute(
self,
context: RequestContext,
event_queue: EventQueue,
) -> None:
result = await self.agent.invoke(context.message)
await event_queue.enqueue_event(new_agent_text_message(result))
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
raise Exception("cancel not supported")
port = int(os.getenv("RESEARCH_PORT", 9001))
skill = AgentSkill(
id="research_agent",
name="Research Agent",
description="Gathers and summarizes information about a given topic using LangGraph",
tags=["research", "information", "summary", "langgraph"],
examples=[
"Research quantum computing",
"Tell me about artificial intelligence",
"Gather information on renewable energy",
],
)
public_agent_card = AgentCard(
name="Research Agent",
description="LangGraph-powered agent that gathers and summarizes information about any topic",
url=f"http://localhost:{port}/",
version="1.0.0",
defaultInputModes=["text"],
defaultOutputModes=["text"],
capabilities=AgentCapabilities(streaming=True),
skills=[skill],
supportsAuthenticatedExtendedCard=False,
)
def main():
if not os.getenv("OPENAI_API_KEY"):
print("⚠️ Warning: OPENAI_API_KEY not set!")
print(" Set it with: export OPENAI_API_KEY='your-key-here'")
print(" Get a key from: https://platform.openai.com/api-keys")
print()
request_handler = DefaultRequestHandler(
agent_executor=ResearchAgentExecutor(),
task_store=InMemoryTaskStore(),
)
server = A2AStarletteApplication(
agent_card=public_agent_card,
http_handler=request_handler,
extended_agent_card=public_agent_card,
)
print(f"🔍 Starting Research Agent (LangGraph + A2A) on http://localhost:{port}")
print(f" Agent: {public_agent_card.name}")
print(f" Description: {public_agent_card.description}")
uvicorn.run(server.build(), host="0.0.0.0", port=port)
if __name__ == "__main__":
main()
@@ -0,0 +1,161 @@
import {
CopilotRuntime,
CopilotKitIntelligence,
createCopilotEndpoint,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
import type {
AgentSubscriber,
RunAgentInput,
RunAgentParameters,
RunAgentResult,
} from "@ag-ui/client";
import { A2AMiddlewareAgent } from "@ag-ui/a2a-middleware";
import type { A2AAgentConfig } from "@ag-ui/a2a-middleware";
import { handle } from "hono/vercel";
const researchAgentUrl =
process.env.RESEARCH_AGENT_URL || "http://localhost:9001";
const analysisAgentUrl =
process.env.ANALYSIS_AGENT_URL || "http://localhost:9002";
const orchestratorUrl = process.env.ORCHESTRATOR_URL || "http://localhost:9000";
type RuntimeRunAgentInput = RunAgentParameters &
Partial<Pick<RunAgentInput, "messages" | "state" | "threadId">>;
type RuntimeA2AMiddlewareAgentConfig = Omit<
A2AAgentConfig,
"orchestrationAgent"
> & {
orchestrationAgentUrl: string;
};
class RuntimeA2AMiddlewareAgent extends A2AMiddlewareAgent {
private readonly config: RuntimeA2AMiddlewareAgentConfig;
constructor(config: RuntimeA2AMiddlewareAgentConfig) {
super({
...config,
orchestrationAgent: new HttpAgent({
url: config.orchestrationAgentUrl,
}),
});
this.config = config;
}
async runAgent(
parameters: RuntimeRunAgentInput = {},
subscriber?: AgentSubscriber,
): Promise<RunAgentResult> {
const isolatedAgent = new A2AMiddlewareAgent({
...this.config,
agentId: this.agentId,
debug: this.debug,
description: this.description,
initialMessages: this.messages,
initialState: this.state,
threadId: parameters.threadId ?? this.threadId,
orchestrationAgent: new HttpAgent({
url: this.config.orchestrationAgentUrl,
}),
});
if (parameters.state) {
isolatedAgent.setState(parameters.state);
}
if (parameters.messages) {
isolatedAgent.setMessages(parameters.messages);
}
return isolatedAgent.runAgent(
{
context: parameters.context,
forwardedProps: parameters.forwardedProps,
runId: parameters.runId,
tools: parameters.tools,
},
subscriber,
);
}
clone(): RuntimeA2AMiddlewareAgent {
return new RuntimeA2AMiddlewareAgent({
...this.config,
agentId: this.agentId,
debug: this.debug,
description: this.description,
initialMessages: this.messages,
initialState: this.state,
threadId: this.threadId,
});
}
}
const a2aMiddlewareAgent = new RuntimeA2AMiddlewareAgent({
orchestrationAgentUrl: orchestratorUrl,
agentId: "a2a_chat",
description:
"Research assistant with 2 specialized agents: Research (LangGraph) and Analysis (ADK)",
agentUrls: [researchAgentUrl, analysisAgentUrl],
instructions: `
You are a research assistant that orchestrates between 2 specialized agents.
AVAILABLE AGENTS:
- Research Agent (LangGraph): Gathers and summarizes information about a topic
- Analysis Agent (ADK): Analyzes research findings and provides insights
WORKFLOW STRATEGY (SEQUENTIAL - ONE AT A TIME):
When the user asks to research a topic:
1. Research Agent - First, gather information about the topic
- Pass: The user's research query or topic
- The agent will return structured JSON with research findings
2. Analysis Agent - Then, analyze the research results
- Pass: The research results from step 1
- The agent will return structured JSON with analysis and insights
3. Present the complete research and analysis to the user
CRITICAL RULES:
- Call agents ONE AT A TIME, wait for results before making next call
- Pass information from earlier agents to later agents
- Synthesize all gathered information in final response
`,
});
const runtime = new CopilotRuntime({
agents: {
a2a_chat: a2aMiddlewareAgent,
},
// --- copilotkit:intelligence (remove this block to opt out) ---
...(process.env.COPILOTKIT_LICENSE_TOKEN
? {
intelligence: new CopilotKitIntelligence({
apiKey: process.env.INTELLIGENCE_API_KEY ?? "",
apiUrl: process.env.INTELLIGENCE_API_URL ?? "http://localhost:4201",
wsUrl:
process.env.INTELLIGENCE_GATEWAY_WS_URL ?? "ws://localhost:4401",
}),
// Demo stub - replace with your own auth-derived user identity (e.g. OIDC)
// before any multi-user deployment, or all users share one thread history.
identifyUser: () => ({ id: "demo-user", name: "Demo User" }),
licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN,
}
: { runner: new InMemoryAgentRunner() }),
// --- /copilotkit:intelligence ---
});
const app = createCopilotEndpoint({
runtime,
basePath: "/api/copilotkit",
});
export const GET = handle(app);
export const POST = handle(app);
export const PATCH = handle(app);
export const DELETE = handle(app);
@@ -0,0 +1,87 @@
@import "tailwindcss";
@config "../tailwind.config.ts";
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
/* Custom elevation shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md:
0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg:
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl:
0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: var(--font-plus-jakarta-sans), sans-serif;
}
}
/* A2A message animations */
@keyframes slide-in {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.a2a-message-enter {
animation: slide-in 0.3s ease-out;
}
.threadsLayout,
body > [role="presentation"] {
--foreground: oklch(0.145 0 0);
--background: oklch(1 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { Plus_Jakarta_Sans, Spline_Sans_Mono } from "next/font/google";
import "./globals.css";
import "@copilotkit/react-core/v2/styles.css";
const plusJakartaSans = Plus_Jakarta_Sans({
variable: "--font-plus-jakarta-sans",
subsets: ["latin"],
});
const splineSansMono = Spline_Sans_Mono({
variable: "--font-spline-sans-mono",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
export const metadata: Metadata = {
title: "A2A + AG-UI Starter",
description: "Multi-agent communication demo with A2A Protocol and AG-UI",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${plusJakartaSans.variable} ${splineSansMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}
@@ -0,0 +1,50 @@
.layout {
display: grid;
/*
Reserve the desktop drawer's width (its default 320px) as a fixed first
column so the layout doesn't shift when the client-only <CopilotThreadsDrawer>
mounts after hydration. On mobile the drawer is an off-canvas overlay (out
of flow), so the column collapses and the content fills the width.
*/
grid-template-columns: var(--cpk-drawer-reserved-width, 320px) minmax(0, 1fr);
/* Drawer sets --cpk-drawer-reserved-width to 0 on desktop-collapse, so the
reserved column collapses and the chat reclaims the space. */
transition: grid-template-columns 0.2s ease;
/*
Bound the single grid row to the viewport (minmax(0,1fr) lets it shrink
below content) so a long thread list scrolls INTERNALLY in the drawer with
the header pinned, instead of the drawer growing past the viewport and the
page scrolling the header away.
*/
grid-template-rows: minmax(0, 1fr);
height: 100dvh;
width: 100%;
overflow: hidden;
}
.mainPanel {
/*
Pin the content to the SECOND track explicitly. The client-only drawer
renders nothing during SSR, so without this the content would flow into the
reserved first column at first paint and then jump once the drawer mounts.
*/
grid-column: 2;
min-width: 0;
height: 100dvh;
overflow: hidden;
}
/*
Mobile (≤768px): the drawer is an off-canvas overlay — collapse to a single
track. MUST come after the base rules (media queries add no specificity, so a
later same-specificity base rule would otherwise win and leak the two-column
desktop layout onto mobile).
*/
@media (max-width: 768px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
.mainPanel {
grid-column: auto;
}
}
@@ -0,0 +1,229 @@
"use client";
import { useState } from "react";
import Chat from "@/components/chat";
import {
CopilotChatConfigurationProvider,
CopilotThreadsDrawer,
CopilotKitProvider,
} from "@copilotkit/react-core/v2";
import styles from "./page.module.css";
export type ResearchData = {
topic: string;
summary: string;
findings: Array<{
title: string;
description: string;
}>;
sources: string;
};
export type AnalysisData = {
topic: string;
overview: string;
insights: Array<{
title: string;
description: string;
importance: string;
}>;
conclusion: string;
};
// Disable static optimization for this page
export const dynamic = "force-dynamic";
function ResearchAssistant() {
const [researchData, setResearchData] = useState<ResearchData | null>(null);
const [analysisData, setAnalysisData] = useState<AnalysisData | null>(null);
return (
<div className="relative flex min-h-dvh overflow-hidden bg-[#DEDEE9] p-2">
{/* Background blur circles - Creating the gradient effect */}
<div
className="absolute w-[445px] h-[445px] left-[1040px] top-[11px] rounded-full z-0"
style={{ background: "rgba(255, 172, 77, 0.2)", filter: "blur(103px)" }}
/>
<div
className="absolute w-[609px] h-[609px] left-[1339px] top-[625px] rounded-full z-0"
style={{ background: "#C9C9DA", filter: "blur(103px)" }}
/>
<div
className="absolute w-[609px] h-[609px] left-[670px] top-[-365px] rounded-full z-0"
style={{ background: "#C9C9DA", filter: "blur(103px)" }}
/>
<div
className="absolute w-[445px] h-[445px] left-[128px] top-[331px] rounded-full z-0"
style={{
background: "rgba(255, 243, 136, 0.3)",
filter: "blur(103px)",
}}
/>
<div className="flex flex-1 flex-col gap-2 overflow-y-auto z-10 lg:flex-row lg:overflow-hidden">
<div className="flex min-h-[calc(100dvh-1rem)] w-full flex-shrink-0 flex-col overflow-hidden rounded-lg border-2 border-white bg-white/50 shadow-elevation-lg backdrop-blur-md lg:w-[450px]">
<div className="p-6 max-lg:pl-16 border-b border-[#DBDBE5]">
<h1 className="text-2xl font-semibold text-[#010507] mb-1">
Research Assistant
</h1>
<p className="text-sm text-[#57575B] leading-relaxed">
Multi-Agent A2A Demo:{" "}
<span className="text-[#1B936F] font-semibold">1 LangGraph</span>{" "}
+ <span className="text-[#BEC2FF] font-semibold">1 ADK</span>{" "}
agent
</p>
<p className="text-xs text-[#838389] mt-1">
Orchestrator-mediated A2A Protocol
</p>
</div>
<div className="flex-1 overflow-hidden">
<Chat
onResearchUpdate={setResearchData}
onAnalysisUpdate={setAnalysisData}
/>
</div>
</div>
<div className="min-h-[520px] flex-1 overflow-y-auto rounded-lg bg-white/30 backdrop-blur-sm lg:min-h-0">
<div className="mx-auto p-4 sm:p-8">
<div className="mb-8">
<h2 className="text-3xl font-semibold text-[#010507] mb-2">
Research Results
</h2>
<p className="text-[#57575B]">
Multi-agent coordination: LangGraph + ADK agents with A2A
Protocol
</p>
</div>
{!researchData && !analysisData && (
<div className="flex items-center justify-center h-[400px] bg-white/60 backdrop-blur-md rounded-xl border-2 border-dashed border-[#DBDBE5] shadow-elevation-sm">
<div className="text-center">
<div className="text-6xl mb-4">🔍</div>
<h3 className="text-xl font-semibold text-[#010507] mb-2">
Start Your Research
</h3>
<p className="text-[#57575B] max-w-md">
Ask the assistant to research any topic. Watch as 2
specialized agents collaborate through A2A Protocol to
gather information and provide insights.
</p>
</div>
</div>
)}
<div className="flex flex-col gap-2 items-stretch xl:flex-row">
{researchData && (
<div className="flex-1 bg-white/60 backdrop-blur-md rounded-xl border-2 border-[#DBDBE5] shadow-elevation-md p-6">
<div className="flex flex-col gap-0 mb-4">
<div className="flex items-center gap-2">
<span className="text-2xl">📚</span>
<h3 className="text-xl font-semibold text-[#010507]">
{researchData.topic}
</h3>
<span className="ml-auto px-3 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-emerald-100 to-green-100 text-emerald-800 border-2 border-emerald-400">
🔗 Research Agent
</span>
</div>
<h4 className="text-lg font-semibold text-gray-500">
Key Points
</h4>
</div>
<p className="text-[#57575B] mb-4">{researchData.summary}</p>
<div className="space-y-3">
{researchData.findings.map((finding, index) => (
<div key={index} className="bg-white/80 rounded-lg p-4">
<h4 className="font-semibold text-[#010507] mb-1">
{finding.title}
</h4>
<p className="text-sm text-[#57575B]">
{finding.description}
</p>
</div>
))}
</div>
<p className="text-xs text-[#838389] mt-4 italic">
{researchData.sources}
</p>
</div>
)}
{analysisData && (
<div className="flex-1 bg-white/60 backdrop-blur-md rounded-xl border-2 border-[#DBDBE5] shadow-elevation-md p-6">
<div className="flex flex-col gap-0 mb-4">
<div className="flex items-center gap-2">
<span className="text-2xl">💡</span>
<h3 className="text-xl font-semibold text-[#010507]">
{analysisData.topic}
</h3>
<span className="ml-auto px-3 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-blue-100 to-sky-100 text-blue-800 border-2 border-blue-400">
Analysis Agent
</span>
</div>
<h4 className="text-lg font-semibold text-gray-500">
Insights and Analysis
</h4>
</div>
<p className="text-[#57575B] mb-4">{analysisData.overview}</p>
<div className="space-y-3 mb-4">
{analysisData.insights.map((insight, index) => (
<div key={index} className="bg-white/80 rounded-lg p-4">
<h4 className="font-semibold text-[#010507] mb-1">
{insight.title}
</h4>
<p className="text-sm text-[#57575B] mb-2">
{insight.description}
</p>
<p className="text-xs text-blue-600 font-medium">
💡 {insight.importance}
</p>
</div>
))}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-semibold text-blue-900 mb-1">
Conclusion
</h4>
<p className="text-sm text-blue-800">
{analysisData.conclusion}
</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
export default function Home() {
return (
<CopilotKitProvider
runtimeUrl="/api/copilotkit"
showDevConsole="auto"
useSingleEndpoint={false}
>
{/*
One UNCONTROLLED CopilotChatConfigurationProvider (no `threadId` prop)
owns the active thread for the whole surface. The SDK <CopilotThreadsDrawer>
drives it directly — picking a row sets the active thread, "+ New"
resets to a fresh thread — with no host thread-state. The chat (inside
ResearchAssistant) reads the same active thread from the provider. A
*controlled* provider would block "+ New" from resetting, so
uncontrolled-inside-provider is required, not optional.
*/}
<CopilotChatConfigurationProvider agentId="a2a_chat">
<div className={`${styles.layout} threadsLayout`}>
{/* SDK threads drawer (replaces the hand-rolled fork). License-gated: the locked view's Upgrade CTA opens the Intelligence docs by default. */}
<CopilotThreadsDrawer agentId="a2a_chat" />
<div className={styles.mainPanel}>
<ResearchAssistant />
</div>
</div>
</CopilotChatConfigurationProvider>
</CopilotKitProvider>
);
}
@@ -0,0 +1,67 @@
/**
* Displays incoming A2A responses (Agent → Orchestrator).
* Blue box with sender/receiver badges. Actual data renders separately in main UI.
*/
import React from "react";
import { getAgentStyle } from "./agent-styles";
type MessageActionRenderProps = {
status: string;
args: {
agentName?: string;
};
};
export const MessageFromA2A: React.FC<MessageActionRenderProps> = ({
status,
args,
}) => {
switch (status) {
case "complete":
break;
default:
return null;
}
if (!args.agentName) {
return null;
}
const agentStyle = getAgentStyle(args.agentName);
return (
<div className="my-2">
<div className="bg-blue-50 border border-blue-200 rounded-lg px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 min-w-[200px] flex-shrink-0">
<div className="flex flex-col items-center">
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border-2 ${agentStyle.bgColor} ${agentStyle.textColor} ${agentStyle.borderColor} flex items-center gap-1`}
>
<span>{agentStyle.icon}</span>
<span>{args.agentName}</span>
</span>
{agentStyle.framework && (
<span className="text-[9px] text-gray-500 mt-0.5">
{agentStyle.framework}
</span>
)}
</div>
<span className="text-gray-400 text-sm"></span>
<div className="flex flex-col items-center">
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-gray-700 text-white">
Orchestrator
</span>
<span className="text-[9px] text-gray-500 mt-0.5">ADK</span>
</div>
</div>
<span className="text-xs text-gray-600"> Response received</span>
</div>
</div>
</div>
);
};
@@ -0,0 +1,72 @@
/**
* Displays outgoing A2A messages (Orchestrator → Agent).
* Green box with sender/receiver badges and task description.
*/
import React from "react";
import { getAgentStyle, truncateTask } from "./agent-styles";
type MessageActionRenderProps = {
status: string;
args: {
agentName?: string;
task?: string;
};
};
export const MessageToA2A: React.FC<MessageActionRenderProps> = ({
status,
args,
}) => {
switch (status) {
case "executing":
case "complete":
break;
default:
return null;
}
if (!args.agentName || !args.task) {
return null;
}
const agentStyle = getAgentStyle(args.agentName);
return (
<div className="bg-green-50 border border-green-200 rounded-lg px-4 py-3 my-2 a2a-message-enter">
<div className="flex items-start gap-3">
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex flex-col items-center">
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-gray-700 text-white">
Orchestrator
</span>
<span className="text-[9px] text-gray-500 mt-0.5">ADK</span>
</div>
<span className="text-gray-400 text-sm"></span>
<div className="flex flex-col items-center">
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border-2 ${agentStyle.bgColor} ${agentStyle.textColor} ${agentStyle.borderColor} flex items-center gap-1`}
>
<span>{agentStyle.icon}</span>
<span>{args.agentName}</span>
</span>
{agentStyle.framework && (
<span className="text-[9px] text-gray-500 mt-0.5">
{agentStyle.framework}
</span>
)}
</div>
</div>
<span
className="text-gray-700 text-sm flex-1 min-w-0 break-words"
title={args.task}
>
{truncateTask(args.task)}
</span>
</div>
</div>
);
};
@@ -0,0 +1,61 @@
/**
* Agent styling utilities for consistent badge appearance.
* LangGraph agents use green, ADK agents use blue.
*/
export type AgentStyle = {
bgColor: string;
textColor: string;
borderColor: string;
icon: string;
framework?: string;
};
export function getAgentStyle(agentName: string): AgentStyle {
if (!agentName) {
return {
bgColor: "bg-gray-100",
textColor: "text-gray-700",
borderColor: "border-gray-300",
icon: "🤖",
framework: "",
};
}
const nameLower = agentName.toLowerCase();
// LangGraph agents (green)
if (nameLower.includes("research")) {
return {
bgColor: "bg-gradient-to-r from-emerald-100 to-green-100",
textColor: "text-emerald-800",
borderColor: "border-emerald-400",
icon: "🔗",
framework: "LangGraph",
};
}
// ADK agents (blue)
if (nameLower.includes("analysis")) {
return {
bgColor: "bg-gradient-to-r from-blue-100 to-sky-100",
textColor: "text-blue-800",
borderColor: "border-blue-400",
icon: "✨",
framework: "ADK",
};
}
return {
bgColor: "bg-gray-100",
textColor: "text-gray-700",
borderColor: "border-gray-300",
icon: "🤖",
framework: "",
};
}
export function truncateTask(text: string, maxLength: number = 50): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + "...";
}
@@ -0,0 +1,116 @@
"use client";
/**
* Chat Component - Main interface with A2A message visualization.
* Extracts structured data from agents and passes to parent for display.
*/
import React, { useEffect } from "react";
import {
useAgent,
useFrontendTool,
CopilotChat,
} from "@copilotkit/react-core/v2";
import { z } from "zod";
import { MessageToA2A } from "./a2a/MessageToA2A";
import { MessageFromA2A } from "./a2a/MessageFromA2A";
type ResearchData = {
topic: string;
summary: string;
findings: Array<{ title: string; description: string }>;
sources: string;
};
type AnalysisData = {
topic: string;
overview: string;
insights: Array<{ title: string; description: string; importance: string }>;
conclusion: string;
};
type ChatProps = {
onResearchUpdate: (data: ResearchData | null) => void;
onAnalysisUpdate: (data: AnalysisData | null) => void;
};
export default function Chat({
onResearchUpdate,
onAnalysisUpdate,
}: ChatProps) {
const { agent } = useAgent({ agentId: "a2a_chat" });
// Extract structured JSON from A2A agent responses and pass to parent
useEffect(() => {
const extractDataFromMessages = () => {
for (const message of agent.messages) {
const msg = message as any;
if (msg.role === "tool" && typeof msg.content !== "undefined") {
try {
const result = msg.content;
let parsed;
if (typeof result === "string") {
let cleanResult = result;
if (result.startsWith("A2A Agent Response: ")) {
cleanResult = result.slice("A2A Agent Response: ".length);
}
try {
parsed = JSON.parse(cleanResult);
} catch {
continue;
}
} else if (typeof result === "object") {
parsed = result;
} else {
continue;
}
if (parsed.findings && Array.isArray(parsed.findings)) {
onResearchUpdate(parsed as ResearchData);
} else if (parsed.insights && Array.isArray(parsed.insights)) {
onAnalysisUpdate(parsed as AnalysisData);
}
} catch (e) {
console.error("Failed to extract data from message:", e);
}
}
}
};
extractDataFromMessages();
}, [agent.messages, onResearchUpdate, onAnalysisUpdate]);
// Register action to render A2A message flow visualization
useFrontendTool({
name: "send_message_to_a2a_agent",
description: "Sends a message to an A2A agent",
available: true,
parameters: z.object({
agentName: z
.string()
.describe("The name of the A2A agent to send the message to"),
task: z.string().describe("The message to send to the A2A agent"),
}),
render: (actionRenderProps) => {
return (
<>
<MessageToA2A {...actionRenderProps} />
<MessageFromA2A {...actionRenderProps} />
</>
);
},
});
return (
<CopilotChat
labels={{
modalHeaderTitle: "Research Assistant",
welcomeMessageText:
'👋 Hi! I\'m your research assistant. I can help you research any topic.\n\nFor example, try:\n- "Research quantum computing"\n- "Tell me about artificial intelligence"\n- "Research renewable energy"\n\nI\'ll coordinate with specialized agents to gather information and provide insights!',
}}
className="h-full"
/>
);
}
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cdf40dca06f22c293a927fa7b70474382438692ec63aa338ab43e613a1a1bcf2
size 1570126

Some files were not shown because too many files have changed in this diff Show More