chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
# Constraint tables for the Showcase Decision Tree.
# Generative UI: allowlist-based (demo must appear in `allowed` list)
# Interaction Modality: denylist-based (all allowed unless in `excluded` list)
generative_ui:
constrained-declarative:
allowed:
- beautiful-chat
- cli-start
- agent-config
- agentic-chat
- chat-customization-css
- tool-rendering-default-catchall
- tool-rendering-custom-catchall
- frontend-tools
- frontend-tools-async
- gen-ui-tool-based
- useComponent
- multimodal
- suggestions
- shared-state-read-write
- readonly-state-agent-context
- shared-state-streaming
- subagents
- voice
- auth
excluded:
- tool-rendering
- hitl-in-chat
- hitl-in-app
- gen-ui-interrupt
constrained-explicit:
allowed:
- beautiful-chat
- cli-start
- agent-config
- agentic-chat
- chat-customization-css
- tool-rendering-default-catchall
- tool-rendering-custom-catchall
- tool-rendering-reasoning-chain
- agentic-chat-reasoning
- reasoning-default-render
- reasoning-default
- reasoning-custom
- prebuilt-sidebar
- prebuilt-popup
- chat-slots
- headless-simple
- headless-complete
- frontend-tools
- frontend-tools-async
- gen-ui-tool-based
- gen-ui-interrupt
- interrupt-headless
- gen-ui-agent
- hitl-in-chat
- hitl-in-chat-booking
- hitl-in-app
- hitl
- declarative-gen-ui
- a2ui-fixed-schema
- a2ui-recovery
- mcp-apps
- tool-rendering
- useComponent
- multimodal
- suggestions
- shared-state-read-write
- shared-state-read
- readonly-state-agent-context
- shared-state-streaming
- subagents
- voice
- auth
- byoc-hashbrown
- byoc-json-render
- declarative-hashbrown
- declarative-json-render
- open-gen-ui
- open-gen-ui-advanced
interaction_modalities:
headless:
excluded:
- voice
# sidebar, embedded, popup, chat: no exclusions
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
# dev-entrypoint.sh — fast-iteration container entrypoint for `showcase up --dev`.
#
# Unlike the per-integration production entrypoint.sh (which runs the BAKED
# image artifacts — `next start` + a non-reloading agent server), this
# entrypoint runs the BIND-MOUNTED SOURCE (`/app/src`, mounted by
# docker-compose.dev.yml) under each stack's native file-watch reloader so an
# edit to a source file hot-reloads the component WITHOUT an image rebuild.
#
# The built-image mode remains the faithful/staging-equivalent default; this
# path trades fidelity for iteration speed. It reuses the SAME image (so the
# venv / node_modules baked at build time are present) — only the run command
# differs (source-mounted + auto-reload).
#
# Stack detection (self-contained, no per-integration script needed):
# * Python agent → present iff `/app/src/agent_server.py` exists (FastAPI/
# uvicorn) OR `/app/langgraph.json` exists (langgraph dev,
# which already hot-reloads its graph). Run with
# `uvicorn --reload` watching `/app/src`.
# * Next.js front → present iff `/app/package.json` has a `dev` script. Run
# `next dev` (Turbopack/webpack file-watch) on $PORT.
#
# Each detected process is launched in the background with a log prefix; the
# script waits on whichever exits first (mirrors the production entrypoint's
# `wait -n` discipline) so a crash surfaces and the container restarts.
set -e
PORT=${PORT:-10000}
export PYTHONUNBUFFERED=1
echo "========================================="
echo "[dev-entrypoint] FAST DEV MODE (bind-mounted source + hot reload)"
echo "[dev-entrypoint] Time: $(date -u)"
echo "[dev-entrypoint] PWD: $(pwd) PORT=${PORT}"
echo "[dev-entrypoint] NOTE: this is NOT the faithful built-image path."
echo "========================================="
PIDS=()
cleanup() {
for pid in "${PIDS[@]}"; do
kill "$pid" 2>/dev/null || true
done
}
trap cleanup EXIT
# ── Python agent (uvicorn --reload) ──────────────────────────────────────
if [ -f /app/src/agent_server.py ]; then
echo "[dev-entrypoint] Detected FastAPI agent (src/agent_server.py) — uvicorn --reload on :8000"
# Run from the bind-mounted source dir so uvicorn imports + watches the
# LIVE source, not the baked /app/agent_server.py copy. PYTHONPATH includes
# /app so shared `tools/` (baked at /app/tools) still resolves.
(
cd /app/src
# `stdbuf -oL -eL` forces line-buffering on the pipe so uvicorn's
# WatchFiles "Detected change / Reloading" lines reach `docker logs`
# immediately (the awk `fflush()` only flushes awk's own buffer).
PYTHONPATH=/app/src:/app stdbuf -oL -eL python -u -m uvicorn agent_server:app \
--host 0.0.0.0 --port 8000 \
--reload --reload-dir /app/src \
2>&1 | stdbuf -oL awk '{print "[agent] " $0; fflush()}'
) &
PIDS+=("$!")
elif [ -f /app/langgraph.json ]; then
echo "[dev-entrypoint] Detected langgraph.json — langgraph dev (hot-reloads graph) on :8123"
(
stdbuf -oL -eL python -u -m langgraph_cli dev \
--config langgraph.json --host 0.0.0.0 --port 8123 --no-browser \
2>&1 | stdbuf -oL awk '{print "[langgraph] " $0; fflush()}'
) &
PIDS+=("$!")
fi
# ── Next.js frontend (next dev — file-watch hot reload) ──────────────────
if [ -f /app/package.json ] && grep -q '"dev"' /app/package.json; then
echo "[dev-entrypoint] Detected Next.js — next dev (hot reload) on :${PORT}"
(
cd /app
# `next dev` watches src/ (bind-mounted) and rebuilds on change. Do NOT
# set NODE_ENV=production here — dev mode needs the development runtime.
npx next dev --port "$PORT" \
2>&1 | stdbuf -oL awk '{print "[nextjs] " $0; fflush()}'
) &
PIDS+=("$!")
fi
if [ ${#PIDS[@]} -eq 0 ]; then
echo "[dev-entrypoint] ERROR: no known stack detected (no agent_server.py / langgraph.json / next dev script)."
exit 1
fi
echo "[dev-entrypoint] Launched ${#PIDS[@]} process(es): ${PIDS[*]} — waiting (hot reload active)."
wait -n "${PIDS[@]}"
EXIT_CODE=$?
echo "[dev-entrypoint] A process exited with code $EXIT_CODE"
exit $EXIT_CODE
+434
View File
@@ -0,0 +1,434 @@
{
"version": "1.0.0",
"categories": [
{
"id": "dev-ex",
"name": "Dev Ex"
},
{
"id": "chat-ui",
"name": "Chat & UI"
},
{
"id": "platform",
"name": "Platform"
},
{
"id": "controlled-generative-ui",
"name": "Controlled Generative UI"
},
{
"id": "declarative-generative-ui",
"name": "Declarative Generative UI"
},
{
"id": "open-generative-ui",
"name": "Open-Ended Generative UI"
},
{
"id": "operational-generative-ui",
"name": "Operational Generative UI"
},
{
"id": "interactivity",
"name": "Interactivity"
},
{
"id": "agent-state",
"name": "Agent State"
},
{
"id": "multi-agent",
"name": "Multi-Agent"
},
{
"id": "byoc",
"name": "BYOC (Bring Your Own Components)"
}
],
"features": [
{
"id": "cli-start",
"name": "CLI Start Command",
"category": "dev-ex",
"description": "One-liner to scaffold a new CopilotKit app via the official CLI",
"kind": "docs-only",
"og_docs_url": "https://docs.copilotkit.ai/quickstart",
"shell_docs_path": "/quickstart"
},
{
"id": "beautiful-chat",
"name": "Beautiful Chat",
"category": "dev-ex",
"description": "Canonical polished starter chat (same as /examples/integrations/langgraph-python)",
"kind": "primary",
"og_docs_url": "https://docs.copilotkit.ai/agentic-chat-ui",
"shell_docs_path": "/agentic-chat-ui"
},
{
"id": "agentic-chat",
"name": "Pre-Built: CopilotChat",
"category": "chat-ui",
"description": "Pre-built <CopilotChat /> component for full-screen/embedded chat",
"kind": "primary",
"og_docs_url": "https://docs.copilotkit.ai/agentic-chat-ui",
"shell_docs_path": "/agentic-chat-ui"
},
{
"id": "prebuilt-sidebar",
"name": "Pre-Built: Sidebar",
"category": "chat-ui",
"description": "Docked sidebar chat via <CopilotSidebar />",
"og_docs_url": "https://docs.copilotkit.ai/prebuilt-components",
"shell_docs_path": "/prebuilt-components/sidebar"
},
{
"id": "prebuilt-popup",
"name": "Pre-Built: Popup",
"category": "chat-ui",
"description": "Floating popup chat via <CopilotPopup />",
"og_docs_url": "https://docs.copilotkit.ai/prebuilt-components",
"shell_docs_path": "/prebuilt-components/popup"
},
{
"id": "chat-slots",
"name": "Chat Customization: Slots",
"category": "chat-ui",
"description": "Customizing chat components via the slot system",
"og_docs_url": "https://docs.copilotkit.ai/custom-look-and-feel/slots",
"shell_docs_path": "/custom-look-and-feel/slots"
},
{
"id": "chat-customization-css",
"name": "Chat Customization: CSS",
"category": "chat-ui",
"description": "Customizing chat components via CSS",
"og_docs_url": "https://docs.copilotkit.ai/custom-look-and-feel",
"shell_docs_path": "/custom-look-and-feel/css"
},
{
"id": "headless-simple",
"name": "Headless UI: Simple",
"category": "chat-ui",
"description": "Simple headless UI getting started",
"og_docs_url": "https://docs.copilotkit.ai/headless",
"shell_docs_path": "/headless"
},
{
"id": "headless-complete",
"name": "Headless UI: Complete",
"category": "chat-ui",
"description": "Full headless UI with messages, tools, gen UI",
"og_docs_url": "https://docs.copilotkit.ai/headless",
"shell_docs_path": "/headless"
},
{
"id": "gen-ui-tool-based",
"name": "Generative UI: useComponent",
"category": "controlled-generative-ui",
"description": "Agent renders typed React components via the useComponent hook",
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/your-components/display-only",
"shell_docs_path": "/generative-ui/your-components/display-only"
},
{
"id": "hitl-in-chat",
"name": "Human In the Loop: In-chat",
"category": "controlled-generative-ui",
"description": "Interactive approval/decision surface rendered inline in the chat. Uses the high-level `useHumanInTheLoop` hook \u2014 handles the interrupt lifecycle for you.",
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/your-components/interactive",
"shell_docs_path": "/generative-ui/your-components/interactive"
},
{
"id": "gen-ui-interrupt",
"name": "Human in the Loop: Interrupts",
"category": "controlled-generative-ui",
"description": "Interactive component rendered inline in the chat via the lower-level `useInterrupt` primitive \u2014 direct control over the LangGraph interrupt lifecycle.",
"og_docs_url": "https://docs.copilotkit.ai/human-in-the-loop",
"shell_docs_path": "/human-in-the-loop/useInterrupt"
},
{
"id": "hitl-in-chat-booking",
"name": "In-Chat HITL (Booking)",
"category": "controlled-generative-ui",
"description": "Time-picker card rendered inline via useHumanInTheLoop for a booking flow",
"kind": "primary",
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/your-components/interactive",
"shell_docs_path": "/generative-ui/your-components/interactive",
"deprecated": true
},
{
"id": "hitl",
"name": "In-Chat Human in the Loop (Original)",
"category": "controlled-generative-ui",
"description": "Original HITL demo kept for backwards compatibility",
"kind": "primary",
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/your-components/interactive",
"shell_docs_path": "/generative-ui/your-components/interactive",
"deprecated": true
},
{
"id": "interrupt-headless",
"name": "Human in the Loop: Headless Interrupts",
"category": "controlled-generative-ui",
"description": "Resolve langgraph interrupts headlessly via agent.subscribe + copilotkit.runAgent \u2014 no chat, no useInterrupt render prop",
"kind": "testing",
"shell_docs_path": "/human-in-the-loop/headless",
"og_docs_url": "https://docs.copilotkit.ai/headless"
},
{
"id": "declarative-gen-ui",
"name": "Declarative UI: Dynamic A2UI",
"category": "declarative-generative-ui",
"description": "Canonical A2UI BYOC \u2014 custom catalog wired via a2ui.catalog",
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/a2ui",
"shell_docs_path": "/generative-ui/a2ui/dynamic-schema"
},
{
"id": "a2ui-fixed-schema",
"name": "Declarative UI: Fixed A2UI",
"category": "declarative-generative-ui",
"description": "A2UI rendering against a known, fixed client-side schema",
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/a2ui",
"shell_docs_path": "/generative-ui/a2ui/fixed-schema"
},
{
"id": "a2ui-recovery",
"name": "A2UI Error Recovery",
"category": "declarative-generative-ui",
"description": "Visible A2UI validate-retry recovery loop: an invalid render heals to a valid one, an always-invalid render shows a graceful recovery-exhausted fallback",
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/a2ui",
"shell_docs_path": "/generative-ui/a2ui/dynamic-schema"
},
{
"id": "mcp-apps",
"name": "MCP Apps",
"category": "open-generative-ui",
"description": "MCP server integration with UI display",
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/mcp-apps",
"shell_docs_path": "/generative-ui/mcp-apps"
},
{
"id": "open-gen-ui",
"name": "Open Generative UI: Default",
"category": "open-generative-ui",
"description": "Agent-generated UI from arbitrary component libraries",
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/open-generative-ui",
"shell_docs_path": "/generative-ui/open-generative-ui"
},
{
"id": "open-gen-ui-advanced",
"name": "Open Generative UI: Custom",
"category": "open-generative-ui",
"description": "Agent-authored UI that can invoke frontend sandbox functions from inside the iframe",
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/open-generative-ui",
"shell_docs_path": "/generative-ui/open-generative-ui"
},
{
"id": "tool-rendering-default-catchall",
"name": "Generative UI: Tool Rendering (Default)",
"category": "operational-generative-ui",
"description": "Out-of-the-box rendering: backend tools surfaced via CopilotKit's built-in default catch-all renderer; no frontend customization",
"kind": "primary",
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/tool-rendering",
"shell_docs_path": "/generative-ui/tool-rendering"
},
{
"id": "tool-rendering-custom-catchall",
"name": "Generative UI: Tool Rendering (Custom default)",
"category": "operational-generative-ui",
"description": "Single branded wildcard renderer that paints every tool call \u2014 one card handles them all",
"kind": "primary",
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/tool-rendering",
"shell_docs_path": "/generative-ui/tool-rendering"
},
{
"id": "tool-rendering",
"name": "Generative UI: Tool Rendering (Specific)",
"category": "operational-generative-ui",
"description": "Custom per-tool renderers for the tools you care about, plus a wildcard catch-all for the rest",
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/tool-rendering",
"shell_docs_path": "/generative-ui/tool-rendering"
},
{
"id": "tool-rendering-reasoning-chain",
"name": "Generative UI: Rendering multiple tools",
"category": "operational-generative-ui",
"description": "Sequential tool calls with reasoning tokens rendered side-by-side",
"kind": "testing",
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/tool-rendering",
"shell_docs_path": "/generative-ui/tool-rendering"
},
{
"id": "agentic-chat-reasoning",
"name": "Reasoning",
"category": "operational-generative-ui",
"description": "Visible reasoning/thinking chain alongside the final answer",
"og_docs_url": "https://docs.copilotkit.ai/custom-look-and-feel/reasoning-messages",
"shell_docs_path": "/custom-look-and-feel/reasoning-messages",
"deprecated": true
},
{
"id": "reasoning-default-render",
"name": "Reasoning (Default Render)",
"category": "operational-generative-ui",
"description": "Built-in CopilotChatReasoningMessage renders without a custom slot",
"kind": "testing",
"shell_docs_path": "/custom-look-and-feel/reasoning-messages",
"og_docs_url": "https://docs.copilotkit.ai/custom-look-and-feel/reasoning-messages",
"deprecated": true
},
{
"id": "reasoning-default",
"name": "Reasoning: Default",
"category": "operational-generative-ui",
"description": "Built-in CopilotChatReasoningMessage rendering with no slot override",
"shell_docs_path": "/custom-look-and-feel/reasoning-messages",
"og_docs_url": "https://docs.copilotkit.ai/custom-look-and-feel/reasoning-messages"
},
{
"id": "reasoning-custom",
"name": "Reasoning: Custom",
"category": "operational-generative-ui",
"description": "Custom reasoning slot override via messageView.reasoningMessage",
"shell_docs_path": "/custom-look-and-feel/reasoning-messages",
"og_docs_url": "https://docs.copilotkit.ai/custom-look-and-feel/reasoning-messages"
},
{
"id": "gen-ui-agent",
"name": "Generative UI: Agent State",
"category": "operational-generative-ui",
"description": "Agent-state-driven inline UI: the agent emits a live `steps` plan via copilotkit_emit_state and the frontend renders it with useCoAgentStateRender (v1 hook)",
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/state-rendering",
"shell_docs_path": "/generative-ui/state-rendering"
},
{
"id": "frontend-tools",
"name": "Frontend Tools: In-app Actions",
"category": "interactivity",
"description": "Frontend tool execution triggered by the agent",
"og_docs_url": "https://docs.copilotkit.ai/frontend-tools",
"shell_docs_path": "/frontend-tools"
},
{
"id": "frontend-tools-async",
"name": "Frontend Tools: Async",
"category": "interactivity",
"description": "useFrontendTool with an async handler \u2014 agent awaits a client-side async operation (e.g. DB query) and uses the returned result",
"og_docs_url": "https://docs.copilotkit.ai/frontend-tools",
"shell_docs_path": "/frontend-tools"
},
{
"id": "threadid-frontend-tool-roundtrip",
"name": "ThreadID: Frontend Tool Round-Trip",
"category": "interactivity",
"description": "Regression demo for ENT-658 — verifies that an explicit threadId survives a useFrontendTool round-trip (agent invokes a frontend tool, awaits the async handler, and the same threadId is preserved across the resume).",
"kind": "testing",
"shell_docs_path": "/frontend-tools",
"og_docs_url": "https://docs.copilotkit.ai/frontend-tools"
},
{
"id": "hitl-in-app",
"name": "Human in the Loop: In-app",
"category": "interactivity",
"description": "Agent calls useFrontendTool with an async handler; the approval UI pops up as an app-level modal OUTSIDE the chat, and a completion callback resolves the pending tool Promise with the user's decision",
"og_docs_url": "https://docs.copilotkit.ai/human-in-the-loop",
"shell_docs_path": "/human-in-the-loop"
},
{
"id": "shared-state-read-write",
"name": "Shared State: Read + Write",
"category": "agent-state",
"description": "Bidirectional agent state \u2014 UI writes preferences, agent writes notes back",
"og_docs_url": "https://docs.copilotkit.ai/shared-state",
"shell_docs_path": "/shared-state"
},
{
"id": "shared-state-read",
"name": "Shared State: Read-only",
"category": "agent-state",
"description": "Frontend recipe form publishes shared state via agent.setState; agent reads but does not mutate the recipe (neutral default agent, no backend tool)",
"kind": "testing",
"og_docs_url": "https://docs.copilotkit.ai/shared-state",
"shell_docs_path": "/shared-state"
},
{
"id": "shared-state-streaming",
"name": "Shared State: Streaming",
"category": "agent-state",
"description": "Per-token state delta streaming from agent to UI",
"og_docs_url": "https://docs.copilotkit.ai/shared-state",
"shell_docs_path": "/shared-state/streaming"
},
{
"id": "readonly-state-agent-context",
"name": "Shared State: Frontend Context",
"category": "agent-state",
"description": "Frontend provides read-only context to the agent via useAgentContext",
"og_docs_url": "https://docs.copilotkit.ai/shared-state",
"shell_docs_path": "/shared-state/agent-readonly"
},
{
"id": "subagents",
"name": "Sub-Agents",
"category": "multi-agent",
"description": "Multiple agents with visible task delegation",
"shell_docs_path": "/multi-agent/subagents",
"og_docs_url": "https://docs.copilotkit.ai/coagents/multi-agent-flows"
},
{
"id": "auth",
"name": "Authentication",
"category": "platform",
"description": "Framework-native authentication",
"shell_docs_path": "/auth"
},
{
"id": "agent-config",
"name": "Agent Config Object",
"category": "platform",
"description": "Forwarded props / config objects",
"shell_docs_path": "/agent-config"
},
{
"id": "voice",
"name": "Voice",
"category": "platform",
"description": "Real-time voice interaction",
"shell_docs_path": "/voice"
},
{
"id": "multimodal",
"name": "Attachments",
"category": "platform",
"description": "File upload and agent processing",
"shell_docs_path": "/multimodal-attachments",
"og_docs_url": "https://docs.copilotkit.ai/multimodal-attachments"
},
{
"id": "byoc-hashbrown",
"name": "Declarative UI: Hashbrown",
"category": "byoc",
"description": "Hashbrown-style generative UI (legacy slug — superseded by `declarative-hashbrown`; kept so the other 17 integrations whose manifests still declare this ID don't drop out of the catalog before their cross-codebase rename lands)",
"deprecated": true
},
{
"id": "byoc-json-render",
"name": "Declarative UI: json-render",
"category": "byoc",
"description": "Streaming structured-output generative UI via @json-render/react (legacy slug — superseded by `declarative-json-render`; kept for cross-integration parity until the rename completes elsewhere)",
"deprecated": true
},
{
"id": "declarative-hashbrown",
"name": "Declarative UI: Hashbrown",
"category": "byoc",
"description": "Hashbrown-style generative UI"
},
{
"id": "declarative-json-render",
"name": "Declarative UI: json-render",
"category": "byoc",
"description": "Streaming structured-output generative UI via @json-render/react"
}
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"langgraph-python": 3100,
"langgraph-typescript": 3101,
"langgraph-fastapi": 3102,
"google-adk": 3103,
"mastra": 3104,
"crewai-crews": 3105,
"pydantic-ai": 3106,
"claude-sdk-python": 3107,
"claude-sdk-typescript": 3108,
"agno": 3109,
"ag2": 3110,
"llamaindex": 3111,
"strands": 3112,
"strands-typescript": 3119,
"langroid": 3113,
"ms-agent-python": 3114,
"ms-agent-dotnet": 3115,
"spring-ai": 3116,
"built-in-agent": 3117,
"ms-agent-harness-dotnet": 3118
}
+261
View File
@@ -0,0 +1,261 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Integration Package Manifest",
"description": "Schema for CopilotKit Showcase integration package manifests",
"type": "object",
"required": [
"name",
"slug",
"category",
"language",
"description",
"features",
"demos"
],
"properties": {
"name": {
"type": "string",
"description": "Display name (e.g. 'LangGraph (Python)')"
},
"slug": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$",
"description": "URL-safe identifier (e.g. 'langgraph-python')"
},
"category": {
"type": "string",
"enum": [
"built-in",
"popular",
"agent-framework",
"enterprise-platform",
"provider-sdk",
"protocol",
"emerging",
"starter"
],
"description": "Integration category"
},
"language": {
"type": "string",
"enum": ["python", "typescript", "dotnet", "java"],
"description": "Primary agent backend language"
},
"logo": {
"type": "string",
"description": "Path to logo SVG relative to package root"
},
"description": {
"type": "string",
"description": "One-paragraph description of the integration"
},
"partner_docs": {
"type": ["string", "null"],
"format": "uri",
"description": "Link to partner's own CopilotKit documentation"
},
"repo": {
"type": "string",
"format": "uri",
"description": "Link to source code in the monorepo"
},
"copilotkit_version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+",
"description": "CopilotKit SDK version this package is built against"
},
"backend_url": {
"type": "string",
"format": "uri",
"description": "Deprecated/optional. Synthesized at build time by generate-registry.ts from SHOWCASE_BACKEND_HOST_PATTERN + slug. Manifests should omit this field; if present, the manifest value still wins via dual-read."
},
"deployed": {
"type": "boolean",
"default": false,
"description": "Whether the backend is deployed and live. Stack nav and demo links only activate when true."
},
"docs_mode": {
"type": "string",
"enum": ["generated", "authored", "hidden"],
"default": "generated",
"description": "How shell-docs serves this framework's docs pages: 'generated' = data-driven FrameworkOverview + agnostic root MDX (current behavior, kept for langgraph-* and google-adk). 'authored' = render the per-framework MDX tree under content/docs/integrations/<docsFolder>/ with its own sidebar. 'hidden' = exclude from the docs site (no framework page, no switcher entry). Defaults to 'generated' when omitted."
},
"sort_order": {
"type": "integer",
"minimum": 0,
"default": 999,
"description": "Sort order for display ranking (lower = higher priority)"
},
"animated_preview_url": {
"type": ["string", "null"],
"description": "Path to an animated preview (gif/video) shown on the landing page when no live mini-demo is available"
},
"features": {
"type": "array",
"items": { "type": "string" },
"minItems": 1,
"description": "List of supported feature IDs from the feature registry"
},
"not_supported_features": {
"type": "array",
"items": { "type": "string" },
"description": "List of feature IDs that this integration's framework cannot architecturally support (e.g., framework lacks graph-interrupt API or MCP tool runtime). These are excluded from parity computation."
},
"demos": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "name", "description", "tags"],
"properties": {
"id": {
"type": "string",
"description": "References a feature ID from the registry"
},
"name": {
"type": "string",
"description": "Display name for this demo"
},
"description": {
"type": "string",
"description": "One-line description of what this demo shows"
},
"tags": {
"type": "array",
"items": { "type": "string" },
"description": "Freeform tags for search and filtering"
},
"route": {
"type": "string",
"pattern": "^/",
"description": "Route within the package (e.g. /demos/agentic-chat). Omit for informational demos that expose `command` instead."
},
"command": {
"type": "string",
"description": "Copy-pasteable shell command (e.g. npx degit ...). When present and `route` is omitted, the shell renders an informational cell with a copy button instead of Demo/Code links."
},
"animated_preview_url": {
"type": ["string", "null"],
"description": "URL to animated preview for this specific demo"
},
"backend_files": {
"type": "array",
"items": { "type": "string" },
"description": "DEPRECATED. Use `highlight` to pin core files (and drive backend scoping). Will be removed."
},
"highlight": {
"type": "array",
"items": { "type": "string" },
"description": "Core files to visually highlight in /code. Paths are relative to the package root (e.g. 'src/app/demos/agentic-chat/page.tsx', 'src/agents/sample_agent.py'). Files outside the demo folder (typically backend agent files) are included in the bundle for this demo when listed here."
}
},
"additionalProperties": false
},
"description": "Runnable demos (subset of features with working implementations)"
},
"generative_ui": {
"type": "array",
"items": {
"type": "string",
"enum": [
"constrained-declarative",
"constrained-explicit",
"a2ui-fixed-schema",
"a2ui-dynamic-schema"
]
},
"minItems": 1,
"description": "Generative UI patterns supported by this integration"
},
"interaction_modalities": {
"type": "array",
"items": {
"type": "string",
"enum": ["sidebar", "embedded", "popup", "chat", "headless"]
},
"minItems": 1,
"description": "UI interaction modalities supported by this integration"
},
"managed_platform": {
"type": "object",
"required": ["name", "url"],
"properties": {
"name": {
"type": "string",
"description": "Display name of the managed platform"
},
"url": {
"type": "string",
"format": "uri",
"description": "URL to the managed platform"
}
},
"additionalProperties": false,
"description": "Managed platform details if this integration is hosted on a managed service"
},
"a2ui_pattern": {
"type": ["string", "null"],
"enum": ["schema-loading", "schema-inline", "llm-driven", null],
"description": "Implementation pattern used by this integration for `a2ui-fixed-schema`. Set only when the feature is wired. `schema-loading` = backend loads schema JSON at startup; `schema-inline` = schema is declared inline as a typed literal in source; `llm-driven` = backend generates the schema via a secondary LLM call. Consumed by docs `<WhenFrameworkHas>` to gate per-pattern prose."
},
"interrupt_pattern": {
"type": ["string", "null"],
"enum": ["native", "promise-based", null],
"description": "Implementation pattern used by this integration for `gen-ui-interrupt` / `interrupt-headless`. Set only when at least one is wired. `native` = framework has a real interrupt primitive (e.g. LangGraph `interrupt()` + `useInterrupt`); `promise-based` = the demo uses `useFrontendTool` with a Promise-based handler. Consumed by docs `<WhenFrameworkHas>`."
},
"thread_persistence_pattern": {
"type": ["string", "null"],
"enum": ["langgraph", "adk-session", null],
"description": "Framework-specific thread persistence/interoperability pattern. Set only when docs need to explain how CopilotKit Enterprise Intelligence Platform threads can be aligned with an external framework's own run/session identifiers. `langgraph` = explicit CopilotKit thread IDs are forwarded as AG-UI thread IDs and can be aligned with LangGraph checkpoint/thread IDs when the backend accepts them; `adk-session` = CopilotKit thread IDs may be mapped to ADK session IDs, while ADK durability depends on the configured ADK session service. Consumed by docs `<WhenFrameworkHas>`."
},
"agent_config_pattern": {
"type": ["string", "null"],
"enum": ["shared-state", "runtime-properties", null],
"description": "Implementation pattern used by this integration for the `agent-config` feature. Set only when the feature is wired. `shared-state` = UI calls `agent.setState({...})` and the agent reads typed config out of state on each turn (most external-backend frameworks); `runtime-properties` = UI passes the typed object as `<CopilotKitProvider properties={...}>` and the runtime hands it to the agent factory via `input.forwardedProps` (built-in-agent). Consumed by docs `<WhenFrameworkHas>`."
},
"auth_pattern": {
"type": ["string", "null"],
"enum": [
"langgraph",
"ag2-context-variables",
"microsoft-agent-framework",
"runtime-onrequest",
null
],
"description": "Implementation pattern used by this integration for the `auth` feature. Set only when the feature is wired. `langgraph` = LangGraph Platform `@auth.authenticate` decorator OR self-hosted `langgraph_config['configurable']` (frontend uses `properties.authorization`); `ag2-context-variables` = AG2 backend reads the Authorization header on `/chat` and threads ContextVariables to tools (frontend uses `properties.authorization`); `microsoft-agent-framework` = ASP.NET Core JwtBearer or FastAPI middleware (frontend uses `headers={{Authorization}}`); `runtime-onrequest` = generic V2 runtime `onRequest` hook validates the Bearer header injected by `<CopilotKit headers={{Authorization}}>`. Consumed by docs `<WhenFrameworkHas>`."
},
"starter": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path from repo root to the starter example directory"
},
"name": {
"type": "string",
"description": "Display name for the starter"
},
"description": {
"type": "string",
"description": "One-line description of what the starter demonstrates"
},
"github_url": {
"type": "string",
"description": "GitHub URL to the starter directory for Clone/Fork"
},
"demo_url": {
"type": "string",
"format": "uri",
"description": "URL of the deployed starter app for live demo iframe"
},
"clone_command": {
"type": "string",
"description": "Shell command to clone the starter (e.g. npx degit ...)"
}
},
"required": ["path", "name"],
"additionalProperties": false,
"description": "Starter project details"
}
},
"additionalProperties": false
}
+22
View File
@@ -0,0 +1,22 @@
[
{ "slug": "ag2", "name": "AG2" },
{ "slug": "agno", "name": "Agno" },
{ "slug": "built-in-agent", "name": "Built-in Agent (TanStack AI)" },
{ "slug": "claude-sdk-python", "name": "Claude SDK Python" },
{ "slug": "claude-sdk-typescript", "name": "Claude SDK TypeScript" },
{ "slug": "crewai-crews", "name": "CrewAI Crews" },
{ "slug": "google-adk", "name": "Google ADK" },
{ "slug": "langgraph-fastapi", "name": "LangGraph FastAPI" },
{ "slug": "langgraph-python", "name": "LangGraph Python" },
{ "slug": "langgraph-typescript", "name": "LangGraph TypeScript" },
{ "slug": "langroid", "name": "Langroid" },
{ "slug": "llamaindex", "name": "LlamaIndex" },
{ "slug": "mastra", "name": "Mastra" },
{ "slug": "ms-agent-dotnet", "name": "MS Agent .NET" },
{ "slug": "ms-agent-harness-dotnet", "name": "MS Agent Harness .NET" },
{ "slug": "ms-agent-python", "name": "MS Agent Python" },
{ "slug": "pydantic-ai", "name": "Pydantic AI" },
{ "slug": "spring-ai", "name": "Spring AI" },
{ "slug": "strands", "name": "AWS Strands (Python)" },
{ "slug": "strands-typescript", "name": "AWS Strands (TypeScript)" }
]
+5
View File
@@ -0,0 +1,5 @@
"""Shared Python tool implementations for CopilotKit Showcase.
Pure Python functions with NO framework imports. Each framework's showcase
package wraps these in its own tool decorator pattern.
"""
+41
View File
@@ -0,0 +1,41 @@
date,category,subcategory,amount,type,notes
2026-01-05,Revenue,Enterprise Subscriptions,28000,income,3 new enterprise customers (Acme Corp, TechFlow, DataViz Inc)
2026-01-05,Revenue,Pro Tier Upgrades,18000,income,24 users upgraded from free to pro
2026-01-08,Revenue,API Usage Overages,9500,income,High API usage from top 5 customers
2026-01-10,Expenses,Engineering Salaries,42000,expense,7 engineers + 2 contractors
2026-01-10,Expenses,Product Team,18000,expense,PM and 2 designers
2026-01-12,Expenses,AWS Infrastructure,8200,expense,Increased compute for new AI features
2026-01-15,Expenses,Marketing - Paid Ads,12000,expense,Google Ads and LinkedIn campaigns
2026-01-18,Revenue,Consulting Services,14500,income,Custom integration for Acme Corp
2026-01-20,Expenses,Customer Success,15000,expense,3 CSMs + support tools (Intercom)
2026-01-22,Expenses,AI Model Costs,4200,expense,OpenAI API usage for product features
2026-01-25,Revenue,Marketplace Sales,12800,income,Template and plugin sales
2026-01-28,Expenses,Office & Equipment,3500,expense,New laptops and coworking spaces
2026-02-03,Revenue,Enterprise Subscriptions,31000,income,2 new customers + expansion from TechFlow
2026-02-03,Revenue,Pro Tier Upgrades,22500,income,31 upgrades + reduced churn
2026-02-05,Revenue,API Usage Overages,11800,income,DataViz Inc heavy API usage spike
2026-02-07,Expenses,Engineering Salaries,42000,expense,Same headcount as January
2026-02-07,Expenses,Product Team,18000,expense,No changes to product team
2026-02-10,Expenses,AWS Infrastructure,9500,expense,Traffic spike from viral social post
2026-02-12,Expenses,Marketing - Paid Ads,15000,expense,Increased ad spend for Q1 push
2026-02-14,Revenue,Consulting Services,18000,income,2 custom projects (TechFlow + new client)
2026-02-18,Expenses,Customer Success,16500,expense,Hired 1 additional CSM
2026-02-20,Expenses,AI Model Costs,5800,expense,Increased usage from new AI features launch
2026-02-22,Revenue,Marketplace Sales,14200,income,Top template hit featured list
2026-02-25,Expenses,Conference & Travel,4500,expense,Team attended SaaS Conference 2026
2026-02-27,Revenue,Partnership Revenue,11500,income,Referral fees from integration partners
2026-03-02,Revenue,Enterprise Subscriptions,35000,income,Major win: Fortune 500 customer signed
2026-03-02,Revenue,Pro Tier Upgrades,26000,income,42 upgrades - best month yet
2026-03-05,Revenue,API Usage Overages,13200,income,Consistent high usage across top tier
2026-03-08,Expenses,Engineering Salaries,48000,expense,Hired 1 senior engineer for AI team
2026-03-08,Expenses,Product Team,21000,expense,Promoted designer to senior level
2026-03-10,Expenses,AWS Infrastructure,11000,expense,Scaled infrastructure for enterprise client
2026-03-12,Expenses,Marketing - Paid Ads,18000,expense,Doubled down on successful campaigns
2026-03-14,Revenue,Consulting Services,21500,income,Fortune 500 onboarding + 2 other projects
2026-03-16,Expenses,Customer Success,19500,expense,Hired dedicated enterprise CSM
2026-03-18,Expenses,AI Model Costs,7200,expense,Fortune 500 client heavy AI usage
2026-03-20,Revenue,Marketplace Sales,15800,income,3 new templates in top 10
2026-03-22,Expenses,Sales & BD,12000,expense,Hired first sales rep for enterprise
2026-03-24,Revenue,Partnership Revenue,14200,income,New integration partnerships launched
2026-03-26,Expenses,Security & Compliance,6500,expense,SOC 2 audit and security tools
2026-03-28,Revenue,Training & Workshops,10200,income,Conducted 2 customer training sessions
1 date,category,subcategory,amount,type,notes
2 2026-01-05,Revenue,Enterprise Subscriptions,28000,income,3 new enterprise customers (Acme Corp, TechFlow, DataViz Inc)
3 2026-01-05,Revenue,Pro Tier Upgrades,18000,income,24 users upgraded from free to pro
4 2026-01-08,Revenue,API Usage Overages,9500,income,High API usage from top 5 customers
5 2026-01-10,Expenses,Engineering Salaries,42000,expense,7 engineers + 2 contractors
6 2026-01-10,Expenses,Product Team,18000,expense,PM and 2 designers
7 2026-01-12,Expenses,AWS Infrastructure,8200,expense,Increased compute for new AI features
8 2026-01-15,Expenses,Marketing - Paid Ads,12000,expense,Google Ads and LinkedIn campaigns
9 2026-01-18,Revenue,Consulting Services,14500,income,Custom integration for Acme Corp
10 2026-01-20,Expenses,Customer Success,15000,expense,3 CSMs + support tools (Intercom)
11 2026-01-22,Expenses,AI Model Costs,4200,expense,OpenAI API usage for product features
12 2026-01-25,Revenue,Marketplace Sales,12800,income,Template and plugin sales
13 2026-01-28,Expenses,Office & Equipment,3500,expense,New laptops and coworking spaces
14 2026-02-03,Revenue,Enterprise Subscriptions,31000,income,2 new customers + expansion from TechFlow
15 2026-02-03,Revenue,Pro Tier Upgrades,22500,income,31 upgrades + reduced churn
16 2026-02-05,Revenue,API Usage Overages,11800,income,DataViz Inc heavy API usage spike
17 2026-02-07,Expenses,Engineering Salaries,42000,expense,Same headcount as January
18 2026-02-07,Expenses,Product Team,18000,expense,No changes to product team
19 2026-02-10,Expenses,AWS Infrastructure,9500,expense,Traffic spike from viral social post
20 2026-02-12,Expenses,Marketing - Paid Ads,15000,expense,Increased ad spend for Q1 push
21 2026-02-14,Revenue,Consulting Services,18000,income,2 custom projects (TechFlow + new client)
22 2026-02-18,Expenses,Customer Success,16500,expense,Hired 1 additional CSM
23 2026-02-20,Expenses,AI Model Costs,5800,expense,Increased usage from new AI features launch
24 2026-02-22,Revenue,Marketplace Sales,14200,income,Top template hit featured list
25 2026-02-25,Expenses,Conference & Travel,4500,expense,Team attended SaaS Conference 2026
26 2026-02-27,Revenue,Partnership Revenue,11500,income,Referral fees from integration partners
27 2026-03-02,Revenue,Enterprise Subscriptions,35000,income,Major win: Fortune 500 customer signed
28 2026-03-02,Revenue,Pro Tier Upgrades,26000,income,42 upgrades - best month yet
29 2026-03-05,Revenue,API Usage Overages,13200,income,Consistent high usage across top tier
30 2026-03-08,Expenses,Engineering Salaries,48000,expense,Hired 1 senior engineer for AI team
31 2026-03-08,Expenses,Product Team,21000,expense,Promoted designer to senior level
32 2026-03-10,Expenses,AWS Infrastructure,11000,expense,Scaled infrastructure for enterprise client
33 2026-03-12,Expenses,Marketing - Paid Ads,18000,expense,Doubled down on successful campaigns
34 2026-03-14,Revenue,Consulting Services,21500,income,Fortune 500 onboarding + 2 other projects
35 2026-03-16,Expenses,Customer Success,19500,expense,Hired dedicated enterprise CSM
36 2026-03-18,Expenses,AI Model Costs,7200,expense,Fortune 500 client heavy AI usage
37 2026-03-20,Revenue,Marketplace Sales,15800,income,3 new templates in top 10
38 2026-03-22,Expenses,Sales & BD,12000,expense,Hired first sales rep for enterprise
39 2026-03-24,Revenue,Partnership Revenue,14200,income,New integration partnerships launched
40 2026-03-26,Expenses,Security & Compliance,6500,expense,SOC 2 audit and security tools
41 2026-03-28,Revenue,Training & Workshops,10200,income,Conducted 2 customer training sessions
@@ -0,0 +1,21 @@
"""Reusable middleware for CopilotKit showcase agents.
Context-driven middleware that reads render_mode and output_schema from
CopilotKit runtime context and adjusts agent behaviour accordingly.
"""
from .render_mode import (
get_render_mode,
get_output_schema,
apply_render_mode_prompt,
apply_render_mode,
JSONL_RENDER_INSTRUCTION,
)
__all__ = [
"get_render_mode",
"get_output_schema",
"apply_render_mode_prompt",
"apply_render_mode",
"JSONL_RENDER_INSTRUCTION",
]
@@ -0,0 +1,177 @@
"""Render-mode middleware for context-driven GenUI strategy switching.
Reads ``render_mode`` and ``output_schema`` from the CopilotKit context list
and adapts agent output accordingly:
- **tool-based**: no changes (default)
- **a2ui**: no changes (agent decides when to call generate_a2ui tool)
- **json-render**: append JSONL instruction to system prompt
- **hashbrown**: apply ``response_format`` with the ``output_schema`` from context
The ``apply_render_mode`` function is a ``@wrap_model_call`` decorator for
LangGraph agents that plugs into the CopilotKit middleware chain.
"""
from __future__ import annotations
import json
from collections.abc import Mapping
from typing import Any
# ---------------------------------------------------------------------------
# Prompt fragments
# ---------------------------------------------------------------------------
JSONL_RENDER_INSTRUCTION = (
"\n\n## Output format — JSONL spec patches\n"
"You MUST emit your UI updates as JSONL (one JSON object per line) inside\n"
"a fenced code block with the ``spec`` language tag. Each line is a patch\n"
'object with at minimum an ``op`` field ("add", "replace", "remove")\n'
"and a ``path`` field (JSON-Pointer into the component tree).\n\n"
"Example:\n"
"```spec\n"
'{"op":"replace","path":"/title","value":"Updated Dashboard"}\n'
'{"op":"add","path":"/widgets/-","value":{"type":"chart","data":[1,2,3]}}\n'
"```\n"
"Do NOT wrap the block in any other markup. The frontend renderer will\n"
"parse each line and apply the patches incrementally.\n"
)
# ---------------------------------------------------------------------------
# Context extraction helpers
# ---------------------------------------------------------------------------
def get_render_mode(context: list[dict[str, Any]]) -> str:
"""Extract render_mode from CopilotKit context entries.
Scans the context list for an entry whose ``description`` is
``"render_mode"`` and returns its ``value``. Falls back to
``"tool-based"`` when no matching entry is found.
"""
for entry in context:
if entry.get("description") == "render_mode":
return entry.get("value", "tool-based")
return "tool-based"
def get_output_schema(context: list[dict[str, Any]]) -> dict[str, Any] | None:
"""Extract output_schema (HashBrown kit schema) from context.
Returns the parsed JSON schema dict, or ``None`` if the context does not
contain an ``output_schema`` entry.
"""
for entry in context:
if entry.get("description") == "output_schema":
val = entry.get("value")
if isinstance(val, str):
try:
return json.loads(val)
except json.JSONDecodeError:
return None
return val
return None
# ---------------------------------------------------------------------------
# Prompt augmentation
# ---------------------------------------------------------------------------
def apply_render_mode_prompt(system_prompt: str, render_mode: str) -> str:
"""Return *system_prompt* with render-mode instructions appended.
For ``tool-based`` and ``a2ui`` modes the prompt is returned unchanged.
For ``json-render`` the relevant instruction block is appended.
"""
if render_mode == "json-render":
return system_prompt + JSONL_RENDER_INSTRUCTION
return system_prompt
# ---------------------------------------------------------------------------
# LangGraph @wrap_model_call decorator
# ---------------------------------------------------------------------------
def apply_render_mode(fn=None):
"""``@wrap_model_call`` middleware that adapts the model request.
Usage with the CopilotKit middleware chain::
from middleware.render_mode import apply_render_mode
agent = create_agent(
...,
middleware=[CopilotKitMiddleware(), apply_render_mode()],
)
Behaviour per mode:
* **tool-based / a2ui** -- pass through unchanged.
* **json-render** -- prepend JSONL instruction to system messages.
* **hashbrown** -- set ``response_format`` with the ``output_schema``
extracted from context.
"""
try:
from langchain.agents.middleware import wrap_model_call
from langchain.agents.structured_output import ProviderStrategy
except ImportError:
# Fallback for environments without the CopilotKit langchain extensions
from copilotkit.langchain import wrap_model_call, ProviderStrategy
@wrap_model_call
async def _apply_render_mode(request, handler):
# --- Extract context from copilotkit state -------------------------
copilot_context: list[dict[str, Any]] | None = None
state = getattr(request, "state", None)
if isinstance(state, dict):
copilot_context = state.get("copilotkit", {}).get("context")
if not isinstance(copilot_context, list):
return await handler(request)
render_mode = get_render_mode(copilot_context)
# --- Prompt-injection modes ----------------------------------------
if render_mode == "json-render":
messages = list(getattr(request, "messages", []))
augmented = []
for msg in messages:
if getattr(msg, "type", None) == "system" or (
isinstance(msg, dict) and msg.get("role") == "system"
):
content = (
msg.content
if hasattr(msg, "content")
else msg.get("content", "")
)
new_content = apply_render_mode_prompt(content, render_mode)
if hasattr(msg, "content"):
# LangChain message object — copy with new content
msg = msg.copy(update={"content": new_content})
else:
msg = {**msg, "content": new_content}
augmented.append(msg)
request = request.override(messages=augmented)
# --- HashBrown mode: structured output via response_format ---------
elif render_mode == "hashbrown":
schema = get_output_schema(copilot_context)
if isinstance(schema, dict):
if not schema.get("title"):
schema["title"] = "StructuredOutput"
if not schema.get("description"):
schema["description"] = (
"Structured response schema for the CopilotKit agent."
)
request = request.override(
response_format=ProviderStrategy(schema=schema, strict=True),
)
return await handler(request)
if fn is not None:
return _apply_render_mode(fn)
return _apply_render_mode
+4
View File
@@ -0,0 +1,4 @@
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@@ -0,0 +1,77 @@
"""Cross-language parity tests.
Verify that Python shared tool outputs match the structural contracts
that TypeScript equivalents also follow. If these tests pass in Python
AND the TS tests pass, the implementations are structurally compatible.
"""
from tools import (
get_weather_impl,
query_data_impl,
manage_sales_todos_impl,
get_sales_todos_impl,
search_flights_impl,
schedule_meeting_impl,
INITIAL_TODOS,
)
def test_weather_field_names_match_typescript():
"""WeatherResult fields must be: city, temperature, humidity, wind_speed, feels_like, conditions"""
result = get_weather_impl("Tokyo")
expected_fields = {
"city",
"temperature",
"humidity",
"wind_speed",
"feels_like",
"conditions",
}
assert set(result.keys()) == expected_fields
def test_initial_todos_ids_match_typescript():
"""INITIAL_TODOS IDs must be st-001, st-002, st-003 (same as TS)"""
assert [t["id"] for t in INITIAL_TODOS] == ["st-001", "st-002", "st-003"]
def test_initial_todos_count_matches_typescript():
assert len(INITIAL_TODOS) == 3
def test_manage_todos_provides_same_defaults_as_typescript():
"""Missing fields default to: stage=prospect, value=0, completed=False"""
result = manage_sales_todos_impl([{"title": "Test"}])
assert result[0]["stage"] == "prospect"
assert result[0]["value"] == 0
assert result[0]["completed"] == False
assert result[0]["dueDate"] == ""
assert result[0]["assignee"] == ""
def test_get_todos_none_returns_initial():
result = get_sales_todos_impl(None)
assert len(result) == 3
def test_get_todos_empty_returns_empty():
result = get_sales_todos_impl([])
assert result == []
def test_search_flights_returns_a2ui_operations():
result = search_flights_impl([{"airline": "Test"}])
assert "a2ui_operations" in result
def test_schedule_meeting_returns_pending():
result = schedule_meeting_impl("test")
assert result["status"] == "pending_approval"
def test_query_data_returns_list_of_dicts_with_expected_columns():
result = query_data_impl("test")
assert isinstance(result, list)
assert len(result) > 0
row = result[0]
assert "category" in row and "date" in row # Both columns must be present
@@ -0,0 +1,110 @@
"""Cross-package equivalence test.
Verifies that all showcase packages' backend tools produce structurally
equivalent outputs when given identical inputs.
"""
import pytest
from tools import (
get_weather_impl,
query_data_impl,
manage_sales_todos_impl,
get_sales_todos_impl,
search_flights_impl,
generate_a2ui_impl,
schedule_meeting_impl,
)
# These tests verify the SHARED implementations. Since all 17 packages
# wrap these same functions, if the shared impls are correct, all
# packages produce equivalent outputs.
class TestToolOutputEquivalence:
"""All tools return consistent structures regardless of caller."""
def test_weather_consistent_structure(self):
cities = ["Tokyo", "London", "New York", "São Paulo", "Sydney"]
for city in cities:
result = get_weather_impl(city)
assert set(result.keys()) == {
"city",
"temperature",
"humidity",
"wind_speed",
"feels_like",
"conditions",
}
assert result["city"] == city
assert isinstance(result["temperature"], int)
def test_query_data_consistent_columns(self):
for query in ["revenue", "expenses", "all", ""]:
result = query_data_impl(query)
assert len(result) > 0
for row in result:
assert "category" in row or "date" in row
def test_manage_todos_idempotent_structure(self):
input_todos = [
{"title": "Deal A", "stage": "prospect", "value": 10000},
{"title": "Deal B", "stage": "qualified", "value": 50000},
]
result = manage_sales_todos_impl(input_todos)
assert len(result) == 2
for todo in result:
assert all(
k in todo
for k in [
"id",
"title",
"stage",
"value",
"dueDate",
"assignee",
"completed",
]
)
def test_get_todos_none_returns_initial(self):
result = get_sales_todos_impl(None)
assert len(result) == 3
assert all(t["id"].startswith("st-") for t in result)
def test_search_flights_returns_a2ui_ops(self):
flights = [
{
"airline": "Test",
"flightNumber": "T1",
"origin": "SFO",
"destination": "JFK",
"date": "Mon",
"departureTime": "08:00",
"arrivalTime": "16:00",
"duration": "8h",
"status": "On Time",
"statusColor": "#22c55e",
"price": "$300",
"currency": "USD",
"airlineLogo": "https://example.com/logo.png",
}
]
result = search_flights_impl(flights)
assert "a2ui_operations" in result
ops = result["a2ui_operations"]
assert any(op["type"] == "create_surface" for op in ops)
assert any(op["type"] == "update_components" for op in ops)
def test_generate_a2ui_returns_prompt_and_schema(self):
result = generate_a2ui_impl(
messages=[{"role": "user", "content": "show dashboard"}]
)
assert "system_prompt" in result
assert "tool_schema" in result
assert result["tool_schema"]["name"] == "render_a2ui"
def test_schedule_meeting_returns_pending(self):
result = schedule_meeting_impl("quarterly review", 45)
assert result["status"] == "pending_approval"
assert result["reason"] == "quarterly review"
assert result["duration_minutes"] == 45
@@ -0,0 +1,73 @@
"""Framework wrapper import tests.
Verify that each showcase package's Python agent module can be imported
and has the expected tools/functions defined. Skips packages whose
framework dependencies aren't installed locally.
"""
import importlib
import sys
import os
import pytest
SHOWCASE_ROOT = os.path.join(os.path.dirname(__file__), "..", "..", "..", "packages")
SHARED_PYTHON = os.path.join(os.path.dirname(__file__), "..")
# Ensure shared tools are importable
if SHARED_PYTHON not in sys.path:
sys.path.insert(0, SHARED_PYTHON)
def _try_import_agent(package_name, agent_module_path, agent_module_name):
"""Try to import a package's agent module. Returns (module, error)."""
agent_dir = os.path.join(SHOWCASE_ROOT, package_name, *agent_module_path.split("/"))
if agent_dir not in sys.path:
sys.path.insert(0, agent_dir)
try:
# Remove cached module if present
if agent_module_name in sys.modules:
del sys.modules[agent_module_name]
mod = importlib.import_module(agent_module_name)
return mod, None
except ImportError as e:
return None, str(e)
finally:
if agent_dir in sys.path:
sys.path.remove(agent_dir)
# Each entry: (package_name, path_to_agent_dir, module_name, expected_attributes)
PACKAGES = [
("langgraph-python", "src", "agents.main", ["graph"]),
(
"langgraph-python",
"src",
"agents.tools",
["query_data", "get_weather", "schedule_meeting"],
),
("langgraph-fastapi", "src/agents", "src.agent", ["graph"]),
("pydantic-ai", "src", "agents.agent", ["agent"]),
("crewai-crews", "src", "agents.crew", ["LatestAiDevelopment"]),
("google-adk", "src", "agents.main", ["sales_pipeline_agent"]),
("agno", "src", "agents.main", ["agent"]),
("claude-sdk-python", "src", "agents.agent", ["create_app"]),
("ag2", "src", "agents.agent", ["agent"]),
("strands", "src", "agents.agent", ["strands_agent", "agui_agent"]),
("llamaindex", "src", "agents.agent", ["agent_router"]),
("langroid", "src", "agents.agent", ["create_agent"]),
("ms-agent-python", "src", "agents.agent", ["create_agent"]),
]
@pytest.mark.parametrize(
"pkg,path,mod_name,attrs", PACKAGES, ids=[p[0] for p in PACKAGES]
)
def test_agent_import(pkg, path, mod_name, attrs):
"""Verify agent module imports and has expected attributes."""
mod, err = _try_import_agent(pkg, path, mod_name)
if err and ("No module named" in err or "cannot import name" in err):
# Framework not installed locally — skip gracefully
pytest.skip(f"Framework dependency not installed: {err}")
assert mod is not None, f"Import failed for {pkg}: {err}"
for attr in attrs:
assert hasattr(mod, attr), f"{pkg} agent missing expected attribute: {attr}"
@@ -0,0 +1,81 @@
import pytest
from tools import generate_a2ui_impl, build_a2ui_operations_from_tool_call
def test_returns_system_prompt():
result = generate_a2ui_impl(messages=[])
assert "system_prompt" in result
def test_returns_tool_schema():
result = generate_a2ui_impl(messages=[])
assert "tool_schema" in result
assert result["tool_schema"]["name"] == "render_a2ui"
def test_returns_tool_choice():
result = generate_a2ui_impl(messages=[])
assert result["tool_choice"] == "render_a2ui"
def test_build_operations_basic():
args = {"surfaceId": "s1", "catalogId": "cat1", "components": [{"id": "root"}]}
result = build_a2ui_operations_from_tool_call(args)
ops = result["a2ui_operations"]
assert len(ops) == 2 # create_surface + update_components
def test_build_operations_with_data():
args = {
"surfaceId": "s1",
"catalogId": "cat1",
"components": [{"id": "root"}],
"data": {"key": "val"},
}
result = build_a2ui_operations_from_tool_call(args)
ops = result["a2ui_operations"]
assert len(ops) == 3 # create_surface + update_components + update_data_model
def test_build_operations_empty_components_warns(caplog):
import logging
with caplog.at_level(logging.WARNING):
build_a2ui_operations_from_tool_call(
{"surfaceId": "s1", "catalogId": "cat1", "components": []}
)
assert "empty components" in caplog.text.lower()
def test_context_entries_with_values_appear_in_system_prompt():
entries = [
{"value": "The user is viewing a sales dashboard."},
{"value": "Current quarter is Q2 2026."},
]
result = generate_a2ui_impl(
messages=[{"role": "user", "content": "hello"}], context_entries=entries
)
assert "sales dashboard" in result["system_prompt"]
assert "Q2 2026" in result["system_prompt"]
def test_context_entries_missing_or_empty_values_filtered():
entries = [
{"value": "Keep this"},
{"value": ""},
{"other_key": "no value field"},
{"value": None},
]
result = generate_a2ui_impl(messages=[], context_entries=entries)
assert "Keep this" in result["system_prompt"]
# Empty/missing values should not produce extra content
assert "None" not in result["system_prompt"]
def test_messages_pass_through_unchanged():
msgs = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
result = generate_a2ui_impl(messages=msgs)
assert result["messages"] is msgs # exact same reference
@@ -0,0 +1,59 @@
import pytest
from tools import get_weather_impl
def test_returns_all_required_fields():
result = get_weather_impl("Tokyo")
assert "city" in result
assert "temperature" in result
assert "humidity" in result
assert "wind_speed" in result
assert "feels_like" in result
assert "conditions" in result
def test_city_name_passed_through():
result = get_weather_impl("San Francisco")
assert result["city"] == "San Francisco"
def test_deterministic_for_same_city():
r1 = get_weather_impl("Tokyo")
r2 = get_weather_impl("Tokyo")
assert r1["temperature"] == r2["temperature"]
assert r1["conditions"] == r2["conditions"]
def test_different_cities_produce_different_results():
r1 = get_weather_impl("Tokyo")
r2 = get_weather_impl("London")
# At least one field should differ (statistically guaranteed with seeded RNG)
assert r1 != r2
def test_temperature_in_valid_range():
result = get_weather_impl("TestCity")
assert 20 <= result["temperature"] <= 95
def test_humidity_in_valid_range():
result = get_weather_impl("TestCity")
assert 30 <= result["humidity"] <= 90
def test_case_insensitivity():
r_lower = get_weather_impl("tokyo")
r_upper = get_weather_impl("TOKYO")
r_mixed = get_weather_impl("Tokyo")
assert r_lower["temperature"] == r_upper["temperature"] == r_mixed["temperature"]
assert r_lower["conditions"] == r_upper["conditions"] == r_mixed["conditions"]
def test_feels_like_within_five_of_temperature():
result = get_weather_impl("TestCity")
assert abs(result["feels_like"] - result["temperature"]) <= 5
def test_wind_speed_in_valid_range():
result = get_weather_impl("TestCity")
assert 2 <= result["wind_speed"] <= 30
@@ -0,0 +1,62 @@
import pytest
import logging
from unittest.mock import patch
from tools import query_data_impl
def test_returns_list():
result = query_data_impl("any query")
assert isinstance(result, list)
def test_returns_nonempty():
result = query_data_impl("revenue breakdown")
assert len(result) > 0
def test_rows_have_expected_columns():
result = query_data_impl("test")
row = result[0]
assert "date" in row
assert "category" in row
assert "subcategory" in row
assert "amount" in row
assert "type" in row
def test_query_param_doesnt_filter():
r1 = query_data_impl("revenue")
r2 = query_data_impl("expenses")
assert len(r1) == len(r2) # same data regardless of query
def test_all_six_columns_present():
"""Verify all 6 expected columns including 'notes'."""
result = query_data_impl("test")
row = result[0]
for col in ("date", "category", "subcategory", "amount", "type", "notes"):
assert col in row, f"Missing column: {col}"
def test_amount_is_string():
"""Amount should be a string (CSV DictReader returns strings, mock data also uses strings)."""
result = query_data_impl("test")
for row in result:
assert isinstance(row["amount"], str), (
f"amount should be str, got {type(row['amount'])}"
)
def test_csv_fallback_uses_mock_data(caplog):
"""When CSV path doesn't exist, module falls back to mock data with a warning."""
# We can't easily re-trigger module-level loading, but we can verify the
# mock data structure matches expectations — the _MOCK_DATA is what gets
# used when CSV is missing.
from tools.query_data import _MOCK_DATA
assert len(_MOCK_DATA) == 3
for row in _MOCK_DATA:
assert "date" in row
assert "category" in row
assert "notes" in row
assert isinstance(row["amount"], str)
@@ -0,0 +1,302 @@
"""Tests for the render_mode middleware."""
from __future__ import annotations
import json
import sys
import os
# Ensure the shared python package is importable.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from middleware.render_mode import (
get_render_mode,
get_output_schema,
apply_render_mode_prompt,
JSONL_RENDER_INSTRUCTION,
)
# ---------------------------------------------------------------------------
# get_render_mode
# ---------------------------------------------------------------------------
class TestGetRenderMode:
def test_default_when_empty(self):
"""No context entries -> default to 'tool-based'."""
assert get_render_mode([]) == "tool-based"
def test_default_when_no_match(self):
"""Context entries exist but none with description 'render_mode'."""
ctx = [{"description": "other", "value": "foo"}]
assert get_render_mode(ctx) == "tool-based"
def test_hashbrown(self):
"""Context with render_mode='hashbrown' is extracted."""
ctx = [
{"description": "something_else", "value": "x"},
{"description": "render_mode", "value": "hashbrown"},
]
assert get_render_mode(ctx) == "hashbrown"
def test_a2ui(self):
ctx = [{"description": "render_mode", "value": "a2ui"}]
assert get_render_mode(ctx) == "a2ui"
def test_json_render(self):
ctx = [{"description": "render_mode", "value": "json-render"}]
assert get_render_mode(ctx) == "json-render"
def test_missing_value_defaults(self):
"""Entry exists but value key is absent -> 'tool-based'."""
ctx = [{"description": "render_mode"}]
assert get_render_mode(ctx) == "tool-based"
# --- Additional tests ---
def test_render_mode_not_first_in_context(self):
"""render_mode is the last of multiple context entries."""
ctx = [
{"description": "user_id", "value": "user-123"},
{"description": "session_id", "value": "sess-456"},
{"description": "locale", "value": "en-US"},
{"description": "render_mode", "value": "a2ui"},
]
assert get_render_mode(ctx) == "a2ui"
def test_render_mode_in_middle_of_context(self):
"""render_mode is sandwiched between other entries."""
ctx = [
{"description": "theme", "value": "dark"},
{"description": "render_mode", "value": "json-render"},
{"description": "feature_flags", "value": "beta"},
]
assert get_render_mode(ctx) == "json-render"
def test_invalid_render_mode_value_passes_through(self):
"""An unrecognized render_mode value is returned as-is.
The middleware does not validate the value -- that is the
responsibility of callers. This test documents that behavior.
"""
ctx = [{"description": "render_mode", "value": "not-a-real-mode"}]
assert get_render_mode(ctx) == "not-a-real-mode"
def test_first_render_mode_entry_wins(self):
"""When multiple render_mode entries exist, the first one wins."""
ctx = [
{"description": "render_mode", "value": "hashbrown"},
{"description": "render_mode", "value": "a2ui"},
]
assert get_render_mode(ctx) == "hashbrown"
def test_tool_based_explicit(self):
"""Explicit tool-based value is returned."""
ctx = [{"description": "render_mode", "value": "tool-based"}]
assert get_render_mode(ctx) == "tool-based"
def test_empty_string_value(self):
"""Empty string value is returned (falsy but still a string)."""
ctx = [{"description": "render_mode", "value": ""}]
assert get_render_mode(ctx) == ""
def test_none_value_defaults(self):
"""None value triggers the default via .get fallback."""
ctx = [{"description": "render_mode", "value": None}]
# .get("value", "tool-based") returns None (key exists), not default
assert get_render_mode(ctx) is None
# ---------------------------------------------------------------------------
# get_output_schema
# ---------------------------------------------------------------------------
class TestGetOutputSchema:
def test_none_when_empty(self):
assert get_output_schema([]) is None
def test_none_when_no_match(self):
ctx = [{"description": "render_mode", "value": "hashbrown"}]
assert get_output_schema(ctx) is None
def test_parses_json_string(self):
schema = {"type": "object", "properties": {"temp": {"type": "number"}}}
ctx = [{"description": "output_schema", "value": json.dumps(schema)}]
result = get_output_schema(ctx)
assert result == schema
def test_returns_dict_directly(self):
schema = {"type": "object", "properties": {"name": {"type": "string"}}}
ctx = [{"description": "output_schema", "value": schema}]
result = get_output_schema(ctx)
assert result == schema
def test_invalid_json_returns_none(self):
ctx = [{"description": "output_schema", "value": "not-json{{{"}]
assert get_output_schema(ctx) is None
# --- Additional tests ---
def test_json_string_vs_dict_both_work(self):
"""Both JSON string and native dict should return the same result."""
schema = {"type": "object", "properties": {"x": {"type": "integer"}}}
ctx_str = [{"description": "output_schema", "value": json.dumps(schema)}]
ctx_dict = [{"description": "output_schema", "value": schema}]
assert get_output_schema(ctx_str) == get_output_schema(ctx_dict)
def test_complex_nested_schema(self):
"""A deeply nested schema is handled correctly."""
schema = {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"value": {"type": "number"},
},
},
},
},
}
ctx = [{"description": "output_schema", "value": json.dumps(schema)}]
result = get_output_schema(ctx)
assert result == schema
def test_output_schema_not_first_in_context(self):
"""output_schema is found even when not the first entry."""
schema = {"type": "object"}
ctx = [
{"description": "render_mode", "value": "hashbrown"},
{"description": "user_id", "value": "u-1"},
{"description": "output_schema", "value": schema},
]
result = get_output_schema(ctx)
assert result == schema
def test_missing_value_key_returns_none(self):
"""Entry with description=output_schema but no value key returns None."""
ctx = [{"description": "output_schema"}]
result = get_output_schema(ctx)
assert result is None
def test_empty_dict_schema(self):
"""An empty dict schema is still returned."""
ctx = [{"description": "output_schema", "value": {}}]
result = get_output_schema(ctx)
assert result == {}
def test_integer_value_is_returned(self):
"""Non-dict, non-string values are returned as-is."""
ctx = [{"description": "output_schema", "value": 42}]
result = get_output_schema(ctx)
assert result == 42
# ---------------------------------------------------------------------------
# apply_render_mode_prompt
# ---------------------------------------------------------------------------
class TestApplyRenderModePrompt:
BASE = "You are a helpful agent."
def test_tool_based_unchanged(self):
result = apply_render_mode_prompt(self.BASE, "tool-based")
assert result == self.BASE
def test_a2ui_unchanged(self):
result = apply_render_mode_prompt(self.BASE, "a2ui")
assert result == self.BASE
def test_json_render_appends_jsonl_instruction(self):
result = apply_render_mode_prompt(self.BASE, "json-render")
assert result.startswith(self.BASE)
assert JSONL_RENDER_INSTRUCTION in result
assert "```spec" in result
assert "JSONL" in result
def test_unknown_mode_unchanged(self):
result = apply_render_mode_prompt(self.BASE, "future-mode")
assert result == self.BASE
# --- Additional tests ---
def test_json_render_contains_op_field_instruction(self):
"""JSONL instruction mentions op field for patch objects."""
result = apply_render_mode_prompt(self.BASE, "json-render")
assert '"op"' in result
assert "add" in result
assert "replace" in result
assert "remove" in result
def test_json_render_contains_path_field_instruction(self):
"""JSONL instruction mentions path field (JSON-Pointer)."""
result = apply_render_mode_prompt(self.BASE, "json-render")
assert '"path"' in result or "path" in result
def test_hashbrown_unchanged(self):
"""HashBrown mode does not modify the prompt (structured output is via response_format)."""
result = apply_render_mode_prompt(self.BASE, "hashbrown")
assert result == self.BASE
def test_empty_base_prompt_still_works(self):
"""An empty base prompt gets the instruction appended."""
result = apply_render_mode_prompt("", "json-render")
assert JSONL_RENDER_INSTRUCTION in result
def test_prompt_injection_content_preserved(self):
"""Base prompt with special characters is preserved verbatim."""
tricky_base = "You are an agent. Do NOT output ```json blocks."
result = apply_render_mode_prompt(tricky_base, "json-render")
assert result.startswith(tricky_base)
assert JSONL_RENDER_INSTRUCTION in result
def test_json_render_instruction_is_exact_constant(self):
"""The appended instruction is exactly the JSONL_RENDER_INSTRUCTION constant."""
result = apply_render_mode_prompt(self.BASE, "json-render")
assert result == self.BASE + JSONL_RENDER_INSTRUCTION
def test_empty_string_mode_unchanged(self):
"""Empty string as mode returns prompt unchanged."""
result = apply_render_mode_prompt(self.BASE, "")
assert result == self.BASE
# ---------------------------------------------------------------------------
# HashBrown mode with missing output_schema (should not crash)
# ---------------------------------------------------------------------------
class TestHashBrownMissingSchema:
def test_no_output_schema_entry_returns_none(self):
"""HashBrown mode with no output_schema in context returns None from get_output_schema."""
ctx = [{"description": "render_mode", "value": "hashbrown"}]
assert get_output_schema(ctx) is None
def test_hashbrown_mode_with_no_schema_does_not_modify_prompt(self):
"""HashBrown mode does not add prompt instructions even without a schema."""
base = "System prompt."
result = apply_render_mode_prompt(base, "hashbrown")
assert result == base
def test_hashbrown_mode_with_null_schema_value(self):
"""output_schema entry with None value returns None."""
ctx = [
{"description": "render_mode", "value": "hashbrown"},
{"description": "output_schema", "value": None},
]
assert get_output_schema(ctx) is None
def test_hashbrown_mode_with_empty_string_schema(self):
"""output_schema with empty string returns None (invalid JSON)."""
ctx = [
{"description": "render_mode", "value": "hashbrown"},
{"description": "output_schema", "value": ""},
]
# Empty string -> json.loads raises -> returns None
assert get_output_schema(ctx) is None
@@ -0,0 +1,60 @@
import pytest
from tools import manage_sales_todos_impl, get_sales_todos_impl, INITIAL_TODOS
def test_initial_todos_have_fixed_ids():
assert INITIAL_TODOS[0]["id"] == "st-001"
assert INITIAL_TODOS[1]["id"] == "st-002"
assert INITIAL_TODOS[2]["id"] == "st-003"
def test_initial_todos_count():
assert len(INITIAL_TODOS) == 3
def test_manage_assigns_id_to_missing():
result = manage_sales_todos_impl([{"title": "New deal"}])
assert result[0]["id"] # should have an ID assigned
assert len(result[0]["id"]) > 0
def test_manage_preserves_existing_id():
result = manage_sales_todos_impl([{"id": "keep-me", "title": "Deal"}])
assert result[0]["id"] == "keep-me"
def test_manage_provides_defaults():
result = manage_sales_todos_impl([{"title": "Minimal"}])
assert result[0]["stage"] == "prospect"
assert result[0]["value"] == 0
assert result[0]["dueDate"] == ""
assert result[0]["assignee"] == ""
assert result[0]["completed"] == False
def test_get_returns_initial_when_none():
result = get_sales_todos_impl(None)
assert len(result) == 3
assert result[0]["id"] == "st-001"
def test_get_returns_empty_when_empty_list():
result = get_sales_todos_impl([])
assert result == []
def test_get_returns_provided_todos():
todos = [
{
"id": "1",
"title": "Test",
"stage": "prospect",
"value": 100,
"dueDate": "",
"assignee": "",
"completed": False,
}
]
result = get_sales_todos_impl(todos)
assert len(result) == 1
assert result[0]["title"] == "Test"
@@ -0,0 +1,22 @@
import pytest
from tools import schedule_meeting_impl
def test_returns_pending_status():
result = schedule_meeting_impl("discuss roadmap")
assert result["status"] == "pending_approval"
def test_includes_reason():
result = schedule_meeting_impl("quarterly review")
assert result["reason"] == "quarterly review"
def test_includes_duration_minutes():
result = schedule_meeting_impl("sync", 45)
assert result["duration_minutes"] == 45
def test_default_duration():
result = schedule_meeting_impl("sync")
assert result["duration_minutes"] == 30
@@ -0,0 +1,85 @@
import pytest
from tools import search_flights_impl
from tools.search_flights import SURFACE_ID, CATALOG_ID
_FULL_FLIGHT = {
"airline": "Test Air",
"flightNumber": "TA100",
"origin": "SFO",
"destination": "JFK",
"date": "Tue, Apr 15",
"departureTime": "08:00",
"arrivalTime": "16:00",
"duration": "5h",
"status": "On Time",
"statusColor": "#22c55e",
"price": "$299",
"currency": "USD",
"airlineLogo": "https://example.com/logo.png",
}
def test_returns_a2ui_operations():
result = search_flights_impl([_FULL_FLIGHT])
assert "a2ui_operations" in result
def test_operations_structure():
flights = [{"airline": "Test"}]
result = search_flights_impl(flights)
ops = result["a2ui_operations"]
assert any(op["type"] == "create_surface" for op in ops)
assert any(op["type"] == "update_components" for op in ops)
def test_all_three_operation_types_present():
result = search_flights_impl([_FULL_FLIGHT])
ops = result["a2ui_operations"]
types = [op["type"] for op in ops]
assert "create_surface" in types
assert "update_components" in types
assert "update_data_model" in types
def test_surface_and_catalog_ids():
result = search_flights_impl([_FULL_FLIGHT])
ops = result["a2ui_operations"]
create_op = next(op for op in ops if op["type"] == "create_surface")
assert create_op["surfaceId"] == SURFACE_ID
assert create_op["catalogId"] == CATALOG_ID
def test_flight_data_embedded_in_data_model():
flights = [_FULL_FLIGHT, {"airline": "Second Air"}]
result = search_flights_impl(flights)
ops = result["a2ui_operations"]
data_op = next(op for op in ops if op["type"] == "update_data_model")
assert data_op["data"]["flights"] == flights
def test_empty_flights_list():
result = search_flights_impl([])
ops = result["a2ui_operations"]
assert len(ops) == 3
data_op = next(op for op in ops if op["type"] == "update_data_model")
assert data_op["data"]["flights"] == []
def test_properly_formed_flight_objects():
result = search_flights_impl([_FULL_FLIGHT])
ops = result["a2ui_operations"]
data_op = next(op for op in ops if op["type"] == "update_data_model")
flight = data_op["data"]["flights"][0]
for key in (
"airline",
"flightNumber",
"origin",
"destination",
"date",
"departureTime",
"arrivalTime",
"duration",
"status",
"price",
):
assert key in flight
+49
View File
@@ -0,0 +1,49 @@
"""Barrel exports for all shared showcase tool implementations."""
from .types import (
SalesStage,
SalesTodo,
Flight,
WeatherResult,
)
from .get_weather import get_weather_impl
from .query_data import query_data_impl
from .sales_todos import (
INITIAL_TODOS,
manage_sales_todos_impl,
get_sales_todos_impl,
)
from .search_flights import search_flights_impl
from .generate_a2ui import (
RENDER_A2UI_TOOL_SCHEMA,
generate_a2ui_impl,
build_a2ui_operations_from_tool_call,
)
from .schedule_meeting import schedule_meeting_impl
from .roll_dice import roll_dice_impl
__all__ = [
# Types
"SalesStage",
"SalesTodo",
"Flight",
"WeatherResult",
# Weather
"get_weather_impl",
# Query data
"query_data_impl",
# Sales todos
"INITIAL_TODOS",
"manage_sales_todos_impl",
"get_sales_todos_impl",
# Flight search (fixed-schema A2UI)
"search_flights_impl",
# Dynamic A2UI
"RENDER_A2UI_TOOL_SCHEMA",
"generate_a2ui_impl",
"build_a2ui_operations_from_tool_call",
# Schedule meeting (HITL)
"schedule_meeting_impl",
# Dice roll
"roll_dice_impl",
]
@@ -0,0 +1,200 @@
"""Dynamic A2UI tool: LLM-generated UI from conversation context.
This module provides the data preparation for a secondary LLM call that
generates v0.9 A2UI components. The actual LLM call is made by the
framework-specific wrapper (LangGraph, CrewAI, etc.) since each framework
has its own way of invoking LLMs.
"""
from __future__ import annotations
import json
import logging
from typing import Any, Optional
_logger = logging.getLogger(__name__)
CUSTOM_CATALOG_ID = "copilotkit://app-dashboard-catalog"
# The render_a2ui tool schema that the secondary LLM is bound to.
RENDER_A2UI_TOOL_SCHEMA = {
"name": "render_a2ui",
"description": (
"Render a dynamic A2UI v0.9 surface.\n\n"
"Args:\n"
" surfaceId: Unique surface identifier.\n"
' catalogId: The catalog ID (use "copilotkit://app-dashboard-catalog").\n'
" components: A2UI v0.9 component array (flat format). "
'The root component must have id "root".\n'
" data: Optional initial data model for the surface."
),
"parameters": {
"type": "object",
"properties": {
"surfaceId": {
"type": "string",
"description": "Unique surface identifier.",
},
"catalogId": {"type": "string", "description": "The catalog ID."},
"components": {
"type": "array",
"items": {"type": "object"},
"description": "A2UI v0.9 component array (flat format).",
},
"data": {
"type": "object",
"description": "Optional initial data model for the surface.",
},
},
"required": ["surfaceId", "catalogId", "components"],
},
}
def generate_a2ui_impl(
messages: list[dict[str, Any]],
context_entries: Optional[list[dict[str, Any]]] = None,
) -> dict[str, Any]:
"""Prepare inputs for a secondary LLM call that generates A2UI components.
Returns a dict with:
- system_prompt: The system prompt for the secondary LLM (built from context)
- tool_schema: The render_a2ui tool schema to bind to the LLM
- tool_choice: The tool name to force
- messages: The conversation messages to pass through
- catalog_id: The default catalog ID
The framework wrapper should:
1. Make an LLM call with these inputs
2. Extract the tool call args (surfaceId, catalogId, components, data)
3. Build a2ui_operations from the args and return them
"""
context_text = ""
if context_entries:
context_text = "\n\n".join(
entry.get("value", "")
for entry in context_entries
if isinstance(entry, dict) and entry.get("value")
)
return {
"system_prompt": context_text,
"tool_schema": RENDER_A2UI_TOOL_SCHEMA,
"tool_choice": "render_a2ui",
"messages": messages,
"catalog_id": CUSTOM_CATALOG_ID,
}
def _unstringify_json_fields(component: dict[str, Any]) -> dict[str, Any]:
"""Parse JSON-string fields back to Python values where the schema
expects structured data.
Gemini's structured-output sometimes emits `"data": "[{...}]"` (a JSON
string) instead of `"data": [...]` (the actual array) for fields
declared with an "any" type in the schema. The React A2UI renderer
expects real arrays/objects on data props — strings render as
"No data available" on charts. We round-trip those known structured
fields through json.loads so the renderer sees the right type.
Returns a new dict (does not mutate the input).
"""
out = dict(component)
for field in ("data", "value", "children"):
v = out.get(field)
if isinstance(v, str) and v.strip().startswith(("[", "{")):
try:
out[field] = json.loads(v)
except (ValueError, TypeError):
# Leave the raw string in place if it doesn't parse — the
# renderer will still receive a defined value rather than
# nothing, and downstream code can decide what to do.
pass
return out
def _sanitize_a2ui_components(raw: Any) -> list[dict[str, Any]]:
"""Drop entries that aren't dicts or are missing `id`/`component`,
then unstringify any JSON-as-string fields the model emitted.
Mirrors `langgraph-python/src/agents/_a2ui_utils.py:sanitize_a2ui_components`
with an added pass for Gemini's stringified `data` quirk.
"""
if not isinstance(raw, list):
return []
return [
_unstringify_json_fields(c)
for c in raw
if isinstance(c, dict) and c.get("id") and c.get("component")
]
def _has_root_component(components: list[dict[str, Any]]) -> bool:
"""True iff `components` contains an entry with `id == "root"`.
Mirrors `langgraph-python/src/agents/_a2ui_utils.py:has_root_component`.
"""
return any(c.get("id") == "root" for c in components)
def build_a2ui_operations_from_tool_call(args: dict[str, Any]) -> dict[str, Any]:
"""Build a2ui_operations dict from the secondary LLM's tool call args.
Call this after the framework wrapper extracts the tool call arguments.
Emits the v0.9 NESTED operation shape that
`@ag-ui/a2ui-middleware`'s `getOperationSurfaceId` and the React
A2UI renderer recognize:
{ "version": "v0.9", "createSurface": { surfaceId, catalogId } }
{ "version": "v0.9", "updateComponents": { surfaceId, components } }
{ "version": "v0.9", "updateDataModel": { surfaceId, path, value } }
The legacy flat shape (`{type: "create_surface", surfaceId, ...}`)
looked plausible but the middleware's matcher only walks the nested
`createSurface` / `updateComponents` / `updateDataModel` keys; when
those were absent it grouped every op under the fallback `"default"`
surface and the renderer never received the schema. Mirrors
`copilotkit.a2ui.create_surface` / `update_components` /
`update_data_model` from the langgraph-python north-star.
"""
surface_id = args.get("surfaceId", "dynamic-surface")
catalog_id = args.get("catalogId", CUSTOM_CATALOG_ID)
# Drop empty/malformed component entries before forwarding. Without
# this, the renderer errors on the first `undefined` id.
components = _sanitize_a2ui_components(args.get("components", []))
if not components:
_logger.warning(
"build_a2ui_operations_from_tool_call: all components were "
"dropped by sanitization (LLM emitted empty {} entries)"
)
elif not _has_root_component(components):
_logger.warning(
"build_a2ui_operations_from_tool_call: no component with id "
"'root' — the renderer will error with 'no root component'"
)
data = args.get("data")
ops: list[dict[str, Any]] = [
{
"version": "v0.9",
"createSurface": {"surfaceId": surface_id, "catalogId": catalog_id},
},
{
"version": "v0.9",
"updateComponents": {"surfaceId": surface_id, "components": components},
},
]
if data:
ops.append(
{
"version": "v0.9",
"updateDataModel": {
"surfaceId": surface_id,
"path": "/",
"value": data,
},
}
)
return {"a2ui_operations": ops}
@@ -0,0 +1,40 @@
"""Mock weather data tool implementation."""
import random
from .types import WeatherResult
_CONDITIONS = [
"Sunny",
"Partly Cloudy",
"Cloudy",
"Overcast",
"Light Rain",
"Heavy Rain",
"Thunderstorm",
"Snow",
"Foggy",
"Windy",
]
def get_weather_impl(city: str) -> WeatherResult:
"""Return mock weather data for the given city.
Uses a seeded random based on the city name so repeated calls
for the same city return consistent results within a session.
"""
rng = random.Random(city.lower())
temperature = rng.randint(20, 95)
humidity = rng.randint(30, 90)
wind_speed = rng.randint(2, 30)
feels_like = temperature + rng.randint(-5, 5)
conditions = rng.choice(_CONDITIONS)
return WeatherResult(
city=city,
temperature=temperature,
humidity=humidity,
wind_speed=wind_speed,
feels_like=feels_like,
conditions=conditions,
)
@@ -0,0 +1,60 @@
"""Query data tool implementation — reads db.csv at module load time."""
from __future__ import annotations
import csv
import logging
from pathlib import Path
from typing import Any
_logger = logging.getLogger(__name__)
_csv_path = Path(__file__).resolve().parent.parent / "data" / "db.csv"
_MOCK_DATA = [
{
"date": "2026-01-05",
"category": "Revenue",
"subcategory": "Enterprise Subscriptions",
"amount": "28000",
"type": "income",
"notes": "3 new enterprise customers",
},
{
"date": "2026-01-10",
"category": "Expenses",
"subcategory": "Engineering Salaries",
"amount": "42000",
"type": "expense",
"notes": "7 engineers + 2 contractors",
},
{
"date": "2026-02-03",
"category": "Revenue",
"subcategory": "Pro Tier Upgrades",
"amount": "22500",
"type": "income",
"notes": "31 upgrades + reduced churn",
},
]
try:
with open(_csv_path) as _f:
_cached_data: list[dict[str, Any]] = list(csv.DictReader(_f))
if not _cached_data:
_logger.warning("CSV at %s is empty, falling back to mock data", _csv_path)
_cached_data = _MOCK_DATA
except (FileNotFoundError, OSError) as exc:
_logger.warning(
"Could not load CSV at %s (%s), falling back to mock data", _csv_path, exc
)
_cached_data = _MOCK_DATA
def query_data_impl(query: str) -> list[dict[str, Any]]:
"""Query the database. Takes natural language.
Always call before showing a chart or graph. Returns the full
dataset as a list of dicts (rows from the CSV).
"""
return _cached_data
+13
View File
@@ -0,0 +1,13 @@
"""Dice-rolling tool implementation."""
import random
from typing import Any
def roll_dice_impl(sides: int) -> dict[str, Any]:
"""Roll a die with the given number of sides.
Returns a dict with the requested ``sides`` and the rolled ``result``
(a random integer in ``[1, sides]``).
"""
return {"sides": sides, "result": random.randint(1, sides)}
@@ -0,0 +1,63 @@
"""Sales todos tool implementation."""
from __future__ import annotations
import uuid
from typing import Optional
from .types import SalesTodo
INITIAL_TODOS: list[SalesTodo] = [
SalesTodo(
id="st-001",
title="Follow up with Acme Corp on enterprise proposal",
stage="proposal",
value=85000,
dueDate="2026-04-15",
assignee="Sarah Chen",
completed=False,
),
SalesTodo(
id="st-002",
title="Qualify lead from TechFlow demo request",
stage="prospect",
value=42000,
dueDate="2026-04-18",
assignee="Mike Johnson",
completed=False,
),
SalesTodo(
id="st-003",
title="Send contract to DataViz Inc for final review",
stage="negotiation",
value=120000,
dueDate="2026-04-20",
assignee="Sarah Chen",
completed=False,
),
]
def manage_sales_todos_impl(todos: list[dict]) -> list[SalesTodo]:
"""Assign UUIDs to any todos missing an ID, then return the updated list."""
result: list[SalesTodo] = []
for todo in todos:
result.append(
SalesTodo(
id=todo.get("id") or str(uuid.uuid4()),
title=todo.get("title", ""),
stage=todo.get("stage", "prospect"),
value=todo.get("value", 0),
dueDate=todo.get("dueDate", ""),
assignee=todo.get("assignee", ""),
completed=todo.get("completed", False),
)
)
return result
def get_sales_todos_impl(current_todos: Optional[list[dict]] = None) -> list[SalesTodo]:
"""Return current todos or initial defaults if none provided."""
if current_todos is not None:
return manage_sales_todos_impl(current_todos)
return list(INITIAL_TODOS)
@@ -0,0 +1,31 @@
"""Schedule meeting tool implementation.
The HITL gating happens on the frontend via useHumanInTheLoop.
This tool just returns a pending approval status for the framework
wrapper to surface.
"""
from __future__ import annotations
from typing import Any
def schedule_meeting_impl(
reason: str,
duration_minutes: int = 30,
) -> dict[str, Any]:
"""Schedule a meeting (requires human approval).
Returns a pending_approval status. The actual gating is done by the
frontend's useHumanInTheLoop hook — the agent pauses until the user
approves or rejects.
"""
return {
"status": "pending_approval",
"reason": reason,
"duration_minutes": duration_minutes,
"message": (
f"Meeting request: {reason} ({duration_minutes} min). "
"Awaiting human approval."
),
}
@@ -0,0 +1,122 @@
"""Fixed-schema A2UI tool: flight search results.
Packages flight data with an A2UI schema for rendering. The schema is loaded
from the shared frontend package's flight_schema.json.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from .types import Flight
_logger = logging.getLogger(__name__)
CATALOG_ID = "copilotkit://app-dashboard-catalog"
SURFACE_ID = "flight-search-results"
# Resolve the flight schema from the shared frontend package.
# Walk up from this file to showcase/shared/, then into frontend/src/a2ui/.
_SHARED_DIR = Path(__file__).resolve().parent.parent.parent # showcase/shared/
_SCHEMA_CANDIDATES = [
_SHARED_DIR / "frontend" / "src" / "a2ui" / "flight-schema.json",
_SHARED_DIR / "frontend" / "src" / "a2ui" / "flight_schema.json",
]
_flight_schema: list[dict[str, Any]] | None = None
for _candidate in _SCHEMA_CANDIDATES:
if _candidate.exists():
with open(_candidate) as _f:
_flight_schema = json.load(_f)
_logger.info("Loaded flight schema from shared frontend: %s", _candidate)
break
# Fallback: use the schema from the examples directory if present
if _flight_schema is None:
try:
_fallback = Path(__file__).resolve().parents[4] / (
"examples/integrations/langgraph-python/apps/agent/src/a2ui/schemas/flight_schema.json"
)
if _fallback.exists():
with open(_fallback) as _f:
_flight_schema = json.load(_f)
_logger.info("Loaded flight schema from examples fallback: %s", _fallback)
except IndexError:
# In Docker the file path is too shallow for parents[4]; skip this fallback.
pass
# Last resort: inline minimal schema
if _flight_schema is None:
_logger.warning("No flight schema file found, using inline minimal schema")
_flight_schema = [
{
"id": "root",
"component": "Row",
"children": {"componentId": "flight-card", "path": "/flights"},
"gap": 16,
},
{
"id": "flight-card",
"component": "FlightCard",
"airline": {"path": "airline"},
"airlineLogo": {"path": "airlineLogo"},
"flightNumber": {"path": "flightNumber"},
"origin": {"path": "origin"},
"destination": {"path": "destination"},
"date": {"path": "date"},
"departureTime": {"path": "departureTime"},
"arrivalTime": {"path": "arrivalTime"},
"duration": {"path": "duration"},
"status": {"path": "status"},
"price": {"path": "price"},
"action": {
"event": {
"name": "book_flight",
"context": {
"flightNumber": {"path": "flightNumber"},
"origin": {"path": "origin"},
"destination": {"path": "destination"},
"price": {"path": "price"},
},
}
},
},
]
def search_flights_impl(flights: list[Flight]) -> dict[str, Any]:
"""Package flight data with A2UI schema for rendering.
Returns a dict with a2ui_operations that the middleware detects in the
TOOL_CALL_RESULT and renders automatically.
Each flight should have: airline, airlineLogo, flightNumber, origin,
destination, date, departureTime, arrivalTime, duration, status,
statusColor, price, currency.
"""
return {
"a2ui_operations": [
{
"version": "v0.9",
"createSurface": {"surfaceId": SURFACE_ID, "catalogId": CATALOG_ID},
},
{
"version": "v0.9",
"updateComponents": {
"surfaceId": SURFACE_ID,
"components": _flight_schema,
},
},
{
"version": "v0.9",
"updateDataModel": {
"surfaceId": SURFACE_ID,
"path": "/",
"value": {"flights": flights},
},
},
]
}
+47
View File
@@ -0,0 +1,47 @@
"""Shared type definitions for showcase tools."""
from typing import TypedDict, Literal
SalesStage = Literal[
"prospect",
"qualified",
"proposal",
"negotiation",
"closed-won",
"closed-lost",
]
class SalesTodo(TypedDict):
id: str
title: str
stage: SalesStage
value: int
dueDate: str
assignee: str
completed: bool
class Flight(TypedDict):
airline: str
airlineLogo: str
flightNumber: str
origin: str
destination: str
date: str
departureTime: str
arrivalTime: str
duration: str
status: str
statusColor: str
price: str
currency: str
class WeatherResult(TypedDict):
city: str
temperature: int
humidity: int
wind_speed: int
feels_like: int
conditions: str
@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
const AGENT_URL =
process.env.AGENT_URL ||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
"http://localhost:8123";
if (!process.env.AGENT_URL && !process.env.LANGGRAPH_DEPLOYMENT_URL) {
console.warn(
"[copilotkit/route] WARNING: No AGENT_URL or LANGGRAPH_DEPLOYMENT_URL set, falling back to localhost:8123",
);
}
console.log("[copilotkit/route] Initializing CopilotKit runtime");
console.log(`[copilotkit/route] AGENT_URL: ${AGENT_URL}`);
function createAgent(graphId: string = "sample_agent") {
return new LangGraphAgent({
deploymentUrl: AGENT_URL,
graphId,
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
});
}
const agentNames = [
"sample_agent",
"agentic_chat",
"human_in_the_loop",
"tool-rendering",
"gen-ui-tool-based",
"gen-ui-agent",
"shared-state-read",
"shared-state-write",
"shared-state-streaming",
"subagents",
"default",
];
const agents: Record<string, LangGraphAgent> = {};
for (const name of agentNames) {
agents[name] = createAgent();
}
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-expect-error -- type wrapping mismatch, fixed in source pending release
agents,
}),
});
return await handleRequest(req);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("[copilotkit/route] ERROR:", error);
return NextResponse.json({ error: message }, { status: 500 });
}
};
export const GET = async () => {
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) {
const msg = e instanceof Error ? e.message : String(e);
agentStatus = `unreachable (${msg})`;
}
const topStatus = agentStatus.startsWith("unreachable") ? "degraded" : "ok";
return NextResponse.json({
status: topStatus,
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,9 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
status: "ok",
integration: "{{SLUG}}",
timestamp: new Date().toISOString(),
});
}
@@ -0,0 +1,6 @@
/* CopilotKit overrides — EXACTLY matches Dojo, nothing more */
.copilotKitInput {
border-radius: 0.75rem;
border: 1px solid var(--copilot-kit-separator-color) !important;
}
@@ -0,0 +1,23 @@
@import "tailwindcss";
:root {
--copilot-kit-background-color: #fafaf9;
--copilot-kit-primary-color: #0d6e3f;
--copilot-kit-response-button-background-color: #f5f5f3;
--copilot-kit-response-button-color: #1a1a18;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
width: 100%;
overflow: auto;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #fafaf9;
color: #1a1a18;
}
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import "@copilotkit/react-core/v2/styles.css";
import "./globals.css";
import "./copilotkit-overrides.css";
export const metadata: Metadata = {
title: "CopilotKit Showcase — {{NAME}}",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<script
dangerouslySetInnerHTML={{
__html: `
console.log('[showcase] {{NAME}} demo loaded');
console.log('[showcase] URL:', window.location.href);
console.log('[showcase] In iframe:', window.self !== window.top);
window.addEventListener('error', function(e) {
console.error('[showcase] Uncaught error:', e.message, e.filename, e.lineno);
});
window.addEventListener('unhandledrejection', function(e) {
console.error('[showcase] Unhandled rejection:', e.reason);
});
`,
}}
/>
{children}
</body>
</html>
);
}
@@ -0,0 +1,96 @@
"use client";
import React from "react";
import { CopilotKit } from "@copilotkit/react-core";
import { CopilotSidebar } from "@copilotkit/react-core/v2";
import { SalesDashboard } from "../components/sales-dashboard";
import { RendererSelector } from "../components/renderers/renderer-selector";
import { useRenderMode } from "../components/renderers/use-render-mode";
import { useShowcaseHooks } from "../hooks/use-showcase-hooks";
import { useShowcaseSuggestions } from "../hooks/use-showcase-suggestions";
import { ToolBasedDashboard } from "../components/renderers/tool-based";
import { A2UIDashboard } from "../components/renderers/a2ui";
import {
HashBrownDashboard,
useHashBrownMessageRenderer,
} from "../components/renderers/hashbrown";
const AGENT_ID = "sample_agent";
function ToolBasedPage() {
return <ToolBasedDashboard agentId={AGENT_ID} />;
}
function A2UIPage() {
return <A2UIDashboard agentId={AGENT_ID} />;
}
function JsonRenderPage() {
// json-render falls back to tool-based with a note
return (
<div>
<div className="bg-yellow-50 border border-yellow-200 text-yellow-800 text-sm px-4 py-2 text-center">
json-render is not yet available as a standalone starter. Showing
tool-based rendering instead.
</div>
<ToolBasedDashboard agentId={AGENT_ID} />
</div>
);
}
function HashBrownInner() {
const RenderMessage = useHashBrownMessageRenderer();
useShowcaseHooks();
useShowcaseSuggestions();
return (
<CopilotKit runtimeUrl="/api/copilotkit" agent={AGENT_ID}>
<div className="min-h-screen w-full flex items-center justify-center">
<SalesDashboard agentId={AGENT_ID} />
<CopilotSidebar
defaultOpen={true}
labels={{ modalHeaderTitle: "Sales Dashboard Assistant" }}
RenderMessage={RenderMessage}
/>
</div>
</CopilotKit>
);
}
function HashBrownPage() {
return (
<HashBrownDashboard>
<HashBrownInner />
</HashBrownDashboard>
);
}
export default function Home() {
const { mode, setMode } = useRenderMode();
const renderDashboard = () => {
switch (mode) {
case "tool-based":
return <ToolBasedPage />;
case "a2ui":
return <A2UIPage />;
case "json-render":
return <JsonRenderPage />;
case "hashbrown":
return <HashBrownPage />;
default:
return <ToolBasedPage />;
}
};
return (
<div className="h-screen flex flex-col">
<header className="sticky top-0 z-[60] border-b border-[var(--border)] bg-[var(--card)] px-6 py-3 flex items-center justify-between shrink-0">
<h1 className="text-sm font-bold text-[var(--foreground)]">
CopilotKit Sales Dashboard
</h1>
<RendererSelector mode={mode} onModeChange={setMode} />
</header>
<main className="flex-1 overflow-hidden">{renderDashboard()}</main>
</div>
);
}
@@ -0,0 +1,180 @@
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 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 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>
);
}
@@ -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)",
},
};
@@ -0,0 +1,154 @@
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 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 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,220 @@
import { useState } from "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 (
<div className="max-w-md w-full mx-auto mb-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]">
<div 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]">
<svg
className="h-5 w-5 text-white"
strokeWidth={3}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</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 && (
<span className="inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--secondary)] px-2.5 py-0.5 text-xs font-semibold text-[var(--secondary-foreground)]">
<svg
className="h-3 w-3 mr-1"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
{selectedSlot.duration}
</span>
)}
</div>
</div>
</div>
);
}
// Declined state
if (declined) {
return (
<div className="max-w-md w-full mx-auto mb-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]">
<div 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)]">
<svg
className="h-6 w-6 text-[var(--muted-foreground)]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</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>
</div>
</div>
);
}
// Selection state
return (
<div className="max-w-md w-full mx-auto mb-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]">
<div 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">
<svg
className="h-6 w-6 text-[#BEC2FF]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</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">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-[var(--muted)] border-t-[var(--foreground)]" />
</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 && (
<span className="shrink-0 text-sm px-3 py-1 inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--secondary)] font-semibold text-[var(--secondary-foreground)]">
{slot.duration}
</span>
)}
<svg
className="h-4 w-4 text-[var(--muted-foreground)] opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
))}
<button
className="w-full mt-1 text-xs text-[var(--muted-foreground)] py-2 hover:bg-[var(--accent)] rounded-md transition-colors"
onClick={handleDecline}
>
None of these work
</button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,180 @@
/**
* 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() })]);
/**
* Shared action schema used by interactive components (Button, FlightCard).
* The GenericBinder resolves `{ event }` objects to callable `() => void` at
* render time.
*/
const ActionSchema = z
.union([
z.object({
event: z.object({
name: z.string(),
context: z.record(z.any()).optional(),
}),
}),
z.null(),
])
.optional();
export const demonstrationCatalogDefinitions = {
Title: {
description: "A heading. Use for section titles and page headers.",
props: z.object({
text: z.string(),
level: z.string().optional(),
}),
},
// Text: removed -- the basic catalog's Text uses DynamicStringSchema
// which supports path bindings (e.g. { path: "flights[*].airline" }).
// Overriding it with z.string() breaks fixed-schema data binding.
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(),
action: ActionSchema,
}),
},
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: ActionSchema,
}),
},
};
/** Type helper for renderers */
export type DemonstrationCatalogDefinitions =
typeof demonstrationCatalogDefinitions;
@@ -0,0 +1,42 @@
"use client";
import React from "react";
import { CopilotKit } from "@copilotkit/react-core";
import { CopilotSidebar } from "@copilotkit/react-core/v2";
import { useShowcaseHooks } from "../../../hooks/use-showcase-hooks";
import { useShowcaseSuggestions } from "../../../hooks/use-showcase-suggestions";
import { SalesDashboard } from "../../sales-dashboard";
import { demonstrationCatalog } from "./renderers";
interface A2UIDashboardProps {
agentId: string;
}
function DashboardContent({ agentId }: A2UIDashboardProps) {
useShowcaseHooks();
useShowcaseSuggestions();
return (
<div className="min-h-screen w-full flex items-center justify-center">
<SalesDashboard agentId={agentId} />
<CopilotSidebar
defaultOpen={true}
labels={{
modalHeaderTitle: "Sales Dashboard Assistant",
}}
/>
</div>
);
}
export function A2UIDashboard({ agentId }: A2UIDashboardProps) {
return (
<CopilotKit
runtimeUrl="/api/copilotkit"
agent={agentId}
a2ui={{ catalog: demonstrationCatalog }}
>
<DashboardContent agentId={agentId} />
</CopilotKit>
);
}
@@ -0,0 +1,623 @@
/**
* 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.
*/
import React, { useState } from "react";
import {
PieChart as RechartsPie,
Pie,
Cell,
ResponsiveContainer,
BarChart as RechartsBar,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
} from "recharts";
import {
createCatalog,
type CatalogRenderers,
} from "@copilotkit/a2ui-renderer";
import {
demonstrationCatalogDefinitions,
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: unknown;
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 as (() => void) | undefined)?.();
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 React.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: unknown, 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 as Record<string, unknown>)
)
return (
<div
key={`${(item as Record<string, unknown>).id}-${i}`}
style={{ flex: "1 1 0", minWidth: 0 }}
>
{(
children as (
id: string,
basePath?: string,
) => React.ReactNode
)(
(item as Record<string, unknown>).id,
(item as Record<string, unknown>).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: unknown, i: number) => {
if (typeof item === "string")
return (
<React.Fragment key={`${item}-${i}`}>
{children(item)}
</React.Fragment>
);
if (
item &&
typeof item === "object" &&
"id" in (item as Record<string, unknown>)
)
return (
<React.Fragment
key={`${(item as Record<string, unknown>).id}-${i}`}
>
{(
children as (
id: string,
basePath?: string,
) => React.ReactNode
)(
(item as Record<string, unknown>).id,
(item as Record<string, unknown>).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: "\u2191",
down: "\u2193",
neutral: "\u2192",
};
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: Record<string, unknown>, i: number) => (
<Cell
key={i}
fill={(entry.color as string) ?? 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: Record<string, unknown>) => (
<th
key={col.key as string}
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 as string}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row: Record<string, unknown>, i: number) => (
<tr key={i} style={{ borderBottom: `1px solid ${c.divider}` }}>
{cols.map((col: Record<string, unknown>) => (
<td
key={col.key as string}
style={{ padding: "8px 12px", color: c.cardFg }}
>
{String(row[col.key as string] ?? "")}
</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, unknown>;
const statusColors: Record<string, string> = {
"On Time": "#22c55e",
Delayed: "#eab308",
Cancelled: "#ef4444",
};
const dotColor =
(props.statusColor as string) ??
statusColors[props.status as string] ??
"#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 as string}
alt={props.airline as string}
style={{
width: 28,
height: 28,
borderRadius: "50%",
objectFit: "contain",
}}
/>
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>
{props.airline as string}
</span>
</div>
<span style={{ fontWeight: 700, fontSize: "1.15rem" }}>
{props.price as string}
</span>
</div>
{/* Meta */}
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "0.8rem",
color: c.muted,
}}
>
<span>{props.flightNumber as string}</span>
<span>{props.date as string}</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 as string}
</span>
<span style={{ fontSize: "0.75rem", color: c.muted }}>
{props.duration as string}
</span>
<span style={{ fontWeight: 700, fontSize: "1.1rem" }}>
{props.arrivalTime as string}
</span>
</div>
{/* Route */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "0.95rem",
fontWeight: 600,
}}
>
<span>{props.origin as string}</span>
<span style={{ color: c.muted }}>{"\u2192"}</span>
<span>{props.destination as string}</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 as string}
</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",
},
);
@@ -0,0 +1,285 @@
"use client";
import React, { memo } from "react";
import { s, prompt } from "@hashbrownai/core";
import {
exposeComponent,
exposeMarkdown,
useUiKit,
useJsonParser,
} from "@hashbrownai/react";
import type { RenderMessageProps } from "@copilotkit/react-ui";
import type { AssistantMessage } from "@ag-ui/core";
import { PieChart } from "../../charts/pie-chart";
import { BarChart } from "../../charts/bar-chart";
import type { SalesStage } from "../../../types";
// ---------------------------------------------------------------------------
// Simple MetricCard with flat string props (optimized for structured output)
// ---------------------------------------------------------------------------
interface MetricCardProps {
label: string;
value: string;
trend?: string;
}
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>
);
}
// ---------------------------------------------------------------------------
// Standalone DealCard for the kit (flat props, no SalesTodo dependency)
// ---------------------------------------------------------------------------
interface HashBrownDealCardProps {
title: string;
stage: SalesStage;
value: number;
assignee?: string;
dueDate?: string;
}
const STAGE_COLORS: Record<SalesStage, string> = {
prospect: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
qualified:
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
proposal: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200",
negotiation:
"bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
"closed-won":
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
"closed-lost": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
};
function DealCardComponent({
title,
stage,
value,
assignee,
dueDate,
}: HashBrownDealCardProps) {
const badgeClass =
STAGE_COLORS[stage] ??
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200";
return (
<div
data-testid="hashbrown-deal-card"
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4"
>
<h3 className="text-sm font-semibold leading-snug text-[var(--foreground)]">
{title}
</h3>
<div className="mt-2 flex items-center gap-2 flex-wrap">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass}`}
>
{stage}
</span>
<span className="text-sm font-semibold text-[var(--foreground)]">
${(value ?? 0).toLocaleString()}
</span>
</div>
<div className="mt-2 flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
{assignee && <span>{assignee}</span>}
{dueDate && <span>Due {dueDate}</span>}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Kit definition
// ---------------------------------------------------------------------------
export function useSalesDashboardKit() {
return useUiKit({
examples: prompt`
# Mixing components and Markdown:
<ui>
<Markdown children="## Q4 Sales Summary" />
<metric label="Total Revenue" value="$1.2M" trend="+12% vs Q3" />
<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" assignee="Jane" dueDate="2024-12-31" />
</ui>
Hint: use Markdown for explanatory text between visual components.
Hint: always include title and data for charts.
`,
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)"),
trend: s.string("Optional trend indicator, e.g. +12%").optional(),
},
}),
exposeComponent(PieChart, {
name: "pieChart",
description: "A donut/pie chart with title and data segments",
props: {
title: s.string("Chart title"),
description: s.string("Brief description or subtitle").optional(),
data: s.streaming.array(
s.object({
label: s.string("Segment label"),
value: s.number("Segment value"),
}),
),
},
}),
exposeComponent(BarChart, {
name: "barChart",
description: "A vertical bar chart with title and data bars",
props: {
title: s.string("Chart title"),
description: s.string("Brief description or subtitle").optional(),
data: s.streaming.array(
s.object({
label: s.string("Bar label"),
value: s.number("Bar value"),
}),
),
},
}),
exposeComponent(DealCardComponent, {
name: "dealCard",
description: "A sales deal card showing pipeline stage and value",
props: {
title: s.string("Deal title"),
stage: s.string(
"Pipeline stage: prospect | qualified | proposal | negotiation | closed-won | closed-lost",
),
value: s.number("Deal value in dollars"),
assignee: s.string("Who owns this deal").optional(),
dueDate: s.string("Expected close date").optional(),
},
}),
],
});
}
// ---------------------------------------------------------------------------
// Custom message renderer
// ---------------------------------------------------------------------------
const AssistantMessageRenderer = memo(function AssistantMessageRenderer({
message,
kit,
}: {
message: AssistantMessage;
kit: ReturnType<typeof useSalesDashboardKit>;
}) {
const { value } = useJsonParser(message.content ?? "", kit.schema);
if (!value) return null;
return (
<div className="mt-2 flex w-full justify-start">
<div className="w-full px-1 py-1">{kit.render(value)}</div>
</div>
);
});
// ---------------------------------------------------------------------------
// Exported dashboard component
// ---------------------------------------------------------------------------
export interface HashBrownDashboardProps {
/**
* Optional custom wrapper for the assistant message area.
* Defaults to rendering messages inline.
*/
children?: React.ReactNode;
}
/**
* Provider that instantiates the HashBrown kit ONCE and shares it via context.
* Both the dashboard layout and message renderer consume the same kit instance.
*
* The kit registers MetricCard, PieChart, BarChart, DealCard, and Markdown
* components. Agent context forwarding for output_schema is omitted because
* the npm-published react-core may not export useAgentContext yet.
*/
const HashBrownKitContext = React.createContext<ReturnType<
typeof useSalesDashboardKit
> | null>(null);
function useHashBrownKit() {
const kit = React.useContext(HashBrownKitContext);
if (!kit)
throw new Error("useHashBrownKit must be used within HashBrownDashboard");
return kit;
}
export function HashBrownDashboard({ children }: HashBrownDashboardProps) {
const kit = useSalesDashboardKit();
// Note: Agent context forwarding (useAgentContext) for output_schema is
// omitted because the npm-published react-core may not export it yet.
return (
<HashBrownKitContext.Provider value={kit}>
{children}
</HashBrownKitContext.Provider>
);
}
/**
* Stable message renderer component that consumes the kit from context.
* Defined at module level to avoid unstable function identity.
*/
function HashBrownRenderMessage({ message }: RenderMessageProps) {
const kit = useHashBrownKit();
if (message.role === "assistant") {
return (
<AssistantMessageRenderer
message={message as AssistantMessage}
kit={kit}
/>
);
}
return null;
}
/**
* Returns the stable HashBrownRenderMessage component.
* Must be used within a HashBrownDashboard provider.
*/
export function useHashBrownMessageRenderer() {
return HashBrownRenderMessage;
}
@@ -0,0 +1,53 @@
"use client";
import React from "react";
import { RenderMode, RENDER_STRATEGIES } from "./types";
export interface RendererSelectorProps {
/** The currently active render mode. */
mode: RenderMode;
/** Called when the user selects a different render mode. */
onModeChange: (mode: RenderMode) => void;
}
/**
* Horizontal pill-toggle for selecting a render strategy.
*
* Each pill shows the strategy icon and name. Hovering reveals a tooltip with
* the one-line description. The active pill is visually highlighted.
*/
export function RendererSelector({
mode,
onModeChange,
}: RendererSelectorProps) {
return (
<div
className="flex flex-wrap gap-2"
role="radiogroup"
aria-label="Render mode"
>
{RENDER_STRATEGIES.map((strategy) => {
const isActive = strategy.mode === mode;
return (
<button
key={strategy.mode}
role="radio"
aria-checked={isActive}
title={strategy.description}
onClick={() => onModeChange(strategy.mode)}
className={[
"inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500",
isActive
? "bg-blue-600 text-white shadow-sm"
: "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700",
].join(" ")}
>
<span aria-hidden="true">{strategy.icon}</span>
{strategy.name}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,37 @@
"use client";
import React from "react";
import { CopilotKit } from "@copilotkit/react-core";
import { CopilotSidebar } from "@copilotkit/react-core/v2";
import { useShowcaseHooks } from "../../../hooks/use-showcase-hooks";
import { useShowcaseSuggestions } from "../../../hooks/use-showcase-suggestions";
import { SalesDashboard } from "../../sales-dashboard";
interface ToolBasedDashboardProps {
agentId: string;
}
function DashboardContent({ agentId }: ToolBasedDashboardProps) {
useShowcaseHooks();
useShowcaseSuggestions();
return (
<div className="min-h-screen w-full flex items-center justify-center">
<SalesDashboard agentId={agentId} />
<CopilotSidebar
defaultOpen={true}
labels={{
modalHeaderTitle: "Sales Dashboard Assistant",
}}
/>
</div>
);
}
export function ToolBasedDashboard({ agentId }: ToolBasedDashboardProps) {
return (
<CopilotKit runtimeUrl="/api/copilotkit" agent={agentId}>
<DashboardContent agentId={agentId} />
</CopilotKit>
);
}
@@ -0,0 +1,71 @@
export const RENDER_MODES = [
"tool-based",
"a2ui",
"json-render",
"hashbrown",
] as const;
export type RenderMode = (typeof RENDER_MODES)[number];
export interface RenderStrategyInfo {
mode: RenderMode;
name: string;
description: string;
icon: string;
features: {
streaming: boolean;
interactivity: boolean;
sandbox: boolean;
constraintLevel: "high" | "medium" | "low" | "none";
};
}
export const RENDER_STRATEGIES: RenderStrategyInfo[] = [
{
mode: "tool-based",
name: "Tool-Based",
description: "Agent calls typed tool functions",
icon: "\u{1F527}",
features: {
streaming: false,
interactivity: true,
sandbox: false,
constraintLevel: "high",
},
},
{
mode: "a2ui",
name: "A2UI Catalog",
description: "Component tree from predefined catalog",
icon: "\u{1F4CB}",
features: {
streaming: false,
interactivity: true,
sandbox: false,
constraintLevel: "medium",
},
},
{
mode: "json-render",
name: "json-render",
description: "JSONL patches with built-in state",
icon: "\u{1F4C4}",
features: {
streaming: true,
interactivity: true,
sandbox: false,
constraintLevel: "medium",
},
},
{
mode: "hashbrown",
name: "HashBrown",
description: "Streaming structured output",
icon: "\u{1F954}",
features: {
streaming: true,
interactivity: false,
sandbox: false,
constraintLevel: "high",
},
},
];
@@ -0,0 +1,35 @@
"use client";
import { useState } from "react";
import { RENDER_MODES, type RenderMode } from "./types";
const STORAGE_KEY = "showcase-render-mode";
const VALID_MODES: Set<string> = new Set(RENDER_MODES);
/**
* Manages the active render mode with localStorage persistence.
*
* Note: Agent context forwarding (useAgentContext) is omitted here because
* the npm-published @copilotkit/react-core may not export it yet, which
* causes prerender failures during Docker builds. The backend render_mode
* middleware falls back to "tool-based" when no context is present.
*/
export function useRenderMode() {
const [mode, setMode] = useState<RenderMode>(() => {
if (typeof window !== "undefined") {
const stored = localStorage.getItem(STORAGE_KEY);
return VALID_MODES.has(stored ?? "")
? (stored as RenderMode)
: "tool-based";
}
return "tool-based";
});
const setAndPersist = (newMode: RenderMode) => {
setMode(newMode);
localStorage.setItem(STORAGE_KEY, newMode);
};
return { mode, setMode: setAndPersist };
}
@@ -0,0 +1,68 @@
import type { SalesTodo } from "../../types";
const STAGE_COLORS: Record<SalesTodo["stage"], 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",
};
export interface DealCardProps {
deal: SalesTodo;
}
export function DealCard({ deal }: DealCardProps) {
return (
<div
data-testid="deal-card"
className={`rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 transition-all duration-150 ${
deal.completed ? "opacity-60" : ""
}`}
>
<div className="flex items-start justify-between gap-2">
{/* Title */}
<h3
className={`text-sm font-semibold leading-snug break-words ${
deal.completed
? "text-[var(--muted-foreground)] line-through"
: "text-[var(--foreground)]"
}`}
>
{deal.title}
</h3>
{/* Completion indicator */}
<span
data-testid="completion-indicator"
className={`mt-0.5 h-2.5 w-2.5 shrink-0 rounded-full ${
deal.completed ? "bg-[var(--muted-foreground)]" : "bg-green-500"
}`}
/>
</div>
{/* Stage badge + value */}
<div className="mt-2 flex items-center gap-2 flex-wrap">
<span
data-testid="stage-badge"
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STAGE_COLORS[deal.stage]}`}
>
{deal.stage}
</span>
<span className="text-sm font-semibold text-[var(--foreground)]">
${deal.value.toLocaleString()}
</span>
</div>
{/* Meta: assignee + due date */}
<div className="mt-2 flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
{deal.assignee && <span>{deal.assignee}</span>}
{deal.dueDate && <span>Due {deal.dueDate}</span>}
</div>
</div>
);
}
@@ -0,0 +1,80 @@
import { useAgent } from "@copilotkit/react-core/v2";
import { TodoList } from "./todo-list";
import type { SalesTodo } from "../../types";
import { SALES_STAGES } from "../../types";
interface SalesDashboardProps {
agentId?: string;
}
export function SalesDashboard({ agentId }: SalesDashboardProps) {
const { agent } = useAgent(agentId ? { agentId } : undefined);
const todos: SalesTodo[] = agent.state?.todos || [];
const onUpdate = (updatedTodos: SalesTodo[]) => {
agent.setState({ todos: updatedTodos });
};
// Pipeline summary
const totalValue = todos.reduce((sum, t) => sum + t.value, 0);
const byStage = SALES_STAGES.reduce(
(acc, stage) => {
const stageTodos = todos.filter((t) => t.stage === stage);
acc[stage] = {
count: stageTodos.length,
value: stageTodos.reduce((sum, t) => sum + t.value, 0),
};
return acc;
},
{} as Record<string, { count: number; value: number }>,
);
return (
<div className="h-full overflow-y-auto bg-[var(--background)]">
<div className="max-w-5xl mx-auto px-8 py-10 h-full">
{/* Pipeline Summary */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-[var(--foreground)] mb-4">
Sales Pipeline
</h1>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div 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">
Total Pipeline
</p>
<p className="text-2xl font-bold text-[var(--foreground)] mt-1">
${totalValue.toLocaleString()}
</p>
</div>
{SALES_STAGES.filter((s) => !s.startsWith("closed")).map(
(stage) => (
<div
key={stage}
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">
{stage.charAt(0).toUpperCase() + stage.slice(1)}
</p>
<p className="text-lg font-semibold text-[var(--foreground)] mt-1">
{byStage[stage]?.count || 0} deals
</p>
<p className="text-sm text-[var(--muted-foreground)]">
${(byStage[stage]?.value || 0).toLocaleString()}
</p>
</div>
),
)}
</div>
</div>
{/* Todo List */}
<TodoList
todos={todos}
onUpdate={onUpdate}
isAgentRunning={agent.isRunning}
/>
</div>
</div>
);
}
@@ -0,0 +1,44 @@
export interface MetricCardProps {
label: string;
value: string;
trend?: {
direction: "up" | "down" | "neutral";
percentage: number;
};
}
const TREND_STYLES: Record<
"up" | "down" | "neutral",
{ color: string; arrow: string }
> = {
up: { color: "text-green-600 dark:text-green-400", arrow: "\u2191" },
down: { color: "text-red-600 dark:text-red-400", arrow: "\u2193" },
neutral: {
color: "text-[var(--muted-foreground)]",
arrow: "\u2192",
},
};
export function MetricCard({ label, value, trend }: MetricCardProps) {
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="trend-indicator"
className={`text-sm font-medium mt-1 ${TREND_STYLES[trend.direction].color}`}
>
{TREND_STYLES[trend.direction].arrow} {trend.percentage}%
</p>
)}
</div>
);
}
@@ -0,0 +1,72 @@
import { DealCard } from "./deal-card";
import type { SalesTodo } from "../../types";
import { SALES_STAGES } from "../../types";
const STAGE_LABELS: Record<SalesTodo["stage"], string> = {
prospect: "Prospect",
qualified: "Qualified",
proposal: "Proposal",
negotiation: "Negotiation",
"closed-won": "Closed Won",
"closed-lost": "Closed Lost",
};
export interface PipelineProps {
deals: SalesTodo[];
}
export function Pipeline({ deals }: PipelineProps) {
const dealsByStage = SALES_STAGES.reduce(
(acc, stage) => {
acc[stage] = deals.filter((d) => d.stage === stage);
return acc;
},
{} as Record<SalesTodo["stage"], SalesTodo[]>,
);
return (
<div
data-testid="pipeline-board"
className="flex gap-4 overflow-x-auto pb-4"
>
{SALES_STAGES.map((stage) => {
const stageDeals = dealsByStage[stage];
const totalValue = stageDeals.reduce((sum, d) => sum + d.value, 0);
return (
<section
key={stage}
aria-label={`${STAGE_LABELS[stage]} column`}
className="flex-shrink-0 w-64 min-w-[16rem]"
>
{/* Column header */}
<div className="mb-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold text-[var(--foreground)]">
{STAGE_LABELS[stage]}
</h3>
<span className="inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--secondary)] px-2 py-0.5 text-xs font-semibold text-[var(--secondary-foreground)]">
{stageDeals.length}
</span>
</div>
<p className="text-xs text-[var(--muted-foreground)] mt-0.5">
${totalValue.toLocaleString()}
</p>
</div>
{/* Cards */}
<div className="space-y-3">
{stageDeals.length === 0 ? (
<div className="text-center text-xs rounded-lg border-2 border-dashed border-[var(--border)] p-4 text-[var(--muted-foreground)]">
No deals
</div>
) : (
stageDeals.map((deal) => <DealCard key={deal.id} deal={deal} />)
)}
</div>
</section>
);
})}
</div>
);
}
@@ -0,0 +1,151 @@
import { useState } from "react";
import type { SalesTodo } from "../../types";
const STAGE_COLORS: Record<SalesTodo["stage"], 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",
};
interface TodoCardProps {
todo: SalesTodo;
onToggleCompleted: (todo: SalesTodo) => void;
onDelete: (todo: SalesTodo) => void;
onUpdateTitle: (todoId: string, title: string) => void;
onUpdateStage: (todoId: string, stage: SalesTodo["stage"]) => void;
onUpdateValue: (todoId: string, value: number) => void;
}
export function TodoCard({
todo,
onToggleCompleted,
onDelete,
onUpdateTitle,
onUpdateStage,
onUpdateValue,
}: TodoCardProps) {
const [editingTitle, setEditingTitle] = useState(false);
const [titleValue, setTitleValue] = useState(todo.title);
const saveTitle = () => {
if (titleValue.trim()) {
onUpdateTitle(todo.id, titleValue.trim());
}
setEditingTitle(false);
};
return (
<div
data-testid="todo-card"
className={`group relative rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 transition-all duration-150 ${
todo.completed ? "opacity-60" : ""
}`}
>
{/* Delete button - hover reveal */}
<button
onClick={() => onDelete(todo)}
className="absolute top-3 right-3 h-7 w-7 flex items-center justify-center rounded-md opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--secondary)]"
aria-label="Delete deal"
>
<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="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<div className="flex items-start gap-3">
{/* Completion checkbox */}
<button
data-testid="toggle-completed"
onClick={() => onToggleCompleted(todo)}
className={`mt-0.5 h-5 w-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors ${
todo.completed
? "bg-[var(--primary)] border-[var(--primary)]"
: "border-[var(--border)] hover:border-[var(--primary)]"
}`}
>
{todo.completed && (
<svg
className="h-3 w-3 text-[var(--primary-foreground)]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</button>
<div className="flex-1 min-w-0">
{/* Title */}
{editingTitle ? (
<input
type="text"
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
onBlur={saveTitle}
onKeyDown={(e) => {
if (e.key === "Enter") saveTitle();
if (e.key === "Escape") {
setTitleValue(todo.title);
setEditingTitle(false);
}
}}
className="w-full text-sm font-semibold focus:outline-none bg-transparent text-[var(--foreground)] border-b-2 border-[var(--primary)] pb-[2px]"
autoFocus
/>
) : (
<div
onClick={() => {
setTitleValue(todo.title);
setEditingTitle(true);
}}
className={`text-sm font-semibold cursor-text break-words leading-snug ${
todo.completed
? "text-[var(--muted-foreground)] line-through"
: "text-[var(--foreground)]"
}`}
>
{todo.title}
</div>
)}
{/* Stage badge */}
<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 ${STAGE_COLORS[todo.stage]}`}
>
{todo.stage}
</span>
<span className="text-sm font-semibold text-[var(--foreground)]">
${todo.value.toLocaleString()}
</span>
</div>
{/* Meta: assignee + due date */}
<div className="mt-2 flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
{todo.assignee && <span>{todo.assignee}</span>}
{todo.dueDate && <span>Due {todo.dueDate}</span>}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,88 @@
import { TodoCard } from "./todo-card";
import type { SalesTodo } from "../../types";
interface TodoColumnProps {
title: string;
todos: SalesTodo[];
emptyMessage: string;
showAddButton?: boolean;
onAddTodo?: () => void;
onToggleCompleted: (todo: SalesTodo) => void;
onDelete: (todo: SalesTodo) => void;
onUpdateTitle: (todoId: string, title: string) => void;
onUpdateStage: (todoId: string, stage: SalesTodo["stage"]) => void;
onUpdateValue: (todoId: string, value: number) => void;
isAgentRunning: boolean;
}
export function TodoColumn({
title,
todos,
emptyMessage,
showAddButton = false,
onAddTodo,
onToggleCompleted,
onDelete,
onUpdateTitle,
onUpdateStage,
onUpdateValue,
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>
<span className="inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--secondary)] px-2.5 py-0.5 text-xs font-semibold text-[var(--secondary-foreground)]">
{todos.length}
</span>
</div>
{showAddButton && onAddTodo && (
<button
onClick={onAddTodo}
disabled={isAgentRunning}
aria-label="Add new deal"
className="h-8 w-8 flex items-center justify-center rounded-md hover:bg-[var(--secondary)] transition-colors disabled:opacity-50"
>
<svg
className="h-4 w-4 text-[var(--foreground)]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
)}
</div>
{/* Cards */}
<div className="space-y-3">
{todos.length === 0 ? (
<div className="text-center text-sm rounded-lg 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}
onToggleCompleted={onToggleCompleted}
onDelete={onDelete}
onUpdateTitle={onUpdateTitle}
onUpdateStage={onUpdateStage}
onUpdateValue={onUpdateValue}
/>
))
)}
</div>
</section>
);
}
@@ -0,0 +1,102 @@
import { TodoColumn } from "./todo-column";
import type { SalesTodo } from "../../types";
interface TodoListProps {
todos: SalesTodo[];
onUpdate: (todos: SalesTodo[]) => void;
isAgentRunning: boolean;
}
export function TodoList({ todos, onUpdate, isAgentRunning }: TodoListProps) {
const activeTodos = todos.filter((t) => !t.completed);
const completedTodos = todos.filter((t) => t.completed);
const toggleCompleted = (todo: SalesTodo) => {
const updated = todos.map((t) =>
t.id === todo.id ? { ...t, completed: !t.completed } : t,
);
onUpdate(updated);
};
const deleteTodo = (todo: SalesTodo) => {
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 updateStage = (todoId: string, stage: SalesTodo["stage"]) => {
const updated = todos.map((t) => (t.id === todoId ? { ...t, stage } : t));
onUpdate(updated);
};
const updateValue = (todoId: string, value: number) => {
const updated = todos.map((t) => (t.id === todoId ? { ...t, value } : t));
onUpdate(updated);
};
const addTodo = () => {
const newTodo: SalesTodo = {
id: crypto.randomUUID(),
title: "New Deal",
stage: "prospect",
value: 0,
dueDate: new Date().toISOString().split("T")[0],
assignee: "",
completed: false,
};
onUpdate([...todos, newTodo]);
};
if (!todos || todos.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-64 gap-4">
<div className="text-5xl">{"\uD83D\uDCBC"}</div>
<p className="text-base font-semibold text-[var(--foreground)]">
No deals yet
</p>
<p className="text-sm text-[var(--muted-foreground)]">
Create your first deal to get started
</p>
<button
onClick={addTodo}
disabled={isAgentRunning}
className="mt-2 px-4 py-2 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
Add a deal
</button>
</div>
);
}
return (
<div className="flex gap-8 h-full">
<TodoColumn
title="Active Deals"
todos={activeTodos}
emptyMessage="No active deals"
showAddButton
onAddTodo={addTodo}
onToggleCompleted={toggleCompleted}
onDelete={deleteTodo}
onUpdateTitle={updateTitle}
onUpdateStage={updateStage}
onUpdateValue={updateValue}
isAgentRunning={isAgentRunning}
/>
<TodoColumn
title="Closed"
todos={completedTodos}
emptyMessage="No closed deals yet"
onToggleCompleted={toggleCompleted}
onDelete={deleteTodo}
onUpdateTitle={updateTitle}
onUpdateStage={updateStage}
onUpdateValue={updateValue}
isAgentRunning={isAgentRunning}
/>
</div>
);
}
@@ -0,0 +1,120 @@
import { useEffect, useRef } from "react";
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 as Record<string, unknown>) : [];
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 ? (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-[var(--muted)] border-t-[var(--foreground)]" />
) : (
<svg
className="h-3 w-3 text-emerald-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
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}
<svg
className="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
<span
className="font-medium"
style={{ fontFamily: "var(--font-code)" }}
>
{name}
</span>
<svg
className="h-3 w-3 ml-auto transition-transform group-open:rotate-180"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</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}
<svg
className="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
<span
className="font-medium"
style={{ fontFamily: "var(--font-code)" }}
>
{name}
</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,128 @@
import React from "react";
export function getWeatherGradient(conditions: string): string {
const c = conditions.toLowerCase();
if (c.includes("clear") || c.includes("sunny"))
return "linear-gradient(135deg, #667eea 0%, #764ba2 100%)";
if (c.includes("rain") || c.includes("storm"))
return "linear-gradient(135deg, #4A5568 0%, #2D3748 100%)";
if (c.includes("cloud") || c.includes("overcast"))
return "linear-gradient(135deg, #718096 0%, #4A5568 100%)";
if (c.includes("snow"))
return "linear-gradient(135deg, #63B3ED 0%, #4299E1 100%)";
return "linear-gradient(135deg, #667eea 0%, #764ba2 100%)";
}
export function getWeatherIcon(conditions: string): string {
const c = conditions.toLowerCase();
if (c.includes("clear") || c.includes("sunny")) return "\u2600\uFE0F";
if (c.includes("rain") || c.includes("drizzle")) return "\uD83C\uDF27\uFE0F";
if (c.includes("snow")) return "\u2744\uFE0F";
if (c.includes("thunderstorm")) return "\u26C8\uFE0F";
if (c.includes("cloud") || c.includes("overcast")) return "\u2601\uFE0F";
if (c.includes("fog")) return "\uD83C\uDF2B\uFE0F";
return "\uD83C\uDF24\uFE0F";
}
export interface WeatherCardProps {
location: string;
temperature?: number;
conditions?: string;
humidity?: number;
windSpeed?: number;
feelsLike?: number;
city?: string;
loading?: boolean;
}
export function WeatherCard({
location,
temperature = 22,
conditions = "Clear",
humidity = 55,
windSpeed = 12,
feelsLike,
city,
loading = false,
}: WeatherCardProps) {
if (loading) {
return (
<div
className="flex items-center gap-3 px-5 py-4 rounded-2xl max-w-sm"
style={{
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
}}
>
<div className="animate-pulse text-2xl">{"\uD83C\uDF24\uFE0F"}</div>
<div>
<p className="text-white font-medium text-sm">Checking weather...</p>
<p className="text-white/60 text-xs">{location}</p>
</div>
</div>
);
}
const temp = temperature;
const cond = conditions;
const hum = humidity;
const wind = windSpeed;
const feels = feelsLike ?? temp;
return (
<div
data-testid="weather-card"
className="rounded-2xl overflow-hidden shadow-xl my-3"
style={{ background: getWeatherGradient(cond), width: "320px" }}
>
<div className="px-5 pt-4 pb-3">
<div className="flex items-start justify-between">
<div>
<h3 className="text-base font-bold text-white capitalize tracking-tight">
{city || location}
</h3>
<p className="text-white/50 text-[10px] font-medium uppercase tracking-wider">
Current Weather
</p>
</div>
<span className="text-4xl leading-none">{getWeatherIcon(cond)}</span>
</div>
<div className="mt-3 flex items-baseline gap-1.5">
<span className="text-4xl font-extralight text-white tracking-tighter">
{temp}&deg;
</span>
<span className="text-white/40 text-xs">
{((temp * 9) / 5 + 32).toFixed(0)}&deg;F
</span>
</div>
<p className="text-white/70 text-xs font-medium capitalize mt-0.5">
{cond}
</p>
</div>
<div
className="grid grid-cols-3 text-center py-2.5 px-5"
style={{ background: "rgba(0,0,0,0.15)" }}
>
<div>
<p className="text-white/40 text-[9px] font-medium uppercase tracking-wider">
Humidity
</p>
<p className="text-white text-xs font-semibold mt-0.5">{hum}%</p>
</div>
<div className="border-x border-white/10">
<p className="text-white/40 text-[9px] font-medium uppercase tracking-wider">
Wind
</p>
<p className="text-white text-xs font-semibold mt-0.5">{wind} mph</p>
</div>
<div>
<p className="text-white/40 text-[9px] font-medium uppercase tracking-wider">
Feels Like
</p>
<p className="text-white text-xs font-semibold mt-0.5">
{feels}&deg;
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,80 @@
import { z } from "zod";
import {
useComponent,
useFrontendTool,
useHumanInTheLoop,
useDefaultRenderTool,
} from "@copilotkit/react-core/v2";
import { PieChart, PieChartProps } from "../components/charts/pie-chart";
import { BarChart, BarChartProps } from "../components/charts/bar-chart";
import { MeetingTimePicker } from "../components/meeting-time-picker";
import { ToolReasoning } from "../components/tool-reasoning";
export const useShowcaseHooks = () => {
// 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", // 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)
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");
if (isDark) {
document.documentElement.classList.remove("dark");
document.documentElement.classList.add("light");
} else {
document.documentElement.classList.remove("light");
document.documentElement.classList.add("dark");
}
},
},
[],
);
};
@@ -0,0 +1,57 @@
/**
* 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).
*/
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
export const useShowcaseSuggestions = (options?: { showcaseMode?: string }) => {
const showcase = options?.showcaseMode;
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: "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",
});
};
+50
View File
@@ -0,0 +1,50 @@
export const SALES_STAGES = [
"prospect",
"qualified",
"proposal",
"negotiation",
"closed-won",
"closed-lost",
] as const;
export type SalesStage = (typeof SALES_STAGES)[number];
export interface SalesTodo {
id: string;
title: string;
stage: SalesStage;
value: number;
dueDate: string;
assignee: string;
completed: boolean;
}
export const INITIAL_SALES_TODOS: SalesTodo[] = [
{
id: "1",
title: "Follow up with Acme Corp",
stage: "qualified",
value: 50000,
dueDate: "2026-04-20",
assignee: "Alice",
completed: false,
},
{
id: "2",
title: "Send proposal to TechStart",
stage: "proposal",
value: 120000,
dueDate: "2026-04-18",
assignee: "Bob",
completed: false,
},
{
id: "3",
title: "Schedule demo for GlobalInc",
stage: "prospect",
value: 75000,
dueDate: "2026-04-22",
assignee: "Alice",
completed: false,
},
];
@@ -0,0 +1,90 @@
import { describe, it, expect, vi } from "vitest";
import {
generateA2uiImpl,
buildA2uiOperationsFromToolCall,
RENDER_A2UI_TOOL_SCHEMA,
CUSTOM_CATALOG_ID,
} from "../generate-a2ui";
describe("generateA2uiImpl", () => {
it("returns system prompt from context entries", () => {
const result = generateA2uiImpl({
messages: [],
contextEntries: [
{ value: "Component catalog info" },
{ value: "More context" },
],
});
expect(result.systemPrompt).toContain("Component catalog info");
expect(result.systemPrompt).toContain("More context");
});
it("filters empty/missing context values", () => {
const result = generateA2uiImpl({
messages: [],
contextEntries: [{ value: "" }, { noValue: true }, { value: "keep" }],
});
expect(result.systemPrompt).toBe("keep");
});
it("returns tool schema and choice", () => {
const result = generateA2uiImpl({ messages: [] });
expect(result.toolSchema.name).toBe("render_a2ui");
expect(result.toolChoice).toBe("render_a2ui");
});
it("passes messages through", () => {
const msgs = [{ role: "user", content: "hello" }];
const result = generateA2uiImpl({ messages: msgs });
expect(result.messages).toBe(msgs);
});
it("returns the default catalog ID", () => {
const result = generateA2uiImpl({ messages: [] });
expect(result.catalogId).toBe(CUSTOM_CATALOG_ID);
});
});
describe("buildA2uiOperationsFromToolCall", () => {
it("builds create_surface + update_components", () => {
const result = buildA2uiOperationsFromToolCall({
surfaceId: "s1",
catalogId: "cat1",
components: [{ id: "root", component: "Title" }],
});
expect(result.a2ui_operations).toHaveLength(2);
expect(result.a2ui_operations[0].type).toBe("create_surface");
expect(result.a2ui_operations[1].type).toBe("update_components");
});
it("includes update_data_model when data provided", () => {
const result = buildA2uiOperationsFromToolCall({
surfaceId: "s1",
catalogId: "cat1",
components: [{ id: "root" }],
data: { key: "value" },
});
expect(result.a2ui_operations).toHaveLength(3);
expect(result.a2ui_operations[2].type).toBe("update_data_model");
});
it("defaults surfaceId and catalogId", () => {
const result = buildA2uiOperationsFromToolCall({ components: [] });
expect(result.a2ui_operations[0].surfaceId).toBe("dynamic-surface");
expect(result.a2ui_operations[0].catalogId).toBe(CUSTOM_CATALOG_ID);
});
it("warns on empty components", () => {
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
buildA2uiOperationsFromToolCall({
surfaceId: "s1",
catalogId: "c1",
components: [],
});
expect(spy).toHaveBeenCalledWith(
expect.stringContaining("empty components"),
"s1",
);
spy.mockRestore();
});
});
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { getWeatherImpl } from "../get-weather";
describe("getWeatherImpl", () => {
it("returns all required fields", () => {
const result = getWeatherImpl("Tokyo");
expect(result).toHaveProperty("city");
expect(result).toHaveProperty("temperature");
expect(result).toHaveProperty("humidity");
expect(result).toHaveProperty("wind_speed");
expect(result).toHaveProperty("feels_like");
expect(result).toHaveProperty("conditions");
});
it("passes city name through", () => {
expect(getWeatherImpl("San Francisco").city).toBe("San Francisco");
});
it("is deterministic for the same city", () => {
const r1 = getWeatherImpl("Tokyo");
const r2 = getWeatherImpl("Tokyo");
expect(r1.temperature).toBe(r2.temperature);
expect(r1.conditions).toBe(r2.conditions);
});
it("produces different results for different cities", () => {
const r1 = getWeatherImpl("Tokyo");
const r2 = getWeatherImpl("London");
expect(r1).not.toEqual(r2);
});
it("temperature is within expected range (20-95)", () => {
const result = getWeatherImpl("Berlin");
expect(result.temperature).toBeGreaterThanOrEqual(20);
expect(result.temperature).toBeLessThanOrEqual(95);
});
it("humidity is within expected range (30-90)", () => {
const result = getWeatherImpl("Paris");
expect(result.humidity).toBeGreaterThanOrEqual(30);
expect(result.humidity).toBeLessThanOrEqual(90);
});
it("wind_speed is within expected range (2-30)", () => {
const result = getWeatherImpl("Sydney");
expect(result.wind_speed).toBeGreaterThanOrEqual(2);
expect(result.wind_speed).toBeLessThanOrEqual(30);
});
it("conditions is one of the known values", () => {
const known = [
"Sunny",
"Partly Cloudy",
"Cloudy",
"Overcast",
"Light Rain",
"Heavy Rain",
"Thunderstorm",
"Snow",
"Foggy",
"Windy",
];
const result = getWeatherImpl("Miami");
expect(known).toContain(result.conditions);
});
it("is case-insensitive for seed (lowercased internally)", () => {
const r1 = getWeatherImpl("tokyo");
const r2 = getWeatherImpl("TOKYO");
expect(r1.temperature).toBe(r2.temperature);
});
it("feels_like is within ±5 of temperature", () => {
const result = getWeatherImpl("Berlin");
expect(
Math.abs(result.feels_like - result.temperature),
).toBeLessThanOrEqual(5);
});
});
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { queryDataImpl } from "../query-data";
describe("queryDataImpl", () => {
it("returns an array", () => {
expect(Array.isArray(queryDataImpl("test"))).toBe(true);
});
it("returns 66 rows (11 categories x 6 months)", () => {
expect(queryDataImpl("test")).toHaveLength(66);
});
it("rows have expected columns", () => {
const row = queryDataImpl("test")[0];
expect(row).toHaveProperty("date");
expect(row).toHaveProperty("category");
expect(row).toHaveProperty("subcategory");
expect(row).toHaveProperty("amount");
expect(row).toHaveProperty("type");
expect(row).toHaveProperty("notes");
});
it("returns same data regardless of query", () => {
const r1 = queryDataImpl("revenue");
const r2 = queryDataImpl("expenses");
expect(r1).toEqual(r2);
});
it("is deterministic (seeded RNG)", () => {
const r1 = queryDataImpl("test");
const r2 = queryDataImpl("test");
expect(r1).toEqual(r2);
});
it("categories are Revenue or Expenses", () => {
const rows = queryDataImpl("test");
const categories = new Set(rows.map((r) => r.category));
expect(categories).toEqual(new Set(["Revenue", "Expenses"]));
});
it("types are income or expense", () => {
const rows = queryDataImpl("test");
const types = new Set(rows.map((r) => r.type));
expect(types).toEqual(new Set(["income", "expense"]));
});
it("dates are in 2026", () => {
const rows = queryDataImpl("test");
for (const row of rows) {
expect(row.date).toMatch(/^2026-/);
}
});
it("amount is a numeric string", () => {
const rows = queryDataImpl("test");
for (const row of rows) {
expect(Number(row.amount)).not.toBeNaN();
expect(Number(row.amount)).toBeGreaterThan(0);
}
});
});
@@ -0,0 +1,124 @@
import { describe, it, expect } from "vitest";
import {
manageSalesTodosImpl,
getSalesTodosImpl,
INITIAL_SALES_TODOS,
} from "../sales-todos";
describe("INITIAL_SALES_TODOS", () => {
it("has 3 items", () => {
expect(INITIAL_SALES_TODOS).toHaveLength(3);
});
it("has fixed IDs", () => {
expect(INITIAL_SALES_TODOS[0].id).toBe("st-001");
expect(INITIAL_SALES_TODOS[1].id).toBe("st-002");
expect(INITIAL_SALES_TODOS[2].id).toBe("st-003");
});
});
describe("manageSalesTodosImpl", () => {
it("assigns UUID to todos missing ID", () => {
const result = manageSalesTodosImpl([{ title: "New deal" }]);
expect(result[0].id).toBeTruthy();
expect(result[0].id.length).toBeGreaterThan(0);
});
it("preserves existing IDs", () => {
const result = manageSalesTodosImpl([{ id: "keep-me", title: "Deal" }]);
expect(result[0].id).toBe("keep-me");
});
it("provides defaults for missing fields", () => {
const result = manageSalesTodosImpl([{ title: "Minimal" }]);
expect(result[0].stage).toBe("prospect");
expect(result[0].value).toBe(0);
expect(result[0].completed).toBe(false);
expect(result[0].dueDate).toBe("");
expect(result[0].assignee).toBe("");
});
it("preserves provided fields", () => {
const result = manageSalesTodosImpl([
{
id: "x",
title: "Big Deal",
stage: "negotiation",
value: 50000,
dueDate: "2026-05-01",
assignee: "Alice",
completed: true,
},
]);
expect(result[0].title).toBe("Big Deal");
expect(result[0].stage).toBe("negotiation");
expect(result[0].value).toBe(50000);
expect(result[0].completed).toBe(true);
});
it("handles empty array", () => {
const result = manageSalesTodosImpl([]);
expect(result).toEqual([]);
});
it("handles multiple todos", () => {
const result = manageSalesTodosImpl([
{ title: "A" },
{ title: "B" },
{ title: "C" },
]);
expect(result).toHaveLength(3);
// Each should get a unique ID
const ids = new Set(result.map((r) => r.id));
expect(ids.size).toBe(3);
});
it("replaces empty string id with a generated UUID", () => {
const result = manageSalesTodosImpl([{ id: "", title: "x" }]);
expect(result[0].id).toBeTruthy();
expect(result[0].id).not.toBe("");
expect(result[0].id.length).toBeGreaterThan(0);
});
});
describe("getSalesTodosImpl", () => {
it("returns initial todos when undefined", () => {
const result = getSalesTodosImpl(undefined);
expect(result).toHaveLength(3);
expect(result[0].id).toBe("st-001");
});
it("returns initial todos when null", () => {
const result = getSalesTodosImpl(null);
expect(result).toHaveLength(3);
});
it("returns empty array when given empty array", () => {
const result = getSalesTodosImpl([]);
// empty array means user cleared all todos — return empty, not defaults
expect(result).toHaveLength(0);
});
it("returns provided todos when non-empty", () => {
const todos = [
{
id: "1",
title: "Test",
stage: "prospect" as const,
value: 100,
dueDate: "",
assignee: "",
completed: false,
},
];
const result = getSalesTodosImpl(todos);
expect(result).toHaveLength(1);
expect(result[0].title).toBe("Test");
});
it("returns a copy, not the original INITIAL_SALES_TODOS reference", () => {
const result = getSalesTodosImpl(undefined);
expect(result).not.toBe(INITIAL_SALES_TODOS);
expect(result).toEqual(INITIAL_SALES_TODOS);
});
});
@@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { scheduleMeetingImpl } from "../schedule-meeting";
describe("scheduleMeetingImpl", () => {
it("returns pending_approval status", () => {
expect(scheduleMeetingImpl("discuss roadmap").status).toBe(
"pending_approval",
);
});
it("includes the reason", () => {
expect(scheduleMeetingImpl("quarterly review").reason).toBe(
"quarterly review",
);
});
it("uses default 30-minute duration", () => {
expect(scheduleMeetingImpl("sync").duration_minutes).toBe(30);
});
it("accepts custom duration", () => {
expect(scheduleMeetingImpl("deep dive", 60).duration_minutes).toBe(60);
});
it("includes a message", () => {
const result = scheduleMeetingImpl("onboarding", 45);
expect(result.message).toContain("onboarding");
expect(result.message).toContain("45");
});
});
@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { searchFlightsImpl } from "../search-flights";
import type { Flight } from "../types";
describe("searchFlightsImpl", () => {
const mockFlight: Flight = {
airline: "Test Air",
airlineLogo: "https://example.com/logo.png",
flightNumber: "TA100",
origin: "SFO",
destination: "JFK",
date: "Tue, Apr 15",
departureTime: "08:00",
arrivalTime: "16:00",
duration: "5h",
status: "On Time",
statusColor: "#22c55e",
price: "$299",
currency: "USD",
};
it("returns flights and schema", () => {
const result = searchFlightsImpl([mockFlight]);
expect(result).toHaveProperty("flights");
expect(result).toHaveProperty("schema");
});
it("passes flights through unchanged", () => {
const result = searchFlightsImpl([mockFlight]);
expect(result.flights).toHaveLength(1);
expect(result.flights[0]).toEqual(mockFlight);
});
it("returns empty schema object", () => {
const result = searchFlightsImpl([mockFlight]);
expect(result.schema).toEqual({});
});
it("handles empty flights array", () => {
const result = searchFlightsImpl([]);
expect(result.flights).toHaveLength(0);
});
it("handles multiple flights", () => {
const result = searchFlightsImpl([
mockFlight,
{ ...mockFlight, flightNumber: "TA200" },
]);
expect(result.flights).toHaveLength(2);
expect(result.flights[1].flightNumber).toBe("TA200");
});
});
@@ -0,0 +1,98 @@
/**
* Dynamic A2UI generation via secondary LLM call.
* TypeScript equivalent of showcase/shared/python/tools/generate_a2ui.py.
*/
export const CUSTOM_CATALOG_ID = "copilotkit://app-dashboard-catalog";
export const DESIGN_A2UI_SURFACE_TOOL_SCHEMA = {
name: "_design_a2ui_surface",
description: "Render a dynamic A2UI v0.9 surface.",
parameters: {
type: "object",
properties: {
surfaceId: { type: "string", description: "Unique surface identifier." },
catalogId: { type: "string", description: "The catalog ID." },
components: {
type: "array",
items: { type: "object" },
description: "A2UI v0.9 component array (flat format).",
},
data: {
type: "object",
description: "Optional initial data model for the surface.",
},
},
required: ["surfaceId", "catalogId", "components"],
},
} as const;
export interface GenerateA2UIInput {
messages: Array<Record<string, unknown>>;
contextEntries?: Array<Record<string, unknown>>;
}
export interface GenerateA2UIResult {
systemPrompt: string;
toolSchema: typeof DESIGN_A2UI_SURFACE_TOOL_SCHEMA;
toolChoice: string;
messages: Array<Record<string, unknown>>;
catalogId: string;
}
export function generateA2uiImpl(input: GenerateA2UIInput): GenerateA2UIResult {
const contextText = (input.contextEntries ?? [])
.filter(
(e): e is Record<string, unknown> & { value: string } =>
typeof e === "object" &&
typeof e.value === "string" &&
e.value.length > 0,
)
.map((e) => e.value)
.join("\n\n");
return {
systemPrompt: contextText,
toolSchema: DESIGN_A2UI_SURFACE_TOOL_SCHEMA,
toolChoice: "_design_a2ui_surface",
messages: input.messages,
catalogId: CUSTOM_CATALOG_ID,
};
}
export interface A2UIOperation {
type: "create_surface" | "update_components" | "update_data_model";
surfaceId: string;
catalogId?: string;
components?: Array<Record<string, unknown>>;
data?: Record<string, unknown>;
}
export function buildA2uiOperationsFromToolCall(
args: Record<string, unknown>,
): {
a2ui_operations: A2UIOperation[];
} {
const surfaceId = (args.surfaceId as string) ?? "dynamic-surface";
const catalogId = (args.catalogId as string) ?? CUSTOM_CATALOG_ID;
const components = (args.components as Array<Record<string, unknown>>) ?? [];
const data = args.data as Record<string, unknown> | undefined;
if (components.length === 0) {
console.warn(
"buildA2uiOperationsFromToolCall: empty components for surface",
surfaceId,
);
}
const ops: A2UIOperation[] = [
{ type: "create_surface", surfaceId, catalogId },
{ type: "update_components", surfaceId, components },
];
if (data) {
ops.push({ type: "update_data_model", surfaceId, data });
}
return { a2ui_operations: ops };
}
@@ -0,0 +1,67 @@
/**
* Mock weather data tool implementation.
*
* TypeScript equivalent of showcase/shared/python/tools/get_weather.py.
* Uses a simple seed derived from the city name so repeated calls for
* the same city return consistent results within a session.
*/
import { WeatherResult } from "./types";
const CONDITIONS = [
"Sunny",
"Partly Cloudy",
"Cloudy",
"Overcast",
"Light Rain",
"Heavy Rain",
"Thunderstorm",
"Snow",
"Foggy",
"Windy",
];
/**
* Simple seeded pseudo-random number generator (mulberry32).
* Produces deterministic output for a given seed so the same city
* always returns the same weather within a process lifetime.
*/
function seededRandom(seed: number): () => number {
let t = seed;
return () => {
t = (t + 0x6d2b79f5) | 0;
let v = Math.imul(t ^ (t >>> 15), 1 | t);
v = (v + Math.imul(v ^ (v >>> 7), 61 | v)) ^ v;
return ((v ^ (v >>> 14)) >>> 0) / 4294967296;
};
}
function hashString(s: string): number {
let hash = 0;
for (let i = 0; i < s.length; i++) {
hash = (Math.imul(31, hash) + s.charCodeAt(i)) | 0;
}
return hash;
}
function randInt(rng: () => number, min: number, max: number): number {
return Math.floor(rng() * (max - min + 1)) + min;
}
function randChoice<T>(rng: () => number, arr: T[]): T {
return arr[Math.floor(rng() * arr.length)];
}
export function getWeatherImpl(city: string): WeatherResult {
const rng = seededRandom(hashString(city.toLowerCase()));
const temperature = randInt(rng, 20, 95);
return {
city,
temperature,
humidity: randInt(rng, 30, 90),
wind_speed: randInt(rng, 2, 30),
feels_like: temperature + randInt(rng, -5, 5),
conditions: randChoice(rng, CONDITIONS),
};
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Shared TypeScript tool implementations for CopilotKit showcase.
*
* Pure functions with no framework imports — consumed by
* langgraph-typescript, mastra, claude-sdk-typescript, and any
* future TypeScript-based showcase packages.
*/
export { getWeatherImpl } from "./get-weather";
export { queryDataImpl } from "./query-data";
export type { DataRow } from "./query-data";
export {
manageSalesTodosImpl,
getSalesTodosImpl,
INITIAL_SALES_TODOS,
} from "./sales-todos";
export { searchFlightsImpl } from "./search-flights";
export { scheduleMeetingImpl } from "./schedule-meeting";
export type { ScheduleMeetingResult } from "./schedule-meeting";
export {
generateA2uiImpl,
buildA2uiOperationsFromToolCall,
RENDER_A2UI_TOOL_SCHEMA,
CUSTOM_CATALOG_ID,
} from "./generate-a2ui";
export type {
GenerateA2UIInput,
GenerateA2UIResult,
A2UIOperation,
} from "./generate-a2ui";
export type { SalesTodo, SalesStage, Flight, WeatherResult } from "./types";
@@ -0,0 +1,107 @@
/**
* Query data tool implementation — returns mock financial data.
*
* TypeScript equivalent of showcase/shared/python/tools/query_data.py.
* In the future this could read a CSV, but for TS backend simplicity
* we use generated mock data matching the Python fallback format.
*/
export interface DataRow {
date: string;
category: string;
subcategory: string;
amount: string;
type: string;
notes: string;
}
// Seeded random for deterministic mock data
function seededRandom(seed: number): () => number {
let s = seed;
return () => {
s = (s * 1103515245 + 12345) & 0x7fffffff;
return s / 0x7fffffff;
};
}
function generateMockData(): DataRow[] {
const rand = seededRandom(42);
const categories: Array<{
category: string;
subcategory: string;
type: string;
}> = [
{
category: "Revenue",
subcategory: "Enterprise Subscriptions",
type: "income",
},
{ category: "Revenue", subcategory: "Pro Tier Upgrades", type: "income" },
{ category: "Revenue", subcategory: "API Usage Overages", type: "income" },
{ category: "Revenue", subcategory: "Consulting Services", type: "income" },
{ category: "Revenue", subcategory: "Marketplace Sales", type: "income" },
{
category: "Expenses",
subcategory: "Engineering Salaries",
type: "expense",
},
{ category: "Expenses", subcategory: "Product Team", type: "expense" },
{
category: "Expenses",
subcategory: "AWS Infrastructure",
type: "expense",
},
{ category: "Expenses", subcategory: "Marketing", type: "expense" },
{ category: "Expenses", subcategory: "Customer Success", type: "expense" },
{ category: "Expenses", subcategory: "AI Model Costs", type: "expense" },
];
const notes: Record<string, string> = {
"Enterprise Subscriptions": "3 new enterprise customers",
"Pro Tier Upgrades": "31 upgrades + reduced churn",
"API Usage Overages": "Heavy usage from top-10 accounts",
"Consulting Services": "2 implementation projects",
"Marketplace Sales": "Partner integrations revenue",
"Engineering Salaries": "7 engineers + 2 contractors",
"Product Team": "PM + designers + QA",
"AWS Infrastructure": "Compute + storage + bandwidth",
Marketing: "Paid ads + content + events",
"Customer Success": "3 CSMs + tooling",
"AI Model Costs": "OpenAI + Anthropic API spend",
};
const rows: DataRow[] = [];
const months = ["01", "02", "03", "04", "05", "06"];
for (const month of months) {
for (const cat of categories) {
const baseAmount =
cat.type === "income"
? 15000 + Math.floor(rand() * 35000)
: 8000 + Math.floor(rand() * 40000);
const day = String(1 + Math.floor(rand() * 28)).padStart(2, "0");
rows.push({
date: `2026-${month}-${day}`,
category: cat.category,
subcategory: cat.subcategory,
amount: String(baseAmount),
type: cat.type,
notes: notes[cat.subcategory] ?? "",
});
}
}
return rows;
}
const MOCK_DATA: DataRow[] = generateMockData();
/**
* Query the database. Takes natural language.
*
* Always call before showing a chart or graph. Returns the full
* dataset as a list of row objects.
*/
export function queryDataImpl(_query: string): DataRow[] {
return MOCK_DATA;
}
@@ -0,0 +1,65 @@
/**
* Sales todos tool implementation.
*
* TypeScript equivalent of showcase/shared/python/tools/sales_todos.py.
*/
import { SalesTodo } from "./types";
export const INITIAL_SALES_TODOS: SalesTodo[] = [
{
id: "st-001",
title: "Follow up with Acme Corp on enterprise proposal",
stage: "proposal",
value: 85000,
dueDate: "2026-04-15",
assignee: "Sarah Chen",
completed: false,
},
{
id: "st-002",
title: "Qualify lead from TechFlow demo request",
stage: "prospect",
value: 42000,
dueDate: "2026-04-18",
assignee: "Mike Johnson",
completed: false,
},
{
id: "st-003",
title: "Send contract to DataViz Inc for final review",
stage: "negotiation",
value: 120000,
dueDate: "2026-04-20",
assignee: "Sarah Chen",
completed: false,
},
];
/**
* Assign crypto.randomUUID() to any todos missing an ID, then return
* the updated list.
*/
export function manageSalesTodosImpl(todos: Partial<SalesTodo>[]): SalesTodo[] {
return todos.map((todo) => ({
id: todo.id || crypto.randomUUID(),
title: todo.title ?? "",
stage: todo.stage ?? "prospect",
value: todo.value ?? 0,
dueDate: todo.dueDate ?? "",
assignee: todo.assignee ?? "",
completed: todo.completed ?? false,
}));
}
/**
* Return current todos or initial defaults if none provided.
*/
export function getSalesTodosImpl(
currentTodos?: Partial<SalesTodo>[] | null,
): SalesTodo[] {
if (currentTodos != null) {
return currentTodos.length > 0 ? manageSalesTodosImpl(currentTodos) : [];
}
return [...INITIAL_SALES_TODOS];
}
@@ -0,0 +1,24 @@
/**
* Schedule meeting tool implementation — HITL gated.
*
* TypeScript equivalent of showcase/shared/python/tools/schedule_meeting.py.
*/
export interface ScheduleMeetingResult {
status: "pending_approval";
reason: string;
duration_minutes: number;
message: string;
}
export function scheduleMeetingImpl(
reason: string,
durationMinutes: number = 30,
): ScheduleMeetingResult {
return {
status: "pending_approval",
reason,
duration_minutes: durationMinutes,
message: `Meeting request: ${reason} (${durationMinutes} min). Awaiting user time selection.`,
};
}
@@ -0,0 +1,19 @@
/**
* Search flights tool implementation.
*
* Simple passthrough that wraps flights for AG-UI rendering.
* The frontend GenUI components handle presentation.
*/
import { Flight } from "./types";
/**
* Wrap the provided flights array for consumption by the frontend
* flight-search GenUI component.
*/
export function searchFlightsImpl(flights: Flight[]): {
flights: Flight[];
schema: Record<string, unknown>;
} {
return { flights, schema: {} };
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Shared type definitions for showcase tools.
*
* These mirror the Python types in showcase/shared/python/tools/types.py
* and the frontend types in showcase/shared/frontend/src/types.ts.
*/
export type SalesStage =
| "prospect"
| "qualified"
| "proposal"
| "negotiation"
| "closed-won"
| "closed-lost";
export interface SalesTodo {
id: string;
title: string;
stage: SalesStage;
value: number;
dueDate: string;
assignee: string;
completed: boolean;
}
export interface Flight {
airline: string;
airlineLogo: string;
flightNumber: string;
origin: string;
destination: string;
date: string;
departureTime: string;
arrivalTime: string;
duration: string;
status: string;
statusColor: string;
price: string;
currency: string;
}
export interface WeatherResult {
city: string;
temperature: number;
humidity: number;
wind_speed: number;
feels_like: number;
conditions: string;
}
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
root: "showcase/shared/typescript",
include: ["tools/__tests__/**/*.test.ts"],
},
});