Skip to content

Commit 2806cfd

Browse files
authored
feat(examples): add Intelligence threads to a2a-a2ui (CopilotKit#5201)
## Summary - add Intelligence Threads wiring for `examples/integrations/a2a-a2ui` - add the reusable threads drawer UI and env-gated Intelligence runtime config - add a local A2UI v0.8 renderer so the demo renders the current A2A operation payloads - extend the migration verifier coverage for a2a-a2ui ## Verification - `pnpm exec vitest run scripts/__tests__/integration-intelligence-migration.test.ts` - `npm run build` from `examples/integrations/a2a-a2ui` - pre-commit hook: `pnpm run test && pnpm run check:packages` via lefthook Refs ENT-734.
2 parents 3de8146 + da45029 commit 2806cfd

14 files changed

Lines changed: 20530 additions & 16 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
OPENAI_API_KEY=your-api-key-here
2+
3+
# --- copilotkit:intelligence (optional local threads stack) ---
4+
# COPILOTKIT_LICENSE_TOKEN=
5+
# INTELLIGENCE_API_KEY=
6+
# INTELLIGENCE_API_URL=http://localhost:4201
7+
# INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,106 @@
11
import {
22
CopilotRuntime,
3+
CopilotKitIntelligence,
34
createCopilotEndpoint,
45
InMemoryAgentRunner,
56
} from "@copilotkit/runtime/v2";
7+
import type {
8+
AgentSubscriber,
9+
RunAgentInput,
10+
RunAgentParameters,
11+
RunAgentResult,
12+
} from "@ag-ui/client";
613
import { handle } from "hono/vercel";
714
import { A2AAgent } from "@ag-ui/a2a";
15+
import type { A2AAgentConfig } from "@ag-ui/a2a";
816
import { A2AClient } from "@a2a-js/sdk/client";
917

1018
const a2aClient = new A2AClient("http://localhost:10002");
1119

12-
const agent = new A2AAgent({ a2aClient });
20+
type RuntimeRunAgentInput = RunAgentParameters &
21+
Partial<Pick<RunAgentInput, "messages" | "state" | "threadId">>;
22+
23+
class RuntimeA2AAgent extends A2AAgent {
24+
private readonly client: A2AClient;
25+
26+
constructor(config: A2AAgentConfig) {
27+
super(config);
28+
this.client = config.a2aClient;
29+
}
30+
31+
async runAgent(
32+
parameters: RuntimeRunAgentInput = {},
33+
subscriber?: AgentSubscriber,
34+
): Promise<RunAgentResult> {
35+
const isolatedAgent = new A2AAgent({
36+
a2aClient: this.client,
37+
agentId: this.agentId,
38+
debug: this.debug,
39+
description: this.description,
40+
initialMessages: this.messages,
41+
initialState: this.state,
42+
threadId: this.threadId,
43+
});
44+
45+
if (parameters.threadId) {
46+
isolatedAgent.threadId = parameters.threadId;
47+
}
48+
49+
if (parameters.state) {
50+
isolatedAgent.setState(parameters.state);
51+
}
52+
53+
if (parameters.messages) {
54+
isolatedAgent.setMessages(parameters.messages);
55+
}
56+
57+
return isolatedAgent.runAgent(
58+
{
59+
context: parameters.context,
60+
forwardedProps: parameters.forwardedProps,
61+
runId: parameters.runId,
62+
tools: parameters.tools,
63+
},
64+
subscriber,
65+
);
66+
}
67+
68+
clone(): RuntimeA2AAgent {
69+
return new RuntimeA2AAgent({
70+
a2aClient: this.client,
71+
agentId: this.agentId,
72+
debug: this.debug,
73+
description: this.description,
74+
initialMessages: this.messages,
75+
initialState: this.state,
76+
threadId: this.threadId,
77+
});
78+
}
79+
}
80+
81+
const agent = new RuntimeA2AAgent({ a2aClient });
1382

1483
const runtime = new CopilotRuntime({
1584
agents: {
1685
default: agent,
1786
},
18-
runner: new InMemoryAgentRunner(),
87+
a2ui: {},
88+
// --- copilotkit:intelligence (remove this block to opt out) ---
89+
...(process.env.COPILOTKIT_LICENSE_TOKEN
90+
? {
91+
intelligence: new CopilotKitIntelligence({
92+
apiKey: process.env.INTELLIGENCE_API_KEY ?? "",
93+
apiUrl: process.env.INTELLIGENCE_API_URL ?? "http://localhost:4201",
94+
wsUrl:
95+
process.env.INTELLIGENCE_GATEWAY_WS_URL ?? "ws://localhost:4401",
96+
}),
97+
// Demo stub — replace with your own auth-derived user identity (e.g. OIDC)
98+
// before any multi-user deployment, or all users share one thread history.
99+
identifyUser: () => ({ id: "demo-user", name: "Demo User" }),
100+
licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN,
101+
}
102+
: { runner: new InMemoryAgentRunner() }),
103+
// --- /copilotkit:intelligence ---
19104
});
20105

21106
const app = createCopilotEndpoint({
@@ -25,3 +110,5 @@ const app = createCopilotEndpoint({
25110

26111
export const GET = handle(app);
27112
export const POST = handle(app);
113+
export const PATCH = handle(app);
114+
export const DELETE = handle(app);
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"use client";
2+
3+
import type { ReactActivityMessageRenderer } from "@copilotkit/react-core/v2";
4+
import { z } from "zod";
5+
6+
type A2UIOperation = {
7+
beginRendering?: { surfaceId?: string };
8+
surfaceUpdate?: {
9+
components?: Array<{
10+
id?: string;
11+
component?: {
12+
Text?: {
13+
text?: { literalString?: string };
14+
};
15+
};
16+
}>;
17+
};
18+
dataModelUpdate?: {
19+
contents?: A2UIDataEntry[];
20+
};
21+
};
22+
23+
type A2UIDataEntry = {
24+
key: string;
25+
valueString?: string;
26+
valueMap?: A2UIDataEntry[];
27+
};
28+
29+
type Restaurant = {
30+
name?: string;
31+
rating?: string;
32+
detail?: string;
33+
infoLink?: string;
34+
imageUrl?: string;
35+
address?: string;
36+
};
37+
38+
function getOperations(content: unknown): A2UIOperation[] {
39+
if (!content || typeof content !== "object") {
40+
return [];
41+
}
42+
43+
const payload = content as {
44+
a2ui_operations?: A2UIOperation[];
45+
operations?: A2UIOperation[];
46+
};
47+
48+
const operations = payload.a2ui_operations ?? payload.operations;
49+
if (Array.isArray(operations)) {
50+
return operations;
51+
}
52+
53+
if (!operations || typeof operations !== "object") {
54+
return [];
55+
}
56+
57+
if (
58+
"beginRendering" in operations ||
59+
"surfaceUpdate" in operations ||
60+
"dataModelUpdate" in operations
61+
) {
62+
return [operations as A2UIOperation];
63+
}
64+
65+
return Object.values(operations).filter(
66+
(operation): operation is A2UIOperation =>
67+
!!operation && typeof operation === "object",
68+
);
69+
}
70+
71+
function getTitle(operations: A2UIOperation[]): string {
72+
for (const operation of operations) {
73+
const title = operation.surfaceUpdate?.components?.find(
74+
(component) => component.id === "title-heading",
75+
)?.component?.Text?.text?.literalString;
76+
77+
if (title) {
78+
return title;
79+
}
80+
}
81+
82+
return "Top Restaurants";
83+
}
84+
85+
function dataEntriesToObject(entries: A2UIDataEntry[] = []): Restaurant {
86+
return Object.fromEntries(
87+
entries.map((entry) => [entry.key, entry.valueString ?? ""]),
88+
);
89+
}
90+
91+
function getRestaurants(operations: A2UIOperation[]): Restaurant[] {
92+
const dataModel = operations.find(
93+
(operation) => operation.dataModelUpdate,
94+
)?.dataModelUpdate;
95+
96+
const itemsEntry = dataModel?.contents?.find(
97+
(entry) => entry.key === "items",
98+
);
99+
return (itemsEntry?.valueMap ?? []).map((entry) =>
100+
dataEntriesToObject(entry.valueMap),
101+
);
102+
}
103+
104+
function readableInfoLink(infoLink: string | undefined): string | null {
105+
if (!infoLink) {
106+
return null;
107+
}
108+
109+
const match = infoLink.match(/\[([^\]]+)\]\(([^)]+)\)/);
110+
return match?.[2] ?? infoLink;
111+
}
112+
113+
function A2UIV08Surface({ content }: { content: unknown }) {
114+
const operations = getOperations(content);
115+
const restaurants = getRestaurants(operations);
116+
const title = getTitle(operations);
117+
118+
if (!operations.length) {
119+
return (
120+
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-500">
121+
Generating UI...
122+
</div>
123+
);
124+
}
125+
126+
return (
127+
<div className="flex flex-col gap-4 py-4">
128+
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
129+
<div className="flex flex-col gap-3">
130+
{restaurants.map((restaurant, index) => (
131+
<article
132+
key={`${restaurant.name ?? "restaurant"}-${index}`}
133+
className="grid gap-4 rounded-lg border border-gray-200 bg-white p-4 shadow-sm sm:grid-cols-[144px_1fr]"
134+
>
135+
{restaurant.imageUrl ? (
136+
<img
137+
src={restaurant.imageUrl}
138+
alt={restaurant.name ?? "Restaurant"}
139+
className="h-32 w-full rounded-md object-cover sm:h-full"
140+
/>
141+
) : null}
142+
<div className="flex min-w-0 flex-col gap-2">
143+
<div>
144+
<h3 className="text-base font-semibold text-gray-950">
145+
{restaurant.name}
146+
</h3>
147+
{restaurant.rating ? (
148+
<p className="text-sm text-amber-500">{restaurant.rating}</p>
149+
) : null}
150+
</div>
151+
{restaurant.detail ? (
152+
<p className="text-sm text-gray-600">{restaurant.detail}</p>
153+
) : null}
154+
{restaurant.address ? (
155+
<p className="text-xs text-gray-500">{restaurant.address}</p>
156+
) : null}
157+
<div className="mt-1 flex flex-wrap gap-2">
158+
{readableInfoLink(restaurant.infoLink) ? (
159+
<a
160+
href={readableInfoLink(restaurant.infoLink) ?? undefined}
161+
target="_blank"
162+
rel="noreferrer"
163+
className="inline-flex h-9 items-center rounded-md border border-gray-200 px-3 text-sm font-medium text-gray-700"
164+
>
165+
More Info
166+
</a>
167+
) : null}
168+
<button
169+
type="button"
170+
className="inline-flex h-9 items-center rounded-md bg-[#FF0000] px-3 text-sm font-medium text-white"
171+
>
172+
Book Now
173+
</button>
174+
</div>
175+
</div>
176+
</article>
177+
))}
178+
</div>
179+
</div>
180+
);
181+
}
182+
183+
export const a2uiV08Renderer: ReactActivityMessageRenderer<unknown> = {
184+
activityType: "a2ui-surface",
185+
content: z.unknown(),
186+
render: ({ content }) => <A2UIV08Surface content={content} />,
187+
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Threads Panel — Design Notes (mastra)
2+
3+
These are mastra's **bespoke** copies of the threads panel. They are no longer a
4+
shared/tokenized base component — they are styled to read as one product with
5+
mastra's `CopilotSidebar` (the right-side chat from `@copilotkit/react-core/v2`).
6+
7+
## Design source of truth
8+
9+
All surfaces, borders, radii, and type ramps are lifted from CopilotKit's V2
10+
design system: `@copilotkit/react-core/src/v2/styles/globals.css` plus the chat
11+
components (`CopilotModalHeader`, `CopilotChatSuggestionPill`,
12+
`CopilotChatInput`, `CopilotSidebarView`). The tokens are mirrored verbatim into
13+
`src/app/globals.css`:
14+
15+
| Token | Value (V2 light) | Role in the panel |
16+
| -------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
17+
| `--card` / `--background` | `oklch(1 0 0)` (white) | Panel + card surfaces |
18+
| `--foreground` | `oklch(0.145 0 0)` | Titles, thread titles, dialog text |
19+
| `--muted` / `--secondary` / `--accent` | `oklch(0.97 0 0)` | Hover/active surfaces, segment track, archived chip, code well |
20+
| `--muted-foreground` | `oklch(0.556 0 0)` | Meta text, idle icons, descriptions, placeholders |
21+
| `--border` / `--input` | `oklch(0.922 0 0)` | All hairline borders |
22+
| `--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 |
23+
| `--primary-foreground` | `oklch(0.985 0 0)` | Primary button text |
24+
| `--destructive` | `oklch(0.577 0.245 27.325)` | Delete hover |
25+
| `--ring` | `oklch(0.708 0 0)` | Focus rings (2px box-shadow) |
26+
| `--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 |
27+
28+
## Forced light
29+
30+
mastra's `CopilotSidebar` is always light regardless of OS color scheme. The
31+
panel must match it, so `src/app/globals.css` re-pins `--foreground` and
32+
`--background` to the V2 light values on `.threadsLayout` (the layout wrapper)
33+
and on `body > [role="presentation"]` (the confirm dialog renders in a portal on
34+
`<body>`). The dark-mode `@media (prefers-color-scheme: dark)` block only flips
35+
the bare page `--background`/`--foreground`; the panel overrides win because
36+
they are scoped to the layout/portal roots.
37+
38+
## Typography
39+
40+
Geist (the app font, via `--font-body` / `--font-code`). Sizes/weights track the
41+
sidebar: header title `1rem / 500 / tracking-tight`, thread titles
42+
`0.8125rem / 500`, meta `0.6875rem`, all medium-weight — no heavy `700`s.
43+
44+
Edit these files freely; they are mastra-owned and not shared with other
45+
examples.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"use client";
2+
3+
export { default as ThreadsDrawer } from "./threads-drawer";
4+
export type { ThreadsDrawerProps } from "./threads-drawer";

0 commit comments

Comments
 (0)