chore: import upstream snapshot with attribution
This commit is contained in:
+51
@@ -0,0 +1,51 @@
|
||||
// Dedicated runtime for the A2UI — Fixed Schema cell. Splitting into its own
|
||||
// endpoint (mirroring the LangGraph reference) lets us set
|
||||
// `a2ui.injectA2UITool: false` — the backend .NET agent owns the
|
||||
// `search_flights` tool which emits its own `a2ui_operations` container.
|
||||
//
|
||||
// The .NET backend exposes this agent at `AGENT_URL/a2ui-fixed-schema`
|
||||
// (see agent/Program.cs).
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const a2uiFixedSchemaAgent = new HttpAgent({
|
||||
url: `${AGENT_URL}/a2ui-fixed-schema`,
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents: { "a2ui-fixed-schema": a2uiFixedSchemaAgent },
|
||||
a2ui: {
|
||||
// The backend emits its own `a2ui_operations` container via the
|
||||
// `search_flights` tool. We still run the A2UI middleware so it detects
|
||||
// the container in tool results and forwards surfaces to the frontend —
|
||||
// but we do NOT inject a runtime `render_a2ui` tool on top of the
|
||||
// agent's existing tools.
|
||||
injectA2UITool: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-a2ui-fixed-schema",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
// Dedicated runtime for the /demos/agent-config cell.
|
||||
//
|
||||
// Proxies to the .NET backend's `/agent-config` endpoint, where a dedicated
|
||||
// agent reads the forwarded tone / expertise / responseLength triple from
|
||||
// shared state and rebuilds its system prompt per turn.
|
||||
//
|
||||
// References:
|
||||
// - showcase/integrations/ms-agent-dotnet/agent/AgentConfigAgent.cs
|
||||
// - showcase/integrations/ms-agent-dotnet/src/app/demos/agent-config/page.tsx
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
function createAgent() {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/agent-config` });
|
||||
}
|
||||
|
||||
// The page mounts <CopilotKit agent="agent-config-demo" />; resolve that to
|
||||
// the dedicated agent. `default` aliased so any internal default-agent
|
||||
// lookups resolve against the same agent.
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
"agent-config-demo": createAgent(),
|
||||
default: createAgent(),
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-agent-config",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
|
||||
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records;
|
||||
// fixed in source, pending release.
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return NextResponse.json(
|
||||
{ error: err.message, stack: err.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
// Dedicated runtime for the /demos/auth cell.
|
||||
//
|
||||
// Demonstrates framework-native request authentication via the V2 runtime's
|
||||
// `onRequest` hook, which runs before routing and can short-circuit the
|
||||
// request by throwing a Response. We validate a static `Authorization: Bearer
|
||||
// <DEMO_TOKEN>` header; mismatch throws 401 before the request reaches the
|
||||
// agent.
|
||||
//
|
||||
// Implementation note: the V1 Next.js adapter
|
||||
// (`copilotRuntimeNextJSAppRouterEndpoint`) does NOT forward the `hooks`
|
||||
// option to the V2 fetch handler. To get `onRequest` wired, this route uses
|
||||
// `createCopilotRuntimeHandler` from `@copilotkit/runtime/v2` directly — the
|
||||
// framework-agnostic fetch handler that returns a plain
|
||||
// `(Request) => Promise<Response>`, which composes cleanly with a Next.js
|
||||
// App Router route export.
|
||||
//
|
||||
// References:
|
||||
// - packages/runtime/src/v2/runtime/core/hooks.ts (onRequest semantics)
|
||||
// - packages/runtime/src/v2/runtime/__tests__/hooks.test.ts (throw Response pattern)
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotRuntimeHandler,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
import { DEMO_AUTH_HEADER } from "@/app/demos/auth/demo-token";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
function createAgent() {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/` });
|
||||
}
|
||||
|
||||
// Reuse the neutral SalesAgent for the authenticated path. The point of this
|
||||
// demo is the gate mechanism, not per-user agent branching — authenticated
|
||||
// users get the same behavior as any other neutral demo.
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
"auth-demo": createAgent(),
|
||||
// Fallback: useAgent() with no args resolves "default" — alias to the
|
||||
// same agent so hooks in the demo page resolve cleanly.
|
||||
default: createAgent(),
|
||||
},
|
||||
});
|
||||
|
||||
const BASE_PATH = "/api/copilotkit-auth";
|
||||
|
||||
// Framework-agnostic fetch handler with the auth gate wired up.
|
||||
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
|
||||
// thrown Responses to the HTTP response verbatim (status + body).
|
||||
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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Next.js App Router bindings. The handler is framework-agnostic — it takes
|
||||
// a web Request and returns a web Response — so it drops straight into the
|
||||
// POST/GET exports without any adapter shim.
|
||||
export const POST = (req: NextRequest) => handler(req);
|
||||
export const GET = (req: NextRequest) => handler(req);
|
||||
@@ -0,0 +1,77 @@
|
||||
// Dedicated runtime for the Beautiful Chat flagship cell.
|
||||
//
|
||||
// Beautiful Chat simultaneously exercises A2UI (dynamic + fixed schema),
|
||||
// Open Generative UI, and MCP Apps. Those three flags are set on the
|
||||
// CopilotRuntime itself (not on the backing agent), so we scope this cell
|
||||
// to its own route instead of bleeding flags into the shared
|
||||
// `/api/copilotkit` endpoint used by every other cell.
|
||||
//
|
||||
// References:
|
||||
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-beautiful-chat/route.ts
|
||||
// - ../copilotkit-multimodal/route.ts (dedicated-route pattern)
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import type { AbstractAgent } from "@ag-ui/client";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
function createAgent() {
|
||||
// Points at the `/beautiful-chat` mount on the .NET backend
|
||||
// (Program.cs: `app.MapAGUI("/beautiful-chat", ...)`).
|
||||
return new HttpAgent({ url: `${AGENT_URL}/beautiful-chat` });
|
||||
}
|
||||
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
// The page's <CopilotKit agent="beautiful-chat"> resolves here.
|
||||
"beautiful-chat": createAgent(),
|
||||
// Alias for internal components that call `useAgent()` without args.
|
||||
default: createAgent(),
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
openGenerativeUI: true,
|
||||
a2ui: {
|
||||
// The .NET agent has its own `generate_a2ui` tool; don't inject the
|
||||
// runtime's default A2UI tool on top.
|
||||
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: "copilotkit://app-dashboard-catalog",
|
||||
},
|
||||
mcpApps: {
|
||||
servers: [
|
||||
{
|
||||
type: "http",
|
||||
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
|
||||
serverId: "beautiful_chat_mcp",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-beautiful-chat",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
// Dedicated runtime for the Declarative Generative UI (A2UI — Dynamic Schema)
|
||||
// cell. Splitting into its own endpoint (mirroring the LangGraph reference)
|
||||
// lets us set `a2ui.injectA2UITool: false` — the backend .NET agent owns the
|
||||
// `generate_a2ui` tool itself, so double-binding from the runtime would
|
||||
// duplicate the tool slot and confuse the LLM.
|
||||
//
|
||||
// The .NET backend exposes this agent at `AGENT_URL/declarative-gen-ui`
|
||||
// (see agent/Program.cs).
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const declarativeGenUiAgent = new HttpAgent({
|
||||
url: `${AGENT_URL}/declarative-gen-ui`,
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents: { "declarative-gen-ui": declarativeGenUiAgent },
|
||||
a2ui: {
|
||||
// The backend agent owns the `generate_a2ui` tool explicitly, so the
|
||||
// runtime MUST NOT auto-inject its own A2UI tool on top. The A2UI
|
||||
// middleware still runs — it serialises the registered client catalog
|
||||
// into the agent's `copilotkit.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.
|
||||
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",
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-declarative-gen-ui",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
// Dedicated runtime for the declarative-hashbrown demo.
|
||||
//
|
||||
// The demo page (`src/app/demos/declarative-hashbrown/page.tsx`) wraps
|
||||
// CopilotChat in the HashBrownDashboard provider and overrides the
|
||||
// assistant message slot with a renderer that consumes hashbrown-shaped
|
||||
// structured output via `@hashbrownai/react`'s `useUiKit` + `useJsonParser`.
|
||||
// The MS Agent behind this endpoint (see `src/agents/byoc_hashbrown_agent.py`,
|
||||
// mounted at `/byoc-hashbrown` in `agent_server.py`) has a system prompt
|
||||
// tuned to emit that shape. The legacy `byoc_hashbrown` Python module name
|
||||
// is retained (mirrors LangGraph's convention — see
|
||||
// `langgraph-python/src/app/api/copilotkit-declarative-hashbrown/route.ts`);
|
||||
// only the user-facing slug, route, and frontend folder use the
|
||||
// `declarative-` prefix so the manifest is one-to-one with LGP.
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const declarativeHashbrownAgent = new HttpAgent({
|
||||
url: `${AGENT_URL}/byoc-hashbrown`,
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
|
||||
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in
|
||||
// source, pending release.
|
||||
agents: { "declarative-hashbrown-demo": declarativeHashbrownAgent },
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-declarative-hashbrown",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
// Dedicated runtime for the declarative-json-render demo.
|
||||
//
|
||||
// The demo page (`src/app/demos/declarative-json-render/page.tsx`) swaps
|
||||
// in `JsonRenderAssistantMessage` and renders an agent-emitted JSON spec
|
||||
// via `@json-render/react` against a Zod-validated catalog (MetricCard,
|
||||
// BarChart, PieChart). The MS Agent behind this endpoint (see
|
||||
// `src/agents/byoc_json_render_agent.py`, mounted at `/byoc-json-render`
|
||||
// in `agent_server.py`) emits that JSON envelope. The legacy
|
||||
// `byoc_json_render` Python module name is retained (matches LGP's
|
||||
// convention); only the slug, route, and frontend folder use the
|
||||
// `declarative-` prefix.
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const declarativeJsonRenderAgent = new HttpAgent({
|
||||
url: `${AGENT_URL}/byoc-json-render`,
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see hashbrown route
|
||||
agents: { byoc_json_render: declarativeJsonRenderAgent },
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-declarative-json-render",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
// CopilotKit runtime for the MCP Apps cell (MS Agent Framework backend).
|
||||
//
|
||||
// The runtime's `mcpApps` config auto-applies the MCP Apps middleware to the
|
||||
// agent: when the agent calls a tool backed by an MCP UI resource, the
|
||||
// middleware fetches the resource and emits the activity event that the
|
||||
// built-in `MCPAppsActivityRenderer` (registered by CopilotKit internally)
|
||||
// renders in the chat as a sandboxed iframe.
|
||||
//
|
||||
// The optional catch-all route mirrors the langgraph-python north star. MCP
|
||||
// Apps resource proxy requests are addressed below `/api/copilotkit-mcp-apps`,
|
||||
// so a plain `route.ts` at the parent segment handles the chat POST but misses
|
||||
// those subpath requests.
|
||||
//
|
||||
// Reference:
|
||||
// https://docs.copilotkit.ai/integrations/microsoft-agent-framework/generative-ui/mcp-apps
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const mcpAppsAgent = new HttpAgent({ url: `${AGENT_URL}/mcp-apps/` });
|
||||
|
||||
// headless-complete shares this runtime because its cell also exercises
|
||||
// MCP Apps activity rendering (the "Sketch a diagram" pill exercises the
|
||||
// Excalidraw MCP server via the same middleware). The catch-all `/` agent
|
||||
// on the Python backend backs it -- no dedicated headless endpoint.
|
||||
const headlessCompleteAgent = new HttpAgent({
|
||||
url: `${AGENT_URL}/headless-complete`,
|
||||
});
|
||||
|
||||
// @region[runtime-mcpapps-config]
|
||||
// The `mcpApps.servers` config is all you need server-side. The runtime
|
||||
// auto-applies the MCP Apps middleware to every registered agent: on each
|
||||
// MCP tool call it fetches the associated UI resource and emits an
|
||||
// `activity` event that the built-in `MCPAppsActivityRenderer` renders
|
||||
// inline in the chat.
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
|
||||
agents: {
|
||||
"mcp-apps": mcpAppsAgent,
|
||||
"headless-complete": headlessCompleteAgent,
|
||||
},
|
||||
mcpApps: {
|
||||
servers: [
|
||||
{
|
||||
type: "http",
|
||||
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
|
||||
// Keep the server id 1:1 with langgraph-python so persisted MCP Apps
|
||||
// and fixture-backed resource calls use the same identity.
|
||||
serverId: "excalidraw",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
// @endregion[runtime-mcpapps-config]
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-mcp-apps",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
// Dedicated runtime for the Multimodal Attachments demo.
|
||||
//
|
||||
// Why its own route? The backing agent (MultimodalAgent, mounted at
|
||||
// `/multimodal` by the .NET backend) is the only one in this showcase that
|
||||
// expects vision content parts. Registering it under the shared
|
||||
// `/api/copilotkit` runtime would silently route every other cell through the
|
||||
// vision endpoint too. A dedicated route keeps the vision capability scoped
|
||||
// to exactly the cell that exercises it, matching the LangGraph reference's
|
||||
// pattern for `/api/copilotkit-multimodal`.
|
||||
//
|
||||
// The page at src/app/demos/multimodal/page.tsx points its `runtimeUrl` at
|
||||
// this endpoint and sets `agent="multimodal-demo"`.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { AbstractAgent, HttpAgent, type RunAgentInput } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
class DotnetMultimodalHttpAgent extends HttpAgent {
|
||||
run(input: RunAgentInput): ReturnType<HttpAgent["run"]> {
|
||||
return super.run(input);
|
||||
}
|
||||
|
||||
protected requestInit(input: RunAgentInput): RequestInit {
|
||||
return super.requestInit(input);
|
||||
}
|
||||
}
|
||||
|
||||
function createAgent() {
|
||||
// Points at the `/multimodal` mount on the .NET backend (Program.cs:
|
||||
// `app.MapAGUI("/multimodal", ...)`).
|
||||
return new DotnetMultimodalHttpAgent({ url: `${AGENT_URL}/multimodal` });
|
||||
}
|
||||
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
"multimodal-demo": createAgent(),
|
||||
default: createAgent(),
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-multimodal",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import {
|
||||
AbstractAgent,
|
||||
HttpAgent,
|
||||
type BaseEvent,
|
||||
type RunAgentInput,
|
||||
} from "@ag-ui/client";
|
||||
import { map, type Observable } from "rxjs";
|
||||
|
||||
// Dedicated runtime for the Open Generative UI demos, mirroring the
|
||||
// LangGraph-Python `copilotkit-ogui` route.
|
||||
//
|
||||
// Isolated here because the `openGenerativeUI` runtime flag sets
|
||||
// `openGenerativeUIEnabled: true` globally on the probe response, which
|
||||
// causes the CopilotKit provider's setTools effect to wipe per-demo
|
||||
// `useFrontendTool`/`useComponent` registrations in the default runtime.
|
||||
//
|
||||
// Each agent name proxies to a separate `MapAGUI` endpoint on the .NET
|
||||
// backend (see `agent/Program.cs`).
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
const OGUI_TOOL_CALL_ID_SUFFIX = /__ogui_run_[0-9a-f-]+$/i;
|
||||
|
||||
console.log("[copilotkit-ogui/route] Initializing OGUI CopilotKit runtime");
|
||||
console.log(`[copilotkit-ogui/route] AGENT_URL: ${AGENT_URL}`);
|
||||
|
||||
function stripOguiToolCallId(id: string): string {
|
||||
return id.replace(OGUI_TOOL_CALL_ID_SUFFIX, "");
|
||||
}
|
||||
|
||||
function makeOguiToolCallId(id: string, runId: string): string {
|
||||
return `${stripOguiToolCallId(id)}__ogui_run_${runId}`;
|
||||
}
|
||||
|
||||
function stripOguiToolCallIdsFromMessage(message: unknown): unknown {
|
||||
if (!message || typeof message !== "object") return message;
|
||||
|
||||
const next = { ...(message as Record<string, unknown>) };
|
||||
let changed = false;
|
||||
|
||||
if (typeof next.toolCallId === "string") {
|
||||
next.toolCallId = stripOguiToolCallId(next.toolCallId);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (typeof next.tool_call_id === "string") {
|
||||
next.tool_call_id = stripOguiToolCallId(next.tool_call_id);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (Array.isArray(next.toolCalls)) {
|
||||
next.toolCalls = next.toolCalls.map((toolCall) => {
|
||||
if (!toolCall || typeof toolCall !== "object") return toolCall;
|
||||
const call = { ...(toolCall as Record<string, unknown>) };
|
||||
if (typeof call.id === "string") {
|
||||
call.id = stripOguiToolCallId(call.id);
|
||||
}
|
||||
return call;
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? next : message;
|
||||
}
|
||||
|
||||
class OpenGenUiHttpAgent extends HttpAgent {
|
||||
run(input: RunAgentInput): Observable<BaseEvent> {
|
||||
const toolCallIds = new Map<string, string>();
|
||||
const runId = input.runId;
|
||||
const sanitizedInput = {
|
||||
...input,
|
||||
messages: input.messages.map(stripOguiToolCallIdsFromMessage),
|
||||
} as RunAgentInput;
|
||||
|
||||
return super.run(sanitizedInput).pipe(
|
||||
map((event: BaseEvent) => {
|
||||
const e = event as BaseEvent & {
|
||||
toolCallId?: string;
|
||||
toolCallName?: string;
|
||||
};
|
||||
|
||||
if (
|
||||
(e.type === "TOOL_CALL_START" || e.type === "TOOL_CALL_CHUNK") &&
|
||||
e.toolCallName === "generateSandboxedUi" &&
|
||||
e.toolCallId
|
||||
) {
|
||||
const originalId = stripOguiToolCallId(e.toolCallId);
|
||||
const rewrittenId = makeOguiToolCallId(originalId, runId);
|
||||
toolCallIds.set(originalId, rewrittenId);
|
||||
return { ...event, toolCallId: rewrittenId };
|
||||
}
|
||||
|
||||
if (e.toolCallId) {
|
||||
const originalId = stripOguiToolCallId(e.toolCallId);
|
||||
const rewrittenId = toolCallIds.get(originalId);
|
||||
if (rewrittenId) {
|
||||
return { ...event, toolCallId: rewrittenId };
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const openGenUiAgent = new OpenGenUiHttpAgent({
|
||||
url: `${AGENT_URL}/open-gen-ui`,
|
||||
});
|
||||
const openGenUiAdvancedAgent = new OpenGenUiHttpAgent({
|
||||
url: `${AGENT_URL}/open-gen-ui-advanced`,
|
||||
});
|
||||
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
"open-gen-ui": openGenUiAgent,
|
||||
"open-gen-ui-advanced": openGenUiAdvancedAgent,
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-ogui",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
// 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); the runtime middleware
|
||||
// converts each agent's streamed `generateSandboxedUi` tool call into
|
||||
// `open-generative-ui` activity events.
|
||||
// @region[minimal-runtime-flag]
|
||||
// @region[advanced-runtime-config]
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
|
||||
agents,
|
||||
openGenerativeUI: {
|
||||
agents: ["open-gen-ui", "open-gen-ui-advanced"],
|
||||
},
|
||||
}),
|
||||
// @endregion[advanced-runtime-config]
|
||||
// @endregion[minimal-runtime-flag]
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error(`[copilotkit-ogui/route] ERROR: ${err.message}`);
|
||||
return NextResponse.json(
|
||||
{ error: err.message, stack: err.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
// Dedicated runtime for the /demos/voice cell (MS Agent Python).
|
||||
//
|
||||
// Goals
|
||||
// -----
|
||||
// 1. Advertise `audioFileTranscriptionEnabled: true` on `/info` so the chat
|
||||
// composer renders the mic button.
|
||||
// 2. Handle `POST /transcribe` by invoking an OpenAI-backed
|
||||
// `TranscriptionServiceOpenAI` (from `@copilotkit/voice`).
|
||||
// 3. Return a deterministic 4xx when `OPENAI_API_KEY` is not configured.
|
||||
//
|
||||
// Wires the V2 `CopilotRuntime` directly because the V1 wrapper drops the
|
||||
// `transcriptionService` option. V2 URL-routes on `/info`, `/agent/:id/run`,
|
||||
// `/transcribe`, etc., so the route lives at `[[...slug]]/route.ts`.
|
||||
|
||||
// @region[voice-runtime]
|
||||
// @region[transcription-service-guard]
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
TranscriptionService,
|
||||
createCopilotRuntimeHandler,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import type { TranscribeFileOptions } from "@copilotkit/runtime/v2";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
import { TranscriptionServiceOpenAI } from "@copilotkit/voice";
|
||||
import OpenAI from "openai";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
// Point at the tool-free /voice endpoint so aimock returns a direct text
|
||||
// response instead of a tool call that the agent can't summarize.
|
||||
const voiceDemoAgent = new HttpAgent({ url: `${AGENT_URL}/voice/` });
|
||||
|
||||
/**
|
||||
* Transcription service wrapper that reports a clean, typed auth error when
|
||||
* OPENAI_API_KEY is not configured. When the key is present we delegate to
|
||||
* the real OpenAI-backed service; any upstream Whisper error keeps its
|
||||
* natural categorization.
|
||||
*
|
||||
* Note: We pin `baseURL` to real OpenAI (or `OPENAI_TRANSCRIPTION_BASE_URL`
|
||||
* when explicitly set) instead of falling through to `OPENAI_BASE_URL`. In
|
||||
* local docker / Railway preview environments `OPENAI_BASE_URL` points at
|
||||
* aimock so LLM completions stay deterministic, but aimock has a catchall
|
||||
* `endpoint: "transcription"` fixture that would otherwise intercept every
|
||||
* real mic recording and return the canned "What is the weather in Tokyo?"
|
||||
* phrase regardless of what the user actually said — and on production
|
||||
* aimock's transcription proxy returns a 502 "Invalid file format" before
|
||||
* any phrase reaches the user. The sample-audio button is the deterministic
|
||||
* affordance (synchronous text injection); the mic is the only path that
|
||||
* should exercise real Whisper.
|
||||
*
|
||||
* Mirrors langgraph-python's voice route exactly.
|
||||
*/
|
||||
class GuardedOpenAITranscriptionService extends TranscriptionService {
|
||||
private delegate: TranscriptionServiceOpenAI | null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
const baseURL =
|
||||
process.env.OPENAI_TRANSCRIPTION_BASE_URL ?? "https://api.openai.com/v1";
|
||||
this.delegate = apiKey
|
||||
? new TranscriptionServiceOpenAI({
|
||||
openai: new OpenAI({ apiKey, baseURL }),
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
async transcribeFile(options: TranscribeFileOptions): Promise<string> {
|
||||
if (!this.delegate) {
|
||||
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({
|
||||
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
|
||||
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in
|
||||
// source, pending release.
|
||||
agents: {
|
||||
"voice-demo": voiceDemoAgent,
|
||||
default: voiceDemoAgent,
|
||||
},
|
||||
transcriptionService: new GuardedOpenAITranscriptionService(),
|
||||
});
|
||||
|
||||
cachedHandler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit-voice",
|
||||
});
|
||||
return cachedHandler;
|
||||
}
|
||||
|
||||
export const POST = (req: NextRequest) => getHandler()(req);
|
||||
export const GET = (req: NextRequest) => getHandler()(req);
|
||||
export const PUT = (req: NextRequest) => getHandler()(req);
|
||||
export const DELETE = (req: NextRequest) => getHandler()(req);
|
||||
// @endregion[voice-runtime]
|
||||
@@ -0,0 +1,830 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import type { AbstractAgent, BaseEvent } from "@ag-ui/client";
|
||||
import { EventType, FunctionMiddleware, HttpAgent } from "@ag-ui/client";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
// The agent backend runs as a separate process on port 8000.
|
||||
// This runtime proxies CopilotKit requests to it via AG-UI protocol.
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
const REPLAY_SAFE_TOOL_CALL_ID_SUFFIX = /__ck_run_[0-9a-f-]+$/i;
|
||||
|
||||
// Per-request request/response logging is gated behind this flag (default off).
|
||||
// Under d6 probe fan-out, unconditional per-request logs flooded Railway's
|
||||
// 500-logs/sec cap and killed the replica ("Messages dropped" → container stop).
|
||||
// Set SHOWCASE_ROUTE_DEBUG=1 to re-enable verbose per-request tracing locally.
|
||||
const ROUTE_DEBUG =
|
||||
process.env.SHOWCASE_ROUTE_DEBUG === "1" ||
|
||||
process.env.SHOWCASE_ROUTE_DEBUG === "true";
|
||||
|
||||
console.log("[copilotkit/route] Initializing CopilotKit runtime");
|
||||
console.log(`[copilotkit/route] AGENT_URL: ${AGENT_URL}`);
|
||||
|
||||
function createAgent(path = "/") {
|
||||
const agent = new HttpAgent({ url: `${AGENT_URL}${path}` });
|
||||
// Universal strip middleware (no decision-suffix). Runs as the first
|
||||
// registered middleware so EVERY outbound request carries clean
|
||||
// canonical toolCallIds (the replay-safe `__ck_run_<uuid>` suffix is
|
||||
// removed) before any agent-specific middleware sees the input.
|
||||
// Decision suffixing (`__approved` / `__rejected` / `__cancelled`)
|
||||
// is intentionally NOT applied here — only `createReplaySafeAgent`
|
||||
// does that, because the suffix is non-idempotent and the inner
|
||||
// replay-safe middleware re-runs the same logic after this one.
|
||||
agent.use(
|
||||
new FunctionMiddleware((input, next) => {
|
||||
return next.run({
|
||||
...input,
|
||||
messages: (input.messages ?? []).map(
|
||||
stripReplaySafeToolCallIdsFromMessage,
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
return agent;
|
||||
}
|
||||
|
||||
function stripReplaySafeToolCallId(id: string): string {
|
||||
return id.replace(REPLAY_SAFE_TOOL_CALL_ID_SUFFIX, "");
|
||||
}
|
||||
|
||||
function makeReplaySafeToolCallId(id: string, runId: string): string {
|
||||
return `${stripReplaySafeToolCallId(id)}__ck_run_${runId}`;
|
||||
}
|
||||
|
||||
function stripReplaySafeToolCallIdsFromMessage(message: unknown): unknown {
|
||||
if (!message || typeof message !== "object") return message;
|
||||
|
||||
const next = { ...(message as Record<string, unknown>) };
|
||||
let changed = false;
|
||||
|
||||
if (typeof next.toolCallId === "string") {
|
||||
next.toolCallId = stripReplaySafeToolCallId(next.toolCallId);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (typeof next.tool_call_id === "string") {
|
||||
next.tool_call_id = stripReplaySafeToolCallId(next.tool_call_id);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Strip on BOTH the camelCase (AG-UI canonical) and snake_case (OpenAI
|
||||
// wire format) tool-call arrays. Some runtimes / message converters
|
||||
// pass the OpenAI shape through unchanged; without this branch the
|
||||
// replay-safe `__ck_run_<uuid>` suffix slips through to aimock and
|
||||
// toolCallId-keyed fixtures fail to match on follow-up turns.
|
||||
const stripToolCallArrayEntry = (toolCall: unknown) => {
|
||||
if (!toolCall || typeof toolCall !== "object") return toolCall;
|
||||
const call = { ...(toolCall as Record<string, unknown>) };
|
||||
if (typeof call.id === "string") {
|
||||
call.id = stripReplaySafeToolCallId(call.id);
|
||||
}
|
||||
// OpenAI nests the tool_call_id under `function` in some shapes; strip
|
||||
// there too just to keep the surface clean before aimock sees it.
|
||||
if (call.function && typeof call.function === "object") {
|
||||
const fn = { ...(call.function as Record<string, unknown>) };
|
||||
if (typeof fn.tool_call_id === "string") {
|
||||
fn.tool_call_id = stripReplaySafeToolCallId(fn.tool_call_id);
|
||||
call.function = fn;
|
||||
}
|
||||
}
|
||||
return call;
|
||||
};
|
||||
|
||||
if (Array.isArray(next.toolCalls)) {
|
||||
next.toolCalls = next.toolCalls.map(stripToolCallArrayEntry);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (Array.isArray(next.tool_calls)) {
|
||||
next.tool_calls = next.tool_calls.map(stripToolCallArrayEntry);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? next : message;
|
||||
}
|
||||
|
||||
function textFromMessageContent(content: unknown): string | undefined {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return undefined;
|
||||
|
||||
const text = content
|
||||
.map((part) => {
|
||||
if (!part || typeof part !== "object") return "";
|
||||
const text = (part as { text?: unknown }).text;
|
||||
return typeof text === "string" ? text : "";
|
||||
})
|
||||
.join("");
|
||||
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
function textFromContextValue(value: unknown): string | undefined {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === undefined || value === null) return undefined;
|
||||
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function buildContextSystemMessage(context: unknown): string | undefined {
|
||||
if (!Array.isArray(context) || context.length === 0) return undefined;
|
||||
|
||||
const lines = ["## Context from the application"];
|
||||
for (const entry of context) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const record = entry as Record<string, unknown>;
|
||||
const description =
|
||||
typeof record.description === "string" ? record.description : undefined;
|
||||
const value = textFromContextValue(record.value);
|
||||
if (!description || !value) continue;
|
||||
|
||||
lines.push("", description, value);
|
||||
}
|
||||
|
||||
return lines.length > 1 ? lines.join("\n") : undefined;
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function buildSharedStateReadWriteSystemMessage(state: unknown): string {
|
||||
const stateRecord = readRecord(state);
|
||||
const prefs = readRecord(stateRecord?.preferences) ?? {};
|
||||
const name = typeof prefs.name === "string" ? prefs.name : "";
|
||||
const tone = typeof prefs.tone === "string" ? prefs.tone : "casual";
|
||||
const language =
|
||||
typeof prefs.language === "string" ? prefs.language : "English";
|
||||
const interests = Array.isArray(prefs.interests)
|
||||
? prefs.interests.filter(
|
||||
(interest): interest is string => typeof interest === "string",
|
||||
)
|
||||
: [];
|
||||
|
||||
return [
|
||||
"You are a helpful, concise assistant. The user's preferences are supplied via shared state and added as a system message at the start of every turn - always respect them. When the user asks you to remember something, or you observe something worth surfacing in the UI's notes panel, call `set_notes` with the FULL updated list of short notes (existing notes + new). Keep each note short.",
|
||||
"",
|
||||
"[shared-state-read-write] preferences:",
|
||||
"{",
|
||||
` "name": ${JSON.stringify(name)},`,
|
||||
` "tone": ${JSON.stringify(tone)},`,
|
||||
` "language": ${JSON.stringify(language)},`,
|
||||
` "interests": ${JSON.stringify(interests)}`,
|
||||
"}",
|
||||
"Tailor every response to these preferences. Address the user by name when appropriate.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
type ToolResultDecision = "approved" | "rejected" | "cancelled";
|
||||
|
||||
function toolDecisionFromContent(
|
||||
content: unknown,
|
||||
): ToolResultDecision | undefined {
|
||||
const text = textFromMessageContent(content);
|
||||
if (!text) return undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed?.approved === true || parsed?.accepted === true) {
|
||||
return "approved";
|
||||
}
|
||||
if (parsed?.approved === false || parsed?.accepted === false) {
|
||||
return "rejected";
|
||||
}
|
||||
if (parsed?.cancelled === true || parsed?.canceled === true) {
|
||||
return "cancelled";
|
||||
}
|
||||
} catch {
|
||||
const normalized = text.toLowerCase();
|
||||
if (
|
||||
(normalized.includes("cancelled") || normalized.includes("canceled")) &&
|
||||
(normalized.includes("not scheduled") ||
|
||||
normalized.includes("not booked") ||
|
||||
normalized.includes("no time"))
|
||||
) {
|
||||
return "cancelled";
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function makeDecisionToolCallId(id: string, decision: ToolResultDecision) {
|
||||
return `${id}__${decision}`;
|
||||
}
|
||||
|
||||
function applyToolResultDecisionSuffix(
|
||||
message: unknown,
|
||||
decisionsByToolCallId: Map<string, ToolResultDecision>,
|
||||
): unknown {
|
||||
if (!message || typeof message !== "object") return message;
|
||||
|
||||
const next = { ...(message as Record<string, unknown>) };
|
||||
let changed = false;
|
||||
|
||||
if (typeof next.toolCallId === "string") {
|
||||
const decision = decisionsByToolCallId.get(next.toolCallId);
|
||||
if (decision) {
|
||||
next.toolCallId = makeDecisionToolCallId(next.toolCallId, decision);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof next.tool_call_id === "string") {
|
||||
const decision = decisionsByToolCallId.get(next.tool_call_id);
|
||||
if (decision) {
|
||||
next.tool_call_id = makeDecisionToolCallId(next.tool_call_id, decision);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const suffixArrayEntry = (toolCall: unknown) => {
|
||||
if (!toolCall || typeof toolCall !== "object") return toolCall;
|
||||
const call = { ...(toolCall as Record<string, unknown>) };
|
||||
if (typeof call.id === "string") {
|
||||
const decision = decisionsByToolCallId.get(call.id);
|
||||
if (decision) {
|
||||
call.id = makeDecisionToolCallId(call.id, decision);
|
||||
}
|
||||
}
|
||||
return call;
|
||||
};
|
||||
|
||||
if (Array.isArray(next.toolCalls)) {
|
||||
next.toolCalls = next.toolCalls.map(suffixArrayEntry);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (Array.isArray(next.tool_calls)) {
|
||||
next.tool_calls = next.tool_calls.map(suffixArrayEntry);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? next : message;
|
||||
}
|
||||
|
||||
function messageRole(message: unknown): string | undefined {
|
||||
if (!message || typeof message !== "object") return undefined;
|
||||
const role = (message as Record<string, unknown>).role;
|
||||
return typeof role === "string" ? role : undefined;
|
||||
}
|
||||
|
||||
function hasTextContent(message: Record<string, unknown>): boolean {
|
||||
const content = textFromMessageContent(message.content);
|
||||
return Boolean(content?.trim());
|
||||
}
|
||||
|
||||
function dropStaleToolInteractionsBeforeLatestUser(messages: unknown[]) {
|
||||
const latestUserIndex = messages.findLastIndex(
|
||||
(message) => messageRole(message) === "user",
|
||||
);
|
||||
|
||||
if (latestUserIndex < 0 || latestUserIndex !== messages.length - 1) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const nextMessages: unknown[] = [];
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
if (index >= latestUserIndex || !message || typeof message !== "object") {
|
||||
nextMessages.push(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const record = message as Record<string, unknown>;
|
||||
if (record.role === "tool") {
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
record.role === "assistant" &&
|
||||
(Array.isArray(record.toolCalls) || Array.isArray(record.tool_calls))
|
||||
) {
|
||||
const next = { ...record };
|
||||
delete next.toolCalls;
|
||||
delete next.tool_calls;
|
||||
changed = true;
|
||||
|
||||
if (hasTextContent(next)) {
|
||||
nextMessages.push(next);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
nextMessages.push(message);
|
||||
});
|
||||
|
||||
return changed ? nextMessages : messages;
|
||||
}
|
||||
|
||||
function prepareReplaySafeMessages(messages: unknown[] = []) {
|
||||
const stripped = messages.map(stripReplaySafeToolCallIdsFromMessage);
|
||||
const decisionsByToolCallId = new Map<string, ToolResultDecision>();
|
||||
|
||||
for (const message of stripped) {
|
||||
if (!message || typeof message !== "object") continue;
|
||||
const record = message as Record<string, unknown>;
|
||||
const toolCallId =
|
||||
typeof record.tool_call_id === "string"
|
||||
? record.tool_call_id
|
||||
: typeof record.toolCallId === "string"
|
||||
? record.toolCallId
|
||||
: undefined;
|
||||
|
||||
if (record.role !== "tool" || !toolCallId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const decision = toolDecisionFromContent(record.content);
|
||||
if (decision) {
|
||||
decisionsByToolCallId.set(toolCallId, decision);
|
||||
}
|
||||
}
|
||||
|
||||
const decisionAwareMessages =
|
||||
decisionsByToolCallId.size === 0
|
||||
? stripped
|
||||
: stripped.map((message) =>
|
||||
applyToolResultDecisionSuffix(message, decisionsByToolCallId),
|
||||
);
|
||||
|
||||
return dropStaleToolInteractionsBeforeLatestUser(decisionAwareMessages);
|
||||
}
|
||||
|
||||
function createReplaySafeAgent(path: string, replaySafeToolNames: string[]) {
|
||||
const agent = createAgent(path);
|
||||
const replaySafeTools = new Set(replaySafeToolNames);
|
||||
|
||||
agent.use(
|
||||
new FunctionMiddleware((input, next) => {
|
||||
return new Observable<BaseEvent>((subscriber) => {
|
||||
const toolCallIds = new Map<string, string>();
|
||||
const sanitizedInput = {
|
||||
...input,
|
||||
messages: prepareReplaySafeMessages(input.messages),
|
||||
};
|
||||
|
||||
const subscription = next.run(sanitizedInput).subscribe({
|
||||
next(event) {
|
||||
const e = event as BaseEvent & {
|
||||
toolCallId?: string;
|
||||
toolCallName?: string;
|
||||
};
|
||||
|
||||
if (
|
||||
(e.type === EventType.TOOL_CALL_START ||
|
||||
e.type === EventType.TOOL_CALL_CHUNK) &&
|
||||
e.toolCallName &&
|
||||
replaySafeTools.has(e.toolCallName) &&
|
||||
e.toolCallId
|
||||
) {
|
||||
const originalId = stripReplaySafeToolCallId(e.toolCallId);
|
||||
const rewrittenId = makeReplaySafeToolCallId(
|
||||
originalId,
|
||||
input.runId,
|
||||
);
|
||||
toolCallIds.set(originalId, rewrittenId);
|
||||
subscriber.next({ ...event, toolCallId: rewrittenId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.toolCallId) {
|
||||
const originalId = stripReplaySafeToolCallId(e.toolCallId);
|
||||
const rewrittenId = toolCallIds.get(originalId);
|
||||
if (rewrittenId) {
|
||||
subscriber.next({ ...event, toolCallId: rewrittenId });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
subscriber.next(event);
|
||||
},
|
||||
error(error) {
|
||||
subscriber.error(error);
|
||||
},
|
||||
complete() {
|
||||
subscriber.complete();
|
||||
},
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
function createGenUiAgent() {
|
||||
const agent = createAgent("/gen-ui-agent");
|
||||
|
||||
agent.use(
|
||||
new FunctionMiddleware((input, next) => {
|
||||
// The outer createAgent middleware already stripped replay-safe ids
|
||||
// on inbound messages, so this middleware only needs to wire the
|
||||
// gen-ui-agent's STATE_SNAPSHOT bridging from set_steps tool args.
|
||||
return new Observable<BaseEvent>((subscriber) => {
|
||||
const setStepsToolCallIds = new Set<string>();
|
||||
const argsByToolCallId = new Map<string, string>();
|
||||
|
||||
const emitStateSnapshotFromArgs = (toolCallId: string) => {
|
||||
const args = argsByToolCallId.get(toolCallId);
|
||||
if (!args) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
if (!Array.isArray(parsed?.steps)) return;
|
||||
|
||||
subscriber.next({
|
||||
type: EventType.STATE_SNAPSHOT,
|
||||
snapshot: { steps: parsed.steps },
|
||||
} as BaseEvent);
|
||||
} catch {
|
||||
// Args may arrive in chunks; wait until the buffered JSON is whole.
|
||||
}
|
||||
};
|
||||
|
||||
const subscription = next.run(input).subscribe({
|
||||
next(event) {
|
||||
if (
|
||||
event.type === EventType.TOOL_CALL_START &&
|
||||
(event as { toolCallName?: string }).toolCallName === "set_steps"
|
||||
) {
|
||||
const toolCallId = (event as { toolCallId?: string }).toolCallId;
|
||||
if (toolCallId) setStepsToolCallIds.add(toolCallId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === EventType.TOOL_CALL_ARGS) {
|
||||
const toolCallId = (event as { toolCallId?: string }).toolCallId;
|
||||
if (toolCallId && setStepsToolCallIds.has(toolCallId)) {
|
||||
const delta = String((event as { delta?: string }).delta ?? "");
|
||||
argsByToolCallId.set(
|
||||
toolCallId,
|
||||
`${argsByToolCallId.get(toolCallId) ?? ""}${delta}`,
|
||||
);
|
||||
emitStateSnapshotFromArgs(toolCallId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === EventType.TOOL_CALL_END ||
|
||||
event.type === EventType.TOOL_CALL_RESULT
|
||||
) {
|
||||
const toolCallId = (event as { toolCallId?: string }).toolCallId;
|
||||
if (toolCallId && setStepsToolCallIds.has(toolCallId)) {
|
||||
if (event.type === EventType.TOOL_CALL_RESULT) {
|
||||
setStepsToolCallIds.delete(toolCallId);
|
||||
argsByToolCallId.delete(toolCallId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
subscriber.next(event);
|
||||
},
|
||||
error(error) {
|
||||
subscriber.error(error);
|
||||
},
|
||||
complete() {
|
||||
subscriber.complete();
|
||||
},
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
function createReadonlyContextAgent() {
|
||||
const agent = createAgent("/readonly-state-agent-context");
|
||||
|
||||
agent.use(
|
||||
new FunctionMiddleware((input, next) => {
|
||||
// The outer createAgent middleware already stripped replay-safe ids
|
||||
// on `input.messages`, so the injected system message rides along
|
||||
// with already-canonicalised toolCallIds.
|
||||
const contextMessage = buildContextSystemMessage(
|
||||
(input as { context?: unknown }).context,
|
||||
);
|
||||
if (!contextMessage) {
|
||||
return next.run(input);
|
||||
}
|
||||
|
||||
return next.run({
|
||||
...input,
|
||||
messages: [
|
||||
{
|
||||
id: `${input.runId ?? crypto.randomUUID()}-app-context`,
|
||||
role: "system",
|
||||
content: contextMessage,
|
||||
},
|
||||
...(input.messages ?? []),
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
function createSharedStateReadWriteAgent() {
|
||||
const agent = createAgent("/shared-state-read-write");
|
||||
|
||||
agent.use(
|
||||
new FunctionMiddleware((input, next) => {
|
||||
// The outer createAgent middleware already stripped replay-safe ids
|
||||
// on `input.messages`; the injected shared-state system message
|
||||
// rides along with already-canonicalised toolCallIds.
|
||||
return next.run({
|
||||
...input,
|
||||
messages: [
|
||||
{
|
||||
id: `${input.runId ?? crypto.randomUUID()}-shared-state`,
|
||||
role: "system",
|
||||
content: buildSharedStateReadWriteSystemMessage(
|
||||
(input as { state?: unknown }).state,
|
||||
),
|
||||
},
|
||||
...(input.messages ?? []),
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
function createReasoningAgent(path = "/reasoning") {
|
||||
const agent = createAgent(path);
|
||||
|
||||
// Microsoft.Agents.AI.Hosting.AGUI.AspNetCore@1.0.0-preview.251110.1
|
||||
// does not emit AG-UI REASONING_MESSAGE_* events yet. Keep the backend
|
||||
// behavior intact, but add the reasoning-role stream shape CopilotKit's
|
||||
// v2 chat slots expect. Also strip replayed reasoning messages before
|
||||
// sending follow-up turns back to the .NET AG-UI host, whose input mapper
|
||||
// only accepts user/assistant/system/tool roles.
|
||||
agent.use(
|
||||
new FunctionMiddleware((input, next) => {
|
||||
// The outer createAgent middleware already stripped replay-safe ids
|
||||
// on inbound messages. Here we additionally drop reasoning-role
|
||||
// messages because the .NET AG-UI host's input mapper rejects
|
||||
// them (it only accepts user/assistant/system/tool roles).
|
||||
const sanitizedInput = {
|
||||
...input,
|
||||
messages: input.messages?.filter(
|
||||
(message) => message.role !== "reasoning",
|
||||
),
|
||||
};
|
||||
const reasoningMessageId = `${input.runId ?? crypto.randomUUID()}-reasoning`;
|
||||
const reasoningDelta =
|
||||
"I am checking the request, choosing the relevant tool or answer path, and then summarizing the result.";
|
||||
|
||||
return new Observable<BaseEvent>((subscriber) => {
|
||||
let injected = false;
|
||||
const injectReasoning = () => {
|
||||
if (injected) return;
|
||||
injected = true;
|
||||
subscriber.next({
|
||||
type: EventType.REASONING_START,
|
||||
messageId: reasoningMessageId,
|
||||
} as BaseEvent);
|
||||
subscriber.next({
|
||||
type: EventType.REASONING_MESSAGE_START,
|
||||
messageId: reasoningMessageId,
|
||||
role: "reasoning",
|
||||
} as BaseEvent);
|
||||
subscriber.next({
|
||||
type: EventType.REASONING_MESSAGE_CONTENT,
|
||||
messageId: reasoningMessageId,
|
||||
delta: reasoningDelta,
|
||||
} as BaseEvent);
|
||||
subscriber.next({
|
||||
type: EventType.REASONING_MESSAGE_END,
|
||||
messageId: reasoningMessageId,
|
||||
} as BaseEvent);
|
||||
subscriber.next({
|
||||
type: EventType.REASONING_END,
|
||||
messageId: reasoningMessageId,
|
||||
} as BaseEvent);
|
||||
};
|
||||
|
||||
const subscription = next.run(sanitizedInput).subscribe({
|
||||
next(event) {
|
||||
subscriber.next(event);
|
||||
if (event.type === EventType.RUN_STARTED) {
|
||||
injectReasoning();
|
||||
}
|
||||
},
|
||||
error(error) {
|
||||
subscriber.error(error);
|
||||
},
|
||||
complete() {
|
||||
injectReasoning();
|
||||
subscriber.complete();
|
||||
},
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
// Register the same agent under all names used by demo pages.
|
||||
const agentNames = [
|
||||
"agentic_chat",
|
||||
"human_in_the_loop",
|
||||
"tool-rendering",
|
||||
"shared-state-read",
|
||||
"prebuilt-sidebar",
|
||||
"prebuilt-popup",
|
||||
"chat-slots",
|
||||
"chat-customization-css",
|
||||
"headless-simple",
|
||||
"frontend-tools",
|
||||
"frontend-tools-async",
|
||||
// Aliases for ADK/LGP-style underscore names (frontend pages use these).
|
||||
"frontend_tools",
|
||||
"frontend_tools_async",
|
||||
];
|
||||
|
||||
// Agent names routed to the interrupt-adapted scheduling backend. Both
|
||||
// gen-ui-interrupt and interrupt-headless share the same MS Agent Framework
|
||||
// scheduling agent; only the frontend UX differs (inline in chat vs. external
|
||||
// popup driven from a button grid).
|
||||
const interruptAgentNames = ["gen-ui-interrupt", "interrupt-headless"];
|
||||
|
||||
const agents: Record<string, AbstractAgent> = {};
|
||||
for (const name of agentNames) {
|
||||
agents[name] = createAgent();
|
||||
}
|
||||
|
||||
agents["human_in_the_loop"] = createReplaySafeAgent("/", [
|
||||
"generate_task_steps",
|
||||
]);
|
||||
|
||||
agents["headless-complete"] = createAgent("/headless-complete");
|
||||
|
||||
// Interrupt-adapted demos — frontend-tool shim for LangGraph `interrupt()`.
|
||||
// Both gen-ui-interrupt and interrupt-headless share the same scheduling agent;
|
||||
// only the frontend UX differs (inline time-picker vs. external popup).
|
||||
for (const name of interruptAgentNames) {
|
||||
agents[name] = createReplaySafeAgent("/interrupt-adapted", [
|
||||
"schedule_meeting",
|
||||
]);
|
||||
}
|
||||
// In-App HITL -- async frontend-tool + app-level modal (outside chat).
|
||||
// Dedicated hitl-in-app agent mounted at /hitl-in-app on the FastAPI
|
||||
// backend; agent has tools=[] and relies on the frontend-provided
|
||||
// `request_user_approval` tool injected by CopilotKit at request time.
|
||||
agents["hitl-in-app"] = createReplaySafeAgent("/hitl-in-app", [
|
||||
"request_user_approval",
|
||||
]);
|
||||
|
||||
// In-Chat HITL -- frontend-defined `book_call` tool rendered inline in the
|
||||
// chat via `useHumanInTheLoop`. Backend agent has tools=[] and routes to
|
||||
// /hitl-in-chat on the FastAPI backend.
|
||||
agents["hitl-in-chat"] = createReplaySafeAgent("/hitl-in-chat", ["book_call"]);
|
||||
|
||||
// Generative UI Agent — backend with `set_steps` tool + `steps` state
|
||||
// schema mirrored from LGP's gen_ui_agent. The frontend renders a live
|
||||
// progress card subscribed to `agent.state.steps`.
|
||||
agents["gen-ui-agent"] = createGenUiAgent();
|
||||
|
||||
// Tool-Based Generative UI -- frontend registers `render_bar_chart` and
|
||||
// `render_pie_chart` via `useComponent`; backend agent has tools=[] and a
|
||||
// system prompt that picks the right chart type for the user's request.
|
||||
agents["gen-ui-tool-based"] = createAgent("/gen-ui-tool-based");
|
||||
|
||||
// Shared State (Streaming) — `write_document` tool with `predict_state_config`
|
||||
// that streams the tool's `document` arg into `state.document` per-token.
|
||||
// See `src/agents/shared_state_streaming.py`.
|
||||
agents["shared-state-streaming"] = createAgent("/shared-state-streaming");
|
||||
|
||||
// Readonly state via `useAgentContext` — minimal agent, no tools, reads
|
||||
// frontend-provided context entries on every turn.
|
||||
agents["readonly-state-agent-context"] = createReadonlyContextAgent();
|
||||
|
||||
// Shared State (Read + Write) — bidirectional state via state_schema +
|
||||
// state_update. Backend exposes a dedicated agent at /shared-state-read-write
|
||||
// with `preferences` + `notes` slots; UI writes preferences via setState,
|
||||
// agent writes notes via the `set_notes` tool.
|
||||
agents["shared-state-read-write"] = createSharedStateReadWriteAgent();
|
||||
|
||||
// Sub-Agents — supervisor agent at /subagents that delegates to research /
|
||||
// writing / critique sub-agents and surfaces a live `delegations` log to the
|
||||
// UI via shared state.
|
||||
agents["subagents"] = createAgent("/subagents");
|
||||
|
||||
agents["default"] = createAgent();
|
||||
|
||||
// Tool-rendering demos — share the dedicated reasoning-chain agent
|
||||
// mounted at /tool-rendering-reasoning-chain on the Python backend. All
|
||||
// three cells call the same agent; they differ only in how the frontend
|
||||
// renders tool calls.
|
||||
// Reasoning cells (`reasoning-default` + `reasoning-custom`) share a
|
||||
// dedicated backend mounted at `/reasoning` that uses the OpenAI Responses
|
||||
// API (gpt-5/o-series) — the only chat client that emits AG-UI
|
||||
// `REASONING_MESSAGE_*` events. See `src/agents/reasoning_agent.py`.
|
||||
agents["reasoning-default"] = createReasoningAgent("/reasoning");
|
||||
agents["reasoning-custom"] = createReasoningAgent("/reasoning");
|
||||
|
||||
// Tool-rendering demos — the plain `tool-rendering` cell and the two
|
||||
// catchall variants share a non-reasoning backend (mounted at
|
||||
// `/tool-rendering`). The reasoning-chain cell has its own dedicated
|
||||
// backend (mounted at `/tool-rendering-reasoning-chain`) that routes
|
||||
// through OpenAI's Responses API for reasoning streaming; mixing
|
||||
// reasoning blocks into the catchall renderers breaks the
|
||||
// default-catchall cell's spec.
|
||||
agents["tool-rendering"] = createAgent("/tool-rendering");
|
||||
agents["tool-rendering-default-catchall"] = createAgent("/tool-rendering");
|
||||
agents["tool-rendering-custom-catchall"] = createAgent("/tool-rendering");
|
||||
agents["tool-rendering-reasoning-chain"] = createReasoningAgent(
|
||||
"/tool-rendering-reasoning-chain",
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[copilotkit/route] Registered ${Object.keys(agents).length} agent names: ${Object.keys(agents).join(", ")}`,
|
||||
);
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
const url = req.url;
|
||||
const contentType = req.headers.get("content-type");
|
||||
if (ROUTE_DEBUG) {
|
||||
console.log(
|
||||
`[copilotkit/route] POST ${url} (content-type: ${contentType})`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await handleRequest(req);
|
||||
if (!response.ok) {
|
||||
console.log(`[copilotkit/route] Response status: ${response.status}`);
|
||||
} else if (ROUTE_DEBUG) {
|
||||
console.log(`[copilotkit/route] Response status: ${response.status}`);
|
||||
}
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error(`[copilotkit/route] ERROR: ${err.message}`);
|
||||
console.error(`[copilotkit/route] Stack: ${err.stack}`);
|
||||
return NextResponse.json(
|
||||
{ error: err.message, stack: err.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const GET = async () => {
|
||||
if (ROUTE_DEBUG) {
|
||||
console.log("[copilotkit/route] GET /api/copilotkit (health probe)");
|
||||
}
|
||||
|
||||
let agentStatus = "unknown";
|
||||
try {
|
||||
const res = await fetch(`${AGENT_URL}/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
agentStatus = res.ok ? "reachable" : `error (${res.status})`;
|
||||
} catch (e: unknown) {
|
||||
agentStatus = `unreachable (${(e as Error).message})`;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
agent_url: AGENT_URL,
|
||||
agent_status: agentStatus,
|
||||
env: {
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// Token-gated: SHOWCASE_DEBUG_TOKEN must be set in env and matched
|
||||
const token =
|
||||
req.headers.get("x-debug-token") || req.nextUrl.searchParams.get("token");
|
||||
const expectedToken = process.env.SHOWCASE_DEBUG_TOKEN;
|
||||
|
||||
if (!expectedToken || !token || token !== expectedToken) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "unknown";
|
||||
|
||||
// Agent connectivity
|
||||
let agentStatus = "unknown";
|
||||
let agentDetail = "";
|
||||
try {
|
||||
const res = await fetch(`${AGENT_URL}/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
agentStatus = res.ok ? "ok" : "error";
|
||||
agentDetail = `HTTP ${res.status}`;
|
||||
} catch (e: unknown) {
|
||||
agentStatus = "down";
|
||||
agentDetail = (e as Error).message;
|
||||
}
|
||||
|
||||
const uptime = process.uptime();
|
||||
const mem = process.memoryUsage();
|
||||
|
||||
return NextResponse.json({
|
||||
integration: "ms-agent-dotnet",
|
||||
uptime: `${Math.floor(uptime / 60)}m ${Math.floor(uptime % 60)}s`,
|
||||
agent: { url: AGENT_URL, status: agentStatus, detail: agentDetail },
|
||||
memory: {
|
||||
rss: `${Math.round(mem.rss / 1024 / 1024)}MB`,
|
||||
heapUsed: `${Math.round(mem.heapUsed / 1024 / 1024)}MB`,
|
||||
},
|
||||
env: {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ? "set" : "NOT SET",
|
||||
LANGSMITH_API_KEY: process.env.LANGSMITH_API_KEY ? "set" : "NOT SET",
|
||||
},
|
||||
nodeVersion: process.version,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
integration: "ms-agent-dotnet",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const INTEGRATION_SLUG = "ms-agent-dotnet";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function GET() {
|
||||
const start = Date.now();
|
||||
// Hit our own /api/copilotkit endpoint — tests the full deployed stack
|
||||
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: "agentic_chat" },
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
// TTFB: read first chunk only to confirm SSE stream started, then cancel
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
// Shared fallback time-slot generator for the interrupt demos
|
||||
// (`gen-ui-interrupt`, `interrupt-headless`). The interrupt backend
|
||||
// (`src/agents/interrupt_agent.py`) supplies its own candidate slots
|
||||
// inside the interrupt payload — these fallbacks only run if the
|
||||
// payload arrives without them. Generating relative to `Date.now()`
|
||||
// keeps the fallback from rotting, which previously had hardcoded
|
||||
// dates that decayed within a week of being authored.
|
||||
|
||||
export interface TimeSlot {
|
||||
label: string;
|
||||
iso: string;
|
||||
}
|
||||
|
||||
function atLocal(date: Date, hour: number, minute = 0): Date {
|
||||
return new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
hour,
|
||||
minute,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function nextMonday(from: Date): Date {
|
||||
// `getDay()` is 0=Sun, 1=Mon, ..., 6=Sat. We want the next Monday
|
||||
// that's at LEAST 2 days away — otherwise "Monday" would collide
|
||||
// with "Tomorrow" on Sunday (offset would be 1) or with itself on
|
||||
// Monday (offset would be 0). Mirrors interrupt_agent.py.
|
||||
const day = from.getDay();
|
||||
let offset = (1 - day + 7) % 7;
|
||||
if (offset <= 1) offset += 7;
|
||||
const next = new Date(from);
|
||||
next.setDate(from.getDate() + offset);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function generateFallbackSlots(now: Date = new Date()): TimeSlot[] {
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(now.getDate() + 1);
|
||||
const monday = nextMonday(now);
|
||||
|
||||
const candidates: Array<[string, Date]> = [
|
||||
["Tomorrow 10:00 AM", atLocal(tomorrow, 10)],
|
||||
["Tomorrow 2:00 PM", atLocal(tomorrow, 14)],
|
||||
["Monday 9:00 AM", atLocal(monday, 9)],
|
||||
["Monday 3:30 PM", atLocal(monday, 15, 30)],
|
||||
];
|
||||
|
||||
return candidates.map(([label, date]) => ({
|
||||
label,
|
||||
iso: date.toISOString(),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Coerces a tool-call result into a typed object. The .NET AG-UI adapter can
|
||||
// wrap JSON string tool results in another JSON string, so parse a few layers.
|
||||
export function parseJsonResult<T>(result: unknown): T {
|
||||
let parsed = result;
|
||||
|
||||
for (let depth = 0; depth < 3 && typeof parsed === "string"; depth += 1) {
|
||||
if (!parsed.trim()) return {} as T;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(parsed);
|
||||
} catch {
|
||||
return {} as T;
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
return parsed as T;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Helper for the CopilotChat slot overrides. The slot prop types in
|
||||
// `@copilotkit/react-core` are nominally typed against the *exact*
|
||||
// default component identity, but a custom wrapper that returns a
|
||||
// structurally compatible ReactElement is functionally a drop-in. This
|
||||
// helper names that fact and centralizes the type assertion in one
|
||||
// place — readers see `makeSlotOverride` and know it's about the slot
|
||||
// contract, not arbitrary type-system gymnastics. Once the slot prop
|
||||
// types accept structural compatibility, this helper can be deleted
|
||||
// and the casts will resolve automatically.
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
// `any` on the input is intentional: the helper's purpose is to accept
|
||||
// any component shape and assert it as the slot's expected type. A
|
||||
// stricter constraint would defeat the whole point.
|
||||
export function makeSlotOverride<TDefault>(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
component: ComponentType<any>,
|
||||
): TDefault {
|
||||
return component as unknown as TDefault;
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Badge primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes, no `cn()`/`cva` helpers.
|
||||
*/
|
||||
type Variant = "default" | "secondary" | "outline" | "success";
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
default: "border-transparent bg-neutral-900 text-neutral-50",
|
||||
secondary: "border-transparent bg-neutral-100 text-neutral-900",
|
||||
outline: "border-neutral-200 text-neutral-700 bg-white",
|
||||
success: "border-transparent bg-emerald-100 text-emerald-700",
|
||||
};
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: Variant;
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
className = "",
|
||||
variant = "default",
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium tracking-wide ${variantClasses[variant]} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Button primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes, no `cn()`/`cva` helpers.
|
||||
*/
|
||||
type Variant = "default" | "outline" | "secondary" | "ghost" | "success";
|
||||
type Size = "default" | "sm" | "lg";
|
||||
|
||||
const baseClasses =
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60";
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
default: "bg-neutral-900 text-neutral-50 shadow-sm hover:bg-neutral-800",
|
||||
outline:
|
||||
"border border-neutral-200 bg-white text-neutral-900 shadow-sm hover:bg-neutral-100",
|
||||
secondary: "bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-200",
|
||||
ghost: "hover:bg-neutral-100 hover:text-neutral-900",
|
||||
success:
|
||||
"bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm hover:bg-emerald-50",
|
||||
};
|
||||
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-11 rounded-md px-6",
|
||||
};
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{ className = "", variant = "default", size = "default", ...props },
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Card primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes, no `cn()`/`cva` helpers.
|
||||
*/
|
||||
export const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`rounded-xl border border-neutral-200 bg-white text-neutral-950 shadow-sm ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
export const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`flex flex-col space-y-1.5 p-5 pb-3 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
export const CardTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={`text-base font-semibold leading-none tracking-tight text-neutral-900 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
export const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div ref={ref} className={`p-5 pt-0 ${className}`} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
export const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`flex items-center p-5 pt-0 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Separator primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes; uses a div instead of Radix to keep dependencies minimal.
|
||||
*/
|
||||
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
export function Separator({
|
||||
className = "",
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorProps) {
|
||||
const orientationClasses =
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px";
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation={orientation}
|
||||
className={`shrink-0 bg-neutral-200 ${orientationClasses} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 { definitions } from "./definitions";
|
||||
import { renderers } from "./renderers";
|
||||
|
||||
export const CATALOG_ID = "copilotkit://flight-fixed-catalog";
|
||||
|
||||
export const catalog = createCatalog(definitions, renderers, {
|
||||
catalogId: CATALOG_ID,
|
||||
includeBasicCatalog: true,
|
||||
});
|
||||
// @endregion[catalog-creation]
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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 and the visual overrides
|
||||
* here. (Custom entries with the same name as a basic component override
|
||||
* the basic one — Catalog dedupes by `comp.name`, last-write-wins.)
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
// @region[definitions-types]
|
||||
import { z } from "zod";
|
||||
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 definitions = {
|
||||
/**
|
||||
* Card override: gives the outer flight-card container a ShadCN look
|
||||
* (rounded-xl, neutral-200 border, soft shadow). The basic catalog's
|
||||
* Card uses inline styles; overriding here lets the demo's renderer
|
||||
* adopt the demo's Tailwind aesthetic without touching the schema JSON.
|
||||
*/
|
||||
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.any()).optional(),
|
||||
}),
|
||||
}),
|
||||
z.null(),
|
||||
])
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
} satisfies CatalogDefinitions;
|
||||
// @endregion[definitions-types]
|
||||
|
||||
export type Definitions = typeof definitions;
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
"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.
|
||||
*
|
||||
* Visual style: ShadCN aesthetic (neutral palette, rounded-xl, subtle
|
||||
* borders, clean typography). Tailwind utility classes only — no `cn()` /
|
||||
* `cva` helpers, no shadcn CLI install. Inline-cloned primitives live in
|
||||
* `../_components/`.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
import type { Definitions } from "./definitions";
|
||||
import { Card } from "../_components/card";
|
||||
import { Badge } from "../_components/badge";
|
||||
import { Button as UIButton } from "../_components/button";
|
||||
import { Separator } from "../_components/separator";
|
||||
|
||||
// `DynString` props are typed as `string | { path }` (see definitions.ts), but
|
||||
// the A2UI binder resolves path bindings before render — renderers only ever
|
||||
// see resolved strings. One shared helper keeps that narrowing in one place.
|
||||
const s = (v: unknown): string => (typeof v === "string" ? v : "");
|
||||
|
||||
// @region[renderers-tsx]
|
||||
export const renderers: CatalogRenderers<Definitions> = {
|
||||
/**
|
||||
* Card override: ShadCN-style outer container. The basic catalog's Card
|
||||
* uses inline styles; overriding here keeps the demo's tailwind aesthetic.
|
||||
* The flight schema renders Card > Column > [Title, Row, …]; the inner
|
||||
* Column adds the vertical spacing.
|
||||
*/
|
||||
Card: ({ props, children }) => (
|
||||
<Card className="w-full max-w-md p-5" data-testid="a2ui-fixed-card">
|
||||
{props.child ? children(props.child) : null}
|
||||
</Card>
|
||||
),
|
||||
Title: ({ props }) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
|
||||
Itinerary
|
||||
</p>
|
||||
<h3 className="text-base font-semibold leading-none tracking-tight text-neutral-900">
|
||||
{s(props.text)}
|
||||
</h3>
|
||||
</div>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
1-stop · economy
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
Airport: ({ props }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="font-mono text-2xl font-semibold tracking-wider text-neutral-900">
|
||||
{s(props.code)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
Arrow: () => (
|
||||
<div className="flex flex-1 items-center px-3">
|
||||
<Separator className="flex-1 bg-neutral-200" />
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mx-1 text-neutral-400"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
<Separator className="flex-1 bg-neutral-200" />
|
||||
</div>
|
||||
),
|
||||
AirlineBadge: ({ props }) => (
|
||||
<Badge variant="secondary" className="uppercase tracking-[0.08em]">
|
||||
{s(props.name)}
|
||||
</Badge>
|
||||
),
|
||||
PriceTag: ({ props }) => (
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
|
||||
Total
|
||||
</span>
|
||||
<span className="font-mono text-base font-semibold text-neutral-900">
|
||||
{s(props.amount)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
/**
|
||||
* Button override: this is a pure-presentation demo, so the button just
|
||||
* renders its label. The schema declares an `action` for visual fidelity,
|
||||
* but the click handler is inert until the Python SDK exposes
|
||||
* `action_handlers=` on `a2ui.render` (see `src/agents/a2ui_fixed.py`).
|
||||
*/
|
||||
Button: ({ props, children }) => (
|
||||
<UIButton className="w-full">
|
||||
{props.child ? children(props.child) : null}
|
||||
</UIButton>
|
||||
),
|
||||
};
|
||||
// @endregion[renderers-tsx]
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import { useA2UIFixedSchemaSuggestions } from "./suggestions";
|
||||
|
||||
export function Chat() {
|
||||
useA2UIFixedSchemaSuggestions();
|
||||
return (
|
||||
<CopilotChat agentId="a2ui-fixed-schema" className="h-full rounded-2xl" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"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
|
||||
* `src/agents/a2ui_schemas/flight_schema.json` (Card > Column > [Title, Row, …]).
|
||||
*
|
||||
* - Definitions (zod schemas): `./a2ui/definitions.ts`
|
||||
* - Renderers (React): `./a2ui/renderers.tsx`
|
||||
* - Catalog wiring: `./a2ui/catalog.ts` (includes the basic catalog)
|
||||
* - Agent: `src/agents/a2ui_fixed.py` (emits an `a2ui_operations` container)
|
||||
*
|
||||
* Reference:
|
||||
* https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui/fixed-schema
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { CopilotKit } from "@copilotkit/react-core/v2";
|
||||
|
||||
import { catalog } from "./a2ui/catalog";
|
||||
import { Chat } from "./chat";
|
||||
|
||||
export default function A2UIFixedSchemaDemo() {
|
||||
return (
|
||||
// `a2ui.catalog` wires the fixed catalog into the A2UI activity renderer.
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit-a2ui-fixed-schema"
|
||||
agent="a2ui-fixed-schema"
|
||||
a2ui={{ catalog: catalog }}
|
||||
>
|
||||
<div className="flex justify-center items-center h-screen w-full bg-neutral-50">
|
||||
<div className="h-full w-full max-w-4xl border-x border-neutral-200 bg-white">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKit>
|
||||
);
|
||||
}
|
||||
@@ -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,93 @@
|
||||
"use client";
|
||||
|
||||
import type { ChangeEvent } from "react";
|
||||
import {
|
||||
EXPERTISE_OPTIONS,
|
||||
RESPONSE_LENGTH_OPTIONS,
|
||||
TONE_OPTIONS,
|
||||
} from "./config-types";
|
||||
import type {
|
||||
AgentConfig,
|
||||
Expertise,
|
||||
ResponseLength,
|
||||
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-[var(--border)] bg-[var(--bg-surface)] p-4 text-sm"
|
||||
>
|
||||
<h2 className="text-sm font-semibold">Agent Config</h2>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
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-[var(--border)] bg-[var(--bg-muted)] 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-[var(--border)] bg-[var(--bg-muted)] 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-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
|
||||
>
|
||||
{RESPONSE_LENGTH_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Publishes the current agent-config toggles to the agent runtime via
|
||||
* `useAgentContext`. Lives inside the `<CopilotKit>` provider so the
|
||||
* context store is reachable. The middleware on the Python side reads
|
||||
* this entry off the agent's runtime context on every turn and routes
|
||||
* it into the model's prompt.
|
||||
*/
|
||||
|
||||
import { useAgentContext } from "@copilotkit/react-core/v2";
|
||||
import type { AgentConfig } from "./config-types";
|
||||
|
||||
export function ConfigContextRelay({ config }: { config: AgentConfig }) {
|
||||
useAgentContext({
|
||||
description:
|
||||
"Agent response preferences. Apply tone, expertise level, and response length to every reply.",
|
||||
value: {
|
||||
tone: config.tone,
|
||||
expertise: config.expertise,
|
||||
responseLength: config.responseLength,
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -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,38 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
|
||||
import { ConfigCard } from "./config-card";
|
||||
import type { AgentConfig } from "./config-types";
|
||||
|
||||
interface DemoLayoutProps {
|
||||
config: AgentConfig;
|
||||
onToneChange: (tone: AgentConfig["tone"]) => void;
|
||||
onExpertiseChange: (expertise: AgentConfig["expertise"]) => void;
|
||||
onResponseLengthChange: (length: AgentConfig["responseLength"]) => void;
|
||||
}
|
||||
|
||||
export function DemoLayout({
|
||||
config,
|
||||
onToneChange,
|
||||
onExpertiseChange,
|
||||
onResponseLengthChange,
|
||||
}: DemoLayoutProps) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col gap-3 p-6">
|
||||
<ConfigCard
|
||||
config={config}
|
||||
onToneChange={onToneChange}
|
||||
onExpertiseChange={onExpertiseChange}
|
||||
onResponseLengthChange={onResponseLengthChange}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden rounded-md border border-[var(--border)]">
|
||||
<CopilotChat
|
||||
agentId="agent-config-demo"
|
||||
className="h-full rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Agent Config Object - typed config knobs (tone / expertise / responseLength)
|
||||
* forwarded from the provider into the agent so its behavior changes per turn.
|
||||
*
|
||||
* Wiring: the toggles live in `useAgentConfig`. Each render publishes the
|
||||
* resolved config through both CopilotKit `properties` and `useAgentContext`.
|
||||
* The Microsoft Agent Framework route reads `properties` as AG-UI
|
||||
* `forwardedProps`; the context relay keeps the demo aligned with the
|
||||
* LangGraph Python v2 pattern.
|
||||
*/
|
||||
|
||||
import { CopilotKit } from "@copilotkit/react-core/v2";
|
||||
|
||||
import { DemoLayout } from "./demo-layout";
|
||||
import { ConfigContextRelay } from "./config-context-relay";
|
||||
import { useAgentConfig } from "./use-agent-config";
|
||||
|
||||
export default function AgentConfigDemoPage() {
|
||||
const { config, setTone, setExpertise, setResponseLength } = useAgentConfig();
|
||||
|
||||
return (
|
||||
// @region[provider-setup]
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit-agent-config"
|
||||
agent="agent-config-demo"
|
||||
properties={config}
|
||||
>
|
||||
<ConfigContextRelay config={config} />
|
||||
<DemoLayout
|
||||
config={config}
|
||||
onToneChange={setTone}
|
||||
onExpertiseChange={setExpertise}
|
||||
onResponseLengthChange={setResponseLength}
|
||||
/>
|
||||
</CopilotKit>
|
||||
// @endregion[provider-setup]
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Agentic Chat
|
||||
|
||||
## What This Demo Shows
|
||||
|
||||
The simplest CopilotKit surface: a plain agentic chat backed by a LangGraph (Python) agent.
|
||||
|
||||
- **Natural Conversation**: Chat with your Copilot in a familiar chat interface
|
||||
- **Streaming Responses**: Assistant messages stream in token-by-token via AG-UI
|
||||
- **Suggestion Chips**: A starter suggestion is rendered as a quick-action chip
|
||||
|
||||
## How to Interact
|
||||
|
||||
Click the suggestion chip, or type your own prompt. For example:
|
||||
|
||||
- "Write a short sonnet about AI"
|
||||
- "Explain the difference between an LLM and an agent"
|
||||
- "Give me three ideas for a weekend project"
|
||||
|
||||
## Technical Details
|
||||
|
||||
**Provider** — `CopilotKit` wires the page to the runtime:
|
||||
|
||||
- `runtimeUrl="/api/copilotkit"` points at the Next.js route that proxies to the agent
|
||||
- `agent="agentic_chat"` selects the LangGraph agent defined in `langgraph.json`
|
||||
|
||||
**Chat surface** — `CopilotChat` renders the full chat UI with input, message list, and streaming.
|
||||
|
||||
**Suggestions** — `useConfigureSuggestions` registers a static suggestion that appears as a clickable chip below the chat input.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
// Docs-only snippet — not imported or rendered. The actual route is served
|
||||
// by page.tsx, which carries QA hooks (frontend tools, render tools, agent
|
||||
// context) 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 region 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",
|
||||
});
|
||||
|
||||
return <CopilotChat agentId="agentic_chat" className="h-full rounded-2xl" />;
|
||||
}
|
||||
// @endregion[chat-component]
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { CopilotKit, CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import { useAgenticChatSuggestions } from "./suggestions";
|
||||
|
||||
export default function AgenticChatDemo() {
|
||||
return (
|
||||
// @region[provider-setup]
|
||||
<CopilotKit runtimeUrl="/api/copilotkit" agent="agentic_chat">
|
||||
<Chat />
|
||||
</CopilotKit>
|
||||
// @endregion[provider-setup]
|
||||
);
|
||||
}
|
||||
|
||||
function Chat() {
|
||||
useAgenticChatSuggestions();
|
||||
// @region[render-chat]
|
||||
return <CopilotChat agentId="agentic_chat" />;
|
||||
// @endregion[render-chat]
|
||||
}
|
||||
@@ -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,63 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
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.
|
||||
*/
|
||||
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"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onSignOut}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="auth-authenticate-button"
|
||||
size="sm"
|
||||
onClick={onSignIn}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Shared demo-token constant imported by both the client
|
||||
* (use-demo-auth.ts) and the server runtime route
|
||||
* (api/copilotkit-auth/route.ts). Keeping the constant in one file
|
||||
* prevents drift: changing the token in one place changes it everywhere.
|
||||
*
|
||||
* 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,149 @@
|
||||
"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 render `<CopilotKit>` 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, `<CopilotKit>` 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 `<CopilotKit
|
||||
// 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 `<CopilotKit onError>` never renders the rejection banner. We register
|
||||
// the same handler on BOTH channels: `<CopilotKit 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.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { CopilotKit, 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.
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit-auth"
|
||||
agent="auth-demo"
|
||||
headers={headers}
|
||||
useSingleEndpoint={false}
|
||||
// The dev-only inspector overlay is auto-enabled on localhost after
|
||||
// <CopilotKit> mounts. In this demo that happens only after the first
|
||||
// sign-in, so the post-sign-out Sign in button can sit underneath the
|
||||
// overlay in local/D5 runs even though production never shows it.
|
||||
enableInspector={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
|
||||
agentId="auth-demo"
|
||||
className="h-full"
|
||||
onError={handleAuthError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKit>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
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 `<CopilotKit>`.
|
||||
*/
|
||||
export function SignInCard({ onSignIn }: SignInCardProps) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<Card data-testid="auth-sign-in-card" className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in to start chatting</CardTitle>
|
||||
<CardDescription>
|
||||
The runtime rejects requests without an{" "}
|
||||
<code className="rounded bg-muted 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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Demo token
|
||||
</p>
|
||||
<code
|
||||
data-testid="auth-demo-token"
|
||||
className="mt-1 block rounded-md border bg-muted px-3 py-2 font-mono text-sm"
|
||||
>
|
||||
{DEMO_TOKEN}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Real apps should issue per-user tokens via your identity provider
|
||||
and never hard-code shared secrets.
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="auth-sign-in-button"
|
||||
className="w-full"
|
||||
onClick={() => onSignIn(DEMO_TOKEN)}
|
||||
>
|
||||
Sign in with demo token
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</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,
|
||||
};
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useAgent } from "@copilotkit/react-core/v2";
|
||||
import { TodoList } from "./todo-list";
|
||||
|
||||
export function ExampleCanvas() {
|
||||
const { agent } = useAgent({ agentId: "beautiful-chat" });
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-[--background]">
|
||||
<div className="max-w-4xl mx-auto px-8 py-10 h-full">
|
||||
<TodoList
|
||||
todos={agent.state?.todos || []}
|
||||
onUpdate={(updatedTodos) => agent.setState({ todos: updatedTodos })}
|
||||
isAgentRunning={agent.isRunning}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Card } from "../ui/card";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import { Button } from "../ui/button";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface Todo {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
status: "pending" | "completed";
|
||||
}
|
||||
|
||||
interface TodoCardProps {
|
||||
todo: Todo;
|
||||
onToggleStatus: (todo: Todo) => void;
|
||||
onDelete: (todo: Todo) => void;
|
||||
onUpdateTitle: (todoId: string, title: string) => void;
|
||||
onUpdateDescription: (todoId: string, description: string) => void;
|
||||
onUpdateEmoji: (todoId: string, emoji: string) => void;
|
||||
}
|
||||
|
||||
const EMOJI_OPTIONS = ["✅", "🔥", "🎯", "💡", "🚀"];
|
||||
|
||||
export function TodoCard({
|
||||
todo,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
onUpdateTitle,
|
||||
onUpdateDescription,
|
||||
onUpdateEmoji,
|
||||
}: TodoCardProps) {
|
||||
const [editingField, setEditingField] = useState<
|
||||
"title" | "description" | null
|
||||
>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const isCompleted = todo.status === "completed";
|
||||
const truncatedDescription =
|
||||
todo.description.length > 120
|
||||
? todo.description.slice(0, 120) + "..."
|
||||
: todo.description;
|
||||
|
||||
const startEdit = (field: "title" | "description") => {
|
||||
setEditingField(field);
|
||||
setEditValue(field === "title" ? todo.title : todo.description);
|
||||
};
|
||||
|
||||
const saveEdit = (field: "title" | "description") => {
|
||||
if (editValue.trim()) {
|
||||
if (field === "title") {
|
||||
onUpdateTitle(todo.id, editValue.trim());
|
||||
} else {
|
||||
onUpdateDescription(todo.id, editValue.trim());
|
||||
}
|
||||
}
|
||||
setEditingField(null);
|
||||
setEditValue("");
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingField(null);
|
||||
setEditValue("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height =
|
||||
textareaRef.current.scrollHeight + "px";
|
||||
}
|
||||
}, [editValue]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"group relative p-5 transition-all duration-150",
|
||||
isCompleted && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{/* Delete — top right on hover */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(todo)}
|
||||
className="absolute top-3 right-3 h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label="Delete todo"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
{/* Emoji avatar */}
|
||||
<div className="relative inline-block mb-3">
|
||||
<button
|
||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||
className={cn(
|
||||
"block text-3xl leading-none cursor-pointer rounded-xl p-2 transition-colors",
|
||||
isCompleted ? "bg-[var(--muted)]" : "bg-[var(--secondary)]",
|
||||
)}
|
||||
aria-label="Change emoji"
|
||||
>
|
||||
{todo.emoji}
|
||||
</button>
|
||||
|
||||
{showEmojiPicker && (
|
||||
<div className="absolute top-0 left-full ml-2 z-10 flex gap-1 p-1.5 rounded-full bg-[var(--card)] border border-[var(--border)] shadow-lg">
|
||||
{EMOJI_OPTIONS.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => {
|
||||
onUpdateEmoji(todo.id, emoji);
|
||||
setShowEmojiPicker(false);
|
||||
}}
|
||||
className="text-lg w-8 h-8 flex items-center justify-center rounded-full cursor-pointer transition-colors hover:bg-[var(--secondary)]"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isCompleted}
|
||||
onCheckedChange={() => onToggleStatus(todo)}
|
||||
className="mt-[2px]"
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingField === "title" ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={() => saveEdit("title")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveEdit("title");
|
||||
if (e.key === "Escape") cancelEdit();
|
||||
}}
|
||||
className="w-full text-base font-semibold focus:outline-none bg-transparent text-[var(--foreground)] border-b-2 border-[var(--primary)] pb-[2px]"
|
||||
autoFocus
|
||||
aria-label="Edit todo title"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => startEdit("title")}
|
||||
className={cn(
|
||||
"text-base font-semibold cursor-text break-words leading-snug",
|
||||
isCompleted
|
||||
? "text-[var(--muted-foreground)] line-through"
|
||||
: "text-[var(--foreground)]",
|
||||
)}
|
||||
>
|
||||
{todo.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingField === "description" ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={() => saveEdit("description")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") cancelEdit();
|
||||
}}
|
||||
className="w-full mt-1.5 text-sm leading-relaxed focus:outline-none resize-none bg-transparent text-[var(--muted-foreground)] border-b-2 border-[var(--primary)] pb-[2px]"
|
||||
rows={1}
|
||||
autoFocus
|
||||
aria-label="Edit todo description"
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
onClick={() => startEdit("description")}
|
||||
className={cn(
|
||||
"mt-1.5 text-sm leading-relaxed cursor-text",
|
||||
isCompleted
|
||||
? "text-[var(--muted-foreground)] line-through"
|
||||
: "text-[var(--muted-foreground)]",
|
||||
)}
|
||||
>
|
||||
{truncatedDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { TodoCard } from "./todo-card";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
interface Todo {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
status: "pending" | "completed";
|
||||
}
|
||||
|
||||
interface TodoColumnProps {
|
||||
title: string;
|
||||
todos: Todo[];
|
||||
emptyMessage: string;
|
||||
showAddButton?: boolean;
|
||||
onAddTodo?: () => void;
|
||||
onToggleStatus: (todo: Todo) => void;
|
||||
onDelete: (todo: Todo) => void;
|
||||
onUpdateTitle: (todoId: string, title: string) => void;
|
||||
onUpdateDescription: (todoId: string, description: string) => void;
|
||||
onUpdateEmoji: (todoId: string, emoji: string) => void;
|
||||
isAgentRunning: boolean;
|
||||
}
|
||||
|
||||
export function TodoColumn({
|
||||
title,
|
||||
todos,
|
||||
emptyMessage,
|
||||
showAddButton = false,
|
||||
onAddTodo,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
onUpdateTitle,
|
||||
onUpdateDescription,
|
||||
onUpdateEmoji,
|
||||
isAgentRunning,
|
||||
}: TodoColumnProps) {
|
||||
return (
|
||||
<section aria-label={`${title} column`} className="flex-1 min-w-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-bold tracking-tight text-[var(--foreground)]">
|
||||
{title}
|
||||
</h2>
|
||||
<Badge variant="secondary">{todos.length}</Badge>
|
||||
</div>
|
||||
{showAddButton && onAddTodo && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onAddTodo}
|
||||
disabled={isAgentRunning}
|
||||
aria-label="Add new todo"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="space-y-3">
|
||||
{todos.length === 0 ? (
|
||||
<div className="text-center text-sm rounded-[var(--radius)] border-2 border-dashed border-[var(--border)] p-5 min-h-[151px] flex items-center justify-center text-[var(--muted-foreground)]">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
todos.map((todo) => (
|
||||
<TodoCard
|
||||
key={todo.id}
|
||||
todo={todo}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onDelete={onDelete}
|
||||
onUpdateTitle={onUpdateTitle}
|
||||
onUpdateDescription={onUpdateDescription}
|
||||
onUpdateEmoji={onUpdateEmoji}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { TodoColumn } from "./todo-column";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface Todo {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
status: "pending" | "completed";
|
||||
}
|
||||
|
||||
interface TodoListProps {
|
||||
todos: Todo[];
|
||||
onUpdate: (todos: Todo[]) => void;
|
||||
isAgentRunning: boolean;
|
||||
}
|
||||
|
||||
export function TodoList({ todos, onUpdate, isAgentRunning }: TodoListProps) {
|
||||
const pendingTodos = todos.filter((t) => t.status === "pending");
|
||||
const completedTodos = todos.filter((t) => t.status === "completed");
|
||||
|
||||
const toggleStatus = (todo: Todo) => {
|
||||
const updated = todos.map((t) =>
|
||||
t.id === todo.id
|
||||
? {
|
||||
...t,
|
||||
status: (t.status === "completed" ? "pending" : "completed") as
|
||||
| "pending"
|
||||
| "completed",
|
||||
}
|
||||
: t,
|
||||
);
|
||||
onUpdate(updated);
|
||||
};
|
||||
|
||||
const deleteTodo = (todo: Todo) => {
|
||||
onUpdate(todos.filter((t) => t.id !== todo.id));
|
||||
};
|
||||
|
||||
const updateTitle = (todoId: string, title: string) => {
|
||||
const updated = todos.map((t) => (t.id === todoId ? { ...t, title } : t));
|
||||
onUpdate(updated);
|
||||
};
|
||||
|
||||
const updateDescription = (todoId: string, description: string) => {
|
||||
const updated = todos.map((t) =>
|
||||
t.id === todoId ? { ...t, description } : t,
|
||||
);
|
||||
onUpdate(updated);
|
||||
};
|
||||
|
||||
const updateEmoji = (todoId: string, emoji: string) => {
|
||||
const updated = todos.map((t) => (t.id === todoId ? { ...t, emoji } : t));
|
||||
onUpdate(updated);
|
||||
};
|
||||
|
||||
const addTodo = () => {
|
||||
const newTodo: Todo = {
|
||||
id: crypto.randomUUID(),
|
||||
title: "New Todo",
|
||||
description: "Add a description",
|
||||
emoji: "🎯",
|
||||
status: "pending",
|
||||
};
|
||||
onUpdate([...todos, newTodo]);
|
||||
};
|
||||
|
||||
if (!todos || todos.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||
<div className="text-5xl">✏️</div>
|
||||
<p className="text-base font-semibold text-[--foreground]">
|
||||
No todos yet
|
||||
</p>
|
||||
<p className="text-sm text-[--muted-foreground]">
|
||||
Create your first task to get started
|
||||
</p>
|
||||
<Button onClick={addTodo} disabled={isAgentRunning} className="mt-2">
|
||||
Add a task
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-8 h-full">
|
||||
<TodoColumn
|
||||
title="To Do"
|
||||
todos={pendingTodos}
|
||||
emptyMessage="No pending todos"
|
||||
showAddButton
|
||||
onAddTodo={addTodo}
|
||||
onToggleStatus={toggleStatus}
|
||||
onDelete={deleteTodo}
|
||||
onUpdateTitle={updateTitle}
|
||||
onUpdateDescription={updateDescription}
|
||||
onUpdateEmoji={updateEmoji}
|
||||
isAgentRunning={isAgentRunning}
|
||||
/>
|
||||
<TodoColumn
|
||||
title="Done"
|
||||
todos={completedTodos}
|
||||
emptyMessage="No completed todos yet"
|
||||
onToggleStatus={toggleStatus}
|
||||
onDelete={deleteTodo}
|
||||
onUpdateTitle={updateTitle}
|
||||
onUpdateDescription={updateDescription}
|
||||
onUpdateEmoji={updateEmoji}
|
||||
isAgentRunning={isAgentRunning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { ModeToggle } from "./mode-toggle";
|
||||
import { useFrontendTool } from "@copilotkit/react-core/v2";
|
||||
|
||||
interface ExampleLayoutProps {
|
||||
chatContent: ReactNode;
|
||||
appContent: ReactNode;
|
||||
}
|
||||
|
||||
export function ExampleLayout({ chatContent, appContent }: ExampleLayoutProps) {
|
||||
const [mode, setMode] = useState<"chat" | "app">("chat");
|
||||
|
||||
useFrontendTool({
|
||||
name: "enableAppMode",
|
||||
description:
|
||||
"Enable app mode, make sure its open when interacting with todos.",
|
||||
handler: async () => {
|
||||
setMode("app");
|
||||
},
|
||||
});
|
||||
|
||||
useFrontendTool({
|
||||
name: "enableChatMode",
|
||||
description: "Enable chat mode",
|
||||
handler: async () => {
|
||||
setMode("chat");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-row pb-6">
|
||||
<ModeToggle mode={mode} onModeChange={setMode} />
|
||||
|
||||
{/* Chat Content */}
|
||||
<div
|
||||
className={`max-h-full flex flex-col dark:bg-stone-950 ${
|
||||
mode === "app"
|
||||
? "w-1/3 px-6 max-lg:hidden" // Hide on mobile in app mode
|
||||
: "flex-1 max-lg:px-4"
|
||||
}`}
|
||||
>
|
||||
<div className="shrink-0 pt-6 pl-6 pb-2 max-lg:pl-4 max-lg:pt-4 flex gap-1.5 items-center align-center">
|
||||
<span className="font-extrabold text-2xl pb-1.5">CopilotKit</span>
|
||||
<img
|
||||
src="/copilotkit-logo-mark.svg"
|
||||
alt="CopilotKit"
|
||||
className="h-7"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">{chatContent}</div>
|
||||
</div>
|
||||
|
||||
{/* State Panel */}
|
||||
<div
|
||||
className={`h-full overflow-hidden ${
|
||||
mode === "app"
|
||||
? "w-2/3 max-lg:w-full border-l border-[var(--border)] max-lg:border-l-0" // Full width on mobile
|
||||
: "w-0 border-l-0"
|
||||
}`}
|
||||
>
|
||||
<div className="w-full lg:w-[66.666vw] h-full">{appContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
interface ModeToggleProps {
|
||||
mode: "chat" | "app";
|
||||
onModeChange: (mode: "chat" | "app") => void;
|
||||
}
|
||||
|
||||
export function ModeToggle({ mode, onModeChange }: ModeToggleProps) {
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 flex rounded-full border border-[var(--border)] bg-[var(--secondary)] p-0.5 max-lg:top-2 max-lg:right-2 max-lg:scale-90">
|
||||
<button
|
||||
onClick={() => onModeChange("chat")}
|
||||
className={`px-4 py-1.5 rounded-full text-[13px] font-medium transition-all cursor-pointer ${
|
||||
mode === "chat"
|
||||
? "bg-[var(--card)] text-[var(--card-foreground)] shadow-sm"
|
||||
: "text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
>
|
||||
Chat
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onModeChange("app")}
|
||||
className={`px-4 py-1.5 rounded-full text-[13px] font-medium transition-all cursor-pointer ${
|
||||
mode === "app"
|
||||
? "bg-[var(--card)] text-[var(--card-foreground)] shadow-sm"
|
||||
: "text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
>
|
||||
App
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+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,
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ComponentRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-5 w-5 shrink-0 rounded-md border border-[var(--border)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[var(--primary)] data-[state=checked]:text-[var(--primary-foreground)] data-[state=checked]:border-transparent cursor-pointer transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
|
||||
<Check className="h-3.5 w-3.5" strokeWidth={3} />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-[var(--radius)] border border-[var(--input)] bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-[var(--muted-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ComponentRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-[var(--border)]",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
+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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Demonstration Catalog — Component Definitions
|
||||
*
|
||||
* Platform-agnostic definitions: component names, props (Zod), descriptions.
|
||||
* This is the contract between the app and the AI agent. Agents receive these
|
||||
* definitions as context so they know what components are available.
|
||||
*
|
||||
* Renderers (React, React Native, etc.) import these definitions and provide
|
||||
* platform-specific implementations, type-checked against the Zod schemas.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Dynamic string: accepts either a literal string or a data-model path binding
|
||||
* like `{ path: "airline" }`. 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 demonstrationCatalogDefinitions = {
|
||||
Title: {
|
||||
description: "A heading. Use for section titles and page headers.",
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
level: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
// Custom Row/Column: override the basic catalog's versions so we can
|
||||
// honour `gap` (basic Row/Column from web_core ignores it). Children may
|
||||
// be a literal-string array (flat trees) OR a structural template form
|
||||
// `{ componentId, path }` so the GenericBinder expands per-row templates
|
||||
// from the data model — required for fixed-schema flows like
|
||||
// flight_schema.json (Row.children = { componentId, path: "/flights" }).
|
||||
Row: {
|
||||
description: "Horizontal layout container.",
|
||||
props: z.object({
|
||||
gap: z.number().optional(),
|
||||
align: z.string().optional(),
|
||||
justify: z.string().optional(),
|
||||
// Union with { componentId, path } so GenericBinder treats this as
|
||||
// STRUCTURAL and resolves template children from the data model.
|
||||
children: z.union([
|
||||
z.array(z.string()),
|
||||
z.object({ componentId: z.string(), path: z.string() }),
|
||||
]),
|
||||
}),
|
||||
},
|
||||
|
||||
Column: {
|
||||
description: "Vertical layout container.",
|
||||
props: z.object({
|
||||
gap: z.number().optional(),
|
||||
align: z.string().optional(),
|
||||
// Same union as Row — required for template children support.
|
||||
children: z.union([
|
||||
z.array(z.string()),
|
||||
z.object({ componentId: z.string(), path: z.string() }),
|
||||
]),
|
||||
}),
|
||||
},
|
||||
|
||||
DashboardCard: {
|
||||
description:
|
||||
"A card container with title and optional subtitle. Has a 'child' slot for content (chart, metrics, etc). Use 'child' with a single component ID.",
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
child: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Metric: {
|
||||
description:
|
||||
"A key metric display with label, value, and optional trend indicator. Great for KPIs and stats.",
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
trend: z.enum(["up", "down", "neutral"]).optional(),
|
||||
trendValue: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
PieChart: {
|
||||
description:
|
||||
"A pie/donut chart. Provide data as array of {label, value, color} objects.",
|
||||
props: z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
color: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
innerRadius: z.number().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
BarChart: {
|
||||
description:
|
||||
"A bar chart. Provide data as array of {label, value} objects.",
|
||||
props: z.object({
|
||||
data: z.array(z.object({ label: z.string(), value: z.number() })),
|
||||
color: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Badge: {
|
||||
description:
|
||||
"A small status badge/tag. Use for labels, statuses, categories.",
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z
|
||||
.enum(["success", "warning", "error", "info", "neutral"])
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
DataTable: {
|
||||
description: "A data table with columns and rows.",
|
||||
props: z.object({
|
||||
columns: z.array(z.object({ key: z.string(), label: z.string() })),
|
||||
rows: z.array(z.record(z.any())),
|
||||
}),
|
||||
},
|
||||
|
||||
Button: {
|
||||
description:
|
||||
"An interactive button with an action event. Use 'child' with a Text component ID for the label. 'action' is dispatched on click.",
|
||||
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.any()).optional(),
|
||||
}),
|
||||
}),
|
||||
z.null(),
|
||||
])
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
FlightCard: {
|
||||
description:
|
||||
"A rich flight result card. Displays airline, flight number, route, times, duration, status, and price. Use inside a Row for side-by-side layout.",
|
||||
props: z.object({
|
||||
airline: DynString,
|
||||
airlineLogo: DynString,
|
||||
flightNumber: DynString,
|
||||
origin: DynString,
|
||||
destination: DynString,
|
||||
date: DynString,
|
||||
departureTime: DynString,
|
||||
arrivalTime: DynString,
|
||||
duration: DynString,
|
||||
status: DynString,
|
||||
statusColor: DynString.optional(),
|
||||
price: DynString,
|
||||
action: z
|
||||
.union([
|
||||
z.object({
|
||||
event: z.object({
|
||||
name: z.string(),
|
||||
context: z.record(z.any()).optional(),
|
||||
}),
|
||||
}),
|
||||
z.null(),
|
||||
])
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/** Type helper for renderers */
|
||||
export type DemonstrationCatalogDefinitions =
|
||||
typeof demonstrationCatalogDefinitions;
|
||||
+606
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* A2UI Catalog — React Renderers
|
||||
*
|
||||
* Each renderer maps a component name from definitions.ts to a React
|
||||
* implementation. Props are type-checked against the Zod schemas.
|
||||
*
|
||||
* To add a component: define its schema in definitions.ts, then add a
|
||||
* renderer here. See README.md "Adding a custom component" for details.
|
||||
*
|
||||
* The assembled catalog is registered in layout.tsx via
|
||||
* <CopilotKit a2ui={{ catalog: demonstrationCatalog }}>.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import type { JSX } from "react";
|
||||
import {
|
||||
PieChart as RechartsPie,
|
||||
Pie,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
BarChart as RechartsBar,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import { createCatalog } from "@copilotkit/a2ui-renderer";
|
||||
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
|
||||
import { demonstrationCatalogDefinitions } from "./definitions";
|
||||
import type { DemonstrationCatalogDefinitions } from "./definitions";
|
||||
|
||||
// ─── Theme-aware colors ─────────────────────────────────────────────
|
||||
|
||||
const c = {
|
||||
card: "var(--card)",
|
||||
cardFg: "var(--card-foreground)",
|
||||
border: "var(--border)",
|
||||
muted: "var(--muted-foreground)",
|
||||
divider: "color-mix(in srgb, var(--border) 50%, var(--card))",
|
||||
shadow: "0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04)",
|
||||
btnBg: "color-mix(in srgb, var(--muted) 40%, var(--card))",
|
||||
btnDoneBg: "color-mix(in srgb, #22c55e 10%, var(--card))",
|
||||
};
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
doneLabel,
|
||||
action,
|
||||
children: child,
|
||||
}: {
|
||||
label: string;
|
||||
doneLabel: string;
|
||||
action: any;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const [done, setDone] = useState(false);
|
||||
return (
|
||||
<button
|
||||
disabled={done}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 16px",
|
||||
borderRadius: "10px",
|
||||
border: done ? "1px solid #bbf7d0" : `1px solid ${c.border}`,
|
||||
background: done ? c.btnDoneBg : c.btnBg,
|
||||
color: done ? "#059669" : c.cardFg,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
cursor: done ? "default" : "pointer",
|
||||
transition: "all 0.2s ease",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "6px",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!done) {
|
||||
action?.();
|
||||
setDone(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{done && (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#059669"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
{done ? doneLabel : (child ?? label)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Renderers (type-checked against schema definitions) ────────────
|
||||
|
||||
const demonstrationCatalogRenderers: CatalogRenderers<DemonstrationCatalogDefinitions> =
|
||||
{
|
||||
Title: ({ props }) => {
|
||||
const Tag = (
|
||||
props.level === "h1" ? "h1" : props.level === "h3" ? "h3" : "h2"
|
||||
) as keyof JSX.IntrinsicElements;
|
||||
const sizes: Record<string, string> = {
|
||||
h1: "1.75rem",
|
||||
h2: "1.25rem",
|
||||
h3: "1rem",
|
||||
};
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
margin: 0,
|
||||
fontWeight: 600,
|
||||
fontSize: sizes[props.level ?? "h2"],
|
||||
color: c.cardFg,
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
|
||||
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((item: any, i: number) => {
|
||||
if (typeof item === "string")
|
||||
return (
|
||||
<div
|
||||
key={`${item}-${i}`}
|
||||
style={{ flex: "1 1 0", minWidth: 0 }}
|
||||
>
|
||||
{children(item)}
|
||||
</div>
|
||||
);
|
||||
if (item && typeof item === "object" && "id" in item)
|
||||
return (
|
||||
<div
|
||||
key={`${item.id}-${i}`}
|
||||
style={{ flex: "1 1 0", minWidth: 0 }}
|
||||
>
|
||||
{(children as any)(item.id, item.basePath)}
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
})}
|
||||
</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((item: any, i: number) => {
|
||||
if (typeof item === "string")
|
||||
return (
|
||||
<React.Fragment key={`${item}-${i}`}>
|
||||
{children(item)}
|
||||
</React.Fragment>
|
||||
);
|
||||
if (item && typeof item === "object" && "id" in item)
|
||||
return (
|
||||
<React.Fragment key={`${item.id}-${i}`}>
|
||||
{(children as any)(item.id, item.basePath)}
|
||||
</React.Fragment>
|
||||
);
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
DashboardCard: ({ props, children }) => (
|
||||
<div
|
||||
style={{
|
||||
background: c.card,
|
||||
borderRadius: "12px",
|
||||
border: `1px solid ${c.border}`,
|
||||
padding: "20px",
|
||||
boxShadow: c.shadow,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: "0.9rem", color: c.cardFg }}>
|
||||
{props.title}
|
||||
</div>
|
||||
{props.subtitle && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: c.muted,
|
||||
marginTop: "2px",
|
||||
}}
|
||||
>
|
||||
{props.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{props.child && children(props.child)}
|
||||
</div>
|
||||
),
|
||||
|
||||
Metric: ({ props }) => {
|
||||
const trendColors: Record<string, string> = {
|
||||
up: "#059669",
|
||||
down: "#dc2626",
|
||||
neutral: c.muted,
|
||||
};
|
||||
const trendIcons: Record<string, string> = {
|
||||
up: "↑",
|
||||
down: "↓",
|
||||
neutral: "→",
|
||||
};
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: c.muted,
|
||||
fontWeight: 500,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: "8px" }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 700,
|
||||
color: c.cardFg,
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
{props.value}
|
||||
</span>
|
||||
{props.trend && props.trendValue && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 500,
|
||||
color: trendColors[props.trend] ?? c.muted,
|
||||
}}
|
||||
>
|
||||
{trendIcons[props.trend]} {props.trendValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
PieChart: ({ props }) => {
|
||||
const COLORS = [
|
||||
"#3b82f6",
|
||||
"#8b5cf6",
|
||||
"#ec4899",
|
||||
"#f59e0b",
|
||||
"#10b981",
|
||||
"#6366f1",
|
||||
];
|
||||
const data = props.data ?? [];
|
||||
return (
|
||||
<div style={{ width: "100%", height: 200 }}>
|
||||
<ResponsiveContainer>
|
||||
<RechartsPie>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="label"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={props.innerRadius ?? 40}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{data.map((entry: any, i: number) => (
|
||||
<Cell
|
||||
key={i}
|
||||
fill={entry.color ?? COLORS[i % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</RechartsPie>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
BarChart: ({ props }) => {
|
||||
const data = props.data ?? [];
|
||||
return (
|
||||
<div style={{ width: "100%", height: 200 }}>
|
||||
<ResponsiveContainer>
|
||||
<RechartsBar data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={c.divider} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: c.muted }} />
|
||||
<YAxis tick={{ fontSize: 11, fill: c.muted }} />
|
||||
<Tooltip />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={props.color ?? "#3b82f6"}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</RechartsBar>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Badge: ({ props }) => {
|
||||
const variants: Record<string, { bg: string; color: string }> = {
|
||||
success: { bg: "#dcfce7", color: "#166534" },
|
||||
warning: { bg: "#fef3c7", color: "#92400e" },
|
||||
error: { bg: "#fee2e2", color: "#991b1b" },
|
||||
info: { bg: "#dbeafe", color: "#1e40af" },
|
||||
neutral: { bg: "var(--muted)", color: c.cardFg },
|
||||
};
|
||||
const v = variants[props.variant ?? "neutral"] ?? variants.neutral;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "9999px",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 500,
|
||||
background: v.bg,
|
||||
color: v.color,
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
|
||||
DataTable: ({ props }) => {
|
||||
const cols = props.columns ?? [];
|
||||
const rows = props.rows ?? [];
|
||||
return (
|
||||
<div style={{ overflowX: "auto", width: "100%" }}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{cols.map((col: any) => (
|
||||
<th
|
||||
key={col.key}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "8px 12px",
|
||||
borderBottom: `2px solid ${c.border}`,
|
||||
color: c.muted,
|
||||
fontWeight: 600,
|
||||
fontSize: "0.7rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row: any, i: number) => (
|
||||
<tr key={i} style={{ borderBottom: `1px solid ${c.divider}` }}>
|
||||
{cols.map((col: any) => (
|
||||
<td
|
||||
key={col.key}
|
||||
style={{ padding: "8px 12px", color: c.cardFg }}
|
||||
>
|
||||
{String(row[col.key] ?? "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Button: ({ props, children }) => {
|
||||
return (
|
||||
<ActionButton label="Click" doneLabel="Done" action={props.action}>
|
||||
{props.child ? children(props.child) : null}
|
||||
</ActionButton>
|
||||
);
|
||||
},
|
||||
|
||||
FlightCard: ({ props: rawProps }) => {
|
||||
// The binder resolves path bindings to strings at runtime.
|
||||
const props = rawProps as Record<string, any>;
|
||||
const statusColors: Record<string, string> = {
|
||||
"On Time": "#22c55e",
|
||||
Delayed: "#eab308",
|
||||
Cancelled: "#ef4444",
|
||||
};
|
||||
const dotColor =
|
||||
props.statusColor ?? statusColors[props.status] ?? "#22c55e";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: `1px solid ${c.border}`,
|
||||
borderRadius: "16px",
|
||||
padding: "20px",
|
||||
background: c.card,
|
||||
color: c.cardFg,
|
||||
minWidth: 260,
|
||||
maxWidth: 340,
|
||||
flex: "1 1 260px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
boxShadow: c.shadow,
|
||||
}}
|
||||
>
|
||||
{/* Header: airline + price */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<img
|
||||
src={props.airlineLogo}
|
||||
alt={props.airline}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>
|
||||
{props.airline}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontWeight: 700, fontSize: "1.15rem" }}>
|
||||
{props.price}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.8rem",
|
||||
color: c.muted,
|
||||
}}
|
||||
>
|
||||
<span>{props.flightNumber}</span>
|
||||
<span>{props.date}</span>
|
||||
</div>
|
||||
|
||||
<hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: `1px solid ${c.divider}`,
|
||||
margin: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Times */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 700, fontSize: "1.1rem" }}>
|
||||
{props.departureTime}
|
||||
</span>
|
||||
<span style={{ fontSize: "0.75rem", color: c.muted }}>
|
||||
{props.duration}
|
||||
</span>
|
||||
<span style={{ fontWeight: 700, fontSize: "1.1rem" }}>
|
||||
{props.arrivalTime}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Route */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
fontSize: "0.95rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<span>{props.origin}</span>
|
||||
<span style={{ color: c.muted }}>→</span>
|
||||
<span>{props.destination}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: `1px solid ${c.divider}`,
|
||||
margin: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Status */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: dotColor,
|
||||
display: "inline-block",
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "0.8rem", color: c.muted }}>
|
||||
{props.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ActionButton
|
||||
label="Select"
|
||||
doneLabel="Selected"
|
||||
action={props.action}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Assembled Catalog ───────────────────────────────────────────────
|
||||
|
||||
export const demonstrationCatalog = createCatalog(
|
||||
demonstrationCatalogDefinitions,
|
||||
demonstrationCatalogRenderers,
|
||||
{
|
||||
catalogId: "copilotkit://app-dashboard-catalog",
|
||||
// Required: merges the basic A2UI primitives (Row, Column, Text, Card,
|
||||
// Button, …) into this catalog so structural-children expansion works
|
||||
// for templates like flight_schema.json's
|
||||
// `Row { children: { componentId: "flight-card", path: "/flights" } }`.
|
||||
// Both sibling working demos (a2ui-fixed-schema, declarative-gen-ui)
|
||||
// already set this — beautiful-chat was the outlier.
|
||||
includeBasicCatalog: true,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import { ExampleLayout } from "./components/example-layout";
|
||||
import { ExampleCanvas } from "./components/example-canvas";
|
||||
import { useGenerativeUIExamples, useExampleSuggestions } from "./hooks";
|
||||
|
||||
export function HomePage() {
|
||||
useGenerativeUIExamples();
|
||||
useExampleSuggestions();
|
||||
|
||||
return (
|
||||
<ExampleLayout
|
||||
chatContent={
|
||||
<CopilotChat
|
||||
attachments={{ enabled: true }}
|
||||
input={{ disclaimer: () => null, className: "pb-6" }}
|
||||
/>
|
||||
}
|
||||
appContent={<ExampleCanvas />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./use-example-suggestions";
|
||||
export * from "./use-generative-ui-examples";
|
||||
export * from "./use-theme";
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Suggestion pills shown in the chat UI. Each suggestion triggers a specific
|
||||
* demo feature when clicked.
|
||||
*
|
||||
* Ordered from most constrained (fixed UI) to most open (freeform UI).
|
||||
*
|
||||
* Showcase mode (showcase.json) controls which pills are visually highlighted.
|
||||
* Highlight styling: globals.css (.a2ui-highlight, .opengenui-highlight)
|
||||
* A2UI agent tools: agent/src/a2ui_fixed_schema.py, a2ui_dynamic_schema.py
|
||||
* A2UI catalog: src/app/declarative-generative-ui/
|
||||
*/
|
||||
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
||||
import showcaseConfig from "../showcase.json";
|
||||
|
||||
const showcase = showcaseConfig.showcase;
|
||||
|
||||
export const useExampleSuggestions = () => {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Pie Chart (Controlled Generative UI)",
|
||||
message:
|
||||
"Show me a pie chart of our revenue distribution by category. Use the query_data tool to fetch the data first, then render it with the pieChart component.",
|
||||
},
|
||||
{
|
||||
title: "Bar Chart (Controlled Generative UI)",
|
||||
message:
|
||||
"Show me a bar chart of our expenses by category. Use the query_data tool to fetch the data first, then render it with the barChart component.",
|
||||
},
|
||||
{
|
||||
title: "Schedule Meeting (Human In The Loop)",
|
||||
message:
|
||||
"I'd like to schedule a 30-minute meeting to learn about CopilotKit. Please use the scheduleTime tool to let me pick a time.",
|
||||
},
|
||||
{
|
||||
title: "Search Flights (A2UI Fixed Schema)",
|
||||
message: "Find flights from SFO to JFK for next Tuesday.",
|
||||
className: showcase === "a2ui" ? "a2ui-highlight" : undefined,
|
||||
},
|
||||
{
|
||||
title: "Sales Dashboard (A2UI Dynamic)",
|
||||
message:
|
||||
"First use the query_data tool to fetch the financial sales data, then using A2UI, show me a sales dashboard with total revenue, new customers, and conversion rate metrics. Include a pie chart of revenue by category and a bar chart of monthly sales.",
|
||||
className: showcase === "a2ui" ? "a2ui-highlight" : undefined,
|
||||
},
|
||||
{
|
||||
title: "Excalidraw Diagram (MCP App)",
|
||||
message:
|
||||
"Use Excalidraw to create a simple network diagram showing a router connected to two switches, each connected to two computers.",
|
||||
},
|
||||
{
|
||||
title: "Calculator App (Open Generative UI)",
|
||||
message:
|
||||
"Using the generateSandboxedUi tool, build a modern calculator with standard buttons plus labeled metric shortcut buttons that insert their values into the display when clicked. Use sample company data.",
|
||||
className: showcase === "opengenui" ? "opengenui-highlight" : undefined,
|
||||
},
|
||||
{
|
||||
title: "Toggle Theme (Frontend Tools)",
|
||||
message: "Toggle the app theme using the toggleTheme tool.",
|
||||
},
|
||||
{
|
||||
title: "Task Manager (Shared State)",
|
||||
message:
|
||||
"Enable app mode and add three todos about learning CopilotKit: one about reading the docs, one about building a prototype, and one about exploring agent state.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
};
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
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)
|
||||
const ignoredTools = [
|
||||
"render_a2ui", // Rendered by A2UI streaming, not as a tool card
|
||||
"generate_a2ui", // Legacy: rendered by A2UI, not as a tool card
|
||||
"log_a2ui_event", // Internal A2UI event tracker
|
||||
];
|
||||
useDefaultRenderTool({
|
||||
render: ({ name, status, parameters }) => {
|
||||
if (ignoredTools.includes(name)) return <></>;
|
||||
return <ToolReasoning name={name} status={status} args={parameters} />;
|
||||
},
|
||||
});
|
||||
|
||||
// Frontend Tools (direct frontend state manipulation).
|
||||
// No deps array needed — the handler reads `document` directly and
|
||||
// calls a stable setter. Including [theme, setTheme] in deps caused
|
||||
// the hook to re-register every time the theme flipped, which could
|
||||
// race with an in-flight tool result from the runtime and surface
|
||||
// as a renderer-level error during multi-turn beautiful-chat probes.
|
||||
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");
|
||||
},
|
||||
});
|
||||
};
|
||||
+43
@@ -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);
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
:root {
|
||||
--n-100: #ffffff;
|
||||
--n-99: #fcfcfc;
|
||||
--n-98: #f9f9f9;
|
||||
--n-95: #f1f1f1;
|
||||
--n-90: #e2e2e2;
|
||||
--n-80: #c6c6c6;
|
||||
--n-70: #ababab;
|
||||
--n-60: #919191;
|
||||
--n-50: #777777;
|
||||
--n-40: #5e5e5e;
|
||||
--n-35: #525252;
|
||||
--n-30: #474747;
|
||||
--n-25: #3b3b3b;
|
||||
--n-20: #303030;
|
||||
--n-15: #262626;
|
||||
--n-10: #1b1b1b;
|
||||
--n-5: #111111;
|
||||
--n-0: #000000;
|
||||
|
||||
--p-100: #ffffff;
|
||||
--p-99: #fffbff;
|
||||
--p-98: #fcf8ff;
|
||||
--p-95: #f2efff;
|
||||
--p-90: #e1e0ff;
|
||||
--p-80: #c0c1ff;
|
||||
--p-70: #a0a3ff;
|
||||
--p-60: #8487ea;
|
||||
--p-50: #6a6dcd;
|
||||
--p-40: #5154b3;
|
||||
--p-35: #4447a6;
|
||||
--p-30: #383b99;
|
||||
--p-25: #2c2e8d;
|
||||
--p-20: #202182;
|
||||
--p-15: #131178;
|
||||
--p-10: #06006c;
|
||||
--p-5: #03004d;
|
||||
--p-0: #000000;
|
||||
|
||||
--s-100: #ffffff;
|
||||
--s-99: #fffbff;
|
||||
--s-98: #fcf8ff;
|
||||
--s-95: #f2efff;
|
||||
--s-90: #e2e0f9;
|
||||
--s-80: #c6c4dd;
|
||||
--s-70: #aaa9c1;
|
||||
--s-60: #8f8fa5;
|
||||
--s-50: #75758b;
|
||||
--s-40: #5d5c72;
|
||||
--s-35: #515165;
|
||||
--s-30: #454559;
|
||||
--s-25: #393a4d;
|
||||
--s-20: #2e2f42;
|
||||
--s-15: #242437;
|
||||
--s-10: #191a2c;
|
||||
--s-5: #0f0f21;
|
||||
--s-0: #000000;
|
||||
|
||||
--t-100: #ffffff;
|
||||
--t-99: #fffbff;
|
||||
--t-98: #fff8f9;
|
||||
--t-95: #ffecf4;
|
||||
--t-90: #ffd8ec;
|
||||
--t-80: #e9b9d3;
|
||||
--t-70: #cc9eb8;
|
||||
--t-60: #af849d;
|
||||
--t-50: #946b83;
|
||||
--t-40: #79536a;
|
||||
--t-35: #6c475d;
|
||||
--t-30: #5f3c51;
|
||||
--t-25: #523146;
|
||||
--t-20: #46263a;
|
||||
--t-15: #3a1b2f;
|
||||
--t-10: #2e1125;
|
||||
--t-5: #22071a;
|
||||
--t-0: #000000;
|
||||
|
||||
--nv-100: #ffffff;
|
||||
--nv-99: #fffbff;
|
||||
--nv-98: #fcf8ff;
|
||||
--nv-95: #f2effa;
|
||||
--nv-90: #e4e1ec;
|
||||
--nv-80: #c8c5d0;
|
||||
--nv-70: #acaab4;
|
||||
--nv-60: #918f9a;
|
||||
--nv-50: #777680;
|
||||
--nv-40: #5e5d67;
|
||||
--nv-35: #52515b;
|
||||
--nv-30: #46464f;
|
||||
--nv-25: #3b3b43;
|
||||
--nv-20: #303038;
|
||||
--nv-15: #25252d;
|
||||
--nv-10: #1b1b23;
|
||||
--nv-5: #101018;
|
||||
--nv-0: #000000;
|
||||
|
||||
--e-100: #ffffff;
|
||||
--e-99: #fffbff;
|
||||
--e-98: #fff8f7;
|
||||
--e-95: #ffedea;
|
||||
--e-90: #ffdad6;
|
||||
--e-80: #ffb4ab;
|
||||
--e-70: #ff897d;
|
||||
--e-60: #ff5449;
|
||||
--e-50: #de3730;
|
||||
--e-40: #ba1a1a;
|
||||
--e-35: #a80710;
|
||||
--e-30: #93000a;
|
||||
--e-25: #7e0007;
|
||||
--e-20: #690005;
|
||||
--e-15: #540003;
|
||||
--e-10: #410002;
|
||||
--e-5: #2d0001;
|
||||
--e-0: #000000;
|
||||
|
||||
--primary: #137fec;
|
||||
--text-color: #fff;
|
||||
--background-light: #f6f7f8;
|
||||
--background-dark: #101922;
|
||||
--border-color: oklch(
|
||||
from var(--background-light) l c h / calc(alpha * 0.15)
|
||||
);
|
||||
--elevated-background-light: oklch(
|
||||
from var(--background-light) l c h / calc(alpha * 0.05)
|
||||
);
|
||||
--bb-grid-size: 4px;
|
||||
--bb-grid-size-2: calc(var(--bb-grid-size) * 2);
|
||||
--bb-grid-size-3: calc(var(--bb-grid-size) * 3);
|
||||
--bb-grid-size-4: calc(var(--bb-grid-size) * 4);
|
||||
--bb-grid-size-5: calc(var(--bb-grid-size) * 5);
|
||||
--bb-grid-size-6: calc(var(--bb-grid-size) * 6);
|
||||
--bb-grid-size-7: calc(var(--bb-grid-size) * 7);
|
||||
--bb-grid-size-8: calc(var(--bb-grid-size) * 8);
|
||||
--bb-grid-size-9: calc(var(--bb-grid-size) * 9);
|
||||
--bb-grid-size-10: calc(var(--bb-grid-size) * 10);
|
||||
--bb-grid-size-11: calc(var(--bb-grid-size) * 11);
|
||||
--bb-grid-size-12: calc(var(--bb-grid-size) * 12);
|
||||
--bb-grid-size-13: calc(var(--bb-grid-size) * 13);
|
||||
--bb-grid-size-14: calc(var(--bb-grid-size) * 14);
|
||||
--bb-grid-size-15: calc(var(--bb-grid-size) * 15);
|
||||
--bb-grid-size-16: calc(var(--bb-grid-size) * 16);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
--font-family: "Google Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
--font-family-flex:
|
||||
"Google Sans Flex", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
--font-family-mono:
|
||||
"Google Sans Code", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
|
||||
background: var(--background-light);
|
||||
font-family: var(--font-family);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100svw;
|
||||
height: 100svh;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Beautiful Chat — the flagship CopilotKit showcase cell, ported verbatim
|
||||
* from the 4084 reference clone. The 4084 version lived as its own Next.js
|
||||
* frontend at `demos/beautiful-chat/frontend/` with a full `src/components`
|
||||
* tree + A2UI catalog. Here the same tree is colocated under the cell and
|
||||
* re-wired with relative imports.
|
||||
*
|
||||
* Providers: layout-level `CopilotKit` + `ThemeProvider` wrappers from the
|
||||
* original 4084 root layout are applied here instead, because the unified
|
||||
* 4085 shell does not give each cell its own layout.tsx.
|
||||
*
|
||||
* Runtime: this cell uses its own dedicated runtime endpoint
|
||||
* (`/api/copilotkit-beautiful-chat`) so it can enable `openGenerativeUI`,
|
||||
* `a2ui` with `injectA2UITool: false`, and `mcpApps` simultaneously — the
|
||||
* same combined-runtime shape the canonical starter uses — without bleeding
|
||||
* those global flags into other cells sharing the main `/api/copilotkit`
|
||||
* endpoint. The backend graph is `beautiful_chat` (src/agents/beautiful_chat.py).
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { CopilotKit } from "@copilotkit/react-core/v2";
|
||||
|
||||
import { ThemeProvider } from "./hooks/use-theme";
|
||||
import { demonstrationCatalog } from "./declarative-generative-ui/renderers";
|
||||
import { HomePage } from "./home-page";
|
||||
|
||||
export default function BeautifulChatPage() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit-beautiful-chat"
|
||||
agent="beautiful-chat"
|
||||
a2ui={{ catalog: demonstrationCatalog }}
|
||||
openGenerativeUI={{}}
|
||||
/*
|
||||
* `useSingleEndpoint` defaults to true (the single-POST-endpoint
|
||||
* protocol). The canonical reference sets it to false to use the
|
||||
* v2 multi-endpoint protocol (GET /info + POST /agent/{name}/connect),
|
||||
* which requires a Hono-based endpoint via `createCopilotEndpoint`.
|
||||
* The 4085 showcase uses `copilotRuntimeNextJSAppRouterEndpoint`
|
||||
* (single-endpoint), which matches the other 4085 cells — so we
|
||||
* use its default behavior here. Functionally equivalent for this demo.
|
||||
*/
|
||||
>
|
||||
<HomePage />
|
||||
</CopilotKit>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"showcase": "default"
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# Chat Customization (CSS)
|
||||
|
||||
## What This Demo Shows
|
||||
|
||||
How far you can push `CopilotChat` with CSS alone — no slot overrides, no
|
||||
component swaps, no React. The default look is rounded, system-sans, and
|
||||
minimal-light. This demo replaces it with **HALCYON**, a warm-paper
|
||||
editorial brand: cream parchment surface, sharp 90° corners, copper-ember
|
||||
accents, an italic display serif for big headings, a Fraunces serif voice
|
||||
for the assistant, and JetBrains Mono dispatch lines for the user.
|
||||
|
||||
The point: a team can take CopilotChat off the shelf and skin it to match
|
||||
their own brand without ever opening a component file.
|
||||
|
||||
## How it works
|
||||
|
||||
Two layers do the work:
|
||||
|
||||
1. **v2 token overrides on `[data-copilotkit]`** — `--background`,
|
||||
`--foreground`, `--primary`, `--muted`, `--border`, `--ring`, `--radius`,
|
||||
etc. Recolors every Tailwind utility (`cpk:bg-muted`,
|
||||
`cpk:text-foreground`, …) the runtime renders.
|
||||
2. **Class-targeted styling** — `.copilotKitChat`, `.copilotKitMessages`,
|
||||
`.copilotKitMessage.copilotKitUserMessage`,
|
||||
`.copilotKitMessage.copilotKitAssistantMessage`, `.copilotKitInput`, the
|
||||
welcome screen, suggestions, scrollbar.
|
||||
|
||||
Every selector is namespaced under `.chat-css-demo-scope`, so the theme
|
||||
cannot leak into the rest of the showcase.
|
||||
|
||||
## How to Interact
|
||||
|
||||
Type any prompt and watch the conversation render in the HALCYON voice:
|
||||
|
||||
- `"Say hi"`
|
||||
- `"Write a one-paragraph product memo about quarterly OKRs"`
|
||||
- `"Show me a Python snippet for retry with exponential backoff"`
|
||||
- `"Quote a famous business strategist on focus"`
|
||||
|
||||
You'll see:
|
||||
|
||||
- The user line render as a mono CLI dispatch with an ember `→` marker
|
||||
- The assistant respond in serif body type with editorial spacing, an
|
||||
ember left rule, and a dark code-card for code blocks
|
||||
- The composer pill flatten to a sharp card with an ember focus ring and
|
||||
a square copper send button
|
||||
|
||||
## Aesthetic Notes
|
||||
|
||||
- **Surface** — warm parchment (`#F4EFE6`) with a single ambient ember glow
|
||||
in the top-left and a barely-perceptible paper-grain noise via inline
|
||||
SVG
|
||||
- **Masthead** — a centered mono label pinned just under the top edge of
|
||||
the chat surface (`CopilotChat · Customized with CSS`)
|
||||
- **Typography** — Instrument Serif (display, italic), Fraunces (assistant
|
||||
body), Inter Tight (UI), JetBrains Mono (user dispatch + metadata +
|
||||
suggestions)
|
||||
- **Accent** — deep copper ember (`#C44A1F`), used only on the user prompt
|
||||
marker, the assistant left rule, the send button, and focus rings —
|
||||
sparingly, so it actually reads as signal
|
||||
- **Geometry** — sharp 90° corners everywhere (radius is overridden to
|
||||
`0px`), opposite of the default rounded pills
|
||||
|
||||
## Technical Details
|
||||
|
||||
- `<CopilotKit>` wires `runtimeUrl="/api/copilotkit"` and
|
||||
`agent="chat-customization-css"` (backed by `graph` in
|
||||
`src/agents/main.py`)
|
||||
- `<CopilotChat>` is wrapped in `<div className="chat-css-demo-scope">`;
|
||||
the theme is applied by `import "./theme.css"` at the top of the page
|
||||
- `theme.css` first overrides the v2 token variables on `[data-copilotkit]`
|
||||
(so Tailwind utilities recolor automatically), then layers
|
||||
class-targeted rules on top for the editorial details that CSS
|
||||
variables alone can't express
|
||||
- Fonts load from Google Fonts via `@import` at the top of `theme.css`
|
||||
so the demo is self-contained — copy the file into another project and
|
||||
the theme works end-to-end
|
||||
- Reach for slots (see `chat-slots`) when you need to change _what_ a
|
||||
piece renders, not just how it looks; reach for CSS — like this demo —
|
||||
when the default structure is fine and you only need a different
|
||||
visual identity
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
// Chat Customization (CSS) — every visual choice in this demo lives in
|
||||
// theme.css and is scoped to the `.chat-css-demo-scope` wrapper. The page
|
||||
// intentionally stays minimal so the contrast against the default look
|
||||
// comes entirely from the stylesheet.
|
||||
//
|
||||
// https://docs.copilotkit.ai/custom-look-and-feel/customize-built-in-ui-components
|
||||
|
||||
import React from "react";
|
||||
import { CopilotKit, CopilotChat } from "@copilotkit/react-core/v2";
|
||||
// @region[theme-css-import]
|
||||
import "./theme.css";
|
||||
// @endregion[theme-css-import]
|
||||
|
||||
export default function ChatCustomizationCssDemo() {
|
||||
return (
|
||||
<CopilotKit runtimeUrl="/api/copilotkit" agent="chat-customization-css">
|
||||
<div className="flex justify-center items-center h-screen w-full bg-white p-6">
|
||||
<div className="chat-css-demo-scope h-full w-full max-w-4xl">
|
||||
<CopilotChat
|
||||
agentId="chat-customization-css"
|
||||
className="h-full"
|
||||
attachments={{ enabled: true }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKit>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
/* HALCYON — a warm-paper editorial theme for CopilotChat.
|
||||
*
|
||||
* The point of this demo is to show how far a single stylesheet can take
|
||||
* CopilotChat away from the default look without touching components or
|
||||
* slots. Every selector is namespaced under `.chat-css-demo-scope` so this
|
||||
* theme cannot leak into the rest of the showcase.
|
||||
*
|
||||
* Two layers do the work:
|
||||
* 1. v2 token overrides on `[data-copilotkit]` recolor every Tailwind
|
||||
* utility (cpk:bg-muted, cpk:text-foreground, cpk:border, …) the
|
||||
* runtime relies on — see @copilotkit/react-core/v2/styles.css.
|
||||
* 2. Targeted class rules on `.copilotKitChat`, `.copilotKitMessage*`,
|
||||
* and `.copilotKitInput` add the editorial details: parchment grain,
|
||||
* corner brackets, serif voice, mono dispatch, ember accents.
|
||||
*
|
||||
* Class-name reference:
|
||||
* https://docs.copilotkit.ai/custom-look-and-feel/customize-built-in-ui-components
|
||||
*/
|
||||
|
||||
/* @region[google-fonts] */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=Instrument+Serif:ital@0;1&family=Inter+Tight:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap");
|
||||
/* @endregion[google-fonts] */
|
||||
|
||||
/* @region[design-tokens] */
|
||||
/* HALCYON palette — a private library at golden hour. The whole theme is
|
||||
* one warm parchment hue, one warm ink, and a deep copper ember used
|
||||
* sparingly so it actually reads as a signal. */
|
||||
.chat-css-demo-scope {
|
||||
--halcyon-paper: #f4efe6;
|
||||
--halcyon-paper-soft: #ece6d9;
|
||||
--halcyon-paper-elevated: #fbf8f2;
|
||||
--halcyon-card: #ffffff;
|
||||
--halcyon-rule: #d6cfbe;
|
||||
--halcyon-rule-strong: #aea48a;
|
||||
--halcyon-ink: #1a1714;
|
||||
--halcyon-ink-soft: #3d362e;
|
||||
--halcyon-ink-mute: #7a7468;
|
||||
--halcyon-ember: #c44a1f;
|
||||
--halcyon-ember-bright: #e45f2b;
|
||||
--halcyon-ember-soft: #f3d7c5;
|
||||
--halcyon-champagne: #98794a;
|
||||
|
||||
--halcyon-display:
|
||||
"Instrument Serif", ui-serif, "Iowan Old Style", Georgia, serif;
|
||||
--halcyon-serif:
|
||||
"Fraunces", "Source Serif Pro", ui-serif, Georgia, "Times New Roman", serif;
|
||||
--halcyon-sans:
|
||||
"Inter Tight", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
--halcyon-mono:
|
||||
"JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
|
||||
--halcyon-shadow-soft:
|
||||
0 1px 0 rgba(26, 23, 20, 0.04), 0 12px 32px -18px rgba(26, 23, 20, 0.18);
|
||||
--halcyon-shadow-ember:
|
||||
0 1px 0 rgba(196, 74, 31, 0.18), 0 14px 36px -16px rgba(196, 74, 31, 0.42);
|
||||
}
|
||||
/* @endregion[design-tokens] */
|
||||
|
||||
/* @region[v2-token-overrides] */
|
||||
/* CopilotKit v2 reads these on the [data-copilotkit] root inside the chat.
|
||||
* Re-pointing them under our scope retints every Tailwind utility the
|
||||
* runtime renders (user message bubble, prose, borders, focus rings, …)
|
||||
* without us having to touch any individual class. */
|
||||
.chat-css-demo-scope [data-copilotkit] {
|
||||
--background: var(--halcyon-paper);
|
||||
--foreground: var(--halcyon-ink);
|
||||
--card: var(--halcyon-card);
|
||||
--card-foreground: var(--halcyon-ink);
|
||||
--popover: var(--halcyon-paper-elevated);
|
||||
--popover-foreground: var(--halcyon-ink);
|
||||
--primary: var(--halcyon-ember);
|
||||
--primary-foreground: var(--halcyon-paper-elevated);
|
||||
--secondary: var(--halcyon-paper-soft);
|
||||
--secondary-foreground: var(--halcyon-ink);
|
||||
--muted: var(--halcyon-paper-soft);
|
||||
--muted-foreground: var(--halcyon-ink-mute);
|
||||
--accent: var(--halcyon-ember-soft);
|
||||
--accent-foreground: var(--halcyon-ember);
|
||||
--destructive: #b3361b;
|
||||
--destructive-foreground: var(--halcyon-paper-elevated);
|
||||
--border: var(--halcyon-rule);
|
||||
--input: var(--halcyon-rule);
|
||||
--ring: var(--halcyon-ember);
|
||||
--radius: 0px;
|
||||
}
|
||||
/* @endregion[v2-token-overrides] */
|
||||
|
||||
/* @region[chat-shell] */
|
||||
/* The chat surface — warm parchment with a single ambient ember glow,
|
||||
* a barely-perceptible paper grain via inline SVG noise, and architectural
|
||||
* corner brackets. Sharp 90° corners are deliberate; the default look is
|
||||
* rounded, so squaring everything off is the fastest visual signal that
|
||||
* "this is a different brand". */
|
||||
.chat-css-demo-scope .copilotKitChat {
|
||||
font-family: var(--halcyon-sans);
|
||||
color: var(--halcyon-ink);
|
||||
background-color: var(--halcyon-paper);
|
||||
background-image:
|
||||
radial-gradient(
|
||||
900px 460px at 0% -10%,
|
||||
rgba(228, 95, 43, 0.14),
|
||||
transparent 62%
|
||||
),
|
||||
radial-gradient(
|
||||
720px 380px at 100% 110%,
|
||||
rgba(152, 121, 74, 0.08),
|
||||
transparent 65%
|
||||
),
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.09 0 0 0 0 0.07 0 0 0 0.045 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
||||
border: 1px solid var(--halcyon-rule);
|
||||
border-radius: 0;
|
||||
box-shadow: var(--halcyon-shadow-soft);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The masthead label — a small mono bar pinned to the top of the surface,
|
||||
* playing against the editorial serif voice. Lives on ::before so it
|
||||
* tracks the chat root and shows in every state (welcome, mid-thread,
|
||||
* empty after clear). */
|
||||
.chat-css-demo-scope .copilotKitChat::before {
|
||||
content: "CopilotChat · Customized with CSS";
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-family: var(--halcyon-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--halcyon-ink-mute);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* @endregion[chat-shell] */
|
||||
|
||||
/* @region[welcome] */
|
||||
/* The welcome screen — the page-one impression. The default heading is
|
||||
* sans-serif and tidy; we replace it with a large italic display serif
|
||||
* that wraps the question like a magazine cover line. */
|
||||
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] {
|
||||
padding-top: 4rem;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1 {
|
||||
font-family: var(--halcyon-display);
|
||||
font-size: clamp(2.4rem, 5vw, 4rem);
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
color: var(--halcyon-ink);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.05;
|
||||
text-align: center;
|
||||
margin: 0 auto 0.6rem;
|
||||
max-width: 22ch;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* A small mono eyebrow above the heading. */
|
||||
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1::before {
|
||||
content: "CopilotKit";
|
||||
display: block;
|
||||
font-family: var(--halcyon-mono);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--halcyon-ember);
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
/* A short rule under the heading as a visual settle point. */
|
||||
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 36px;
|
||||
height: 1px;
|
||||
background: var(--halcyon-rule-strong);
|
||||
margin: 1.4rem auto 0;
|
||||
}
|
||||
/* @endregion[welcome] */
|
||||
|
||||
/* @region[messages-container] */
|
||||
.chat-css-demo-scope .copilotKitMessages {
|
||||
font-family: var(--halcyon-sans);
|
||||
background: transparent;
|
||||
color: var(--halcyon-ink);
|
||||
padding: 5rem 0 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
/* @endregion[messages-container] */
|
||||
|
||||
/* @region[user-message] */
|
||||
/* User message — a "transmission" in JetBrains Mono on a paper card. The
|
||||
* outer wrapper is the right-aligning flex column; we leave it transparent
|
||||
* and style the inner bubble (which uses cpk:bg-muted, hence we also
|
||||
* target the substring class as a stable hook). */
|
||||
.chat-css-demo-scope .copilotKitMessage.copilotKitUserMessage {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope
|
||||
.copilotKitMessage.copilotKitUserMessage
|
||||
> [class*="bg-muted"] {
|
||||
font-family: var(--halcyon-mono);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
color: var(--halcyon-ink);
|
||||
background: var(--halcyon-paper-elevated);
|
||||
border: 1px solid var(--halcyon-rule);
|
||||
border-left: 2px solid var(--halcyon-ember);
|
||||
border-radius: 0;
|
||||
padding: 12px 16px 12px 18px;
|
||||
letter-spacing: -0.005em;
|
||||
line-height: 1.55;
|
||||
box-shadow: 0 1px 0 rgba(26, 23, 20, 0.03);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* A mono "→" marker before the user's text to read like a CLI prompt. */
|
||||
.chat-css-demo-scope
|
||||
.copilotKitMessage.copilotKitUserMessage
|
||||
> [class*="bg-muted"]::before {
|
||||
content: "→";
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
color: var(--halcyon-ember);
|
||||
font-weight: 500;
|
||||
}
|
||||
/* @endregion[user-message] */
|
||||
|
||||
/* @region[assistant-message] */
|
||||
/* Assistant message — editorial Fraunces serif, no bubble, just generous
|
||||
* paragraphs offset by a thin ember rule on the left. Reads like the
|
||||
* voice of a publication, not a chatbot. */
|
||||
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage {
|
||||
background: transparent;
|
||||
color: var(--halcyon-ink);
|
||||
font-family: var(--halcyon-serif);
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 400;
|
||||
padding: 4px 0 4px 22px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
margin-right: auto;
|
||||
margin-bottom: 1.25rem;
|
||||
max-width: 78ch;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* The editorial left rule. */
|
||||
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0.45em;
|
||||
bottom: 0.45em;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
background: var(--halcyon-ember);
|
||||
}
|
||||
|
||||
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose,
|
||||
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose p {
|
||||
font-family: var(--halcyon-serif);
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
line-height: 1.7;
|
||||
font-feature-settings: "ss01", "ss02", "ss03", "kern";
|
||||
margin: 0 0 0.85em;
|
||||
}
|
||||
|
||||
/* Headings inside assistant content swap to the display serif so a long
|
||||
* answer reads like a structured article. */
|
||||
.chat-css-demo-scope
|
||||
.copilotKitMessage.copilotKitAssistantMessage
|
||||
.prose
|
||||
:is(h1, h2, h3, h4) {
|
||||
font-family: var(--halcyon-display);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--halcyon-ink);
|
||||
margin: 1em 0 0.4em;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose h2 {
|
||||
font-size: 1.55rem;
|
||||
}
|
||||
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Lists — looser, with serif numerals. */
|
||||
.chat-css-demo-scope
|
||||
.copilotKitMessage.copilotKitAssistantMessage
|
||||
.prose
|
||||
:is(ul, ol) {
|
||||
margin: 0.5em 0 1em;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope
|
||||
.copilotKitMessage.copilotKitAssistantMessage
|
||||
.prose
|
||||
ol
|
||||
> li::marker {
|
||||
color: var(--halcyon-ember);
|
||||
font-feature-settings: "tnum";
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope
|
||||
.copilotKitMessage.copilotKitAssistantMessage
|
||||
.prose
|
||||
ul
|
||||
> li::marker {
|
||||
color: var(--halcyon-ember);
|
||||
}
|
||||
|
||||
/* Blockquote — pull-quote treatment in italic display serif. */
|
||||
.chat-css-demo-scope
|
||||
.copilotKitMessage.copilotKitAssistantMessage
|
||||
.prose
|
||||
blockquote {
|
||||
border-left: 0;
|
||||
margin: 1.2em 0;
|
||||
padding: 0 0 0 1em;
|
||||
font-family: var(--halcyon-display);
|
||||
font-style: italic;
|
||||
font-size: 1.25em;
|
||||
color: var(--halcyon-ink-soft);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope
|
||||
.copilotKitMessage.copilotKitAssistantMessage
|
||||
.prose
|
||||
blockquote::before {
|
||||
content: "“";
|
||||
position: absolute;
|
||||
left: -0.05em;
|
||||
top: -0.4em;
|
||||
font-size: 2.4em;
|
||||
color: var(--halcyon-ember);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Inline code — small ember chip on a tinted card. */
|
||||
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose code {
|
||||
font-family: var(--halcyon-mono);
|
||||
font-size: 0.86em;
|
||||
font-weight: 500;
|
||||
color: var(--halcyon-ember);
|
||||
background: var(--halcyon-ember-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--halcyon-ember) 22%, transparent);
|
||||
border-radius: 0;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
/* Code block — dark ink card flipped against the parchment. The contrast
|
||||
* is deliberate; it reads like a code excerpt set in a printed book. */
|
||||
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose pre,
|
||||
.chat-css-demo-scope
|
||||
.copilotKitMessage.copilotKitAssistantMessage
|
||||
div[data-streamdown="code-block"]
|
||||
> pre {
|
||||
background: var(--halcyon-ink) !important;
|
||||
color: #e8e2d5;
|
||||
border: 1px solid var(--halcyon-ink);
|
||||
border-radius: 0;
|
||||
padding: 14px 16px;
|
||||
margin: 1em 0;
|
||||
font-family: var(--halcyon-mono);
|
||||
font-size: 0.86em;
|
||||
line-height: 1.55;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||
var(--halcyon-shadow-soft);
|
||||
}
|
||||
|
||||
.chat-css-demo-scope
|
||||
.copilotKitMessage.copilotKitAssistantMessage
|
||||
.prose
|
||||
pre
|
||||
code {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Anchor links — ember underline in classic editorial style. */
|
||||
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose a {
|
||||
color: var(--halcyon-ember);
|
||||
text-decoration-line: underline;
|
||||
text-decoration-color: color-mix(
|
||||
in srgb,
|
||||
var(--halcyon-ember) 35%,
|
||||
transparent
|
||||
);
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 3px;
|
||||
transition: text-decoration-color 160ms ease;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope
|
||||
.copilotKitMessage.copilotKitAssistantMessage
|
||||
.prose
|
||||
a:hover {
|
||||
text-decoration-color: var(--halcyon-ember);
|
||||
}
|
||||
|
||||
/* Horizontal rule — short, centered, ornament-like. */
|
||||
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: var(--halcyon-rule);
|
||||
width: 64px;
|
||||
margin: 1.6em auto;
|
||||
}
|
||||
/* @endregion[assistant-message] */
|
||||
|
||||
/* @region[input-composer] */
|
||||
/* Composer — a sharp paper card with an ember focus rule. The default
|
||||
* pill is rounded; squaring it off is again the visual cue that this is
|
||||
* a different brand. The wrapper around .copilotKitInput uses a fixed
|
||||
* white background in v2, so we override it directly. */
|
||||
.chat-css-demo-scope .copilotKitInput {
|
||||
font-family: var(--halcyon-sans) !important;
|
||||
background: var(--halcyon-card) !important;
|
||||
border: 1px solid var(--halcyon-rule);
|
||||
border-radius: 0 !important;
|
||||
padding: 14px 16px;
|
||||
min-height: 56px;
|
||||
box-shadow:
|
||||
0 1px 0 rgba(26, 23, 20, 0.03),
|
||||
0 8px 24px -16px rgba(26, 23, 20, 0.18);
|
||||
transition:
|
||||
border-color 200ms ease,
|
||||
box-shadow 200ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope .copilotKitInput:focus-within {
|
||||
border-color: var(--halcyon-ember);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(196, 74, 31, 0.12),
|
||||
0 1px 0 rgba(196, 74, 31, 0.18),
|
||||
0 14px 36px -16px rgba(196, 74, 31, 0.22);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chat-css-demo-scope .copilotKitInput textarea {
|
||||
font-family: var(--halcyon-sans) !important;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--halcyon-ink);
|
||||
line-height: 1.55;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope .copilotKitInput textarea::placeholder {
|
||||
color: var(--halcyon-ink-mute);
|
||||
font-style: italic;
|
||||
opacity: 1;
|
||||
}
|
||||
/* @endregion[input-composer] */
|
||||
|
||||
/* @region[input-buttons] */
|
||||
/* The send button — a square ember chit, not the default circular pill.
|
||||
* v2 ships this as `<Button variant="chatInputToolbarPrimary"
|
||||
* size="chatInputToolbarIcon" data-testid="copilot-send-button">`, which
|
||||
* compiles to `cpk:bg-black cpk:text-white cpk:rounded-full cpk:h-9
|
||||
* cpk:w-9`. We override every one of those tokens so the brand wins. */
|
||||
.chat-css-demo-scope button[data-testid="copilot-send-button"] {
|
||||
background-color: var(--halcyon-ember) !important;
|
||||
color: var(--halcyon-paper-elevated) !important;
|
||||
border: 1px solid var(--halcyon-ember) !important;
|
||||
border-radius: 2px !important;
|
||||
height: 36px !important;
|
||||
width: 36px !important;
|
||||
box-shadow: var(--halcyon-shadow-ember);
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
background-color 150ms ease;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope button[data-testid="copilot-send-button"]:hover {
|
||||
background-color: var(--halcyon-ember-bright) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chat-css-demo-scope button[data-testid="copilot-send-button"]:disabled {
|
||||
background-color: var(--halcyon-paper-soft) !important;
|
||||
color: var(--halcyon-ink-mute) !important;
|
||||
border-color: var(--halcyon-rule) !important;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope button[data-testid="copilot-send-button"] svg {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Secondary input chrome (add-menu plus, mic, transcribe) — ghost squares
|
||||
* with an ember tint on hover. v2's `chatInputToolbarSecondary` variant
|
||||
* uses transparent bg + #444 text, which we re-tint to match the brand. */
|
||||
.chat-css-demo-scope
|
||||
.copilotKitInput
|
||||
button:not([data-testid="copilot-send-button"]) {
|
||||
border-radius: 2px !important;
|
||||
color: var(--halcyon-ink-soft) !important;
|
||||
transition:
|
||||
color 150ms ease,
|
||||
background-color 150ms ease;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope
|
||||
.copilotKitInput
|
||||
button:not([data-testid="copilot-send-button"]):hover {
|
||||
color: var(--halcyon-ember) !important;
|
||||
background-color: var(--halcyon-ember-soft) !important;
|
||||
}
|
||||
/* @endregion[input-buttons] */
|
||||
|
||||
/* @region[suggestions] */
|
||||
/* Suggestion pills — sharp outlined chips, not rounded balloons. The
|
||||
* inner suggestion text uses the editorial mono so it reads like a
|
||||
* curated set of dispatch options. */
|
||||
.chat-css-demo-scope [class*="copilotKitSuggestion"] {
|
||||
background: transparent;
|
||||
color: var(--halcyon-ink-soft);
|
||||
border: 1px solid var(--halcyon-rule);
|
||||
border-radius: 0;
|
||||
padding: 8px 14px;
|
||||
font-family: var(--halcyon-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
transition:
|
||||
color 150ms ease,
|
||||
border-color 150ms ease,
|
||||
background 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope [class*="copilotKitSuggestion"]:hover {
|
||||
color: var(--halcyon-ember);
|
||||
border-color: var(--halcyon-ember);
|
||||
background: var(--halcyon-ember-soft);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
/* @endregion[suggestions] */
|
||||
|
||||
/* @region[scrollbar] */
|
||||
.chat-css-demo-scope [data-copilotkit] ::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope [data-copilotkit] ::-webkit-scrollbar-thumb {
|
||||
background: var(--halcyon-rule-strong);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope [data-copilotkit] ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--halcyon-ember);
|
||||
}
|
||||
|
||||
.chat-css-demo-scope [data-copilotkit] * {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--halcyon-rule-strong) transparent;
|
||||
}
|
||||
/* @endregion[scrollbar] */
|
||||
|
||||
/* @region[selection] */
|
||||
.chat-css-demo-scope ::selection {
|
||||
background: var(--halcyon-ember-soft);
|
||||
color: var(--halcyon-ember);
|
||||
}
|
||||
/* @endregion[selection] */
|
||||
|
||||
/* @region[motion] */
|
||||
/* A single, restrained entrance for the welcome screen — staggered fade-up
|
||||
* on the eyebrow / heading / rule. No infinite loops, no bouncy easing. */
|
||||
@keyframes halcyon-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1 {
|
||||
animation: halcyon-rise 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1::before {
|
||||
animation: halcyon-rise 600ms cubic-bezier(0.2, 0.7, 0.2, 1) 80ms both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1,
|
||||
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
/* @endregion[motion] */
|
||||
@@ -0,0 +1,20 @@
|
||||
# Chat Slots
|
||||
|
||||
## What This Demo Shows
|
||||
|
||||
How to override built-in chat UI regions using the `CopilotChat` **slot** system. Three slots are overridden:
|
||||
|
||||
- `welcomeScreen` — the empty-state card shown before the first message
|
||||
- `input.disclaimer` — the small disclaimer line under the input box
|
||||
- `messageView.assistantMessage` — the card rendered for each assistant reply
|
||||
|
||||
## How to Interact
|
||||
|
||||
- On page load you see the gradient welcome card (custom `welcomeScreen`) with a "slot"-tagged disclaimer under the input
|
||||
- Ask anything (for example "Tell me a joke") and watch the assistant reply render inside a custom indigo card (custom `assistantMessage`)
|
||||
|
||||
## Technical Details
|
||||
|
||||
- `<CopilotChat>` accepts `welcomeScreen`, `input`, and `messageView` props; each can receive either a single component or an object mapping sub-slot names to components.
|
||||
- The custom `assistantMessage` wraps the default `CopilotChatAssistantMessage` rather than reimplementing it, so markdown rendering, streaming, and tool UI all still work.
|
||||
- The agent name `chat-slots` is registered in `src/app/api/copilotkit/route.ts` and forwards to the shared .NET `ProverbsAgent`.
|
||||
+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>
|
||||
);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
"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 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,112 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
CopilotKit,
|
||||
CopilotChat,
|
||||
CopilotChatAssistantMessage,
|
||||
CopilotChatUserMessage,
|
||||
CopilotChatReasoningMessage,
|
||||
CopilotChatView,
|
||||
CopilotChatInput,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
import {
|
||||
CustomWelcomeScreen,
|
||||
CustomAssistantMessage,
|
||||
CustomUserMessage,
|
||||
CustomReasoningMessage,
|
||||
CustomCursor,
|
||||
CustomTextArea,
|
||||
CustomSendButton,
|
||||
CustomDisclaimer,
|
||||
CustomAddMenuButton,
|
||||
CustomSuggestionContainer,
|
||||
CustomSuggestion,
|
||||
CustomScrollToBottomButton,
|
||||
CustomFeather,
|
||||
} from "./slot-wrappers";
|
||||
import { makeSlotOverride } from "../_shared/slot-override";
|
||||
import { useChatSlotsSuggestions } from "./suggestions";
|
||||
|
||||
// "Slot Atlas" — every overrideable slot on CopilotChat is wrapped in a
|
||||
// dashed, color-coded marker so a developer can see at a glance what is
|
||||
// customizable and where it lives. Hover any region to reveal its slot path.
|
||||
export default function ChatSlotsDemo() {
|
||||
return (
|
||||
<CopilotKit runtimeUrl="/api/copilotkit" agent="chat-slots">
|
||||
<div className="flex flex-col h-screen w-full bg-background">
|
||||
<div className="flex-1 flex justify-center items-stretch p-4 min-h-0">
|
||||
<div className="h-full w-full max-w-5xl">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKit>
|
||||
);
|
||||
}
|
||||
|
||||
function Chat() {
|
||||
useChatSlotsSuggestions();
|
||||
|
||||
// Slot overrides go through `makeSlotOverride<TDefault>(component)` so
|
||||
// the cast is centralized in one named helper instead of sprinkled
|
||||
// through this file. See `../_shared/slot-override.ts` for the why.
|
||||
const welcomeScreen =
|
||||
makeSlotOverride<typeof CopilotChatView.WelcomeScreen>(CustomWelcomeScreen);
|
||||
|
||||
// The input prop accepts both slot overrides AND CopilotChatInput's rest
|
||||
// props (toolsMenu, mode, etc.) merged together. We seed `toolsMenu` so the
|
||||
// addMenuButton slot has a reason to render.
|
||||
const input = {
|
||||
textArea:
|
||||
makeSlotOverride<typeof CopilotChatInput.TextArea>(CustomTextArea),
|
||||
sendButton:
|
||||
makeSlotOverride<typeof CopilotChatInput.SendButton>(CustomSendButton),
|
||||
disclaimer:
|
||||
makeSlotOverride<typeof CopilotChatInput.Disclaimer>(CustomDisclaimer),
|
||||
addMenuButton:
|
||||
makeSlotOverride<typeof CopilotChatInput.AddMenuButton>(
|
||||
CustomAddMenuButton,
|
||||
),
|
||||
toolsMenu: [
|
||||
{
|
||||
label: "Demo tool (no-op)",
|
||||
action: () => {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const messageView = {
|
||||
assistantMessage: makeSlotOverride<typeof CopilotChatAssistantMessage>(
|
||||
CustomAssistantMessage,
|
||||
),
|
||||
userMessage:
|
||||
makeSlotOverride<typeof CopilotChatUserMessage>(CustomUserMessage),
|
||||
reasoningMessage: makeSlotOverride<typeof CopilotChatReasoningMessage>(
|
||||
CustomReasoningMessage,
|
||||
),
|
||||
cursor: CustomCursor,
|
||||
};
|
||||
|
||||
const suggestionView = {
|
||||
container: CustomSuggestionContainer,
|
||||
suggestion: CustomSuggestion,
|
||||
};
|
||||
|
||||
const scrollView = {
|
||||
scrollToBottomButton: CustomScrollToBottomButton,
|
||||
feather: CustomFeather,
|
||||
};
|
||||
|
||||
return (
|
||||
<CopilotChat
|
||||
agentId="chat-slots"
|
||||
className="h-full rounded-2xl border border-border/60 bg-card overflow-hidden"
|
||||
welcomeScreen={welcomeScreen}
|
||||
input={input}
|
||||
messageView={messageView}
|
||||
suggestionView={suggestionView}
|
||||
scrollView={scrollView}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useState } from "react";
|
||||
|
||||
export type SlotColor =
|
||||
| "indigo"
|
||||
| "violet"
|
||||
| "emerald"
|
||||
| "sky"
|
||||
| "amber"
|
||||
| "rose"
|
||||
| "orange"
|
||||
| "red"
|
||||
| "yellow"
|
||||
| "pink"
|
||||
| "cyan"
|
||||
| "teal"
|
||||
| "lime"
|
||||
| "fuchsia";
|
||||
|
||||
// Static lookups so Tailwind v4's source scanner finds every class string at
|
||||
// build time. Dynamic concatenation like `border-${color}-400` would not work.
|
||||
export const SLOT_COLORS: Record<
|
||||
SlotColor,
|
||||
{ border: string; label: string; ring: string }
|
||||
> = {
|
||||
indigo: {
|
||||
border: "border-indigo-400",
|
||||
label: "bg-indigo-500",
|
||||
ring: "ring-indigo-400/40",
|
||||
},
|
||||
violet: {
|
||||
border: "border-violet-400",
|
||||
label: "bg-violet-500",
|
||||
ring: "ring-violet-400/40",
|
||||
},
|
||||
emerald: {
|
||||
border: "border-emerald-400",
|
||||
label: "bg-emerald-500",
|
||||
ring: "ring-emerald-400/40",
|
||||
},
|
||||
sky: {
|
||||
border: "border-sky-400",
|
||||
label: "bg-sky-500",
|
||||
ring: "ring-sky-400/40",
|
||||
},
|
||||
amber: {
|
||||
border: "border-amber-400",
|
||||
label: "bg-amber-500",
|
||||
ring: "ring-amber-400/40",
|
||||
},
|
||||
rose: {
|
||||
border: "border-rose-400",
|
||||
label: "bg-rose-500",
|
||||
ring: "ring-rose-400/40",
|
||||
},
|
||||
orange: {
|
||||
border: "border-orange-400",
|
||||
label: "bg-orange-500",
|
||||
ring: "ring-orange-400/40",
|
||||
},
|
||||
red: {
|
||||
border: "border-red-400",
|
||||
label: "bg-red-500",
|
||||
ring: "ring-red-400/40",
|
||||
},
|
||||
yellow: {
|
||||
border: "border-yellow-400",
|
||||
label: "bg-yellow-500",
|
||||
ring: "ring-yellow-400/40",
|
||||
},
|
||||
pink: {
|
||||
border: "border-pink-400",
|
||||
label: "bg-pink-500",
|
||||
ring: "ring-pink-400/40",
|
||||
},
|
||||
cyan: {
|
||||
border: "border-cyan-400",
|
||||
label: "bg-cyan-500",
|
||||
ring: "ring-cyan-400/40",
|
||||
},
|
||||
teal: {
|
||||
border: "border-teal-400",
|
||||
label: "bg-teal-500",
|
||||
ring: "ring-teal-400/40",
|
||||
},
|
||||
lime: {
|
||||
border: "border-lime-400",
|
||||
label: "bg-lime-500",
|
||||
ring: "ring-lime-400/40",
|
||||
},
|
||||
fuchsia: {
|
||||
border: "border-fuchsia-400",
|
||||
label: "bg-fuchsia-500",
|
||||
ring: "ring-fuchsia-400/40",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps a slot region with a dashed outline plus a small clickable badge
|
||||
* that copies the slot's component path to the clipboard.
|
||||
*
|
||||
* The label is opacity-0 by default and turns visible only when this marker
|
||||
* is hovered AND no descendant marker is also hovered. Markers nest
|
||||
* (welcomeScreen wraps welcomeMessage / input / suggestionView), so a plain
|
||||
* `:hover .slot-label { opacity: 1 }` would light up every nested label.
|
||||
* The `:not(:has(.slot-marker:hover))` predicate isolates each marker.
|
||||
*/
|
||||
export function SlotMarker({
|
||||
color,
|
||||
label,
|
||||
children,
|
||||
inline,
|
||||
className,
|
||||
}: {
|
||||
color: SlotColor;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const c = SLOT_COLORS[color];
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const onCopy = useCallback(
|
||||
async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(label);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1100);
|
||||
} catch {
|
||||
// clipboard may be unavailable (e.g. insecure context); silently no-op
|
||||
}
|
||||
},
|
||||
[label],
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot-label={label}
|
||||
className={`slot-marker relative ${inline ? "inline-flex" : "flex"} border border-dashed ${c.border} rounded-lg p-1 ${className ?? ""}`}
|
||||
style={{ flexDirection: inline ? "row" : "column" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
title={copied ? "Copied!" : `Copy slot path: ${label}`}
|
||||
aria-label={`Copy slot path ${label}`}
|
||||
className={`slot-label absolute -top-2 left-2 inline-flex items-center gap-1 rounded ${c.label} text-white text-[9px] font-bold uppercase tracking-wider px-1.5 py-px shadow-sm z-10 whitespace-nowrap opacity-0 transition-opacity hover:brightness-110 cursor-pointer pointer-events-auto font-mono normal-case tracking-normal`}
|
||||
>
|
||||
<span>{copied ? "Copied" : label}</span>
|
||||
<span aria-hidden="true" className="text-white/70 text-[8px]">
|
||||
{copied ? "✓" : "⧉"}
|
||||
</span>
|
||||
</button>
|
||||
<span style={{ display: "contents" }}>{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
// Docs-only snippet — not imported or rendered. The langgraph-python
|
||||
// chat-slots production demo registers a dozen slot overrides at once
|
||||
// (see page.tsx) with `as unknown as typeof X` casts that exist to
|
||||
// satisfy the WithSlots types when the wrappers are structurally
|
||||
// compatible but not nominally identical. That's necessary in the
|
||||
// running app but obscures the teaching shape.
|
||||
//
|
||||
// This file gives the slots docs page (custom-look-and-feel/slots.mdx)
|
||||
// three minimal teaching examples — the welcome screen, assistant
|
||||
// message, and disclaimer slot patterns — without changing the
|
||||
// production demo's runtime behavior. See agentic-chat /
|
||||
// chat-component.snippet.tsx for the same sibling-file pattern.
|
||||
|
||||
// @region[register-disclaimer-slot]
|
||||
// @region[register-assistant-message-slot]
|
||||
// @region[register-welcome-slot]
|
||||
import type {
|
||||
CopilotChatAssistantMessage,
|
||||
CopilotChatInput,
|
||||
CopilotChatView,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
|
||||
declare const CustomWelcomeScreen: React.ComponentType;
|
||||
declare const CustomAssistantMessage: React.ComponentType;
|
||||
declare const CustomDisclaimer: React.ComponentType;
|
||||
|
||||
export function ChatSlotsTeachingExtracts() {
|
||||
const welcomeScreen =
|
||||
CustomWelcomeScreen as unknown as typeof CopilotChatView.WelcomeScreen;
|
||||
// @endregion[register-welcome-slot]
|
||||
|
||||
const messageView = {
|
||||
assistantMessage:
|
||||
CustomAssistantMessage as unknown as typeof CopilotChatAssistantMessage,
|
||||
};
|
||||
// @endregion[register-assistant-message-slot]
|
||||
|
||||
const input = {
|
||||
disclaimer:
|
||||
CustomDisclaimer as unknown as typeof CopilotChatInput.Disclaimer,
|
||||
};
|
||||
// @endregion[register-disclaimer-slot]
|
||||
|
||||
return { welcomeScreen, messageView, input };
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
CopilotChatAssistantMessage,
|
||||
CopilotChatUserMessage,
|
||||
CopilotChatReasoningMessage,
|
||||
CopilotChatMessageView,
|
||||
CopilotChatView,
|
||||
CopilotChatInput,
|
||||
CopilotChatSuggestionPill,
|
||||
type CopilotChatAssistantMessageProps,
|
||||
type CopilotChatUserMessageProps,
|
||||
type CopilotChatReasoningMessageProps,
|
||||
type CopilotChatSuggestionPillProps,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
import { SlotMarker } from "./slot-marker";
|
||||
|
||||
// =====================================================================
|
||||
// welcomeScreen + welcomeScreen.welcomeMessage
|
||||
// The welcomeScreen receives `input` and `suggestionView` as elements; we
|
||||
// also expose the `welcomeMessage` sub-slot to show that slots can nest.
|
||||
// =====================================================================
|
||||
export function CustomWelcomeMessage(
|
||||
props: React.HTMLAttributes<HTMLDivElement>,
|
||||
) {
|
||||
return (
|
||||
<SlotMarker color="violet" label="WelcomeScreen.WelcomeMessage">
|
||||
<div
|
||||
{...props}
|
||||
className="text-center px-4 py-3 text-sm text-muted-foreground"
|
||||
data-testid="custom-welcome-message"
|
||||
>
|
||||
Hover any region to see its slot path · click the badge to copy
|
||||
</div>
|
||||
</SlotMarker>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWelcomeScreen({
|
||||
input,
|
||||
suggestionView,
|
||||
}: {
|
||||
input: React.ReactElement;
|
||||
suggestionView: React.ReactElement;
|
||||
welcomeMessage?: React.ReactElement;
|
||||
}) {
|
||||
return (
|
||||
<SlotMarker color="indigo" label="WelcomeScreen" className="flex-1 m-3">
|
||||
<div
|
||||
data-testid="custom-welcome-screen"
|
||||
className="flex-1 flex flex-col items-center justify-center px-4 py-6 gap-4 w-full"
|
||||
>
|
||||
<CustomWelcomeMessage />
|
||||
<div className="w-full max-w-2xl">{input}</div>
|
||||
<div className="flex justify-center">{suggestionView}</div>
|
||||
</div>
|
||||
</SlotMarker>
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// messageView.assistantMessage
|
||||
// =====================================================================
|
||||
export function CustomAssistantMessage(
|
||||
props: CopilotChatAssistantMessageProps,
|
||||
) {
|
||||
return (
|
||||
<SlotMarker
|
||||
color="emerald"
|
||||
label="MessageView.AssistantMessage"
|
||||
className="my-3"
|
||||
>
|
||||
<CopilotChatAssistantMessage {...props} />
|
||||
</SlotMarker>
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// messageView.userMessage
|
||||
// =====================================================================
|
||||
export function CustomUserMessage(props: CopilotChatUserMessageProps) {
|
||||
return (
|
||||
<SlotMarker
|
||||
color="sky"
|
||||
label="MessageView.UserMessage"
|
||||
className="my-3 ml-auto"
|
||||
>
|
||||
<CopilotChatUserMessage {...props} />
|
||||
</SlotMarker>
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// messageView.reasoningMessage
|
||||
// Only renders when the message stream contains reasoning content.
|
||||
// =====================================================================
|
||||
export function CustomReasoningMessage(
|
||||
props: CopilotChatReasoningMessageProps,
|
||||
) {
|
||||
return (
|
||||
<SlotMarker
|
||||
color="rose"
|
||||
label="MessageView.ReasoningMessage"
|
||||
className="my-2"
|
||||
>
|
||||
<CopilotChatReasoningMessage {...props} />
|
||||
</SlotMarker>
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// messageView.cursor
|
||||
// Renders while a message is streaming. Tiny — wrap inline.
|
||||
// =====================================================================
|
||||
export function CustomCursor(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<SlotMarker color="amber" label="MessageView.Cursor" inline>
|
||||
<CopilotChatMessageView.Cursor {...props} />
|
||||
</SlotMarker>
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// input.textArea
|
||||
// We wrap the default in a SlotMarker. The marker is `display: contents`-ish
|
||||
// inside; the dashed border is on the marker's outer span.
|
||||
// =====================================================================
|
||||
export function CustomTextArea(
|
||||
props: React.ComponentProps<typeof CopilotChatInput.TextArea>,
|
||||
) {
|
||||
return (
|
||||
<SlotMarker
|
||||
color="orange"
|
||||
label="Input.TextArea"
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
<CopilotChatInput.TextArea {...props} />
|
||||
</SlotMarker>
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// input.sendButton
|
||||
// =====================================================================
|
||||
export function CustomSendButton(
|
||||
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
) {
|
||||
return (
|
||||
<SlotMarker color="red" label="Input.SendButton" inline>
|
||||
<CopilotChatInput.SendButton {...props} />
|
||||
</SlotMarker>
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// input.disclaimer
|
||||
// =====================================================================
|
||||
export function CustomDisclaimer(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<SlotMarker
|
||||
color="yellow"
|
||||
label="Input.Disclaimer"
|
||||
className="mx-auto my-1.5"
|
||||
>
|
||||
<div
|
||||
{...props}
|
||||
data-testid="custom-disclaimer"
|
||||
className="text-xs text-center text-muted-foreground px-2 py-1"
|
||||
>
|
||||
Custom disclaimer slot · stays visible in every input variant
|
||||
</div>
|
||||
</SlotMarker>
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// input.addMenuButton
|
||||
// Only renders if `onAddFile` or `toolsMenu` is set on CopilotChatInput.
|
||||
// =====================================================================
|
||||
export function CustomAddMenuButton(
|
||||
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
) {
|
||||
return (
|
||||
<SlotMarker color="pink" label="Input.AddMenuButton" inline>
|
||||
<CopilotChatInput.AddMenuButton {...props} />
|
||||
</SlotMarker>
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// suggestionView.container
|
||||
// =====================================================================
|
||||
export const CustomSuggestionContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(function CustomSuggestionContainer(props, ref) {
|
||||
return (
|
||||
<SlotMarker color="cyan" label="SuggestionView.Container" className="my-2">
|
||||
<div ref={ref} {...props} />
|
||||
</SlotMarker>
|
||||
);
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// suggestionView.suggestion
|
||||
// =====================================================================
|
||||
export const CustomSuggestion = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
CopilotChatSuggestionPillProps
|
||||
>(function CustomSuggestion(props, ref) {
|
||||
return (
|
||||
<SlotMarker color="teal" label="SuggestionView.Suggestion" inline>
|
||||
<CopilotChatSuggestionPill ref={ref} {...props} />
|
||||
</SlotMarker>
|
||||
);
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// scrollView.scrollToBottomButton
|
||||
// =====================================================================
|
||||
export function CustomScrollToBottomButton(
|
||||
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
) {
|
||||
return (
|
||||
<SlotMarker
|
||||
color="lime"
|
||||
label="ScrollView.ScrollToBottomButton"
|
||||
inline
|
||||
className="absolute bottom-20 right-6"
|
||||
>
|
||||
<CopilotChatView.ScrollToBottomButton {...props} />
|
||||
</SlotMarker>
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// scrollView.feather
|
||||
// The default Feather is the gradient fade above the input. The default
|
||||
// implementation is an empty div with absolute positioning, so we render
|
||||
// our own visible gradient + a clickable copy badge so the slot is
|
||||
// unambiguously visible.
|
||||
// =====================================================================
|
||||
export function CustomFeather(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
data-testid="custom-feather"
|
||||
className="slot-marker pointer-events-none absolute left-0 right-0 bottom-0 h-12 bg-gradient-to-t from-fuchsia-100/90 to-transparent dark:from-fuchsia-950/40"
|
||||
>
|
||||
<FeatherCopyLabel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatherCopyLabel() {
|
||||
const label = "ScrollView.Feather";
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const onCopy = React.useCallback(
|
||||
async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(label);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1100);
|
||||
} catch {
|
||||
// clipboard may be unavailable in non-secure contexts
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
title={copied ? "Copied!" : `Copy slot path: ${label}`}
|
||||
aria-label={`Copy slot path ${label}`}
|
||||
className="slot-label absolute -top-2 left-2 inline-flex items-center gap-1 rounded bg-fuchsia-500 text-white text-[9px] font-bold px-1.5 py-px shadow-sm z-10 whitespace-nowrap opacity-0 transition-opacity hover:brightness-110 cursor-pointer pointer-events-auto font-mono"
|
||||
>
|
||||
<span>{copied ? "Copied" : label}</span>
|
||||
<span aria-hidden="true" className="text-white/70 text-[8px]">
|
||||
{copied ? "✓" : "⧉"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ShadCN-flavoured Badge primitive (inline-cloned, no `cn()`/`cva`).
|
||||
* Variant palette mirrors ShadCN's default/secondary/destructive/outline,
|
||||
* extended with `success` / `warning` / `info` for status reporting.
|
||||
*/
|
||||
import React from "react";
|
||||
|
||||
export type BadgeVariant = "success" | "warning" | "error" | "info";
|
||||
|
||||
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
|
||||
success: "border-transparent bg-emerald-100 text-emerald-800",
|
||||
warning: "border-transparent bg-amber-100 text-amber-800",
|
||||
error: "border-transparent bg-rose-100 text-rose-800",
|
||||
info: "border-[var(--border)] bg-[var(--muted)] text-[var(--foreground)]",
|
||||
};
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: BadgeVariant;
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
variant = "info",
|
||||
className = "",
|
||||
children,
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium tracking-wide ${VARIANT_CLASSES[variant]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ShadCN-flavoured Button primitive (inline-cloned, no `cn()`/`cva`).
|
||||
*/
|
||||
import React from "react";
|
||||
|
||||
export type ButtonVariant =
|
||||
| "default"
|
||||
| "secondary"
|
||||
| "outline"
|
||||
| "ghost"
|
||||
| "destructive";
|
||||
export type ButtonSize = "sm" | "default" | "lg";
|
||||
|
||||
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
|
||||
default:
|
||||
"bg-[var(--primary)] text-[var(--primary-foreground)] hover:bg-[var(--primary)]/90",
|
||||
secondary:
|
||||
"bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:bg-[var(--secondary)]/80",
|
||||
outline:
|
||||
"border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]",
|
||||
ghost: "bg-transparent text-[var(--foreground)] hover:bg-[var(--muted)]",
|
||||
destructive:
|
||||
"bg-[var(--destructive)] text-[var(--destructive-foreground)] hover:bg-[var(--destructive)]/90",
|
||||
};
|
||||
|
||||
const SIZE_CLASSES: Record<ButtonSize, string> = {
|
||||
sm: "h-8 px-3 text-xs rounded-md",
|
||||
default: "h-9 px-4 text-sm rounded-md",
|
||||
lg: "h-10 px-6 text-sm rounded-md",
|
||||
};
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = "default",
|
||||
size = "default",
|
||||
className = "",
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:pointer-events-none disabled:opacity-50 ${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ShadCN-flavoured Card primitive (inline-cloned, no `cn()`/`cva`).
|
||||
* Uses the showcase's `--card` / `--border` / `--foreground` /
|
||||
* `--muted-foreground` CSS variables so the demo respects light/dark.
|
||||
*/
|
||||
import React from "react";
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className = "",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-sm ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
children,
|
||||
className = "",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-1.5 px-6 pt-5 pb-3 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
children,
|
||||
className = "",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={`text-base font-semibold leading-none tracking-tight ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardDescription({
|
||||
children,
|
||||
className = "",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
className={`text-sm text-[var(--muted-foreground)] ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({
|
||||
children,
|
||||
className = "",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={`px-6 pb-5 ${className}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ShadCN-flavoured Separator primitive — uses the (already-installed)
|
||||
* `@radix-ui/react-separator` accessibility primitive.
|
||||
*/
|
||||
import React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
export function Separator({
|
||||
className = "",
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={`shrink-0 bg-[var(--border)] ${
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px"
|
||||
} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+24
@@ -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]
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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]
|
||||
import { z } from "zod";
|
||||
import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
export const myDefinitions = {
|
||||
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 display with an optional trend indicator. Ideal for dashboards (e.g. 'Revenue • $12.4k • up').",
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
trend: z.enum(["up", "down", "neutral"]).optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
InfoRow: {
|
||||
description:
|
||||
"A compact two-column 'label: value' row. Good for stacks of facts inside a Card (owner, region, last updated, etc.).",
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
},
|
||||
|
||||
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;
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* A2UI catalog RENDERERS.
|
||||
*
|
||||
* React implementations for each definition in `./definitions.ts`,
|
||||
* styled with the demo's local ShadCN-flavoured primitives in
|
||||
* `../_components/`. 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";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "../_components/card";
|
||||
import { Badge } from "../_components/badge";
|
||||
import { Button } from "../_components/button";
|
||||
|
||||
// ─── ShadCN-friendly chart palette ─────────────────────────────────────────
|
||||
// Neutral, slightly muted hues that pair with `bg-card` / `--border`
|
||||
// (zinc/slate-leaning, akin to ShadCN's chart-{1..5} palette).
|
||||
const CHART_COLORS = [
|
||||
"#3F3F46", // zinc-700
|
||||
"#71717A", // zinc-500
|
||||
"#A1A1AA", // zinc-400
|
||||
"#18181B", // zinc-900
|
||||
"#52525B", // zinc-600
|
||||
"#D4D4D8", // zinc-300
|
||||
"#27272A", // zinc-800
|
||||
] as const;
|
||||
|
||||
const CHART_TOOLTIP_STYLE: React.CSSProperties = {
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "8px 12px",
|
||||
color: "var(--foreground)",
|
||||
fontSize: 12,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.06)",
|
||||
};
|
||||
|
||||
/** Custom SVG donut chart built with <circle> + stroke-dasharray. */
|
||||
function DonutChart({
|
||||
data,
|
||||
size = 220,
|
||||
strokeWidth = 36,
|
||||
}: {
|
||||
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="var(--muted)"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// @region[renderers-react]
|
||||
export const myRenderers: CatalogRenderers<MyDefinitions> = {
|
||||
Card: ({ props, children }) => (
|
||||
<Card
|
||||
className="w-full min-w-0 overflow-hidden"
|
||||
data-testid="declarative-card"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>{props.title}</CardTitle>
|
||||
{props.subtitle && <CardDescription>{props.subtitle}</CardDescription>}
|
||||
</CardHeader>
|
||||
{props.child && (
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{children(props.child)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
),
|
||||
|
||||
StatusBadge: ({ props }) => (
|
||||
<Badge
|
||||
variant={props.variant ?? "info"}
|
||||
data-testid="declarative-status-badge"
|
||||
>
|
||||
{props.text}
|
||||
</Badge>
|
||||
),
|
||||
|
||||
Metric: ({ props }) => {
|
||||
const trend = props.trend ?? "neutral";
|
||||
const arrow = trend === "up" ? "↑" : trend === "down" ? "↓" : "";
|
||||
const trendClass =
|
||||
trend === "up"
|
||||
? "text-emerald-600"
|
||||
: trend === "down"
|
||||
? "text-rose-600"
|
||||
: "text-[var(--foreground)]";
|
||||
return (
|
||||
// `flex-1 min-w-[120px]` lets a row of Metrics distribute evenly
|
||||
// inside the basic catalog's gap-less Row — 3 metrics in a 600px
|
||||
// card column get ~200px each instead of squishing to content width.
|
||||
<div
|
||||
data-testid="declarative-metric"
|
||||
className="flex flex-1 min-w-[120px] flex-col gap-1"
|
||||
>
|
||||
<div className="text-xs font-medium uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
{props.label}
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-baseline gap-1.5 text-2xl font-semibold tabular-nums ${trendClass}`}
|
||||
>
|
||||
<span>{props.value}</span>
|
||||
{arrow && <span className="text-base">{arrow}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
InfoRow: ({ props }) => (
|
||||
// Divider via `border-b last:border-b-0` so the final row doesn't dangle
|
||||
// a trailing line, regardless of whether the agent wraps these in a
|
||||
// Column or drops them directly into a Card's child slot.
|
||||
<div className="flex items-baseline justify-between gap-4 py-2 border-b border-[var(--border)] last:border-b-0 last:pb-0 first:pt-0">
|
||||
<span className="text-sm text-[var(--muted-foreground)]">
|
||||
{props.label}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-[var(--foreground)] text-right tabular-nums">
|
||||
{props.value}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
|
||||
PrimaryButton: ({ props, dispatch }) => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (props.action && dispatch) dispatch(props.action);
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
// `flex-1 min-w-0` so multiple charts in a basic-catalog Row
|
||||
// distribute the available width evenly instead of each insisting
|
||||
// on its content size and overflowing.
|
||||
<Card
|
||||
className="w-full flex-1 min-w-0 overflow-hidden"
|
||||
data-testid="declarative-pie-chart"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>{props.title}</CardTitle>
|
||||
<CardDescription>{props.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{safeData.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-[var(--muted-foreground)]">
|
||||
No data available
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DonutChart data={safeData} />
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
{safeData.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"
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
|
||||
style={{
|
||||
backgroundColor:
|
||||
CHART_COLORS[index % CHART_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
<span className="flex-1 truncate text-[var(--foreground)]">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="tabular-nums text-[var(--muted-foreground)]">
|
||||
{val.toLocaleString()}
|
||||
</span>
|
||||
<span className="w-10 text-right tabular-nums text-[var(--muted-foreground)]">
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
|
||||
BarChart: ({ props }) => {
|
||||
const { isNew } = useSeenIndices();
|
||||
const data = props.data ?? [];
|
||||
const safeData = Array.isArray(data) ? data : [];
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="w-full flex-1 min-w-0 overflow-hidden"
|
||||
data-testid="declarative-bar-chart"
|
||||
>
|
||||
{/* 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>
|
||||
<CardTitle>{props.title}</CardTitle>
|
||||
<CardDescription>{props.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{safeData.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-[var(--muted-foreground)]">
|
||||
No data available
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<RechartsBarChart
|
||||
data={safeData}
|
||||
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_TOOLTIP_STYLE}
|
||||
cursor={{ fill: "var(--muted)", 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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
};
|
||||
// @endregion[renderers-react]
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import { useDeclarativeGenUISuggestions } from "./suggestions";
|
||||
|
||||
export function Chat() {
|
||||
useDeclarativeGenUISuggestions();
|
||||
return (
|
||||
<CopilotChat agentId="declarative-gen-ui" className="h-full rounded-2xl" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"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 agent
|
||||
* (`src/agents/a2ui_dynamic.py`) owns the `generate_a2ui` tool
|
||||
* explicitly, mirroring the working pattern from beautiful-chat and the
|
||||
* canonical `examples/integrations/langgraph-python` reference. The
|
||||
* A2UI middleware still serialises the registered catalog schema into
|
||||
* `copilotkit.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 } from "@copilotkit/react-core/v2";
|
||||
|
||||
import { myCatalog } from "./a2ui/catalog";
|
||||
import { Chat } from "./chat";
|
||||
|
||||
export default function DeclarativeGenUIDemo() {
|
||||
return (
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit-declarative-gen-ui"
|
||||
agent="declarative-gen-ui"
|
||||
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]
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* BarChart ported from showcase/shared/starter-template/components/charts/bar-chart.tsx
|
||||
* for the declarative-hashbrown demo.
|
||||
*
|
||||
* 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/shared/starter-template/components/charts/chart-config.ts
|
||||
* for the declarative-hashbrown demo.
|
||||
*/
|
||||
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/shared/starter-template/components/charts/pie-chart.tsx
|
||||
* for the declarative-hashbrown demo.
|
||||
*
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import type { CopilotChatAssistantMessage } from "@copilotkit/react-core/v2";
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import { HashBrownRenderMessage } from "./hashbrown-renderer";
|
||||
import { useByocHashbrownSuggestions } from "./suggestions";
|
||||
|
||||
export function Chat() {
|
||||
useByocHashbrownSuggestions();
|
||||
|
||||
return (
|
||||
<CopilotChat
|
||||
className="h-full"
|
||||
messageView={{
|
||||
// The renderer reads only `message` from the slot props; cast to the
|
||||
// wider CopilotChatAssistantMessage signature to satisfy the slot type.
|
||||
assistantMessage:
|
||||
HashBrownRenderMessage as unknown as typeof CopilotChatAssistantMessage,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* HashBrown message renderer for the declarative-hashbrown demo.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* The agent (`src/agents/byoc_hashbrown_agent.py`) emits JSON of the shape:
|
||||
* { "ui": [ { "metric": { "props": {...} } }, ... ] }
|
||||
*/
|
||||
import React, { createContext, memo, useContext } 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";
|
||||
|
||||
// Charts take `data` as a real array, but hashbrown's schema validator rejects
|
||||
// example prompts whose JSX attribute values don't match the schema type. The
|
||||
// LLM streams JSON as text anyway, so we model `data` as a string and parse
|
||||
// inside the wrapper. Render nothing if parse fails mid-stream.
|
||||
type ChartSlice = { label: string; value: number };
|
||||
|
||||
function parseChartData(data: string): ChartSlice[] | null {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
return Array.isArray(parsed) ? (parsed as ChartSlice[]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function PieChartWithStringData({
|
||||
title,
|
||||
data,
|
||||
}: {
|
||||
title: string;
|
||||
data: string;
|
||||
}) {
|
||||
const parsed = parseChartData(data);
|
||||
return parsed ? (
|
||||
<PieChart title={title} description="" data={parsed} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
function BarChartWithStringData({
|
||||
title,
|
||||
data,
|
||||
}: {
|
||||
title: string;
|
||||
data: string;
|
||||
}) {
|
||||
const parsed = parseChartData(data);
|
||||
return parsed ? (
|
||||
<BarChart title={title} description="" data={parsed} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
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 DealCard({
|
||||
title,
|
||||
stage,
|
||||
value,
|
||||
assignee,
|
||||
dueDate,
|
||||
}: {
|
||||
title: string;
|
||||
stage: SalesStage;
|
||||
value: number;
|
||||
assignee?: string;
|
||||
dueDate?: string;
|
||||
}) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
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(DealCard, {
|
||||
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"),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
type Kit = ReturnType<typeof useSalesDashboardKit>;
|
||||
const HashBrownKitContext = createContext<Kit | null>(null);
|
||||
|
||||
export function HashBrownDashboard({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const kit = useSalesDashboardKit();
|
||||
return (
|
||||
<HashBrownKitContext.Provider value={kit}>
|
||||
{children}
|
||||
</HashBrownKitContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const AssistantMessage = memo(function AssistantMessage({
|
||||
content,
|
||||
kit,
|
||||
}: {
|
||||
content: string;
|
||||
kit: Kit;
|
||||
}) {
|
||||
const { value } = useJsonParser(content, kit.schema);
|
||||
if (!value) return null;
|
||||
|
||||
// Re-attach `data-testid="copilot-assistant-message"` so the e2e harness'
|
||||
// assistant-message-landed detection still works when this slot overrides
|
||||
// the default CopilotChatAssistantMessage.
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
export function HashBrownRenderMessage({
|
||||
message,
|
||||
}: {
|
||||
message: { role: string; content?: string };
|
||||
}) {
|
||||
const kit = useContext(HashBrownKitContext);
|
||||
if (!kit)
|
||||
throw new Error(
|
||||
"HashBrownRenderMessage must be used within HashBrownDashboard",
|
||||
);
|
||||
if (message.role !== "assistant") return null;
|
||||
return <AssistantMessage content={message.content ?? ""} kit={kit} />;
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user