Skip to content

Commit 34afaea

Browse files
committed
feat(showcase/ag2): add headless-complete demo with dedicated AG2 sub-app
1 parent 164233c commit 34afaea

15 files changed

Lines changed: 1013 additions & 0 deletions

showcase/integrations/ag2/src/agent_server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from dotenv import load_dotenv
2020

2121
from agents.agent import stream as default_stream
22+
from agents.headless_complete import headless_complete_app
2223
from agents.shared_state_read_write import (
2324
shared_state_read_write_app,
2425
)
@@ -57,6 +58,7 @@ async def dispatch(self, request, call_next):
5758
# under it, so the named mounts must come first.
5859
app.mount("/shared-state-read-write", shared_state_read_write_app)
5960
app.mount("/subagents", subagents_app)
61+
app.mount("/headless-complete", headless_complete_app)
6062

6163

6264
# Mount the default AG2 AG-UI endpoint at the root.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""AG2 agent backing the Headless Chat (Complete) demo.
2+
3+
The cell exists to prove that every CopilotKit rendering surface works
4+
when the chat UI is composed manually (no <CopilotChatMessageView /> or
5+
<CopilotChatAssistantMessage />). To exercise those surfaces we give
6+
this agent two mock backend tools (``get_weather``, ``get_stock_price``)
7+
which the frontend renders via app-registered ``useRenderTool``
8+
renderers, plus a frontend-registered ``useComponent`` tool
9+
(``highlight_note``) that the agent can invoke -- the UI flows through
10+
the same ``useRenderToolCall`` path.
11+
12+
The system prompt nudges the model toward the right surface per user
13+
question and falls back to plain text otherwise.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from typing import Annotated
19+
20+
from autogen import ConversableAgent, LLMConfig
21+
from autogen.ag_ui import AGUIStream
22+
from fastapi import FastAPI
23+
24+
25+
SYSTEM_PROMPT = (
26+
"You are a helpful, concise assistant wired into a headless chat "
27+
"surface that demonstrates CopilotKit's full rendering stack. Pick "
28+
"the right surface for each user question and fall back to plain "
29+
"text when none of the tools fit.\n\n"
30+
"Routing rules:\n"
31+
" - If the user asks about weather for a place, call `get_weather` "
32+
"with the location.\n"
33+
" - If the user asks about a stock or ticker (AAPL, TSLA, MSFT, "
34+
"...), call `get_stock_price` with the ticker.\n"
35+
" - If the user asks you to highlight, flag, or mark a short note "
36+
"or phrase, call the frontend `highlight_note` tool with the text "
37+
"and a color (yellow, pink, green, or blue). Do NOT ask the user "
38+
"for the color -- pick a sensible one if they didn't say.\n"
39+
" - Otherwise, reply in plain text.\n\n"
40+
"After a tool returns, write one short sentence summarizing the "
41+
"result. Never fabricate data a tool could provide."
42+
)
43+
44+
45+
async def get_weather(
46+
location: Annotated[str, "City or place to look up the weather for"],
47+
) -> dict:
48+
"""Get the current weather for a given location.
49+
50+
Returns a mock payload with city, temperature in Fahrenheit, humidity,
51+
wind speed, and conditions.
52+
"""
53+
return {
54+
"city": location,
55+
"temperature": 68,
56+
"humidity": 55,
57+
"wind_speed": 10,
58+
"conditions": "Sunny",
59+
}
60+
61+
62+
async def get_stock_price(
63+
ticker: Annotated[str, "Stock ticker symbol (e.g. AAPL, TSLA, MSFT)"],
64+
) -> dict:
65+
"""Get a mock current price for a stock ticker.
66+
67+
Returns a payload with the ticker symbol (uppercased), price in USD,
68+
and percentage change for the day.
69+
"""
70+
return {
71+
"ticker": ticker.upper(),
72+
"price_usd": 189.42,
73+
"change_pct": 1.27,
74+
}
75+
76+
77+
agent = ConversableAgent(
78+
name="headless_complete_assistant",
79+
system_message=SYSTEM_PROMPT,
80+
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
81+
human_input_mode="NEVER",
82+
max_consecutive_auto_reply=8,
83+
functions=[get_weather, get_stock_price],
84+
)
85+
86+
stream = AGUIStream(agent)
87+
headless_complete_app = FastAPI()
88+
headless_complete_app.mount("", stream.build_asgi())

