chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
Executable
+96
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)" }
|
||||
]
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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},
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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}°
|
||||
</span>
|
||||
<span className="text-white/40 text-xs">
|
||||
{((temp * 9) / 5 + 32).toFixed(0)}°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}°
|
||||
</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",
|
||||
});
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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: {} };
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user