chore: import upstream snapshot with attribution
This commit is contained in:
+48
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotRuntimeHandler,
|
||||
InMemoryAgentRunner,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { createA2UIFixedSchemaAgent } from "@/lib/factory/a2ui-fixed-schema-factory";
|
||||
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
|
||||
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
|
||||
// LLM calls. See @/lib/header-forwarding for the full rationale.
|
||||
import { withForwardedHeaders } from "@/lib/header-forwarding";
|
||||
|
||||
// Dedicated runtime for the A2UI — Fixed Schema demo.
|
||||
//
|
||||
// `a2ui.injectA2UITool: false` — the backend factory owns the
|
||||
// `display_flight` tool which emits its own `a2ui_operations` container
|
||||
// (see src/lib/factory/a2ui-fixed-schema-factory.ts). The A2UI middleware
|
||||
// still runs so it detects the container in tool results and forwards the
|
||||
// rendered surface to the frontend renderer; we just don't want it to also
|
||||
// inject a runtime `render_a2ui` tool on top of our own.
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: { default: createA2UIFixedSchemaAgent() },
|
||||
runner: new InMemoryAgentRunner(),
|
||||
a2ui: {
|
||||
injectA2UITool: false,
|
||||
},
|
||||
});
|
||||
|
||||
const handler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit-a2ui-fixed-schema",
|
||||
mode: "single-route",
|
||||
});
|
||||
|
||||
async function withProbeCompat(req: Request): Promise<Response> {
|
||||
const res = await handler(req);
|
||||
if (res.status === 404) {
|
||||
const body = await res.text();
|
||||
return new Response(body, { status: 400, headers: res.headers });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export const GET = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
export const POST = (req: Request) =>
|
||||
withForwardedHeaders(req, () => withProbeCompat(req));
|
||||
export const OPTIONS = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
@@ -0,0 +1,113 @@
|
||||
// Dedicated runtime for the Agent Config Object demo.
|
||||
//
|
||||
// The factory reads `input.forwardedProps` (which the CopilotKit provider
|
||||
// populates from its `properties` prop) and prepends a tone/expertise/
|
||||
// length-tuned system prompt per turn.
|
||||
|
||||
import {
|
||||
BuiltInAgent,
|
||||
CopilotRuntime,
|
||||
convertInputToTanStackAI,
|
||||
createCopilotRuntimeHandler,
|
||||
InMemoryAgentRunner,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { chat } from "@tanstack/ai";
|
||||
import { openaiText } from "@tanstack/ai-openai";
|
||||
// `withForwardedHeaders` snapshots inbound x-* headers (e.g.
|
||||
// x-aimock-context) into an AsyncLocalStorage scope; `forwardingFetch`
|
||||
// re-attaches them on every outbound LLM call. Required because
|
||||
// `@tanstack/ai-openai`'s `openaiText()` adapter has no per-request
|
||||
// header hook. See @/lib/header-forwarding for the full rationale.
|
||||
import { forwardingFetch, withForwardedHeaders } from "@/lib/header-forwarding";
|
||||
|
||||
const TONE_GUIDANCE: Record<string, string> = {
|
||||
professional:
|
||||
"Use a measured, professional tone. Avoid slang and exclamation marks.",
|
||||
casual:
|
||||
"Use a friendly, conversational tone — like talking to a coworker over coffee.",
|
||||
enthusiastic:
|
||||
"Use an upbeat, energetic tone. Show genuine excitement about the topic.",
|
||||
};
|
||||
|
||||
const EXPERTISE_GUIDANCE: Record<string, string> = {
|
||||
beginner:
|
||||
"Assume the user is new to this topic. Avoid jargon; define terms inline.",
|
||||
intermediate:
|
||||
"Assume the user has working familiarity. You can use common technical terms without defining each one.",
|
||||
expert:
|
||||
"Assume the user is an expert. You can use precise jargon and skip introductory framing.",
|
||||
};
|
||||
|
||||
const RESPONSE_LENGTH_GUIDANCE: Record<string, string> = {
|
||||
concise:
|
||||
"Keep responses tight — 1-3 short sentences, or a 3-bullet list at most.",
|
||||
detailed:
|
||||
"Provide a thorough answer. Use headings, paragraphs, or longer lists when warranted.",
|
||||
};
|
||||
|
||||
function buildConfigSystemPrompt(props: Record<string, unknown>): string {
|
||||
const tone = typeof props.tone === "string" ? props.tone : "professional";
|
||||
const expertise =
|
||||
typeof props.expertise === "string" ? props.expertise : "intermediate";
|
||||
const responseLength =
|
||||
typeof props.responseLength === "string" ? props.responseLength : "concise";
|
||||
|
||||
const toneLine = TONE_GUIDANCE[tone] ?? TONE_GUIDANCE.professional;
|
||||
const expertiseLine =
|
||||
EXPERTISE_GUIDANCE[expertise] ?? EXPERTISE_GUIDANCE.intermediate;
|
||||
const lengthLine =
|
||||
RESPONSE_LENGTH_GUIDANCE[responseLength] ??
|
||||
RESPONSE_LENGTH_GUIDANCE.concise;
|
||||
|
||||
return [
|
||||
"You adapt your responses based on the active agent config:",
|
||||
`- tone=${tone}: ${toneLine}`,
|
||||
`- expertise=${expertise}: ${expertiseLine}`,
|
||||
`- responseLength=${responseLength}: ${lengthLine}`,
|
||||
"Mention the active config (tone / expertise / responseLength) at the start of each reply so the user can see it took effect.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function createAgentConfigAgent() {
|
||||
return new BuiltInAgent({
|
||||
type: "tanstack",
|
||||
factory: ({ input, abortController }) => {
|
||||
const props = (input.forwardedProps ?? {}) as Record<string, unknown>;
|
||||
const { messages, systemPrompts } = convertInputToTanStackAI(input);
|
||||
return chat({
|
||||
adapter: openaiText("gpt-4o", { fetch: forwardingFetch }),
|
||||
messages,
|
||||
systemPrompts: [buildConfigSystemPrompt(props), ...systemPrompts],
|
||||
tools: [],
|
||||
abortController,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: { default: createAgentConfigAgent() },
|
||||
runner: new InMemoryAgentRunner(),
|
||||
});
|
||||
|
||||
const handler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit-agent-config",
|
||||
mode: "single-route",
|
||||
});
|
||||
|
||||
async function withProbeCompat(req: Request): Promise<Response> {
|
||||
const res = await handler(req);
|
||||
if (res.status === 404) {
|
||||
const body = await res.text();
|
||||
return new Response(body, { status: 400, headers: res.headers });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export const GET = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
export const POST = (req: Request) =>
|
||||
withForwardedHeaders(req, () => withProbeCompat(req));
|
||||
export const OPTIONS = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
@@ -0,0 +1,61 @@
|
||||
// Dedicated runtime for the /demos/auth cell.
|
||||
//
|
||||
// Demonstrates framework-native request authentication via the V2 runtime's
|
||||
// `onRequest` hook, which runs before routing and short-circuits with a 401
|
||||
// Response when the Authorization header is missing or invalid.
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotRuntimeHandler,
|
||||
InMemoryAgentRunner,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { createBuiltInAgent } from "@/lib/factory/tanstack-factory";
|
||||
import { DEMO_AUTH_HEADER } from "@/app/demos/auth/demo-token";
|
||||
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
|
||||
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
|
||||
// LLM calls. See @/lib/header-forwarding for the full rationale.
|
||||
import { withForwardedHeaders } from "@/lib/header-forwarding";
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: { default: createBuiltInAgent() },
|
||||
runner: new InMemoryAgentRunner(),
|
||||
});
|
||||
|
||||
const BASE_PATH = "/api/copilotkit-auth";
|
||||
|
||||
const handler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: BASE_PATH,
|
||||
hooks: {
|
||||
onRequest: ({ request }) => {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (authHeader !== DEMO_AUTH_HEADER) {
|
||||
// Throwing a Response short-circuits the pipeline — the runtime maps
|
||||
// the thrown Response to the HTTP response verbatim.
|
||||
throw new Response(
|
||||
JSON.stringify({
|
||||
error: "unauthorized",
|
||||
message:
|
||||
"Missing or invalid Authorization header. Click Authenticate above to send messages.",
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = (req: NextRequest) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
export const GET = (req: NextRequest) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
export const PUT = (req: NextRequest) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
export const DELETE = (req: NextRequest) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
export const OPTIONS = (req: NextRequest) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
@@ -0,0 +1,43 @@
|
||||
// Dedicated runtime for the BYOC hashbrown demo.
|
||||
//
|
||||
// Built-in-agent factory with a sales-dashboard system prompt and OpenAI
|
||||
// `response_format: { type: "json_object" }` so the model can only emit a
|
||||
// single JSON object — exactly what the hashbrown `useJsonParser` consumes.
|
||||
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotRuntimeHandler,
|
||||
InMemoryAgentRunner,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { createByocHashbrownAgent } from "@/lib/factory/byoc-hashbrown-factory";
|
||||
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
|
||||
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
|
||||
// LLM calls. See @/lib/header-forwarding for the full rationale.
|
||||
import { withForwardedHeaders } from "@/lib/header-forwarding";
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: { default: createByocHashbrownAgent() },
|
||||
runner: new InMemoryAgentRunner(),
|
||||
});
|
||||
|
||||
const handler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit-byoc-hashbrown",
|
||||
mode: "single-route",
|
||||
});
|
||||
|
||||
async function withProbeCompat(req: Request): Promise<Response> {
|
||||
const res = await handler(req);
|
||||
if (res.status === 404) {
|
||||
const body = await res.text();
|
||||
return new Response(body, { status: 400, headers: res.headers });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export const GET = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
export const POST = (req: Request) =>
|
||||
withForwardedHeaders(req, () => withProbeCompat(req));
|
||||
export const OPTIONS = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
@@ -0,0 +1,44 @@
|
||||
// Dedicated runtime for the BYOC json-render demo.
|
||||
//
|
||||
// Built-in-agent factory with a sales-dashboard system prompt and OpenAI
|
||||
// `response_format: { type: "json_object" }` so the model emits exactly
|
||||
// one JSON object — what `<Renderer />` consumes as a flat
|
||||
// `{ root, elements }` spec.
|
||||
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotRuntimeHandler,
|
||||
InMemoryAgentRunner,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { createByocJsonRenderAgent } from "@/lib/factory/byoc-json-render-factory";
|
||||
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
|
||||
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
|
||||
// LLM calls. See @/lib/header-forwarding for the full rationale.
|
||||
import { withForwardedHeaders } from "@/lib/header-forwarding";
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: { default: createByocJsonRenderAgent() },
|
||||
runner: new InMemoryAgentRunner(),
|
||||
});
|
||||
|
||||
const handler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit-byoc-json-render",
|
||||
mode: "single-route",
|
||||
});
|
||||
|
||||
async function withProbeCompat(req: Request): Promise<Response> {
|
||||
const res = await handler(req);
|
||||
if (res.status === 404) {
|
||||
const body = await res.text();
|
||||
return new Response(body, { status: 400, headers: res.headers });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export const GET = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
export const POST = (req: Request) =>
|
||||
withForwardedHeaders(req, () => withProbeCompat(req));
|
||||
export const OPTIONS = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotRuntimeHandler,
|
||||
InMemoryAgentRunner,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { createDeclarativeGenUIAgent } from "@/lib/factory/a2ui-factory";
|
||||
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
|
||||
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
|
||||
// LLM calls. See @/lib/header-forwarding for the full rationale.
|
||||
import { withForwardedHeaders } from "@/lib/header-forwarding";
|
||||
|
||||
// Dedicated runtime for the Declarative Generative UI (A2UI — Dynamic
|
||||
// Schema) demo.
|
||||
//
|
||||
// `a2ui.injectA2UITool: false` — the backend factory owns the
|
||||
// `generate_a2ui` tool itself (see `src/lib/factory/a2ui-factory.ts`), so
|
||||
// the runtime MUST NOT auto-inject its own A2UI tool on top. The A2UI
|
||||
// middleware still runs — it serialises the registered client catalog
|
||||
// schema into the agent's `input.context` so the secondary LLM inside
|
||||
// `generate_a2ui` knows which components to emit, and it still detects the
|
||||
// `a2ui_operations` container in the tool result and streams rendered
|
||||
// surfaces to the frontend.
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: { default: createDeclarativeGenUIAgent() },
|
||||
runner: new InMemoryAgentRunner(),
|
||||
a2ui: {
|
||||
injectA2UITool: false,
|
||||
// Models follow the tool-usage guide and omit `catalogId`, and the
|
||||
// middleware then falls back to the unregistered spec basic catalog
|
||||
// ("Catalog not found" render error). Pin the catalog the page registers.
|
||||
defaultCatalogId: "declarative-gen-ui-catalog",
|
||||
},
|
||||
});
|
||||
|
||||
const handler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit-declarative-gen-ui",
|
||||
mode: "single-route",
|
||||
});
|
||||
|
||||
async function withProbeCompat(req: Request): Promise<Response> {
|
||||
const res = await handler(req);
|
||||
if (res.status === 404) {
|
||||
const body = await res.text();
|
||||
return new Response(body, { status: 400, headers: res.headers });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export const GET = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
export const POST = (req: Request) =>
|
||||
withForwardedHeaders(req, () => withProbeCompat(req));
|
||||
export const OPTIONS = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotRuntimeHandler,
|
||||
InMemoryAgentRunner,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { createMcpAppsAgent } from "@/lib/factory/mcp-apps-factory";
|
||||
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
|
||||
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
|
||||
// LLM calls. See @/lib/header-forwarding for the full rationale.
|
||||
import { withForwardedHeaders } from "@/lib/header-forwarding";
|
||||
|
||||
// @region[runtime-mcpapps-config]
|
||||
// Dedicated runtime for the MCP Apps demo.
|
||||
//
|
||||
// `mcpApps.servers` auto-applies the MCP Apps middleware to every registered
|
||||
// agent: the middleware exposes the remote MCP server's tools to the agent at
|
||||
// request time and emits the activity events that CopilotKit's built-in
|
||||
// `MCPAppsActivityRenderer` renders inline as a sandboxed iframe.
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: { default: createMcpAppsAgent() },
|
||||
runner: new InMemoryAgentRunner(),
|
||||
mcpApps: {
|
||||
servers: [
|
||||
{
|
||||
type: "http",
|
||||
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
|
||||
// Always pin a stable serverId — without it CopilotKit hashes the URL
|
||||
// and a URL change silently breaks restoration of persisted MCP apps.
|
||||
serverId: "excalidraw",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
// @endregion[runtime-mcpapps-config]
|
||||
|
||||
const handler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit-mcp-apps",
|
||||
mode: "single-route",
|
||||
});
|
||||
|
||||
async function withProbeCompat(req: Request): Promise<Response> {
|
||||
const res = await handler(req);
|
||||
if (res.status === 404) {
|
||||
const body = await res.text();
|
||||
return new Response(body, { status: 400, headers: res.headers });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export const GET = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
export const POST = (req: Request) =>
|
||||
withForwardedHeaders(req, () => withProbeCompat(req));
|
||||
export const OPTIONS = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
@@ -0,0 +1,43 @@
|
||||
// Dedicated runtime for the Multimodal Attachments demo.
|
||||
//
|
||||
// Reuses the base built-in-agent factory (which already uses gpt-4o, a
|
||||
// vision-capable model). AG-UI image / document parts flow through
|
||||
// `convertInputToTanStackAI` natively; no agent-side rewrite is required.
|
||||
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotRuntimeHandler,
|
||||
InMemoryAgentRunner,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { createBuiltInAgent } from "@/lib/factory/tanstack-factory";
|
||||
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
|
||||
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
|
||||
// LLM calls. See @/lib/header-forwarding for the full rationale.
|
||||
import { withForwardedHeaders } from "@/lib/header-forwarding";
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: { default: createBuiltInAgent() },
|
||||
runner: new InMemoryAgentRunner(),
|
||||
});
|
||||
|
||||
const handler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit-multimodal",
|
||||
mode: "single-route",
|
||||
});
|
||||
|
||||
async function withProbeCompat(req: Request): Promise<Response> {
|
||||
const res = await handler(req);
|
||||
if (res.status === 404) {
|
||||
const body = await res.text();
|
||||
return new Response(body, { status: 400, headers: res.headers });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export const GET = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
export const POST = (req: Request) =>
|
||||
withForwardedHeaders(req, () => withProbeCompat(req));
|
||||
export const OPTIONS = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotRuntimeHandler,
|
||||
InMemoryAgentRunner,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { createOguiAgent } from "@/lib/factory/ogui-factory";
|
||||
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
|
||||
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
|
||||
// LLM calls. See @/lib/header-forwarding for the full rationale.
|
||||
import { withForwardedHeaders } from "@/lib/header-forwarding";
|
||||
|
||||
// Dedicated runtime for the Open Generative UI demo.
|
||||
//
|
||||
// Isolated because the `openGenerativeUI` runtime flag advertises
|
||||
// `openGenerativeUIEnabled: true` on the probe, which causes the
|
||||
// CopilotKit provider's setTools effect to behave differently from the
|
||||
// default tools-only runtime. Keeping it on its own basePath avoids
|
||||
// cross-talk with other demos.
|
||||
//
|
||||
// Server-side config is identical for the minimal and advanced cells —
|
||||
// the advanced behaviour (sandbox -> host function calls) is wired
|
||||
// entirely on the frontend via `openGenerativeUI.sandboxFunctions` on
|
||||
// the provider. The single `openGenerativeUI` flag below turns on
|
||||
// Open Generative UI for the listed agent(s).
|
||||
// @region[minimal-runtime-flag]
|
||||
// @region[advanced-runtime-config]
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: { default: createOguiAgent() },
|
||||
runner: new InMemoryAgentRunner(),
|
||||
openGenerativeUI: {
|
||||
agents: ["default"],
|
||||
},
|
||||
});
|
||||
// @endregion[advanced-runtime-config]
|
||||
// @endregion[minimal-runtime-flag]
|
||||
|
||||
const handler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit-ogui",
|
||||
mode: "single-route",
|
||||
});
|
||||
|
||||
async function withProbeCompat(req: Request): Promise<Response> {
|
||||
const res = await handler(req);
|
||||
if (res.status === 404) {
|
||||
const body = await res.text();
|
||||
return new Response(body, { status: 400, headers: res.headers });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export const GET = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
export const POST = (req: Request) =>
|
||||
withForwardedHeaders(req, () => withProbeCompat(req));
|
||||
export const OPTIONS = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotRuntimeHandler,
|
||||
InMemoryAgentRunner,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import {
|
||||
createAgenticChatReasoningAgent,
|
||||
createReasoningDefaultRenderAgent,
|
||||
createToolRenderingReasoningChainAgent,
|
||||
} from "@/lib/factory/reasoning-factory";
|
||||
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
|
||||
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
|
||||
// LLM calls. See @/lib/header-forwarding for the full rationale.
|
||||
import { withForwardedHeaders } from "@/lib/header-forwarding";
|
||||
|
||||
// Shared runtime for the three reasoning demos. The default tanstack
|
||||
// factory uses a non-reasoning model (gpt-4o) — these demos need a
|
||||
// reasoning-capable variant so REASONING_* events flow. They live on
|
||||
// their own basePath so a single page only spins up the reasoning model
|
||||
// when actually viewing a reasoning demo.
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
"agentic-chat-reasoning": createAgenticChatReasoningAgent(),
|
||||
"reasoning-default-render": createReasoningDefaultRenderAgent(),
|
||||
"tool-rendering-reasoning-chain": createToolRenderingReasoningChainAgent(),
|
||||
},
|
||||
runner: new InMemoryAgentRunner(),
|
||||
});
|
||||
|
||||
const handler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit-reasoning",
|
||||
mode: "single-route",
|
||||
});
|
||||
|
||||
async function withProbeCompat(req: Request): Promise<Response> {
|
||||
const res = await handler(req);
|
||||
if (res.status === 404) {
|
||||
const body = await res.text();
|
||||
return new Response(body, { status: 400, headers: res.headers });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export const GET = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
export const POST = (req: Request) =>
|
||||
withForwardedHeaders(req, () => withProbeCompat(req));
|
||||
export const OPTIONS = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
// Dedicated runtime for the voice demo.
|
||||
//
|
||||
// 1. Advertises `audioFileTranscriptionEnabled: true` on `/info` so the chat
|
||||
// composer renders the mic button.
|
||||
// 2. Handles `POST /transcribe` by invoking an OpenAI-backed
|
||||
// `TranscriptionServiceOpenAI` (from `@copilotkit/voice`).
|
||||
// 3. Returns a deterministic 401 when `OPENAI_API_KEY` is not configured —
|
||||
// `handleTranscribe` maps "api key" / "unauthorized" messages to
|
||||
// AUTH_FAILED → HTTP 401.
|
||||
//
|
||||
// Lives at `[[...slug]]/route.ts` because the V2 router URL-routes on
|
||||
// `/info`, `/transcribe`, etc., under the same base path.
|
||||
|
||||
// @region[voice-runtime]
|
||||
// @region[transcription-service-guard]
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
TranscriptionService,
|
||||
createCopilotRuntimeHandler,
|
||||
InMemoryAgentRunner,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import type { TranscribeFileOptions } from "@copilotkit/runtime/v2";
|
||||
import { TranscriptionServiceOpenAI } from "@copilotkit/voice";
|
||||
import OpenAI from "openai";
|
||||
import { createBuiltInAgent } from "@/lib/factory/tanstack-factory";
|
||||
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
|
||||
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
|
||||
// LLM calls. See @/lib/header-forwarding for the full rationale.
|
||||
import { withForwardedHeaders } from "@/lib/header-forwarding";
|
||||
|
||||
class GuardedOpenAITranscriptionService extends TranscriptionService {
|
||||
private delegate: TranscriptionServiceOpenAI | null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
this.delegate = apiKey
|
||||
? new TranscriptionServiceOpenAI({ openai: new OpenAI({ apiKey }) })
|
||||
: null;
|
||||
}
|
||||
|
||||
async transcribeFile(options: TranscribeFileOptions): Promise<string> {
|
||||
if (!this.delegate) {
|
||||
// "api key" substring → handleTranscribe maps to AUTH_FAILED → 401.
|
||||
throw new Error(
|
||||
"OPENAI_API_KEY not configured for this deployment (api key missing). " +
|
||||
"Set OPENAI_API_KEY to enable voice transcription.",
|
||||
);
|
||||
}
|
||||
return this.delegate.transcribeFile(options);
|
||||
}
|
||||
}
|
||||
// @endregion[transcription-service-guard]
|
||||
|
||||
let cachedHandler: ((req: Request) => Promise<Response>) | null = null;
|
||||
function getHandler(): (req: Request) => Promise<Response> {
|
||||
if (cachedHandler) return cachedHandler;
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: { default: createBuiltInAgent() },
|
||||
runner: new InMemoryAgentRunner(),
|
||||
transcriptionService: new GuardedOpenAITranscriptionService(),
|
||||
});
|
||||
|
||||
cachedHandler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit-voice",
|
||||
});
|
||||
return cachedHandler;
|
||||
}
|
||||
|
||||
export const POST = (req: NextRequest) =>
|
||||
withForwardedHeaders(req, () => getHandler()(req));
|
||||
export const GET = (req: NextRequest) =>
|
||||
withForwardedHeaders(req, () => getHandler()(req));
|
||||
export const PUT = (req: NextRequest) =>
|
||||
withForwardedHeaders(req, () => getHandler()(req));
|
||||
export const DELETE = (req: NextRequest) =>
|
||||
withForwardedHeaders(req, () => getHandler()(req));
|
||||
// @endregion[voice-runtime]
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotRuntimeHandler,
|
||||
InMemoryAgentRunner,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { createBuiltInAgent } from "@/lib/factory/tanstack-factory";
|
||||
// `withForwardedHeaders` snapshots inbound x-* headers (e.g.
|
||||
// x-aimock-context) into an AsyncLocalStorage scope so the wrapped
|
||||
// OpenAI client's custom fetch can re-attach them on every outbound
|
||||
// LLM call. Required because `@tanstack/ai-openai`'s `openaiText()`
|
||||
// adapter has no per-request header hook of its own.
|
||||
import { withForwardedHeaders } from "@/lib/header-forwarding";
|
||||
// CVDIAG backend instrumentation (L1-E). No-op pass-through unless
|
||||
// CVDIAG_BACKEND_EMITTER is set truthy (default OFF).
|
||||
import { withCvdiagBackend } from "@/cvdiag-backend";
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: { default: createBuiltInAgent() },
|
||||
runner: new InMemoryAgentRunner(),
|
||||
});
|
||||
|
||||
const handler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit",
|
||||
mode: "single-route",
|
||||
});
|
||||
|
||||
async function withProbeCompat(req: Request): Promise<Response> {
|
||||
const res = await handler(req);
|
||||
if (res.status === 404) {
|
||||
const body = await res.text();
|
||||
return new Response(body, { status: 400, headers: res.headers });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
const copilotkitPost = async (req: Request): Promise<Response> =>
|
||||
withForwardedHeaders(req, () => withProbeCompat(req));
|
||||
|
||||
export const GET = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
// Wrap POST with CVDIAG backend instrumentation (L1-E). built-in-agent runs
|
||||
// its BuiltInAgent in-process inside this route handler. No-op pass-through
|
||||
// unless CVDIAG_BACKEND_EMITTER is set truthy (default OFF).
|
||||
export const POST = withCvdiagBackend(copilotkitPost, {
|
||||
slug: "built-in-agent",
|
||||
agentName: "default",
|
||||
provider: "openai",
|
||||
});
|
||||
export const OPTIONS = (req: Request) =>
|
||||
withForwardedHeaders(req, () => handler(req));
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
integration: "built-in-agent",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const INTEGRATION_SLUG = "built-in-agent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function GET() {
|
||||
const start = Date.now();
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||
`http://localhost:${process.env.PORT || 3000}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/copilotkit`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
method: "agent/run",
|
||||
params: { agentId: "default" },
|
||||
body: {
|
||||
threadId: `smoke-${Date.now()}`,
|
||||
runId: `smoke-run-${Date.now()}`,
|
||||
state: {},
|
||||
messages: [
|
||||
{
|
||||
id: `smoke-msg-${Date.now()}`,
|
||||
role: "user",
|
||||
content: "Respond with exactly: OK",
|
||||
},
|
||||
],
|
||||
tools: [],
|
||||
context: [],
|
||||
forwardedProps: {},
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(45000),
|
||||
});
|
||||
|
||||
const latency = Date.now() - start;
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => "");
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
integration: INTEGRATION_SLUG,
|
||||
stage: "runtime_response",
|
||||
error: `Runtime returned ${res.status}: ${errBody.slice(0, 200)}`,
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
integration: INTEGRATION_SLUG,
|
||||
stage: "response_empty",
|
||||
error: "Runtime returned no readable body",
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
const { value, done } = await reader.read();
|
||||
reader.cancel();
|
||||
if (done || !value || value.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
integration: INTEGRATION_SLUG,
|
||||
stage: "response_empty",
|
||||
error: "Runtime returned empty response body",
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
integration: INTEGRATION_SLUG,
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
const latency = Date.now() - start;
|
||||
|
||||
let stage = "unknown";
|
||||
if (err.name === "AbortError" || err.message.includes("timeout"))
|
||||
stage = "timeout";
|
||||
else if (
|
||||
err.message.includes("fetch") ||
|
||||
err.message.includes("ECONNREFUSED")
|
||||
)
|
||||
stage = "agent_unreachable";
|
||||
else stage = "pipeline_error";
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
integration: INTEGRATION_SLUG,
|
||||
stage,
|
||||
error: err.message,
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* CopilotKit overrides — matches Dojo styling */
|
||||
|
||||
.copilotKitInput {
|
||||
border-bottom-left-radius: 0.75rem;
|
||||
border-bottom-right-radius: 0.75rem;
|
||||
border-top-left-radius: 0.75rem;
|
||||
border-top-right-radius: 0.75rem;
|
||||
border: 1px solid var(--copilot-kit-separator-color) !important;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function NotSupportedBanner({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
data-testid="not-supported-banner"
|
||||
className="min-h-screen flex items-center justify-center p-8"
|
||||
>
|
||||
<div className="max-w-xl text-center border rounded p-6">
|
||||
<h2 className="text-xl font-semibold mb-3">
|
||||
Not supported on built-in-agent
|
||||
</h2>
|
||||
<p className="text-sm opacity-80">{children}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# A2UI — Fixed Schema (Built-in Agent)
|
||||
|
||||
Declarative Generative UI with a fixed component tree. The frontend authors
|
||||
the schema (a JSON tree of A2UI components); the in-process tanstack agent
|
||||
only streams _data_ into the data model via a single `display_flight` tool.
|
||||
|
||||
## Pattern
|
||||
|
||||
- **Frontend** registers a custom catalog (`Title`, `Airport`, `Arrow`,
|
||||
`AirlineBadge`, `PriceTag`, plus a stateful `Button` override) merged with
|
||||
the basic A2UI catalog (`Card`, `Column`, `Row`, `Text`).
|
||||
See `./a2ui/{catalog,definitions,renderers}.{ts,tsx}`.
|
||||
- **Backend** (`src/lib/factory/a2ui-fixed-schema-factory.ts`) defines a
|
||||
`display_flight` TanStack tool that returns an `a2ui_operations` container
|
||||
with three ops: `createSurface`, `updateComponents` (the inlined schema),
|
||||
`updateDataModel` (the user's flight values).
|
||||
- **Runtime** (`src/app/api/copilotkit-a2ui-fixed-schema/route.ts`) enables
|
||||
the A2UI middleware with `injectA2UITool: false` — the agent owns its own
|
||||
emitter, so the runtime's auto-injected `render_a2ui` tool would only
|
||||
duplicate the slot.
|
||||
|
||||
## How it differs from `declarative-gen-ui` (dynamic schema)
|
||||
|
||||
Dynamic schema lets the LLM emit _any_ component tree it likes from the
|
||||
registered catalog. Fixed schema pins the tree ahead of time and lets the
|
||||
LLM only fill in data — strictly typed, predictable, no second LLM call.
|
||||
|
||||
## Try it
|
||||
|
||||
```text
|
||||
Find me a flight from SFO to JFK on United for $289.
|
||||
```
|
||||
|
||||
The agent calls `display_flight(...)`, which streams the flight card with
|
||||
"Book flight" stateful button.
|
||||
|
||||
## Reference
|
||||
|
||||
- Docs: https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui/fixed-schema
|
||||
- Source-of-truth: `showcase/integrations/langgraph-python/src/agents/a2ui_fixed.py`
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
// Docs-only snippet — not imported or run. The shell-docs page at
|
||||
// `/generative-ui/a2ui/fixed-schema` references the regions
|
||||
// `backend-schema-json-load` and `backend-render-operations` to teach
|
||||
// the *schema-inline* pattern for built-in-agent (the schema is declared
|
||||
// as a typed literal in source rather than loaded from JSON at startup).
|
||||
// This file exposes those regions as canonical teaching code so the docs
|
||||
// render real samples instead of a missing-snippet box.
|
||||
//
|
||||
// Mirrors the convention from `tool-rendering/render-flight-tool.snippet.tsx`.
|
||||
|
||||
// @region[backend-render-operations]
|
||||
// @region[backend-schema-json-load]
|
||||
declare const a2ui: {
|
||||
createSurface: (id: string, opts: { catalogId: string }) => unknown;
|
||||
updateComponents: (id: string, schema: unknown) => unknown;
|
||||
updateDataModel: (id: string, data: Record<string, unknown>) => unknown;
|
||||
render: (args: { operations: unknown[] }) => unknown;
|
||||
};
|
||||
const SURFACE_ID = "flight-fixed-schema";
|
||||
const CATALOG_ID = "flight-catalog";
|
||||
|
||||
// In the schema-inline pattern, the schema is declared as a typed literal
|
||||
// in source rather than loaded from JSON at startup. Same shape as the
|
||||
// schema-loading variant; just no file I/O.
|
||||
const FLIGHT_SCHEMA = [
|
||||
{
|
||||
type: "Card",
|
||||
children: [
|
||||
{ type: "Title", text: "Flight" },
|
||||
{
|
||||
type: "Row",
|
||||
children: [
|
||||
{ type: "Label", bind: "origin" },
|
||||
{ type: "Label", bind: "destination" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "Row",
|
||||
children: [
|
||||
{ type: "Label", bind: "airline" },
|
||||
{ type: "Label", bind: "price" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
// @endregion[backend-schema-json-load]
|
||||
|
||||
export function emitRenderOperations(args: {
|
||||
origin: string;
|
||||
destination: string;
|
||||
airline: string;
|
||||
price: number;
|
||||
}) {
|
||||
// The a2ui middleware detects the `a2ui_operations` container in this
|
||||
// tool result and forwards the ops to the frontend renderer. The
|
||||
// frontend catalog resolves component names to local React components.
|
||||
return a2ui.render({
|
||||
operations: [
|
||||
a2ui.createSurface(SURFACE_ID, { catalogId: CATALOG_ID }),
|
||||
a2ui.updateComponents(SURFACE_ID, FLIGHT_SCHEMA),
|
||||
a2ui.updateDataModel(SURFACE_ID, {
|
||||
origin: args.origin,
|
||||
destination: args.destination,
|
||||
airline: args.airline,
|
||||
price: args.price,
|
||||
}),
|
||||
],
|
||||
});
|
||||
// @endregion[backend-render-operations]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Fixed A2UI catalog — wires definitions to renderers.
|
||||
*
|
||||
* `includeBasicCatalog: true` merges CopilotKit's built-in components
|
||||
* (Card, Column, Row, Text, Button, Divider, …) into this catalog, so
|
||||
* the agent's fixed schema (src/agents/a2ui_schemas/flight_schema.json) can
|
||||
* compose custom and basic components interchangeably.
|
||||
*/
|
||||
// @region[catalog-creation]
|
||||
import { createCatalog } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
import { flightDefinitions } from "./definitions";
|
||||
import { flightRenderers } from "./renderers";
|
||||
|
||||
export const CATALOG_ID = "copilotkit://flight-fixed-catalog";
|
||||
|
||||
export const fixedCatalog = createCatalog(flightDefinitions, flightRenderers, {
|
||||
catalogId: CATALOG_ID,
|
||||
includeBasicCatalog: true,
|
||||
});
|
||||
// @endregion[catalog-creation]
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* A2UI catalog DEFINITIONS — platform-agnostic.
|
||||
*
|
||||
* Each entry declares a component name + its Zod props schema. The basic
|
||||
* catalog (Card, Column, Row, Text, Button, …) ships with CopilotKit and
|
||||
* is mixed in via `createCatalog(..., { includeBasicCatalog: true })`, so
|
||||
* we only declare the project-specific additions here.
|
||||
*
|
||||
* IMPORTANT — path bindings: fields that can be bound to a data-model path
|
||||
* (e.g. `{ path: "/origin" }` in the fixed schema JSON) must declare their
|
||||
* Zod type as a union of `z.string()` and `z.object({ path: z.string() })`.
|
||||
* The A2UI `GenericBinder` uses this union to detect the field as dynamic
|
||||
* and resolve the path against the current data model at render time. Using
|
||||
* plain `z.string()` causes the raw `{ path }` object to reach the
|
||||
* renderer, which React then throws on (error #31 "object with keys {path}").
|
||||
* This matches the canonical catalog's `DynString` helper:
|
||||
* examples/integrations/langgraph-python/src/app/declarative-generative-ui/definitions.ts
|
||||
*
|
||||
* ZOD VERSION (load-bearing): these defs are authored with **Zod 3** (the
|
||||
* `zod-v3` alias), NOT the package's root `zod@4`. `@a2ui/web_core@0.9.0`'s
|
||||
* `GenericBinder.scrapeSchemaBehavior` classifies a field as DYNAMIC (and thus
|
||||
* resolves its `{ path }` binding) by inspecting **Zod 3** schema internals
|
||||
* (`_def.typeName === "ZodUnion"`, the union's `_def.options`, an option's
|
||||
* `_def.shape().path`). A Zod-4 schema reports `_def.typeName === undefined`
|
||||
* (Zod 4 moved to `_def.type === "union"`), so the binder MISCLASSIFIES the
|
||||
* field STATIC and passes the raw `{ path: "/origin" }` object through to the
|
||||
* renderer → React error #31 ("object with keys {path}") crashes the page.
|
||||
* web_core bundles its own `zod@3.25.x`; authoring these defs with a matching
|
||||
* Zod 3 makes the union recognizable so the binder resolves `{ path }` → "SFO".
|
||||
* (`scrapeSchemaBehavior` compares `_def.typeName` by string, not `instanceof`,
|
||||
* so a separate Zod-3 module instance is recognized identically.)
|
||||
*/
|
||||
// @region[definitions-types]
|
||||
import { z } from "zod-v3";
|
||||
import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
/**
|
||||
* Dynamic string: literal OR a data-model path binding. The GenericBinder
|
||||
* resolves path bindings to the actual value at render time.
|
||||
*/
|
||||
const DynString = z.union([z.string(), z.object({ path: z.string() })]);
|
||||
|
||||
export const flightDefinitions = {
|
||||
/**
|
||||
* Card override: gives the outer flight-card container a stable
|
||||
* `data-testid` for D6 e2e selectors. The basic catalog's Card ships
|
||||
* its own renderer; declaring `Card` here lets us swap in a thin React
|
||||
* component without otherwise altering layout.
|
||||
*/
|
||||
Card: {
|
||||
description: "A container card with a single child.",
|
||||
props: z.object({
|
||||
child: z.string(),
|
||||
}),
|
||||
},
|
||||
Title: {
|
||||
description: "A prominent heading for the flight card.",
|
||||
props: z.object({
|
||||
text: DynString,
|
||||
}),
|
||||
},
|
||||
Airport: {
|
||||
description: "A 3-letter airport code, displayed large.",
|
||||
props: z.object({
|
||||
code: DynString,
|
||||
}),
|
||||
},
|
||||
Arrow: {
|
||||
description: "A right-pointing arrow used between airports.",
|
||||
props: z.object({}),
|
||||
},
|
||||
AirlineBadge: {
|
||||
description: "A pill-styled airline name tag.",
|
||||
props: z.object({
|
||||
name: DynString,
|
||||
}),
|
||||
},
|
||||
PriceTag: {
|
||||
description: "A stylized price display (e.g. '$289').",
|
||||
props: z.object({
|
||||
amount: DynString,
|
||||
}),
|
||||
},
|
||||
/**
|
||||
* Button override: swaps in an ActionButton renderer that tracks
|
||||
* its own `done` state so clicking "Book flight" visually updates to
|
||||
* a "Booked ✓" confirmation. The basic catalog's Button is stateless,
|
||||
* so without this override the click fires the action but the button
|
||||
* looks unchanged. Mirrors the pattern in beautiful-chat
|
||||
* (src/app/demos/beautiful-chat/declarative-generative-ui/renderers.tsx).
|
||||
*/
|
||||
Button: {
|
||||
description:
|
||||
"An interactive button with an action event. Use 'child' with a Text component ID for the label. After click, the button shows a confirmation state.",
|
||||
props: z.object({
|
||||
child: z
|
||||
.string()
|
||||
.describe(
|
||||
"The ID of the child component (e.g. a Text component for the label).",
|
||||
),
|
||||
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
|
||||
// Union with { event } so GenericBinder resolves this as ACTION → callable () => void.
|
||||
action: z
|
||||
.union([
|
||||
z.object({
|
||||
event: z.object({
|
||||
name: z.string(),
|
||||
context: z.record(z.string(), z.any()).optional(),
|
||||
}),
|
||||
}),
|
||||
z.null(),
|
||||
])
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
} satisfies CatalogDefinitions;
|
||||
// @endregion[definitions-types]
|
||||
|
||||
export type FlightDefinitions = typeof flightDefinitions;
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* A2UI catalog RENDERERS — React implementations for the custom components
|
||||
* declared in `./definitions`. TypeScript enforces that the renderer map's
|
||||
* keys and prop shapes match the definitions exactly.
|
||||
*
|
||||
* NOTE: Props in `definitions.ts` use `DynString` (a `string | { path }`
|
||||
* union) so the A2UI `GenericBinder` treats them as dynamic and resolves
|
||||
* path bindings before render. The binder always hands the renderer a
|
||||
* resolved string, but TypeScript sees the raw union — so we cast to
|
||||
* `Record<string, any>` at the renderer boundary. This matches the
|
||||
* canonical beautiful-chat pattern (see FlightCard in
|
||||
* `examples/integrations/langgraph-python/src/app/declarative-generative-ui/renderers.tsx`).
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
import type { FlightDefinitions } from "./definitions";
|
||||
|
||||
/**
|
||||
* Stateful action button: tracks `done` locally so clicking "Book flight"
|
||||
* transitions to "Booked ✓" and disables further clicks. Ports the
|
||||
* beautiful-chat pattern (see
|
||||
* `src/app/demos/beautiful-chat/declarative-generative-ui/renderers.tsx`'s
|
||||
* ActionButton). The basic catalog's Button is stateless, so overriding
|
||||
* the `Button` entry in this custom catalog is what gives the fixed-schema
|
||||
* demo a visible post-click confirmation.
|
||||
*/
|
||||
function ActionButton({
|
||||
label,
|
||||
doneLabel,
|
||||
action,
|
||||
children: child,
|
||||
}: {
|
||||
label: string;
|
||||
doneLabel: string;
|
||||
action: unknown;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const [done, setDone] = useState(false);
|
||||
return (
|
||||
<button
|
||||
disabled={done}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 16px",
|
||||
borderRadius: "12px",
|
||||
border: done ? "1px solid #85ECCE4D" : "1px solid transparent",
|
||||
background: done ? "rgba(133, 236, 206, 0.15)" : "#010507",
|
||||
color: done ? "#189370" : "#ffffff",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
cursor: done ? "default" : "pointer",
|
||||
transition: "all 0.2s ease",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "6px",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (done) return;
|
||||
if (typeof action === "function") {
|
||||
(action as () => void)();
|
||||
}
|
||||
setDone(true);
|
||||
}}
|
||||
>
|
||||
{done && (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#189370"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
{done ? doneLabel : (child ?? label)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// @region[renderers-tsx]
|
||||
export const flightRenderers: CatalogRenderers<FlightDefinitions> = {
|
||||
/**
|
||||
* Card override: wraps the basic-catalog Card render so the flight card
|
||||
* exposes a stable `data-testid` for D6 e2e tests. Behavior is otherwise
|
||||
* identical to the basic catalog default (renders the resolved `child`).
|
||||
*/
|
||||
Card: ({ props, children }) => (
|
||||
<div
|
||||
data-testid="a2ui-fixed-card"
|
||||
style={{
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(0, 0, 0, 0.08)",
|
||||
borderRadius: "16px",
|
||||
padding: "16px",
|
||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.04)",
|
||||
}}
|
||||
>
|
||||
{props.child ? children(props.child) : null}
|
||||
</div>
|
||||
),
|
||||
Title: ({ props: rawProps }) => {
|
||||
const props = rawProps as Record<string, any>;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "1.15rem",
|
||||
fontWeight: 600,
|
||||
color: "#010507",
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
Airport: ({ props: rawProps }) => {
|
||||
const props = rawProps as Record<string, any>;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.05em",
|
||||
color: "#010507",
|
||||
}}
|
||||
>
|
||||
{props.code}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
Arrow: () => <span style={{ color: "#AFAFB7", fontSize: "1.5rem" }}>→</span>,
|
||||
AirlineBadge: ({ props: rawProps }) => {
|
||||
const props = rawProps as Record<string, any>;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 10px",
|
||||
background: "rgba(190, 194, 255, 0.15)",
|
||||
color: "#010507",
|
||||
border: "1px solid #BEC2FF",
|
||||
borderRadius: 999,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{props.name}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
PriceTag: ({ props: rawProps }) => {
|
||||
const props = rawProps as Record<string, any>;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "1.1rem",
|
||||
color: "#189370",
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
}}
|
||||
>
|
||||
{props.amount}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Button override: the basic catalog's Button is stateless. This
|
||||
* stateful version lets clicking "Book flight" transition to
|
||||
* "Booked ✓" without a round-trip to the agent.
|
||||
*/
|
||||
Button: ({ props, children }) => {
|
||||
return (
|
||||
<ActionButton
|
||||
label="Book flight"
|
||||
doneLabel="Booked"
|
||||
action={(props as Record<string, any>).action}
|
||||
>
|
||||
{(props as Record<string, any>).child
|
||||
? children((props as Record<string, any>).child)
|
||||
: null}
|
||||
</ActionButton>
|
||||
);
|
||||
},
|
||||
};
|
||||
// @endregion[renderers-tsx]
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Declarative Generative UI — A2UI Fixed Schema demo.
|
||||
*
|
||||
* In the fixed-schema flavor of A2UI, the component tree (schema) lives on
|
||||
* the frontend and the agent only streams *data* into the data model. The
|
||||
* flight card is ASSEMBLED from small sub-components in the inlined schema
|
||||
* (`src/lib/factory/a2ui-fixed-schema-factory.ts`), reusing the same shape
|
||||
* as `langgraph-python`'s `a2ui_schemas/flight_schema.json`.
|
||||
*
|
||||
* - Definitions (zod schemas): `./a2ui/definitions.ts`
|
||||
* - Renderers (React): `./a2ui/renderers.tsx`
|
||||
* - Catalog wiring: `./a2ui/catalog.ts` (includes the basic catalog)
|
||||
* - Agent: `src/lib/factory/a2ui-fixed-schema-factory.ts` (the
|
||||
* `display_flight` tool emits an `a2ui_operations` container directly)
|
||||
*
|
||||
* Reference:
|
||||
* https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui/fixed-schema
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
CopilotKit,
|
||||
CopilotChat,
|
||||
useConfigureSuggestions,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
|
||||
import { fixedCatalog } from "./a2ui/catalog";
|
||||
|
||||
export default function A2UIFixedSchemaDemo() {
|
||||
return (
|
||||
// `a2ui.catalog` wires the fixed catalog into the A2UI activity renderer.
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit-a2ui-fixed-schema"
|
||||
agent="default"
|
||||
a2ui={{ catalog: fixedCatalog }}
|
||||
>
|
||||
<div className="flex justify-center items-center h-screen w-full">
|
||||
<div className="h-full w-full max-w-4xl">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKit>
|
||||
);
|
||||
}
|
||||
|
||||
function Chat() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Find SFO → JFK",
|
||||
message: "Find me a flight from SFO to JFK on United for $289.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
|
||||
return <CopilotChat agentId="default" className="h-full rounded-2xl" />;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
||||
|
||||
export function useA2UIFixedSchemaSuggestions() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Find SFO → JFK",
|
||||
message: "Find me a flight from SFO to JFK on United for $289.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# Agent Config (built-in-agent)
|
||||
|
||||
Frontend forwards typed config (`tone`, `expertise`, `responseLength`)
|
||||
through the `<CopilotKitProvider properties={...}>` prop. The runtime puts
|
||||
that on `input.forwardedProps`, and the built-in-agent factory reads it to
|
||||
synthesize a tuned system prompt per turn — no external graph, no extra
|
||||
state plumbing.
|
||||
|
||||
- Dedicated route: `/api/copilotkit-agent-config`
|
||||
- Single-route mode (`useSingleEndpoint`)
|
||||
- Key files: `page.tsx`, `config-card.tsx`, `use-agent-config.ts`,
|
||||
`config-types.ts`, `../../api/copilotkit-agent-config/route.ts`
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import type { ChangeEvent } from "react";
|
||||
import {
|
||||
type AgentConfig,
|
||||
EXPERTISE_OPTIONS,
|
||||
type Expertise,
|
||||
RESPONSE_LENGTH_OPTIONS,
|
||||
type ResponseLength,
|
||||
TONE_OPTIONS,
|
||||
type Tone,
|
||||
} from "./config-types";
|
||||
|
||||
interface ConfigCardProps {
|
||||
config: AgentConfig;
|
||||
onToneChange: (tone: Tone) => void;
|
||||
onExpertiseChange: (expertise: Expertise) => void;
|
||||
onResponseLengthChange: (length: ResponseLength) => void;
|
||||
}
|
||||
|
||||
export function ConfigCard({
|
||||
config,
|
||||
onToneChange,
|
||||
onExpertiseChange,
|
||||
onResponseLengthChange,
|
||||
}: ConfigCardProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="agent-config-card"
|
||||
className="flex flex-col gap-2 rounded-md border border-neutral-200 bg-white p-4 text-sm"
|
||||
>
|
||||
<h2 className="text-sm font-semibold">Agent Config</h2>
|
||||
<p className="text-xs text-neutral-600">
|
||||
Change these and send a message to see the agent adapt.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">Tone</span>
|
||||
<select
|
||||
data-testid="agent-config-tone-select"
|
||||
value={config.tone}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
||||
onToneChange(e.target.value as Tone)
|
||||
}
|
||||
className="rounded border border-neutral-200 bg-neutral-50 px-2 py-1 text-sm"
|
||||
>
|
||||
{TONE_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">Expertise</span>
|
||||
<select
|
||||
data-testid="agent-config-expertise-select"
|
||||
value={config.expertise}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
||||
onExpertiseChange(e.target.value as Expertise)
|
||||
}
|
||||
className="rounded border border-neutral-200 bg-neutral-50 px-2 py-1 text-sm"
|
||||
>
|
||||
{EXPERTISE_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">Response length</span>
|
||||
<select
|
||||
data-testid="agent-config-length-select"
|
||||
value={config.responseLength}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
||||
onResponseLengthChange(e.target.value as ResponseLength)
|
||||
}
|
||||
className="rounded border border-neutral-200 bg-neutral-50 px-2 py-1 text-sm"
|
||||
>
|
||||
{RESPONSE_LENGTH_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type Tone = "professional" | "casual" | "enthusiastic";
|
||||
export type Expertise = "beginner" | "intermediate" | "expert";
|
||||
export type ResponseLength = "concise" | "detailed";
|
||||
|
||||
export interface AgentConfig {
|
||||
tone: Tone;
|
||||
expertise: Expertise;
|
||||
responseLength: ResponseLength;
|
||||
}
|
||||
|
||||
export const DEFAULT_AGENT_CONFIG: AgentConfig = {
|
||||
tone: "professional",
|
||||
expertise: "intermediate",
|
||||
responseLength: "concise",
|
||||
};
|
||||
|
||||
export const TONE_OPTIONS: Tone[] = ["professional", "casual", "enthusiastic"];
|
||||
export const EXPERTISE_OPTIONS: Expertise[] = [
|
||||
"beginner",
|
||||
"intermediate",
|
||||
"expert",
|
||||
];
|
||||
export const RESPONSE_LENGTH_OPTIONS: ResponseLength[] = [
|
||||
"concise",
|
||||
"detailed",
|
||||
];
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { CopilotKitProvider, CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { ConfigCard } from "./config-card";
|
||||
import { useAgentConfig } from "./use-agent-config";
|
||||
|
||||
export default function AgentConfigDemoPage() {
|
||||
const { config, setTone, setExpertise, setResponseLength } = useAgentConfig();
|
||||
|
||||
// Stable reference between renders when nothing has changed so the
|
||||
// provider's `[properties]`-keyed effect only re-fires on real updates.
|
||||
const providerProperties = useMemo<Record<string, unknown>>(
|
||||
() => ({
|
||||
tone: config.tone,
|
||||
expertise: config.expertise,
|
||||
responseLength: config.responseLength,
|
||||
}),
|
||||
[config.tone, config.expertise, config.responseLength],
|
||||
);
|
||||
|
||||
return (
|
||||
<CopilotKitProvider
|
||||
runtimeUrl="/api/copilotkit-agent-config"
|
||||
properties={providerProperties}
|
||||
useSingleEndpoint
|
||||
>
|
||||
<div className="flex h-screen flex-col gap-3 p-6">
|
||||
<header>
|
||||
<h1 className="text-lg font-semibold">Agent Config Object</h1>
|
||||
<p className="text-sm text-neutral-600">
|
||||
Forwarded props let the frontend tell the agent how to behave. This
|
||||
demo passes <code>tone</code>, <code>expertise</code>, and
|
||||
<code> responseLength</code> through the provider; the
|
||||
built-in-agent factory reads them from{" "}
|
||||
<code>input.forwardedProps</code> and prepends a tuned system prompt
|
||||
per turn.
|
||||
</p>
|
||||
</header>
|
||||
<ConfigCard
|
||||
config={config}
|
||||
onToneChange={setTone}
|
||||
onExpertiseChange={setExpertise}
|
||||
onResponseLengthChange={setResponseLength}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden rounded-md border border-neutral-200">
|
||||
<CopilotChat className="h-full rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKitProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
type AgentConfig,
|
||||
DEFAULT_AGENT_CONFIG,
|
||||
type Expertise,
|
||||
type ResponseLength,
|
||||
type Tone,
|
||||
} from "./config-types";
|
||||
|
||||
export interface UseAgentConfigHandle {
|
||||
config: AgentConfig;
|
||||
setTone: (tone: Tone) => void;
|
||||
setExpertise: (expertise: Expertise) => void;
|
||||
setResponseLength: (length: ResponseLength) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useAgentConfig(): UseAgentConfigHandle {
|
||||
const [config, setConfig] = useState<AgentConfig>(DEFAULT_AGENT_CONFIG);
|
||||
|
||||
const setTone = useCallback(
|
||||
(tone: Tone) => setConfig((prev) => ({ ...prev, tone })),
|
||||
[],
|
||||
);
|
||||
const setExpertise = useCallback(
|
||||
(expertise: Expertise) => setConfig((prev) => ({ ...prev, expertise })),
|
||||
[],
|
||||
);
|
||||
const setResponseLength = useCallback(
|
||||
(responseLength: ResponseLength) =>
|
||||
setConfig((prev) => ({ ...prev, responseLength })),
|
||||
[],
|
||||
);
|
||||
const reset = useCallback(() => setConfig(DEFAULT_AGENT_CONFIG), []);
|
||||
|
||||
return { config, setTone, setExpertise, setResponseLength, reset };
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
// Docs-only snippet — not imported or rendered. The actual route is served
|
||||
// by page.tsx, which carries a frontend tool demo (setBackground) plus
|
||||
// page chrome that aren't relevant to the prebuilt-chat docs page. This
|
||||
// file gives the docs a minimal Chat definition to point at via the
|
||||
// chat-component / configure-suggestions regions without disturbing the
|
||||
// runtime demo.
|
||||
//
|
||||
// Why a sibling file: the bundler walks every file in the demo folder and
|
||||
// extracts region markers from each, so a docs-targeted teaching example
|
||||
// can live alongside the production demo without being wired into the
|
||||
// route. See: showcase/scripts/bundle-demo-content.ts.
|
||||
|
||||
import {
|
||||
CopilotChat,
|
||||
useConfigureSuggestions,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
|
||||
// @region[chat-component]
|
||||
export function Chat() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{ title: "Write a sonnet", message: "Write a short sonnet about AI." },
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
|
||||
// @region[render-chat]
|
||||
return <CopilotChat className="h-full rounded-2xl" />;
|
||||
// @endregion[render-chat]
|
||||
}
|
||||
// @endregion[chat-component]
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
// @region[frontend-tool]
|
||||
// @region[frontend-tool-registration]
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CopilotKitProvider,
|
||||
CopilotChat,
|
||||
useFrontendTool,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
import { z } from "zod";
|
||||
|
||||
export default function AgenticChat() {
|
||||
return (
|
||||
// @region[provider-setup]
|
||||
<CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
|
||||
<Demo />
|
||||
</CopilotKitProvider>
|
||||
// @endregion[provider-setup]
|
||||
);
|
||||
}
|
||||
|
||||
function Demo() {
|
||||
const [bg, setBg] = useState<string>("var(--copilot-kit-background-color)");
|
||||
|
||||
useFrontendTool({
|
||||
name: "setBackground",
|
||||
description:
|
||||
"Set the page background. Accepts any CSS background value (color, gradient, etc.).",
|
||||
parameters: z.object({
|
||||
background: z
|
||||
.string()
|
||||
.describe("CSS background value (color, gradient, etc.)"),
|
||||
}),
|
||||
// @region[frontend-tool-handler]
|
||||
handler: async ({ background }) => {
|
||||
setBg(background);
|
||||
return { ok: true, background };
|
||||
},
|
||||
// @endregion[frontend-tool-handler]
|
||||
});
|
||||
// @endregion[frontend-tool-registration]
|
||||
// @endregion[frontend-tool]
|
||||
|
||||
return (
|
||||
<main style={{ background: bg, minHeight: "100vh" }} className="p-8">
|
||||
<h1 className="text-2xl font-semibold mb-4">Agentic Chat</h1>
|
||||
<p className="text-sm opacity-70 mb-6">
|
||||
Try: “Set the background to a sunset gradient.”
|
||||
</p>
|
||||
<CopilotChat />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
||||
|
||||
// @region[configure-suggestions]
|
||||
export function useAgenticChatSuggestions() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{ title: "Write a sonnet", message: "Write a short sonnet about AI." },
|
||||
{
|
||||
title: "Tell me a joke",
|
||||
message: "Tell me a one-line joke.",
|
||||
},
|
||||
{
|
||||
title: "Is 17 prime?",
|
||||
message: "Walk me through whether 17 is prime.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
}
|
||||
// @endregion[configure-suggestions]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Authentication (built-in-agent)
|
||||
|
||||
Bearer-token gate on the V2 runtime via the `onRequest` hook. The hook
|
||||
throws a `Response` with status 401 when the header is missing or invalid,
|
||||
which the runtime maps to the HTTP response verbatim. The frontend defaults
|
||||
to UNAUTHENTICATED on first paint (SignInCard); after sign-in the provider
|
||||
injects `Authorization: Bearer <DEMO_TOKEN>` and the chat stays mounted
|
||||
across the sign-out → sign-in cycle so the post-sign-out 401 is surfaced in
|
||||
the chat (amber `auth-demo-error` banner) rather than bouncing back to the
|
||||
gate. The 401 is captured via the agent-scoped `<CopilotChat onError>`
|
||||
channel (and the provider-level `onError` for handshake errors).
|
||||
|
||||
- Dedicated route: `/api/copilotkit-auth/[[...slug]]`
|
||||
- Uses `useSingleEndpoint={false}` (multi-endpoint runtime)
|
||||
- Mounts `<CopilotKitProvider>` (built-in agent under the default key)
|
||||
rather than `<CopilotKit agent="...">` — a forced provider divergence from
|
||||
the langgraph-python gold reference; the error-handling shape, auth hook,
|
||||
and testid contract match.
|
||||
- Key files: `page.tsx`, `use-demo-auth.ts`, `auth-banner.tsx`,
|
||||
`sign-in-card.tsx`, `demo-token.ts`,
|
||||
`../../api/copilotkit-auth/[[...slug]]/route.ts`
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
interface AuthBannerProps {
|
||||
authenticated: boolean;
|
||||
onSignOut: () => void;
|
||||
onSignIn: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status strip rendered above <CopilotChat /> in both authenticated and
|
||||
* post-sign-out states. The post-sign-out (amber) variant exists so the demo
|
||||
* actually showcases what its name promises — the runtime rejecting an
|
||||
* unauthenticated request — instead of bouncing the user back to the gate
|
||||
* page where the rejection never happens.
|
||||
*
|
||||
* Pure presentational — owns no state itself. Testids are stable contract
|
||||
* for QA + Playwright specs.
|
||||
*
|
||||
* Note: built-in-agent is a deliberately minimal integration with no
|
||||
* `@/components/ui` shadcn primitives, so this uses raw Tailwind-styled
|
||||
* elements rather than the shared `Button` component that langgraph-python
|
||||
* (the gold reference) uses. The prop contract, testids, and behavior match.
|
||||
*/
|
||||
export function AuthBanner({
|
||||
authenticated,
|
||||
onSignOut,
|
||||
onSignIn,
|
||||
}: AuthBannerProps) {
|
||||
const classes = authenticated
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-900"
|
||||
: "border-amber-300 bg-amber-50 text-amber-900";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="auth-banner"
|
||||
data-authenticated={authenticated ? "true" : "false"}
|
||||
className={`flex items-center justify-between gap-3 rounded-md border px-4 py-2 text-sm ${classes}`}
|
||||
>
|
||||
<span data-testid="auth-status" className="font-medium">
|
||||
{authenticated
|
||||
? "✓ Signed in as demo user"
|
||||
: "⚠ Signed out — the agent will reject your messages until you sign in."}
|
||||
</span>
|
||||
{authenticated ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="auth-sign-out-button"
|
||||
onClick={onSignOut}
|
||||
className="rounded border border-emerald-400 bg-white px-3 py-1 text-xs font-medium text-emerald-800 hover:bg-emerald-100"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="auth-authenticate-button"
|
||||
onClick={onSignIn}
|
||||
className="rounded border border-amber-400 bg-white px-3 py-1 text-xs font-medium text-amber-800 hover:bg-amber-100"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Shared demo-token constant imported by both the client (use-demo-auth.ts)
|
||||
* and the server runtime route. This is a DEMO token. Never use a hard-coded
|
||||
* shared secret for real auth.
|
||||
*/
|
||||
export const DEMO_TOKEN = "demo-token-123";
|
||||
|
||||
export const DEMO_AUTH_HEADER = `Bearer ${DEMO_TOKEN}`;
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
// Auth demo — framework-native request authentication via the V2 runtime's
|
||||
// `onRequest` hook. The runtime route (/api/copilotkit-auth) rejects any
|
||||
// request whose `Authorization: Bearer <demo-token>` header is missing or
|
||||
// wrong.
|
||||
//
|
||||
// UX shape: the demo defaults to UNAUTHENTICATED on first paint so visitors
|
||||
// land on a clear sign-in card. We don't mount the chat until the user has
|
||||
// signed in at least once — that sidesteps the transport 401 that would
|
||||
// otherwise crash `<CopilotChat>` during its initial `/info` handshake.
|
||||
// After the user signs in once, the chat stays mounted across the
|
||||
// sign-out → sign-in cycle so the post-sign-out state can actually
|
||||
// demonstrate the runtime rejecting unauthenticated requests in the chat
|
||||
// surface (the whole point of the demo).
|
||||
//
|
||||
// Error surfacing: the post-sign-out 401 is captured via the AGENT-SCOPED
|
||||
// `<CopilotChat onError>` channel, NOT the provider-level `<CopilotKitProvider
|
||||
// onError>` alone. Agent-run errors (`agent_run_failed`) are reliably
|
||||
// delivered to the chat-scoped subscription, whereas the provider-level
|
||||
// handler does not fire for them in this flow — so a demo that relies only
|
||||
// on the provider `onError` never renders the rejection banner. We register
|
||||
// the same handler on BOTH channels: `<CopilotKitProvider onError>` covers
|
||||
// any provider-level errors (e.g. the initial `/info` handshake) and
|
||||
// `<CopilotChat onError>` covers agent-run rejections, which is what the
|
||||
// sign-out path produces.
|
||||
//
|
||||
// Provider note: this integration is the BUILT-IN agent, so it mounts
|
||||
// `<CopilotKitProvider>` (the runtime registers the agent under the default
|
||||
// key) rather than `<CopilotKit agent="auth-demo">` like the named-agent
|
||||
// langgraph-python reference. That provider choice is a forced divergence;
|
||||
// the error-handling shape, auth hook, and testid contract match the gold
|
||||
// reference exactly.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { CopilotKitProvider, CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import type { CopilotKitCoreErrorCode } from "@copilotkit/react-core/v2";
|
||||
import { AuthBanner } from "./auth-banner";
|
||||
import { SignInCard } from "./sign-in-card";
|
||||
import { useDemoAuth } from "./use-demo-auth";
|
||||
import { DEMO_TOKEN } from "./demo-token";
|
||||
|
||||
interface AuthDemoErrorState {
|
||||
message: string;
|
||||
code: CopilotKitCoreErrorCode | string;
|
||||
}
|
||||
|
||||
interface AuthErrorEvent {
|
||||
error?: { message?: string } | null;
|
||||
code: CopilotKitCoreErrorCode;
|
||||
}
|
||||
|
||||
export default function AuthDemoPage() {
|
||||
const {
|
||||
isAuthenticated,
|
||||
authorizationHeader,
|
||||
hasEverSignedIn,
|
||||
signIn,
|
||||
signOut,
|
||||
} = useDemoAuth();
|
||||
|
||||
const headers = useMemo<Record<string, string>>(
|
||||
() => (authorizationHeader ? { Authorization: authorizationHeader } : {}),
|
||||
[authorizationHeader],
|
||||
);
|
||||
|
||||
const [authError, setAuthError] = useState<AuthDemoErrorState | null>(null);
|
||||
|
||||
// Shared error handler wired to BOTH the provider-level and chat-level
|
||||
// `onError` channels (see the file header for why both are needed).
|
||||
const handleAuthError = useCallback((event: AuthErrorEvent) => {
|
||||
setAuthError({
|
||||
message:
|
||||
(event.error?.message && event.error.message.trim()) ||
|
||||
(event.code
|
||||
? `Request rejected (${event.code})`
|
||||
: "The request was rejected."),
|
||||
code: event.code,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Clear stale errors as soon as the user re-authenticates. This is the
|
||||
// ONLY thing that gates the amber error surface on auth state — the render
|
||||
// condition below keys off `authError` alone. Coupling the render to a
|
||||
// second `!isAuthenticated` slice (the obvious-but-wrong guard) created a
|
||||
// post-sign-out race: the rejection's `onError` fires and calls
|
||||
// `setAuthError`, but if that commit landed in a render where the auth
|
||||
// state hadn't yet settled to false, `authError && !isAuthenticated`
|
||||
// evaluated false and the banner never appeared. Driving the surface off
|
||||
// `authError` and clearing it here on re-auth removes the cross-slice
|
||||
// ordering dependency: a rejection always renders, and signing back in
|
||||
// always wipes it.
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) setAuthError(null);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
if (!hasEverSignedIn) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<SignInCard onSignIn={signIn} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// `useSingleEndpoint={false}` opts into the V2 multi-endpoint protocol
|
||||
// (separate /info, /agents/<id>/run, etc.), which is what this demo's
|
||||
// runtime route is wired up for.
|
||||
<CopilotKitProvider
|
||||
runtimeUrl="/api/copilotkit-auth"
|
||||
headers={headers}
|
||||
useSingleEndpoint={false}
|
||||
onError={handleAuthError}
|
||||
>
|
||||
<div className="flex h-screen flex-col gap-3 p-6">
|
||||
<AuthBanner
|
||||
authenticated={isAuthenticated}
|
||||
onSignOut={signOut}
|
||||
onSignIn={() => signIn(DEMO_TOKEN)}
|
||||
/>
|
||||
<header>
|
||||
<h1 className="text-lg font-semibold">Authentication</h1>
|
||||
</header>
|
||||
{authError && (
|
||||
<div
|
||||
data-testid="auth-demo-error"
|
||||
className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900"
|
||||
>
|
||||
<strong className="font-semibold">
|
||||
Runtime rejected the request:
|
||||
</strong>{" "}
|
||||
<span data-testid="auth-demo-error-message">
|
||||
{authError.message}
|
||||
</span>{" "}
|
||||
<code className="ml-1 rounded bg-amber-100 px-1 py-0.5 font-mono text-xs">
|
||||
{authError.code}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden rounded-md border border-neutral-200">
|
||||
<CopilotChat className="h-full" onError={handleAuthError} />
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKitProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { DEMO_TOKEN } from "./demo-token";
|
||||
|
||||
interface SignInCardProps {
|
||||
onSignIn: (token: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthenticated landing card for the auth demo. Surfaces the demo bearer
|
||||
* token in plain text so visitors can see exactly what gets sent on the
|
||||
* `Authorization` header — there's no real form because the value is fixed
|
||||
* by the runtime gate. Clicking "Sign in" stores the token via
|
||||
* `useDemoAuth()`, which causes the parent to mount the chat surface.
|
||||
*
|
||||
* Note: built-in-agent is a deliberately minimal integration with no
|
||||
* `@/components/ui` shadcn primitives, so this uses raw Tailwind-styled
|
||||
* elements rather than the shared `Card`/`Button` components that
|
||||
* langgraph-python (the gold reference) uses. The testids and behavior match.
|
||||
*/
|
||||
export function SignInCard({ onSignIn }: SignInCardProps) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div
|
||||
data-testid="auth-sign-in-card"
|
||||
className="w-full max-w-md rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">
|
||||
Sign in to start chatting
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-neutral-600">
|
||||
The runtime rejects requests without an{" "}
|
||||
<code className="rounded bg-neutral-100 px-1 py-0.5 font-mono text-xs">
|
||||
Authorization
|
||||
</code>{" "}
|
||||
header. Sign in below to mount the chat with a demo bearer token
|
||||
attached.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4 space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||
Demo token
|
||||
</p>
|
||||
<code
|
||||
data-testid="auth-demo-token"
|
||||
className="block rounded-md border border-neutral-200 bg-neutral-50 px-3 py-2 font-mono text-sm text-neutral-800"
|
||||
>
|
||||
{DEMO_TOKEN}
|
||||
</code>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Real apps should issue per-user tokens via your identity provider
|
||||
and never hard-code shared secrets.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="auth-sign-in-button"
|
||||
onClick={() => onSignIn(DEMO_TOKEN)}
|
||||
className="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800"
|
||||
>
|
||||
Sign in with demo token
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { DEMO_TOKEN } from "./demo-token";
|
||||
|
||||
const STORAGE_KEY = "copilotkit:auth-demo:token";
|
||||
|
||||
export interface DemoAuthHandle {
|
||||
isAuthenticated: boolean;
|
||||
/** The token string when authenticated, otherwise null. */
|
||||
token: string | null;
|
||||
/** The full `Bearer <token>` value when authenticated, otherwise null. */
|
||||
authorizationHeader: string | null;
|
||||
/**
|
||||
* Whether the user has signed in at least once during the current page
|
||||
* session. Used by the page to decide between the first-paint SignInCard
|
||||
* (never signed in) and the persistent chat-with-amber-banner state
|
||||
* (signed in, then signed out) — the latter is the only state that
|
||||
* actually showcases the runtime rejecting unauthenticated requests.
|
||||
* Resets on full page reload by design.
|
||||
*/
|
||||
hasEverSignedIn: boolean;
|
||||
/** Sign in with the provided token. */
|
||||
signIn: (token: string) => void;
|
||||
/** Clear the stored token. */
|
||||
signOut: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent demo auth state for the /demos/auth showcase cell. Tokens are
|
||||
* stored in localStorage so a page reload doesn't kick the user back out;
|
||||
* first paint of a fresh visitor is unauthenticated, which lets the demo
|
||||
* showcase its sign-in CTA up front.
|
||||
*
|
||||
* This is a DEMO. Never store real bearer tokens in localStorage in a
|
||||
* production application — that exposes them to any script running on the
|
||||
* page.
|
||||
*/
|
||||
export function useDemoAuth(): DemoAuthHandle {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [hasEverSignedIn, setHasEverSignedIn] = useState(false);
|
||||
|
||||
// Hydrate from localStorage after mount. Reading on initial render would
|
||||
// mismatch SSR (where window is undefined); deferring to useEffect keeps
|
||||
// first paint unauthenticated and avoids hydration warnings.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
setToken(stored);
|
||||
setHasEverSignedIn(true);
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable (privacy mode, etc.) — fall back to
|
||||
// in-memory only.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const signIn = useCallback((nextToken: string) => {
|
||||
setToken(nextToken);
|
||||
setHasEverSignedIn(true);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, nextToken);
|
||||
} catch {
|
||||
// Ignore — in-memory state still works.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
setToken(null);
|
||||
// hasEverSignedIn intentionally stays true so the page keeps showing
|
||||
// the chat surface (with amber banner) after sign-out. That is the
|
||||
// state that demonstrates the runtime returning 401.
|
||||
try {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The runtime gate compares against a fixed token, so anything other than
|
||||
// DEMO_TOKEN won't actually authenticate against the API. We still allow
|
||||
// arbitrary strings here because validation is the runtime's job — the UI
|
||||
// just owns "what header are we sending".
|
||||
return {
|
||||
isAuthenticated: token !== null,
|
||||
token,
|
||||
authorizationHeader: token ? `Bearer ${token}` : null,
|
||||
hasEverSignedIn,
|
||||
signIn,
|
||||
signOut,
|
||||
};
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
BarChart as RechartsBarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Rectangle,
|
||||
} from "recharts";
|
||||
import { z } from "zod";
|
||||
import { CHART_COLORS, CHART_CONFIG } from "./config";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "../../ui/card";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
|
||||
export const BarChartProps = z.object({
|
||||
title: z.string().describe("Chart title"),
|
||||
description: z.string().describe("Brief description or subtitle"),
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type BarChartProps = z.infer<typeof BarChartProps>;
|
||||
|
||||
/** Tracks seen indices so only NEW bars get the fade-in animation. */
|
||||
function useSeenIndices() {
|
||||
const seen = useRef(new Set<number>());
|
||||
return {
|
||||
isNew(index: number) {
|
||||
if (seen.current.has(index)) return false;
|
||||
seen.current.add(index);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function AnimatedBar(props: any) {
|
||||
const { isNew, ...rest } = props;
|
||||
return (
|
||||
<g
|
||||
style={
|
||||
isNew
|
||||
? {
|
||||
animation: "barSlideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Rectangle {...rest} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarChart({ title, description, data }: BarChartProps) {
|
||||
const { isNew } = useSeenIndices();
|
||||
|
||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<Card className="max-w-2xl mx-auto my-4">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-[var(--muted-foreground)]" />
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
|
||||
No data available
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="max-w-2xl mx-auto my-4 overflow-hidden">
|
||||
{/* Scoped keyframe — no globals.css needed */}
|
||||
<style>{`
|
||||
@keyframes barSlideIn {
|
||||
from { transform: translateY(40px); opacity: 0; }
|
||||
20% { opacity: 1; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center h-6 w-6 rounded-md bg-[var(--secondary)]">
|
||||
<BarChart3 className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
|
||||
</div>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<RechartsBarChart
|
||||
data={data}
|
||||
margin={{ top: 12, right: 12, bottom: 4, left: -8 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={CHART_CONFIG.tooltipStyle}
|
||||
cursor={{ fill: "var(--secondary)", opacity: 0.5 }}
|
||||
/>
|
||||
<Bar
|
||||
isAnimationActive={false}
|
||||
dataKey="value"
|
||||
radius={[6, 6, 0, 0]}
|
||||
maxBarSize={48}
|
||||
shape={
|
||||
((props: Record<string, unknown>) => (
|
||||
<AnimatedBar
|
||||
{...props}
|
||||
isNew={isNew(props.index as number)}
|
||||
/>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
)) as any
|
||||
}
|
||||
>
|
||||
{data.map((_, index) => (
|
||||
<Cell
|
||||
key={index}
|
||||
fill={CHART_COLORS[index % CHART_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* CopilotKit brand chart palette — Plus Jakarta Sans / brand color system.
|
||||
*/
|
||||
export const CHART_COLORS = [
|
||||
"#BEC2FF", // lilac-400
|
||||
"#85ECCE", // mint-400
|
||||
"#FFAC4D", // orange-400
|
||||
"#FFF388", // yellow-400
|
||||
"#189370", // mint-800
|
||||
"#EEE6FE", // primary-100
|
||||
"#FA5F67", // red-400
|
||||
] as const;
|
||||
|
||||
export const CHART_CONFIG = {
|
||||
tooltipStyle: {
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "10px",
|
||||
padding: "10px 14px",
|
||||
color: "var(--foreground)",
|
||||
fontSize: "13px",
|
||||
fontFamily: "var(--font-body)",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
|
||||
},
|
||||
};
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import { z } from "zod";
|
||||
import { CHART_COLORS } from "./config";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "../../ui/card";
|
||||
|
||||
export const PieChartProps = z.object({
|
||||
title: z.string().describe("Chart title"),
|
||||
description: z.string().describe("Brief description or subtitle"),
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type PieChartProps = z.infer<typeof PieChartProps>;
|
||||
|
||||
/** Custom SVG donut chart built with <circle> + stroke-dasharray. */
|
||||
function DonutChart({
|
||||
data,
|
||||
size = 240,
|
||||
strokeWidth = 40,
|
||||
}: {
|
||||
data: { label: string; value: number }[];
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const center = size / 2;
|
||||
|
||||
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
|
||||
|
||||
// Calculate each slice's arc length and starting position
|
||||
let accumulated = 0;
|
||||
const slices = data.map((item, index) => {
|
||||
const val = Number(item.value) || 0;
|
||||
const ratio = total > 0 ? val / total : 0;
|
||||
const arc = ratio * circumference;
|
||||
const startAt = accumulated;
|
||||
accumulated += arc;
|
||||
return {
|
||||
...item,
|
||||
arc,
|
||||
gap: circumference - arc,
|
||||
// Negative dashoffset shifts the dash forward (clockwise) to the correct position
|
||||
dashoffset: -startAt,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="100%"
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="block mx-auto"
|
||||
style={{ maxWidth: size, transform: "scaleX(-1)" }}
|
||||
>
|
||||
{/* Background ring */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--secondary)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Data slices */}
|
||||
{slices.map((slice, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={slice.color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${slice.arc} ${slice.gap}`}
|
||||
strokeDashoffset={slice.dashoffset}
|
||||
strokeLinecap="butt"
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PieChart({ title, description, data }: PieChartProps) {
|
||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<Card className="max-w-lg mx-auto my-4">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
|
||||
No data available
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg mx-auto my-4 overflow-hidden">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<DonutChart data={data} />
|
||||
|
||||
{/* Legend */}
|
||||
<div className="space-y-2 pt-4">
|
||||
{data.map((item, index) => {
|
||||
const val = Number(item.value) || 0;
|
||||
const pct = total > 0 ? ((val / total) * 100).toFixed(0) : 0;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-3 text-sm transition-opacity duration-300 ease-out"
|
||||
style={{ opacity: 1 }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full shrink-0"
|
||||
style={{
|
||||
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
<span className="flex-1 text-[var(--foreground)] truncate">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-[var(--muted-foreground)] tabular-nums">
|
||||
{val.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-[var(--muted-foreground)] text-sm w-10 text-right tabular-nums">
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent } from "../ui/card";
|
||||
import { Button } from "../ui/button";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Spinner } from "../ui/spinner";
|
||||
import { Check, X, Clock, ChevronRight } from "lucide-react";
|
||||
|
||||
export interface TimeSlot {
|
||||
date: string;
|
||||
time: string;
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
export interface MeetingTimePickerProps {
|
||||
status: "inProgress" | "executing" | "complete";
|
||||
respond?: (response: string) => void;
|
||||
reasonForScheduling?: string;
|
||||
meetingDuration?: number;
|
||||
title?: string;
|
||||
timeSlots?: TimeSlot[];
|
||||
}
|
||||
|
||||
export function MeetingTimePicker({
|
||||
status,
|
||||
respond,
|
||||
reasonForScheduling,
|
||||
meetingDuration,
|
||||
title = "Schedule a Meeting",
|
||||
timeSlots = [
|
||||
{ date: "Tomorrow", time: "2:00 PM", duration: "30 min" },
|
||||
{ date: "Friday", time: "10:00 AM", duration: "30 min" },
|
||||
{ date: "Next Monday", time: "3:00 PM", duration: "30 min" },
|
||||
],
|
||||
}: MeetingTimePickerProps) {
|
||||
const displayTitle = reasonForScheduling || title;
|
||||
const slots = meetingDuration
|
||||
? timeSlots.map((slot) => ({ ...slot, duration: `${meetingDuration} min` }))
|
||||
: timeSlots;
|
||||
const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(null);
|
||||
const [declined, setDeclined] = useState(false);
|
||||
|
||||
const handleSelectSlot = (slot: TimeSlot) => {
|
||||
setSelectedSlot(slot);
|
||||
respond?.(
|
||||
`Meeting scheduled for ${slot.date} at ${slot.time}${slot.duration ? ` (${slot.duration})` : ""}.`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
setDeclined(true);
|
||||
respond?.(
|
||||
"The user declined all proposed meeting times. Please suggest alternative times or ask for their availability.",
|
||||
);
|
||||
};
|
||||
|
||||
// Confirmed state
|
||||
if (selectedSlot) {
|
||||
return (
|
||||
<Card className="max-w-md w-full mx-auto mb-4 overflow-hidden">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col items-center text-center gap-3">
|
||||
<div className="flex items-center justify-center h-10 w-10 rounded-full bg-[#189370]">
|
||||
<Check className="h-5 w-5 text-white" strokeWidth={3} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-[var(--foreground)]">
|
||||
Meeting Scheduled
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
{selectedSlot.date} at {selectedSlot.time}
|
||||
</p>
|
||||
</div>
|
||||
{selectedSlot.duration && (
|
||||
<Badge variant="secondary">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
{selectedSlot.duration}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Declined state
|
||||
if (declined) {
|
||||
return (
|
||||
<Card className="max-w-md w-full mx-auto mb-4 overflow-hidden">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col items-center text-center gap-3">
|
||||
<div className="flex items-center justify-center h-12 w-12 rounded-full bg-[var(--secondary)]">
|
||||
<X className="h-6 w-6 text-[var(--muted-foreground)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-[var(--foreground)]">
|
||||
No Time Selected
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
Looking for a better time that works for you
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Selection state
|
||||
return (
|
||||
<Card className="max-w-md w-full mx-auto mb-4 overflow-hidden">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col items-center text-center mb-5">
|
||||
<div className="flex items-center justify-center h-12 w-12 rounded-full bg-[var(--accent)] mb-3">
|
||||
<Clock className="h-6 w-6 text-[#BEC2FF]" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-[var(--foreground)]">
|
||||
{displayTitle}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
{status === "inProgress"
|
||||
? "Finding available times..."
|
||||
: "Pick a time that works for you"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status === "inProgress" && (
|
||||
<div className="flex justify-center py-6">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "executing" && (
|
||||
<div className="space-y-3">
|
||||
{slots.map((slot, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleSelectSlot(slot)}
|
||||
className="group w-full px-6 py-5 rounded-[var(--radius)]
|
||||
border border-[var(--border)]
|
||||
hover:border-[var(--ring)] hover:bg-[var(--accent)]
|
||||
transition-all duration-150 cursor-pointer
|
||||
flex items-center gap-4"
|
||||
>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-semibold text-base text-[var(--foreground)]">
|
||||
{slot.date}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)] mt-0.5">
|
||||
{slot.time}
|
||||
</div>
|
||||
</div>
|
||||
{slot.duration && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 text-sm px-3 py-1"
|
||||
>
|
||||
{slot.duration}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-[var(--muted-foreground)] opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full mt-1 text-xs text-[var(--muted-foreground)]"
|
||||
onClick={handleDecline}
|
||||
>
|
||||
None of these work
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Wrench, Check, ChevronDown } from "lucide-react";
|
||||
import { Spinner } from "./ui/spinner";
|
||||
|
||||
interface ToolReasoningProps {
|
||||
name: string;
|
||||
args?: object | unknown;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.length} items]`;
|
||||
if (typeof value === "object" && value !== null)
|
||||
return `{${Object.keys(value).length} keys}`;
|
||||
if (typeof value === "string") return `"${value}"`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function ToolReasoning({ name, args, status }: ToolReasoningProps) {
|
||||
const entries = args ? Object.entries(args) : [];
|
||||
const detailsRef = useRef<HTMLDetailsElement>(null);
|
||||
const isRunning = status === "executing" || status === "inProgress";
|
||||
|
||||
// Auto-open while executing, auto-close when complete
|
||||
useEffect(() => {
|
||||
if (!detailsRef.current) return;
|
||||
detailsRef.current.open = isRunning;
|
||||
}, [isRunning]);
|
||||
|
||||
const statusIcon = isRunning ? (
|
||||
<Spinner size="sm" className="h-3 w-3" />
|
||||
) : (
|
||||
<Check className="h-3 w-3 text-emerald-500" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="my-1.5">
|
||||
{entries.length > 0 ? (
|
||||
<details ref={detailsRef} open className="group">
|
||||
<summary className="flex items-center gap-2 cursor-pointer list-none text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors">
|
||||
{statusIcon}
|
||||
<Wrench className="h-3 w-3" />
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ fontFamily: "var(--font-code)" }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 ml-auto transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<div className="ml-5 mt-1.5 rounded-md bg-[var(--secondary)] px-3 py-2 space-y-1">
|
||||
{entries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex gap-2 min-w-0 text-xs"
|
||||
style={{ fontFamily: "var(--font-code)" }}
|
||||
>
|
||||
<span className="text-[var(--muted-foreground)] shrink-0">
|
||||
{key}:
|
||||
</span>
|
||||
<span className="text-[var(--foreground)] truncate">
|
||||
{formatValue(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
|
||||
{statusIcon}
|
||||
<Wrench className="h-3 w-3" />
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ fontFamily: "var(--font-code)" }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-[var(--primary)] text-[var(--primary-foreground)]",
|
||||
secondary:
|
||||
"border-transparent bg-[var(--secondary)] text-[var(--secondary-foreground)]",
|
||||
outline: "border-[var(--border)] text-[var(--foreground)]",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "secondary",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius)] text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:pointer-events-none disabled:opacity-50 cursor-pointer",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90",
|
||||
secondary:
|
||||
"bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:opacity-80",
|
||||
outline:
|
||||
"border border-[var(--border)] bg-[var(--background)] hover:bg-[var(--secondary)]",
|
||||
ghost:
|
||||
"hover:bg-[var(--secondary)] hover:text-[var(--secondary-foreground)]",
|
||||
destructive:
|
||||
"bg-[var(--destructive)] text-[var(--destructive-foreground)] hover:opacity-90",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-6",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-[var(--radius)] border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-[var(--muted-foreground)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
};
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface SpinnerProps {
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: "h-4 w-4 border-2",
|
||||
md: "h-6 w-6 border-2",
|
||||
lg: "h-8 w-8 border-3",
|
||||
};
|
||||
|
||||
export function Spinner({ className, size = "md" }: SpinnerProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block rounded-full border-[var(--muted)] border-t-[var(--primary)] animate-spin",
|
||||
sizeMap[size],
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Beautiful-chat generative-UI tool registrations.
|
||||
*
|
||||
* Ported from the langgraph-python flagship
|
||||
* (src/app/demos/beautiful-chat/hooks/use-generative-ui-examples.tsx).
|
||||
* Registers the three controlled-gen-UI surfaces the dashboard probes
|
||||
* exercise on this cell:
|
||||
*
|
||||
* - `pieChart` → PieChart (useComponent)
|
||||
* - `barChart` → BarChart (useComponent)
|
||||
* - `scheduleTime` → MeetingTimePicker (useHumanInTheLoop)
|
||||
*
|
||||
* plus the `toggleTheme` frontend tool (already green — kept intact).
|
||||
*
|
||||
* The CopilotKit runtime forwards these frontend tool definitions to the
|
||||
* agent (see the Python backend's `_build_frontend_tools`), so the LLM —
|
||||
* or the aimock fixture — can call them by name and the matching `render`
|
||||
* callback paints the component inline.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { useTheme } from "./use-theme";
|
||||
|
||||
import {
|
||||
useComponent,
|
||||
useFrontendTool,
|
||||
useHumanInTheLoop,
|
||||
useDefaultRenderTool,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
|
||||
import {
|
||||
PieChart,
|
||||
PieChartProps,
|
||||
} from "../components/generative-ui/charts/pie-chart";
|
||||
import {
|
||||
BarChart,
|
||||
BarChartProps,
|
||||
} from "../components/generative-ui/charts/bar-chart";
|
||||
import { MeetingTimePicker } from "../components/generative-ui/meeting-time-picker";
|
||||
import { ToolReasoning } from "../components/tool-rendering";
|
||||
|
||||
export const useGenerativeUIExamples = () => {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
// Human-in-the-Loop (frontend tool requiring user decision)
|
||||
useHumanInTheLoop({
|
||||
name: "scheduleTime",
|
||||
description: "Use human-in-the-loop to schedule a meeting with the user.",
|
||||
parameters: z.object({
|
||||
reasonForScheduling: z
|
||||
.string()
|
||||
.describe("Reason for scheduling, very brief - 5 words."),
|
||||
meetingDuration: z
|
||||
.number()
|
||||
.describe("Duration of the meeting in minutes"),
|
||||
}),
|
||||
render: ({ respond, status, args }) => {
|
||||
return <MeetingTimePicker status={status} respond={respond} {...args} />;
|
||||
},
|
||||
});
|
||||
|
||||
// Controlled Generative UI (frontend-defined chart components)
|
||||
useComponent({
|
||||
name: "pieChart",
|
||||
description: "Controlled Generative UI that displays data as a pie chart.",
|
||||
parameters: PieChartProps,
|
||||
render: PieChart,
|
||||
});
|
||||
|
||||
useComponent({
|
||||
name: "barChart",
|
||||
description: "Controlled Generative UI that displays data as a bar chart.",
|
||||
parameters: BarChartProps,
|
||||
render: BarChart,
|
||||
});
|
||||
|
||||
// Default Tool Rendering (backend tool UI) — render any other backend
|
||||
// tool call as a compact reasoning card.
|
||||
useDefaultRenderTool({
|
||||
render: ({ name, status, parameters }) => {
|
||||
return <ToolReasoning name={name} status={status} args={parameters} />;
|
||||
},
|
||||
});
|
||||
|
||||
// Frontend Tools (direct frontend state manipulation).
|
||||
// No deps array — the handler reads `document` directly and calls a
|
||||
// stable setter. Including [theme, setTheme] in deps re-registers the
|
||||
// hook on every theme flip, which can race an in-flight tool result.
|
||||
useFrontendTool({
|
||||
name: "toggleTheme",
|
||||
description: "Frontend tool for toggling the theme of the app.",
|
||||
parameters: z.object({}),
|
||||
handler: async () => {
|
||||
const isDark = document.documentElement.classList.contains("dark");
|
||||
setTheme(isDark ? "light" : "dark");
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "dark" | "light" | "system";
|
||||
|
||||
const ThemeContext = createContext<{
|
||||
theme: Theme;
|
||||
setTheme: (t: Theme) => void;
|
||||
}>({
|
||||
theme: "system",
|
||||
setTheme: () => {},
|
||||
});
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>("system");
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const apply = () => {
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(mq.matches ? "dark" : "light");
|
||||
};
|
||||
apply();
|
||||
mq.addEventListener("change", apply);
|
||||
return () => mq.removeEventListener("change", apply);
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Beautiful-chat brand design tokens.
|
||||
*
|
||||
* The ported chart / meeting-picker / ui components key on the
|
||||
* `--card`, `--border`, `--foreground`, … CSS variables from the
|
||||
* langgraph-python flagship's globals.css. This integration's global
|
||||
* stylesheet does not define them, so we scope the same palette to the
|
||||
* `.beautiful-chat-root` wrapper this demo's page renders — keeping the
|
||||
* tokens out of every other cell that shares the app's globals.css.
|
||||
*
|
||||
* `.dark .beautiful-chat-root` mirrors the dark palette so the
|
||||
* `toggleTheme` frontend tool (which flips `documentElement.classList`
|
||||
* "dark") repaints the surface.
|
||||
*/
|
||||
.beautiful-chat-root {
|
||||
--background: #ffffff;
|
||||
--foreground: #1a1a18;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #1a1a18;
|
||||
--primary: #1a1a18;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #ededf5;
|
||||
--secondary-foreground: #1a1a18;
|
||||
--muted: #ededf5;
|
||||
--muted-foreground: #57575b;
|
||||
--accent: #eee6fe;
|
||||
--accent-foreground: #1a1a18;
|
||||
--destructive: #fa5f67;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #dbdbe5;
|
||||
--ring: #bec2ff;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
.dark .beautiful-chat-root,
|
||||
.beautiful-chat-root.dark {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #fafafa;
|
||||
--card: #191a1e;
|
||||
--card-foreground: #fafafa;
|
||||
--primary: #fafafa;
|
||||
--primary-foreground: #1a1a18;
|
||||
--secondary: #242529;
|
||||
--secondary-foreground: #fafafa;
|
||||
--muted: #242529;
|
||||
--muted-foreground: #adadb2;
|
||||
--accent: #303136;
|
||||
--accent-foreground: #fafafa;
|
||||
--destructive: #fa5f67;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #303136;
|
||||
--ring: #bec2ff;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Beautiful Chat (built-in-agent flavor) — a polished two-pane chat that
|
||||
* showcases CopilotChat plus the three controlled generative-UI surfaces
|
||||
* the dashboard probes exercise:
|
||||
* - `pieChart` / `barChart` → recharts/SVG charts (useComponent)
|
||||
* - `scheduleTime` → meeting time picker (useHumanInTheLoop)
|
||||
* - `toggleTheme` → frontend theme toggle (already green)
|
||||
*
|
||||
* The renderers are ported from the langgraph-python flagship and wired to
|
||||
* the same tool names the agent emits. The in-process CopilotKit runtime
|
||||
* (TanStack AI) forwards the frontend tool definitions to the LLM, so a
|
||||
* tool call by name paints the matching renderer inline.
|
||||
*
|
||||
* The A2UI fixed-schema / dynamic-dashboard surfaces remain out of scope —
|
||||
* only the controlled-gen-UI + HITL pills are ported here.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
CopilotKitProvider,
|
||||
CopilotChat,
|
||||
useConfigureSuggestions,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
|
||||
import { ThemeProvider } from "./hooks/use-theme";
|
||||
import { useGenerativeUIExamples } from "./hooks/use-generative-ui-examples";
|
||||
import "./lib/theme.css";
|
||||
|
||||
export default function BeautifulChatPage() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
|
||||
<ChatSurface />
|
||||
</CopilotKitProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatSurface() {
|
||||
// Register the controlled-gen-UI + HITL renderers (pieChart, barChart,
|
||||
// scheduleTime) and the toggleTheme frontend tool. Must run inside the
|
||||
// provider so the tool definitions are forwarded to the agent.
|
||||
useGenerativeUIExamples();
|
||||
|
||||
return (
|
||||
<div className="beautiful-chat-root grid grid-cols-1 md:grid-cols-[1fr_420px] h-screen w-full bg-gradient-to-br from-slate-50 to-indigo-50 dark:from-slate-950 dark:to-indigo-950">
|
||||
<Canvas />
|
||||
<aside className="border-l border-slate-200 dark:border-slate-800 bg-white/60 dark:bg-slate-900/60 backdrop-blur flex flex-col">
|
||||
<Suggestions />
|
||||
<CopilotChat
|
||||
agentId="default"
|
||||
className="flex-1"
|
||||
input={{ disclaimer: () => null, className: "pb-6" }}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Canvas() {
|
||||
return (
|
||||
<main className="hidden md:flex flex-col p-12 overflow-y-auto">
|
||||
<header className="mb-8">
|
||||
<span className="inline-block rounded-full bg-indigo-100 dark:bg-indigo-900 text-indigo-700 dark:text-indigo-200 px-3 py-1 text-xs font-semibold uppercase tracking-wider">
|
||||
Starter
|
||||
</span>
|
||||
<h1 className="mt-3 text-4xl font-bold text-slate-900 dark:text-slate-50">
|
||||
Beautiful Chat
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600 dark:text-slate-400 max-w-prose">
|
||||
A polished starter layout: the built-in CopilotChat dropped onto a
|
||||
two-pane canvas, with suggestions pre-wired and the disclaimer slot
|
||||
customized away. Everything is theme-aware and responsive.
|
||||
</p>
|
||||
</header>
|
||||
<div className="grid grid-cols-2 gap-4 max-w-3xl">
|
||||
<Card title="In-process runner">
|
||||
The CopilotKit runtime runs inside the Next.js route handler — there
|
||||
is no separate agent server.
|
||||
</Card>
|
||||
<Card title="TanStack AI">
|
||||
Backend tools defined with <code>toolDefinition()</code> stream
|
||||
through the agent factory.
|
||||
</Card>
|
||||
<Card title="Tailwind theme">
|
||||
Colors come from the global CopilotKit CSS variables; flip the page to
|
||||
dark mode and the chat follows.
|
||||
</Card>
|
||||
<Card title="Drop-in primitives">
|
||||
<code><CopilotChat /></code> ships with messages, input,
|
||||
suggestions, and disclaimer slots out of the box.
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-slate-900/80 p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-50">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-slate-600 dark:text-slate-400 leading-relaxed">
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Suggestions() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{ title: "Say hi", message: "Say hi!" },
|
||||
{
|
||||
title: "Weather check",
|
||||
message: "What's the weather in San Francisco?",
|
||||
},
|
||||
{ title: "Compose a haiku", message: "Write a haiku about coffee." },
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# BYOC: Hashbrown (built-in-agent)
|
||||
|
||||
Streaming structured output via `@hashbrownai/react`. The built-in-agent
|
||||
factory emits a catalog-constrained `{ ui: [...] }` JSON envelope; the
|
||||
hashbrown `useJsonParser` + `useUiKit` parses it progressively and renders
|
||||
MetricCard + PieChart + BarChart + DealCard + Markdown.
|
||||
|
||||
The agent is forced to JSON-object output via OpenAI's `response_format:
|
||||
{ type: "json_object" }` (`modelOptions` on the TanStack `chat()` call) so
|
||||
the parser never has to tolerate code fences or preamble.
|
||||
|
||||
- Dedicated route: `/api/copilotkit-byoc-hashbrown`
|
||||
- Single-route mode (`useSingleEndpoint`)
|
||||
- Key files: `page.tsx`, `hashbrown-renderer.tsx`, `metric-card.tsx`,
|
||||
`charts/`, `suggestions.ts`, `types.ts`,
|
||||
`../../api/copilotkit-byoc-hashbrown/route.ts`,
|
||||
`../../../lib/factory/byoc-hashbrown-factory.ts`
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* BarChart ported from showcase/starters/template/frontend/components/charts/bar-chart.tsx
|
||||
* for the byoc-hashbrown demo (Wave 4a).
|
||||
*
|
||||
* Adds data-testid="bar-chart" on the top-level container for E2E coverage.
|
||||
*/
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
BarChart as RechartsBarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Rectangle,
|
||||
} from "recharts";
|
||||
import { z } from "zod";
|
||||
import { CHART_COLORS, CHART_CONFIG } from "./chart-config";
|
||||
|
||||
export const BarChartProps = z.object({
|
||||
title: z.string().describe("Chart title"),
|
||||
description: z.string().describe("Brief description or subtitle"),
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type BarChartPropsType = z.infer<typeof BarChartProps>;
|
||||
|
||||
/** Tracks seen indices so only NEW bars get the fade-in animation. */
|
||||
function useSeenIndices() {
|
||||
const seen = useRef(new Set<number>());
|
||||
return {
|
||||
isNew(index: number) {
|
||||
if (seen.current.has(index)) return false;
|
||||
seen.current.add(index);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function AnimatedBar(props: any) {
|
||||
const { isNew, ...rest } = props;
|
||||
return (
|
||||
<g
|
||||
style={
|
||||
isNew
|
||||
? {
|
||||
animation: "barSlideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Rectangle {...rest} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarChart({ title, description, data }: BarChartPropsType) {
|
||||
const { isNew } = useSeenIndices();
|
||||
|
||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
data-testid="bar-chart"
|
||||
className="max-w-2xl mx-auto my-4 rounded-lg border border-[var(--border)] bg-[var(--card)]"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="h-4 w-4 text-[var(--muted-foreground)]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="20" x2="18" y2="10" />
|
||||
<line x1="12" y1="20" x2="12" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="14" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 pt-0">
|
||||
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
|
||||
No data available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="bar-chart"
|
||||
className="max-w-2xl mx-auto my-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]"
|
||||
>
|
||||
{/* Scoped keyframe -- no globals.css needed */}
|
||||
<style>{`
|
||||
@keyframes barSlideIn {
|
||||
from { transform: translateY(40px); opacity: 0; }
|
||||
20% { opacity: 1; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
<div className="p-6 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center h-6 w-6 rounded-md bg-[var(--secondary)]">
|
||||
<svg
|
||||
className="h-3.5 w-3.5 text-[var(--muted-foreground)]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="20" x2="18" y2="10" />
|
||||
<line x1="12" y1="20" x2="12" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="14" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
|
||||
</div>
|
||||
<div className="p-6 pt-2">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<RechartsBarChart
|
||||
data={data}
|
||||
margin={{ top: 12, right: 12, bottom: 4, left: -8 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={CHART_CONFIG.tooltipStyle}
|
||||
cursor={{ fill: "var(--secondary)", opacity: 0.5 }}
|
||||
/>
|
||||
<Bar
|
||||
isAnimationActive={false}
|
||||
dataKey="value"
|
||||
radius={[6, 6, 0, 0]}
|
||||
maxBarSize={48}
|
||||
shape={(props: unknown) => {
|
||||
const p = props as Record<string, unknown>;
|
||||
return <AnimatedBar {...p} isNew={isNew(p.index as number)} />;
|
||||
}}
|
||||
>
|
||||
{data.map((_, index) => (
|
||||
<Cell
|
||||
key={index}
|
||||
fill={CHART_COLORS[index % CHART_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* CopilotKit brand chart palette -- Plus Jakarta Sans / brand color system.
|
||||
*
|
||||
* Ported verbatim from showcase/starters/template/frontend/components/charts/chart-config.ts
|
||||
* for the byoc-hashbrown demo (Wave 4a).
|
||||
*/
|
||||
export const CHART_COLORS = [
|
||||
"#BEC2FF", // lilac-400
|
||||
"#85ECCE", // mint-400
|
||||
"#FFAC4D", // orange-400
|
||||
"#FFF388", // yellow-400
|
||||
"#189370", // mint-800
|
||||
"#EEE6FE", // primary-100
|
||||
"#FA5F67", // red-400
|
||||
] as const;
|
||||
|
||||
export const CHART_CONFIG = {
|
||||
tooltipStyle: {
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "10px",
|
||||
padding: "10px 14px",
|
||||
color: "var(--foreground)",
|
||||
fontSize: "13px",
|
||||
fontFamily: "var(--font-body)",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
|
||||
},
|
||||
};
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PieChart ported from showcase/starters/template/frontend/components/charts/pie-chart.tsx
|
||||
* for the byoc-hashbrown demo (Wave 4a).
|
||||
*
|
||||
* Adds data-testid="pie-chart" on the top-level container for E2E coverage.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { CHART_COLORS } from "./chart-config";
|
||||
|
||||
export const PieChartProps = z.object({
|
||||
title: z.string().describe("Chart title"),
|
||||
description: z.string().describe("Brief description or subtitle"),
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type PieChartPropsType = z.infer<typeof PieChartProps>;
|
||||
|
||||
/** Custom SVG donut chart built with <circle> + stroke-dasharray. */
|
||||
function DonutChart({
|
||||
data,
|
||||
size = 240,
|
||||
strokeWidth = 40,
|
||||
}: {
|
||||
data: { label: string; value: number }[];
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const center = size / 2;
|
||||
|
||||
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
|
||||
|
||||
// Calculate each slice's arc length and starting position
|
||||
let accumulated = 0;
|
||||
const slices = data.map((item, index) => {
|
||||
const val = Number(item.value) || 0;
|
||||
const ratio = total > 0 ? val / total : 0;
|
||||
const arc = ratio * circumference;
|
||||
const startAt = accumulated;
|
||||
accumulated += arc;
|
||||
return {
|
||||
...item,
|
||||
arc,
|
||||
gap: circumference - arc,
|
||||
// Negative dashoffset shifts the dash forward (clockwise) to the correct position
|
||||
dashoffset: -startAt,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="100%"
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="block mx-auto"
|
||||
style={{ maxWidth: size, transform: "scaleX(-1)" }}
|
||||
>
|
||||
{/* Background ring */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--secondary)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Data slices */}
|
||||
{slices.map((slice, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={slice.color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${slice.arc} ${slice.gap}`}
|
||||
strokeDashoffset={slice.dashoffset}
|
||||
strokeLinecap="butt"
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PieChart({ title, description, data }: PieChartPropsType) {
|
||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
data-testid="pie-chart"
|
||||
className="max-w-lg mx-auto my-4 rounded-lg border border-[var(--border)] bg-[var(--card)]"
|
||||
>
|
||||
<div className="p-6 pb-0">
|
||||
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
|
||||
No data available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="pie-chart"
|
||||
className="max-w-lg mx-auto my-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]"
|
||||
>
|
||||
<div className="p-6 pb-0">
|
||||
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
|
||||
</div>
|
||||
<div className="p-6 pt-4">
|
||||
<DonutChart data={data} />
|
||||
|
||||
{/* Legend */}
|
||||
<div className="space-y-2 pt-4">
|
||||
{data.map((item, index) => {
|
||||
const val = Number(item.value) || 0;
|
||||
const pct = total > 0 ? ((val / total) * 100).toFixed(0) : 0;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-3 text-sm transition-opacity duration-300 ease-out"
|
||||
style={{ opacity: 1 }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full shrink-0"
|
||||
style={{
|
||||
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
<span className="flex-1 text-[var(--foreground)] truncate">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-[var(--muted-foreground)] tabular-nums">
|
||||
{val.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-[var(--muted-foreground)] text-sm w-10 text-right tabular-nums">
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* HashBrown message renderer for the byoc-hashbrown demo (Wave 4a).
|
||||
*
|
||||
* Ported from showcase/starters/template/frontend/components/renderers/hashbrown/index.tsx
|
||||
* with these adjustments:
|
||||
* - MetricCard lives in ./metric-card (extracted module).
|
||||
* - Charts live under ./charts/ (co-located in the demo).
|
||||
* - SalesStage type lives in ./types.
|
||||
*
|
||||
* Registers MetricCard + PieChart + BarChart + DealCard + Markdown against the
|
||||
* hashbrown schema via `@hashbrownai/react`'s `useUiKit`. Renders assistant
|
||||
* messages through `useJsonParser` for progressive JSON→UI streaming.
|
||||
*
|
||||
* Wire format
|
||||
* -----------
|
||||
* `useJsonParser(content, kit.schema)` parses a streaming JSON object of the
|
||||
* shape produced by `createUiKit(...).schema`:
|
||||
*
|
||||
* {
|
||||
* "ui": [
|
||||
* { "metric": { "props": { "label": "...", "value": "..." } } },
|
||||
* { "pieChart": { "props": { "title": "...", "data": "[{...}]" } } },
|
||||
* { "Markdown": { "props": { "children": "..." } } }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* The `useUiKit({ examples: ... })` `<ui>` JSX is hashbrown's prompt DSL used
|
||||
* when hashbrown drives the LLM directly (e.g. `useUiChat`). Because this
|
||||
* demo drives the LLM via langgraph, the backend agent
|
||||
* (`src/agents/byoc_hashbrown_agent.py`) is responsible for emitting the JSON
|
||||
* shape shown above.
|
||||
*
|
||||
* Consume the renderer like this in a page:
|
||||
*
|
||||
* <HashBrownDashboard>
|
||||
* <CopilotChat RenderMessage={useHashBrownMessageRenderer()} />
|
||||
* </HashBrownDashboard>
|
||||
*/
|
||||
import React, { memo } from "react";
|
||||
import { s, prompt } from "@hashbrownai/core";
|
||||
import {
|
||||
exposeComponent,
|
||||
exposeMarkdown,
|
||||
useUiKit,
|
||||
useJsonParser,
|
||||
} from "@hashbrownai/react";
|
||||
import { PieChart } from "./charts/pie-chart";
|
||||
import { BarChart } from "./charts/bar-chart";
|
||||
import { MetricCard } from "./metric-card";
|
||||
import type { SalesStage } from "./types";
|
||||
|
||||
/**
|
||||
* The underlying PieChart / BarChart components take `data` as a real
|
||||
* array, but hashbrown's build-time validator (0.5.0-beta.4) rejects
|
||||
* example prompts whose JSX attribute values don't match the schema
|
||||
* type — i.e. `data='[{"label":"A","value":1}]'` (string) doesn't pass
|
||||
* an `s.streaming.array(...)` schema. And since the LLM streams JSON as
|
||||
* text anyway, modeling `data` as a string prop and parsing inside the
|
||||
* component is the path the example syntax naturally supports.
|
||||
*
|
||||
* These wrappers accept `data: string`, JSON-parse it, and render the
|
||||
* real chart. Defensive — silently render nothing if parse fails mid-
|
||||
* stream (hashbrown feeds partial tokens while streaming).
|
||||
*/
|
||||
type ChartSlice = { label: string; value: number };
|
||||
|
||||
function parseChartData(data: string): ChartSlice[] | null {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed as ChartSlice[];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function PieChartWithStringData({
|
||||
title,
|
||||
data,
|
||||
}: {
|
||||
title: string;
|
||||
data: string;
|
||||
}) {
|
||||
const parsed = parseChartData(data);
|
||||
if (!parsed) return null;
|
||||
return <PieChart title={title} description="" data={parsed} />;
|
||||
}
|
||||
|
||||
function BarChartWithStringData({
|
||||
title,
|
||||
data,
|
||||
}: {
|
||||
title: string;
|
||||
data: string;
|
||||
}) {
|
||||
const parsed = parseChartData(data);
|
||||
if (!parsed) return null;
|
||||
return <BarChart title={title} description="" data={parsed} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal local types for the CopilotChat RenderMessage slot + AG-UI
|
||||
* assistant message shape. These mirror `RenderMessageProps` from
|
||||
* `@copilotkit/react-ui` and `AssistantMessage` from `@ag-ui/core`, inlined
|
||||
* to avoid adding those packages as direct dependencies of langgraph-python
|
||||
* (they come in transitively via `@copilotkit/react-core`).
|
||||
*
|
||||
* Only the fields the renderer reads are declared.
|
||||
*/
|
||||
interface LocalAssistantMessage {
|
||||
role: "assistant";
|
||||
content?: string;
|
||||
}
|
||||
|
||||
interface LocalChatMessage {
|
||||
role: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
interface LocalRenderMessageProps {
|
||||
message: LocalChatMessage;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standalone DealCard for the kit (flat props, no SalesTodo dependency)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HashBrownDealCardProps {
|
||||
title: string;
|
||||
stage: SalesStage;
|
||||
value: number;
|
||||
assignee?: string;
|
||||
dueDate?: string;
|
||||
}
|
||||
|
||||
const STAGE_COLORS: Record<SalesStage, string> = {
|
||||
prospect: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
qualified:
|
||||
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
|
||||
proposal: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200",
|
||||
negotiation:
|
||||
"bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
|
||||
"closed-won":
|
||||
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
"closed-lost": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
||||
};
|
||||
|
||||
function DealCardComponent({
|
||||
title,
|
||||
stage,
|
||||
value,
|
||||
assignee,
|
||||
dueDate,
|
||||
}: HashBrownDealCardProps) {
|
||||
const badgeClass =
|
||||
STAGE_COLORS[stage] ??
|
||||
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="hashbrown-deal-card"
|
||||
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4"
|
||||
>
|
||||
<h3 className="text-sm font-semibold leading-snug text-[var(--foreground)]">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass}`}
|
||||
>
|
||||
{stage}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-[var(--foreground)]">
|
||||
${(value ?? 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
|
||||
{assignee && <span>{assignee}</span>}
|
||||
{dueDate && <span>Due {dueDate}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kit definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useSalesDashboardKit() {
|
||||
return useUiKit({
|
||||
examples: prompt`
|
||||
# Mixing components and Markdown:
|
||||
<ui>
|
||||
<Markdown children="## Q4 Sales Summary" />
|
||||
<metric label="Total Revenue" value="$1.2M" />
|
||||
<Markdown children="Revenue breakdown by segment:" />
|
||||
<pieChart title="Revenue by Segment" data='[{"label":"Enterprise","value":600000},{"label":"SMB","value":400000},{"label":"Startup","value":200000}]' />
|
||||
<barChart title="Monthly Trend" data='[{"label":"Oct","value":350000},{"label":"Nov","value":400000},{"label":"Dec","value":450000}]' />
|
||||
<dealCard title="Acme Corp Renewal" stage="negotiation" value="250000" />
|
||||
</ui>
|
||||
|
||||
Hint: use Markdown for explanatory text between visual components.
|
||||
Hint: always include title and data for charts. Data is a JSON-encoded
|
||||
array of {label, value} objects as a string.
|
||||
`,
|
||||
components: [
|
||||
exposeMarkdown(),
|
||||
exposeComponent(MetricCard, {
|
||||
name: "metric",
|
||||
description: "A KPI metric card with label, value, and optional trend",
|
||||
// Note on "optional" props: @hashbrownai/core@0.5.0-beta.4 dropped
|
||||
// the `.optional()` chain in favor of treating component prop schemas
|
||||
// as Partial at the exposeComponent layer. We omit optional keys from
|
||||
// the schema and surface their existence to the LLM via the
|
||||
// `examples` prompt above and the natural-language `description`.
|
||||
props: {
|
||||
label: s.string("The metric label/name"),
|
||||
value: s.string("The metric value (formatted)"),
|
||||
},
|
||||
}),
|
||||
exposeComponent(PieChartWithStringData, {
|
||||
name: "pieChart",
|
||||
description:
|
||||
"A donut/pie chart. `data` is a JSON-encoded string of an " +
|
||||
"array of {label, value} segments, e.g. " +
|
||||
'\'[{"label":"A","value":1}]\'.',
|
||||
props: {
|
||||
title: s.string("Chart title"),
|
||||
data: s.string("JSON array of {label, value} segments"),
|
||||
},
|
||||
}),
|
||||
exposeComponent(BarChartWithStringData, {
|
||||
name: "barChart",
|
||||
description:
|
||||
"A vertical bar chart. `data` is a JSON-encoded string of an " +
|
||||
"array of {label, value} bars, e.g. " +
|
||||
'\'[{"label":"A","value":1}]\'.',
|
||||
props: {
|
||||
title: s.string("Chart title"),
|
||||
data: s.string("JSON array of {label, value} bars"),
|
||||
},
|
||||
}),
|
||||
exposeComponent(DealCardComponent, {
|
||||
name: "dealCard",
|
||||
description: "A sales deal card showing pipeline stage and value",
|
||||
props: {
|
||||
title: s.string("Deal title"),
|
||||
stage: s.enumeration("Pipeline stage", [
|
||||
"prospect",
|
||||
"qualified",
|
||||
"proposal",
|
||||
"negotiation",
|
||||
"closed-won",
|
||||
"closed-lost",
|
||||
]),
|
||||
value: s.number("Deal value in dollars"),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom message renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AssistantMessageRenderer = memo(function AssistantMessageRenderer({
|
||||
message,
|
||||
kit,
|
||||
}: {
|
||||
message: LocalAssistantMessage;
|
||||
kit: ReturnType<typeof useSalesDashboardKit>;
|
||||
}) {
|
||||
const { value } = useJsonParser(message.content ?? "", kit.schema);
|
||||
|
||||
if (!value) return null;
|
||||
|
||||
// The CopilotChat default assistantMessage slot renders a wrapper with
|
||||
// `data-testid="copilot-assistant-message"` — used by the harness'
|
||||
// `e2e-deep` conversation runner to count settled responses. Overriding
|
||||
// the slot drops that testid, so any tooling that waits for "an
|
||||
// assistant message landed" never sees a count change. Re-attaching it
|
||||
// here keeps the slot override behaviorally identical to the default
|
||||
// for response-detection purposes.
|
||||
return (
|
||||
<div
|
||||
data-testid="copilot-assistant-message"
|
||||
data-message-role="assistant"
|
||||
className="mt-2 flex w-full justify-start"
|
||||
>
|
||||
<div className="w-full px-1 py-1">{kit.render(value)}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported dashboard provider + renderer hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HashBrownDashboardProps {
|
||||
/**
|
||||
* Optional custom wrapper for the assistant message area.
|
||||
* Defaults to rendering messages inline.
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider that instantiates the HashBrown kit ONCE and shares it via context.
|
||||
* Both the dashboard layout and message renderer consume the same kit instance.
|
||||
*
|
||||
* The kit registers MetricCard, PieChart, BarChart, DealCard, and Markdown
|
||||
* components. Agent context forwarding for output_schema is omitted because
|
||||
* the npm-published react-core may not export useAgentContext yet.
|
||||
*/
|
||||
const HashBrownKitContext = React.createContext<ReturnType<
|
||||
typeof useSalesDashboardKit
|
||||
> | null>(null);
|
||||
|
||||
function useHashBrownKit() {
|
||||
const kit = React.useContext(HashBrownKitContext);
|
||||
if (!kit)
|
||||
throw new Error("useHashBrownKit must be used within HashBrownDashboard");
|
||||
return kit;
|
||||
}
|
||||
|
||||
export function HashBrownDashboard({ children }: HashBrownDashboardProps) {
|
||||
const kit = useSalesDashboardKit();
|
||||
|
||||
// Note: Agent context forwarding (useAgentContext) for output_schema is
|
||||
// omitted because the npm-published react-core may not export it yet.
|
||||
|
||||
return (
|
||||
<HashBrownKitContext.Provider value={kit}>
|
||||
{children}
|
||||
</HashBrownKitContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable message renderer component that consumes the kit from context.
|
||||
* Defined at module level to avoid unstable function identity.
|
||||
*/
|
||||
function HashBrownRenderMessage({ message }: LocalRenderMessageProps) {
|
||||
const kit = useHashBrownKit();
|
||||
if (message.role === "assistant") {
|
||||
return (
|
||||
<AssistantMessageRenderer
|
||||
message={message as LocalAssistantMessage}
|
||||
kit={kit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stable HashBrownRenderMessage component.
|
||||
* Must be used within a HashBrownDashboard provider.
|
||||
*/
|
||||
export function useHashBrownMessageRenderer() {
|
||||
return HashBrownRenderMessage;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MetricCard for the byoc-hashbrown demo (Wave 4a).
|
||||
*
|
||||
* Extracted from showcase/starters/template/frontend/components/renderers/hashbrown/index.tsx
|
||||
* so the hashbrown-renderer can import it as its own module.
|
||||
*/
|
||||
import React from "react";
|
||||
|
||||
interface MetricCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
trend?: string;
|
||||
}
|
||||
|
||||
export function MetricCard({ label, value, trend }: MetricCardProps) {
|
||||
const isPositive =
|
||||
trend?.startsWith("+") || trend?.toLowerCase().includes("up");
|
||||
const isNegative =
|
||||
trend?.startsWith("-") || trend?.toLowerCase().includes("down");
|
||||
const trendColor = isPositive
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: isNegative
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "text-[var(--muted-foreground)]";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="metric-card"
|
||||
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4"
|
||||
>
|
||||
<p className="text-xs text-[var(--muted-foreground)] uppercase tracking-wider font-medium">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-[var(--foreground)] mt-1">
|
||||
{value}
|
||||
</p>
|
||||
{trend && (
|
||||
<p data-testid="metric-trend" className={`text-sm mt-1 ${trendColor}`}>
|
||||
{trend}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* byoc-hashbrown demo page (Wave 4a).
|
||||
*
|
||||
* Dedicated single-mode demo that ports the starter's hashbrown renderer
|
||||
* onto a langgraph-python agent. Streaming structured output from the agent
|
||||
* (`byoc_hashbrown_agent`) is parsed progressively by `@hashbrownai/react`'s
|
||||
* `useJsonParser` + `useUiKit` and rendered with MetricCard + PieChart +
|
||||
* BarChart from `./charts/`.
|
||||
*
|
||||
* Layout:
|
||||
* - Header with title + short description.
|
||||
* - Chat composer with pre-seeded suggestion pills (via
|
||||
* `useConfigureSuggestions`). Clicking a pill sends the canned prompt.
|
||||
* - Assistant messages are routed through `HashBrownAssistantMessage` via
|
||||
* `<CopilotChat messageView={{ assistantMessage: ... }} />`.
|
||||
*
|
||||
* Runtime: dedicated endpoint `/api/copilotkit-byoc-hashbrown` with its own
|
||||
* agent — no bleed into the default runtime.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
CopilotKitProvider,
|
||||
CopilotChat,
|
||||
CopilotChatAssistantMessage,
|
||||
useConfigureSuggestions,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
import {
|
||||
HashBrownDashboard,
|
||||
useHashBrownMessageRenderer,
|
||||
} from "./hashbrown-renderer";
|
||||
import { BYOC_HASHBROWN_SUGGESTIONS } from "./suggestions";
|
||||
|
||||
export default function ByocHashbrownDemoPage() {
|
||||
return (
|
||||
<CopilotKitProvider
|
||||
runtimeUrl="/api/copilotkit-byoc-hashbrown"
|
||||
useSingleEndpoint
|
||||
>
|
||||
<HashBrownDashboard>
|
||||
<div className="flex h-screen flex-col gap-3 p-6">
|
||||
<header>
|
||||
<h1 className="text-lg font-semibold">BYOC: Hashbrown</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Streaming structured output via <code>@hashbrownai/react</code>.
|
||||
The agent emits a catalog- constrained UI envelope that renders
|
||||
progressively as data streams.
|
||||
</p>
|
||||
</header>
|
||||
<div className="flex-1 overflow-hidden rounded-md border border-[var(--border)]">
|
||||
<ChatBody />
|
||||
</div>
|
||||
</div>
|
||||
</HashBrownDashboard>
|
||||
</CopilotKitProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatBody() {
|
||||
// Pre-seed the composer with canonical prompts that steer the agent toward
|
||||
// hashbrown-shaped output. `useConfigureSuggestions` renders pills inside
|
||||
// the CopilotChat composer; clicking a pill sends its `message` directly.
|
||||
useConfigureSuggestions({
|
||||
suggestions: BYOC_HASHBROWN_SUGGESTIONS.map((s) => ({
|
||||
title: s.label,
|
||||
message: s.prompt,
|
||||
// E2E testid-friendly class — Playwright targets visible text, but we
|
||||
// keep a class hook in case we need finer-grained selectors later.
|
||||
className: `byoc-hashbrown-suggestion-${s.label
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")}`,
|
||||
})),
|
||||
available: "always",
|
||||
});
|
||||
|
||||
// Resolve the memoized HashBrownRenderMessage component from the kit
|
||||
// provider. It consumes the shared kit via context (see
|
||||
// hashbrown-renderer.tsx) and renders assistant messages as a progressively
|
||||
// assembled UI catalog.
|
||||
const HashBrownMessage = useHashBrownMessageRenderer();
|
||||
|
||||
return (
|
||||
<CopilotChat
|
||||
className="h-full"
|
||||
messageView={{
|
||||
// `HashBrownMessage` matches the RenderMessage slot shape ({ message })
|
||||
// but the v2 assistantMessage slot expects CopilotChatAssistantMessage's
|
||||
// wider props. The cast is intentional — the renderer reads only
|
||||
// `message`, exactly like the starter's page does with `RenderMessage`
|
||||
// on CopilotSidebar.
|
||||
assistantMessage:
|
||||
HashBrownMessage as unknown as typeof CopilotChatAssistantMessage,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Pre-seeded suggestion prompts for the byoc-hashbrown demo (Wave 4a).
|
||||
*
|
||||
* Each prompt is tuned to steer the agent toward emitting hashbrown-shaped
|
||||
* structured output that the ported renderer (MetricCard + PieChart +
|
||||
* BarChart) can progressively assemble via `@hashbrownai/react`'s `useUiKit`
|
||||
* + `useJsonParser`.
|
||||
*/
|
||||
export interface Suggestion {
|
||||
/** Short label rendered on the pill + used in data-testid suffix. */
|
||||
label: string;
|
||||
/** Full prompt sent to the agent when the pill is clicked. */
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export const BYOC_HASHBROWN_SUGGESTIONS: Suggestion[] = [
|
||||
{
|
||||
label: "Sales dashboard",
|
||||
prompt:
|
||||
"Show me a Q4 sales dashboard. Include a total-revenue metric card, a pie chart of revenue by segment, and a bar chart of monthly revenue.",
|
||||
},
|
||||
{
|
||||
label: "Revenue by category",
|
||||
prompt:
|
||||
"Break down Q4 revenue by product category as a pie chart. Include at least four segments with realistic sample values.",
|
||||
},
|
||||
{
|
||||
label: "Expense trend",
|
||||
prompt:
|
||||
"Show me monthly operating expenses for the last six months as a bar chart with one bar per month.",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Local types for the byoc-hashbrown demo (Wave 4a).
|
||||
*
|
||||
* Ported subset from showcase/starters/template/frontend/types.ts — only the
|
||||
* domain types the hashbrown renderer's DealCard needs.
|
||||
*/
|
||||
export const SALES_STAGES = [
|
||||
"prospect",
|
||||
"qualified",
|
||||
"proposal",
|
||||
"negotiation",
|
||||
"closed-won",
|
||||
"closed-lost",
|
||||
] as const;
|
||||
|
||||
export type SalesStage = (typeof SALES_STAGES)[number];
|
||||
@@ -0,0 +1,16 @@
|
||||
# BYOC: json-render (built-in-agent)
|
||||
|
||||
Streaming structured output via `@json-render/react`. The built-in-agent
|
||||
factory emits a flat `{ root, elements }` spec; the
|
||||
`JsonRenderAssistantMessage` slot validates it against the Zod-typed
|
||||
catalog and feeds it to `<Renderer />` against a shared registry.
|
||||
|
||||
The agent is forced to JSON-object output via OpenAI's `response_format:
|
||||
{ type: "json_object" }` (`modelOptions` on the TanStack `chat()` call).
|
||||
|
||||
- Dedicated route: `/api/copilotkit-byoc-json-render`
|
||||
- Single-route mode (`useSingleEndpoint`)
|
||||
- Key files: `page.tsx`, `json-render-renderer.tsx`, `registry.tsx`,
|
||||
`catalog.ts`, `metric-card.tsx`, `charts/`, `suggestions.ts`,
|
||||
`types.ts`, `../../api/copilotkit-byoc-json-render/route.ts`,
|
||||
`../../../lib/factory/byoc-json-render-factory.ts`
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* json-render catalog for the byoc-json-render demo.
|
||||
*
|
||||
* We reuse the prebuilt React schema exposed by `@json-render/react/schema`
|
||||
* (flat element tree: `{ root, elements }`) rather than defining our own,
|
||||
* because the React renderer we ship is `@json-render/react`'s `<Renderer />`
|
||||
* which expects that exact shape.
|
||||
*
|
||||
* The catalog declares three components (MetricCard, BarChart, PieChart)
|
||||
* matching Wave 4a's hashbrown catalog so the two BYOC demos are directly
|
||||
* comparable. No actions are declared — this is a read-only rendering demo.
|
||||
*/
|
||||
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
/** Numeric data point used by both bar and pie charts. */
|
||||
const dataPoint = z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
});
|
||||
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
MetricCard: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
trend: z.string().nullable(),
|
||||
}),
|
||||
description:
|
||||
"A labelled metric (single number) with an optional trend subtitle",
|
||||
},
|
||||
BarChart: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
data: z.array(dataPoint),
|
||||
}),
|
||||
description:
|
||||
"A vertical bar chart for comparing discrete values side by side",
|
||||
},
|
||||
PieChart: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
data: z.array(dataPoint),
|
||||
}),
|
||||
description:
|
||||
"A donut-style pie chart for breaking a total down into category slices",
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Human-readable catalog description. Used by the agent's system prompt
|
||||
* so the LLM's available component list stays in lockstep with this file
|
||||
* (single source of truth, mirrors R4 in the spec).
|
||||
*/
|
||||
export const CATALOG_DESCRIPTION = `
|
||||
Available components (use each as the "type" field of an element):
|
||||
|
||||
- MetricCard
|
||||
props: { label: string, value: string, trend: string | null }
|
||||
Example trend strings: "+12% vs last quarter", "-3% vs last month", null.
|
||||
|
||||
- BarChart
|
||||
props: { title: string, description: string | null, data: [{ label: string, value: number }] }
|
||||
|
||||
- PieChart
|
||||
props: { title: string, description: string | null, data: [{ label: string, value: number }] }
|
||||
`.trim();
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
BarChart as RechartsBarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Rectangle,
|
||||
} from "recharts";
|
||||
import { CHART_COLORS, CHART_CONFIG } from "./chart-config";
|
||||
|
||||
export interface BarChartDatum {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface BarChartComponentProps {
|
||||
title: string;
|
||||
/** Optional subtitle — json-render catalog emits a nullable string. */
|
||||
description?: string | null;
|
||||
data: BarChartDatum[];
|
||||
}
|
||||
|
||||
/** Tracks seen indices so only NEW bars get the fade-in animation. */
|
||||
function useSeenIndices() {
|
||||
const seen = useRef(new Set<number>());
|
||||
return {
|
||||
isNew(index: number) {
|
||||
if (seen.current.has(index)) return false;
|
||||
seen.current.add(index);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function AnimatedBar(props: any) {
|
||||
const { isNew, ...rest } = props;
|
||||
return (
|
||||
<g
|
||||
style={
|
||||
isNew
|
||||
? {
|
||||
animation: "barSlideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Rectangle {...rest} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarChart({ title, description, data }: BarChartComponentProps) {
|
||||
const { isNew } = useSeenIndices();
|
||||
|
||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
data-testid="bar-chart"
|
||||
className="max-w-2xl mx-auto my-4 rounded-lg border border-[var(--border)] bg-[var(--card)]"
|
||||
>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
|
||||
{title}
|
||||
</h3>
|
||||
{description ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-6 pt-0">
|
||||
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
|
||||
No data available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="bar-chart"
|
||||
className="max-w-2xl mx-auto my-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]"
|
||||
>
|
||||
<style>{`
|
||||
@keyframes barSlideIn {
|
||||
from { transform: translateY(40px); opacity: 0; }
|
||||
20% { opacity: 1; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
<div className="p-6 pb-2">
|
||||
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
|
||||
{title}
|
||||
</h3>
|
||||
{description ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-6 pt-2">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<RechartsBarChart
|
||||
data={data}
|
||||
margin={{ top: 12, right: 12, bottom: 4, left: -8 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={CHART_CONFIG.tooltipStyle}
|
||||
cursor={{ fill: "var(--secondary)", opacity: 0.5 }}
|
||||
/>
|
||||
<Bar
|
||||
isAnimationActive={false}
|
||||
dataKey="value"
|
||||
radius={[6, 6, 0, 0]}
|
||||
maxBarSize={48}
|
||||
shape={(props: unknown) => {
|
||||
const p = props as Record<string, unknown>;
|
||||
return <AnimatedBar {...p} isNew={isNew(p.index as number)} />;
|
||||
}}
|
||||
>
|
||||
{data.map((_, index) => (
|
||||
<Cell
|
||||
key={index}
|
||||
fill={CHART_COLORS[index % CHART_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* CopilotKit brand chart palette — Plus Jakarta Sans / brand color system.
|
||||
* Ported from `showcase/starters/template/frontend/components/charts/chart-config.ts`
|
||||
* to keep the byoc-json-render demo self-contained (per spec decision: each demo
|
||||
* maintains its own catalog component copies).
|
||||
*/
|
||||
export const CHART_COLORS = [
|
||||
"#BEC2FF", // lilac-400
|
||||
"#85ECCE", // mint-400
|
||||
"#FFAC4D", // orange-400
|
||||
"#FFF388", // yellow-400
|
||||
"#189370", // mint-800
|
||||
"#EEE6FE", // primary-100
|
||||
"#FA5F67", // red-400
|
||||
] as const;
|
||||
|
||||
export const CHART_CONFIG = {
|
||||
tooltipStyle: {
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "10px",
|
||||
padding: "10px 14px",
|
||||
color: "var(--foreground)",
|
||||
fontSize: "13px",
|
||||
fontFamily: "var(--font-body)",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
|
||||
},
|
||||
};
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { CHART_COLORS } from "./chart-config";
|
||||
|
||||
export interface PieChartDatum {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface PieChartComponentProps {
|
||||
title: string;
|
||||
/** Optional subtitle — json-render catalog emits a nullable string. */
|
||||
description?: string | null;
|
||||
data: PieChartDatum[];
|
||||
}
|
||||
|
||||
/** Custom SVG donut chart built with <circle> + stroke-dasharray. */
|
||||
function DonutChart({
|
||||
data,
|
||||
size = 240,
|
||||
strokeWidth = 40,
|
||||
}: {
|
||||
data: PieChartDatum[];
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const center = size / 2;
|
||||
|
||||
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
|
||||
|
||||
let accumulated = 0;
|
||||
const slices = data.map((item, index) => {
|
||||
const val = Number(item.value) || 0;
|
||||
const ratio = total > 0 ? val / total : 0;
|
||||
const arc = ratio * circumference;
|
||||
const startAt = accumulated;
|
||||
accumulated += arc;
|
||||
return {
|
||||
...item,
|
||||
arc,
|
||||
gap: circumference - arc,
|
||||
dashoffset: -startAt,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="100%"
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="block mx-auto"
|
||||
style={{ maxWidth: size, transform: "scaleX(-1)" }}
|
||||
>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--secondary)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{slices.map((slice, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={slice.color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${slice.arc} ${slice.gap}`}
|
||||
strokeDashoffset={slice.dashoffset}
|
||||
strokeLinecap="butt"
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PieChart({ title, description, data }: PieChartComponentProps) {
|
||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
data-testid="pie-chart"
|
||||
className="max-w-lg mx-auto my-4 rounded-lg border border-[var(--border)] bg-[var(--card)]"
|
||||
>
|
||||
<div className="p-6 pb-0">
|
||||
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
|
||||
{title}
|
||||
</h3>
|
||||
{description ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
|
||||
No data available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="pie-chart"
|
||||
className="max-w-lg mx-auto my-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]"
|
||||
>
|
||||
<div className="p-6 pb-0">
|
||||
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
|
||||
{title}
|
||||
</h3>
|
||||
{description ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-6 pt-4">
|
||||
<DonutChart data={data} />
|
||||
<div className="space-y-2 pt-4">
|
||||
{data.map((item, index) => {
|
||||
const val = Number(item.value) || 0;
|
||||
const pct = total > 0 ? ((val / total) * 100).toFixed(0) : 0;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-3 text-sm transition-opacity duration-300 ease-out"
|
||||
style={{ opacity: 1 }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full shrink-0"
|
||||
style={{
|
||||
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
<span className="flex-1 text-[var(--foreground)] truncate">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-[var(--muted-foreground)] tabular-nums">
|
||||
{val.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-[var(--muted-foreground)] text-sm w-10 text-right tabular-nums">
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Custom `messageView.assistantMessage` slot that renders the agent's
|
||||
* structured-JSON output through `@json-render/react`.
|
||||
*
|
||||
* The langgraph-python `byoc_json_render_agent` emits a single JSON object
|
||||
* shaped like `@json-render/react`'s flat spec format:
|
||||
*
|
||||
* ```json
|
||||
* {
|
||||
* "root": "<id>",
|
||||
* "elements": {
|
||||
* "<id>": { "type": "MetricCard" | "BarChart" | "PieChart", "props": { ... } }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* While the agent streams, the content is often a partial/invalid JSON
|
||||
* string — we fall back to the default CopilotChatAssistantMessage which
|
||||
* shows the raw streaming text. Once the content parses AND every
|
||||
* referenced element type is in the catalog, we swap to the json-render
|
||||
* Renderer + our catalog-backed registry (see `registry.tsx`).
|
||||
*
|
||||
* Streaming-model decision (R2 in the spec): `@json-render/core`'s
|
||||
* SpecStream compiler consumes JSONL patches, but our agent emits a
|
||||
* single JSON object, not patches. We buffer until the content is valid
|
||||
* JSON, then render — losing progressive in-JSON rendering but gaining
|
||||
* correct behaviour against the agent's actual output shape.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { CopilotChatAssistantMessage } from "@copilotkit/react-core/v2";
|
||||
import type { CopilotChatAssistantMessageProps } from "@copilotkit/react-core/v2";
|
||||
import { JSONUIProvider, Renderer } from "@json-render/react";
|
||||
import { registry } from "./registry";
|
||||
import type { JsonRenderSpec } from "./types";
|
||||
|
||||
// Allowed component types per the catalog (see ./catalog.ts). Kept in sync
|
||||
// manually rather than derived from the catalog object, because the agent
|
||||
// output may contain stray tokens while streaming and we want a defensive
|
||||
// allowlist here too.
|
||||
const ALLOWED_TYPES = new Set(["MetricCard", "BarChart", "PieChart"]);
|
||||
|
||||
export function JsonRenderAssistantMessage(
|
||||
props: CopilotChatAssistantMessageProps,
|
||||
) {
|
||||
const content =
|
||||
typeof props.message.content === "string" ? props.message.content : "";
|
||||
|
||||
const parseResult = useMemo(() => parseSpec(content), [content]);
|
||||
|
||||
// Still streaming or not valid spec yet — fall through to the default
|
||||
// assistant-message chrome (renders the raw text via Streamdown). This
|
||||
// keeps the bubble visually consistent during streaming, and if the
|
||||
// agent replied with plain text (e.g. an unprompted free-form answer)
|
||||
// we still render it sensibly.
|
||||
if (!parseResult.ok) {
|
||||
return <CopilotChatAssistantMessage {...props} />;
|
||||
}
|
||||
|
||||
// Valid spec — render via json-render. `<Renderer />` alone does not
|
||||
// set up the StateProvider / VisibilityProvider / ActionProvider /
|
||||
// ValidationProvider contexts its `ElementRenderer` requires (it would
|
||||
// crash with `useVisibility must be used within a VisibilityProvider`).
|
||||
// `JSONUIProvider` wires all four in one; we don't use actions or
|
||||
// state here, so defaults are fine.
|
||||
return (
|
||||
<div data-testid="json-render-root" className="w-full">
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer
|
||||
spec={
|
||||
parseResult.spec as unknown as Parameters<
|
||||
typeof Renderer
|
||||
>[0]["spec"]
|
||||
}
|
||||
registry={registry}
|
||||
/>
|
||||
</JSONUIProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ParseOk {
|
||||
ok: true;
|
||||
spec: JsonRenderSpec;
|
||||
}
|
||||
|
||||
interface ParseFail {
|
||||
ok: false;
|
||||
reason: "empty" | "invalid-json" | "wrong-shape" | "unknown-type";
|
||||
}
|
||||
|
||||
type ParseResult = ParseOk | ParseFail;
|
||||
|
||||
/**
|
||||
* Parse the assistant message content into a json-render spec.
|
||||
*
|
||||
* Tolerates:
|
||||
* - code-fenced JSON (```json ... ```)
|
||||
* - leading/trailing prose around the JSON object
|
||||
* - partial streams (returns `invalid-json` silently)
|
||||
*/
|
||||
function parseSpec(raw: string): ParseResult {
|
||||
if (!raw || !raw.trim()) return { ok: false, reason: "empty" };
|
||||
|
||||
const jsonText = extractJsonObject(raw);
|
||||
if (!jsonText) return { ok: false, reason: "invalid-json" };
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(jsonText);
|
||||
} catch {
|
||||
return { ok: false, reason: "invalid-json" };
|
||||
}
|
||||
|
||||
if (!isRecord(parsed)) return { ok: false, reason: "wrong-shape" };
|
||||
const root = (parsed as { root?: unknown }).root;
|
||||
const elements = (parsed as { elements?: unknown }).elements;
|
||||
if (typeof root !== "string" || !isRecord(elements)) {
|
||||
return { ok: false, reason: "wrong-shape" };
|
||||
}
|
||||
if (!(root in (elements as Record<string, unknown>))) {
|
||||
return { ok: false, reason: "wrong-shape" };
|
||||
}
|
||||
|
||||
for (const [, el] of Object.entries(elements as Record<string, unknown>)) {
|
||||
if (!isRecord(el)) return { ok: false, reason: "wrong-shape" };
|
||||
const type = (el as { type?: unknown }).type;
|
||||
if (typeof type !== "string" || !ALLOWED_TYPES.has(type)) {
|
||||
return { ok: false, reason: "unknown-type" };
|
||||
}
|
||||
const elProps = (el as { props?: unknown }).props;
|
||||
if (elProps !== undefined && !isRecord(elProps)) {
|
||||
return { ok: false, reason: "wrong-shape" };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, spec: parsed as unknown as JsonRenderSpec };
|
||||
}
|
||||
|
||||
/** Strip code fences and find the first balanced JSON object. */
|
||||
function extractJsonObject(raw: string): string | null {
|
||||
const fenceMatch = /```(?:json)?\s*([\s\S]*?)```/i.exec(raw);
|
||||
const candidate = (fenceMatch ? fenceMatch[1] : raw).trim();
|
||||
if (!candidate) return null;
|
||||
|
||||
const start = candidate.indexOf("{");
|
||||
if (start === -1) return null;
|
||||
|
||||
// Walk balanced braces, respecting strings and escapes.
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
for (let i = start; i < candidate.length; i++) {
|
||||
const ch = candidate[i];
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (inString) {
|
||||
if (ch === "\\") {
|
||||
escape = true;
|
||||
} else if (ch === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "{") depth++;
|
||||
else if (ch === "}") {
|
||||
depth--;
|
||||
if (depth === 0) return candidate.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import type { MetricCardTrendDirection } from "./types";
|
||||
|
||||
export interface MetricCardComponentProps {
|
||||
label: string;
|
||||
value: string;
|
||||
/** Optional trend copy, e.g. "+12% vs last quarter". */
|
||||
trend?: string | null;
|
||||
/** Optional trend direction hint, drives the colored badge. */
|
||||
trendDirection?: MetricCardTrendDirection | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentational metric card used by the json-render catalog.
|
||||
*
|
||||
* The component is intentionally self-contained — the catalog shape
|
||||
* (`{ label, value, trend }`) mirrors Wave 4a's hashbrown MetricCard
|
||||
* so the two BYOC demos are directly comparable.
|
||||
*/
|
||||
export function MetricCard({
|
||||
label,
|
||||
value,
|
||||
trend,
|
||||
trendDirection,
|
||||
}: MetricCardComponentProps) {
|
||||
const resolvedDirection =
|
||||
trendDirection ?? inferTrendDirection(trend ?? null);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="metric-card"
|
||||
className="max-w-xs mx-auto my-3 rounded-lg border border-[var(--border)] bg-[var(--card)] p-5"
|
||||
>
|
||||
<div className="text-xs font-medium uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 text-3xl font-semibold tabular-nums text-[var(--foreground)]">
|
||||
{value}
|
||||
</div>
|
||||
{trend ? (
|
||||
<div
|
||||
className={`mt-2 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass(
|
||||
resolvedDirection,
|
||||
)}`}
|
||||
>
|
||||
{trend}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function inferTrendDirection(trend: string | null): MetricCardTrendDirection {
|
||||
if (!trend) return "neutral";
|
||||
const normalized = trend.trim();
|
||||
if (normalized.startsWith("+")) return "up";
|
||||
if (normalized.startsWith("-") || normalized.startsWith("−")) {
|
||||
return "down";
|
||||
}
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function badgeClass(direction: MetricCardTrendDirection): string {
|
||||
switch (direction) {
|
||||
case "up":
|
||||
return "bg-[color-mix(in_oklab,var(--primary)_15%,transparent)] text-[var(--primary)]";
|
||||
case "down":
|
||||
return "bg-[color-mix(in_oklab,var(--destructive)_15%,transparent)] text-[var(--destructive)]";
|
||||
case "neutral":
|
||||
default:
|
||||
return "bg-[var(--secondary)] text-[var(--muted-foreground)]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* BYOC json-render demo.
|
||||
*
|
||||
* Scenario: user asks for a sales-dashboard-style UI; the LangGraph agent
|
||||
* emits a JSON spec shaped like `{ root, elements }`, and `@json-render/react`
|
||||
* renders it against a Zod-validated catalog of three components
|
||||
* (MetricCard, BarChart, PieChart).
|
||||
*
|
||||
* Structurally mirrors Wave 4a's hashbrown demo so the two dashboard rows
|
||||
* are directly comparable — the only substantive difference is the message
|
||||
* renderer (this file swaps in `JsonRenderAssistantMessage`).
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
CopilotKitProvider,
|
||||
CopilotChat,
|
||||
CopilotChatAssistantMessage,
|
||||
useConfigureSuggestions,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
import { JsonRenderAssistantMessage } from "./json-render-renderer";
|
||||
import { BYOC_JSON_RENDER_SUGGESTIONS } from "./suggestions";
|
||||
|
||||
export default function ByocJsonRenderDemo() {
|
||||
return (
|
||||
<CopilotKitProvider
|
||||
runtimeUrl="/api/copilotkit-byoc-json-render"
|
||||
useSingleEndpoint
|
||||
>
|
||||
<div className="flex justify-center items-center h-screen w-full">
|
||||
<div className="h-full w-full max-w-4xl">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKitProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Chat() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: BYOC_JSON_RENDER_SUGGESTIONS.map((s) => ({
|
||||
title: s.label,
|
||||
message: s.prompt,
|
||||
})),
|
||||
available: "always",
|
||||
});
|
||||
|
||||
// `messageView.assistantMessage` replaces CopilotChat's default assistant
|
||||
// bubble. The cast mirrors the pattern used in `demos/chat-slots/page.tsx`
|
||||
// — the slot's prop shape is identical to `CopilotChatAssistantMessage`'s,
|
||||
// but TypeScript can't prove that through the WithSlots indirection.
|
||||
const messageView = {
|
||||
assistantMessage:
|
||||
JsonRenderAssistantMessage as unknown as typeof CopilotChatAssistantMessage,
|
||||
};
|
||||
|
||||
return (
|
||||
<CopilotChat className="h-full rounded-2xl" messageView={messageView} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Bridges the byoc-json-render catalog to concrete React components.
|
||||
*
|
||||
* `defineRegistry` returns a `registry` object that `@json-render/react`'s
|
||||
* `<Renderer />` consumes. Each entry is a component function receiving
|
||||
* `{ props, children }` — props are already validated + typed by the Zod
|
||||
* schemas declared in `./catalog.ts`.
|
||||
*
|
||||
* Factored out of `json-render-renderer.tsx` so the registry is built
|
||||
* exactly once at module scope (`defineRegistry` snapshots the catalog
|
||||
* reference; rebuilding it per render would churn `<Renderer />`'s
|
||||
* internal memoization unnecessarily).
|
||||
*/
|
||||
|
||||
import { defineRegistry } from "@json-render/react";
|
||||
import { catalog } from "./catalog";
|
||||
import { MetricCard } from "./metric-card";
|
||||
import { BarChart } from "./charts/bar-chart";
|
||||
import { PieChart } from "./charts/pie-chart";
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
// The agent's system prompt includes a worked example where a MetricCard
|
||||
// is the root of a sales dashboard with a BarChart nested in its
|
||||
// `children` array. Forward `children` through so that multi-component
|
||||
// dashboards render as one wrapped block rather than dropping the chart.
|
||||
MetricCard: ({ props, children }) => (
|
||||
<div className="flex w-full flex-col items-stretch gap-3">
|
||||
<MetricCard
|
||||
label={props.label}
|
||||
value={props.value}
|
||||
trend={props.trend}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
BarChart: ({ props }) => (
|
||||
<BarChart
|
||||
title={props.title}
|
||||
description={props.description}
|
||||
data={props.data}
|
||||
/>
|
||||
),
|
||||
PieChart: ({ props }) => (
|
||||
<PieChart
|
||||
title={props.title}
|
||||
description={props.description}
|
||||
data={props.data}
|
||||
/>
|
||||
),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Suggestion prompts for the byoc-json-render demo.
|
||||
*
|
||||
* These match Wave 4a's (hashbrown) prompts verbatim so the two demos
|
||||
* can be compared side-by-side in the dashboard.
|
||||
*/
|
||||
|
||||
export interface Suggestion {
|
||||
/** Short label shown on the pill. */
|
||||
label: string;
|
||||
/** Full prompt text sent to the agent when the pill is clicked. */
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export const BYOC_JSON_RENDER_SUGGESTIONS: Suggestion[] = [
|
||||
{
|
||||
label: "Sales dashboard",
|
||||
prompt: "Show me the sales dashboard with metrics and a revenue chart",
|
||||
},
|
||||
{
|
||||
label: "Revenue by category",
|
||||
prompt: "Break down revenue by category as a pie chart",
|
||||
},
|
||||
{
|
||||
label: "Expense trend",
|
||||
prompt: "Show me monthly expenses as a bar chart",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Shared types for the byoc-json-render demo catalog.
|
||||
*
|
||||
* The catalog mirrors Wave 4a's (hashbrown) dashboard shapes so the two
|
||||
* BYOC demos are directly comparable; the difference is purely in the
|
||||
* rendering technology.
|
||||
*/
|
||||
|
||||
export type MetricCardTrendDirection = "up" | "down" | "neutral";
|
||||
|
||||
/**
|
||||
* Shape of the streaming JSON spec emitted by the agent. Matches
|
||||
* `@json-render/react`'s Spec contract: a flat `elements` map keyed by
|
||||
* id, with one designated `root` id.
|
||||
*/
|
||||
export interface JsonRenderElement {
|
||||
type: string;
|
||||
props: Record<string, unknown>;
|
||||
children?: string[];
|
||||
}
|
||||
|
||||
export interface JsonRenderSpec {
|
||||
root: string;
|
||||
elements: Record<string, JsonRenderElement>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
// Chat Customization (CSS) — all theming lives in theme.css, scoped to the
|
||||
// `.chat-css-demo-scope` wrapper. The page stays intentionally minimal;
|
||||
// only <CopilotChat /> is visibly re-themed.
|
||||
//
|
||||
// https://docs.copilotkit.ai/custom-look-and-feel/customize-built-in-ui-components
|
||||
|
||||
import React from "react";
|
||||
import { CopilotKitProvider, CopilotChat } from "@copilotkit/react-core/v2";
|
||||
// @region[theme-css-import]
|
||||
import "./theme.css";
|
||||
// @endregion[theme-css-import]
|
||||
|
||||
export default function ChatCustomizationCssDemo() {
|
||||
return (
|
||||
<CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
|
||||
<div className="flex justify-center items-center h-screen w-full">
|
||||
<div className="chat-css-demo-scope h-full w-full max-w-4xl">
|
||||
<CopilotChat agentId="default" className="h-full rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKitProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/* Scoped theme for the chat-customization-css demo.
|
||||
* All selectors are prefixed with `.chat-css-demo-scope` so they do not
|
||||
* leak out and affect the rest of the showcase app.
|
||||
*
|
||||
* Targets the CopilotKit built-in classes documented at
|
||||
* https://docs.copilotkit.ai/custom-look-and-feel/customize-built-in-ui-components
|
||||
*/
|
||||
|
||||
/* @region[css-variables] */
|
||||
/* CopilotKit CSS variable overrides (accent colors, etc.) */
|
||||
.chat-css-demo-scope {
|
||||
--copilot-kit-primary-color: #ff006e;
|
||||
--copilot-kit-contrast-color: #ffffff;
|
||||
--copilot-kit-background-color: #fff8f0;
|
||||
--copilot-kit-input-background-color: #fef3c7;
|
||||
--copilot-kit-secondary-color: #fde047;
|
||||
--copilot-kit-secondary-contrast-color: #2c1810;
|
||||
--copilot-kit-separator-color: #ff006e;
|
||||
--copilot-kit-muted-color: #c2185b;
|
||||
}
|
||||
/* @endregion[css-variables] */
|
||||
|
||||
/* Messages container – swap the font for the entire chat */
|
||||
.chat-css-demo-scope .copilotKitMessages {
|
||||
font-family: "Georgia", "Cambria", "Times New Roman", serif;
|
||||
background-color: #fff8f0;
|
||||
color: #2c1810;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* User message bubble – hot pink, bold white serif text, large */
|
||||
.chat-css-demo-scope .copilotKitMessage.copilotKitUserMessage {
|
||||
background: linear-gradient(135deg, #ff006e 0%, #c2185b 100%);
|
||||
color: #ffffff;
|
||||
font-family: "Georgia", "Cambria", "Times New Roman", serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
padding: 14px 20px;
|
||||
border-radius: 22px 22px 4px 22px;
|
||||
box-shadow: 0 6px 16px rgba(255, 0, 110, 0.35);
|
||||
border: 2px solid #ff6fa5;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Assistant message bubble – amber, monospace, boxy, dark text */
|
||||
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage {
|
||||
background: #fde047;
|
||||
color: #1e1b4b;
|
||||
font-family:
|
||||
"JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 1rem;
|
||||
padding: 16px 20px;
|
||||
border-radius: 4px 22px 22px 22px;
|
||||
border: 2px solid #1e1b4b;
|
||||
box-shadow: 4px 4px 0 #1e1b4b;
|
||||
max-width: 80%;
|
||||
margin-right: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Make markdown inside assistant bubble inherit our theme */
|
||||
.chat-css-demo-scope
|
||||
.copilotKitMessage.copilotKitAssistantMessage
|
||||
.copilotKitMarkdown,
|
||||
.chat-css-demo-scope
|
||||
.copilotKitMessage.copilotKitAssistantMessage
|
||||
.copilotKitMarkdown
|
||||
p,
|
||||
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage p {
|
||||
color: #1e1b4b;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* Input area – themed border and font */
|
||||
.chat-css-demo-scope .copilotKitInput {
|
||||
font-family: "Georgia", "Cambria", "Times New Roman", serif;
|
||||
background-color: #fef3c7;
|
||||
border: 3px dashed #ff006e;
|
||||
border-radius: 18px;
|
||||
padding: 16px 18px;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope .copilotKitInput > textarea {
|
||||
font-family: "Georgia", "Cambria", "Times New Roman", serif;
|
||||
font-size: 1.1rem;
|
||||
color: #2c1810;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope .copilotKitInput > textarea::placeholder {
|
||||
color: #c2185b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope .copilotKitInputContainer {
|
||||
background: #fff8f0;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope .copilotKitChat {
|
||||
background-color: #fff8f0;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { CopilotChatAssistantMessage } from "@copilotkit/react-core/v2";
|
||||
import type { CopilotChatAssistantMessageProps } from "@copilotkit/react-core/v2";
|
||||
|
||||
// Custom assistantMessage sub-slot of messageView — wraps the default assistant
|
||||
// message in a visibly tinted card with a corner "slot" badge, proving the slot
|
||||
// override is active during the in-chat message flow (not just the welcome screen).
|
||||
export function CustomAssistantMessage(
|
||||
props: CopilotChatAssistantMessageProps,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
data-testid="custom-assistant-message"
|
||||
className="relative rounded-xl border border-indigo-200 bg-indigo-50/60 dark:bg-indigo-950/40 dark:border-indigo-800 p-3 my-3"
|
||||
>
|
||||
<span className="absolute -top-2 -left-2 inline-block rounded-full bg-indigo-600 text-white text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 shadow">
|
||||
slot
|
||||
</span>
|
||||
<CopilotChatAssistantMessage {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
// Custom disclaimer sub-slot of the input — visibly tagged so reviewers can
|
||||
// tell the slot is in use even once the welcome screen is dismissed.
|
||||
export function CustomDisclaimer(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
data-testid="custom-disclaimer"
|
||||
className="text-xs text-center text-muted-foreground py-2"
|
||||
>
|
||||
<span className="inline-block rounded bg-indigo-100 text-indigo-700 px-2 py-0.5 mr-2 font-semibold">
|
||||
slot
|
||||
</span>
|
||||
Custom disclaimer injected via <code>input.disclaimer</code>.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
// Custom welcomeScreen slot — a visibly distinct gradient card wrapping the
|
||||
// default input + suggestions props passed in by CopilotChatView.
|
||||
export function CustomWelcomeScreen({
|
||||
input,
|
||||
suggestionView,
|
||||
}: {
|
||||
input: React.ReactElement;
|
||||
suggestionView: React.ReactElement;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid="custom-welcome-screen"
|
||||
className="flex-1 flex flex-col items-center justify-center px-4"
|
||||
>
|
||||
<div className="w-full max-w-3xl flex flex-col items-center">
|
||||
<div
|
||||
data-testid="custom-welcome-message"
|
||||
className="mb-6 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 p-6 text-white shadow-lg text-center"
|
||||
>
|
||||
<div className="inline-block rounded-full bg-white/20 px-3 py-1 text-xs font-semibold uppercase tracking-wider mb-3">
|
||||
Custom Slot
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Welcome to the Slots demo</h1>
|
||||
<p className="mt-2 text-sm text-white/90">
|
||||
This welcome card is rendered via the{" "}
|
||||
<code className="font-mono">welcomeScreen</code> slot.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full">{input}</div>
|
||||
<div className="mt-4 flex justify-center">{suggestionView}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
// @region[register-assistant-message-slot]
|
||||
// @region[register-disclaimer-slot]
|
||||
// @region[register-welcome-slot]
|
||||
import React from "react";
|
||||
import {
|
||||
CopilotKitProvider,
|
||||
CopilotChat,
|
||||
CopilotChatAssistantMessage,
|
||||
useConfigureSuggestions,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
import { CustomWelcomeScreen } from "./custom-welcome-screen";
|
||||
import { CustomAssistantMessage } from "./custom-assistant-message";
|
||||
import { CustomDisclaimer } from "./custom-disclaimer";
|
||||
|
||||
// Outer layer — provider + layout chrome.
|
||||
export default function ChatSlotsDemo() {
|
||||
return (
|
||||
<CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
|
||||
<div className="flex justify-center items-center h-screen w-full">
|
||||
<div className="h-full w-full max-w-4xl">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKitProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// The actual view — just the chat, with two slot overrides.
|
||||
function Chat() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{ title: "Write a sonnet", message: "Write a short sonnet about AI." },
|
||||
{ title: "Tell me a joke", message: "Tell me a short joke." },
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
|
||||
// Each slot is wired in as a prop on <CopilotChat>. Extracting the
|
||||
// overrides up here keeps the JSX readable and gives the docs something
|
||||
// to point at with `@region` markers for the slot system guide.
|
||||
const welcomeScreen = CustomWelcomeScreen;
|
||||
// @endregion[register-welcome-slot]
|
||||
const input = { disclaimer: CustomDisclaimer };
|
||||
// @endregion[register-disclaimer-slot]
|
||||
const messageView = {
|
||||
assistantMessage:
|
||||
CustomAssistantMessage as unknown as typeof CopilotChatAssistantMessage,
|
||||
};
|
||||
// @endregion[register-assistant-message-slot]
|
||||
|
||||
return (
|
||||
<CopilotChat
|
||||
agentId="default"
|
||||
className="h-full rounded-2xl"
|
||||
welcomeScreen={welcomeScreen}
|
||||
input={input}
|
||||
messageView={messageView}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
||||
|
||||
// The chat-slots cell is wired to the neutral `sample_agent` graph
|
||||
// (plain ChatOpenAI, no Responses API, no reasoning config), so it never
|
||||
// emits AG-UI REASONING_MESSAGE_* events — the `messageView.reasoningMessage`
|
||||
// slot is wrapped for the slot-atlas demo but stays dormant here. A
|
||||
// "Show reasoning" pill therefore can't light it up; that demo lives at
|
||||
// /demos/reasoning-default and /demos/reasoning-custom instead.
|
||||
export function useChatSlotsSuggestions() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{ title: "Write a sonnet", message: "Write a short sonnet about AI." },
|
||||
{ title: "Tell me a joke", message: "Tell me a short joke." },
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Declarative Generative UI — A2UI Dynamic Schema (Built-in Agent)
|
||||
|
||||
Declarative Generative UI where the agent itself designs the component tree
|
||||
at runtime. Unlike the fixed-schema variant, the LLM emits an arbitrary
|
||||
A2UI v0.9 schema from the registered catalog every turn.
|
||||
|
||||
## Pattern
|
||||
|
||||
- **Frontend** registers a custom catalog (`Card`, `StatusBadge`, `Metric`,
|
||||
`InfoRow`, `PrimaryButton`, `PieChart`, `BarChart`) merged with the basic
|
||||
A2UI catalog. See `./a2ui/{catalog,definitions,renderers}.{ts,tsx}`.
|
||||
- **Backend** (`src/lib/factory/a2ui-factory.ts`) owns a `generate_a2ui`
|
||||
tool. When the primary LLM calls it, the tool fires a _secondary_ LLM
|
||||
call (forced JSON-object output) that designs the surface tree and data
|
||||
using the registered catalog. The tool wraps the result in an
|
||||
`a2ui_operations` container.
|
||||
- **Runtime** (`src/app/api/copilotkit-declarative-gen-ui/route.ts`) enables
|
||||
the A2UI middleware with `injectA2UITool: false` — the agent already owns
|
||||
the tool slot. The middleware still serialises the catalog into the
|
||||
agent's `input.context` (which the factory pipes into the secondary
|
||||
LLM's system prompt) and detects `a2ui_operations` in tool results.
|
||||
|
||||
## Why a secondary LLM call?
|
||||
|
||||
The primary LLM is small (gpt-4o-mini-class), conversational, and steered
|
||||
to call `generate_a2ui` whenever a UI would help. A separate, structured
|
||||
JSON call (gpt-4o, `response_format: json_object`) keeps the schema
|
||||
generation deterministic and the chat reply short.
|
||||
|
||||
## Try it
|
||||
|
||||
```text
|
||||
Show me a quick KPI dashboard with revenue, signups, and churn.
|
||||
```
|
||||
|
||||
```text
|
||||
Pie chart of sales by region.
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
- Docs: https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui
|
||||
- Source-of-truth: `showcase/integrations/langgraph-typescript/src/agent/a2ui-dynamic.ts`
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* A2UI catalog DECLARATION.
|
||||
*
|
||||
* Wires `myDefinitions` (component schemas) × `myRenderers` (React
|
||||
* implementations) into a Catalog the provider consumes via
|
||||
* `a2ui={{ catalog: myCatalog }}`. `includeBasicCatalog: true` merges
|
||||
* CopilotKit's built-in A2UI primitives (Column, Row, Text, Image,
|
||||
* Card, Button, List, Tabs, …) so the agent can compose custom + basic
|
||||
* components interchangeably.
|
||||
*
|
||||
* Reference:
|
||||
* https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui
|
||||
*/
|
||||
// @region[create-catalog]
|
||||
import { createCatalog } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
import { myDefinitions } from "./definitions";
|
||||
import { myRenderers } from "./renderers";
|
||||
|
||||
export const myCatalog = createCatalog(myDefinitions, myRenderers, {
|
||||
catalogId: "declarative-gen-ui-catalog",
|
||||
includeBasicCatalog: true,
|
||||
});
|
||||
// @endregion[create-catalog]
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* A2UI catalog DEFINITIONS — platform-agnostic.
|
||||
*
|
||||
* Each entry declares a custom component name + Zod props schema + a short
|
||||
* description. The runtime's A2UI middleware serialises this schema into
|
||||
* the agent's `copilotkit.context` at request time, so the LLM knows which
|
||||
* components it may emit and what each prop expects.
|
||||
*
|
||||
* The React implementations live next to these definitions in
|
||||
* `./renderers.tsx`, where they are wired through `createCatalog(...)` with
|
||||
* `includeBasicCatalog: true` so the built-in A2UI primitives (Text, Row,
|
||||
* Column, Image, Card, Button, …) come along for free.
|
||||
*/
|
||||
// @region[definitions-zod]
|
||||
// ZOD VERSION: stays on root zod@4 (NOT the `zod-v3` alias) because this catalog
|
||||
// declares NO {path} dynamic bindings — only inline literals — so @a2ui/web_core's
|
||||
// Zod-3 schema scraper never needs to classify a binding here. If a path-bound prop
|
||||
// is ever added, switch to the `zod-v3` alias like sibling a2ui-fixed-schema/a2ui/
|
||||
// definitions.ts (whose ZOD VERSION comment explains the React #31 crash otherwise).
|
||||
import { z } from "zod";
|
||||
import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
export const myDefinitions = {
|
||||
// Override the basic catalog's Row/Column so `gap` is honoured — the
|
||||
// built-in versions ignore it, which makes composed dashboards cramped.
|
||||
Row: {
|
||||
description:
|
||||
"Horizontal layout container. Children share the width evenly. Use `gap` (px) to space dashboard tiles.",
|
||||
props: z.object({
|
||||
gap: z.number().optional(),
|
||||
// Enum mirrors the keys the renderer actually maps to CSS. Anything
|
||||
// outside this set silently falls back at render time, so we reject
|
||||
// it at schema-parse time to surface LLM typos early.
|
||||
align: z
|
||||
.enum(["start", "center", "end", "stretch", "baseline"])
|
||||
.optional(),
|
||||
justify: z.enum(["start", "center", "end", "spaceBetween"]).optional(),
|
||||
children: z.array(z.string()),
|
||||
}),
|
||||
},
|
||||
|
||||
Column: {
|
||||
description:
|
||||
"Vertical layout container. Use `gap` (px) to space stacked sections.",
|
||||
props: z.object({
|
||||
gap: z.number().optional(),
|
||||
align: z
|
||||
.enum(["start", "center", "end", "stretch", "baseline"])
|
||||
.optional(),
|
||||
children: z.array(z.string()),
|
||||
}),
|
||||
},
|
||||
|
||||
// Override the basic catalog's Text so it aligns flush with sibling
|
||||
// components (the built-in version carries an 8px outer margin).
|
||||
Text: {
|
||||
description: "A plain text line. Use for short explanations inside cards.",
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
},
|
||||
|
||||
Card: {
|
||||
description:
|
||||
"A titled card container with an optional subtitle and a single child slot. Use it to group related content (metrics, rows, buttons).",
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
child: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
StatusBadge: {
|
||||
description:
|
||||
"A small coloured pill communicating the state of something (healthy/degraded/down, online/offline, open/closed). Choose `variant` to match the intent.",
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z.enum(["success", "warning", "error", "info"]).optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Metric: {
|
||||
description:
|
||||
"A key/value KPI tile with an optional trend indicator and trend delta. Ideal for dashboard KPI rows (e.g. 'Revenue • $4.2M • up 12%').",
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
trend: z.enum(["up", "down", "neutral"]).optional(),
|
||||
trendValue: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
InfoRow: {
|
||||
description:
|
||||
"A compact two-column 'label: value' row. Good for stacks of facts inside a Card (owner, region, ARR, renewal date, etc.).",
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
},
|
||||
|
||||
DataTable: {
|
||||
description:
|
||||
"A data table with column headers and rows. Ideal for rankings and per-person/per-item breakdowns (rep performance vs quota, deal lists). Each row's keys MUST appear in `columns[].key`; unknown row keys render as blank cells and indicate model/schema drift.",
|
||||
props: z.object({
|
||||
columns: z.array(z.object({ key: z.string(), label: z.string() })),
|
||||
// Cells may be strings or numbers — the renderer stringifies at
|
||||
// render time, but accepting both lets the LLM emit raw numerics
|
||||
// (e.g. attainment 124) instead of being forced to stringify.
|
||||
rows: z.array(z.record(z.string(), z.union([z.string(), z.number()]))),
|
||||
}),
|
||||
},
|
||||
|
||||
PrimaryButton: {
|
||||
description:
|
||||
"A styled primary call-to-action button. Attach an optional `action` that will be dispatched back to the agent when the user clicks it.",
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
action: z.any().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
PieChart: {
|
||||
description:
|
||||
"A pie/donut chart with a brand-coloured legend. Provide `title`, `description`, and `data` as an array of `{ label, value }` objects. Great for part-of-whole breakdowns (sales by region, traffic sources, portfolio allocation).",
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
|
||||
BarChart: {
|
||||
description:
|
||||
"A vertical bar chart built on Recharts. Provide `title`, `description`, and `data` as an array of `{ label, value }` objects. Great for comparing series across categories (quarterly revenue, headcount by team, signups per month).",
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
} satisfies CatalogDefinitions;
|
||||
// @endregion[definitions-zod]
|
||||
|
||||
export type MyDefinitions = typeof myDefinitions;
|
||||
+668
@@ -0,0 +1,668 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* A2UI catalog RENDERERS.
|
||||
*
|
||||
* React implementations for each definition in `./definitions.ts`.
|
||||
* The assembled catalog (definitions × renderers via `createCatalog`)
|
||||
* lives in `./catalog.ts`.
|
||||
*
|
||||
* Reference:
|
||||
* https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui
|
||||
*/
|
||||
import React, { useRef } from "react";
|
||||
import {
|
||||
BarChart as RechartsBarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Rectangle,
|
||||
} from "recharts";
|
||||
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
import type { MyDefinitions } from "./definitions";
|
||||
|
||||
// ─── Brand chart palette (verbatim from beautiful-chat/charts/config.ts) ────
|
||||
// CopilotKit brand tokens — Plus Jakarta Sans / brand colour system.
|
||||
const CHART_COLORS = [
|
||||
"#BEC2FF", // lilac-400
|
||||
"#85ECCE", // mint-400
|
||||
"#FFAC4D", // orange-400
|
||||
"#FFF388", // yellow-400
|
||||
"#189370", // mint-800
|
||||
"#EEE6FE", // primary-100
|
||||
"#FA5F67", // red-400
|
||||
] as const;
|
||||
|
||||
const CHART_TOOLTIP_STYLE: React.CSSProperties = {
|
||||
backgroundColor: "white",
|
||||
border: "1px solid #DBDBE5",
|
||||
borderRadius: 10,
|
||||
padding: "10px 14px",
|
||||
color: "#010507",
|
||||
fontSize: 13,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
|
||||
};
|
||||
|
||||
/** Custom SVG donut chart built with <circle> + stroke-dasharray. */
|
||||
function DonutChart({
|
||||
data,
|
||||
size = 240,
|
||||
strokeWidth = 40,
|
||||
}: {
|
||||
data: { label: string; value: number }[];
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const center = size / 2;
|
||||
|
||||
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
|
||||
|
||||
let accumulated = 0;
|
||||
const slices = data.map((item, index) => {
|
||||
const val = Number(item.value) || 0;
|
||||
const ratio = total > 0 ? val / total : 0;
|
||||
const arc = ratio * circumference;
|
||||
const startAt = accumulated;
|
||||
accumulated += arc;
|
||||
return {
|
||||
...item,
|
||||
arc,
|
||||
gap: circumference - arc,
|
||||
dashoffset: -startAt,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="100%"
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
style={{
|
||||
display: "block",
|
||||
margin: "0 auto",
|
||||
maxWidth: size,
|
||||
transform: "scaleX(-1)",
|
||||
}}
|
||||
>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#F4F4F7"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{slices.map((slice, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={slice.color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${slice.arc} ${slice.gap}`}
|
||||
strokeDashoffset={slice.dashoffset}
|
||||
strokeLinecap="butt"
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tracks seen indices so only NEW bars get the fade-in animation. */
|
||||
function useSeenIndices() {
|
||||
const seen = useRef(new Set<number>());
|
||||
return {
|
||||
isNew(index: number) {
|
||||
if (seen.current.has(index)) return false;
|
||||
seen.current.add(index);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function AnimatedBar(props: any) {
|
||||
const { isNew, ...rest } = props;
|
||||
return (
|
||||
<g
|
||||
style={
|
||||
isNew
|
||||
? {
|
||||
animation: "barSlideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Rectangle {...rest} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
const badgePalette: Record<
|
||||
"success" | "warning" | "error" | "info",
|
||||
{ bg: string; fg: string; border: string }
|
||||
> = {
|
||||
success: {
|
||||
bg: "rgba(133, 236, 206, 0.15)",
|
||||
fg: "#189370",
|
||||
border: "#85ECCE4D",
|
||||
},
|
||||
warning: {
|
||||
bg: "rgba(255, 172, 77, 0.12)",
|
||||
fg: "#010507",
|
||||
border: "#FFAC4D33",
|
||||
},
|
||||
error: { bg: "rgba(250, 95, 103, 0.1)", fg: "#FA5F67", border: "#FA5F6733" },
|
||||
info: { bg: "#BEC2FF1A", fg: "#010507", border: "#BEC2FF" },
|
||||
};
|
||||
|
||||
// @region[renderers-react]
|
||||
export const myRenderers: CatalogRenderers<MyDefinitions> = {
|
||||
// Gap-honouring Row/Column overrides — the basic catalog's versions ignore
|
||||
// `gap`, which makes composed dashboards cramped. Children share width
|
||||
// evenly in a Row (flex: 1 1 0) and stack in a Column.
|
||||
Row: ({ props, children }) => {
|
||||
const justifyMap: Record<string, string> = {
|
||||
start: "flex-start",
|
||||
center: "center",
|
||||
end: "flex-end",
|
||||
spaceBetween: "space-between",
|
||||
};
|
||||
const items = Array.isArray(props.children) ? props.children : [];
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: `${props.gap ?? 16}px`,
|
||||
alignItems: props.align ?? "stretch",
|
||||
justifyContent: justifyMap[props.justify ?? "start"] ?? "flex-start",
|
||||
flexWrap: "wrap",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{items.map((id, i) => (
|
||||
<div key={`${id}-${i}`} style={{ flex: "1 1 0", minWidth: 0 }}>
|
||||
{children(id)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Column: ({ props, children }) => {
|
||||
const items = Array.isArray(props.children) ? props.children : [];
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${props.gap ?? 12}px`,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{items.map((id, i) => (
|
||||
<React.Fragment key={`${id}-${i}`}>{children(id)}</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Text: ({ props }) => (
|
||||
<span style={{ fontSize: "0.85rem", color: "#010507", lineHeight: 1.5 }}>
|
||||
{props.text}
|
||||
</span>
|
||||
),
|
||||
|
||||
Card: ({ props, children }) => (
|
||||
<div
|
||||
data-testid="declarative-card"
|
||||
style={{
|
||||
border: "1px solid #DBDBE5",
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
background: "white",
|
||||
boxShadow: "0 1px 3px rgba(1, 5, 7, 0.04)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
minWidth: 260,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: "1rem", color: "#010507" }}>
|
||||
{props.title}
|
||||
</div>
|
||||
{props.subtitle && (
|
||||
<div style={{ color: "#57575B", fontSize: "0.85rem" }}>
|
||||
{props.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{props.child && children(props.child)}
|
||||
</div>
|
||||
),
|
||||
|
||||
StatusBadge: ({ props }) => {
|
||||
const variant = props.variant ?? "info";
|
||||
const { bg, fg, border } = badgePalette[variant];
|
||||
return (
|
||||
<span
|
||||
data-testid="declarative-status-badge"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 10px",
|
||||
background: bg,
|
||||
color: fg,
|
||||
border: `1px solid ${border}`,
|
||||
borderRadius: 999,
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.1em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
|
||||
Metric: ({ props }) => {
|
||||
const trend = props.trend ?? "neutral";
|
||||
const arrow = trend === "up" ? "↑" : trend === "down" ? "↓" : "";
|
||||
const color =
|
||||
trend === "up" ? "#189370" : trend === "down" ? "#FA5F67" : "#010507";
|
||||
return (
|
||||
<div
|
||||
data-testid="declarative-metric"
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
color: "#838389",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.12em",
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 600,
|
||||
color,
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<span>{props.value}</span>
|
||||
{(arrow || props.trendValue) && (
|
||||
<span style={{ fontSize: "0.85rem", fontWeight: 500 }}>
|
||||
{arrow}
|
||||
{props.trendValue
|
||||
? arrow
|
||||
? ` ${props.trendValue}`
|
||||
: props.trendValue
|
||||
: ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
InfoRow: ({ props }) => (
|
||||
<div
|
||||
data-testid="declarative-info-row"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
gap: 16,
|
||||
paddingTop: 6,
|
||||
paddingBottom: 6,
|
||||
borderBottom: "1px solid #E9E9EF",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#57575B", fontSize: "0.85rem" }}>
|
||||
{props.label}
|
||||
</span>
|
||||
<span style={{ color: "#010507", fontWeight: 500, fontSize: "0.9rem" }}>
|
||||
{props.value}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
|
||||
DataTable: ({ props }) => {
|
||||
const cols = Array.isArray(props.columns) ? props.columns : [];
|
||||
const rows = Array.isArray(props.rows) ? props.rows : [];
|
||||
return (
|
||||
<div
|
||||
data-testid="declarative-data-table"
|
||||
style={{ width: "100%", overflowX: "auto" }}
|
||||
>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{cols.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
style={{
|
||||
borderBottom: "2px solid #DBDBE5",
|
||||
padding: "8px 12px",
|
||||
textAlign: "left",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.08em",
|
||||
color: "#838389",
|
||||
}}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
// Stable row key: prefer the first column's value (primary-key-ish),
|
||||
// suffix with index in case values repeat, fall back to a JSON
|
||||
// stringify of the row when columns is empty.
|
||||
const pk = cols.length > 0 ? row[cols[0].key] : undefined;
|
||||
const rowKey = pk !== undefined ? `${pk}-${i}` : `row-${i}`;
|
||||
return (
|
||||
<tr key={rowKey} style={{ borderBottom: "1px solid #E9E9EF" }}>
|
||||
{cols.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
color: "#010507",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{String(row[col.key] ?? "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
PrimaryButton: ({ props, dispatch }) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (props.action && dispatch) dispatch(props.action);
|
||||
}}
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
borderRadius: 12,
|
||||
border: "none",
|
||||
background: "#010507",
|
||||
color: "white",
|
||||
fontWeight: 500,
|
||||
fontSize: "0.9rem",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.15s ease",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
((e.currentTarget as HTMLButtonElement).style.background = "#2B2B2B")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
((e.currentTarget as HTMLButtonElement).style.background = "#010507")
|
||||
}
|
||||
>
|
||||
{props.label}
|
||||
</button>
|
||||
),
|
||||
|
||||
PieChart: ({ props }) => {
|
||||
const data = props.data ?? [];
|
||||
const safeData = Array.isArray(data) ? data : [];
|
||||
const total = safeData.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="declarative-pie-chart"
|
||||
style={{
|
||||
border: "1px solid #DBDBE5",
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
background: "white",
|
||||
boxShadow: "0 1px 3px rgba(1, 5, 7, 0.04)",
|
||||
maxWidth: 520,
|
||||
margin: "0 auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: "1rem", color: "#010507" }}>
|
||||
{props.title}
|
||||
</div>
|
||||
<div style={{ color: "#57575B", fontSize: "0.85rem" }}>
|
||||
{props.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{safeData.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
color: "#838389",
|
||||
textAlign: "center",
|
||||
padding: "32px 0",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
No data available
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DonutChart data={safeData} />
|
||||
|
||||
{/* Legend */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
paddingTop: 8,
|
||||
}}
|
||||
>
|
||||
{safeData.map((item, index) => {
|
||||
const val = Number(item.value) || 0;
|
||||
const pct = total > 0 ? ((val / total) * 100).toFixed(0) : "0";
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
backgroundColor:
|
||||
CHART_COLORS[index % CHART_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
color: "#010507",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: "#57575B",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{val.toLocaleString()}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: "#57575B",
|
||||
width: 40,
|
||||
textAlign: "right",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
BarChart: ({ props }) => {
|
||||
const { isNew } = useSeenIndices();
|
||||
const data = props.data ?? [];
|
||||
const safeData = Array.isArray(data) ? data : [];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="declarative-bar-chart"
|
||||
style={{
|
||||
border: "1px solid #DBDBE5",
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
background: "white",
|
||||
boxShadow: "0 1px 3px rgba(1, 5, 7, 0.04)",
|
||||
maxWidth: 640,
|
||||
margin: "0 auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Scoped keyframe — no globals.css needed */}
|
||||
<style>{`
|
||||
@keyframes barSlideIn {
|
||||
from { transform: translateY(40px); opacity: 0; }
|
||||
20% { opacity: 1; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: "1rem", color: "#010507" }}>
|
||||
{props.title}
|
||||
</div>
|
||||
<div style={{ color: "#57575B", fontSize: "0.85rem" }}>
|
||||
{props.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{safeData.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
color: "#838389",
|
||||
textAlign: "center",
|
||||
padding: "32px 0",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
No data available
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<RechartsBarChart
|
||||
data={safeData}
|
||||
margin={{ top: 12, right: 12, bottom: 4, left: -8 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="#E9E9EF"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 12, fill: "#57575B" }}
|
||||
stroke="#E9E9EF"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: "#57575B" }}
|
||||
stroke="#E9E9EF"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
cursor={{ fill: "#F4F4F7", opacity: 0.5 }}
|
||||
/>
|
||||
<Bar
|
||||
isAnimationActive={false}
|
||||
dataKey="value"
|
||||
radius={[6, 6, 0, 0]}
|
||||
maxBarSize={48}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
shape={
|
||||
((barProps: any) => (
|
||||
<AnimatedBar
|
||||
{...barProps}
|
||||
isNew={isNew(barProps.index as number)}
|
||||
/>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
)) as any
|
||||
}
|
||||
>
|
||||
{safeData.map((_, index) => (
|
||||
<Cell
|
||||
key={index}
|
||||
fill={CHART_COLORS[index % CHART_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
// @endregion[renderers-react]
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Declarative Generative UI (A2UI — Dynamic Schema) demo.
|
||||
*
|
||||
* Pattern:
|
||||
* 1. Define a small set of branded React components + Zod schemas in
|
||||
* `./a2ui/definitions.ts` and `./a2ui/renderers.tsx` (the latter calls
|
||||
* `createCatalog(..., { includeBasicCatalog: true })` and exports
|
||||
* `myCatalog`).
|
||||
* 2. Pass that catalog to the provider via
|
||||
* `<CopilotKit a2ui={{ catalog: myCatalog }}>`.
|
||||
* 3. The dedicated runtime at `/api/copilotkit-declarative-gen-ui` is
|
||||
* configured with `injectA2UITool: false` — the backend factory
|
||||
* (`src/lib/factory/a2ui-factory.ts`) owns the `generate_a2ui` tool
|
||||
* explicitly. The A2UI middleware still serialises the registered
|
||||
* catalog schema into the agent's `input.context` so the secondary LLM
|
||||
* inside `generate_a2ui` knows which components are available.
|
||||
*
|
||||
* Reference:
|
||||
* https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui
|
||||
*/
|
||||
|
||||
// @region[provider-a2ui-prop]
|
||||
import React from "react";
|
||||
import {
|
||||
CopilotKit,
|
||||
CopilotChat,
|
||||
useConfigureSuggestions,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
|
||||
import { myCatalog } from "./a2ui/catalog";
|
||||
import { useSalesAnalystContext } from "./sales-context";
|
||||
|
||||
export default function DeclarativeGenUIDemo() {
|
||||
return (
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit-declarative-gen-ui"
|
||||
agent="default"
|
||||
a2ui={{ catalog: myCatalog }}
|
||||
>
|
||||
<div className="flex justify-center items-center h-screen w-full">
|
||||
<div className="h-full w-full max-w-4xl">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKit>
|
||||
// @endregion[provider-a2ui-prop]
|
||||
);
|
||||
}
|
||||
|
||||
function Chat() {
|
||||
// Grounding data + composition rules for the sales-analyst persona. Flows
|
||||
// to the secondary `generate_a2ui` planner LLM (the A2UI middleware
|
||||
// serialises frontend context entries into the catalog context the tool
|
||||
// reads), so prompts like "Show me my sales dashboard" produce grounded,
|
||||
// composed surfaces instead of empty ones.
|
||||
useSalesAnalystContext();
|
||||
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Show a KPI dashboard",
|
||||
message:
|
||||
"Show me a quick KPI dashboard with 3-4 metrics (revenue, signups, churn).",
|
||||
},
|
||||
{
|
||||
title: "Pie chart — sales by region",
|
||||
message: "Show a pie chart of sales by region.",
|
||||
},
|
||||
{
|
||||
title: "Bar chart — quarterly revenue",
|
||||
message: "Render a bar chart of quarterly revenue.",
|
||||
},
|
||||
{
|
||||
title: "Status report",
|
||||
message:
|
||||
"Give me a status report on system health — API, database, and background workers.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
|
||||
return <CopilotChat agentId="default" className="h-full rounded-2xl" />;
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { useAgentContext } from "@copilotkit/react-core/v2";
|
||||
|
||||
// Grounding data + composition rules for the sales-analyst demo persona.
|
||||
// Registered as agent context so it reaches both the primary agent (App
|
||||
// Context) and the secondary A2UI planner LLM, which serialises frontend
|
||||
// context entries into its system instruction.
|
||||
//
|
||||
// DUPLICATION NOTICE: This file is intentionally byte-duplicated across
|
||||
// 5 integrations — built-in-agent, google-adk, langgraph-python, strands,
|
||||
// and strands-typescript — per the showcase's per-integration parity
|
||||
// convention (no cross-integration imports). All 5 copies MUST be kept
|
||||
// byte-for-byte identical — verify with `diff` after any edit, and update
|
||||
// every copy in the same commit.
|
||||
//
|
||||
// TODO(OSS-136): Extract this dataset + composition rules into a shared
|
||||
// showcase module so both integrations import a single source of truth
|
||||
// instead of relying on manual byte-sync.
|
||||
const SALES_DATASET = `Vantage Threads (fictional B2B apparel company) — Q2 sales data. Ground every visual in these numbers; invent only plausible details consistent with them.
|
||||
- Quarterly revenue: $4.2M (up 12% QoQ). New customers: 186 (up 8%). Win rate: 31% (down 2pts). Avg deal size: $22.6k (up 5%).
|
||||
- Revenue by region: North America $1.9M, EMEA $1.3M, APAC $720k, LATAM $280k.
|
||||
- Monthly revenue: Jan $1.21M, Feb $1.34M, Mar $1.65M, Apr $1.38M, May $1.42M, Jun $1.40M.
|
||||
- Reps (vs quota): Dana Whitfield 124%, Marcus Lee 108%, Priya Sharma 97%, Tom Okafor 88%, Elena Vasquez 71%.
|
||||
- At-risk: total $615k ARR across 3 accounts — Northwind Retail ($340k renewal, no contact 6 weeks; severity high), Cascadia Outfitters ($180k, champion left; severity medium), Atlas Goods ($95k, stalled legal review; severity medium).
|
||||
- Biggest account: Meridian Apparel Group — owner Dana Whitfield, region North America, ARR $612k, renewal Sep 30, last contact 3 days ago, health green, 4 open opportunities worth $210k.
|
||||
- Meridian revenue by product line: Outerwear $260k, Footwear $180k, Accessories $112k, Custom $60k.`;
|
||||
|
||||
const COMPOSITION_RULES = `Pick A2UI components by the shape of the question — never ask which chart the user wants:
|
||||
1. Overall snapshot / "sales dashboard" → a Column (gap 16) whose first child is a Row (gap 16) of 4 Metric tiles (each with trend + trendValue), followed by a Row with a PieChart (revenue by region) next to a BarChart (monthly revenue, all six months Jan-Jun). Do NOT wrap the dashboard in a surrounding Card — the charts carry their own card chrome. Do NOT use StatusBadge, DataTable, or InfoRow here.
|
||||
2. Rep / team performance → a Column (gap 16) with a Card containing a DataTable (columns: rep, attainment, pipeline) next to or above a BarChart of quota attainment % per rep — no StatusBadge or InfoRow.
|
||||
3. Risk / health checks → a Column (gap 16): first a Row (gap 16) of 3 Metric tiles (ARR at risk $615k trend down, accounts at risk 3, biggest exposure Northwind $340k), then a Row (gap 16) with one compact Card per at-risk account (title = account name, subtitle = ARR at stake) containing a StatusBadge (error for high severity, warning otherwise) above a one-line Text with the reason and the recommended next action — no DataTable or InfoRow.
|
||||
4. Single account/entity details → a Row (gap 16) with a Card of InfoRow facts (owner, region, ARR, renewal date, last contact) next to a PieChart of that account's revenue by product line — no DataTable or StatusBadge.
|
||||
5. Part-of-whole follow-ups → PieChart; trends or comparisons over time/categories → BarChart.
|
||||
Compose generously — a dashboard should feel like a real analytics product, not a single widget.`;
|
||||
|
||||
export function useSalesAnalystContext() {
|
||||
useAgentContext({
|
||||
description: "Sales dataset for Vantage Threads (the demo company)",
|
||||
value: SALES_DATASET,
|
||||
});
|
||||
useAgentContext({
|
||||
description: "Dashboard composition rules for A2UI surfaces",
|
||||
value: COMPOSITION_RULES,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
||||
|
||||
export function useDeclarativeGenUISuggestions() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Show a KPI dashboard",
|
||||
message:
|
||||
"Show me a quick KPI dashboard with 3-4 metrics (revenue, signups, churn).",
|
||||
},
|
||||
{
|
||||
title: "Pie chart — sales by region",
|
||||
message: "Show a pie chart of sales by region.",
|
||||
},
|
||||
{
|
||||
title: "Bar chart — quarterly revenue",
|
||||
message: "Render a bar chart of quarterly revenue.",
|
||||
},
|
||||
{
|
||||
title: "Status report",
|
||||
message:
|
||||
"Give me a status report on system health — API, database, and background workers.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Frontend Tools (Async)
|
||||
|
||||
Same `useFrontendTool` pattern as `frontend-tools`, but the handler is
|
||||
async and awaits a simulated client-side DB round-trip (500ms). Once it
|
||||
resolves, the result is passed back to the agent and rendered through a
|
||||
branded `NotesCard`.
|
||||
|
||||
## Files
|
||||
|
||||
- `page.tsx` — registers `query_notes` (async handler + render)
|
||||
- `notes-card.tsx` — `NotesCard` component used by the per-tool render
|
||||
|
||||
## Backend
|
||||
|
||||
Reuses `createBuiltInAgent` (TanStack AI + `openaiText("gpt-4o")`) at
|
||||
`src/app/api/copilotkit/route.ts`. No backend changes — frontend tools
|
||||
live entirely in the browser; the agent only sees their schema.
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface NotesCardProps {
|
||||
loading: boolean;
|
||||
keyword: string;
|
||||
notes?: Note[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Branded card rendering the client-side "notes DB query" result.
|
||||
* Mirrors the per-tool render path used by other tool-rendering cells —
|
||||
* but the `notes` array is awaited from an async handler living entirely
|
||||
* in the browser (no backend tool involved).
|
||||
*/
|
||||
export function NotesCard({ loading, keyword, notes }: NotesCardProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="notes-card"
|
||||
className="rounded-2xl mt-4 mb-4 max-w-md w-full bg-white border border-[#DBDBE5] shadow-sm"
|
||||
>
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.14em] text-[#57575B] mb-1">
|
||||
Notes DB
|
||||
</div>
|
||||
<h3
|
||||
data-testid="notes-keyword"
|
||||
className="text-base font-semibold text-[#010507]"
|
||||
>
|
||||
Matching “{keyword}”
|
||||
</h3>
|
||||
<p className="text-[#57575B] text-xs mt-0.5">
|
||||
{loading
|
||||
? "Querying local notes DB..."
|
||||
: `${notes?.length ?? 0} match${(notes?.length ?? 0) === 1 ? "" : "es"}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-xl" aria-hidden>
|
||||
{loading ? "..." : "📓"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && notes && notes.length > 0 && (
|
||||
<ul
|
||||
data-testid="notes-list"
|
||||
className="mt-4 pt-4 border-t border-[#E9E9EF] space-y-2 text-sm"
|
||||
>
|
||||
{notes.map((n) => (
|
||||
<li
|
||||
key={n.id}
|
||||
data-testid={`note-${n.id}`}
|
||||
className="rounded-xl border border-[#E9E9EF] bg-[#FAFAFC] p-2.5"
|
||||
>
|
||||
<p className="font-medium text-[#010507]">{n.title}</p>
|
||||
<p className="text-[#57575B] text-xs mt-0.5">{n.excerpt}</p>
|
||||
{n.tags && n.tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{n.tags.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="text-[10px] font-medium uppercase tracking-[0.1em] bg-white border border-[#DBDBE5] text-[#57575B] rounded-full px-1.5 py-0.5"
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!loading && (!notes || notes.length === 0) && (
|
||||
<p className="mt-4 pt-4 border-t border-[#E9E9EF] text-sm text-[#838389] italic">
|
||||
No notes matched.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
// Frontend Tools (Async) demo.
|
||||
//
|
||||
// Same `useFrontendTool` pattern as `frontend-tools`, but the handler is
|
||||
// async and awaits a simulated client-side DB round-trip. The full async
|
||||
// path is exercised end-to-end: agent invokes -> handler awaits -> result
|
||||
// returned -> branded render.
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
CopilotKitProvider,
|
||||
CopilotChat,
|
||||
useFrontendTool,
|
||||
useConfigureSuggestions,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
import { z } from "zod";
|
||||
import { NotesCard, type Note } from "./notes-card";
|
||||
|
||||
// Fake client-side "notes database". In a real app this could be an
|
||||
// IndexedDB, a fetched local cache, or any other client-owned data store.
|
||||
const NOTES_DB: Note[] = [
|
||||
{
|
||||
id: "n1",
|
||||
title: "Q2 project planning kickoff",
|
||||
excerpt:
|
||||
"Discussed scope for the new onboarding flow with design. Draft spec due Friday.",
|
||||
tags: ["planning", "project", "onboarding"],
|
||||
},
|
||||
{
|
||||
id: "n2",
|
||||
title: "Planning: migrate auth to passkeys",
|
||||
excerpt:
|
||||
"Research WebAuthn library options. Consider fallback for unsupported browsers.",
|
||||
tags: ["planning", "auth", "security"],
|
||||
},
|
||||
{
|
||||
id: "n3",
|
||||
title: "Grocery list",
|
||||
excerpt: "Olive oil, tomatoes, sourdough, basil, parmesan.",
|
||||
tags: ["personal", "shopping"],
|
||||
},
|
||||
{
|
||||
id: "n4",
|
||||
title: "Book recommendations",
|
||||
excerpt:
|
||||
"Thinking Fast and Slow (Kahneman); The Design of Everyday Things (Norman).",
|
||||
tags: ["reading"],
|
||||
},
|
||||
{
|
||||
id: "n5",
|
||||
title: "Project planning retrospective notes",
|
||||
excerpt:
|
||||
"What went well: async standups. What didn't: ambiguous ownership on shared components.",
|
||||
tags: ["retro", "project", "planning"],
|
||||
},
|
||||
{
|
||||
id: "n6",
|
||||
title: "Weekend hike plan",
|
||||
excerpt: "Tam West Peak → Rock Spring. 8mi loop, bring layers.",
|
||||
tags: ["personal", "outdoors"],
|
||||
},
|
||||
{
|
||||
id: "n7",
|
||||
title: "1:1 prep — career planning",
|
||||
excerpt: "Discuss growth areas. Ask about scope for Q3. Revisit goals doc.",
|
||||
tags: ["career", "planning"],
|
||||
},
|
||||
];
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function parseJsonResult<T>(result: unknown): T {
|
||||
if (!result) return {} as T;
|
||||
try {
|
||||
return (typeof result === "string" ? JSON.parse(result) : result) as T;
|
||||
} catch {
|
||||
return {} as T;
|
||||
}
|
||||
}
|
||||
|
||||
export default function FrontendToolsAsyncDemo() {
|
||||
return (
|
||||
<CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
|
||||
<div className="flex justify-center items-center h-screen w-full">
|
||||
<div className="h-full w-full max-w-4xl">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKitProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Chat() {
|
||||
// @region[frontend-tool-async]
|
||||
// @region[frontend-tool-async-registration]
|
||||
useFrontendTool({
|
||||
name: "query_notes",
|
||||
description:
|
||||
"Search the user's local notes database for notes whose title, " +
|
||||
"excerpt, or tags contain the given keyword (case-insensitive). " +
|
||||
"Returns up to 5 matching notes.",
|
||||
parameters: z.object({
|
||||
keyword: z
|
||||
.string()
|
||||
.describe("Keyword or phrase to search notes for (case-insensitive)."),
|
||||
}),
|
||||
// @region[frontend-tool-async-handler]
|
||||
// Async handler: awaits a simulated client-side DB round-trip (500ms)
|
||||
// and returns the matching notes. The agent then uses the returned
|
||||
// result to summarize what it found — exercising the full async
|
||||
// frontend-tool path end-to-end.
|
||||
handler: async ({ keyword }: { keyword: string }) => {
|
||||
await sleep(500);
|
||||
const q = keyword.toLowerCase();
|
||||
const matches = NOTES_DB.filter((n) => {
|
||||
return (
|
||||
n.title.toLowerCase().includes(q) ||
|
||||
n.excerpt.toLowerCase().includes(q) ||
|
||||
(n.tags ?? []).some((t) => t.toLowerCase().includes(q))
|
||||
);
|
||||
}).slice(0, 5);
|
||||
return {
|
||||
keyword,
|
||||
count: matches.length,
|
||||
notes: matches,
|
||||
};
|
||||
},
|
||||
// @endregion[frontend-tool-async-handler]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
render: ({ args, result, status }: any) => {
|
||||
const loading = status !== "complete";
|
||||
const parsed = parseJsonResult<{
|
||||
keyword?: string;
|
||||
count?: number;
|
||||
notes?: Note[];
|
||||
}>(result);
|
||||
return (
|
||||
<NotesCard
|
||||
loading={loading}
|
||||
keyword={args?.keyword ?? parsed.keyword ?? ""}
|
||||
notes={parsed.notes}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
// @endregion[frontend-tool-async-registration]
|
||||
// @endregion[frontend-tool-async]
|
||||
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Find project-planning notes",
|
||||
message: "Find my notes about project planning.",
|
||||
},
|
||||
{
|
||||
title: "Search for 'auth'",
|
||||
message: "Search my notes for anything related to auth.",
|
||||
},
|
||||
{
|
||||
title: "What do I have about reading?",
|
||||
message: "Do I have any notes tagged reading?",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
|
||||
return <CopilotChat className="h-full rounded-2xl" />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Frontend Tools
|
||||
|
||||
Defines a tool entirely in the React tree via `useFrontendTool`. The
|
||||
agent sees the schema (forwarded over AG-UI), invokes it, and the
|
||||
handler runs in the browser to mutate page state — here, the page
|
||||
background.
|
||||
|
||||
## Files
|
||||
|
||||
- `page.tsx` — registers `change_background` and reflects the result in
|
||||
local state.
|
||||
|
||||
## Backend
|
||||
|
||||
Reuses `createBuiltInAgent` (TanStack AI + `openaiText("gpt-4o")`) at
|
||||
`src/app/api/copilotkit/route.ts`. No backend changes — frontend tools
|
||||
are entirely in-browser, advertised to the agent via the chat input
|
||||
contract.
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
// Frontend Tools demo.
|
||||
//
|
||||
// Showcases `useFrontendTool` — a tool DEFINED in the React tree,
|
||||
// EXECUTED in the browser, and INVOKED by the agent. The tool's schema
|
||||
// is forwarded over the AG-UI protocol so the agent knows it exists; the
|
||||
// tool's handler runs locally in the page on invocation. No backend
|
||||
// tool wiring required.
|
||||
|
||||
// @region[frontend-tool]
|
||||
// @region[frontend-tool-registration]
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
CopilotKitProvider,
|
||||
CopilotChat,
|
||||
useFrontendTool,
|
||||
useConfigureSuggestions,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
import { z } from "zod";
|
||||
|
||||
export default function FrontendToolsDemo() {
|
||||
return (
|
||||
<CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
|
||||
<Chat />
|
||||
</CopilotKitProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Chat() {
|
||||
const [background, setBackground] = useState<string>(
|
||||
"var(--copilot-kit-background-color)",
|
||||
);
|
||||
|
||||
useFrontendTool({
|
||||
name: "change_background",
|
||||
description:
|
||||
"Change the background color of the chat. Accepts any valid CSS background value — colors, linear or radial gradients, etc.",
|
||||
parameters: z.object({
|
||||
background: z
|
||||
.string()
|
||||
.describe("The CSS background value. Prefer gradients."),
|
||||
}),
|
||||
// @region[frontend-tool-handler]
|
||||
handler: async ({ background }: { background: string }) => {
|
||||
setBackground(background);
|
||||
return {
|
||||
status: "success",
|
||||
message: `Background changed to ${background}`,
|
||||
};
|
||||
},
|
||||
// @endregion[frontend-tool-handler]
|
||||
});
|
||||
// @endregion[frontend-tool-registration]
|
||||
// @endregion[frontend-tool]
|
||||
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Change background",
|
||||
message: "Change the background to a blue-to-purple gradient.",
|
||||
},
|
||||
{
|
||||
title: "Sunset theme",
|
||||
message: "Make the background a sunset-themed gradient.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex justify-center items-center h-screen w-full"
|
||||
data-testid="frontend-tools-background"
|
||||
data-background-value={background}
|
||||
style={{ background }}
|
||||
>
|
||||
<div className="h-full w-full max-w-4xl">
|
||||
<CopilotChat className="h-full rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
||||
|
||||
export function useFrontendToolsSuggestions() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Sunset theme",
|
||||
message: "Make the background a sunset gradient.",
|
||||
},
|
||||
{
|
||||
title: "Forest theme",
|
||||
message: "Switch to a deep green forest gradient.",
|
||||
},
|
||||
{
|
||||
title: "Cosmic theme",
|
||||
message: "Make it a navy → magenta cosmic gradient.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CopilotKitProvider,
|
||||
CopilotChat,
|
||||
useAgent,
|
||||
UseAgentUpdate,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
|
||||
type Step = {
|
||||
title: string;
|
||||
status?: "pending" | "in_progress" | "done";
|
||||
};
|
||||
|
||||
export default function GenUiAgent() {
|
||||
return (
|
||||
<CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
|
||||
<Demo />
|
||||
</CopilotKitProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Demo() {
|
||||
const { agent } = useAgent({
|
||||
agentId: "default",
|
||||
updates: [UseAgentUpdate.OnStateChanged],
|
||||
});
|
||||
|
||||
const steps = (agent.state as { steps?: Step[] }).steps ?? [];
|
||||
|
||||
return (
|
||||
<main className="p-8 grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold mb-4">Agentic Generative UI</h1>
|
||||
<p className="text-sm opacity-70 mb-4">
|
||||
The agent emits a live plan via <code>AGUISendStateDelta</code> (
|
||||
<code>{`{ op: "replace", path: "/steps", value: [...] }`}</code>
|
||||
). Each tick re-renders the panel below. Try: “Plan a 4-step
|
||||
morning routine and execute it; emit the plan to state.”
|
||||
</p>
|
||||
<StepsPanel steps={steps} />
|
||||
</div>
|
||||
<div data-testid="copilot-message-list">
|
||||
<CopilotChat />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function StepsPanel({ steps }: { steps: Step[] }) {
|
||||
if (!steps.length) {
|
||||
return (
|
||||
<div className="border rounded p-3 text-sm opacity-40 italic">
|
||||
No plan yet. The agent will fill this panel as it works.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div data-testid="agent-state-card" className="border rounded p-3">
|
||||
<div className="font-medium mb-2">Plan</div>
|
||||
<ol className="space-y-1 text-sm">
|
||||
{steps.map((s, i) => (
|
||||
<li
|
||||
key={i}
|
||||
data-testid="agent-step"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span className="w-4">
|
||||
{s.status === "done"
|
||||
? "✓"
|
||||
: s.status === "in_progress"
|
||||
? "•"
|
||||
: "○"}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
s.status === "done" ? "line-through opacity-60" : undefined
|
||||
}
|
||||
>
|
||||
{s.title}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
||||
|
||||
export function useSuggestions() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Plan a product launch",
|
||||
message: "Plan a product launch for a new mobile app.",
|
||||
},
|
||||
{
|
||||
title: "Organize a team offsite",
|
||||
message: "Organize a three-day engineering team offsite.",
|
||||
},
|
||||
{
|
||||
title: "Research a competitor",
|
||||
message:
|
||||
"Research our top competitor and summarize their strengths and weaknesses.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Gen UI Interrupt — Built-in Agent (Strategy B: Frontend Tool)
|
||||
|
||||
In-chat time-picker that blocks the agent until the user picks a slot or
|
||||
cancels. Produces the same UX as the LangGraph `interrupt()` primitive, but
|
||||
via `useFrontendTool` with an async handler.
|
||||
|
||||
The built-in agent (TanStack AI) auto-discovers the frontend-registered
|
||||
`schedule_meeting` tool and calls it as a regular tool call. The async
|
||||
handler returns a Promise that only resolves once the user picks a slot,
|
||||
blocking the agent loop until the decision is made.
|
||||
|
||||
See the `langgraph-python` integration for the native interrupt-based
|
||||
implementation.
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
// Gen UI Interrupt demo (Built-in Agent port).
|
||||
//
|
||||
// The LangGraph version of this demo uses `useInterrupt` with LangGraph's
|
||||
// native `interrupt()` primitive — the backend pauses the run and surfaces
|
||||
// a payload that the frontend renders into the chat via the `useInterrupt`
|
||||
// hook. The built-in agent (TanStack AI) does NOT have an equivalent
|
||||
// interrupt primitive, so we adapt the demo by registering a frontend tool
|
||||
// with `useFrontendTool`. The handler returns a Promise that only resolves
|
||||
// once the user picks a time (or cancels), which produces the same UX: the
|
||||
// picker appears inline in the chat and the agent's tool call blocks until
|
||||
// the user decides.
|
||||
|
||||
// @region[frontend-useinterrupt-render]
|
||||
import React, { useRef } from "react";
|
||||
import {
|
||||
CopilotKitProvider,
|
||||
CopilotChat,
|
||||
useConfigureSuggestions,
|
||||
useFrontendTool,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
import { z } from "zod";
|
||||
import { TimePickerCard, TimeSlot } from "./time-picker-card";
|
||||
|
||||
const DEFAULT_SLOTS: TimeSlot[] = [
|
||||
{ label: "Tomorrow 10:00 AM", iso: "2026-04-25T10:00:00-07:00" },
|
||||
{ label: "Tomorrow 2:00 PM", iso: "2026-04-25T14:00:00-07:00" },
|
||||
{ label: "Monday 9:00 AM", iso: "2026-04-28T09:00:00-07:00" },
|
||||
{ label: "Monday 3:30 PM", iso: "2026-04-28T15:30:00-07:00" },
|
||||
];
|
||||
|
||||
type PickerResult =
|
||||
| { chosen_time: string; chosen_label: string }
|
||||
| { cancelled: true };
|
||||
|
||||
export default function GenUiInterruptDemo() {
|
||||
return (
|
||||
<CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
|
||||
<div className="flex justify-center items-center h-screen w-full">
|
||||
<div className="h-full w-full max-w-4xl">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKitProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Chat() {
|
||||
// Pending-resolver ref: set by the async handler, called by the render
|
||||
// prop when the user clicks a slot or cancels. This is the built-in agent
|
||||
// adaptation of the LangGraph `resolve(...)` callback.
|
||||
const resolverRef = useRef<((result: PickerResult) => void) | null>(null);
|
||||
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Book a call with sales",
|
||||
message: "Book an intro call with the sales team to discuss pricing.",
|
||||
},
|
||||
{
|
||||
title: "Schedule a 1:1 with Alice",
|
||||
message: "Schedule a 1:1 with Alice next week to review Q2 goals.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
|
||||
// @region[frontend-promise-handler]
|
||||
useFrontendTool({
|
||||
name: "schedule_meeting",
|
||||
description:
|
||||
"Ask the user to pick a time slot for a meeting via an in-chat " +
|
||||
"picker. Blocks until the user chooses a slot or cancels.",
|
||||
parameters: z.object({
|
||||
topic: z
|
||||
.string()
|
||||
.describe("Short human-readable description of the meeting."),
|
||||
attendee: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Who the meeting is with (optional)."),
|
||||
}),
|
||||
// Async handler: returns a Promise that resolves only once the user
|
||||
// acts on the picker. This is the built-in agent shim for LangGraph's
|
||||
// `interrupt()`/`resolve()` pair.
|
||||
handler: async (): Promise<string> => {
|
||||
const result = await new Promise<PickerResult>((resolve) => {
|
||||
resolverRef.current = resolve;
|
||||
});
|
||||
if ("cancelled" in result && result.cancelled) {
|
||||
return "User cancelled. Meeting NOT scheduled.";
|
||||
}
|
||||
if ("chosen_label" in result) {
|
||||
return `Meeting scheduled for ${result.chosen_label}.`;
|
||||
}
|
||||
return "User did not pick a time. Meeting NOT scheduled.";
|
||||
},
|
||||
render: ({ args, status }) => {
|
||||
if (status === "complete") return null;
|
||||
const topic =
|
||||
(args as { topic?: string } | undefined)?.topic ?? "a meeting";
|
||||
const attendee = (args as { attendee?: string } | undefined)?.attendee;
|
||||
return (
|
||||
<TimePickerCard
|
||||
topic={topic}
|
||||
attendee={attendee}
|
||||
slots={DEFAULT_SLOTS}
|
||||
onSubmit={(result) => {
|
||||
const fn = resolverRef.current;
|
||||
resolverRef.current = null;
|
||||
fn?.(result);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
// @endregion[frontend-promise-handler]
|
||||
// @endregion[frontend-useinterrupt-render]
|
||||
|
||||
return <CopilotChat className="h-full rounded-2xl" />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
||||
|
||||
export function useGenUiInterruptSuggestions() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Book a call with sales",
|
||||
message: "Book an intro call with the sales team to discuss pricing.",
|
||||
},
|
||||
{
|
||||
title: "Schedule a 1:1 with Alice",
|
||||
message: "Schedule a 1:1 with Alice next week to review Q2 goals.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
export interface TimeSlot {
|
||||
label: string;
|
||||
iso: string;
|
||||
}
|
||||
|
||||
export interface TimePickerCardProps {
|
||||
topic: string;
|
||||
attendee?: string;
|
||||
slots: TimeSlot[];
|
||||
onSubmit: (
|
||||
result: { chosen_time: string; chosen_label: string } | { cancelled: true },
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a "Book a call" card with a small grid of time slots.
|
||||
*
|
||||
* In the LangGraph showcase this card is driven by the native
|
||||
* `interrupt()` primitive and `useInterrupt`. The built-in-agent has no
|
||||
* interrupt primitive, so this port wires the same card up through
|
||||
* `useFrontendTool` with an async handler: when the agent calls
|
||||
* `schedule_meeting`, the handler returns a Promise that only resolves
|
||||
* once the user picks a slot (or cancels).
|
||||
*/
|
||||
export function TimePickerCard({
|
||||
topic,
|
||||
attendee,
|
||||
slots,
|
||||
onSubmit,
|
||||
}: TimePickerCardProps) {
|
||||
const [picked, setPicked] = useState<TimeSlot | null>(null);
|
||||
const [cancelled, setCancelled] = useState(false);
|
||||
const disabled = picked !== null || cancelled;
|
||||
|
||||
if (cancelled) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border border-[#DBDBE5] bg-[#F7F7F9] p-4 text-sm text-[#57575B] max-w-md"
|
||||
data-testid="time-picker-cancelled"
|
||||
>
|
||||
Cancelled — no time picked.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (picked) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border border-[#85ECCE4D] bg-[#85ECCE]/10 p-4 max-w-md"
|
||||
data-testid="time-picker-picked"
|
||||
>
|
||||
<p className="text-sm text-[#010507]">
|
||||
Booked for <span className="font-semibold">{picked.label}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border border-[#DBDBE5] bg-white p-5 shadow-sm max-w-md"
|
||||
data-testid="time-picker-card"
|
||||
>
|
||||
<p className="text-[10px] uppercase tracking-[0.14em] text-[#57575B] font-medium">
|
||||
Book a call
|
||||
</p>
|
||||
<h3 className="text-base font-semibold text-[#010507] mt-1.5">{topic}</h3>
|
||||
{attendee && (
|
||||
<p className="text-sm text-[#57575B] mt-0.5">With {attendee}</p>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-[#57575B] mt-4 mb-2">Pick a time:</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{slots.map((s) => (
|
||||
<button
|
||||
key={s.iso}
|
||||
disabled={disabled}
|
||||
data-testid="time-picker-slot"
|
||||
onClick={() => {
|
||||
setPicked(s);
|
||||
onSubmit({ chosen_time: s.iso, chosen_label: s.label });
|
||||
}}
|
||||
className="rounded-xl border border-[#DBDBE5] bg-white px-3 py-2 text-sm font-medium text-[#010507] hover:border-[#BEC2FF] hover:bg-[#BEC2FF1A] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
disabled={disabled}
|
||||
data-testid="time-picker-cancel"
|
||||
onClick={() => {
|
||||
setCancelled(true);
|
||||
onSubmit({ cancelled: true });
|
||||
}}
|
||||
className="mt-3 w-full rounded-xl border border-[#E9E9EF] px-3 py-1.5 text-xs text-[#838389] hover:bg-[#FAFAFC] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
None of these work
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
// Docs-only snippet — not imported or rendered. The dashboard demo at
|
||||
// `page.tsx` for this framework runs a haiku-generator showcase via
|
||||
// `useFrontendTool`, but the canonical `/generative-ui/tool-based` page
|
||||
// teaches the simpler `useComponent` bar-chart pattern. This file shows
|
||||
// what the bar-chart renderer would look like in the same framework's
|
||||
// shape, so the docs render real teaching code rather than a missing-
|
||||
// snippet box.
|
||||
//
|
||||
// Mirrors the convention from `tool-rendering/render-flight-tool.snippet.tsx`.
|
||||
|
||||
import { useComponent } from "@copilotkit/react-core/v2";
|
||||
import { z } from "zod";
|
||||
|
||||
// Stand-ins for the locally-authored bar chart component + its prop
|
||||
// schema. In a real page, these live in the demo directory (e.g.
|
||||
// `./bar-chart.tsx` exporting `BarChart` and `barChartPropsSchema`).
|
||||
declare const BarChart: React.ComponentType<{
|
||||
title: string;
|
||||
data: { label: string; value: number }[];
|
||||
}>;
|
||||
declare const barChartPropsSchema: z.ZodSchema;
|
||||
|
||||
export function BarChartRenderer() {
|
||||
// @region[bar-chart-renderer]
|
||||
useComponent({
|
||||
name: "render_bar_chart",
|
||||
description: "Display a bar chart with labeled numeric values.",
|
||||
parameters: barChartPropsSchema,
|
||||
render: BarChart,
|
||||
});
|
||||
// @endregion[bar-chart-renderer]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CopilotKitProvider,
|
||||
CopilotChat,
|
||||
useComponent,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
|
||||
export default function GenUiToolBased() {
|
||||
return (
|
||||
<CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
|
||||
<Demo />
|
||||
</CopilotKitProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Demo() {
|
||||
useComponent({
|
||||
name: "generate_haiku",
|
||||
render: HaikuCard,
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="p-8">
|
||||
<h1 className="text-2xl font-semibold mb-4">Tool-Based Generative UI</h1>
|
||||
<p className="text-sm opacity-70 mb-6">
|
||||
Try: “Write me a haiku about nature.” The agent calls the
|
||||
<code className="mx-1 px-1 bg-gray-100 rounded">generate_haiku</code>
|
||||
tool and the result renders inline as a typed card.
|
||||
</p>
|
||||
<CopilotChat />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HaikuCard — rendered by `useComponent({ name: "generate_haiku" })`.
|
||||
*
|
||||
* `useComponent` passes tool-call arguments directly as React props (via
|
||||
* `useFrontendTool`'s `render: ({ args }) => <Component {...args} />`).
|
||||
* The D5 fixture sends: { japanese, english, image_name, gradient }.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function HaikuCard(props: any) {
|
||||
const japaneseLines: string[] = props.japanese ?? [];
|
||||
const englishLines: string[] = props.english ?? props.lines ?? [];
|
||||
const gradient: string | undefined = props.gradient;
|
||||
const topic: string | undefined = props.topic;
|
||||
|
||||
const hasContent = japaneseLines.length > 0 || englishLines.length > 0;
|
||||
|
||||
if (!hasContent) {
|
||||
return (
|
||||
<div
|
||||
data-testid="haiku-card"
|
||||
className="border rounded p-3 my-2 opacity-70 text-sm"
|
||||
>
|
||||
Composing haiku{topic ? ` about ${topic}` : ""}…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="haiku-card"
|
||||
className="border rounded p-4 my-2 bg-amber-50"
|
||||
style={gradient ? { background: gradient } : undefined}
|
||||
>
|
||||
<div className="font-medium mb-2">Haiku{topic ? ` — ${topic}` : ""}</div>
|
||||
{japaneseLines.length > 0 && (
|
||||
<div className="mb-2">
|
||||
{japaneseLines.map((line: string, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
data-testid="haiku-japanese-line"
|
||||
className="text-lg leading-relaxed"
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-serif italic whitespace-pre-line text-lg leading-relaxed">
|
||||
{englishLines.map((line: string, i: number) => (
|
||||
<div key={i} data-testid="haiku-english-line">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
||||
|
||||
export function useSuggestions() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Sales bar chart",
|
||||
message: "Show me a bar chart of quarterly sales for Q1, Q2, Q3, Q4.",
|
||||
},
|
||||
{
|
||||
title: "Traffic pie chart",
|
||||
message: "Show me a pie chart of website traffic by source.",
|
||||
},
|
||||
{
|
||||
title: "Market share",
|
||||
message: "Show a pie chart of smartphone market share by brand.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user