showcase/integrations/ag2/src/app/api/copilotkit/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const sharedAgentNames = [
5656
const dedicatedAgents: Record<string, string> = {
5757
"shared-state-read-write": "/shared-state-read-write/",
5858
subagents: "/subagents/",
59+
"headless-complete": "/headless-complete/",
5960
};
6061

6162
const agents: Record<string, AbstractAgent> = {};
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Headless Chat (Complete)
2+
3+
## What This Demo Shows
4+
5+
Full chat implementation built from scratch on `useAgent` — no
6+
`<CopilotChat />`, no `<CopilotChatMessageView>`, no
7+
`<CopilotChatAssistantMessage>`. Demonstrates that every CopilotKit
8+
rendering surface (text, reasoning, tool-call renders, custom-message
9+
slots) can be re-composed by hand from the low-level hooks.
10+
11+
## How to Interact
12+
13+
Try asking your Copilot to:
14+
15+
- "What's the weather in Tokyo?"
16+
- "What's AAPL trading at right now?"
17+
- "Highlight 'meeting at 3pm' in yellow."
18+
19+
## Technical Details
20+
21+
- **Backend**: A dedicated AG2 `ConversableAgent` (see
22+
`src/agents/headless_complete.py`) mounted at `/headless-complete/`
23+
on the Python agent server. Exposes `get_weather` and
24+
`get_stock_price` as backend tools.
25+
- **Frontend tool**: `highlight_note` is registered on the frontend
26+
via `useComponent`, which surfaces through the same
27+
`useRenderToolCall` path the manual hook consumes.
28+
- **Manual composition**: `use-rendered-messages.tsx` mirrors
29+
CopilotChatMessageView's role dispatch, walking each message and
30+
producing a `renderedContent` node. The list (message-list.tsx)
31+
drops that node into a `<UserBubble>` or `<AssistantBubble>`
32+
chrome wrapper.
33+
- **Chrome only**: the bubbles, typing indicator, and input bar
34+
contain zero imports from `@copilotkit/react-core`'s chat
35+
primitives — they're pure presentational components.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"use client";
2+
3+
import React from "react";
4+
5+
/**
6+
* Left-aligned assistant bubble — pure chrome.
7+
*
8+
* Receives a precomputed `renderedContent` node (built by
9+
* `useRenderedMessages`) and wraps it in the styled bubble container.
10+
* No imports from `@copilotkit/react-core`'s chat primitives here — the
11+
* manual composition upstream already produced the final node, so this
12+
* file is purely presentational.
13+
*
14+
* An empty node (e.g. an assistant message that has neither text nor tool
15+
* calls yet) is suppressed so the bubble doesn't flash an empty rounded
16+
* box while streaming hasn't started.
17+
*/
18+
// @region[custom-bubbles]
19+
export function AssistantBubble({ children }: { children: React.ReactNode }) {
20+
if (isEmpty(children)) return null;
21+
22+
return (
23+
<div className="flex justify-start">
24+
<div className="max-w-[85%] flex flex-col gap-2">
25+
<div className="rounded-2xl rounded-bl-sm bg-[#F0F0F4] text-[#010507] px-4 py-2 text-sm">
26+
{children}
27+
</div>
28+
</div>
29+
</div>
30+
);
31+
}
32+
// @endregion[custom-bubbles]
33+
34+
function isEmpty(node: React.ReactNode): boolean {
35+
if (node == null || node === false) return true;
36+
if (typeof node === "string") return node.trim().length === 0;
37+
if (Array.isArray(node)) return node.every(isEmpty);
38+
return false;
39+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"use client";
2+
3+
import React from "react";
4+
import { z } from "zod";
5+
6+
/**
7+
* Frontend-only component invoked by the agent as a tool call via
8+
* `useComponent({ name: "highlight_note", ... })`. The backend does
9+
* NOT define this tool — `useComponent` is sugar over
10+
* `useFrontendTool`, so the tool is registered against the frontend and
11+
* surfaces through the same `useRenderToolCall` path the manual hook
12+
* in `use-rendered-messages.tsx` is wired to.
13+
*/
14+
export const highlightNotePropsSchema = z.object({
15+
text: z.string().describe("The note text to highlight."),
16+
color: z
17+
.enum(["yellow", "pink", "green", "blue"])
18+
.describe("Highlight color for the note."),
19+
});
20+
21+
export type HighlightNoteProps = z.infer<typeof highlightNotePropsSchema>;
22+
23+
const COLOR_CLASSES: Record<HighlightNoteProps["color"], string> = {
24+
yellow: "bg-[#FFF388]/30 border-[#FFF388] text-[#010507]",
25+
pink: "bg-[#FA5F67]/10 border-[#FA5F6733] text-[#010507]",
26+
green: "bg-[#85ECCE]/20 border-[#85ECCE4D] text-[#010507]",
27+
blue: "bg-[#BEC2FF1A] border-[#BEC2FF] text-[#010507]",
28+
};
29+
30+
export function HighlightNote({ text, color }: HighlightNoteProps) {
31+
const cls = COLOR_CLASSES[color] ?? COLOR_CLASSES.yellow;
32+
return (
33+
<div
34+
className={`mt-2 mb-2 inline-block rounded-xl border px-3 py-2 text-sm font-medium shadow-sm ${cls}`}
35+
>
36+
<span className="mr-2 text-[10px] uppercase tracking-[0.14em] text-[#57575B]">
37+
Note
38+
</span>
39+
{text}
40+
</div>
41+
);
42+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"use client";
2+
3+
import React, { useRef } from "react";
4+
5+
/**
6+
* Composer for the headless chat.
7+
*
8+
* A textarea plus a Send / Stop toggle. Enter submits; Shift+Enter inserts a
9+
* newline. The textarea is disabled while the agent is running so users can't
10+
* pile up concurrent turns.
11+
*/
12+
export function InputBar({
13+
value,
14+
onChange,
15+
onSubmit,
16+
onStop,
17+
isRunning,
18+
canStop,
19+
}: {
20+
value: string;
21+
onChange: (v: string) => void;
22+
onSubmit: () => void;
23+
onStop: () => void;
24+
isRunning: boolean;
25+
canStop: boolean;
26+
}) {
27+
const inputRef = useRef<HTMLTextAreaElement>(null);
28+
29+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
30+
if (e.key === "Enter" && !e.shiftKey) {
31+
e.preventDefault();
32+
onSubmit();
33+
}
34+
};
35+
36+
return (
37+
<form
38+
className="border-t border-[#E9E9EF] p-3 flex gap-2 items-end bg-white"
39+
onSubmit={(e) => {
40+
e.preventDefault();
41+
onSubmit();
42+
}}
43+
>
44+
<textarea
45+
ref={inputRef}
46+
rows={1}
47+
value={value}
48+
onChange={(e) => onChange(e.target.value)}
49+
onKeyDown={handleKeyDown}
50+
placeholder={isRunning ? "Agent is working..." : "Type a message..."}
51+
disabled={isRunning}
52+
className="flex-1 resize-none rounded-2xl border border-[#DBDBE5] bg-white px-4 py-2 text-sm leading-6 text-[#010507] focus:border-[#BEC2FF] focus:outline-none focus:ring-2 focus:ring-[#BEC2FF33] disabled:bg-[#FAFAFC] disabled:text-[#AFAFB7]"
53+
/>
54+
{canStop ? (
55+
<button
56+
type="button"
57+
onClick={onStop}
58+
className="rounded-full px-4 py-2 text-sm font-medium bg-[#FA5F67] text-white hover:opacity-90 transition-opacity"
59+
>
60+
Stop
61+
</button>
62+
) : (
63+
<button
64+
type="submit"
65+
disabled={isRunning || value.trim().length === 0}
66+
className="rounded-full px-4 py-2 text-sm font-medium bg-[#010507] text-white hover:bg-[#2B2B2B] disabled:bg-[#DBDBE5] disabled:cursor-not-allowed transition-colors"
67+
>
68+
Send
69+
</button>
70+
)}
71+
</form>
72+
);
73+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"use client";
2+
3+
import React, { useLayoutEffect, useRef } from "react";
4+
import type { Message } from "@ag-ui/core";
5+
import { UserBubble } from "./user-bubble";
6+
import { AssistantBubble } from "./assistant-bubble";
7+
import { TypingIndicator } from "./typing-indicator";
8+
import { useRenderedMessages } from "./use-rendered-messages";
9+
10+
/**
11+
* Scrollable messages area — TRULY headless.
12+
*
13+
* The per-message generative-UI weave (text, reasoning, tool-call renders,
14+
* activity messages, custom-before / custom-after) is composed inline by
15+
* `useRenderedMessages`, which returns a flat list of messages each carrying
16+
* a precomputed `renderedContent` field. Here we simply dispatch on role
17+
* and drop that node into the appropriate bubble chrome — no
18+
* `<CopilotChatMessageView>`, no `<CopilotChatAssistantMessage>`.
19+
*
20+
* See `use-rendered-messages.tsx` for the composition logic; it mirrors
21+
* `packages/react-core/src/v2/components/chat/CopilotChatMessageView.tsx:542-612`.
22+
*/
23+
export function MessageList({
24+
messages,
25+
isRunning,
26+
}: {
27+
messages: Message[];
28+
isRunning: boolean;
29+
}) {
30+
const scrollRef = useRef<HTMLDivElement>(null);
31+
const renderedMessages = useRenderedMessages(messages, isRunning);
32+
33+
// Auto-scroll on streaming content changes (not just new messages).
34+
const fingerprint = messages
35+
.map((m) => {
36+
const contentLen =
37+
typeof m.content === "string"
38+
? m.content.length
39+
: Array.isArray(m.content)
40+
? m.content.length
41+
: 0;
42+
const tcLen =
43+
"toolCalls" in m && Array.isArray(m.toolCalls)
44+
? m.toolCalls.map((tc) => tc.function.arguments.length).join(",")
45+
: "";
46+
return `${m.id}:${m.role}:${contentLen}:${tcLen}`;
47+
})
48+
.join("|");
49+
50+
useLayoutEffect(() => {
51+
const el = scrollRef.current;
52+
if (!el) return;
53+
el.scrollTop = el.scrollHeight;
54+
}, [fingerprint, isRunning]);
55+
56+
return (
57+
<div
58+
ref={scrollRef}
59+
data-testid="headless-complete-messages"
60+
className="flex-1 min-h-0 overflow-y-auto px-4 py-4"
61+
>
62+
<div className="space-y-3">
63+
{renderedMessages.length === 0 && (
64+
<div className="text-center text-sm text-[#838389] mt-8">
65+
Try weather, a stock, a highlighted note, or an Excalidraw sketch.
66+
</div>
67+
)}
68+
{renderedMessages.map((m) => {
69+
// Tool-role messages are folded into the preceding assistant
70+
// message's tool-call renders; `renderedContent` is null for them.
71+
if (m.renderedContent == null) return null;
72+
if (m.role === "user") {
73+
return <UserBubble key={m.id}>{m.renderedContent}</UserBubble>;
74+
}
75+
return (
76+
<AssistantBubble key={m.id}>{m.renderedContent}</AssistantBubble>
77+
);
78+
})}
79+
{isRunning && <TypingIndicator />}
80+
</div>
81+
</div>
82+
);
83+
}

0 commit comments

Comments
 (0)