chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
**/node_modules
|
||||
.next
|
||||
__pycache__
|
||||
*.pyc
|
||||
.git
|
||||
**/vitest.config.ts
|
||||
**/test-setup.ts
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.spec.ts
|
||||
**/*.spec.tsx
|
||||
@@ -0,0 +1,13 @@
|
||||
# API Keys (shared across integrations)
|
||||
OPENAI_API_KEY=replace-with-your-key
|
||||
ANTHROPIC_API_KEY=replace-with-your-key
|
||||
|
||||
# Agent backend URL (for the CopilotKit runtime proxy).
|
||||
# Matches the package's own dev script which binds langgraph_cli on :8123
|
||||
# (see "dev" in package.json). Setting this to :8000 here would cause an
|
||||
# out-of-the-box ECONNREFUSED when a contributor runs `pnpm dev`, since no
|
||||
# process is ever bound there. Generated starters use :8123 as well.
|
||||
AGENT_URL=http://localhost:8123
|
||||
|
||||
# Showcase
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
@@ -0,0 +1,8 @@
|
||||
# Sample multimodal demo binaries (PNG + PDF) must stay as regular
|
||||
# binaries — not LFS pointers — so deploy environments without `git lfs pull`
|
||||
# (Railway, in particular) serve the actual files. LFS tracking for other
|
||||
# PNG/PDFs under this package inherits from the repo-root .gitattributes.
|
||||
public/demo-files/sample.png -filter -diff -merge binary
|
||||
public/demo-files/sample.pdf -filter -diff -merge binary
|
||||
# Voice demo sample audio follows the same convention.
|
||||
public/demo-audio/sample.wav -filter -diff -merge binary
|
||||
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.env.local
|
||||
.env
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.venv/
|
||||
dist/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Shared modules (copied by CI)
|
||||
shared_frontend/
|
||||
@@ -0,0 +1,91 @@
|
||||
# Stage 1: Build Next.js frontend
|
||||
FROM node:22-slim AS frontend
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Python venv with agent deps
|
||||
#
|
||||
# True multi-stage split — the builder carries full `python:3.12` (compilers,
|
||||
# build-essential, dev headers needed to compile wheels that don't ship
|
||||
# pre-built for ``-slim``) and produces a self-contained ``/opt/venv`` that
|
||||
# the runner COPYs in as a single opaque tree. The runner never runs
|
||||
# ``pip install`` itself, so no compiler toolchain bloats the final image.
|
||||
FROM python:3.12.13 AS agent-builder
|
||||
WORKDIR /agent
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH=/opt/venv/bin:$PATH
|
||||
COPY requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Stage 3: Production image with Node.js + Python (runtime only — no pip,
|
||||
# no build tools). Node.js is installed via NodeSource because the package
|
||||
# runs BOTH Next.js (frontend) and the Python agent inside this single image;
|
||||
# entrypoint.sh orchestrates both processes.
|
||||
FROM python:3.12.13-slim AS runner
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create unprivileged runtime user BEFORE any COPY so --chown resolves
|
||||
# by name and so recursive chown over /app is never needed (fast builds).
|
||||
# Mirrors the starter Dockerfile pattern for parity — Railway / any
|
||||
# platform that enforces non-root by policy needs this from the package
|
||||
# image too, not just the generated starter.
|
||||
RUN (groupadd --system --gid 1001 app 2>/dev/null || true) \
|
||||
&& (useradd --system --uid 1001 --gid 1001 --no-create-home app 2>/dev/null || true) \
|
||||
&& mkdir -p /home/app && chown app:app /home/app
|
||||
|
||||
# Python venv (prebuilt in agent-builder stage — no pip in the runner).
|
||||
COPY --chown=app:app --from=agent-builder /opt/venv /opt/venv
|
||||
ENV PATH=/opt/venv/bin:$PATH
|
||||
|
||||
# Next.js build artifacts
|
||||
COPY --chown=app:app --from=frontend /app/.next ./.next
|
||||
COPY --chown=app:app --from=frontend /app/node_modules ./node_modules
|
||||
COPY --chown=app:app --from=frontend /app/package.json ./
|
||||
COPY --chown=app:app --from=frontend /app/public ./public
|
||||
|
||||
# Agent code + config
|
||||
COPY --chown=app:app langgraph.json ./
|
||||
COPY --chown=app:app src/agents/ ./src/agents/
|
||||
|
||||
# Shared Python tools (symlinked in source, copied into build context by CI)
|
||||
COPY --chown=app:app tools/ /app/tools/
|
||||
|
||||
# Shared CVDIAG bootstrap module (single source: showcase/integrations/_shared;
|
||||
# symlinked in source, dereferenced into this build context by the harness
|
||||
# stage_shared step). Lands at /app/_shared; /app is already on PYTHONPATH.
|
||||
COPY --chown=app:app _shared/ ./_shared/
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
# Entrypoint
|
||||
COPY --chown=app:app entrypoint.sh ./
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# Pre-create .langgraph_api (CWD-relative state dir) with app ownership.
|
||||
# langgraph_runtime_inmem/store.py calls `os.makedirs(".langgraph_api",
|
||||
# exist_ok=True)` at import time, which resolves against CWD (/app). WORKDIR
|
||||
# creates /app as root; USER app then can't write there. A fresh container
|
||||
# imports the module, hits PermissionError [Errno 13], crashes the agent
|
||||
# subprocess, and the watchdog/Railway restart loops forever.
|
||||
# Creating the dir up-front with the right owner sidesteps the write.
|
||||
RUN mkdir -p /app/.langgraph_api && chown app:app /app /app/.langgraph_api
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 10000
|
||||
# Intentionally NOT setting `ENV NODE_ENV=production` at the image level.
|
||||
# NODE_ENV=production at the image level would leak into every child process
|
||||
# (Python agent, shell scripts, healthchecks) — most of which don't use it
|
||||
# the way Next.js does. entrypoint.sh scopes NODE_ENV=production to the
|
||||
# Next.js invocation only so non-Next children see the host's environment.
|
||||
ENV PORT=10000
|
||||
CMD ["./entrypoint.sh"]
|
||||
@@ -0,0 +1 @@
|
||||
../_shared
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"framework": "langgraph-fastapi",
|
||||
"features": {
|
||||
"agentic-chat": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/integrations/langgraph/prebuilt-components",
|
||||
"shell_docs_path": "/integrations/langgraph/prebuilt-components"
|
||||
},
|
||||
"tool-rendering": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/integrations/langgraph/generative-ui/tool-rendering",
|
||||
"shell_docs_path": "/integrations/langgraph/generative-ui/tool-rendering"
|
||||
},
|
||||
"hitl-in-chat": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/integrations/langgraph/generative-ui/your-components/interactive",
|
||||
"shell_docs_path": "/integrations/langgraph/generative-ui/your-components/interactive"
|
||||
},
|
||||
"gen-ui-tool-based": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/integrations/langgraph/generative-ui/your-components/display-only",
|
||||
"shell_docs_path": "/integrations/langgraph/generative-ui/your-components/display-only"
|
||||
},
|
||||
"gen-ui-agent": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/integrations/langgraph/generative-ui/state-rendering",
|
||||
"shell_docs_path": "/integrations/langgraph/generative-ui/state-rendering"
|
||||
},
|
||||
"shared-state-read-write": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/integrations/langgraph/shared-state/in-app-agent-write",
|
||||
"shell_docs_path": "/integrations/langgraph/shared-state/in-app-agent-write"
|
||||
},
|
||||
"shared-state-streaming": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/integrations/langgraph/shared-state/predictive-state-updates",
|
||||
"shell_docs_path": "/integrations/langgraph/shared-state/predictive-state-updates"
|
||||
},
|
||||
"subagents": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/integrations/langgraph/multi-agent-flows",
|
||||
"shell_docs_path": "/integrations/langgraph/multi-agent-flows"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
cleanup() {
|
||||
kill $AGENT_PID $NEXTJS_PID $WATCHDOG_PID 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Disable Python stdout buffering so langgraph_cli's dev server and any
|
||||
# tracebacks it emits reach the Railway log stream immediately rather than
|
||||
# sitting in Python's userspace buffer until the process exits.
|
||||
export PYTHONUNBUFFERED=1
|
||||
|
||||
echo "========================================="
|
||||
echo "[entrypoint] Starting showcase package: langgraph-fastapi"
|
||||
echo "[entrypoint] Time: $(date -u)"
|
||||
echo "[entrypoint] PORT=${PORT:-not set}"
|
||||
echo "========================================="
|
||||
|
||||
if [ -z "$OPENAI_API_KEY" ]; then
|
||||
echo "[entrypoint] WARNING: OPENAI_API_KEY is not set! Agent will fail."
|
||||
else
|
||||
echo "[entrypoint] OPENAI_API_KEY: set (${#OPENAI_API_KEY} chars)"
|
||||
fi
|
||||
|
||||
echo "[entrypoint] Starting LangGraph agent server on port 8123..."
|
||||
# Disable langgraph_runtime_inmem's pickle-flush-to-disk loop. Without this,
|
||||
# the inmem runtime periodically flushes unbounded thread/checkpoint state to
|
||||
# .langgraph_api/*.pckl files, which is a slow-burn OOM risk on Railway.
|
||||
# The env var is checked at import time in langgraph_runtime_inmem
|
||||
# _persistence.py and checkpoint.py (langgraph-api==0.7.101 / runtime==0.27.4).
|
||||
export LANGGRAPH_DISABLE_FILE_PERSISTENCE=true
|
||||
|
||||
# `python -u` + `awk ... fflush()`: unbuffered stdout at the interpreter
|
||||
# level + line-flushed awk prefixer so tracebacks reach the container log
|
||||
# immediately rather than block-buffered in pipe buffers.
|
||||
# `--no-reload` disables watchfiles hot-reload, which fires on every request
|
||||
# and causes "1 change detected" log spam → Railway 500-logs/sec kill.
|
||||
python -u -m langgraph_cli dev \
|
||||
--config langgraph.json \
|
||||
--host 0.0.0.0 \
|
||||
--port 8123 \
|
||||
--no-browser \
|
||||
--no-reload &> >(awk '{print "[agent] " $0; fflush()}') &
|
||||
AGENT_PID=$!
|
||||
|
||||
sleep 3
|
||||
|
||||
if kill -0 $AGENT_PID 2>/dev/null; then
|
||||
echo "[entrypoint] LangGraph agent started (PID: $AGENT_PID)"
|
||||
else
|
||||
echo "[entrypoint] ERROR: LangGraph agent failed to start — exiting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "========================================="
|
||||
echo "[entrypoint] Starting Next.js frontend on port ${PORT:-10000}..."
|
||||
echo "========================================="
|
||||
|
||||
PORT=${PORT:-10000}
|
||||
env NODE_ENV=production npx next start --port $PORT &> >(awk '{print "[nextjs] " $0; fflush()}') &
|
||||
NEXTJS_PID=$!
|
||||
|
||||
echo "[entrypoint] Next.js started (PID: $NEXTJS_PID)"
|
||||
|
||||
# Watchdog: Railway deploys of showcase packages have been observed to hit a
|
||||
# silent agent hang — the agent process stays alive (so `wait -n` never
|
||||
# fires and the container never restarts) but stops responding on :8123.
|
||||
# Poll the agent's /ok endpoint (langgraph_cli's health path) every 30s;
|
||||
# after 3 consecutive failures (~90s of unreachable agent), kill the agent
|
||||
# process so `wait -n` returns and Railway restarts the container.
|
||||
# Generalized from showcase/integrations/crewai-crews/entrypoint.sh (PRs #4114
|
||||
# + #4115).
|
||||
#
|
||||
# Startup grace: langgraph_cli dev does a heavy cold-start (graph compile
|
||||
# + uvicorn boot). On fresh Railway containers this can exceed the 90s
|
||||
# (3-strike) budget introduced in PR #4116, matching the restart loop
|
||||
# observed on langgraph-typescript (deployment
|
||||
# 58bbebe8-7a94-4f99-b6e4-ffcbb4eb78b9, 04-20 17:05 UTC). Wait up to 180s
|
||||
# for the first healthy /ok probe before arming the strike counter; if
|
||||
# /ok comes up sooner, fall through immediately. If 180s elapses without
|
||||
# success, arm the counter anyway — the steady-state watchdog will then
|
||||
# handle a true hang.
|
||||
(
|
||||
GRACE=180
|
||||
echo "[watchdog] Startup grace: waiting up to ${GRACE}s for first successful health probe before arming strike counter"
|
||||
ELAPSED=0
|
||||
while [ $ELAPSED -lt $GRACE ]; do
|
||||
if ! kill -0 $AGENT_PID 2>/dev/null; then
|
||||
# Agent died during startup — wait -n in the main shell will handle it.
|
||||
exit 0
|
||||
fi
|
||||
if curl -fsS --max-time 5 http://127.0.0.1:8123/ok > /dev/null 2>&1; then
|
||||
echo "[watchdog] Agent healthy after ${ELAPSED}s — arming strike counter"
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
ELAPSED=$((ELAPSED + 5))
|
||||
done
|
||||
if [ $ELAPSED -ge $GRACE ]; then
|
||||
echo "[watchdog] Grace window elapsed without successful probe — arming strike counter anyway"
|
||||
fi
|
||||
FAILS=0
|
||||
while sleep 30; do
|
||||
if ! kill -0 $AGENT_PID 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
if curl -fsS --max-time 5 http://127.0.0.1:8123/ok > /dev/null 2>&1; then
|
||||
FAILS=0
|
||||
else
|
||||
FAILS=$((FAILS + 1))
|
||||
echo "[watchdog] Agent health probe failed (count=$FAILS)"
|
||||
if [ $FAILS -ge 3 ]; then
|
||||
echo "[watchdog] Agent unresponsive for ~90s — killing PID $AGENT_PID to trigger container restart"
|
||||
kill -9 $AGENT_PID 2>/dev/null || true
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
) &
|
||||
WATCHDOG_PID=$!
|
||||
|
||||
echo "[entrypoint] Watchdog started (PID: $WATCHDOG_PID, startup grace 180s)"
|
||||
echo "[entrypoint] Agent PID=$AGENT_PID, Next PID=$NEXTJS_PID"
|
||||
wait -n $AGENT_PID $NEXTJS_PID
|
||||
EXIT_CODE=$?
|
||||
if ! kill -0 $AGENT_PID 2>/dev/null; then
|
||||
echo "[entrypoint] Agent (PID: $AGENT_PID) exited with code $EXIT_CODE"
|
||||
elif ! kill -0 $NEXTJS_PID 2>/dev/null; then
|
||||
echo "[entrypoint] Next.js (PID: $NEXTJS_PID) exited with code $EXIT_CODE"
|
||||
else
|
||||
echo "[entrypoint] A process exited with code $EXIT_CODE"
|
||||
fi
|
||||
exit $EXIT_CODE
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"http": {
|
||||
"configurable_headers": {
|
||||
"include": ["x-*"]
|
||||
}
|
||||
},
|
||||
"graphs": {
|
||||
"sample_agent": "./src/agents/src/agent.py:graph",
|
||||
"beautiful_chat": "./src/agents/src/beautiful_chat.py:graph",
|
||||
"headless_complete": "./src/agents/src/headless_complete.py:graph",
|
||||
"multimodal": "./src/agents/src/multimodal_agent.py:graph",
|
||||
"agent_config_agent": "./src/agents/src/agent_config_agent.py:graph",
|
||||
"reasoning_agent": "./src/agents/src/reasoning_agent.py:graph",
|
||||
"tool_rendering": "./src/agents/src/tool_rendering_agent.py:graph",
|
||||
"tool_rendering_reasoning_chain": "./src/agents/src/tool_rendering_reasoning_chain_agent.py:graph",
|
||||
"interrupt_agent": "./src/agents/src/interrupt_agent.py:graph",
|
||||
"a2ui_dynamic": "./src/agents/src/a2ui_dynamic.py:graph",
|
||||
"a2ui_fixed": "./src/agents/src/a2ui_fixed.py:graph",
|
||||
"a2ui_recovery": "./src/agents/src/recovery_agent.py:graph",
|
||||
"mcp_apps": "./src/agents/src/mcp_apps_agent.py:graph",
|
||||
"frontend_tools": "./src/agents/src/frontend_tools.py:graph",
|
||||
"frontend_tools_async": "./src/agents/src/frontend_tools_async.py:graph",
|
||||
"hitl_in_app": "./src/agents/src/hitl_in_app.py:graph",
|
||||
"hitl_in_chat": "./src/agents/src/hitl_in_chat_agent.py:graph",
|
||||
"hitl_steps": "./src/agents/src/hitl_steps.py:graph",
|
||||
"readonly_state_agent_context": "./src/agents/src/readonly_state_agent_context.py:graph",
|
||||
"byoc_hashbrown": "./src/agents/src/byoc_hashbrown_agent.py:graph",
|
||||
"byoc_json_render": "./src/agents/src/byoc_json_render_agent.py:graph",
|
||||
"open_gen_ui": "./src/agents/src/open_gen_ui_agent.py:graph",
|
||||
"open_gen_ui_advanced": "./src/agents/src/open_gen_ui_advanced_agent.py:graph",
|
||||
"shared_state_read_write": "./src/agents/src/shared_state_read_write.py:graph",
|
||||
"subagents": "./src/agents/src/subagents.py:graph"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
name: LangGraph (FastAPI)
|
||||
slug: langgraph-fastapi
|
||||
category: emerging
|
||||
language: python
|
||||
logo: /logos/langgraph-fastapi.svg
|
||||
description: CopilotKit integration with LangGraph (FastAPI)
|
||||
partner_docs: null
|
||||
repo: https://github.com/CopilotKit/CopilotKit/tree/main/showcase/integrations/langgraph-fastapi
|
||||
copilotkit_version: 2.0.0
|
||||
deployed: true
|
||||
docs_mode: generated
|
||||
sort_order: 12
|
||||
generative_ui:
|
||||
- constrained-explicit
|
||||
- a2ui-fixed-schema
|
||||
- a2ui-dynamic-schema
|
||||
interaction_modalities:
|
||||
- sidebar
|
||||
- embedded
|
||||
- chat
|
||||
managed_platform:
|
||||
name: LangGraph Platform
|
||||
url: https://langsmith.com
|
||||
not_supported_features:
|
||||
- shared-state-streaming
|
||||
# QUARANTINED pending a @copilotkit/react-core release. gen-ui-interrupt /
|
||||
# interrupt-headless fail turn-2 on a useInterrupt/useHeadlessInterrupt
|
||||
# RESUME-PATH hook bug in @copilotkit/react-core/v2: backend resumes + streams
|
||||
# (HTTP 200) but the frontend never appends the confirmation assistant bubble,
|
||||
# so the harness DOM settle-check times out. Fix is a published-package change
|
||||
# (out of scope here); honestly marked not-supported (skipped-incapable, not
|
||||
# green, not red) rather than a regression. Demos remain wired below.
|
||||
- gen-ui-interrupt
|
||||
- interrupt-headless
|
||||
features:
|
||||
- cli-start
|
||||
- agentic-chat
|
||||
- tool-rendering
|
||||
- tool-rendering-default-catchall
|
||||
- tool-rendering-custom-catchall
|
||||
- tool-rendering-reasoning-chain
|
||||
- agentic-chat-reasoning
|
||||
- reasoning-default-render
|
||||
- hitl
|
||||
- gen-ui-agent
|
||||
- declarative-gen-ui
|
||||
- a2ui-fixed-schema
|
||||
- a2ui-recovery
|
||||
- mcp-apps
|
||||
- gen-ui-tool-based
|
||||
- shared-state-read-write
|
||||
- subagents
|
||||
- beautiful-chat
|
||||
- prebuilt-sidebar
|
||||
- prebuilt-popup
|
||||
- chat-slots
|
||||
- chat-customization-css
|
||||
- headless-simple
|
||||
- headless-complete
|
||||
- auth
|
||||
- multimodal
|
||||
- agent-config
|
||||
- frontend-tools
|
||||
- frontend-tools-async
|
||||
- hitl-in-app
|
||||
- hitl-in-chat
|
||||
- hitl-in-chat-booking
|
||||
- readonly-state-agent-context
|
||||
- voice
|
||||
- byoc-hashbrown
|
||||
- byoc-json-render
|
||||
- open-gen-ui
|
||||
- open-gen-ui-advanced
|
||||
a2ui_pattern: schema-loading
|
||||
interrupt_pattern: native
|
||||
thread_persistence_pattern: langgraph
|
||||
agent_config_pattern: shared-state
|
||||
auth_pattern: langgraph
|
||||
demos:
|
||||
- id: cli-start
|
||||
name: CLI Start Command
|
||||
description: Copy-paste command to clone the canonical starter
|
||||
tags:
|
||||
- chat-ui
|
||||
command: "npx copilotkit@latest init --framework langgraph-fastapi"
|
||||
- id: agentic-chat
|
||||
name: Agentic Chat
|
||||
description: Natural conversation with frontend tool execution
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/agentic-chat
|
||||
animated_preview_url:
|
||||
- id: tool-rendering
|
||||
name: Tool Rendering
|
||||
description: Backend agent tools rendered as UI components
|
||||
tags:
|
||||
- agent-capabilities
|
||||
route: /demos/tool-rendering
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/tool_rendering_agent.py
|
||||
- src/app/demos/tool-rendering/page.tsx
|
||||
- id: hitl
|
||||
name: In-Chat Human in the Loop (Original)
|
||||
description: User approves agent actions before execution
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/hitl
|
||||
animated_preview_url:
|
||||
- id: gen-ui-agent
|
||||
name: Agentic Generative UI
|
||||
description: Long-running agent tasks with generated UI
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/gen-ui-agent
|
||||
animated_preview_url:
|
||||
- id: gen-ui-tool-based
|
||||
name: Tool-Based Generative UI
|
||||
description: Agent uses tools to trigger UI generation
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/gen-ui-tool-based
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/app/demos/gen-ui-tool-based/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- src/app/demos/gen-ui-tool-based/bar-chart.tsx
|
||||
- id: shared-state-streaming
|
||||
name: State Streaming
|
||||
description: Per-token state delta streaming from agent to UI
|
||||
tags:
|
||||
- agent-state
|
||||
route: /demos/shared-state-streaming
|
||||
animated_preview_url:
|
||||
- id: shared-state-read-write
|
||||
name: Shared State (Read + Write)
|
||||
description: Bidirectional shared state — UI writes preferences, agent writes notes
|
||||
tags:
|
||||
- agent-state
|
||||
route: /demos/shared-state-read-write
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/shared_state_read_write.py
|
||||
- src/app/demos/shared-state-read-write/page.tsx
|
||||
- src/app/demos/shared-state-read-write/preferences-card.tsx
|
||||
- src/app/demos/shared-state-read-write/notes-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: subagents
|
||||
name: Sub-Agents
|
||||
description: Multiple agents with visible task delegation
|
||||
tags:
|
||||
- multi-agent
|
||||
route: /demos/subagents
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/subagents.py
|
||||
- src/app/demos/subagents/page.tsx
|
||||
- src/app/demos/subagents/delegation-log.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: prebuilt-sidebar
|
||||
name: "Pre-Built: Sidebar"
|
||||
description: Docked sidebar chat via <CopilotSidebar />
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/prebuilt-sidebar
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/agent.py
|
||||
- src/app/demos/prebuilt-sidebar/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: prebuilt-popup
|
||||
name: "Pre-Built: Popup"
|
||||
description: Floating popup chat via <CopilotPopup />
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/prebuilt-popup
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/agent.py
|
||||
- src/app/demos/prebuilt-popup/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: chat-slots
|
||||
name: Chat Customization (Slots)
|
||||
description: Customize CopilotChat via its slot system
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/chat-slots
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/agent.py
|
||||
- src/app/demos/chat-slots/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- src/app/demos/chat-slots/slot-wrappers.tsx
|
||||
- id: chat-customization-css
|
||||
name: Chat Customization (CSS)
|
||||
description: Default CopilotChat re-themed via CopilotKitCSSProperties
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/chat-customization-css
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/agent.py
|
||||
- src/app/demos/chat-customization-css/page.tsx
|
||||
- src/app/demos/chat-customization-css/theme.css
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: beautiful-chat
|
||||
name: Beautiful Chat
|
||||
description: Canonical polished starter chat — brand fonts, theme tokens,
|
||||
suggestion pills
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/beautiful-chat
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/beautiful_chat.py
|
||||
- src/app/demos/beautiful-chat/page.tsx
|
||||
- src/app/demos/beautiful-chat/components/example-layout/index.tsx
|
||||
- src/app/demos/beautiful-chat/components/example-canvas/index.tsx
|
||||
- src/app/demos/beautiful-chat/components/generative-ui/charts/bar-chart.tsx
|
||||
- src/app/demos/beautiful-chat/components/generative-ui/charts/pie-chart.tsx
|
||||
- src/app/demos/beautiful-chat/hooks/use-example-suggestions.tsx
|
||||
- src/app/demos/beautiful-chat/hooks/use-generative-ui-examples.tsx
|
||||
- src/app/api/copilotkit-beautiful-chat/route.ts
|
||||
- id: headless-simple
|
||||
name: Headless Chat (Simple)
|
||||
description: Minimal custom chat surface built on useAgent
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/headless-simple
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/agent.py
|
||||
- src/app/demos/headless-simple/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: headless-complete
|
||||
name: Headless Chat (Complete)
|
||||
description: Full chat implementation built from scratch on useAgent
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/headless-complete
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/headless_complete.py
|
||||
- src/app/demos/headless-complete/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- src/app/demos/headless-complete/chat/chat.tsx
|
||||
- src/app/demos/headless-complete/hooks/use-tool-renderers.tsx
|
||||
- src/app/demos/headless-complete/hooks/use-frontend-components.ts
|
||||
- src/app/demos/headless-complete/hooks/use-headless-suggestions.ts
|
||||
- src/app/demos/headless-complete/attachments/use-attachments-config.ts
|
||||
- src/app/demos/headless-complete/tools/weather-card.tsx
|
||||
- src/app/demos/headless-complete/tools/stock-card.tsx
|
||||
- src/app/demos/headless-complete/tools/chart-card.tsx
|
||||
- src/app/demos/headless-complete/tools/highlight-note.tsx
|
||||
- id: auth
|
||||
name: Authentication
|
||||
description:
|
||||
Bearer-token gate via runtime onRequest hook with unauthenticated /
|
||||
authenticated states
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/auth
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/app/demos/auth/page.tsx
|
||||
- src/app/demos/auth/auth-banner.tsx
|
||||
- src/app/demos/auth/use-demo-auth.ts
|
||||
- src/app/demos/auth/demo-token.ts
|
||||
- src/app/api/copilotkit-auth/[[...slug]]/route.ts
|
||||
- id: multimodal
|
||||
name: Multimodal Attachments
|
||||
description:
|
||||
Image and PDF uploads via CopilotChat attachments, processed by a
|
||||
vision-capable agent
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/multimodal
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/multimodal_agent.py
|
||||
- src/app/demos/multimodal/page.tsx
|
||||
- src/app/demos/multimodal/sample-attachment-buttons.tsx
|
||||
- src/app/api/copilotkit-multimodal/route.ts
|
||||
- id: agent-config
|
||||
name: Agent Config Object
|
||||
description:
|
||||
Forward a typed config object (tone / expertise / response length)
|
||||
from the provider to the agent's dynamic system prompt
|
||||
tags:
|
||||
- platform
|
||||
route: /demos/agent-config
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/agent_config_agent.py
|
||||
- src/app/demos/agent-config/page.tsx
|
||||
- src/app/demos/agent-config/config-card.tsx
|
||||
- src/app/demos/agent-config/use-agent-config.ts
|
||||
- src/app/demos/agent-config/config-types.ts
|
||||
- src/app/api/copilotkit-agent-config/route.ts
|
||||
- id: tool-rendering-default-catchall
|
||||
name: Tool Rendering (Default Catch-all)
|
||||
description: Out-of-the-box tool rendering — backend defines the tools; the
|
||||
frontend adds zero custom renderers and relies on CopilotKit's built-in
|
||||
default UI
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/tool-rendering-default-catchall
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/app/demos/tool-rendering-default-catchall/page.tsx
|
||||
- src/agents/src/tool_rendering_agent.py
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: tool-rendering-custom-catchall
|
||||
name: Tool Rendering (Custom Catch-all)
|
||||
description: Single branded wildcard renderer via useDefaultRenderTool — the
|
||||
same app-designed card paints every tool call
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/tool-rendering-custom-catchall
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/app/demos/tool-rendering-custom-catchall/page.tsx
|
||||
- src/app/demos/tool-rendering-custom-catchall/custom-catchall-renderer.tsx
|
||||
- src/agents/src/tool_rendering_agent.py
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: tool-rendering-reasoning-chain
|
||||
name: Tool Rendering + Reasoning Chain (testing)
|
||||
description: Sequential tool calls with reasoning tokens rendered side-by-side
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/tool-rendering-reasoning-chain
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/tool_rendering_reasoning_chain_agent.py
|
||||
- src/app/demos/tool-rendering-reasoning-chain/page.tsx
|
||||
- src/app/demos/tool-rendering-reasoning-chain/weather-card.tsx
|
||||
- src/app/demos/tool-rendering-reasoning-chain/flight-list-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: gen-ui-interrupt
|
||||
name: In-Chat HITL (useInterrupt — low-level primitive)
|
||||
description: Interactive component rendered inline in the chat via the
|
||||
lower-level `useInterrupt` primitive — direct control over the LangGraph
|
||||
interrupt lifecycle
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/gen-ui-interrupt
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/interrupt_agent.py
|
||||
- src/app/demos/gen-ui-interrupt/page.tsx
|
||||
- src/app/demos/gen-ui-interrupt/_components/time-picker-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: interrupt-headless
|
||||
name: Headless Interrupt (testing)
|
||||
description:
|
||||
Resolve langgraph interrupts from a plain button grid — no chat, no
|
||||
useInterrupt render prop
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/interrupt-headless
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/interrupt_agent.py
|
||||
- src/app/demos/interrupt-headless/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: declarative-gen-ui
|
||||
name: Declarative Generative UI (A2UI)
|
||||
description: Canonical A2UI BYOC — custom catalog
|
||||
(Card/StatusBadge/Metric/InfoRow/PrimaryButton) wired via a2ui.catalog on
|
||||
the provider
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/declarative-gen-ui
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/a2ui_dynamic.py
|
||||
- src/app/demos/declarative-gen-ui/page.tsx
|
||||
- src/app/demos/declarative-gen-ui/a2ui/catalog.ts
|
||||
- src/app/demos/declarative-gen-ui/a2ui/definitions.ts
|
||||
- src/app/demos/declarative-gen-ui/a2ui/renderers.tsx
|
||||
- src/app/api/copilotkit-declarative-gen-ui/route.ts
|
||||
- id: a2ui-fixed-schema
|
||||
name: Declarative Generative UI (A2UI — Fixed Schema)
|
||||
description: A2UI rendering against a known client-side schema
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/a2ui-fixed-schema
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/a2ui_fixed.py
|
||||
- src/agents/src/a2ui_schemas/flight_schema.json
|
||||
- src/agents/src/a2ui_schemas/booked_schema.json
|
||||
- src/app/demos/a2ui-fixed-schema/page.tsx
|
||||
- src/app/demos/a2ui-fixed-schema/a2ui/catalog.ts
|
||||
- src/app/demos/a2ui-fixed-schema/a2ui/definitions.ts
|
||||
- src/app/demos/a2ui-fixed-schema/a2ui/renderers.tsx
|
||||
- src/app/api/copilotkit-a2ui-fixed-schema/route.ts
|
||||
- id: a2ui-recovery
|
||||
name: A2UI Error Recovery
|
||||
description: Makes the A2UI validate->retry recovery loop visible — an invalid first render heals to a valid one, and an always-invalid render shows a graceful recovery-exhausted fallback. Backend-owned via get_a2ui_tools (injectA2UITool=false); reuses the declarative-gen-ui catalog.
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/a2ui-recovery
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/recovery_agent.py
|
||||
- src/app/demos/a2ui-recovery/page.tsx
|
||||
- src/app/demos/a2ui-recovery/suggestions.ts
|
||||
- src/app/api/copilotkit-a2ui-recovery/route.ts
|
||||
- id: mcp-apps
|
||||
name: MCP Apps
|
||||
description: MCP server-driven UI via activity renderers
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/mcp-apps
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/mcp_apps_agent.py
|
||||
- src/app/demos/mcp-apps/page.tsx
|
||||
- src/app/api/copilotkit-mcp-apps/route.ts
|
||||
- id: frontend-tools
|
||||
name: Frontend Tools (In-App Actions)
|
||||
description: Agent invokes client-side handlers registered with useFrontendTool
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/frontend-tools
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/frontend_tools.py
|
||||
- src/app/demos/frontend-tools/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: frontend-tools-async
|
||||
name: Frontend Tools (Async)
|
||||
description:
|
||||
useFrontendTool with an async handler — agent awaits a client-side
|
||||
async operation (simulated notes DB query) and uses the returned result
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/frontend-tools-async
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/frontend_tools_async.py
|
||||
- src/app/demos/frontend-tools-async/page.tsx
|
||||
- src/app/demos/frontend-tools-async/notes-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: hitl-in-app
|
||||
name: In-App Human in the Loop (Frontend Tools + async HITL)
|
||||
description:
|
||||
Agent requests approval via 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
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/hitl-in-app
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/hitl_in_app.py
|
||||
- src/app/demos/hitl-in-app/page.tsx
|
||||
- src/app/demos/hitl-in-app/approval-dialog.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: hitl-in-chat
|
||||
name: In-Chat HITL (useHumanInTheLoop — ergonomic API)
|
||||
description:
|
||||
Interactive approval/decision surface rendered inline in the chat
|
||||
via the high-level `useHumanInTheLoop` hook
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/hitl-in-chat
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/hitl_in_chat_agent.py
|
||||
- src/app/demos/hitl-in-chat/page.tsx
|
||||
- src/app/demos/hitl-in-chat/time-picker-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: hitl-in-chat-booking
|
||||
name: In-Chat HITL (Booking)
|
||||
description: Time-picker card rendered inline via useHumanInTheLoop for a booking flow
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/hitl-in-chat
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/hitl_in_chat_agent.py
|
||||
- src/app/demos/hitl-in-chat/page.tsx
|
||||
- src/app/demos/hitl-in-chat/time-picker-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: readonly-state-agent-context
|
||||
name: Readonly State (Agent Context)
|
||||
description: Frontend provides read-only context to the agent via useAgentContext
|
||||
tags:
|
||||
- agent-state
|
||||
route: /demos/readonly-state-agent-context
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/readonly_state_agent_context.py
|
||||
- src/app/demos/readonly-state-agent-context/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: voice
|
||||
name: Voice Input
|
||||
description: Speech-to-text via @copilotkit/voice with a bundled sample audio button
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/voice
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/agent.py
|
||||
- src/app/demos/voice/page.tsx
|
||||
- src/app/demos/voice/sample-audio-button.tsx
|
||||
- src/app/api/copilotkit-voice/[[...slug]]/route.ts
|
||||
- id: open-gen-ui
|
||||
name: Fully Open-Ended Generative UI
|
||||
description: Agent generates UI from an arbitrary component library
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/open-gen-ui
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/open_gen_ui_agent.py
|
||||
- src/app/demos/open-gen-ui/page.tsx
|
||||
- src/app/api/copilotkit-ogui/route.ts
|
||||
- id: open-gen-ui-advanced
|
||||
name: "Open-Ended Gen UI (Advanced: with frontend function calling)"
|
||||
description:
|
||||
Agent-authored UI that can invoke frontend sandbox functions from
|
||||
inside the iframe
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/open-gen-ui-advanced
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/src/open_gen_ui_advanced_agent.py
|
||||
- src/app/demos/open-gen-ui-advanced/page.tsx
|
||||
- src/app/demos/open-gen-ui-advanced/sandbox-functions.ts
|
||||
- src/app/demos/open-gen-ui-advanced/suggestions.ts
|
||||
- src/app/api/copilotkit-ogui/route.ts
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Allow iframe embedding from the showcase shell
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "ALLOWALL",
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: "frame-ancestors *;",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
+13328
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@copilotkit/showcase-langgraph-fastapi",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"next dev --turbopack\" \"PYTHONPATH=. python -m langgraph_cli dev --config langgraph.json --host 0.0.0.0 --port 8123 --no-browser\"",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@copilotkit/a2ui-renderer": "1.61.2",
|
||||
"@copilotkit/react-core": "1.61.2",
|
||||
"@copilotkit/react-ui": "1.61.2",
|
||||
"@copilotkit/runtime": "1.61.2",
|
||||
"@copilotkit/shared": "1.61.2",
|
||||
"@copilotkit/voice": "1.61.2",
|
||||
"@hashbrownai/core": "0.5.0-beta.4",
|
||||
"@hashbrownai/react": "0.5.0-beta.4",
|
||||
"@json-render/core": "0.18.0",
|
||||
"@json-render/react": "0.18.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^0.2.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"next": "^15.0.0",
|
||||
"openai": "5.9.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^2.15.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"yaml": "^2.8.4",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"concurrently": "^9.1.0",
|
||||
"postcss": "^8.5.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@copilotkit/web-inspector": {
|
||||
"@copilotkit/core": "1.61.2"
|
||||
}
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@copilotkit/web-inspector>@copilotkit/core": "1.61.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "html",
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
extraHTTPHeaders: {
|
||||
"X-AIMock-Context": "langgraph-fastapi",
|
||||
},
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: process.env.CI
|
||||
? undefined
|
||||
: {
|
||||
command: "pnpm dev",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: true,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL || "",
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY || "",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,30 @@
|
||||
<svg viewBox="0 0 224.43 237.166" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M76.4556 75.7547C97.0552 48.81 114.152 22.1656 120.731 0.648813C120.908 0.0633572 121.594 -0.185355 122.103 0.152636C144.979 15.3013 186.646 25.2726 223.5 25.5066C224.134 25.5107 224.571 26.1359 224.342 26.7272C212.088 57.8146 197.122 113.518 196.54 177.128C196.54 178.073 195.21 178.412 194.742 177.59C173.768 140.886 106.586 89.3107 76.7986 77.138C76.2477 76.9114 76.0919 76.2307 76.4556 75.7547Z" fill="url(#paint0_linear)"/>
|
||||
<path d="M145.956 59.273C113.757 69.4674 84.3336 75.1349 77.3077 76.4226C76.8608 76.5047 76.7673 77.1231 77.1934 77.2977C107.209 89.777 174.059 141.202 194.835 177.757C194.877 177.837 194.981 177.867 195.064 177.83C195.147 177.791 195.189 177.687 195.158 177.597L145.956 59.273Z" fill="url(#paint1_linear)"/>
|
||||
<path d="M122.197 0.0860308C149.76 15.1211 181.615 21.8738 223.875 25.4319C224.135 25.4546 224.228 25.8103 223.989 25.9339C218.585 28.7116 187.623 44.4667 164.633 52.905C158.47 55.1657 152.275 57.263 146.174 59.1979C146.039 59.2402 145.894 59.1736 145.842 59.0446L121.563 0.655481C121.397 0.262302 121.823 -0.117886 122.197 0.0860308Z" fill="url(#paint2_linear)"/>
|
||||
<path d="M121.361 0.145972C121.761 -0.0218955 122.214 0.121754 122.45 0.467128L122.536 0.6264L196.496 177.058L196.548 177.233C196.628 177.642 196.415 178.065 196.015 178.233C195.615 178.401 195.162 178.257 194.926 177.912L194.838 177.753L120.881 1.32093L120.826 1.14599C120.745 0.736568 120.961 0.313703 121.361 0.145972Z" fill="#513C9F"/>
|
||||
<path d="M223.089 25.5869C223.52 25.3424 224.069 25.4925 224.313 25.9238C224.558 26.3552 224.406 26.9035 223.974 27.1483V27.1509H223.969C223.965 27.153 223.96 27.1575 223.953 27.1614C223.939 27.1695 223.916 27.1821 223.888 27.1979C223.832 27.2294 223.749 27.2755 223.64 27.3363C223.419 27.4593 223.091 27.6413 222.661 27.8768C221.8 28.3482 220.529 29.0371 218.885 29.9029C215.597 31.6348 210.813 34.0821 204.83 36.9423C192.865 42.6619 176.091 50.0388 156.86 56.6737C137.624 63.3089 117.782 68.4614 102.755 71.9534C95.2398 73.6998 88.9245 75.0317 84.4882 75.9274C82.2701 76.3752 80.5211 76.7135 79.3262 76.9405C78.7293 77.0538 78.2706 77.1391 77.9606 77.1963C77.8058 77.2249 77.6873 77.2472 77.6081 77.2616C77.5693 77.2687 77.5395 77.2737 77.5194 77.2773C77.5095 77.2791 77.501 77.2816 77.4959 77.2825H77.488L77.3052 77.2982C76.8881 77.2877 76.5205 76.9859 76.4436 76.5593C76.356 76.0711 76.6815 75.6027 77.1695 75.5149H77.1773C77.182 75.514 77.1888 75.5113 77.1982 75.5096C77.2176 75.5061 77.2481 75.501 77.287 75.494C77.3647 75.4798 77.4812 75.4595 77.6342 75.4313C77.9413 75.3746 78.3978 75.2882 78.992 75.1754C80.1806 74.9497 81.9227 74.6138 84.1331 74.1676C88.5551 73.2748 94.8521 71.9459 102.348 70.204C117.342 66.7197 137.119 61.5841 156.276 54.9766C175.426 48.3694 192.134 41.0193 204.055 35.3208C210.015 32.4718 214.777 30.0352 218.047 28.3128C219.682 27.4518 220.944 26.7694 221.796 26.3024C222.222 26.0693 222.546 25.8906 222.763 25.7697C222.871 25.7092 222.954 25.6619 223.008 25.6313C223.035 25.6163 223.055 25.6049 223.068 25.5974C223.075 25.5937 223.08 25.5914 223.084 25.5895L223.086 25.5869H223.089Z" fill="#513C9F"/>
|
||||
<path d="M2.77027 236.611C2.20838 237.273 1.21525 237.353 0.553517 236.792C-0.107154 236.23 -0.18789 235.239 0.373357 234.577L2.77027 236.611ZM132.306 21.7584C133.136 22.0085 133.607 22.8858 133.358 23.7167L106.569 112.86H169.57L169.886 112.891C170.602 113.037 171.142 113.672 171.142 114.431C171.142 115.191 170.602 115.825 169.886 115.972L169.57 116.003H105.182L2.77027 236.611L1.57181 235.593L0.373357 234.577L103.044 113.664L130.347 22.8133C130.597 21.9819 131.474 21.5086 132.306 21.7584Z" fill="#ABABAB"/>
|
||||
<path d="M72.9636 210.65L60.8346 212.356C67.1226 228.985 80.0206 236.249 95.4131 236.249C133.141 236.249 121.625 193.585 143.482 193.585C159.342 193.585 152.899 228.165 187.02 228.165C207.848 228.165 209.927 207.183 206.372 198.156C206.351 198.101 206.331 198.051 206.299 198.002L200.718 189.455C200.355 188.887 199.471 189.101 199.409 189.776L198.369 200.135C198.297 200.856 198.317 201.574 198.401 202.293C199.253 209.45 199.804 226.818 187.02 226.818C173.54 226.818 170.297 192.686 143.482 192.686C112.032 192.686 116.075 234.902 96.7643 234.902C84.0221 234.902 74.3043 220.531 72.9636 210.65Z" fill="url(#paint3_linear)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear" x1="171.825" y1="13.8344" x2="135.895" y2="112.635" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6430AB"/>
|
||||
<stop offset="1" stop-color="#AA89D8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear" x1="143.981" y1="69.5214" x2="97.7306" y2="158.891" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#005DBB"/>
|
||||
<stop offset="1" stop-color="#3D92E8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear" x1="164.633" y1="13.8337" x2="150.706" y2="57.3959" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1B70C4"/>
|
||||
<stop offset="1" stop-color="#54A4F2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear" x1="60.8346" y1="213.57" x2="207.775" y2="213.57" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4497EA"/>
|
||||
<stop offset="0.254755" stop-color="#1463B2"/>
|
||||
<stop offset="0.498725" stop-color="#0A437D"/>
|
||||
<stop offset="0.666667" stop-color="#2476C8"/>
|
||||
<stop offset="0.972542" stop-color="#0C549A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,46 @@
|
||||
<svg viewBox="0 0 1044.21 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(7.74 31.74)">
|
||||
<g id="CopilotKit">
|
||||
<path d="M111.689 53.5173H78.8802C78.647 50.8028 78.0267 48.3403 77.0185 46.1299C76.0487 43.9195 74.6918 42.0192 72.9466 40.4291C71.2403 38.8006 69.1654 37.5591 66.7223 36.7063C64.2791 35.8143 61.5062 35.3683 58.4038 35.3683C52.9745 35.3683 48.379 36.6867 44.6173 39.3235C40.8943 41.9611 38.0633 45.742 36.1243 50.6669C34.224 55.5919 33.2739 61.5063 33.2739 68.4092C33.2739 75.7001 34.2434 81.8084 36.1825 86.7334C38.1602 91.6199 41.0106 95.3042 44.7336 97.7855C48.4566 100.229 52.9358 101.45 58.1711 101.45C61.1573 101.45 63.8331 101.082 66.1988 100.345C68.5644 99.5695 70.6198 98.4647 72.3649 97.0293C74.11 95.5947 75.5258 93.8687 76.6117 91.8523C77.7362 89.7965 78.4923 87.4895 78.8802 84.9297L111.689 85.1629C111.301 90.2041 109.885 95.3426 107.442 100.578C104.999 105.774 101.528 110.583 97.0293 115.004C92.57 119.386 87.0435 122.916 80.4507 125.591C73.8579 128.267 66.1988 129.605 57.4731 129.605C46.5369 129.605 36.7254 127.259 28.0384 122.567C19.3904 117.874 12.5456 110.971 7.50404 101.858C2.50134 92.7443 0 81.5948 0 68.4092C0 55.1467 2.55952 43.9776 7.6786 34.9026C12.7977 25.7893 19.7006 18.9057 28.3875 14.252C37.0744 9.55957 46.7696 7.2133 57.4731 7.2133C64.9965 7.2133 71.9189 8.24101 78.2403 10.2964C84.5614 12.3518 90.1067 15.3572 94.877 19.3129C99.6472 23.2297 103.486 28.0578 106.395 33.7978C109.303 39.5371 111.068 46.1102 111.689 53.5173Z" fill="#010507"/>
|
||||
<path d="M169.874 129.606C160.179 129.606 151.88 127.686 144.977 123.847C138.074 119.968 132.781 114.578 129.096 107.675C125.412 100.733 123.57 92.6864 123.57 83.5337C123.57 74.3818 125.412 66.3544 129.096 59.4515C132.781 52.5092 138.074 47.1186 144.977 43.2796C151.88 39.4014 160.179 37.4626 169.874 37.4626C179.569 37.4626 187.868 39.4014 194.771 43.2796C201.674 47.1186 206.968 52.5092 210.652 59.4515C214.336 66.3544 216.178 74.3818 216.178 83.5337C216.178 92.6864 214.336 100.733 210.652 107.675C206.968 114.578 201.674 119.968 194.771 123.847C187.868 127.686 179.569 129.606 169.874 129.606ZM170.107 105.872C172.822 105.872 175.168 104.96 177.145 103.138C179.123 101.315 180.655 98.7169 181.741 95.3428C182.827 91.9686 183.37 87.9553 183.37 83.3013C183.37 78.6087 182.827 74.5946 181.741 71.2597C180.655 67.8856 179.123 65.2873 177.145 63.4648C175.168 61.6422 172.822 60.7306 170.107 60.7306C167.237 60.7306 164.775 61.6422 162.719 63.4648C160.664 65.2873 159.093 67.8856 158.007 71.2597C156.922 74.5946 156.378 78.6087 156.378 83.3013C156.378 87.9553 156.922 91.9686 158.007 95.3428C159.093 98.7169 160.664 101.315 162.719 103.138C164.775 104.96 167.237 105.872 170.107 105.872Z" fill="#010507"/>
|
||||
<path d="M229.208 161.484V38.6256H261.086V54.2155H261.784C262.948 51.1908 264.655 48.4182 266.903 45.8976C269.153 43.3377 271.945 41.3016 275.28 39.7893C278.615 38.2376 282.493 37.4626 286.914 37.4626C292.809 37.4626 298.413 39.0331 303.726 42.174C309.077 45.3157 313.421 50.2603 316.756 57.0078C320.13 63.7561 321.817 72.52 321.817 83.3013C321.817 93.6168 320.208 102.168 316.989 108.955C313.809 115.741 309.543 120.802 304.191 124.137C298.878 127.473 293.041 129.14 286.682 129.14C282.493 129.14 278.751 128.461 275.455 127.104C272.197 125.708 269.405 123.827 267.078 121.462C264.79 119.057 263.025 116.342 261.784 113.317H261.319V161.484H229.208ZM260.621 83.3013C260.621 87.6444 261.183 91.4064 262.308 94.5866C263.471 97.7275 265.101 100.171 267.195 101.916C269.327 103.622 271.868 104.476 274.815 104.476C277.762 104.476 280.263 103.642 282.319 101.974C284.413 100.268 286.003 97.8437 287.089 94.7028C288.214 91.5226 288.776 87.7221 288.776 83.3013C288.776 78.8804 288.214 75.0995 287.089 71.9578C286.003 68.7776 284.413 66.3544 282.319 64.6866C280.263 62.9803 277.762 62.1267 274.815 62.1267C271.868 62.1267 269.327 62.9803 267.195 64.6866C265.101 66.3544 263.471 68.7776 262.308 71.9578C261.183 75.0995 260.621 78.8804 260.621 83.3013Z" fill="#010507"/>
|
||||
<path d="M335.269 127.977V38.6253H367.38V127.977H335.269ZM351.324 29.318C346.981 29.318 343.258 27.8834 340.156 25.0136C337.054 22.1438 335.502 18.6923 335.502 14.6592C335.502 10.6259 337.054 7.17442 340.156 4.30463C343.258 1.43485 346.981 0 351.324 0C355.707 0 359.429 1.43485 362.493 4.30463C365.596 7.17442 367.147 10.6259 367.147 14.6592C367.147 18.6923 365.596 22.1438 362.493 25.0136C359.429 27.8834 355.707 29.318 351.324 29.318Z" fill="#010507"/>
|
||||
<path d="M415.851 8.84214V127.977H383.74V8.84214H415.851Z" fill="#010507"/>
|
||||
<path d="M475.258 129.606C465.563 129.606 457.264 127.686 450.361 123.847C443.458 119.968 438.164 114.578 434.48 107.675C430.796 100.733 428.954 92.6864 428.954 83.5337C428.954 74.3818 430.796 66.3544 434.48 59.4515C438.164 52.5092 443.458 47.1186 450.361 43.2796C457.264 39.4014 465.563 37.4626 475.258 37.4626C484.954 37.4626 493.253 39.4014 500.156 43.2796C507.059 47.1186 512.352 52.5092 516.036 59.4515C519.72 66.3544 521.563 74.3818 521.563 83.5337C521.563 92.6864 519.72 100.733 516.036 107.675C512.352 114.578 507.059 119.968 500.156 123.847C493.253 127.686 484.954 129.606 475.258 129.606ZM475.491 105.872C478.205 105.872 480.552 104.96 482.53 103.138C484.508 101.315 486.039 98.7169 487.125 95.3428C488.211 91.9686 488.754 87.9553 488.754 83.3013C488.754 78.6087 488.211 74.5946 487.125 71.2597C486.039 67.8856 484.508 65.2873 482.53 63.4648C480.552 61.6422 478.205 60.7306 475.491 60.7306C472.621 60.7306 470.158 61.6422 468.103 63.4648C466.048 65.2873 464.477 67.8856 463.391 71.2597C462.305 74.5946 461.763 78.6087 461.763 83.3013C461.763 87.9553 462.305 91.9686 463.391 95.3428C464.477 98.7169 466.048 101.315 468.103 103.138C470.158 104.96 472.621 105.872 475.491 105.872Z" fill="#010507"/>
|
||||
<path d="M587.877 38.6253V61.8941H529.008V38.6253H587.877ZM540.41 17.2186H572.52V99.2396C572.52 100.481 572.734 101.528 573.16 102.381C573.587 103.196 574.246 103.816 575.138 104.242C576.03 104.63 577.174 104.824 578.57 104.824C579.539 104.824 580.664 104.708 581.944 104.476C583.262 104.242 584.232 104.048 584.852 103.894L589.506 126.464C588.072 126.891 586.016 127.415 583.34 128.035C580.703 128.655 577.561 129.063 573.916 129.256C566.548 129.644 560.363 128.888 555.36 126.987C550.357 125.049 546.595 122.004 544.075 117.854C541.554 113.705 540.332 108.508 540.41 102.265V17.2186Z" fill="#010507"/>
|
||||
<path d="M602.435 127.977V8.84214H634.778V57.0077H636.406L672.24 8.84214H709.935L669.681 61.8942L710.866 127.977H672.24L645.481 83.3012L634.778 97.2626V127.977H602.435Z" fill="#010507"/>
|
||||
<path d="M725.597 127.977V38.6253H757.708V127.977H725.597ZM741.653 29.318C737.309 29.318 733.586 27.8834 730.484 25.0136C727.381 22.1438 725.831 18.6923 725.831 14.6592C725.831 10.6259 727.381 7.17442 730.484 4.30463C733.586 1.43485 737.309 0 741.653 0C746.035 0 749.758 1.43485 752.821 4.30463C755.924 7.17442 757.475 10.6259 757.475 14.6592C757.475 18.6923 755.924 22.1438 752.821 25.0136C749.758 27.8834 746.035 29.318 741.653 29.318Z" fill="#010507"/>
|
||||
<path d="M827.354 38.6253V61.8941H768.484V38.6253H827.354ZM779.886 17.2186H811.994V99.2396C811.994 100.481 812.206 101.528 812.632 102.381C813.066 103.196 813.72 103.816 814.612 104.242C815.504 104.63 816.65 104.824 818.05 104.824C819.015 104.824 820.136 104.708 821.421 104.476C822.739 104.242 823.704 104.048 824.326 103.894L828.983 126.464C827.551 126.891 825.489 127.415 822.812 128.035C820.177 128.655 817.035 129.063 813.393 129.256C806.024 129.644 799.838 128.888 794.836 126.987C789.833 125.049 786.071 122.004 783.55 117.854C781.03 113.705 779.808 108.508 779.886 102.265V17.2186Z" fill="#010507"/>
|
||||
</g>
|
||||
</g>
|
||||
<svg x="859.58" y="7.1" width="176.717" height="186.745" viewBox="0 0 176.717 186.745" fill="none">
|
||||
<path d="M60.2013 59.6493C76.4214 38.4331 89.8836 17.4532 95.0639 0.510877C95.203 0.0498883 95.7431 -0.145948 96.1441 0.120187C114.156 12.0483 146.965 19.8996 175.984 20.0839C176.484 20.0871 176.827 20.5795 176.647 21.045C166.999 45.5233 155.214 89.384 154.756 139.471C154.756 140.215 153.708 140.482 153.34 139.835C136.825 110.934 83.9258 70.3234 60.4713 60.7386C60.0376 60.5602 59.9148 60.0242 60.2013 59.6493Z" fill="url(#paint0_linear)"/>
|
||||
<path d="M114.926 46.6717C89.5724 54.6988 66.4044 59.1614 60.8722 60.1754C60.5203 60.24 60.4466 60.7269 60.7822 60.8644C84.4167 70.6906 137.054 111.183 153.414 139.967C153.446 140.03 153.528 140.052 153.594 140.024C153.659 139.993 153.692 139.911 153.667 139.841L114.926 46.6717Z" fill="url(#paint1_linear)"/>
|
||||
<path d="M96.218 0.0677872C117.921 11.9064 143.004 17.2235 176.279 20.0252C176.484 20.043 176.558 20.3231 176.369 20.4204C172.114 22.6076 147.734 35.0132 129.632 41.6575C124.779 43.4376 119.902 45.089 115.098 46.6126C114.991 46.6459 114.877 46.5934 114.836 46.4919L95.7188 0.516173C95.5878 0.206583 95.9234 -0.0927773 96.218 0.0677872Z" fill="url(#paint2_linear)"/>
|
||||
<path d="M95.5597 0.114978C95.875 -0.0172013 96.2317 0.0959082 96.417 0.367856L96.4849 0.493267L154.721 139.416L154.762 139.554C154.825 139.876 154.657 140.209 154.343 140.341C154.027 140.473 153.671 140.36 153.485 140.088L153.415 139.963L95.1814 1.04014L95.1383 0.902396C95.075 0.580014 95.2449 0.247049 95.5597 0.114978Z" fill="#513C9F"/>
|
||||
<path d="M175.661 20.1472C176 19.9546 176.432 20.0729 176.625 20.4124C176.818 20.7522 176.697 21.1839 176.357 21.3767V21.3787H176.353C176.351 21.3803 176.346 21.3839 176.341 21.3869C176.33 21.3933 176.312 21.4033 176.29 21.4157C176.246 21.4405 176.18 21.4768 176.094 21.5247C175.921 21.6215 175.662 21.7648 175.323 21.9503C174.646 22.3215 173.645 22.8639 172.351 23.5456C169.761 24.9093 165.995 26.8363 161.283 29.0884C151.862 33.592 138.655 39.4006 123.512 44.625C108.365 49.8495 92.7416 53.9066 80.9093 56.6563C74.9919 58.0314 70.0192 59.0801 66.5261 59.7854C64.7796 60.138 63.4024 60.4044 62.4615 60.5831C61.9916 60.6723 61.6304 60.7395 61.3863 60.7845C61.2644 60.807 61.1711 60.8246 61.1087 60.8359C61.0782 60.8415 61.0547 60.8454 61.0388 60.8483C61.0311 60.8497 61.0244 60.8517 61.0203 60.8524H61.0142L60.8702 60.8647C60.5418 60.8565 60.2523 60.6188 60.1918 60.2829C60.1228 59.8986 60.3791 59.5297 60.7633 59.4605H60.7695C60.7732 59.4599 60.7786 59.4578 60.786 59.4564C60.8013 59.4537 60.8252 59.4497 60.8559 59.4441C60.9171 59.4329 61.0088 59.417 61.1293 59.3947C61.3711 59.3501 61.7305 59.2821 62.1984 59.1933C63.1343 59.0155 64.506 58.7511 66.2465 58.3997C69.7284 57.6967 74.6866 56.6503 80.5886 55.2788C92.3949 52.5352 107.968 48.4914 123.052 43.2887C138.13 38.0862 151.286 32.2987 160.673 27.8117C165.366 25.5684 169.116 23.6497 171.691 22.2936C172.978 21.6156 173.972 21.0783 174.643 20.7105C174.978 20.527 175.233 20.3863 175.404 20.2911C175.489 20.2435 175.554 20.2062 175.597 20.1822C175.618 20.1704 175.634 20.1614 175.644 20.1554C175.649 20.1525 175.654 20.1507 175.656 20.1493L175.658 20.1472H175.661Z" fill="#513C9F"/>
|
||||
<path d="M2.18131 186.308C1.73888 186.829 0.956885 186.893 0.435837 186.45C-0.0843773 186.008 -0.147949 185.227 0.293978 184.707L2.18131 186.308ZM104.178 17.1327C104.832 17.3295 105.202 18.0203 105.006 18.6746L83.9124 88.8658H133.52L133.768 88.8905C134.333 89.0058 134.757 89.5053 134.757 90.1035C134.757 90.7016 134.333 91.2011 133.768 91.3164L133.52 91.3411H82.8207L2.18131 186.308L1.23765 185.506L0.293978 184.707L81.1369 89.499L102.636 17.9632C102.832 17.3086 103.523 16.9359 104.178 17.1327Z" fill="#ABABAB"/>
|
||||
<path d="M57.4516 165.866L47.9012 167.209C52.8524 180.303 63.0083 186.023 75.1284 186.023C104.835 186.023 95.7677 152.43 112.978 152.43C125.466 152.43 120.393 179.658 147.26 179.658C163.66 179.658 165.297 163.136 162.498 156.029C162.481 155.985 162.465 155.946 162.44 155.907L158.046 149.177C157.759 148.73 157.064 148.898 157.015 149.43L156.196 157.587C156.139 158.154 156.155 158.72 156.221 159.286C156.892 164.921 157.326 178.597 147.26 178.597C136.645 178.597 134.092 151.722 112.978 151.722C88.2142 151.722 91.3977 184.962 76.1923 184.962C66.1591 184.962 58.5073 173.647 57.4516 165.866Z" fill="url(#paint3_linear)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear" x1="135.295" y1="10.8932" x2="107.004" y2="88.6892" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6430AB"/>
|
||||
<stop offset="1" stop-color="#AA89D8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear" x1="113.371" y1="54.7414" x2="76.9532" y2="125.111" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#005DBB"/>
|
||||
<stop offset="1" stop-color="#3D92E8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear" x1="129.632" y1="10.8927" x2="118.666" y2="45.1937" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1B70C4"/>
|
||||
<stop offset="1" stop-color="#54A4F2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear" x1="47.9012" y1="168.165" x2="163.603" y2="168.165" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4497EA"/>
|
||||
<stop offset="0.254755" stop-color="#1463B2"/>
|
||||
<stop offset="0.498725" stop-color="#0A437D"/>
|
||||
<stop offset="0.666667" stop-color="#2476C8"/>
|
||||
<stop offset="0.972542" stop-color="#0C549A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,18 @@
|
||||
# Voice demo audio
|
||||
|
||||
Drop a small (<100KB) WAV file named `sample.wav` in this directory. The voice
|
||||
demo (`/demos/voice`) fetches it client-side and POSTs to the transcription
|
||||
endpoint so the flow works without mic permissions.
|
||||
|
||||
Expected content: an audio clip speaking the phrase
|
||||
**"What is the weather in Tokyo?"** — the demo caption advertises that phrase
|
||||
to the user, and the bundled QA checklist + E2E spec assert the transcribed
|
||||
text contains "weather" and/or "Tokyo".
|
||||
|
||||
Generate locally, for example:
|
||||
|
||||
- macOS: `say -o sample.aiff "What is the weather in Tokyo?" && ffmpeg -i sample.aiff -ar 16000 -ac 1 sample.wav`
|
||||
- Linux: `espeak-ng -w sample.wav "What is the weather in Tokyo?"`
|
||||
- Windows: PowerShell `System.Speech.Synthesis.SpeechSynthesizer` → `SetOutputToWaveFile`
|
||||
|
||||
Target: 16kHz mono, 3-5s duration, <100KB.
|
||||
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
# Demo Files — Multimodal Demo
|
||||
|
||||
This directory bundles sample files referenced by the `/demos/multimodal` page.
|
||||
|
||||
Required files (must be committed as binaries; see `.gitattributes` at repo root):
|
||||
|
||||
- `sample.png` — a small (< 50 KB) PNG that the "Try with sample image" button
|
||||
injects into the chat. A CopilotKit logo or other recognizable brand mark
|
||||
works well so the vision-capable agent has something to describe.
|
||||
- `sample.pdf` — a small (< 50 KB) one-page PDF that the "Try with sample PDF"
|
||||
button injects. The content must mention "CopilotKit" so E2E soft
|
||||
assertions hold (e.g. a one-page export of the CopilotKit quickstart).
|
||||
|
||||
The page at `src/app/demos/multimodal/page.tsx` fetches these via the public
|
||||
path (`/demo-files/sample.png`, `/demo-files/sample.pdf`), wraps the fetched
|
||||
blob in a `File`, and routes it through the same `AttachmentsConfig.onUpload`
|
||||
callback the paperclip button uses — so the sample path exercises the exact
|
||||
same queueing code as a real user upload.
|
||||
|
||||
If these files are missing, the demo page still renders but the sample
|
||||
buttons will surface a fetch error. The paperclip / drag-and-drop paths
|
||||
continue to work without them.
|
||||
@@ -0,0 +1,80 @@
|
||||
%PDF-1.4
|
||||
%“Œ‹ž ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 9 0 R /MediaBox [ 0 0 612 792 ] /Parent 8 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Author (CopilotKit) /CreationDate (D:20260424104452+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260424104452+02'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (CopilotKit Quickstart) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Count 1 /Kids [ 5 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 942
|
||||
>>
|
||||
stream
|
||||
Gat=)gMRoa&:N^l7S$NN`GHCmThV;Z2^rJ`P):Y@]XYQU3:,AeX>cqAr-QI2/=2N"LfPX,loA_(*1m\5btm94UD2YX&6drn#XTAg+m:hr0`277L6Xugi+Zi-%K4(Zk-_X)UIg0#"[s_$PB)p"JhpIS$sekH(N!]Wm13pi-C<+2Z\%NM%.@X"c6;!4Z\-]lQ_FW]'b$98<b+A;AK8VL'gsRZ%'52u3[a]V,-i>M8Rs*Je-b?d:1;&?$,`ad5V$Xf0gWUq#g$2OoROl&VT9iG>Z7e:(6,L^5qWRe9f&aq&5@k4gFJA#"jE(QdPamJY0Hpqn>r_e:*FVtpYPMT?RZaJ8P7Ca0<':`;qM'##`XXPXO9RF6i%iKqn<L:oPEI$hcphT25S/nN3<DAK4NVdiJL>7#+eNHaXE@Y[_>W6oJG]uZn6;#DFBr_^iD&OGjVg%"ij8a:&7/qaE+7a=d``5hdQO":;.r]$4@TOAQt"^6*,kR%u>XHZrCjf@5l+)<U?'C_6MX"fJq<J@]>%B@BumKlTA+:A9AG5jqSXcFh[j7G)iN0D\N/+I[;hq]7D]ZAE\d8UW,N3+.Br<N@#$kD%W'f-CmKrYTKkeeuX8:pui+(3#6dK@@XC>.sl!SW4(X]/!H@1dH0Qp(55E^KJk1]UY=9;_"eb*-cL!Qq,hJ?,?l*JeVfts!/i_[Ejo:h;fDQSkbMmd[X?rTSO%M5Qeh<MleEs/@&O$fl?bGg9%rX0LW>9/:K"H3?p7"G:0Ha3Qj[6jL<;9nL312;])jCZe6\\@E*Ttr?Xg2lNp?68b2o5,cF;--/kSU`mMnHk40JE<",2f4YKj@2GX^Q[V>`.2@i]-rEiq2ARgR#W*>Hsb'riEWK24?;X\G?nL'id4l2fR3ocbEr3QJOgLr..O8N<DDWc>P*6r.',3Q1&LkGF@<;hrL[kDNqL~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 10
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000436 00000 n
|
||||
0000000629 00000 n
|
||||
0000000697 00000 n
|
||||
0000000982 00000 n
|
||||
0000001041 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<ad0be0ae447f61082137085c690fb230><ad0be0ae447f61082137085c690fb230>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 7 0 R
|
||||
/Root 6 0 R
|
||||
/Size 10
|
||||
>>
|
||||
startxref
|
||||
2073
|
||||
%%EOF
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
@@ -0,0 +1,44 @@
|
||||
# QA: A2UI Error Recovery — LangGraph (FastAPI)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible at `/demos/a2ui-recovery` on the dashboard host
|
||||
- Agent backend is healthy; `OPENAI_API_KEY` is set; `AGENT_URL` points at the LangGraph deployment exposing the `a2ui_recovery` graph (registered as agent name `a2ui-recovery` — see `src/app/api/copilotkit-a2ui-recovery/route.ts`)
|
||||
- Requires `ag-ui-langgraph >= 0.0.41` (the `get_a2ui_tools` validate→retry recovery loop + `a2ui_recovery_exhausted` hard-fail envelope) and the `@copilotkit` A2UI renderer (the `building`/`retrying`/`failed` lifecycle rendering)
|
||||
- Backend-owned wiring: the route sets `injectA2UITool: false`; the agent owns `generate_a2ui` via `get_a2ui_tools({ recovery: { maxAttempts: 3 } })` (see `src/agents/src/recovery_agent.py`)
|
||||
- Reuses the **declarative-gen-ui** catalog (`catalogId: "declarative-gen-ui-catalog"`) and the Vantage Threads sales context — no new components
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/a2ui-recovery`; verify the page renders within 3s and a single `CopilotChat` pane is centered (max-width ~896px, rounded-2xl, full-height)
|
||||
- [ ] Verify the chat is wired to `runtimeUrl="/api/copilotkit-a2ui-recovery"` and `agent="a2ui-recovery"` (DevTools → Network: sending a message hits that endpoint, not `/api/copilotkit`)
|
||||
- [ ] Verify both suggestion pills are visible with verbatim titles:
|
||||
- "Recover a bad render"
|
||||
- "Show an unrecoverable failure"
|
||||
|
||||
### 2. Healing path
|
||||
|
||||
- [ ] Click "Recover a bad render" ("Render my Q2 sales dashboard, recovering if the first attempt is malformed.")
|
||||
- [ ] The inner `render_a2ui` returns **free-form / sloppy** A2UI args (components & data as JSON strings rather than structured arrays). Verify the middleware **heals** them via `parse_and_fix` into a valid surface that paints (no broken surface, no error banner)
|
||||
- [ ] Verify the **painted** surface is valid: a `declarative-metric` row ("Quarterly Revenue $4.2M", "Win Rate 31%")
|
||||
- [ ] DevTools → Network: verify the final tool result carries an `a2ui_operations` container (no `a2ui_recovery_exhausted`)
|
||||
- [ ] Verify the chat reply is one short sentence noting the heal
|
||||
|
||||
### 3. Hard-fail (recovery exhausted) path
|
||||
|
||||
- [ ] Click "Show an unrecoverable failure" ("Render a dashboard that keeps failing validation so I can see the fallback.")
|
||||
- [ ] Verify the lifecycle ends in a tasteful `failed` state (NOT a broken/half-rendered surface and NOT a silent drop)
|
||||
- [ ] DevTools → Network: verify `render_a2ui` was attempted up to the cap (3 attempts, all invalid) and the tool returned an `a2ui_recovery_exhausted` envelope (no `a2ui_operations` painted)
|
||||
- [ ] Verify the chat reply gracefully explains the fallback (one short sentence)
|
||||
|
||||
### 4. Regression / isolation
|
||||
|
||||
- [ ] Verify the recovery demo does not affect the declarative-gen-ui or beautiful-chat demos (separate routes/agents)
|
||||
- [ ] Re-run each pill a second time and verify the same lifecycle
|
||||
|
||||
## Notes
|
||||
|
||||
- The malformed renders are forced by aimock fixtures (`showcase/aimock/d6/langgraph-fastapi/a2ui-recovery.json`): the inner `render_a2ui` call is matched by `userMessage` + `toolName=render_a2ui`. Healing itself is performed live by the toolkit recovery loop inside `ag_ui_langgraph.get_a2ui_tools`.
|
||||
- LangGraph-FastAPI sibling of the langgraph-python `a2ui-recovery` demo (same backend `get_a2ui_tools` recovery loop).
|
||||
@@ -0,0 +1,62 @@
|
||||
# QA: Agentic Chat — LangGraph (FastAPI)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to the agentic-chat demo page
|
||||
- [ ] Verify the chat interface loads with a text input placeholder "Type a message"
|
||||
- [ ] Verify the background container (`data-testid="background-container"`) is visible
|
||||
- [ ] Verify the default background color is the theme default (rgb(250, 250, 249))
|
||||
- [ ] Send a basic message (e.g. "Hello")
|
||||
- [ ] Verify the agent responds with a text message
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify "Change background" suggestion button is visible
|
||||
- [ ] Verify "Generate sonnet" suggestion button is visible
|
||||
- [ ] Click the "Change background" suggestion
|
||||
- [ ] Verify the suggestion either populates the input or sends the message
|
||||
|
||||
#### Background Change (useFrontendTool)
|
||||
|
||||
- [ ] Ask "Change the background to a sunset gradient"
|
||||
- [ ] Verify the background container style changes from the default
|
||||
- [ ] Verify the change_background tool returns a success status
|
||||
|
||||
#### Weather Render Tool (useRenderTool)
|
||||
|
||||
- [ ] Type "What's the weather in Tokyo?"
|
||||
- [ ] Verify loading state shows "Loading weather..." (`data-testid="weather-info-loading"`)
|
||||
- [ ] Verify WeatherCard renders (`data-testid="weather-info"`) with:
|
||||
- [ ] City name displayed
|
||||
- [ ] Temperature in degrees C
|
||||
- [ ] Humidity percentage
|
||||
- [ ] Wind speed in mph
|
||||
- [ ] Conditions text
|
||||
|
||||
#### Agent Context
|
||||
|
||||
- [ ] Verify the agent knows the user's name is "Bob" (provided via useAgentContext)
|
||||
- [ ] Ask "What is my name?" and verify the agent responds with "Bob"
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send an empty message (should be handled gracefully)
|
||||
- [ ] Verify no console errors during normal usage
|
||||
- [ ] Send a very long message and verify no UI breakage
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat loads within 3 seconds
|
||||
- Agent responds within 10 seconds
|
||||
- Background changes are instant after tool execution
|
||||
- Weather card renders with all data fields populated
|
||||
- No UI errors or broken layouts
|
||||
@@ -0,0 +1,62 @@
|
||||
# QA: Agentic Generative UI — LangGraph (FastAPI)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to the gen-ui-agent demo page
|
||||
- [ ] Verify the chat interface loads in a centered full-height layout
|
||||
- [ ] Verify the chat input placeholder "Type a message" is visible
|
||||
- [ ] Send a basic message
|
||||
- [ ] Verify the agent responds
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify "Simple plan" suggestion button is visible (plan to go to Mars in 5 steps)
|
||||
- [ ] Verify "Complex plan" suggestion button is visible (plan to make pizza in 10 steps)
|
||||
|
||||
#### Task Progress Tracker (useAgent with state streaming)
|
||||
|
||||
- [ ] Click "Simple plan" suggestion or type "Build a plan to go to Mars in 5 steps"
|
||||
- [ ] Verify the TaskProgress component renders (`data-testid="task-progress"`)
|
||||
- [ ] Verify the progress bar appears with a gradient fill
|
||||
- [ ] Verify step items appear with descriptions (`data-testid="task-step-text"`)
|
||||
- [ ] Verify the "N/N Complete" counter updates as steps complete
|
||||
- [ ] Verify completed steps show:
|
||||
- Green background gradient
|
||||
- Check icon
|
||||
- Green text color
|
||||
- [ ] Verify the current pending step shows:
|
||||
- Blue/purple background gradient
|
||||
- Spinner icon with "Processing..." text
|
||||
- Pulsing animation
|
||||
- [ ] Verify future pending steps show:
|
||||
- Gray background
|
||||
- Clock icon
|
||||
- Muted text color
|
||||
|
||||
#### Complex Plan
|
||||
|
||||
- [ ] Type "Plan to make pizza in 10 steps"
|
||||
- [ ] Verify 10 steps appear in the progress tracker
|
||||
- [ ] Verify progress bar width increases as steps complete
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send an empty message (should be handled gracefully)
|
||||
- [ ] Verify no console errors during normal usage
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat loads within 3 seconds
|
||||
- Agent responds within 10 seconds
|
||||
- Task progress tracker shows live step completion
|
||||
- Progress bar animates smoothly
|
||||
- No UI errors or broken layouts
|
||||
@@ -0,0 +1,59 @@
|
||||
# QA: Tool-Based Generative UI — LangGraph (FastAPI)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to the gen-ui-tool-based demo page
|
||||
- [ ] Verify the CopilotSidebar opens by default with title "Haiku Generator"
|
||||
- [ ] Verify a placeholder haiku card is displayed on the main area
|
||||
- [ ] Send a basic message via the sidebar
|
||||
- [ ] Verify the agent responds
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify "Nature Haiku" suggestion button is visible
|
||||
- [ ] Verify "Ocean Haiku" suggestion button is visible
|
||||
- [ ] Verify "Spring Haiku" suggestion button is visible
|
||||
|
||||
#### Haiku Generation (useFrontendTool)
|
||||
|
||||
- [ ] Click the "Nature Haiku" suggestion or type "Write me a haiku about nature"
|
||||
- [ ] Verify a HaikuCard renders (`data-testid="haiku-card"`) with:
|
||||
- [ ] Three Japanese text lines (`data-testid="haiku-japanese-line"`)
|
||||
- [ ] Three English translation lines (`data-testid="haiku-english-line"`)
|
||||
- [ ] A background gradient style applied to the card
|
||||
- [ ] Verify the Japanese text contains actual Japanese characters (not Latin)
|
||||
- [ ] Verify the English lines are a readable English translation
|
||||
|
||||
#### Image Display
|
||||
|
||||
- [ ] After haiku generation, verify an image renders (`data-testid="haiku-image"`) if the agent provides an image_name
|
||||
- [ ] Verify the image src points to /images/ with a valid filename from the predefined list
|
||||
|
||||
#### Multiple Haikus
|
||||
|
||||
- [ ] Generate a second haiku (e.g. "Ocean Haiku")
|
||||
- [ ] Verify the new haiku card appears at the top
|
||||
- [ ] Verify the previous haiku card is still visible below
|
||||
- [ ] Verify the initial placeholder haiku is removed
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send an empty message (should be handled gracefully)
|
||||
- [ ] Verify no console errors during normal usage
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Sidebar loads within 3 seconds
|
||||
- Agent responds and generates haiku within 10 seconds
|
||||
- Haiku cards display with Japanese and English text
|
||||
- Generated haikus stack with newest on top
|
||||
- No UI errors or broken layouts
|
||||
@@ -0,0 +1,57 @@
|
||||
# QA: Human in the Loop — LangGraph (FastAPI)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to the HITL demo page (`/demos/hitl`) > _Note: the URL path `/demos/hitl` is intentionally shorter than the feature id `hitl-in-chat`._
|
||||
- [ ] Verify the chat interface loads in a centered max-w-4xl container
|
||||
- [ ] Verify the chat input placeholder "Type a message" is visible
|
||||
- [ ] Send a basic message
|
||||
- [ ] Verify the agent responds
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify "Simple plan" suggestion button is visible
|
||||
- [ ] Verify "Complex plan" suggestion button is visible
|
||||
- [ ] Click the "Simple plan" suggestion
|
||||
- [ ] Verify it triggers a message about planning a trip to Mars in 5 steps
|
||||
|
||||
#### Step Selection and Approval
|
||||
|
||||
The HITL demo renders a single StepSelector card regardless of whether
|
||||
the underlying flow uses an interrupt hook or a frontend HITL tool. The
|
||||
framework-specific hook name is an implementation detail covered in each
|
||||
package's demo source; QA below validates the user-visible card behavior.
|
||||
|
||||
- [ ] Send "Plan a trip to Mars in 5 steps"
|
||||
- [ ] Verify the StepSelector card appears (`data-testid="select-steps"`)
|
||||
- [ ] Verify step items are displayed with checkboxes (`data-testid="step-item"`)
|
||||
- [ ] Verify step text is visible (`data-testid="step-text"`)
|
||||
- [ ] Verify the selected count display shows "N/N selected"
|
||||
- [ ] Toggle a step checkbox off and verify the count decreases
|
||||
- [ ] Toggle it back on and verify the count increases
|
||||
- [ ] Click "Perform Steps (N)" / "Confirm (N)" button
|
||||
- [ ] Verify the agent continues processing after confirmation
|
||||
- [ ] In a new conversation, trigger the same flow and click "Reject" (where present)
|
||||
- [ ] Verify the card reflects the decision (Accepted / Rejected) and buttons disable
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send an empty message (should be handled gracefully)
|
||||
- [ ] Verify no console errors during normal usage
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat loads within 3 seconds
|
||||
- Agent responds within 10 seconds
|
||||
- Step selector renders with toggleable checkboxes
|
||||
- Accept/Reject flow completes without errors
|
||||
- No UI errors or broken layouts
|
||||
@@ -0,0 +1,67 @@
|
||||
# QA: Shared State (Read + Write) — LangGraph (FastAPI)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check `/api/copilotkit` GET — `agent_status: "reachable"`)
|
||||
- `OPENAI_API_KEY` is set in the agent environment
|
||||
|
||||
## What this demo proves
|
||||
|
||||
- **UI -> agent (write)**: editing the preferences card writes the new
|
||||
`preferences` object straight into agent state via `agent.setState`.
|
||||
- **agent -> UI (read)**: when the agent invokes its `set_notes` tool the
|
||||
`notes` slice of agent state updates and the Notes card re-renders.
|
||||
- **agent -> reads UI writes**: a `PreferencesInjectorMiddleware` reads
|
||||
`preferences` out of state every turn and prepends it to the system
|
||||
prompt, so the next reply visibly adapts.
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Page loads with both cards
|
||||
|
||||
- [ ] Navigate to `/demos/shared-state-read-write`
|
||||
- [ ] The Preferences card (`data-testid="preferences-card"`) is visible
|
||||
- [ ] The Notes card (`data-testid="notes-card"`) is visible
|
||||
- [ ] The Notes card shows "No notes yet" (`data-testid="notes-empty"`)
|
||||
- [ ] The chat input on the right is visible
|
||||
|
||||
### 2. UI writes flow into agent state (preferences)
|
||||
|
||||
- [ ] Type a name into `pref-name` (e.g. "Atai") — the JSON in
|
||||
`pref-state-json` updates immediately
|
||||
- [ ] Change tone to "playful" via `pref-tone` — JSON reflects it
|
||||
- [ ] Change language to "Spanish" via `pref-language` — JSON reflects it
|
||||
- [ ] Click two interest pills (e.g. Cooking, Music) — JSON `interests` array updates
|
||||
|
||||
### 3. Agent reads the UI-written preferences
|
||||
|
||||
- [ ] In chat, send "Say hi and introduce yourself."
|
||||
- [ ] The agent reply addresses the user by the name from the card
|
||||
- [ ] The reply tone matches the selected tone
|
||||
- [ ] If language was changed (e.g. Spanish), the reply is in that language
|
||||
|
||||
### 4. Agent writes notes via `set_notes`
|
||||
|
||||
- [ ] Send: "Remember that I prefer morning meetings and that I don't eat dairy."
|
||||
- [ ] Within ~10 seconds, the Notes card transitions away from `notes-empty`
|
||||
- [ ] At least one `note-item` appears with each remembered fact
|
||||
- [ ] Subsequent unrelated chat turns leave the notes intact
|
||||
|
||||
### 5. UI can clear agent-written state
|
||||
|
||||
- [ ] With at least one note showing, click `notes-clear-button`
|
||||
- [ ] The Notes card returns to `notes-empty` immediately
|
||||
- [ ] No errors in the browser console
|
||||
|
||||
### 6. Error handling
|
||||
|
||||
- [ ] Sending an empty message is handled gracefully (no crash)
|
||||
- [ ] Refreshing the page resets state to defaults; first turn re-seeds it
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat loads within 3 seconds
|
||||
- Each preference edit reflects in `pref-state-json` immediately
|
||||
- Note updates land within 10 seconds of asking the agent to remember
|
||||
- No UI errors or broken layouts; no React warnings in console
|
||||
@@ -0,0 +1,75 @@
|
||||
# QA: Shared State (Reading) — LangGraph (FastAPI)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to the shared-state-read demo page
|
||||
- [ ] Verify the recipe card form loads (`data-testid="recipe-card"`)
|
||||
- [ ] Verify the CopilotSidebar opens by default with title "AI Recipe Assistant"
|
||||
- [ ] Send a message via the sidebar
|
||||
- [ ] Verify the agent responds
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Initial Recipe State
|
||||
|
||||
- [ ] Verify the recipe title input shows "Make Your Recipe"
|
||||
- [ ] Verify the cooking time dropdown defaults to "45 min"
|
||||
- [ ] Verify the skill level dropdown defaults to "Intermediate"
|
||||
- [ ] Verify the default ingredients are displayed:
|
||||
- [ ] Carrots (3 large, grated) with carrot emoji
|
||||
- [ ] All-Purpose Flour (2 cups) with wheat emoji
|
||||
- [ ] Verify the default instruction is displayed: "Preheat oven to 350 F"
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify "Create Italian recipe" suggestion is visible
|
||||
- [ ] Verify "Make it healthier" suggestion is visible
|
||||
- [ ] Verify "Suggest variations" suggestion is visible
|
||||
|
||||
#### Recipe Editing (Local State)
|
||||
|
||||
- [ ] Edit the recipe title and verify it updates
|
||||
- [ ] Change the skill level dropdown and verify it updates
|
||||
- [ ] Change the cooking time dropdown and verify it updates
|
||||
- [ ] Toggle a dietary preference checkbox (e.g. "Vegetarian") and verify it's checked
|
||||
- [ ] Click "+ Add Ingredient" (`data-testid="add-ingredient-button"`) and verify a new empty row appears
|
||||
- [ ] Edit an ingredient name and amount
|
||||
- [ ] Remove an ingredient by clicking the "x" button
|
||||
- [ ] Click "+ Add Step" and verify a new instruction row appears
|
||||
- [ ] Edit an instruction and verify it saves
|
||||
- [ ] Remove an instruction by clicking the "x" button
|
||||
|
||||
#### AI-Powered Recipe Updates (useAgent with shared state)
|
||||
|
||||
- [ ] Click "Create Italian recipe" suggestion
|
||||
- [ ] Verify the agent updates the recipe title, ingredients, and instructions
|
||||
- [ ] Verify the ping indicator appears on changed sections
|
||||
- [ ] Verify the "Improve with AI" button (`data-testid="improve-button"`) changes to "Please Wait..." while loading
|
||||
- [ ] Click "Improve with AI" and verify the recipe is enhanced
|
||||
|
||||
#### Agent Reads Frontend State
|
||||
|
||||
- [ ] Edit the recipe (change title, add ingredients)
|
||||
- [ ] Ask the agent "What recipe am I making?"
|
||||
- [ ] Verify the agent's response references the current recipe state
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send an empty message (should be handled gracefully)
|
||||
- [ ] Verify no console errors during normal usage
|
||||
- [ ] Verify the "Improve with AI" button is disabled while loading
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Recipe card and sidebar load within 3 seconds
|
||||
- Agent responds within 10 seconds
|
||||
- Recipe state syncs bidirectionally between UI and agent
|
||||
- Ping indicators highlight changed sections
|
||||
- No UI errors or broken layouts
|
||||
@@ -0,0 +1,41 @@
|
||||
# QA: State Streaming — LangGraph (FastAPI)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to the shared-state-streaming demo page
|
||||
- [ ] Verify the chat interface loads with title "State Streaming"
|
||||
- [ ] Verify the chat input placeholder "Type a message..." is visible
|
||||
- [ ] Send a basic message (e.g. "Hello! What can you do?")
|
||||
- [ ] Verify the agent responds
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify "Get started" suggestion button is visible
|
||||
|
||||
#### Note: Stub Demo
|
||||
|
||||
> **Status: Stub** — This demo is currently a stub (TODO: implement)
|
||||
|
||||
- [ ] Verify the basic CopilotChat loads and accepts messages
|
||||
- [ ] Verify the agent responds to messages
|
||||
- [ ] No custom UI components are expected beyond the chat interface
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send an empty message (should be handled gracefully)
|
||||
- [ ] Verify no console errors during normal usage
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat loads within 3 seconds
|
||||
- Agent responds within 10 seconds
|
||||
- No UI errors or broken layouts
|
||||
@@ -0,0 +1,41 @@
|
||||
# QA: Shared State (Writing) — LangGraph (FastAPI)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to the shared-state-write demo page
|
||||
- [ ] Verify the chat interface loads with title "Shared State (Writing)"
|
||||
- [ ] Verify the chat input placeholder "Type a message..." is visible
|
||||
- [ ] Send a basic message (e.g. "Hello! What can you do?")
|
||||
- [ ] Verify the agent responds
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify "Get started" suggestion button is visible
|
||||
|
||||
#### Note: Stub Demo
|
||||
|
||||
> **Status: Stub** — This demo is currently a stub (TODO: implement)
|
||||
|
||||
- [ ] Verify the basic CopilotChat loads and accepts messages
|
||||
- [ ] Verify the agent responds to messages
|
||||
- [ ] No custom UI components are expected beyond the chat interface
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send an empty message (should be handled gracefully)
|
||||
- [ ] Verify no console errors during normal usage
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat loads within 3 seconds
|
||||
- Agent responds within 10 seconds
|
||||
- No UI errors or broken layouts
|
||||
@@ -0,0 +1,70 @@
|
||||
# QA: Sub-Agents — LangGraph (FastAPI)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check `/api/copilotkit` GET — `agent_status: "reachable"`)
|
||||
- `OPENAI_API_KEY` is set in the agent environment
|
||||
|
||||
## What this demo proves
|
||||
|
||||
- A supervisor LLM exposes three sub-agents (`research_agent`,
|
||||
`writing_agent`, `critique_agent`) as tools.
|
||||
- Each sub-agent is a real `create_agent(...)` with its own system
|
||||
prompt — the supervisor does NOT just template-fill responses.
|
||||
- Every delegation appends an entry to the `delegations` slot of agent
|
||||
state, which the UI renders live as a delegation log.
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Page loads with delegation log + chat
|
||||
|
||||
- [ ] Navigate to `/demos/subagents`
|
||||
- [ ] The delegation log (`data-testid="delegation-log"`) is visible on the left
|
||||
- [ ] Header reads "Sub-agent delegations"
|
||||
- [ ] Counter (`data-testid="delegation-count"`) reads "0 calls"
|
||||
- [ ] Empty state copy: "Ask the supervisor to complete a task..."
|
||||
- [ ] Chat is visible on the right with placeholder "Give the supervisor a task..."
|
||||
|
||||
### 2. Supervisor running indicator
|
||||
|
||||
- [ ] Send the "Write a blog post" suggestion
|
||||
- [ ] Within ~2 seconds the `supervisor-running` badge appears on the log header
|
||||
- [ ] The badge has the pulsing dot and "Supervisor running" label
|
||||
|
||||
### 3. Delegations appear live
|
||||
|
||||
- [ ] As the run progresses, `delegation-entry` rows appear one at a time
|
||||
- [ ] Counter increments accordingly (1 calls, 2 calls, 3 calls)
|
||||
- [ ] Typical sequence on the "Write a blog post" suggestion:
|
||||
- [ ] First entry shows the Research badge (`🔎 Research`)
|
||||
- [ ] Second entry shows the Writing badge (`✍️ Writing`)
|
||||
- [ ] Third entry shows the Critique badge (`🧐 Critique`)
|
||||
- [ ] Each entry shows the task on one line and the sub-agent's result
|
||||
below it in the result panel
|
||||
- [ ] Each entry status shows `completed`
|
||||
|
||||
### 4. Result quality (sanity check)
|
||||
|
||||
- [ ] The research entry's result is a bulleted list of 3-5 facts
|
||||
- [ ] The writing entry's result is a single coherent paragraph
|
||||
- [ ] The critique entry's result is 2-3 bullet/critique items
|
||||
- [ ] When the supervisor finishes, the `supervisor-running` badge disappears
|
||||
|
||||
### 5. Multiple runs accumulate
|
||||
|
||||
- [ ] Send the "Explain a topic" suggestion next
|
||||
- [ ] Counter continues from previous total (does not reset)
|
||||
- [ ] New delegation entries are appended at the bottom
|
||||
|
||||
### 6. Error handling
|
||||
|
||||
- [ ] Sending an empty message is handled gracefully (no crash)
|
||||
- [ ] No console errors during a normal run
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat loads within 3 seconds
|
||||
- First delegation appears within ~10 seconds of sending a task
|
||||
- A typical 3-step plan completes in under 60 seconds
|
||||
- No UI errors, broken layouts, or console warnings
|
||||
@@ -0,0 +1,61 @@
|
||||
# QA: Tool Rendering — LangGraph (FastAPI)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to the tool-rendering demo page
|
||||
- [ ] Verify the chat interface loads in a centered full-height layout
|
||||
- [ ] Verify the chat input placeholder "Type a message" is visible
|
||||
- [ ] Send a basic message
|
||||
- [ ] Verify the agent responds
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify "Weather in San Francisco" suggestion button is visible
|
||||
- [ ] Verify "Weather in New York" suggestion button is visible
|
||||
- [ ] Verify "Weather in Tokyo" suggestion button is visible
|
||||
- [ ] Click a weather suggestion and verify it populates the input or sends the message
|
||||
|
||||
#### Weather Card Rendering (useRenderTool)
|
||||
|
||||
- [ ] Type "What's the weather in San Francisco?"
|
||||
- [ ] Verify loading state shows "Retrieving weather..." with a spinner
|
||||
- [ ] Verify the WeatherCard renders (`data-testid="weather-card"`) with:
|
||||
- [ ] City name displayed (`data-testid="weather-city"`)
|
||||
- [ ] Temperature in both Celsius and Fahrenheit
|
||||
- [ ] Humidity percentage (`data-testid="weather-humidity"`)
|
||||
- [ ] Wind speed in mph (`data-testid="weather-wind"`)
|
||||
- [ ] Feels-like temperature (`data-testid="weather-feels-like"`)
|
||||
- [ ] Conditions text with appropriate weather icon (sun/rain/cloud)
|
||||
- [ ] Verify the card background color matches the weather condition theme:
|
||||
- Clear/Sunny: #667eea (blue-purple)
|
||||
- Rain/Storm: #4A5568 (dark gray)
|
||||
- Cloudy: #718096 (medium gray)
|
||||
- Snow: #63B3ED (light blue)
|
||||
|
||||
#### Multiple Weather Queries
|
||||
|
||||
- [ ] Ask about weather in a second city
|
||||
- [ ] Verify a second WeatherCard renders without breaking the first
|
||||
- [ ] Verify each card shows the correct city name
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send an empty message (should be handled gracefully)
|
||||
- [ ] Verify no console errors during normal usage
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat loads within 3 seconds
|
||||
- Agent responds within 10 seconds
|
||||
- Weather cards render with all data fields populated
|
||||
- Weather icon and theme color match the conditions
|
||||
- No UI errors or broken layouts
|
||||
@@ -0,0 +1,13 @@
|
||||
copilotkit==0.1.94
|
||||
langchain==1.2.15
|
||||
langchain-openai==1.1.9
|
||||
langchain-anthropic==1.4.1
|
||||
langgraph==1.1.6
|
||||
langgraph-cli[inmem]==0.4.21
|
||||
langgraph-api==0.7.101
|
||||
langsmith==0.7.33
|
||||
openai==1.109.1
|
||||
ag-ui-langgraph[fastapi]==0.0.41
|
||||
ag-ui-protocol==0.1.18
|
||||
pypdf>=4.0.0
|
||||
deepagents>=0.5.3,<0.6.0
|
||||
@@ -0,0 +1,432 @@
|
||||
"""_cvdiag_backend.py — schema-v1 backend CVDIAG emitter for langgraph-fastapi.
|
||||
|
||||
This is the langgraph-fastapi realization of the §3 backend layer: it wires
|
||||
the 11 backend boundaries through the shared ``_shared.cvdiag_bootstrap.emit_cvdiag``
|
||||
single-source emitter. It is the middleware-style sibling of the langgraph-python
|
||||
(LGP) emitter (same ``AgentMiddleware`` wrap path, different ``slug``). It runs
|
||||
ALONGSIDE the legacy free-form ``_cvdiag()`` log lines in
|
||||
``_header_forwarding_middleware.py`` (dual-emit during the transition):
|
||||
|
||||
- legacy ``_cvdiag()`` keeps writing the human-grep ``CVDIAG component=...`` line,
|
||||
- this module writes the structured schema-v1 ``CVDIAG {json}`` envelope.
|
||||
|
||||
Guard: every emit here is gated on ``CVDIAG_BACKEND_EMITTER=1`` (default OFF). With
|
||||
the guard off this module is a pure no-op — it never validates, never writes, never
|
||||
throws into the observed boundary.
|
||||
|
||||
The 11 backend boundaries (spec §3 / §5):
|
||||
backend.request.ingress, backend.agent.enter, backend.llm.call.start,
|
||||
backend.llm.call.heartbeat (VERBOSE tier — periodic ~10s asyncio task),
|
||||
backend.llm.call.response, backend.sse.first_byte, backend.sse.event (DEBUG tier),
|
||||
backend.sse.aborted, backend.agent.exit, backend.response.complete,
|
||||
backend.error.caught.
|
||||
|
||||
Pure instrumentation: like the shared emitter, nothing here raises into the caller;
|
||||
the one place we ``await`` (the heartbeat task) is cancelled cleanly in a finally.
|
||||
|
||||
Plan unit: L1-D3 (mirrors L1-I).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from _shared.cvdiag_bootstrap import _resolve_tier, current_tier, emit_cvdiag
|
||||
|
||||
# ── Tier gating ───────────────────────────────────────────────────────────────
|
||||
# The shared bootstrap already resolved the tier (default | verbose | debug) and
|
||||
# applied the §6 fail-closed DEBUG guard. We mirror the §6 tier matrix locally so
|
||||
# VERBOSE-only (heartbeat) and DEBUG-only (sse.event) boundaries are suppressed at
|
||||
# the wrong tier rather than relying on the consumer to filter.
|
||||
_VERBOSE_TIERS = frozenset({"verbose", "debug"})
|
||||
_DEBUG_TIERS = frozenset({"debug"})
|
||||
|
||||
_SLUG = "langgraph-fastapi"
|
||||
_HEARTBEAT_INTERVAL_S = 10.0
|
||||
|
||||
|
||||
def emitter_enabled() -> bool:
|
||||
"""True when the schema-v1 backend emitter is armed (``CVDIAG_BACKEND_EMITTER=1``).
|
||||
|
||||
Default OFF: a missing/any-other value disables every emit in this module.
|
||||
"""
|
||||
return os.environ.get("CVDIAG_BACKEND_EMITTER") == "1"
|
||||
|
||||
|
||||
def _active_tier() -> str:
|
||||
"""Resolve the verbosity tier from a LIVE env read.
|
||||
|
||||
``emitter_enabled()`` reads ``CVDIAG_BACKEND_EMITTER`` live, so the tier MUST
|
||||
be read from the same live source — otherwise flipping ``CVDIAG_VERBOSE`` /
|
||||
``CVDIAG_DEBUG`` AFTER import arms the emitter but the tier stays frozen at
|
||||
the import-time ``setup()`` value, silently no-op'ing every verbose/debug-
|
||||
gated boundary (heartbeat, sse.event). We reuse the bootstrap's
|
||||
``_resolve_tier`` so the §6 fail-closed DEBUG guard still applies (a
|
||||
production / unresolved DEBUG request raises → degrade to the frozen tier).
|
||||
"""
|
||||
try:
|
||||
return _resolve_tier(dict(os.environ))
|
||||
except RuntimeError:
|
||||
# Fail-closed DEBUG refusal: fall back to the import-time resolved tier
|
||||
# (never silently escalate to debug in production).
|
||||
return current_tier()
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _mono_ns() -> int:
|
||||
return time.monotonic_ns()
|
||||
|
||||
|
||||
def _span_id() -> str:
|
||||
"""16 hex chars (8 random bytes) — matches SPAN_ID_PATTERN."""
|
||||
return uuid.uuid4().hex[:16]
|
||||
|
||||
|
||||
def _coerce_test_id(raw: Optional[str]) -> str:
|
||||
"""Return a schema-valid UUIDv7 ``test_id``.
|
||||
|
||||
When the inbound header carries a valid UUIDv7 we keep it (this is the
|
||||
propagation we are measuring). When it is absent/malformed we synthesize a
|
||||
deterministic-shape UUIDv7 so the envelope still validates — but the
|
||||
propagation gate measures the *inbound* presence, not this fallback.
|
||||
"""
|
||||
from _shared.cvdiag_schema import (
|
||||
TEST_ID_PATTERN,
|
||||
) # local import: cheap, avoids cycle
|
||||
import re
|
||||
|
||||
if isinstance(raw, str) and re.match(TEST_ID_PATTERN, raw):
|
||||
return raw
|
||||
# Synthesize a UUIDv7-shaped value (version nibble 7, variant 8..b).
|
||||
hexs = uuid.uuid4().hex
|
||||
return f"{hexs[0:8]}-{hexs[8:12]}-7{hexs[13:16]}-8{hexs[17:20]}-{hexs[20:32]}"
|
||||
|
||||
|
||||
def extract_test_id(headers: Dict[str, str]) -> Optional[str]:
|
||||
"""Return the inbound ``x-test-id`` header value, or ``None`` when absent.
|
||||
|
||||
This is the raw inbound value used by the propagation-reliability gate — it
|
||||
is NOT coerced/synthesized here so the gate can measure true propagation.
|
||||
"""
|
||||
raw = headers.get("x-test-id")
|
||||
return raw if isinstance(raw, str) and raw else None
|
||||
|
||||
|
||||
def _empty_edge_headers() -> Dict[str, Any]:
|
||||
return {
|
||||
"cf-ray": None,
|
||||
"cf-mitigated": None,
|
||||
"cf-cache-status": None,
|
||||
"x-railway-edge": None,
|
||||
"x-railway-request-id": None,
|
||||
"x-hikari-trace": None,
|
||||
"retry-after": None,
|
||||
"via": None,
|
||||
"server": None,
|
||||
}
|
||||
|
||||
|
||||
def _edge_headers_from(headers: Dict[str, str]) -> Dict[str, Any]:
|
||||
"""Project the inbound header bag onto the closed 9-key edge-header shape.
|
||||
|
||||
Only the 9 allow-listed keys are carried; everything else is dropped (the
|
||||
envelope's per-boundary model + EdgeHeaders ``extra=forbid`` enforce this).
|
||||
"""
|
||||
allow = _empty_edge_headers()
|
||||
for key in list(allow.keys()):
|
||||
val = headers.get(key)
|
||||
if isinstance(val, str) and val:
|
||||
allow[key] = val
|
||||
return allow
|
||||
|
||||
|
||||
def _emit(
|
||||
boundary: str,
|
||||
*,
|
||||
headers: Dict[str, str],
|
||||
trace_id: str,
|
||||
outcome: str = "ok",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
demo: str = "chat",
|
||||
tier_gate: Optional[frozenset] = None,
|
||||
) -> None:
|
||||
"""Build + emit one schema-v1 envelope, guarded + tier-filtered.
|
||||
|
||||
No-op when the emitter is disabled OR the boundary's tier gate excludes the
|
||||
resolved tier. Never raises (delegates to the shared emitter's safety).
|
||||
"""
|
||||
if not emitter_enabled():
|
||||
return
|
||||
if tier_gate is not None and _active_tier() not in tier_gate:
|
||||
return
|
||||
envelope = {
|
||||
"schema_version": 1,
|
||||
"test_id": _coerce_test_id(headers.get("x-test-id")),
|
||||
"trace_id": trace_id,
|
||||
"span_id": _span_id(),
|
||||
"parent_span_id": None,
|
||||
"layer": "backend",
|
||||
"boundary": boundary,
|
||||
"slug": _SLUG,
|
||||
"demo": demo,
|
||||
"ts": _now_iso(),
|
||||
"mono_ns": _mono_ns(),
|
||||
"duration_ms": duration_ms,
|
||||
"outcome": outcome,
|
||||
"edge_headers": _edge_headers_from(headers),
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
emit_cvdiag(envelope)
|
||||
|
||||
|
||||
class CvdiagBackendRun:
|
||||
"""Per-model-call CVDIAG run context for the LGP middleware.
|
||||
|
||||
Constructed inside ``awrap_model_call`` (and the sync ``wrap_model_call``);
|
||||
owns the trace correlation id, the ingress monotonic anchor, and the
|
||||
heartbeat asyncio task. All methods are no-ops when the emitter is disabled.
|
||||
"""
|
||||
|
||||
def __init__(self, headers: Dict[str, str]) -> None:
|
||||
self._headers = dict(headers)
|
||||
# Correlate every boundary in this run under one trace_id. Prefer the
|
||||
# inbound x-diag-run-id breadcrumb so probe/backend rows join; fall back
|
||||
# to a synthesized id.
|
||||
self._trace_id = (
|
||||
headers.get("x-diag-run-id") or headers.get("x-test-id") or uuid.uuid4().hex
|
||||
)
|
||||
self._ingress_mono = _mono_ns()
|
||||
self._first_byte_emitted = False
|
||||
self._sse_seq = 0
|
||||
self._heartbeat_task: Optional[asyncio.Task] = None
|
||||
|
||||
# ── Lifecycle boundaries ──────────────────────────────────────────────
|
||||
def request_ingress(self) -> None:
|
||||
_emit(
|
||||
"backend.request.ingress",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
metadata={"method": "POST", "path": "/threads", "content_length": None},
|
||||
tier_gate=_VERBOSE_TIERS,
|
||||
)
|
||||
|
||||
def agent_enter(
|
||||
self, agent_name: Optional[str] = None, model_id: Optional[str] = None
|
||||
) -> None:
|
||||
_emit(
|
||||
"backend.agent.enter",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
metadata={"agent_name": agent_name, "model_id": model_id},
|
||||
)
|
||||
|
||||
def llm_call_start(
|
||||
self, provider: Optional[str] = None, model: Optional[str] = None
|
||||
) -> None:
|
||||
_emit(
|
||||
"backend.llm.call.start",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
metadata={
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"prompt_token_count_estimate": None,
|
||||
},
|
||||
tier_gate=_VERBOSE_TIERS,
|
||||
)
|
||||
|
||||
def llm_call_response(
|
||||
self,
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
latency_ms: Optional[int] = None,
|
||||
error_class: Optional[str] = None,
|
||||
) -> None:
|
||||
_emit(
|
||||
"backend.llm.call.response",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
outcome="err" if error_class else "ok",
|
||||
metadata={
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"response_token_count": None,
|
||||
"latency_ms": latency_ms,
|
||||
"error_class": error_class,
|
||||
},
|
||||
tier_gate=_VERBOSE_TIERS,
|
||||
)
|
||||
|
||||
def sse_first_byte(self) -> None:
|
||||
"""Emit ``backend.sse.first_byte`` once, with the ingress→first-byte delta."""
|
||||
if self._first_byte_emitted:
|
||||
return
|
||||
self._first_byte_emitted = True
|
||||
delta_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
|
||||
_emit(
|
||||
"backend.sse.first_byte",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
metadata={"delta_ms_from_ingress": delta_ms},
|
||||
tier_gate=_VERBOSE_TIERS,
|
||||
)
|
||||
|
||||
def sse_event(
|
||||
self, event_type: Optional[str] = None, payload_size_bytes: Optional[int] = None
|
||||
) -> None:
|
||||
"""Emit ``backend.sse.event`` (DEBUG tier — suppressed below debug)."""
|
||||
self._sse_seq += 1
|
||||
_emit(
|
||||
"backend.sse.event",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
metadata={
|
||||
"event_type": event_type,
|
||||
"payload_size_bytes": payload_size_bytes,
|
||||
"sequence_num": self._sse_seq,
|
||||
},
|
||||
tier_gate=_DEBUG_TIERS,
|
||||
)
|
||||
|
||||
def sse_aborted(
|
||||
self,
|
||||
termination_kind: Optional[str] = None,
|
||||
bytes_before_abort: Optional[int] = None,
|
||||
) -> None:
|
||||
_emit(
|
||||
"backend.sse.aborted",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
outcome="err",
|
||||
metadata={
|
||||
"termination_kind": termination_kind,
|
||||
"bytes_before_abort": bytes_before_abort,
|
||||
},
|
||||
)
|
||||
|
||||
def agent_exit(self, terminal_outcome: str = "ok") -> None:
|
||||
total_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
|
||||
_emit(
|
||||
"backend.agent.exit",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
outcome="err" if terminal_outcome == "err" else "ok",
|
||||
metadata={
|
||||
"terminal_outcome": terminal_outcome,
|
||||
"total_duration_ms": total_ms,
|
||||
},
|
||||
)
|
||||
|
||||
def response_complete(
|
||||
self,
|
||||
http_status: Optional[int] = 200,
|
||||
sse_event_count: Optional[int] = None,
|
||||
) -> None:
|
||||
total_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
|
||||
_emit(
|
||||
"backend.response.complete",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
metadata={
|
||||
"http_status": http_status,
|
||||
"content_length": None,
|
||||
"total_duration_ms": total_ms,
|
||||
"sse_event_count": sse_event_count
|
||||
if sse_event_count is not None
|
||||
else self._sse_seq,
|
||||
},
|
||||
)
|
||||
|
||||
def error_caught(self, exc: BaseException) -> None:
|
||||
_emit(
|
||||
"backend.error.caught",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
outcome="err",
|
||||
metadata={
|
||||
"exception_type": type(exc).__name__,
|
||||
"message_scrubbed": "<scrubbed>",
|
||||
"stack_brief": None,
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
|
||||
# ── Heartbeat (VERBOSE tier — periodic asyncio task) ──────────────────
|
||||
async def _heartbeat_loop(self) -> None:
|
||||
"""Emit ``backend.llm.call.heartbeat`` every ~10s while the LLM call runs."""
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
|
||||
elapsed_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
|
||||
_emit(
|
||||
"backend.llm.call.heartbeat",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
outcome="info",
|
||||
metadata={"elapsed_ms_since_start": elapsed_ms},
|
||||
tier_gate=_VERBOSE_TIERS,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# Clean cancellation when the LLM call finishes — swallow.
|
||||
return
|
||||
|
||||
def start_heartbeat(self) -> None:
|
||||
"""Arm the heartbeat task (no-op when disabled or below VERBOSE tier)."""
|
||||
if not emitter_enabled() or _active_tier() not in _VERBOSE_TIERS:
|
||||
return
|
||||
if self._heartbeat_task is not None:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
self._heartbeat_task = loop.create_task(self._heartbeat_loop())
|
||||
|
||||
async def stop_heartbeat(self) -> None:
|
||||
"""Cancel + await the heartbeat task. Safe to call when never started.
|
||||
|
||||
Cooperative cancellation: the legacy ``except (CancelledError,
|
||||
Exception)`` swallowed the CALLER's CancelledError, breaking cooperative
|
||||
cancellation (a client-disconnect / request-cancel that arrives while we
|
||||
await the heartbeat task would be lost). We suppress ONLY the heartbeat
|
||||
task's OWN cancellation — the one we just requested — and re-raise when
|
||||
THIS task is being cancelled by the caller (a pending cancellation
|
||||
request, ``current_task().cancelling() > 0``). ``Task.cancelling()`` is
|
||||
3.11+ (production runs 3.12); on older runtimes the attribute is absent
|
||||
and we degrade to suppressing (the legacy behavior).
|
||||
"""
|
||||
task = self._heartbeat_task
|
||||
if task is None:
|
||||
return
|
||||
self._heartbeat_task = None
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
current = asyncio.current_task()
|
||||
cancelling = getattr(current, "cancelling", None)
|
||||
if current is not None and cancelling is not None and cancelling() > 0:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - heartbeat body must never throw out
|
||||
return
|
||||
|
||||
def emit_heartbeat_once(self) -> None:
|
||||
"""Synchronous single heartbeat emit (used by the sync wrap path + tests)."""
|
||||
elapsed_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
|
||||
_emit(
|
||||
"backend.llm.call.heartbeat",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
outcome="info",
|
||||
metadata={"elapsed_ms_since_start": elapsed_ms},
|
||||
tier_gate=_VERBOSE_TIERS,
|
||||
)
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
"""Minimal header-forwarding-only AgentMiddleware.
|
||||
|
||||
Some showcase demos (reasoning, tool-rendering-reasoning-chain, the
|
||||
sub-agents in `subagents.py`) intentionally avoid the full
|
||||
`CopilotKitMiddleware` because they don't need its frontend-tool
|
||||
injection, App-Context surfacing, or state-note features — they're
|
||||
minimal demos of LangGraph capabilities.
|
||||
|
||||
But every showcase request goes through aimock (the locally-served
|
||||
LLM mock), and aimock requires the ``x-aimock-context`` header (and
|
||||
friends) on every ``/v1/responses`` and ``/v1/chat/completions``
|
||||
request to match the right fixture. Without middleware to populate
|
||||
the header-forwarding ContextVar from the LangGraph RunnableConfig
|
||||
``configurable``, those requests go out without the header and aimock
|
||||
returns 404, breaking the demo.
|
||||
|
||||
This middleware does ONLY that header propagation — nothing else.
|
||||
It reuses copilotkit's own primitives (kept private but exported by
|
||||
the installed package at the module level) so the propagation logic
|
||||
is identical to the full middleware. No App-Context injection, no
|
||||
tool-merging, no state-to-prompt surfacing, no Bedrock message
|
||||
fix-up.
|
||||
|
||||
CVDIAG instrumentation (diagnostic only — DOES NOT change WHERE
|
||||
headers come from): after the existing
|
||||
``_extract_forwarded_headers_from_config()`` populates copilotkit's
|
||||
forwarded-headers ContextVar, we read it back via
|
||||
``get_forwarded_headers()`` and emit a structured ``CVDIAG`` log line
|
||||
at the configurable-read boundary recording whether
|
||||
``x-aimock-context`` actually arrived on the LangGraph configurable
|
||||
channel (``header_present=false`` is the alarm we are hunting). We
|
||||
also append this layer's hop tag to ``x-diag-hops`` on the SAME
|
||||
ContextVar the httpx hook already forwards from — so the breadcrumb
|
||||
and correlation headers (``x-diag-run-id``, ``x-diag-hops``) ride
|
||||
along on the outbound LLM call exactly the way ``x-aimock-context``
|
||||
does, without introducing any new forwarding source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# CVDIAG bootstrap — folded-in L1-H bootstrap. langgraph-fastapi has no
|
||||
# entrypoint of its own (the langgraph dev/server loads graphs from
|
||||
# langgraph.json), so this header-forwarding middleware module — imported by
|
||||
# every graph's middleware list — is the earliest shared import point. Importing
|
||||
# the bootstrap here configures the root logger so the legacy ``_cvdiag()`` lines
|
||||
# actually EMIT and resolves the verbosity tier + PB writer. It imports
|
||||
# pydantic/starlette only (NOT langchain/langgraph), so it is import-safe.
|
||||
import _shared.cvdiag_bootstrap # noqa: F401 (bootstrap side effects)
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable, Dict
|
||||
|
||||
from langchain.agents.middleware import (
|
||||
AgentMiddleware,
|
||||
AgentState,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
)
|
||||
|
||||
# Reuse the installed copilotkit's existing header-forwarding helpers so
|
||||
# the behaviour stays bit-identical to the full CopilotKitMiddleware's
|
||||
# header-propagation step. These are module-level functions in
|
||||
# copilotkit 0.1.94's copilotkit_lg_middleware module.
|
||||
from copilotkit.copilotkit_lg_middleware import (
|
||||
_extract_forwarded_headers_from_config,
|
||||
_ensure_httpx_hook,
|
||||
)
|
||||
|
||||
# CVDIAG-only: read/append the forwarded-header ContextVar copilotkit
|
||||
# already populates. set_forwarded_headers is used SOLELY to append the
|
||||
# diagnostic hop breadcrumb onto the SAME channel x-aimock-context rides;
|
||||
# it does not introduce a new forwarding source.
|
||||
from copilotkit.header_propagation import (
|
||||
get_forwarded_headers,
|
||||
set_forwarded_headers,
|
||||
)
|
||||
|
||||
# CVDIAG schema-v1 backend emitter (L1-D3, mirrors LGP's L1-I). Dual-emit: this
|
||||
# rides ALONGSIDE the legacy free-form _cvdiag() log lines below — it writes the
|
||||
# structured schema-v1 CVDIAG envelope through the shared single-source emitter,
|
||||
# guarded by CVDIAG_BACKEND_EMITTER (default OFF). With the guard off it is a
|
||||
# pure no-op.
|
||||
from src.agents.src._cvdiag_backend import CvdiagBackendRun
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CVDIAG_COMPONENT = "backend-langgraph-fastapi"
|
||||
_CVDIAG_HOP_TAG = "backend-langgraph-fastapi"
|
||||
|
||||
|
||||
def _cvdiag(
|
||||
boundary: str,
|
||||
headers: Dict[str, str],
|
||||
status: str,
|
||||
*,
|
||||
hop: Any = "-",
|
||||
error: str = "",
|
||||
) -> None:
|
||||
"""Emit a single CVDIAG log line in the shared cross-language convention.
|
||||
|
||||
Never logs full header values — only a 12-char prefix of
|
||||
``x-aimock-context``.
|
||||
"""
|
||||
slug = headers.get("x-aimock-context")
|
||||
header_present = isinstance(slug, str) and len(slug) > 0
|
||||
run_id = headers.get("x-diag-run-id", "none")
|
||||
test_id = headers.get("x-test-id", "none")
|
||||
prefix = slug[:12] if header_present else ""
|
||||
logger.info(
|
||||
"CVDIAG component=%s boundary=%s run_id=%s slug=%s "
|
||||
"header_present=%s header_value_prefix=%s hop=%s status=%s "
|
||||
"test_id=%s error=%s",
|
||||
_CVDIAG_COMPONENT,
|
||||
boundary,
|
||||
run_id,
|
||||
slug if header_present else "MISSING",
|
||||
str(header_present).lower(),
|
||||
prefix,
|
||||
hop,
|
||||
status,
|
||||
test_id,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
def _instrument_and_breadcrumb() -> None:
|
||||
"""Read the configurable-read result, log it, and append the diag hop.
|
||||
|
||||
Called immediately AFTER
|
||||
``_extract_forwarded_headers_from_config()`` has populated the
|
||||
ContextVar. Reads the headers back, emits the configurable-read
|
||||
CVDIAG line (wrapping the previously-silent "no x-aimock-context in
|
||||
configurable" case as an alarm), then — only when x-aimock-context
|
||||
is present — appends this layer's hop tag to ``x-diag-hops`` on the
|
||||
SAME ContextVar so the breadcrumb rides the existing forwarding path.
|
||||
"""
|
||||
headers = dict(get_forwarded_headers())
|
||||
has_context = (
|
||||
isinstance(headers.get("x-aimock-context"), str)
|
||||
and len(headers.get("x-aimock-context", "")) > 0
|
||||
)
|
||||
|
||||
if has_context:
|
||||
_cvdiag("configurable-read", headers, "ok")
|
||||
else:
|
||||
# The alarm we are hunting: the configurable channel reached this
|
||||
# middleware without x-aimock-context. Surface it instead of the
|
||||
# previous silent no-op.
|
||||
_cvdiag(
|
||||
"configurable-read",
|
||||
headers,
|
||||
"miss" if headers else "error",
|
||||
error="x-aimock-context-absent-in-configurable"
|
||||
if headers
|
||||
else "no-forwarded-headers-in-configurable",
|
||||
)
|
||||
# Nothing to breadcrumb onto — do not invent a forwarding source.
|
||||
return
|
||||
|
||||
# Append this layer's hop tag to x-diag-hops on the SAME ContextVar the
|
||||
# httpx hook forwards from. This rides the existing path; no new source.
|
||||
existing_hops = headers.get("x-diag-hops", "")
|
||||
headers["x-diag-hops"] = (
|
||||
f"{existing_hops},{_CVDIAG_HOP_TAG}"
|
||||
if isinstance(existing_hops, str) and existing_hops
|
||||
else _CVDIAG_HOP_TAG
|
||||
)
|
||||
set_forwarded_headers(headers)
|
||||
|
||||
hop = len([h for h in headers["x-diag-hops"].split(",") if h])
|
||||
_cvdiag("outbound-llm", headers, "ok", hop=hop)
|
||||
|
||||
|
||||
class HeaderForwardingMiddleware(AgentMiddleware[AgentState, Any]):
|
||||
"""AgentMiddleware that only forwards inbound x-* headers.
|
||||
|
||||
Behaviourally a no-op except for two calls inside both
|
||||
``wrap_model_call`` and ``awrap_model_call``:
|
||||
|
||||
1. ``_extract_forwarded_headers_from_config()`` — read the
|
||||
``x-*`` keys from the active LangGraph RunnableConfig
|
||||
(``context`` and ``configurable``) and populate the
|
||||
header-forwarding ContextVar.
|
||||
2. ``_ensure_httpx_hook(request.model)`` — install copilotkit's
|
||||
httpx event hook on the model's underlying HTTP client(s)
|
||||
so the next outgoing LLM request picks the headers up.
|
||||
|
||||
No App-Context injection, no tool-merging, no state-surfacing,
|
||||
no Bedrock message fix-up — strictly header propagation.
|
||||
|
||||
CVDIAG: ``_instrument_and_breadcrumb()`` is inserted between the
|
||||
two steps purely to OBSERVE the configurable-read boundary and tag
|
||||
the existing breadcrumb. It does not change where headers come from.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "HeaderForwardingMiddleware"
|
||||
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelResponse:
|
||||
_extract_forwarded_headers_from_config()
|
||||
_instrument_and_breadcrumb()
|
||||
_ensure_httpx_hook(request.model)
|
||||
|
||||
# CVDIAG schema-v1 dual-emit (L1-D3). No-op when CVDIAG_BACKEND_EMITTER off.
|
||||
headers = dict(get_forwarded_headers())
|
||||
run = CvdiagBackendRun(headers)
|
||||
model_name = _model_name(request)
|
||||
run.request_ingress()
|
||||
run.agent_enter(agent_name=self.name, model_id=model_name)
|
||||
run.llm_call_start(provider="langchain", model=model_name)
|
||||
run.emit_heartbeat_once()
|
||||
start_ns = time.monotonic_ns()
|
||||
try:
|
||||
response = handler(request)
|
||||
except BaseException as exc: # noqa: BLE001 - re-raised after observing
|
||||
run.error_caught(exc)
|
||||
run.agent_exit(terminal_outcome="err")
|
||||
raise
|
||||
latency_ms = int((time.monotonic_ns() - start_ns) / 1_000_000)
|
||||
run.llm_call_response(
|
||||
provider="langchain", model=model_name, latency_ms=latency_ms
|
||||
)
|
||||
run.sse_first_byte()
|
||||
run.sse_event(event_type="response", payload_size_bytes=None)
|
||||
run.agent_exit(terminal_outcome="ok")
|
||||
run.response_complete(http_status=200)
|
||||
return response
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelResponse:
|
||||
_extract_forwarded_headers_from_config()
|
||||
_instrument_and_breadcrumb()
|
||||
_ensure_httpx_hook(request.model)
|
||||
|
||||
# CVDIAG schema-v1 dual-emit (L1-D3). No-op when CVDIAG_BACKEND_EMITTER off.
|
||||
headers = dict(get_forwarded_headers())
|
||||
run = CvdiagBackendRun(headers)
|
||||
model_name = _model_name(request)
|
||||
run.request_ingress()
|
||||
run.agent_enter(agent_name=self.name, model_id=model_name)
|
||||
run.llm_call_start(provider="langchain", model=model_name)
|
||||
run.start_heartbeat()
|
||||
start_ns = time.monotonic_ns()
|
||||
try:
|
||||
response = await handler(request)
|
||||
except BaseException as exc: # noqa: BLE001 - re-raised after observing
|
||||
await run.stop_heartbeat()
|
||||
run.error_caught(exc)
|
||||
run.agent_exit(terminal_outcome="err")
|
||||
raise
|
||||
await run.stop_heartbeat()
|
||||
latency_ms = int((time.monotonic_ns() - start_ns) / 1_000_000)
|
||||
run.llm_call_response(
|
||||
provider="langchain", model=model_name, latency_ms=latency_ms
|
||||
)
|
||||
run.sse_first_byte()
|
||||
run.sse_event(event_type="response", payload_size_bytes=None)
|
||||
run.agent_exit(terminal_outcome="ok")
|
||||
run.response_complete(http_status=200)
|
||||
return response
|
||||
|
||||
|
||||
def _model_name(request: ModelRequest) -> str:
|
||||
"""Best-effort model identifier off the ModelRequest (never raises)."""
|
||||
try:
|
||||
model = getattr(request, "model", None)
|
||||
for attr in ("model_name", "model", "model_id"):
|
||||
val = getattr(model, attr, None)
|
||||
if isinstance(val, str) and val:
|
||||
return val
|
||||
except Exception: # noqa: BLE001 - instrumentation must not throw
|
||||
pass
|
||||
return "unknown"
|
||||
@@ -0,0 +1,26 @@
|
||||
"""LangGraph agent for the Declarative Generative UI (A2UI — Dynamic Schema) demo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a demo assistant for Declarative Generative UI (A2UI — Dynamic "
|
||||
"Schema). Whenever a response would benefit from a rich visual — a "
|
||||
"dashboard, status report, KPI summary, card layout, info grid, a "
|
||||
"pie/donut chart of part-of-whole breakdowns, or a bar chart comparing "
|
||||
"values across categories — call `generate_a2ui` to draw it. The tool "
|
||||
"renders the surface automatically from the registered component catalog; "
|
||||
"keep chat replies to one short sentence and let the UI do the talking."
|
||||
)
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4.1"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
LangGraph agent for the Declarative Generative UI (A2UI — Fixed Schema) demo.
|
||||
|
||||
Fixed-schema A2UI: the component tree (schema) is authored ahead of time as
|
||||
JSON and loaded at startup via `a2ui.load_schema(...)`. The agent only
|
||||
streams *data* into the data model at runtime. The frontend registers a
|
||||
matching catalog (see `src/app/demos/a2ui-fixed-schema/a2ui/catalog.ts`)
|
||||
that pins the schema's component names to real React implementations.
|
||||
|
||||
Reference:
|
||||
examples/integrations/langgraph-python/agent/src/a2ui_fixed_schema.py
|
||||
"""
|
||||
|
||||
# @region[backend-render-operations]
|
||||
# @region[backend-schema-json-load]
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
from copilotkit import CopilotKitMiddleware, a2ui
|
||||
from langchain.agents import create_agent
|
||||
from langchain.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
CATALOG_ID = "copilotkit://flight-fixed-catalog"
|
||||
SURFACE_ID = "flight-fixed-schema"
|
||||
|
||||
_SCHEMAS_DIR = Path(__file__).parent / "a2ui_schemas"
|
||||
|
||||
# Schemas are JSON so they can be authored and reviewed independently of the
|
||||
# Python code. `a2ui.load_schema` is just a thin `json.load` wrapper.
|
||||
FLIGHT_SCHEMA = a2ui.load_schema(_SCHEMAS_DIR / "flight_schema.json")
|
||||
BOOKED_SCHEMA = a2ui.load_schema(_SCHEMAS_DIR / "booked_schema.json")
|
||||
# @endregion[backend-schema-json-load]
|
||||
|
||||
|
||||
class Flight(TypedDict):
|
||||
"""Shape the LLM should fill in when calling `display_flight`.
|
||||
|
||||
LangGraph serializes this TypedDict into the tool's JSON schema, so
|
||||
defining it narrowly is how we steer the LLM to produce data that fits
|
||||
the frontend `FlightCard` component's props.
|
||||
"""
|
||||
|
||||
origin: str
|
||||
destination: str
|
||||
airline: str
|
||||
price: str
|
||||
|
||||
|
||||
@tool
|
||||
def display_flight(origin: str, destination: str, airline: str, price: str) -> str:
|
||||
"""Show a flight card for the given trip.
|
||||
|
||||
Use short airport codes (e.g. "SFO", "JFK") for origin/destination and a
|
||||
price string like "$289".
|
||||
"""
|
||||
# The A2UI middleware detects the `a2ui_operations` container in this
|
||||
# tool result and forwards the ops to the frontend renderer. The frontend
|
||||
# catalog resolves component names to the local React components.
|
||||
return a2ui.render(
|
||||
operations=[
|
||||
a2ui.create_surface(SURFACE_ID, catalog_id=CATALOG_ID),
|
||||
a2ui.update_components(SURFACE_ID, FLIGHT_SCHEMA),
|
||||
a2ui.update_data_model(
|
||||
SURFACE_ID,
|
||||
{
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"airline": airline,
|
||||
"price": price,
|
||||
},
|
||||
),
|
||||
],
|
||||
# NOTE: The canonical reference (and the docs at
|
||||
# docs/integrations/langgraph/generative-ui/a2ui/fixed-schema.mdx)
|
||||
# also pass `action_handlers={...}` here to declare optimistic UI
|
||||
# transitions — e.g. swapping to BOOKED_SCHEMA when the card's
|
||||
# `book_flight` button is clicked. The Python SDK's `a2ui.render`
|
||||
# does not yet accept that kwarg (see sdk-python/copilotkit/a2ui.py),
|
||||
# so we omit it for now. The `booked_schema.json` sibling is kept
|
||||
# so the schema is ready to wire up once the SDK exposes handlers.
|
||||
)
|
||||
# @endregion[backend-render-operations]
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[display_flight],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=(
|
||||
"You help users find flights. When asked about a flight, call "
|
||||
"display_flight with origin, destination, airline, and price. "
|
||||
"Keep any chat reply to one short sentence."
|
||||
),
|
||||
)
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"id": "root",
|
||||
"component": "Column",
|
||||
"gap": 8,
|
||||
"children": ["title", "detail"]
|
||||
},
|
||||
{
|
||||
"id": "title",
|
||||
"component": "Text",
|
||||
"text": { "path": "/title" },
|
||||
"variant": "h2"
|
||||
},
|
||||
{
|
||||
"id": "detail",
|
||||
"component": "Text",
|
||||
"text": { "path": "/detail" },
|
||||
"variant": "body"
|
||||
}
|
||||
]
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
[
|
||||
{
|
||||
"id": "root",
|
||||
"component": "Card",
|
||||
"child": "content"
|
||||
},
|
||||
{
|
||||
"id": "content",
|
||||
"component": "Column",
|
||||
"children": ["title", "route", "meta", "bookButton"]
|
||||
},
|
||||
{
|
||||
"id": "title",
|
||||
"component": "Title",
|
||||
"text": "Flight Details"
|
||||
},
|
||||
{
|
||||
"id": "route",
|
||||
"component": "Row",
|
||||
"justify": "spaceBetween",
|
||||
"align": "center",
|
||||
"children": ["from", "arrow", "to"]
|
||||
},
|
||||
{
|
||||
"id": "from",
|
||||
"component": "Airport",
|
||||
"code": { "path": "/origin" }
|
||||
},
|
||||
{
|
||||
"id": "arrow",
|
||||
"component": "Arrow"
|
||||
},
|
||||
{
|
||||
"id": "to",
|
||||
"component": "Airport",
|
||||
"code": { "path": "/destination" }
|
||||
},
|
||||
{
|
||||
"id": "meta",
|
||||
"component": "Row",
|
||||
"justify": "spaceBetween",
|
||||
"align": "center",
|
||||
"children": ["airline", "price"]
|
||||
},
|
||||
{
|
||||
"id": "airline",
|
||||
"component": "AirlineBadge",
|
||||
"name": { "path": "/airline" }
|
||||
},
|
||||
{
|
||||
"id": "price",
|
||||
"component": "PriceTag",
|
||||
"amount": { "path": "/price" }
|
||||
},
|
||||
{
|
||||
"id": "bookButton",
|
||||
"component": "Button",
|
||||
"variant": "primary",
|
||||
"child": "bookButtonLabel",
|
||||
"action": {
|
||||
"event": {
|
||||
"name": "book_flight",
|
||||
"context": {
|
||||
"origin": { "path": "/origin" },
|
||||
"destination": { "path": "/destination" },
|
||||
"airline": { "path": "/airline" },
|
||||
"price": { "path": "/price" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bookButtonLabel",
|
||||
"component": "Text",
|
||||
"text": "Book flight"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
LangGraph agent for the CopilotKit Showcase (FastAPI variant).
|
||||
|
||||
Uses copilotkit's create_agent (wrapping langgraph) with CopilotKitMiddleware
|
||||
so frontend-registered tools (useHumanInTheLoop, useFrontendTool) are properly
|
||||
injected into the LLM's tool list and executed on the frontend rather than
|
||||
locally.
|
||||
"""
|
||||
|
||||
from tools import (
|
||||
get_weather_impl,
|
||||
query_data_impl,
|
||||
schedule_meeting_impl,
|
||||
manage_sales_todos_impl,
|
||||
get_sales_todos_impl,
|
||||
search_flights_impl,
|
||||
build_a2ui_operations_from_tool_call,
|
||||
)
|
||||
from tools.types import SalesTodo, Flight
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_core.tools import tool as lc_tool
|
||||
from langchain_core.messages import SystemMessage
|
||||
from langchain.agents import AgentState as BaseAgentState, create_agent
|
||||
from langchain.tools import ToolRuntime, tool
|
||||
from langchain.messages import ToolMessage
|
||||
from langgraph.types import Command
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
class AgentState(BaseAgentState):
|
||||
todos: list[SalesTodo]
|
||||
|
||||
|
||||
@lc_tool
|
||||
def get_weather(location: str):
|
||||
"""Get the current weather for a location."""
|
||||
return get_weather_impl(location)
|
||||
|
||||
|
||||
@lc_tool
|
||||
def query_data(query: str):
|
||||
"""Query the database. Takes natural language. Always call before showing a chart."""
|
||||
return query_data_impl(query)
|
||||
|
||||
|
||||
@lc_tool
|
||||
def schedule_meeting(reason: str, duration_minutes: int = 30):
|
||||
"""Schedule a meeting. The user will be asked to pick a time via the UI."""
|
||||
return schedule_meeting_impl(reason, duration_minutes)
|
||||
|
||||
|
||||
@lc_tool
|
||||
def search_flights(flights: list[Flight]) -> str:
|
||||
"""Search for flights and display the results as rich cards. Return exactly 2 flights.
|
||||
|
||||
Each flight must have: airline, airlineLogo, flightNumber, origin, destination,
|
||||
date (short readable format like "Tue, Mar 18" -- use near-future dates),
|
||||
departureTime, arrivalTime, duration (e.g. "4h 25m"),
|
||||
status (e.g. "On Time" or "Delayed"),
|
||||
statusColor (hex color for status dot),
|
||||
price (e.g. "$289"), and currency (e.g. "USD").
|
||||
|
||||
For airlineLogo use Google favicon API:
|
||||
https://www.google.com/s2/favicons?domain={airline_domain}&sz=128
|
||||
"""
|
||||
result = search_flights_impl(flights)
|
||||
return json.dumps(result)
|
||||
|
||||
|
||||
@tool
|
||||
def manage_sales_todos(todos: list[SalesTodo], runtime: ToolRuntime) -> Command:
|
||||
"""
|
||||
Manage the current sales todos. Pass the full updated list.
|
||||
"""
|
||||
updated = manage_sales_todos_impl(todos)
|
||||
return Command(
|
||||
update={
|
||||
"todos": updated,
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
content="Successfully updated sales todos",
|
||||
tool_call_id=runtime.tool_call_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def get_sales_todos(runtime: ToolRuntime):
|
||||
"""
|
||||
Get the current sales todos.
|
||||
"""
|
||||
current = runtime.state.get("todos", [])
|
||||
return get_sales_todos_impl(current if current else None)
|
||||
|
||||
|
||||
@lc_tool
|
||||
def render_a2ui(
|
||||
surfaceId: str,
|
||||
catalogId: str,
|
||||
components: list[dict],
|
||||
data: dict | None = None,
|
||||
) -> str:
|
||||
"""Render a dynamic A2UI v0.9 surface."""
|
||||
return "rendered"
|
||||
|
||||
|
||||
@tool()
|
||||
def generate_a2ui(runtime: ToolRuntime[Any]) -> str:
|
||||
"""Generate dynamic A2UI components based on the conversation.
|
||||
|
||||
A secondary LLM designs the UI schema and data.
|
||||
"""
|
||||
t0 = time.time()
|
||||
messages = runtime.state["messages"][:-1]
|
||||
context_entries = runtime.state.get("copilotkit", {}).get("context", [])
|
||||
context_text = "\n\n".join(
|
||||
entry.get("value", "")
|
||||
for entry in context_entries
|
||||
if isinstance(entry, dict) and entry.get("value")
|
||||
)
|
||||
|
||||
model = ChatOpenAI(model="gpt-4.1")
|
||||
model_with_tool = model.bind_tools([render_a2ui], tool_choice="render_a2ui")
|
||||
response = model_with_tool.invoke(
|
||||
[SystemMessage(content=context_text), *messages],
|
||||
)
|
||||
|
||||
if not response.tool_calls:
|
||||
return json.dumps({"error": "LLM did not call render_a2ui"})
|
||||
|
||||
args = response.tool_calls[0]["args"]
|
||||
result = build_a2ui_operations_from_tool_call(args)
|
||||
return json.dumps(result)
|
||||
|
||||
|
||||
model = ChatOpenAI(model="gpt-4o-mini")
|
||||
|
||||
SYSTEM_PROMPT = """You are a polished, professional demo assistant for CopilotKit.
|
||||
Keep responses brief and clear -- 1 to 2 sentences max.
|
||||
|
||||
You can:
|
||||
- Chat naturally with the user
|
||||
- Change the UI background when asked (via frontend tool)
|
||||
- Query data and render charts (via query_data tool)
|
||||
- Get weather information (via get_weather tool)
|
||||
- Schedule meetings with the user (via schedule_meeting tool -- the user picks a time in the UI)
|
||||
- Manage sales pipeline todos (via manage_sales_todos / get_sales_todos tools)
|
||||
- Search flights and display rich A2UI cards (via search_flights tool)
|
||||
- Generate dynamic A2UI dashboards from conversation context (via generate_a2ui tool)
|
||||
- Generate step-by-step plans for user review (human-in-the-loop)
|
||||
"""
|
||||
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=[
|
||||
get_weather,
|
||||
query_data,
|
||||
schedule_meeting,
|
||||
search_flights,
|
||||
generate_a2ui,
|
||||
manage_sales_todos,
|
||||
get_sales_todos,
|
||||
],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
state_schema=AgentState,
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""LangGraph agent backing the Agent Config Object demo.
|
||||
|
||||
Reads three forwarded properties — tone, expertise, responseLength — from the
|
||||
LangGraph run's ``RunnableConfig["configurable"]["properties"]`` dict and
|
||||
builds its system prompt dynamically per turn.
|
||||
|
||||
The CopilotKit provider's ``properties`` prop is wired through the runtime as
|
||||
``forwardedProps`` on each AG-UI run. This graph reads those with defensive
|
||||
defaults (unknown / missing values fall back to the defaults) and composes the
|
||||
system prompt from three small rulebooks before invoking the model.
|
||||
"""
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.graph import END, START, MessagesState, StateGraph
|
||||
|
||||
|
||||
_llm: ChatOpenAI | None = None
|
||||
|
||||
|
||||
def _get_llm() -> ChatOpenAI:
|
||||
"""Lazy-instantiate the LLM so importing this module (e.g. in unit tests)
|
||||
does not require ``OPENAI_API_KEY`` to be set."""
|
||||
global _llm
|
||||
if _llm is None:
|
||||
_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.4)
|
||||
return _llm
|
||||
|
||||
|
||||
Tone = Literal["professional", "casual", "enthusiastic"]
|
||||
Expertise = Literal["beginner", "intermediate", "expert"]
|
||||
ResponseLength = Literal["concise", "detailed"]
|
||||
|
||||
DEFAULT_TONE: Tone = "professional"
|
||||
DEFAULT_EXPERTISE: Expertise = "intermediate"
|
||||
DEFAULT_RESPONSE_LENGTH: ResponseLength = "concise"
|
||||
|
||||
VALID_TONES: set[str] = {"professional", "casual", "enthusiastic"}
|
||||
VALID_EXPERTISE: set[str] = {"beginner", "intermediate", "expert"}
|
||||
VALID_RESPONSE_LENGTHS: set[str] = {"concise", "detailed"}
|
||||
|
||||
|
||||
def read_properties(config: RunnableConfig | None) -> dict[str, str]:
|
||||
"""Read the forwarded ``properties`` object with defensive defaults.
|
||||
|
||||
Any missing or unrecognized value falls back to the corresponding
|
||||
``DEFAULT_*`` constant. The function never raises.
|
||||
"""
|
||||
configurable = (config or {}).get("configurable", {}) or {}
|
||||
properties = configurable.get("properties", {}) or {}
|
||||
|
||||
tone = properties.get("tone", DEFAULT_TONE)
|
||||
expertise = properties.get("expertise", DEFAULT_EXPERTISE)
|
||||
response_length = properties.get("responseLength", DEFAULT_RESPONSE_LENGTH)
|
||||
|
||||
if tone not in VALID_TONES:
|
||||
tone = DEFAULT_TONE
|
||||
if expertise not in VALID_EXPERTISE:
|
||||
expertise = DEFAULT_EXPERTISE
|
||||
if response_length not in VALID_RESPONSE_LENGTHS:
|
||||
response_length = DEFAULT_RESPONSE_LENGTH
|
||||
|
||||
return {
|
||||
"tone": tone,
|
||||
"expertise": expertise,
|
||||
"response_length": response_length,
|
||||
}
|
||||
|
||||
|
||||
def build_system_prompt(tone: str, expertise: str, response_length: str) -> str:
|
||||
"""Compose the system prompt from the three axes."""
|
||||
tone_rules = {
|
||||
"professional": ("Use neutral, precise language. No emoji. Short sentences."),
|
||||
"casual": (
|
||||
"Use friendly, conversational language. Contractions OK. "
|
||||
"Light humor welcome."
|
||||
),
|
||||
"enthusiastic": (
|
||||
"Use upbeat, energetic language. Exclamation points OK. Emoji OK."
|
||||
),
|
||||
}
|
||||
expertise_rules = {
|
||||
"beginner": "Assume no prior knowledge. Define jargon. Use analogies.",
|
||||
"intermediate": (
|
||||
"Assume common terms are understood; explain specialized terms."
|
||||
),
|
||||
"expert": ("Assume technical fluency. Use precise terminology. Skip basics."),
|
||||
}
|
||||
length_rules = {
|
||||
"concise": "Respond in 1-3 sentences.",
|
||||
"detailed": ("Respond in multiple paragraphs with examples where relevant."),
|
||||
}
|
||||
return (
|
||||
"You are a helpful assistant.\n\n"
|
||||
f"Tone: {tone_rules[tone]}\n"
|
||||
f"Expertise level: {expertise_rules[expertise]}\n"
|
||||
f"Response length: {length_rules[response_length]}"
|
||||
)
|
||||
|
||||
|
||||
def call_model(
|
||||
state: MessagesState, config: RunnableConfig | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Single graph node — read forwarded props, build prompt, invoke LLM."""
|
||||
props = read_properties(config)
|
||||
system_prompt = build_system_prompt(
|
||||
props["tone"], props["expertise"], props["response_length"]
|
||||
)
|
||||
messages = [{"role": "system", "content": system_prompt}] + state["messages"]
|
||||
response = _get_llm().invoke(messages)
|
||||
return {"messages": [response]}
|
||||
|
||||
|
||||
graph_builder = StateGraph(MessagesState)
|
||||
graph_builder.add_node("model", call_model)
|
||||
graph_builder.add_edge(START, "model")
|
||||
graph_builder.add_edge("model", END)
|
||||
graph = graph_builder.compile()
|
||||
@@ -0,0 +1,290 @@
|
||||
"""LangGraph agent backing the Beautiful Chat demo.
|
||||
|
||||
Verbatim port of the canonical starter at /examples/integrations/langgraph-python.
|
||||
Reference structure (agent/main.py + agent/src/{todos,query,a2ui_fixed_schema,
|
||||
a2ui_dynamic_schema}.py) is inlined here into a single module to match the
|
||||
showcase cell's flat backend layout.
|
||||
|
||||
Data files (db.csv + schemas/) live alongside this module under
|
||||
`beautiful_chat_data/` to keep the cell self-contained without polluting the
|
||||
shared `a2ui_schemas/` directory (which is owned by a2ui_fixed.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
from copilotkit import (
|
||||
CopilotKitMiddleware,
|
||||
StateItem,
|
||||
StateStreamingMiddleware,
|
||||
a2ui,
|
||||
)
|
||||
from langchain.agents import AgentState as BaseAgentState
|
||||
from langchain.agents import create_agent
|
||||
from langchain.messages import ToolMessage
|
||||
from langchain.tools import ToolRuntime, tool
|
||||
from langchain_core.messages import SystemMessage
|
||||
from langchain_core.tools import tool as lc_tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.types import Command
|
||||
|
||||
|
||||
# ─── Shared state schema ────────────────────────────────────────────
|
||||
|
||||
|
||||
class Todo(TypedDict):
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
emoji: str
|
||||
status: Literal["pending", "completed"]
|
||||
|
||||
|
||||
class AgentState(BaseAgentState):
|
||||
todos: list[Todo]
|
||||
|
||||
|
||||
# ─── Todo tools ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@tool
|
||||
def manage_todos(todos: list[Todo], runtime: ToolRuntime) -> Command:
|
||||
"""
|
||||
Manage the current todos.
|
||||
"""
|
||||
# Ensure all todos have IDs that are unique
|
||||
for todo in todos:
|
||||
if "id" not in todo or not todo["id"]:
|
||||
todo["id"] = str(uuid.uuid4())
|
||||
|
||||
# Update the state
|
||||
return Command(
|
||||
update={
|
||||
"todos": todos,
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
content="Successfully updated todos",
|
||||
tool_call_id=runtime.tool_call_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def get_todos(runtime: ToolRuntime):
|
||||
"""
|
||||
Get the current todos.
|
||||
"""
|
||||
return runtime.state.get("todos", [])
|
||||
|
||||
|
||||
todo_tools = [
|
||||
manage_todos,
|
||||
get_todos,
|
||||
]
|
||||
|
||||
|
||||
# ─── Data query tool ────────────────────────────────────────────────
|
||||
|
||||
# Read data at module load time to avoid file I/O issues in
|
||||
# LangGraph Cloud's sandboxed tool execution environment.
|
||||
_DATA_DIR = Path(__file__).parent / "beautiful_chat_data"
|
||||
_csv_path = _DATA_DIR / "db.csv"
|
||||
with open(_csv_path) as _f:
|
||||
_cached_data = list(csv.DictReader(_f))
|
||||
|
||||
|
||||
@tool
|
||||
def query_data(query: str):
|
||||
"""
|
||||
Query the database, takes natural language. Always call before showing a chart or graph.
|
||||
"""
|
||||
import time
|
||||
|
||||
print(
|
||||
f"[A2UI-DEBUG] query_data called: query='{query[:60]}' at {time.strftime('%H:%M:%S')}"
|
||||
)
|
||||
return _cached_data
|
||||
|
||||
|
||||
# ─── A2UI fixed-schema tool: flight search ──────────────────────────
|
||||
|
||||
CATALOG_ID = "copilotkit://app-dashboard-catalog"
|
||||
SURFACE_ID = "flight-search-results"
|
||||
FLIGHT_SCHEMA = a2ui.load_schema(_DATA_DIR / "schemas" / "flight_schema.json")
|
||||
|
||||
|
||||
class Flight(TypedDict):
|
||||
id: str
|
||||
airline: str
|
||||
airlineLogo: str
|
||||
flightNumber: str
|
||||
origin: str
|
||||
destination: str
|
||||
date: str
|
||||
departureTime: str
|
||||
arrivalTime: str
|
||||
duration: str
|
||||
status: str
|
||||
statusIcon: str
|
||||
price: str
|
||||
|
||||
|
||||
@tool
|
||||
def search_flights(flights: list[Flight]) -> str:
|
||||
"""Search for flights and display the results as rich cards. Return exactly 2 flights.
|
||||
|
||||
Each flight must have: id, airline (e.g. "United Airlines"),
|
||||
airlineLogo (use Google favicon API: https://www.google.com/s2/favicons?domain={airline_domain}&sz=128
|
||||
e.g. "https://www.google.com/s2/favicons?domain=united.com&sz=128" for United,
|
||||
"https://www.google.com/s2/favicons?domain=delta.com&sz=128" for Delta,
|
||||
"https://www.google.com/s2/favicons?domain=aa.com&sz=128" for American,
|
||||
"https://www.google.com/s2/favicons?domain=alaskaair.com&sz=128" for Alaska),
|
||||
flightNumber, origin, destination,
|
||||
date (short readable format like "Tue, Mar 18" — use near-future dates),
|
||||
departureTime, arrivalTime,
|
||||
duration (e.g. "4h 25m"), status (e.g. "On Time" or "Delayed"),
|
||||
statusIcon (colored dot: use "https://placehold.co/12/22c55e/22c55e.png"
|
||||
for On Time, "https://placehold.co/12/eab308/eab308.png" for Delayed,
|
||||
"https://placehold.co/12/ef4444/ef4444.png" for Cancelled),
|
||||
and price (e.g. "$289").
|
||||
"""
|
||||
return a2ui.render(
|
||||
operations=[
|
||||
a2ui.create_surface(SURFACE_ID, catalog_id=CATALOG_ID),
|
||||
a2ui.update_components(SURFACE_ID, FLIGHT_SCHEMA),
|
||||
a2ui.update_data_model(SURFACE_ID, {"flights": flights}),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ─── A2UI dynamic-schema tool: LLM-generated UI ─────────────────────
|
||||
|
||||
CUSTOM_CATALOG_ID = "copilotkit://app-dashboard-catalog"
|
||||
|
||||
|
||||
@lc_tool
|
||||
def render_a2ui(
|
||||
surfaceId: str,
|
||||
catalogId: str,
|
||||
components: list[dict],
|
||||
data: dict | None = None,
|
||||
) -> str:
|
||||
"""Render a dynamic A2UI v0.9 surface.
|
||||
|
||||
Args:
|
||||
surfaceId: Unique surface identifier.
|
||||
catalogId: The catalog ID (use "copilotkit://app-dashboard-catalog").
|
||||
components: A2UI v0.9 component array (flat format). The root
|
||||
component must have id "root".
|
||||
data: Optional initial data model for the surface (e.g. form values,
|
||||
list items for data-bound components).
|
||||
"""
|
||||
return "rendered"
|
||||
|
||||
|
||||
@tool()
|
||||
def generate_a2ui(runtime: ToolRuntime[Any]) -> str:
|
||||
"""Generate dynamic A2UI components based on the conversation.
|
||||
|
||||
A secondary LLM designs the UI schema and data. The result is
|
||||
returned as an a2ui_operations container for the middleware to detect.
|
||||
"""
|
||||
import time
|
||||
|
||||
t0 = time.time()
|
||||
print(f"[A2UI-DEBUG] generate_a2ui STARTED at t=0")
|
||||
|
||||
messages = runtime.state["messages"][:-1]
|
||||
print(f"[A2UI-DEBUG] messages count: {len(messages)}")
|
||||
|
||||
# Get context entries from copilotkit state (catalog capabilities + component schema)
|
||||
context_entries = runtime.state.get("copilotkit", {}).get("context", [])
|
||||
context_text = "\n\n".join(
|
||||
entry.get("value", "")
|
||||
for entry in context_entries
|
||||
if isinstance(entry, dict) and entry.get("value")
|
||||
)
|
||||
print(
|
||||
f"[A2UI-DEBUG] context entries: {len(context_entries)}, context_text_len: {len(context_text)}"
|
||||
)
|
||||
|
||||
prompt = context_text
|
||||
|
||||
model = ChatOpenAI(model="gpt-4.1")
|
||||
model_with_tool = model.bind_tools(
|
||||
[render_a2ui],
|
||||
tool_choice="render_a2ui",
|
||||
)
|
||||
|
||||
print(f"[A2UI-DEBUG] calling secondary LLM at t={time.time() - t0:.1f}s")
|
||||
response = model_with_tool.invoke(
|
||||
[SystemMessage(content=prompt), *messages],
|
||||
)
|
||||
print(f"[A2UI-RESPONSE] {response}")
|
||||
print(f"[A2UI-DEBUG] secondary LLM responded at t={time.time() - t0:.1f}s")
|
||||
|
||||
if not response.tool_calls:
|
||||
print(f"[A2UI-DEBUG] ERROR: no tool calls in response")
|
||||
return json.dumps({"error": "LLM did not call render_a2ui"})
|
||||
|
||||
tool_call = response.tool_calls[0]
|
||||
args = tool_call["args"]
|
||||
|
||||
surface_id = args.get("surfaceId", "dynamic-surface")
|
||||
catalog_id = args.get("catalogId", CUSTOM_CATALOG_ID)
|
||||
components = args.get("components", [])
|
||||
data = args.get("data", {})
|
||||
print(
|
||||
f"[A2UI-DEBUG] components={len(components)} data_keys={list(data.keys()) if data else []} surface={surface_id}"
|
||||
)
|
||||
|
||||
ops = [
|
||||
a2ui.create_surface(surface_id, catalog_id=catalog_id),
|
||||
a2ui.update_components(surface_id, components),
|
||||
]
|
||||
if data:
|
||||
ops.append(a2ui.update_data_model(surface_id, data))
|
||||
|
||||
result = a2ui.render(operations=ops)
|
||||
print(
|
||||
f"[A2UI-DEBUG] generate_a2ui DONE at t={time.time() - t0:.1f}s result_len={len(result)}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ─── Graph ──────────────────────────────────────────────────────────
|
||||
|
||||
model = ChatOpenAI(model="gpt-5.4", model_kwargs={"parallel_tool_calls": False})
|
||||
|
||||
agent = create_agent(
|
||||
model=model,
|
||||
tools=[query_data, *todo_tools, generate_a2ui, search_flights],
|
||||
middleware=[
|
||||
CopilotKitMiddleware(),
|
||||
StateStreamingMiddleware(
|
||||
StateItem(state_key="todos", tool="manage_todos", tool_argument="todos")
|
||||
),
|
||||
],
|
||||
state_schema=AgentState,
|
||||
system_prompt="""
|
||||
You are a polished, professional demo assistant. Keep responses to 1-2 sentences.
|
||||
|
||||
Tool guidance:
|
||||
- Flights: call search_flights to show flight cards with a pre-built schema.
|
||||
- Dashboards & rich UI: call generate_a2ui to create dashboard UIs with metrics,
|
||||
charts, tables, and cards. It handles rendering automatically.
|
||||
- Charts: call query_data first, then render with the chart component.
|
||||
- Todos: enable app mode first, then manage todos.
|
||||
- A2UI actions: when you see a log_a2ui_event result (e.g. "view_details"),
|
||||
respond with a brief confirmation. The UI already updated on the frontend.
|
||||
""",
|
||||
)
|
||||
|
||||
graph = agent
|
||||
@@ -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
|
||||
|
+37
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,104 @@
|
||||
"""LangGraph agent backing the byoc-hashbrown demo (Wave 4a).
|
||||
|
||||
Emits hashbrown-shaped structured output that the ported HashBrownDashboard
|
||||
renderer (`src/app/demos/byoc-hashbrown/hashbrown-renderer.tsx`) progressively
|
||||
parses via `@hashbrownai/react`'s `useJsonParser` + `useUiKit`.
|
||||
|
||||
Wire format
|
||||
-----------
|
||||
`@hashbrownai/react`'s `useJsonParser(content, kit.schema)` expects the agent
|
||||
to stream a JSON object literal matching `kit.schema` — NOT the `<ui>...</ui>`
|
||||
XML-style examples shown inside `useUiKit({ examples })`. Those XML examples
|
||||
are the hashbrown prompt DSL that hashbrown compiles into a schema description
|
||||
when driving the LLM directly (e.g. `useUiChat`/`useUiCompletion`). Because
|
||||
this demo drives the LLM via langgraph instead, we must mirror what
|
||||
hashbrown's own schema wire format looks like:
|
||||
|
||||
{
|
||||
"ui": [
|
||||
{ "metric": { "props": { "label": "...", "value": "..." } } },
|
||||
{ "pieChart": { "props": { "title": "...", "data": "[{...}]" } } },
|
||||
{ "barChart": { "props": { "title": "...", "data": "[{...}]" } } },
|
||||
{ "dealCard": { "props": { "title": "...", "stage": "prospect", "value": 100000 } } },
|
||||
{ "Markdown": { "props": { "children": "## heading\\nbody" } } }
|
||||
]
|
||||
}
|
||||
|
||||
Every node is a single-key object `{tagName: {props: {...}}}`. The tag names
|
||||
and prop schemas match `useSalesDashboardKit()` in
|
||||
`hashbrown-renderer.tsx`. `pieChart` and `barChart` receive `data` as a
|
||||
JSON-encoded string (this was intentional in PR #4252 to keep the schema
|
||||
stable under partial streaming).
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
BYOC_HASHBROWN_SYSTEM_PROMPT = """\
|
||||
You are a sales analytics assistant that replies by emitting a single JSON
|
||||
object consumed by a streaming JSON parser on the frontend.
|
||||
|
||||
ALWAYS respond with a single JSON object of the form:
|
||||
|
||||
{
|
||||
"ui": [
|
||||
{ <componentName>: { "props": { ... } } },
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Do NOT wrap the response in code fences. Do NOT include any preface or
|
||||
explanation outside the JSON object. The response MUST be valid JSON.
|
||||
|
||||
Available components and their prop schemas:
|
||||
|
||||
- "metric": { "props": { "label": string, "value": string } }
|
||||
A KPI card. `value` is a pre-formatted string like "$1.2M" or "248".
|
||||
|
||||
- "pieChart": { "props": { "title": string, "data": string } }
|
||||
A donut chart. `data` is a JSON-encoded STRING (embedded JSON) of an
|
||||
array of {label, value} objects with at least 3 segments, e.g.
|
||||
"data": "[{\\"label\\":\\"Enterprise\\",\\"value\\":600000}]".
|
||||
|
||||
- "barChart": { "props": { "title": string, "data": string } }
|
||||
A vertical bar chart. `data` is a JSON-encoded STRING of an array of
|
||||
{label, value} objects with at least 3 bars, typically time-ordered.
|
||||
|
||||
- "dealCard": { "props": { "title": string, "stage": string, "value": number } }
|
||||
A single sales deal. `stage` MUST be one of: "prospect", "qualified",
|
||||
"proposal", "negotiation", "closed-won", "closed-lost". `value` is a
|
||||
raw number (no currency symbol or comma).
|
||||
|
||||
- "Markdown": { "props": { "children": string } }
|
||||
Short explanatory text. Use for section headings and brief summaries.
|
||||
Standard markdown is supported in `children`.
|
||||
|
||||
Rules:
|
||||
- Always produce plausible sample data when the user asks for a dashboard or
|
||||
chart — do not refuse for lack of data.
|
||||
- Prefer 3-6 rows of data in charts; keep labels short.
|
||||
- Use "Markdown" for short headings or linking sentences between visual
|
||||
components. Do not emit long prose.
|
||||
- Do not emit components that are not listed above.
|
||||
- `data` props on charts MUST be a JSON STRING — escape inner quotes.
|
||||
|
||||
Example response (sales dashboard):
|
||||
{"ui":[{"Markdown":{"props":{"children":"## Q4 Sales Summary"}}},{"metric":{"props":{"label":"Total Revenue","value":"$1.2M"}}},{"metric":{"props":{"label":"New Customers","value":"248"}}},{"pieChart":{"props":{"title":"Revenue by Segment","data":"[{\\"label\\":\\"Enterprise\\",\\"value\\":600000},{\\"label\\":\\"SMB\\",\\"value\\":400000},{\\"label\\":\\"Startup\\",\\"value\\":200000}]"}}},{"barChart":{"props":{"title":"Monthly Revenue","data":"[{\\"label\\":\\"Oct\\",\\"value\\":350000},{\\"label\\":\\"Nov\\",\\"value\\":400000},{\\"label\\":\\"Dec\\",\\"value\\":450000}]"}}}]}
|
||||
"""
|
||||
|
||||
# Force JSON-object output mode. The frontend's `useJsonParser` bails to
|
||||
# `null` on any non-JSON prefix (code fences, prose preamble, etc.), so
|
||||
# leaving the model free to wander out of JSON is what left the renderer
|
||||
# empty in practice. `response_format={"type": "json_object"}` tells
|
||||
# OpenAI to refuse to emit anything but a single JSON object, which
|
||||
# aligns the wire-level contract with what the parser accepts.
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(
|
||||
model="gpt-4o-mini",
|
||||
model_kwargs={"response_format": {"type": "json_object"}},
|
||||
),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=BYOC_HASHBROWN_SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""LangGraph agent backing the BYOC json-render demo.
|
||||
|
||||
Emits a single JSON object shaped like `@json-render/react`'s flat spec
|
||||
format (`{ root, elements }`) so the frontend can feed it directly into
|
||||
`<Renderer />` against a Zod-validated catalog of three components —
|
||||
MetricCard, BarChart, PieChart.
|
||||
|
||||
The scenario mirrors Wave 4a (hashbrown) so the two BYOC rows on the
|
||||
dashboard are directly comparable. The only difference is the rendering
|
||||
technology; the catalog shape and suggestion prompts are identical.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """
|
||||
You are a sales-dashboard UI generator for a BYOC json-render demo.
|
||||
|
||||
When the user asks for a UI, respond with **exactly one JSON object** and
|
||||
nothing else — no prose, no markdown fences, no leading explanation. The
|
||||
object must match this schema (the "flat element map" format consumed by
|
||||
`@json-render/react`):
|
||||
|
||||
{
|
||||
"root": "<id of the root element>",
|
||||
"elements": {
|
||||
"<id>": {
|
||||
"type": "<component name>",
|
||||
"props": { ... component-specific props ... },
|
||||
"children": [ "<id>", ... ]
|
||||
},
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
Available components (use each name verbatim as "type"):
|
||||
|
||||
- MetricCard
|
||||
props: { "label": string, "value": string, "trend": string | null }
|
||||
Example trend strings: "+12% vs last quarter", "-3% vs last month", null.
|
||||
|
||||
- BarChart
|
||||
props: {
|
||||
"title": string,
|
||||
"description": string | null,
|
||||
"data": [ { "label": string, "value": number }, ... ]
|
||||
}
|
||||
|
||||
- PieChart
|
||||
props: {
|
||||
"title": string,
|
||||
"description": string | null,
|
||||
"data": [ { "label": string, "value": number }, ... ]
|
||||
}
|
||||
|
||||
Rules:
|
||||
|
||||
1. Output **only** valid JSON. No markdown code fences. No text outside
|
||||
the object.
|
||||
2. Every id referenced in `root` or any `children` array must be a key
|
||||
in `elements`.
|
||||
3. For a multi-component dashboard, use a root MetricCard and list the
|
||||
charts in its `children` array, OR pick any element as root and list
|
||||
the others as its children. Do not emit orphan elements.
|
||||
4. Use realistic sales-domain values (revenue, pipeline, conversion,
|
||||
categories, months) — the demo is a sales dashboard.
|
||||
5. `children` is optional but when present must be an array of strings.
|
||||
6. Never invent component types outside the three listed above.
|
||||
|
||||
### Worked example — "Show me the sales dashboard with metrics and a revenue chart"
|
||||
|
||||
{
|
||||
"root": "revenue-metric",
|
||||
"elements": {
|
||||
"revenue-metric": {
|
||||
"type": "MetricCard",
|
||||
"props": {
|
||||
"label": "Revenue (Q3)",
|
||||
"value": "$1.24M",
|
||||
"trend": "+18% vs Q2"
|
||||
},
|
||||
"children": ["revenue-bar"]
|
||||
},
|
||||
"revenue-bar": {
|
||||
"type": "BarChart",
|
||||
"props": {
|
||||
"title": "Monthly revenue",
|
||||
"description": "Revenue by month across Q3",
|
||||
"data": [
|
||||
{ "label": "Jul", "value": 380000 },
|
||||
{ "label": "Aug", "value": 410000 },
|
||||
{ "label": "Sep", "value": 450000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
### Worked example — "Break down revenue by category as a pie chart"
|
||||
|
||||
{
|
||||
"root": "category-pie",
|
||||
"elements": {
|
||||
"category-pie": {
|
||||
"type": "PieChart",
|
||||
"props": {
|
||||
"title": "Revenue by category",
|
||||
"description": "Share of total revenue by product category",
|
||||
"data": [
|
||||
{ "label": "Enterprise", "value": 540000 },
|
||||
{ "label": "SMB", "value": 310000 },
|
||||
{ "label": "Self-serve", "value": 220000 },
|
||||
{ "label": "Partner", "value": 170000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
### Worked example — "Show me monthly expenses as a bar chart"
|
||||
|
||||
{
|
||||
"root": "expense-bar",
|
||||
"elements": {
|
||||
"expense-bar": {
|
||||
"type": "BarChart",
|
||||
"props": {
|
||||
"title": "Monthly expenses",
|
||||
"description": "Operating expenses by month",
|
||||
"data": [
|
||||
{ "label": "Jul", "value": 210000 },
|
||||
{ "label": "Aug", "value": 225000 },
|
||||
{ "label": "Sep", "value": 240000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Respond with the JSON object only.
|
||||
"""
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(
|
||||
model="gpt-4o-mini",
|
||||
temperature=0.2,
|
||||
model_kwargs={"response_format": {"type": "json_object"}},
|
||||
),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT.strip(),
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""LangGraph agent backing the Chat Customization (CSS) demo.
|
||||
|
||||
The demo is about CSS — the agent has no custom tools or behavior.
|
||||
CopilotKitMiddleware is attached so CopilotKit-specific context is
|
||||
picked up if the frontend ever registers suggestions/components.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt="You are a helpful, concise assistant.",
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""LangGraph agent backing the Frontend Tools (Async) demo.
|
||||
|
||||
This cell demonstrates `useFrontendTool` with an ASYNC handler. The
|
||||
frontend registers a `query_notes` tool whose handler awaits a simulated
|
||||
client-side DB query (500ms latency) and returns matching notes. The
|
||||
agent uses the returned result to summarize what it found.
|
||||
|
||||
Like the sibling `frontend_tools` cell, the backend graph registers no
|
||||
tools of its own — CopilotKit forwards the frontend tool schema(s) to
|
||||
the agent at runtime, and the handler executes in the browser.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant that can search the user's personal notes. "
|
||||
"When the user asks about their notes, call the `query_notes` tool with "
|
||||
"a concise keyword extracted from their request. The tool is provided "
|
||||
"by the frontend at runtime and runs entirely in the user's browser — "
|
||||
"you do not need to implement it yourself. After the tool returns, "
|
||||
"summarize the matching notes clearly and concisely. If no notes match, "
|
||||
"say so plainly and offer to try a different keyword."
|
||||
)
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""LangGraph agent backing the Headless Chat (Complete) demo.
|
||||
|
||||
The cell exists to prove that every CopilotKit rendering surface works
|
||||
when the chat UI is composed manually (no <CopilotChatMessageView /> or
|
||||
<CopilotChatAssistantMessage />). To exercise those surfaces we give
|
||||
this agent:
|
||||
|
||||
- two mock backend tools (get_weather, get_stock_price) — render via
|
||||
app-registered `useRenderTool` renderers on the frontend,
|
||||
- access to a frontend-registered `useComponent` tool
|
||||
(`highlight_note`) — the agent "calls" it and the UI flows through
|
||||
the same `useRenderToolCall` path,
|
||||
- MCP Apps wired through the runtime — the agent can invoke Excalidraw
|
||||
MCP tools and the middleware emits activity events that
|
||||
`useRenderActivityMessage` picks up.
|
||||
|
||||
The system prompt nudges the model toward the right surface per user
|
||||
question and falls back to plain text otherwise.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful, concise assistant wired into a headless chat "
|
||||
"surface that demonstrates CopilotKit's full rendering stack. Pick the "
|
||||
"right surface for each user question and fall back to plain text when "
|
||||
"none of the tools fit.\n\n"
|
||||
"Routing rules:\n"
|
||||
" - If the user asks about weather for a place, call `get_weather` "
|
||||
"with the location.\n"
|
||||
" - If the user asks about a stock or ticker (AAPL, TSLA, MSFT, ...), "
|
||||
"call `get_stock_price` with the ticker.\n"
|
||||
" - If the user asks you to highlight, flag, or mark a short note or "
|
||||
"phrase, call the frontend `highlight_note` tool with the text and a "
|
||||
"color (yellow, pink, green, or blue). Do NOT ask the user for the "
|
||||
"color — pick a sensible one if they didn't say.\n"
|
||||
" - If the user asks to draw, sketch, or diagram something, use the "
|
||||
"Excalidraw MCP tools that are available to you.\n"
|
||||
" - Otherwise, reply in plain text.\n\n"
|
||||
"After a tool returns, write one short sentence summarizing the "
|
||||
"result. Never fabricate data a tool could provide."
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def get_weather(location: str) -> dict:
|
||||
"""Get the current weather for a given location.
|
||||
|
||||
Returns a mock payload with city, temperature in Fahrenheit, humidity,
|
||||
wind speed, and conditions. Use this whenever the user asks about
|
||||
weather anywhere.
|
||||
"""
|
||||
return {
|
||||
"city": location,
|
||||
"temperature": 68,
|
||||
"humidity": 55,
|
||||
"wind_speed": 10,
|
||||
"conditions": "Sunny",
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def get_stock_price(ticker: str) -> dict:
|
||||
"""Get a mock current price for a stock ticker.
|
||||
|
||||
Returns a payload with the ticker symbol (uppercased), price in USD,
|
||||
and percentage change for the day. Use this whenever the user asks
|
||||
about a stock price.
|
||||
"""
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"price_usd": 189.42,
|
||||
"change_pct": 1.27,
|
||||
}
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[get_weather, get_stock_price],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""LangGraph agent backing the In-App HITL (frontend-tool + popup) demo.
|
||||
|
||||
The agent is a support assistant that processes customer-care requests
|
||||
(refunds, account changes, escalations). Any action that materially
|
||||
affects a customer MUST be confirmed by the human operator via the
|
||||
frontend-provided `request_user_approval` tool.
|
||||
|
||||
The tool is defined on the frontend via `useFrontendTool` with an async
|
||||
handler that opens a modal dialog OUTSIDE the chat surface. The handler
|
||||
awaits the user's decision and resolves with
|
||||
`{"approved": bool, "reason": str}`. This agent treats that result as
|
||||
authoritative: if `approved` is `True`, continue; otherwise, stop and
|
||||
explain the decision back to the user.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a support operations copilot working alongside a human operator "
|
||||
"inside an internal support console. The operator can see a list of open "
|
||||
"support tickets on the left side of their screen and is chatting with "
|
||||
"you on the right.\n"
|
||||
"\n"
|
||||
"Whenever the operator asks you to take an action that affects a "
|
||||
"customer — for example: issuing a refund, updating a customer's plan, "
|
||||
"cancelling a subscription, escalating a ticket, or sending an apology "
|
||||
"credit — you MUST first call the frontend-provided "
|
||||
"`request_user_approval` tool to obtain the operator's explicit consent.\n"
|
||||
"\n"
|
||||
"How to use `request_user_approval`:\n"
|
||||
"- `message`: a short, plain-English summary of the exact action you "
|
||||
" are about to take, including concrete numbers (e.g. '$50 refund to "
|
||||
" customer #12345').\n"
|
||||
"- `context`: optional extra context the operator might want to review "
|
||||
" (the ticket ID, the policy rule you're applying, etc.). Keep it to "
|
||||
" one or two short sentences.\n"
|
||||
"\n"
|
||||
"The tool returns an object of the shape "
|
||||
'`{"approved": boolean, "reason": string | null}`.\n'
|
||||
"- If `approved` is `true`: confirm in one short sentence that you are "
|
||||
" processing the action. You do not actually need to call any other "
|
||||
" tool — this is a demo. Just acknowledge.\n"
|
||||
"- If `approved` is `false`: acknowledge the rejection in one short "
|
||||
" sentence and, if `reason` is non-empty, reflect the operator's "
|
||||
" reason back to them. Do NOT retry the action.\n"
|
||||
"\n"
|
||||
"Keep all chat replies to one or two short sentences. Never make up "
|
||||
"customer data — always use whatever the operator told you in the "
|
||||
"prompt."
|
||||
)
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""LangGraph agent backing the In-Chat HITL (useHumanInTheLoop) demo.
|
||||
|
||||
The `book_call` tool is defined on the frontend via `useHumanInTheLoop`,
|
||||
so there is no backend tool here. CopilotKitMiddleware is attached so the
|
||||
frontend suggestions and the time-picker render hook are picked up.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=(
|
||||
"You help users book an onboarding call with the sales team. "
|
||||
"When they ask to book a call, call the frontend-provided "
|
||||
"`book_call` tool with a short topic and the user's name. "
|
||||
"Keep any chat reply to one short sentence."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""LangGraph agent backing the HITL step-selection demo (/demos/hitl).
|
||||
|
||||
Minimal neutral assistant with no backend tools — frontend-registered
|
||||
tools (useHumanInTheLoop's `generate_task_steps`) are injected via
|
||||
CopilotKitMiddleware at runtime. Mirrors the langgraph-python reference's
|
||||
`main.py` (sample_agent) pattern: tools=[], middleware only.
|
||||
|
||||
The heavy `sample_agent` (agent.py) defines 7+ backend tools and a custom
|
||||
AgentState with `todos: list[SalesTodo]`. Routing the HITL step-selection
|
||||
demo through that graph risks state-schema mismatches and tool-dispatch
|
||||
contention when the only tool the demo needs is the frontend-injected
|
||||
`generate_task_steps`. This dedicated graph eliminates that surface area.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt="You are a helpful, concise assistant.",
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""LangGraph agent for the Interrupt-based Generative UI demo.
|
||||
|
||||
Defines a backend tool `schedule_meeting(topic, attendee)` that uses
|
||||
langgraph's `interrupt()` primitive to pause the run and surface the
|
||||
meeting context to the frontend. The frontend `useInterrupt` renderer
|
||||
shows a time picker and resolves with `{chosen_time, chosen_label}` or
|
||||
`{cancelled: true}`, which this tool turns into a human-readable result.
|
||||
"""
|
||||
|
||||
# @region[backend-interrupt-tool]
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.types import interrupt
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a scheduling assistant. Whenever the user asks you to book a "
|
||||
"call / schedule a meeting, you MUST call the `schedule_meeting` tool. "
|
||||
"Pass a short `topic` describing the purpose and `attendee` describing "
|
||||
"who the meeting is with. After the tool returns, confirm briefly "
|
||||
"whether the meeting was scheduled and at what time, or that the user "
|
||||
"cancelled."
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def schedule_meeting(topic: str, attendee: Optional[str] = None) -> str:
|
||||
"""Ask the user to pick a time slot for a call, via an in-chat picker.
|
||||
|
||||
Args:
|
||||
topic: Short human-readable description of the call's purpose.
|
||||
attendee: Who the call is with (optional).
|
||||
|
||||
Returns:
|
||||
Human-readable result string describing the chosen slot or
|
||||
indicating the user cancelled.
|
||||
"""
|
||||
# langgraph's `interrupt()` pauses execution and forwards the payload to
|
||||
# the client. The frontend v2 `useInterrupt` hook renders the picker and
|
||||
# calls `resolve(...)` with the user's selection, which comes back here.
|
||||
response: Any = interrupt({"topic": topic, "attendee": attendee})
|
||||
|
||||
if isinstance(response, dict):
|
||||
if response.get("cancelled"):
|
||||
return f"User cancelled. Meeting NOT scheduled: {topic}"
|
||||
chosen_label = response.get("chosen_label") or response.get("chosen_time")
|
||||
if chosen_label:
|
||||
return f"Meeting scheduled for {chosen_label}: {topic}"
|
||||
|
||||
return f"User did not pick a time. Meeting NOT scheduled: {topic}"
|
||||
|
||||
|
||||
# @endregion[backend-interrupt-tool]
|
||||
|
||||
|
||||
model = ChatOpenAI(model="gpt-4o-mini")
|
||||
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=[schedule_meeting],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
LangGraph agent for the CopilotKit MCP Apps demo.
|
||||
|
||||
This agent has no bespoke tools — the CopilotKit runtime is wired with
|
||||
``mcpApps: { servers: [...] }`` pointing at the public Excalidraw MCP
|
||||
server (see ``src/app/api/copilotkit-mcp-apps/route.ts``). The runtime
|
||||
auto-applies the MCP Apps middleware, which exposes the remote MCP
|
||||
server's tools to this agent at request time and emits the activity
|
||||
events that CopilotKit's built-in ``MCPAppsActivityRenderer`` renders in
|
||||
the chat as a sandboxed iframe.
|
||||
|
||||
Reference:
|
||||
https://docs.copilotkit.ai/integrations/langgraph/generative-ui/mcp-apps
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
You draw simple diagrams in Excalidraw via the MCP tool.
|
||||
|
||||
SPEED MATTERS. Produce a correct-enough diagram fast; do not optimize
|
||||
for polish. Target: one tool call, done in seconds.
|
||||
|
||||
When the user asks for a diagram:
|
||||
1. Call `create_view` ONCE with 3-5 elements total: shapes + arrows +
|
||||
an optional title text.
|
||||
2. Use straightforward shapes (rectangle, ellipse, diamond) with plain
|
||||
`label` fields (`{"text": "...", "fontSize": 18}`) on them.
|
||||
3. Connect with arrows. Endpoints can be element centers or simple
|
||||
coordinates — you don't need edge anchors / fixedPoint bindings.
|
||||
4. Include ONE `cameraUpdate` at the END of the elements array that
|
||||
frames the whole diagram. Use an approved 4:3 size (600x450 or
|
||||
800x600). No opening camera needed.
|
||||
5. Reply with ONE short sentence describing what you drew.
|
||||
|
||||
Every element needs a unique string `id` (e.g. `"b1"`, `"a1"`,
|
||||
`"title"`). Standard sizes: rectangles 160x70, ellipses/diamonds
|
||||
120x80, 40-80px gap between shapes.
|
||||
|
||||
Do NOT:
|
||||
- Call `read_me`. You already know the basic shape API.
|
||||
- Make multiple `create_view` calls.
|
||||
- Iterate or refine. Ship on the first shot.
|
||||
- Add decorative colors / fills / zone backgrounds unless the user
|
||||
explicitly asks for them.
|
||||
- Add labels on arrows unless crucial.
|
||||
|
||||
If the user asks for something specific (colors, more elements,
|
||||
particular layout), follow their lead — but still in ONE call.
|
||||
"""
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
# gpt-4o-mini for speed — Excalidraw element emission is simple
|
||||
# JSON and we're biasing hard toward sub-30s generation. A faster
|
||||
# model produces shorter, quicker outputs with acceptable layouts.
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Multimodal LangGraph agent — accepts image + document (PDF) attachments.
|
||||
|
||||
Wave 2b design: a *dedicated* vision-capable graph scoped to the
|
||||
`/demos/multimodal` cell. Other demos continue to use their own (cheaper,
|
||||
text-only) models — this keeps vision cost isolated to the one demo that
|
||||
exercises it.
|
||||
|
||||
Wire format the agent sees
|
||||
==========================
|
||||
Attachments arrive here after travelling through:
|
||||
|
||||
CopilotChat → AG-UI message content parts → @ag-ui/langgraph runtime
|
||||
(ag-ui → LangChain converter)
|
||||
→ this agent (LangChain HumanMessage content parts)
|
||||
|
||||
The ag-ui-langgraph converter only understands the legacy
|
||||
``{ type: "binary", mimeType, data | url }`` AG-UI part shape — the page
|
||||
at ``src/app/demos/multimodal/page.tsx`` installs an
|
||||
``onRunInitialized`` shim that rewrites the modern
|
||||
``{ type: "image" | "document", source: {...} }`` shape CopilotChat emits
|
||||
to the legacy shape before it hits the runtime. Once the converter has
|
||||
run, every attachment shows up in this agent as a LangChain
|
||||
``image_url`` content part::
|
||||
|
||||
{"type": "image_url", "image_url": {"url": "data:<mime>;base64,<payload>"}}
|
||||
|
||||
regardless of whether the upstream modality was ``image`` or ``document``.
|
||||
|
||||
We therefore route on ``mimeType``, not the part ``type``:
|
||||
``image/*`` parts are forwarded to GPT-4o unchanged (vision-native);
|
||||
``application/pdf`` parts are flattened to inline text via ``pypdf`` so
|
||||
the model can read them without needing file-part support.
|
||||
|
||||
References:
|
||||
- src/agents/main.py, src/agents/agentic_chat.py (baseline pattern)
|
||||
- packages/runtime/src/agent/converters/tanstack.ts (the modern content-
|
||||
part shape — useful context when the runtime gets upgraded and this
|
||||
agent can drop the pypdf flatten)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant. The user may attach images or documents "
|
||||
"(PDFs). When they do, analyze the attachment carefully and answer the "
|
||||
"user's question. If no attachment is present, answer the text question "
|
||||
"normally. Keep responses concise (1-3 sentences) unless asked to go deep."
|
||||
)
|
||||
|
||||
|
||||
def _extract_data_url_parts(url: str) -> tuple[str, str]:
|
||||
"""Split a ``data:<mime>;base64,<payload>`` URL into (mime, base64-payload).
|
||||
|
||||
Returns ("", url) if the input is not a base64 data URL — callers can
|
||||
fall back to treating the url as a fetchable reference.
|
||||
"""
|
||||
if not url.startswith("data:"):
|
||||
return "", url
|
||||
header, _, payload = url.partition(",")
|
||||
# Header looks like "data:application/pdf;base64" — take the piece
|
||||
# between the colon and the first semicolon.
|
||||
if ":" not in header:
|
||||
return "", payload
|
||||
meta = header.split(":", 1)[1]
|
||||
mime = meta.split(";", 1)[0] if ";" in meta else meta
|
||||
return mime, payload
|
||||
|
||||
|
||||
def _extract_pdf_text(b64: str) -> str:
|
||||
"""Decode an inline-base64 PDF and extract its text.
|
||||
|
||||
Returns an empty string if decoding or extraction fails — callers must
|
||||
treat the extracted text as best-effort. Any exception here is logged
|
||||
and swallowed so one malformed attachment does not tank the whole
|
||||
user turn.
|
||||
"""
|
||||
try:
|
||||
raw = base64.b64decode(b64, validate=False)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
print(f"[multimodal_agent] base64 decode failed: {exc}")
|
||||
return ""
|
||||
|
||||
try:
|
||||
# Lazy import — keeps the module importable even if pypdf is missing
|
||||
# at dev-server boot (we only need it when a PDF actually arrives).
|
||||
from pypdf import PdfReader # type: ignore[import-not-found]
|
||||
except ImportError as exc: # pragma: no cover - defensive
|
||||
print(
|
||||
"[multimodal_agent] pypdf not installed — PDF text extraction "
|
||||
f"unavailable: {exc}",
|
||||
)
|
||||
return ""
|
||||
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(raw))
|
||||
pages = [page.extract_text() or "" for page in reader.pages]
|
||||
return "\n\n".join(pages).strip()
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
print(f"[multimodal_agent] pypdf extraction failed: {exc}")
|
||||
return ""
|
||||
|
||||
|
||||
def _classify_attachment_part(part: Any) -> tuple[str, str, str] | None:
|
||||
"""Inspect a content part and return (kind, mime, base64_payload).
|
||||
|
||||
``kind`` is one of ``"image"``, ``"pdf"``, ``"other"``. Returns
|
||||
``None`` if the part is not an attachment we recognise (plain text,
|
||||
unrelated dict, string, etc.).
|
||||
|
||||
Handles the shapes we actually see in practice:
|
||||
|
||||
- ``{"type": "image_url", "image_url": {"url": "data:..."}}``
|
||||
(what the ag-ui-langgraph converter emits for every attachment
|
||||
after the page rewrites to legacy ``binary``).
|
||||
- ``{"type": "image_url", "image_url": "data:..."}``
|
||||
(older LangChain/OpenAI shape where ``image_url`` is a raw string).
|
||||
- ``{"type": "document", "source": {"type": "data",
|
||||
"value": "<base64>", "mimeType": "application/pdf"}}``
|
||||
(modern AG-UI shape — preserved for forward-compat if the runtime
|
||||
ever starts forwarding modern parts directly).
|
||||
"""
|
||||
if not isinstance(part, dict):
|
||||
return None
|
||||
part_type = part.get("type")
|
||||
|
||||
if part_type == "image_url":
|
||||
image_url = part.get("image_url")
|
||||
url: str | None = None
|
||||
if isinstance(image_url, str):
|
||||
url = image_url
|
||||
elif isinstance(image_url, dict):
|
||||
raw_url = image_url.get("url")
|
||||
if isinstance(raw_url, str):
|
||||
url = raw_url
|
||||
if not url:
|
||||
return None
|
||||
mime, payload = _extract_data_url_parts(url)
|
||||
if not payload or not mime:
|
||||
return None
|
||||
if mime.startswith("image/"):
|
||||
return ("image", mime, payload)
|
||||
if "pdf" in mime.lower():
|
||||
return ("pdf", mime, payload)
|
||||
return ("other", mime, payload)
|
||||
|
||||
if part_type == "document":
|
||||
source = part.get("source")
|
||||
if not isinstance(source, dict) or source.get("type") != "data":
|
||||
return None
|
||||
value = source.get("value")
|
||||
mime = source.get("mimeType", "")
|
||||
if not isinstance(value, str) or not isinstance(mime, str):
|
||||
return None
|
||||
if "pdf" in mime.lower():
|
||||
return ("pdf", mime, value)
|
||||
return ("other", mime, value)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _preprocess_part(part: Any) -> Any:
|
||||
"""Flatten PDF attachments to text; pass everything else through.
|
||||
|
||||
Images stay as-is so GPT-4o consumes them natively via its vision
|
||||
adapter. PDFs (which gpt-4o cannot read directly) become a text part
|
||||
prefixed with ``[Attached document]`` and the extracted body. If
|
||||
extraction fails we emit a structured placeholder so the model can
|
||||
tell the user the document was unreadable instead of pretending no
|
||||
attachment was sent.
|
||||
"""
|
||||
classified = _classify_attachment_part(part)
|
||||
if classified is None:
|
||||
return part
|
||||
kind, _mime, payload = classified
|
||||
if kind != "pdf":
|
||||
return part
|
||||
text = _extract_pdf_text(payload)
|
||||
if not text:
|
||||
return {
|
||||
"type": "text",
|
||||
"text": "[Attached document: PDF could not be read.]",
|
||||
}
|
||||
return {"type": "text", "text": f"[Attached document]\n{text}"}
|
||||
|
||||
|
||||
def _rewrite_messages(messages: list[Any]) -> list[Any]:
|
||||
"""Rewrite user messages so non-image attachments become text parts.
|
||||
|
||||
Operates on the messages list stored in agent state. Returns a *new*
|
||||
list; the input list is not mutated.
|
||||
"""
|
||||
rewritten: list[Any] = []
|
||||
for message in messages:
|
||||
# Only touch HumanMessage — assistant/tool messages stay as-is.
|
||||
if not isinstance(message, HumanMessage):
|
||||
rewritten.append(message)
|
||||
continue
|
||||
content = message.content
|
||||
if not isinstance(content, list):
|
||||
rewritten.append(message)
|
||||
continue
|
||||
new_parts = [_preprocess_part(part) for part in content]
|
||||
rewritten.append(HumanMessage(content=new_parts, id=message.id))
|
||||
return rewritten
|
||||
|
||||
|
||||
class _PdfFlattenMiddleware(AgentMiddleware):
|
||||
"""Flatten PDF content parts to text before the model call.
|
||||
|
||||
We run this in ``before_model`` so every model invocation — including
|
||||
retries after tool calls — sees the flattened view. The middleware is
|
||||
idempotent: once a part has been rewritten to ``{"type": "text", ...}``
|
||||
it is returned unchanged on subsequent passes.
|
||||
"""
|
||||
|
||||
def before_model(self, state, runtime): # type: ignore[override]
|
||||
del runtime # unused
|
||||
messages = state.get("messages") if isinstance(state, dict) else None
|
||||
if not messages:
|
||||
return None
|
||||
rewritten = _rewrite_messages(messages)
|
||||
# Only emit a patch if anything actually changed — avoids a
|
||||
# superfluous state update on every model hop.
|
||||
if rewritten == messages:
|
||||
return None
|
||||
return {"messages": rewritten}
|
||||
|
||||
|
||||
# Vision-capable model. gpt-4o consumes `image_url` content parts natively.
|
||||
_MODEL = ChatOpenAI(model="gpt-4o", temperature=0.2)
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=_MODEL,
|
||||
tools=[],
|
||||
middleware=[_PdfFlattenMiddleware(), CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
|
||||
# Re-export under both names — `graph` matches the langgraph.json convention
|
||||
# used by the rest of the package; `multimodal_agent` is a friendlier alias
|
||||
# for any future non-langgraph.json import paths.
|
||||
multimodal_agent = graph
|
||||
|
||||
__all__ = ["graph", "multimodal_agent"]
|
||||
@@ -0,0 +1,90 @@
|
||||
"""LangGraph agent for the Open-Ended Generative UI (Advanced) demo.
|
||||
|
||||
This is the "advanced" variant of the Open Generative UI demo. The key
|
||||
distinguishing feature: the agent-authored, sandboxed UI can invoke
|
||||
frontend-registered **sandbox functions** — functions the app defines on
|
||||
the host page (see `src/app/demos/open-gen-ui-advanced/sandbox-functions.ts`)
|
||||
and makes callable from inside the iframe via
|
||||
`await Websandbox.connection.remote.<name>(args)`.
|
||||
|
||||
How it works end-to-end:
|
||||
- The frontend passes `openGenerativeUI={{ sandboxFunctions }}` to the
|
||||
`CopilotKitProvider`. The provider injects a JSON descriptor of those
|
||||
functions into the agent context.
|
||||
- `CopilotKitMiddleware` here picks up both the frontend-registered
|
||||
`generateSandboxedUi` tool (auto-registered by the provider when OGUI
|
||||
is enabled on the runtime) AND the sandbox-function descriptors (via
|
||||
`copilotkit.context`), and merges them into what the LLM sees.
|
||||
- The LLM then generates HTML + JS that calls
|
||||
`Websandbox.connection.remote.<name>(...)` in response to user
|
||||
interactions.
|
||||
- The runtime's `OpenGenerativeUIMiddleware` converts the streaming
|
||||
`generateSandboxedUi` tool call into `open-generative-ui` activity
|
||||
events that the built-in renderer mounts inside a sandboxed iframe.
|
||||
- The renderer wires each `sandboxFunctions` entry as a `localApi`
|
||||
method on the websandbox connection so in-iframe code can call it.
|
||||
|
||||
The "minimal" sibling (`open_gen_ui_agent.py`) uses the same OGUI
|
||||
pipeline without sandbox functions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are a UI-generating assistant for the Open Generative UI (Advanced) demo.
|
||||
|
||||
On every user turn you MUST call the `generateSandboxedUi` frontend tool
|
||||
exactly once. The generated UI must be INTERACTIVE and must invoke the
|
||||
available host-side sandbox functions described in your agent context
|
||||
(delivered via `copilotkit.context`) in response to user interactions.
|
||||
|
||||
Sandbox-function calling contract (inside the generated iframe):
|
||||
- Call a host function with:
|
||||
await Websandbox.connection.remote.<functionName>(args)
|
||||
The call returns a Promise; await it.
|
||||
- Each handler returns a plain object. Read the return shape from the
|
||||
function's description in your context and use the EXACT field names
|
||||
it returns (e.g. if the description says the handler returns
|
||||
`{ ok, value }`, read `res.value` — not `res.result`).
|
||||
- Descriptions, names, and JSON-schema parameter shapes for every
|
||||
available sandbox function are listed in your context. Read them
|
||||
carefully and wire at least one interactive UI element to call one.
|
||||
|
||||
Sandbox iframe restrictions (CRITICAL):
|
||||
- The iframe runs with `sandbox="allow-scripts"` ONLY. Forms are NOT
|
||||
allowed. You MUST NOT use `<form>` elements or `<button type="submit">`.
|
||||
Clicking a submit button inside a sandboxed form is blocked by the
|
||||
browser BEFORE any onsubmit handler runs, so the sandbox-function call
|
||||
never fires.
|
||||
- Use plain `<button type="button">` elements and wire them with
|
||||
`addEventListener('click', ...)` or an inline click handler. Do the same
|
||||
for "Enter" keypresses on inputs: attach a `keydown` listener that
|
||||
checks `e.key === 'Enter'` and calls your handler directly — do NOT
|
||||
wrap inputs in a `<form>`.
|
||||
|
||||
Generation guidance:
|
||||
- Emit `initialHeight` and `placeholderMessages` first, then CSS, then
|
||||
HTML, then `jsFunctions` / `jsExpressions` if helpful.
|
||||
- Always include a visible result element (e.g. an output div) that you
|
||||
UPDATE after the sandbox function resolves, so the user can *see* the
|
||||
round-trip: "Button clicked -> remote call -> visible result".
|
||||
- Use CDN scripts (Chart.js, D3, etc.) via <script> tags in the HTML head
|
||||
when you need libraries.
|
||||
- Do NOT use fetch/XHR, localStorage, or document.cookie — the sandbox has
|
||||
no same-origin access. ONLY use `Websandbox.connection.remote.*` for
|
||||
host-page interactions.
|
||||
- Keep your own chat message brief (1 sentence max); the rendered UI is
|
||||
the real output.
|
||||
"""
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4.1", model_kwargs={"parallel_tool_calls": False}),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Minimal LangGraph agent for the Open-Ended Generative UI demo.
|
||||
|
||||
The simplest possible example that exercises the open-ended generative UI
|
||||
pipeline. All the interesting work happens outside the agent:
|
||||
|
||||
- `CopilotKitMiddleware` merges the frontend-registered `generateSandboxedUi`
|
||||
tool (auto-registered by `CopilotKitProvider` when the runtime has
|
||||
`openGenerativeUI` enabled) into the agent's tool list. The LLM then sees
|
||||
the tool via the normal AG-UI flow.
|
||||
- When the LLM calls `generateSandboxedUi`, the runtime's
|
||||
`OpenGenerativeUIMiddleware` (enabled via `openGenerativeUI` on the
|
||||
runtime — see `src/app/api/copilotkit-ogui/route.ts`) converts that
|
||||
streaming tool call into `open-generative-ui` activity events that the
|
||||
built-in renderer mounts inside a sandboxed iframe.
|
||||
|
||||
This is the minimal variant: no sandbox functions, no app-side tools. The
|
||||
agent simply asks the LLM to design and emit a single-shot sandboxed UI.
|
||||
The "advanced" sibling (`open_gen_ui_advanced_agent.py`) builds on this
|
||||
with sandbox-to-host function calling via `openGenerativeUI.sandboxFunctions`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are a UI-generating assistant for an Open Generative UI demo
|
||||
focused on intricate, educational visualisations (3D axes / rotations,
|
||||
neural-network activations, sorting-algorithm walkthroughs, Fourier
|
||||
series, wave interference, planetary orbits, etc.).
|
||||
|
||||
On every user turn you MUST call the `generateSandboxedUi` frontend tool
|
||||
exactly once. Design a visually polished, self-contained HTML + CSS +
|
||||
SVG widget that *teaches* the requested concept.
|
||||
|
||||
The frontend injects a detailed "design skill" as agent context
|
||||
describing the palette, typography, labelling, and motion conventions
|
||||
expected — follow it closely. Key invariants:
|
||||
- Use inline SVG (or <canvas>) for geometric content, not stacks of <div>s.
|
||||
- Every axis is labelled; every colour-coded series has a legend.
|
||||
- Prefer CSS @keyframes / transitions over setInterval; loop cyclical
|
||||
concepts with animation-iteration-count: infinite.
|
||||
- Motion must teach — animate the actual step of the concept, not decoration.
|
||||
- No fetch / XHR / localStorage — the sandbox has no same-origin access.
|
||||
|
||||
Output order:
|
||||
- `initialHeight` (typically 480-560 for visualisations) first.
|
||||
- A short `placeholderMessages` array (2-3 lines describing the build).
|
||||
- `css` (complete).
|
||||
- `html` (streams live — keep it tidy). CDN <script> tags for Chart.js /
|
||||
D3 / etc. go inside the html.
|
||||
|
||||
Keep your own chat message brief (1 sentence) — the real output is the
|
||||
rendered visualisation.
|
||||
"""
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4.1", model_kwargs={"parallel_tool_calls": False}),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
"""LangGraph agent backing the Shared State (Agent Read-Only) demo.
|
||||
|
||||
Demonstrates the `useAgentContext` hook from @copilotkit/react-core/v2:
|
||||
the frontend provides READ-ONLY context *to* the agent. This is the
|
||||
reverse direction of writable-shared-state — the UI cannot be edited by
|
||||
the agent, but the agent reads this context on every turn via
|
||||
`CopilotKitMiddleware`, which routes the context entries into the
|
||||
model's message history.
|
||||
|
||||
No custom state, no tools: this is the minimal shape of the
|
||||
useAgentContext pattern. The agent just reads whatever context the
|
||||
frontend registered and answers accordingly.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=(
|
||||
"You are a helpful, concise assistant. The frontend may provide "
|
||||
"read-only context about the user (e.g. name, timezone, recent "
|
||||
"activity) via the `useAgentContext` hook. Always consult that "
|
||||
"context when it is relevant — address the user by name if known, "
|
||||
"respect their timezone when mentioning times, and reference "
|
||||
"recent activity when it helps you answer. Keep responses short."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Reasoning agent — emits AG-UI REASONING_MESSAGE_* events.
|
||||
|
||||
Shared by agentic-chat-reasoning (custom amber ReasoningBlock) and
|
||||
reasoning-default-render (CopilotKit's built-in reasoning slot).
|
||||
|
||||
Why a reasoning model + Responses API:
|
||||
The OpenAI Responses API streams `response.reasoning_summary_text.delta`
|
||||
items only for native reasoning models (gpt-5, o3, o4-mini, etc.).
|
||||
CopilotKit's bridge translates those into AG-UI REASONING_MESSAGE_*
|
||||
events with `role: "reasoning"`, which the frontend renders via the
|
||||
`reasoningMessage` slot. gpt-4o / gpt-4o-mini do not emit reasoning
|
||||
items, so a non-reasoning model would never light up the slot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from deepagents import create_deep_agent
|
||||
from langchain.chat_models import init_chat_model
|
||||
|
||||
from src.agents.src._header_forwarding_middleware import HeaderForwardingMiddleware
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant. For each user question, first think "
|
||||
"step-by-step about the approach, then give a concise answer."
|
||||
)
|
||||
|
||||
REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-5-mini")
|
||||
|
||||
# No full CopilotKitMiddleware — this demo exercises only reasoning-token
|
||||
# streaming through the OpenAI Responses API and doesn't consume frontend
|
||||
# tools or app context. We still attach the minimal HeaderForwardingMiddleware
|
||||
# so the inbound ``x-aimock-context`` (and other ``x-*``) headers reach the
|
||||
# outgoing /v1/responses call; without it the LangGraph run swallows them
|
||||
# inside ``configurable`` and aimock 404s with no fixture match. The minimal
|
||||
# middleware does ONLY header propagation — no App-Context injection, no
|
||||
# tool-merging, no state-surfacing. Mirrors langgraph-python's reasoning agent.
|
||||
graph = create_deep_agent(
|
||||
model=init_chat_model(
|
||||
f"openai:{REASONING_MODEL}",
|
||||
use_responses_api=True,
|
||||
reasoning={"effort": "low", "summary": "auto"},
|
||||
),
|
||||
tools=[],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
middleware=[HeaderForwardingMiddleware()],
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""LangGraph (FastAPI) agent for the A2UI Error Recovery demo (OSS-158 / OSS-375).
|
||||
|
||||
Same dynamic-schema A2UI setup as `a2ui_dynamic.py` (declarative-gen-ui), but
|
||||
with the toolkit's validate->retry recovery loop made *visible*. The two aimock
|
||||
pills drive the inner `render_a2ui` sub-agent two ways:
|
||||
|
||||
- HEAL pill: the model emits FREE-FORM / sloppy A2UI args (components and data
|
||||
as JSON strings rather than structured arrays) — the toolkit heals them via
|
||||
`parse_and_fix` into a valid surface in a single pass, which paints.
|
||||
- EXHAUST pill: every attempt is structurally invalid (the root references a
|
||||
missing child), so the validate->retry loop hits the cap and the tool
|
||||
returns the `a2ui_recovery_exhausted` hard-fail envelope, which the renderer
|
||||
surfaces as a tasteful `failed` state (no broken surface).
|
||||
|
||||
Backend-owned wiring: unlike the declarative-gen-ui demo (which relies on the
|
||||
CopilotKit runtime auto-injecting `generate_a2ui`), this agent OWNS the tool via
|
||||
`ag_ui_langgraph.get_a2ui_tools`, whose body runs the `render_a2ui` sub-agent +
|
||||
the toolkit recovery loop IN-GRAPH. The dedicated route sets
|
||||
`injectA2UITool: false` so the runtime does not inject a second copy.
|
||||
|
||||
Mirrors `showcase/integrations/langgraph-python/src/agents/recovery_agent.py`.
|
||||
Catalog is reused from declarative-gen-ui ("declarative-gen-ui-catalog"); the
|
||||
Vantage Threads sales dataset + composition rules arrive from the frontend via
|
||||
App Context (declarative-gen-ui/sales-context.ts).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from ag_ui_langgraph import get_a2ui_tools
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _log_attempt(record: dict) -> None:
|
||||
"""Dev observability: log each recovery attempt (incl. rejected ones)."""
|
||||
logger.info(
|
||||
"[a2ui recovery] attempt %s: %s %s",
|
||||
record.get("attempt"),
|
||||
"valid" if record.get("ok") else "invalid",
|
||||
record.get("errors"),
|
||||
)
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are the embedded sales analyst for Vantage Threads, the fictional "
|
||||
"B2B apparel company described in your App Context. Answer every business "
|
||||
"question by calling `generate_a2ui` to draw a rich visual surface, and "
|
||||
"keep the chat reply to one short sentence. Ground every number in the "
|
||||
"sales dataset from your App Context. `generate_a2ui` handles the "
|
||||
"rendering — and its automatic recovery — for you."
|
||||
)
|
||||
|
||||
_MODEL = "gpt-4.1"
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model=_MODEL),
|
||||
tools=[
|
||||
get_a2ui_tools(
|
||||
{
|
||||
"model": ChatOpenAI(model=_MODEL),
|
||||
"default_catalog_id": "declarative-gen-ui-catalog",
|
||||
"recovery": {"maxAttempts": 3},
|
||||
"on_a2ui_attempt": _log_attempt,
|
||||
}
|
||||
)
|
||||
],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""LangGraph agent backing the Shared State (Read + Write) demo.
|
||||
|
||||
Demonstrates the full bidirectional shared-state pattern between UI and
|
||||
agent:
|
||||
|
||||
- **UI -> agent (write)**: The UI owns a `preferences` object (the user's
|
||||
profile) that it writes into agent state via `agent.setState(...)`. A
|
||||
middleware reads those preferences every turn and injects them into
|
||||
the system prompt, so the LLM adapts accordingly.
|
||||
- **agent -> UI (read)**: The agent can call `set_notes` to update a
|
||||
`notes` slot in shared state. The UI reflects every update in real
|
||||
time via `useAgent(...)`.
|
||||
|
||||
Together this shows the canonical LangGraph bidirectional shared
|
||||
state: frontend writes, backend reads AND writes, frontend re-renders.
|
||||
|
||||
This is the FastAPI variant — the graph is exported and registered in
|
||||
`langgraph.json`. The FastAPI server (langgraph-cli) hosts the graph;
|
||||
the Next.js runtime route bridges the CopilotKit AG-UI protocol to the
|
||||
LangGraph deployment.
|
||||
"""
|
||||
|
||||
from typing import Any, Awaitable, Callable, TypedDict
|
||||
|
||||
from langchain.agents import AgentState as BaseAgentState, create_agent
|
||||
from langchain.agents.middleware import (
|
||||
AgentMiddleware,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
)
|
||||
from langchain.tools import ToolRuntime, tool
|
||||
from langchain_core.messages import SystemMessage, ToolMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.types import Command
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
class Preferences(TypedDict, total=False):
|
||||
name: str
|
||||
tone: str # "formal" | "casual" | "playful"
|
||||
language: str # "English", "Spanish", ...
|
||||
interests: list[str]
|
||||
|
||||
|
||||
class AgentState(BaseAgentState):
|
||||
"""Bidirectional shared state between UI and agent.
|
||||
|
||||
- `preferences` is written by the UI (via agent.setState).
|
||||
- `notes` is written by the agent (via the `set_notes` tool) and
|
||||
read by the UI.
|
||||
"""
|
||||
|
||||
preferences: Preferences
|
||||
notes: list[str]
|
||||
|
||||
|
||||
@tool
|
||||
def set_notes(notes: list[str], runtime: ToolRuntime) -> Command:
|
||||
"""Replace the notes array in shared state with the full updated list.
|
||||
|
||||
Use this tool whenever the user asks you to "remember" something, or
|
||||
when you have an observation about the user worth surfacing in the
|
||||
UI's notes panel. Always pass the FULL notes list (existing notes +
|
||||
any new ones), not a diff. Keep each note short (< 120 chars).
|
||||
"""
|
||||
return Command(
|
||||
update={
|
||||
"notes": notes,
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
content="Notes updated.",
|
||||
tool_call_id=runtime.tool_call_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PreferencesInjectorMiddleware(AgentMiddleware[AgentState, Any]):
|
||||
"""Injects the UI-supplied `preferences` into the system prompt.
|
||||
|
||||
Every turn, we read the latest `preferences` from agent state and
|
||||
prepend a SystemMessage that tells the LLM about them. This is how
|
||||
UI-written state becomes visible to the agent.
|
||||
"""
|
||||
|
||||
state_schema = AgentState
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "PreferencesInjectorMiddleware"
|
||||
|
||||
def _build_prefs_message(self, prefs: Preferences) -> SystemMessage | None:
|
||||
if not prefs:
|
||||
return None
|
||||
lines = ["The user has shared these preferences with you:"]
|
||||
if prefs.get("name"):
|
||||
lines.append(f"- Name: {prefs['name']}")
|
||||
if prefs.get("tone"):
|
||||
lines.append(f"- Preferred tone: {prefs['tone']}")
|
||||
if prefs.get("language"):
|
||||
lines.append(f"- Preferred language: {prefs['language']}")
|
||||
interests = prefs.get("interests") or []
|
||||
if interests:
|
||||
lines.append(f"- Interests: {', '.join(interests)}")
|
||||
# If `prefs` is non-empty but contains only unknown keys or empty
|
||||
# values, no payload lines were appended — emitting just the
|
||||
# header + trailer would lie to the agent ("preferences exist")
|
||||
# when in fact none do. Bail out instead.
|
||||
if len(lines) == 1:
|
||||
return None
|
||||
lines.append(
|
||||
"Tailor every response to these preferences. Address the user "
|
||||
"by name when appropriate."
|
||||
)
|
||||
return SystemMessage(content="\n".join(lines))
|
||||
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelResponse:
|
||||
prefs = request.state.get("preferences") or {}
|
||||
prefs_message = self._build_prefs_message(prefs)
|
||||
if prefs_message is None:
|
||||
return handler(request)
|
||||
return handler(request.override(messages=[prefs_message, *request.messages]))
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelResponse:
|
||||
prefs = request.state.get("preferences") or {}
|
||||
prefs_message = self._build_prefs_message(prefs)
|
||||
if prefs_message is None:
|
||||
return await handler(request)
|
||||
return await handler(
|
||||
request.override(messages=[prefs_message, *request.messages])
|
||||
)
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[set_notes],
|
||||
middleware=[CopilotKitMiddleware(), PreferencesInjectorMiddleware()],
|
||||
state_schema=AgentState,
|
||||
system_prompt=(
|
||||
"You are a helpful, concise assistant. "
|
||||
"The user's preferences are supplied via shared state and will be "
|
||||
"added as a system message at the start of every turn. Always "
|
||||
"respect them. "
|
||||
"When the user asks you to remember something, or when you observe "
|
||||
"something worth surfacing in the UI, call `set_notes` with the "
|
||||
"FULL updated list of short note strings (existing notes + new)."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""LangGraph agent backing the Sub-Agents demo.
|
||||
|
||||
Demonstrates multi-agent delegation with a visible delegation log.
|
||||
|
||||
A top-level "supervisor" LLM orchestrates three specialized sub-agents,
|
||||
exposed as tools:
|
||||
|
||||
- `research_agent` — gathers facts
|
||||
- `writing_agent` — drafts prose
|
||||
- `critique_agent` — reviews drafts
|
||||
|
||||
Each sub-agent is a full `create_agent(...)` under the hood. Every
|
||||
delegation appends an entry to the `delegations` slot in shared agent
|
||||
state so the UI can render a live "delegation log" as the supervisor
|
||||
fans work out and collects results. This is the canonical LangGraph
|
||||
sub-agents-as-tools pattern, adapted to surface delegation events to
|
||||
the frontend via CopilotKit's shared-state channel.
|
||||
|
||||
This is the FastAPI variant — the graph is exported and registered in
|
||||
`langgraph.json`. Identical agent topology to the langgraph-python
|
||||
reference; only the server framework differs.
|
||||
"""
|
||||
|
||||
# @region[supervisor-delegation-tools]
|
||||
# @region[subagent-setup]
|
||||
import uuid
|
||||
from operator import add
|
||||
from typing import Annotated, Literal, TypedDict
|
||||
|
||||
from langchain.agents import AgentState as BaseAgentState, create_agent
|
||||
from langchain.tools import ToolRuntime, tool
|
||||
from langchain_core.messages import HumanMessage, ToolMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.types import Command
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Delegation(TypedDict):
|
||||
id: str
|
||||
sub_agent: Literal["research_agent", "writing_agent", "critique_agent"]
|
||||
task: str
|
||||
status: Literal["running", "completed", "failed"]
|
||||
result: str
|
||||
|
||||
|
||||
class AgentState(BaseAgentState):
|
||||
"""Shared state. `delegations` is rendered as a live log in the UI.
|
||||
|
||||
`delegations` uses `operator.add` as its channel reducer so concurrent
|
||||
tool calls within a single supervisor turn each contribute their own
|
||||
entry. Without a reducer, parallel `tool_calls` would each read the
|
||||
same snapshot and the channel would last-write-wins, silently dropping
|
||||
every delegation but one from the UI log.
|
||||
"""
|
||||
|
||||
delegations: Annotated[list[Delegation], add]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sub-agents (real LLM agents under the hood)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Each sub-agent is a full-fledged `create_agent(...)` with its own
|
||||
# system prompt. They don't share memory or tools with the supervisor —
|
||||
# the supervisor only sees their return value.
|
||||
_sub_model = ChatOpenAI(model="gpt-4o-mini")
|
||||
|
||||
_research_agent = create_agent(
|
||||
model=_sub_model,
|
||||
tools=[],
|
||||
system_prompt=(
|
||||
"You are a research sub-agent. Given a topic, produce a concise "
|
||||
"bulleted list of 3-5 key facts. No preamble, no closing."
|
||||
),
|
||||
)
|
||||
|
||||
_writing_agent = create_agent(
|
||||
model=_sub_model,
|
||||
tools=[],
|
||||
system_prompt=(
|
||||
"You are a writing sub-agent. Given a brief and optional source "
|
||||
"facts, produce a polished 1-paragraph draft. Be clear and "
|
||||
"concrete. No preamble."
|
||||
),
|
||||
)
|
||||
|
||||
_critique_agent = create_agent(
|
||||
model=_sub_model,
|
||||
tools=[],
|
||||
system_prompt=(
|
||||
"You are an editorial critique sub-agent. Given a draft, give "
|
||||
"2-3 crisp, actionable critiques. No preamble."
|
||||
),
|
||||
)
|
||||
# @endregion[subagent-setup]
|
||||
|
||||
|
||||
def _invoke_sub_agent(agent, task: str) -> str:
|
||||
"""Run a sub-agent on `task` and return its final message content."""
|
||||
result = agent.invoke({"messages": [HumanMessage(content=task)]})
|
||||
messages = result.get("messages", [])
|
||||
if not messages:
|
||||
return ""
|
||||
return str(messages[-1].content)
|
||||
|
||||
|
||||
def _delegation_command(
|
||||
sub_agent: str,
|
||||
task: str,
|
||||
status: Literal["completed", "failed"],
|
||||
result: str,
|
||||
tool_call_id: str,
|
||||
) -> Command:
|
||||
"""Build a Command that appends a single new delegation entry.
|
||||
|
||||
Emits ONLY the new entry under `delegations`. The channel reducer
|
||||
(`operator.add` on `AgentState.delegations`) extends the existing
|
||||
list, so parallel tool calls within one supervisor turn each
|
||||
contribute their own entry instead of clobbering each other via a
|
||||
last-write-wins read-modify-write.
|
||||
"""
|
||||
entry: Delegation = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"sub_agent": sub_agent, # type: ignore[typeddict-item]
|
||||
"task": task,
|
||||
"status": status,
|
||||
"result": result,
|
||||
}
|
||||
return Command(
|
||||
update={
|
||||
"delegations": [entry],
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
content=result,
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _delegate(
|
||||
sub_agent_name: str,
|
||||
agent,
|
||||
task: str,
|
||||
tool_call_id: str,
|
||||
) -> Command:
|
||||
"""Invoke a sub-agent and turn the outcome into a Command.
|
||||
|
||||
Wrapped in try/except so that a sub-agent LLM failure (rate limit,
|
||||
transport error, missing API key, etc.) is recorded as a `failed`
|
||||
delegation entry and surfaced to the supervisor as a ToolMessage,
|
||||
instead of propagating and crashing the supervisor turn. The
|
||||
user-facing `result` is scrubbed to the exception class name only;
|
||||
full details are captured server-side via the standard logging path
|
||||
when the exception is re-raised at the caller's discretion (we do
|
||||
not re-raise here — recovery is the supervisor's job).
|
||||
"""
|
||||
try:
|
||||
result = _invoke_sub_agent(agent, task)
|
||||
return _delegation_command(
|
||||
sub_agent_name, task, "completed", result, tool_call_id
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - intentional broad catch
|
||||
# Keep the message generic; class name only, no exception args
|
||||
# (which can contain prompts, keys, or other sensitive data).
|
||||
message = (
|
||||
f"sub-agent call failed: {exc.__class__.__name__} "
|
||||
f"(see server logs for details)"
|
||||
)
|
||||
return _delegation_command(
|
||||
sub_agent_name, task, "failed", message, tool_call_id
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supervisor tools (each tool delegates to one sub-agent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Each @tool wraps a sub-agent invocation. The supervisor LLM "calls"
|
||||
# these tools to delegate work; each call synchronously runs the
|
||||
# matching sub-agent, records the delegation into shared state, and
|
||||
# returns the sub-agent's output as a ToolMessage the supervisor can
|
||||
# read on its next step.
|
||||
@tool
|
||||
def research_agent(task: str, runtime: ToolRuntime) -> Command:
|
||||
"""Delegate a research task to the research sub-agent.
|
||||
|
||||
Use for: gathering facts, background, definitions, statistics.
|
||||
Returns a bulleted list of key facts.
|
||||
"""
|
||||
return _delegate("research_agent", _research_agent, task, runtime.tool_call_id)
|
||||
|
||||
|
||||
@tool
|
||||
def writing_agent(task: str, runtime: ToolRuntime) -> Command:
|
||||
"""Delegate a drafting task to the writing sub-agent.
|
||||
|
||||
Use for: producing a polished paragraph, draft, or summary. Pass
|
||||
relevant facts from prior research inside `task`.
|
||||
"""
|
||||
return _delegate("writing_agent", _writing_agent, task, runtime.tool_call_id)
|
||||
|
||||
|
||||
@tool
|
||||
def critique_agent(task: str, runtime: ToolRuntime) -> Command:
|
||||
"""Delegate a critique task to the critique sub-agent.
|
||||
|
||||
Use for: reviewing a draft and suggesting concrete improvements.
|
||||
"""
|
||||
return _delegate("critique_agent", _critique_agent, task, runtime.tool_call_id)
|
||||
|
||||
|
||||
# @endregion[supervisor-delegation-tools]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supervisor (the graph we export)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[research_agent, writing_agent, critique_agent],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
state_schema=AgentState,
|
||||
system_prompt=(
|
||||
"You are a supervisor agent that coordinates three specialized "
|
||||
"sub-agents to produce high-quality deliverables.\n\n"
|
||||
"Available sub-agents (call them as tools):\n"
|
||||
" - research_agent: gathers facts on a topic.\n"
|
||||
" - writing_agent: turns facts + a brief into a polished draft.\n"
|
||||
" - critique_agent: reviews a draft and suggests improvements.\n\n"
|
||||
"For most non-trivial user requests, delegate in sequence: "
|
||||
"research -> write -> critique. Pass the relevant facts/draft "
|
||||
"through the `task` argument of each tool. Keep your own "
|
||||
"messages short — explain the plan once, delegate, then return "
|
||||
"a concise summary once done. The UI shows the user a live log "
|
||||
"of every sub-agent delegation."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
LangGraph agent for the CopilotKit Tool Rendering demos.
|
||||
|
||||
Backs the three tool-rendering cells:
|
||||
- tool-rendering-default-catchall (no frontend renderers)
|
||||
- tool-rendering-custom-catchall (wildcard renderer on frontend)
|
||||
- tool-rendering (per-tool + catch-all on frontend)
|
||||
- tool-rendering-reasoning-chain (testing — also streams reasoning)
|
||||
|
||||
All cells share this backend — they differ only in how the frontend
|
||||
renders the same tool calls. Kept separate from `agent.py` so the
|
||||
tool-rendering demo has a tightly-scoped tool set.
|
||||
"""
|
||||
|
||||
# @region[weather-tool-backend]
|
||||
from random import choice, randint
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
# Multi-tool chaining prompt.
|
||||
#
|
||||
# The goal of this demo is to surface MULTIPLE tool-call cards per turn so
|
||||
# the rendering patterns (per-tool + catch-all) get exercised visibly. The
|
||||
# prompt nudges the model toward an explore-then-enrich pattern (e.g.
|
||||
# `get_weather("Tokyo")` -> `search_flights(..., "Tokyo")`) without forcing
|
||||
# a rigid recipe: we describe the *habit*, not a chain.
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful travel & lifestyle concierge. You have mock tools "
|
||||
"for weather, flights, stock prices, and dice rolls - they all return "
|
||||
"fake data, so call them liberally.\n\n"
|
||||
"Your habit is to CHAIN tools when one answer naturally invites another. "
|
||||
"For a single user question, call at least TWO tools in succession when "
|
||||
"the topic allows before composing your final reply. Examples of "
|
||||
"helpful chains you should default to:\n"
|
||||
" - 'What's the weather in Tokyo?' -> call get_weather('Tokyo'), then "
|
||||
"call search_flights(origin='SFO', destination='Tokyo') so the user "
|
||||
"also sees how to get there.\n"
|
||||
" - 'How is AAPL doing?' -> call get_stock_price('AAPL'), then call "
|
||||
"get_stock_price on a related ticker (e.g. 'MSFT' or 'GOOGL') for "
|
||||
"comparison.\n"
|
||||
" - 'Roll a d20' -> call roll_dice(20), then call roll_dice again with "
|
||||
"a different number of sides so the user sees a contrast.\n"
|
||||
" - 'Find flights from SFO to JFK' -> call search_flights, then call "
|
||||
"get_weather on the destination city.\n\n"
|
||||
"Only skip chaining when the user has clearly asked for a single, "
|
||||
"atomic answer and more tool calls would feel intrusive. Never "
|
||||
"fabricate data that a tool could provide."
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def get_weather(location: str) -> dict:
|
||||
"""Get the current weather for a given location.
|
||||
|
||||
Useful on its own for weather questions, and a great companion to
|
||||
`search_flights` - always consider checking the weather at a
|
||||
destination the user is flying to, and checking flights to any
|
||||
city whose weather the user has just asked about.
|
||||
"""
|
||||
return {
|
||||
"city": location,
|
||||
"temperature": 68,
|
||||
"humidity": 55,
|
||||
"wind_speed": 10,
|
||||
"conditions": "Sunny",
|
||||
}
|
||||
|
||||
|
||||
# @endregion[weather-tool-backend]
|
||||
|
||||
|
||||
@tool
|
||||
def search_flights(origin: str, destination: str) -> dict:
|
||||
"""Search mock flights from an origin airport to a destination airport.
|
||||
|
||||
Pairs naturally with `get_weather`: after searching flights, check
|
||||
the weather at the destination so the user can plan. When the user
|
||||
mentions a city without a matching origin, default the origin to
|
||||
'SFO'.
|
||||
"""
|
||||
return {
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"flights": [
|
||||
{
|
||||
"airline": "United",
|
||||
"flight": "UA231",
|
||||
"depart": "08:15",
|
||||
"arrive": "16:45",
|
||||
"price_usd": 348,
|
||||
},
|
||||
{
|
||||
"airline": "Delta",
|
||||
"flight": "DL412",
|
||||
"depart": "11:20",
|
||||
"arrive": "19:55",
|
||||
"price_usd": 312,
|
||||
},
|
||||
{
|
||||
"airline": "JetBlue",
|
||||
"flight": "B6722",
|
||||
"depart": "17:05",
|
||||
"arrive": "01:30",
|
||||
"price_usd": 289,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def get_stock_price(ticker: str) -> dict:
|
||||
"""Get a mock current price for a stock ticker.
|
||||
|
||||
When the user asks about a single ticker, consider also pulling a
|
||||
related ticker for context (e.g. if they ask about 'AAPL', also
|
||||
fetch 'MSFT' or 'GOOGL' so the reply can compare).
|
||||
"""
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"price_usd": round(100 + randint(0, 400) + randint(0, 99) / 100, 2),
|
||||
"change_pct": round(choice([-1, 1]) * (randint(0, 300) / 100), 2),
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def roll_dice(sides: int = 6) -> dict:
|
||||
"""Roll a single die with the given number of sides.
|
||||
|
||||
When the user asks for a roll, consider rolling twice with different
|
||||
numbers of sides so the reply can show a contrast (e.g. a d6 AND a
|
||||
d20).
|
||||
"""
|
||||
return {"sides": sides, "result": randint(1, max(2, sides))}
|
||||
|
||||
|
||||
model = ChatOpenAI(model="gpt-4o-mini")
|
||||
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=[get_weather, search_flights, get_stock_price, roll_dice],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
"""Tool Rendering (Reasoning Chain) — minimal deep agent with tools.
|
||||
|
||||
Routes through a reasoning-capable OpenAI model via the Responses API
|
||||
so the chain of thought streams as AG-UI REASONING_MESSAGE_* events
|
||||
alongside the tool calls. See `reasoning_agent.py` for the rationale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from random import choice, randint
|
||||
|
||||
from deepagents import create_deep_agent
|
||||
from langchain.chat_models import init_chat_model
|
||||
from langchain.tools import tool
|
||||
|
||||
from src.agents.src._header_forwarding_middleware import HeaderForwardingMiddleware
|
||||
|
||||
|
||||
@tool
|
||||
def get_weather(location: str) -> dict:
|
||||
"""Get the current weather for a given location."""
|
||||
return {
|
||||
"city": location,
|
||||
"temperature": 68,
|
||||
"humidity": 55,
|
||||
"wind_speed": 10,
|
||||
"conditions": "Sunny",
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def search_flights(origin: str, destination: str) -> dict:
|
||||
"""Search mock flights from an origin airport to a destination airport."""
|
||||
return {
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"flights": [
|
||||
{
|
||||
"airline": "United",
|
||||
"flight": "UA231",
|
||||
"depart": "08:15",
|
||||
"arrive": "16:45",
|
||||
"price_usd": 348,
|
||||
},
|
||||
{
|
||||
"airline": "Delta",
|
||||
"flight": "DL412",
|
||||
"depart": "11:20",
|
||||
"arrive": "19:55",
|
||||
"price_usd": 312,
|
||||
},
|
||||
{
|
||||
"airline": "JetBlue",
|
||||
"flight": "B6722",
|
||||
"depart": "17:05",
|
||||
"arrive": "01:30",
|
||||
"price_usd": 289,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def get_stock_price(ticker: str) -> dict:
|
||||
"""Get a mock current price for a stock ticker."""
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"price_usd": round(100 + randint(0, 400) + randint(0, 99) / 100, 2),
|
||||
"change_pct": round(choice([-1, 1]) * (randint(0, 300) / 100), 2),
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def roll_dice(sides: int = 6) -> dict:
|
||||
"""Roll a single die with the given number of sides."""
|
||||
return {"sides": sides, "result": randint(1, max(2, sides))}
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a travel & lifestyle concierge. When a user asks a question, "
|
||||
"reason step-by-step and call 2+ tools in succession when relevant."
|
||||
)
|
||||
|
||||
REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-5-mini")
|
||||
|
||||
# No full CopilotKitMiddleware — this demo exercises only reasoning-token
|
||||
# streaming alongside tool calls and doesn't consume frontend tools or app
|
||||
# context. We attach the minimal HeaderForwardingMiddleware so the inbound
|
||||
# ``x-aimock-context`` (and other ``x-*``) headers reach the outgoing
|
||||
# /v1/responses call. Mirrors langgraph-python's tool_rendering_reasoning_chain.
|
||||
graph = create_deep_agent(
|
||||
model=init_chat_model(
|
||||
f"openai:{REASONING_MODEL}",
|
||||
use_responses_api=True,
|
||||
reasoning={"effort": "low", "summary": "auto"},
|
||||
),
|
||||
tools=[get_weather, search_flights, get_stock_price, roll_dice],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
middleware=[HeaderForwardingMiddleware()],
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
from langchain_core.messages import ToolCall
|
||||
|
||||
|
||||
def should_route_to_tool_node(tool_calls: list[ToolCall], fe_tools: list[ToolCall]):
|
||||
"""
|
||||
Returns True if none of the tool calls are frontend tools.
|
||||
|
||||
Args:
|
||||
tool_calls: List of tool calls from the model response
|
||||
fe_tools: List of frontend tool names
|
||||
|
||||
Returns:
|
||||
bool: True if all tool calls are backend tools, False if any are frontend tools
|
||||
"""
|
||||
if not tool_calls:
|
||||
return False
|
||||
|
||||
# Get the set of frontend tool names for faster lookup
|
||||
fe_tool_names = {tool.get("name") for tool in fe_tools}
|
||||
|
||||
# Check if any tool call is a frontend tool
|
||||
for tool_call in tool_calls:
|
||||
tool_name = (
|
||||
tool_call.get("name")
|
||||
if isinstance(tool_call, dict)
|
||||
else getattr(tool_call, "name", None)
|
||||
)
|
||||
if tool_name in fe_tool_names:
|
||||
return False
|
||||
|
||||
# None of the tool calls are frontend tools
|
||||
return True
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
// Dedicated runtime for the A2UI — Fixed Schema cell. Splitting into its own
|
||||
// endpoint (mirroring beautiful-chat) lets us set
|
||||
// `a2ui.injectA2UITool: false` — the backend agent owns the `display_flight`
|
||||
// tool which emits its own `a2ui_operations` container via `a2ui.render(...)`.
|
||||
//
|
||||
// Reference:
|
||||
// - src/app/api/copilotkit/route.ts (LF main runtime)
|
||||
// - src/agents/src/a2ui_fixed.py (the backend graph)
|
||||
|
||||
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 || "http://localhost:8123";
|
||||
|
||||
const a2uiFixedSchemaAgent = new LangGraphAgent({
|
||||
deploymentUrl: `${AGENT_URL}/`,
|
||||
graphId: "a2ui_fixed",
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents: { "a2ui-fixed-schema": a2uiFixedSchemaAgent },
|
||||
a2ui: {
|
||||
// The backend emits its own `a2ui_operations` container via
|
||||
// `a2ui.render(...)` inside `display_flight` (see src/agents/src/a2ui_fixed.py).
|
||||
// We still run the A2UI middleware so it detects the container in tool
|
||||
// results and forwards surfaces to the frontend — but we do NOT inject a
|
||||
// runtime `render_a2ui` tool on top of the agent's existing tools.
|
||||
injectA2UITool: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-a2ui-fixed-schema",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
// Dedicated runtime for the A2UI Error Recovery demo.
|
||||
// `a2ui.injectA2UITool: false` — the backend LangGraph agent OWNS
|
||||
// `generate_a2ui` via `ag_ui_langgraph.get_a2ui_tools` (see
|
||||
// src/agents/src/recovery_agent.py), whose body runs the `render_a2ui`
|
||||
// sub-agent + the toolkit validate->retry recovery loop + the
|
||||
// recovery-exhausted hard-fail envelope IN-GRAPH (OSS-158 / OSS-375). The
|
||||
// runtime must NOT inject a second copy (double-bind); this `false` is
|
||||
// load-bearing post CopilotKit#5611 (the provider catalog otherwise defaults
|
||||
// injectA2UITool to true). The middleware still renders the building ->
|
||||
// retrying (N/M) -> painted / failed lifecycle.
|
||||
//
|
||||
// The demo reuses the declarative-gen-ui catalog. The aimock fixtures force the
|
||||
// inner render_a2ui sub-agent to emit free-form/sloppy args the middleware heals
|
||||
// (heal pill) or a structurally-invalid surface on every attempt (exhaust pill).
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8123";
|
||||
|
||||
const recoveryAgent = new LangGraphAgent({
|
||||
deploymentUrl: `${AGENT_URL}/`,
|
||||
graphId: "a2ui_recovery",
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents: { "a2ui-recovery": recoveryAgent },
|
||||
a2ui: {
|
||||
injectA2UITool: false,
|
||||
// Reuse the catalog the page registers (shared with declarative-gen-ui).
|
||||
defaultCatalogId: "declarative-gen-ui-catalog",
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-a2ui-recovery",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
// Dedicated runtime for the Agent Config Object demo.
|
||||
//
|
||||
// This runtime hosts a single LangGraph agent (`agent_config_agent`).
|
||||
// The Python graph reads three properties — tone / expertise / responseLength
|
||||
// — from `RunnableConfig["configurable"]["properties"]` to build its system
|
||||
// prompt dynamically per turn (see `src/agents/agent_config_agent.py`).
|
||||
//
|
||||
// ── Property-forwarding regression note ────────────────────────────
|
||||
// Previously this route used a custom `AgentConfigLangGraphAgent` subclass
|
||||
// that repacked the CopilotKit provider's `properties` into
|
||||
// `forwardedProps.config.configurable.properties` so the Python graph could
|
||||
// read them. That stopped working with `@ag-ui/langgraph@0.0.31`, which
|
||||
// builds the LangGraph SDK request as
|
||||
// `{ ..., config, context: { ...input.context, ...config.configurable } }`
|
||||
// — i.e. it merges `configurable` INTO `context`. LangGraph 0.6.0+ rejects
|
||||
// any request that sets both `configurable` and `context`:
|
||||
//
|
||||
// HTTP 400: "Cannot specify both configurable and context. Prefer setting
|
||||
// context alone. Context was introduced in LangGraph 0.6.0 and is the long
|
||||
// term planned replacement for configurable."
|
||||
//
|
||||
// Net effect: any forwardedProps that landed in `configurable.<key>` made
|
||||
// the chat round-trip 400 unconditionally — the user message rendered, but
|
||||
// no assistant reply ever came back.
|
||||
//
|
||||
// To unbreak the chat round-trip, this route now uses the plain
|
||||
// `LangGraphAgent` and stops repacking properties into `configurable`. The
|
||||
// Python graph falls back to its `DEFAULT_*` constants, so the demo's
|
||||
// frontend toggles no longer affect the agent's response style. The
|
||||
// property-forwarding feature is tracked as a known regression pending an
|
||||
// `@ag-ui/langgraph` fix that decouples `context` from `configurable`.
|
||||
//
|
||||
// References:
|
||||
// - src/agents/agent_config_agent.py — the graph (still reads
|
||||
// configurable.properties; falls back to DEFAULT_* when missing)
|
||||
// - src/app/demos/agent-config/page.tsx — the provider config
|
||||
// - node_modules/.pnpm/@ag-ui+langgraph@0.0.31_*/dist/index.js — the
|
||||
// prepareStream merge that introduces the conflict
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
|
||||
const LANGGRAPH_URL =
|
||||
process.env.AGENT_URL ||
|
||||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
|
||||
"http://localhost:8123";
|
||||
|
||||
const agentConfigAgent = new LangGraphAgent({
|
||||
deploymentUrl: LANGGRAPH_URL,
|
||||
graphId: "agent_config_agent",
|
||||
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
|
||||
});
|
||||
|
||||
const agents: Record<string, LangGraphAgent> = {
|
||||
// The page's <CopilotKitProvider agent="agent-config-demo"> resolves here.
|
||||
"agent-config-demo": agentConfigAgent,
|
||||
// Internal components (headless-chat, example-canvas) call `useAgent()` with
|
||||
// no args, which defaults to agentId "default". Alias to the same graph so
|
||||
// those component hooks resolve instead of throwing "Agent 'default' not
|
||||
// found".
|
||||
default: agentConfigAgent,
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
|
||||
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in
|
||||
// source, pending release.
|
||||
agents,
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-agent-config",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
// Dedicated runtime for the /demos/auth cell.
|
||||
//
|
||||
// Demonstrates framework-native request authentication via the V2 runtime's
|
||||
// `onRequest` hook, which runs before routing and can short-circuit the
|
||||
// request by throwing a Response. We validate a static `Authorization: Bearer
|
||||
// <DEMO_TOKEN>` header; mismatch throws 401 before the request reaches the
|
||||
// agent.
|
||||
//
|
||||
// Implementation note: the V1 Next.js adapter
|
||||
// (`copilotRuntimeNextJSAppRouterEndpoint`) does NOT forward the `hooks`
|
||||
// option to the V2 fetch handler. To get `onRequest` wired, this route uses
|
||||
// `createCopilotRuntimeHandler` from `@copilotkit/runtime/v2` directly — the
|
||||
// framework-agnostic fetch handler that returns a plain
|
||||
// `(Request) => Promise<Response>`, which composes cleanly with a Next.js
|
||||
// App Router route export.
|
||||
//
|
||||
// References:
|
||||
// - packages/runtime/src/v2/runtime/core/hooks.ts (onRequest semantics)
|
||||
// - packages/runtime/src/v2/runtime/__tests__/hooks.test.ts (throw Response pattern)
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotRuntimeHandler,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
import { DEMO_AUTH_HEADER } from "@/app/demos/auth/demo-token";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8123";
|
||||
|
||||
// Reuse the neutral `sample_agent` graph for the authenticated path. The
|
||||
// point of this demo is the gate mechanism, not per-user agent branching —
|
||||
// authenticated users get the same behavior as any other neutral demo.
|
||||
const authDemoAgent = new LangGraphAgent({
|
||||
deploymentUrl: `${AGENT_URL}/`,
|
||||
graphId: "sample_agent",
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
"auth-demo": authDemoAgent,
|
||||
// Fallback: useAgent() with no args resolves "default" — alias to the
|
||||
// same agent so hooks in the demo page resolve cleanly.
|
||||
default: authDemoAgent,
|
||||
},
|
||||
});
|
||||
|
||||
const BASE_PATH = "/api/copilotkit-auth";
|
||||
|
||||
// Framework-agnostic fetch handler with the auth gate wired up.
|
||||
const handler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: BASE_PATH,
|
||||
hooks: {
|
||||
onRequest: ({ request }) => {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (authHeader !== DEMO_AUTH_HEADER) {
|
||||
// Throwing a Response short-circuits the pipeline. The runtime maps
|
||||
// thrown Responses to the HTTP response verbatim (status + body).
|
||||
throw new Response(
|
||||
JSON.stringify({
|
||||
error: "unauthorized",
|
||||
message:
|
||||
"Missing or invalid Authorization header. Click Authenticate above to send messages.",
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Next.js App Router bindings. The handler is framework-agnostic — it takes
|
||||
// a web Request and returns a web Response — so it drops straight into the
|
||||
// POST/GET exports without any adapter shim.
|
||||
export const POST = (req: NextRequest) => handler(req);
|
||||
export const GET = (req: NextRequest) => handler(req);
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
// Dedicated runtime for the Beautiful Chat flagship showcase cell.
|
||||
//
|
||||
// Beautiful Chat simultaneously exercises A2UI (dynamic + fixed schema),
|
||||
// Open Generative UI, and MCP Apps. The canonical reference
|
||||
// (examples/integrations/langgraph-python) ships all three flags on a single
|
||||
// runtime, but the 4085 showcase splits those concerns into per-feature
|
||||
// endpoints so non-flagship cells keep their per-demo `useFrontendTool` /
|
||||
// `useComponent` registrations isolated. This route restores the canonical's
|
||||
// combined runtime for just the one cell that needs it.
|
||||
//
|
||||
// References:
|
||||
// - examples/integrations/langgraph-python/src/app/api/copilotkit/[[...slug]]/route.ts
|
||||
// - src/app/api/copilotkit-ogui/route.ts (scoping pattern)
|
||||
// - src/app/api/copilotkit-mcp-apps/route.ts (mcpApps config pattern)
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
|
||||
const LANGGRAPH_URL =
|
||||
process.env.LANGGRAPH_DEPLOYMENT_URL || "http://localhost:8123";
|
||||
|
||||
const beautifulChatAgent = new LangGraphAgent({
|
||||
deploymentUrl: LANGGRAPH_URL,
|
||||
graphId: "beautiful_chat",
|
||||
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
|
||||
});
|
||||
|
||||
const agents: Record<string, LangGraphAgent> = {
|
||||
// The page's <CopilotKit agent="beautiful-chat"> resolves here.
|
||||
"beautiful-chat": beautifulChatAgent,
|
||||
// Internal components (headless-chat, example-canvas) call `useAgent()`
|
||||
// with no args, which defaults to agentId "default". Alias to the same
|
||||
// graph so those component hooks resolve instead of throwing
|
||||
// "Agent 'default' not found". This matches the canonical's
|
||||
// `agents: { default: defaultAgent }` shape.
|
||||
default: beautifulChatAgent,
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
// Canonical: openGenerativeUI: true, a2ui.injectA2UITool: false, mcpApps.
|
||||
openGenerativeUI: true,
|
||||
a2ui: {
|
||||
// The backend graph has its own `generate_a2ui` tool, so we must NOT
|
||||
// inject the runtime's default A2UI tool on top (that would double-bind
|
||||
// the tool slot and confuse the LLM).
|
||||
injectA2UITool: false,
|
||||
// Models follow the tool-usage guide and omit `catalogId`, and the
|
||||
// middleware then falls back to the unregistered spec basic catalog
|
||||
// ("Catalog not found" render error). Pin the catalog the page registers.
|
||||
defaultCatalogId: "copilotkit://app-dashboard-catalog",
|
||||
},
|
||||
mcpApps: {
|
||||
servers: [
|
||||
{
|
||||
type: "http",
|
||||
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
|
||||
// Stable serverId so persisted threads keep restoring the same MCP
|
||||
// server across URL changes.
|
||||
serverId: "beautiful_chat_mcp",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-beautiful-chat",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
// Dedicated runtime for the byoc-hashbrown demo (Wave 4a).
|
||||
//
|
||||
// The demo page (`src/app/demos/byoc-hashbrown/page.tsx`) wraps CopilotChat
|
||||
// in the HashBrownDashboard provider and overrides the assistant message
|
||||
// slot with a renderer that consumes hashbrown-shaped structured output via
|
||||
// `@hashbrownai/react`'s `useUiKit` + `useJsonParser`. The agent behind this
|
||||
// endpoint (`byoc_hashbrown_agent`) has a system prompt tuned to emit that
|
||||
// shape — see `src/agents/byoc_hashbrown_agent.py`.
|
||||
//
|
||||
// Reference:
|
||||
// - src/app/api/copilotkit-a2ui-fixed-schema/route.ts (topology this mirrors)
|
||||
// - src/agents/byoc_hashbrown_agent.py (the backend graph)
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
|
||||
const LANGGRAPH_URL =
|
||||
process.env.AGENT_URL ||
|
||||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
|
||||
"http://localhost:8123";
|
||||
|
||||
const byocHashbrownAgent = new LangGraphAgent({
|
||||
deploymentUrl: LANGGRAPH_URL,
|
||||
graphId: "byoc_hashbrown",
|
||||
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
|
||||
});
|
||||
|
||||
const agents: Record<string, LangGraphAgent> = {
|
||||
"byoc-hashbrown-demo": byocHashbrownAgent,
|
||||
// Internal components (headless-chat, example-canvas) call `useAgent()` with
|
||||
// no args, which defaults to agentId "default". Alias to the same graph so
|
||||
// those component hooks resolve instead of throwing "Agent 'default' not
|
||||
// found".
|
||||
default: byocHashbrownAgent,
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-byoc-hashbrown",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Dedicated runtime for the BYOC json-render demo.
|
||||
*
|
||||
* Splitting into its own endpoint (mirroring beautiful-chat +
|
||||
* declarative-gen-ui) keeps the `byoc_json_render` agent isolated from the
|
||||
* default multi-agent `/api/copilotkit` runtime. The frontend's demo page
|
||||
* (src/app/demos/byoc-json-render/page.tsx) points `<CopilotKit
|
||||
* runtimeUrl>` here.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
|
||||
const LANGGRAPH_URL =
|
||||
process.env.AGENT_URL ||
|
||||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
|
||||
"http://localhost:8123";
|
||||
|
||||
const byocJsonRenderAgent = new LangGraphAgent({
|
||||
deploymentUrl: LANGGRAPH_URL,
|
||||
graphId: "byoc_json_render",
|
||||
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- same-shape mismatch as the other dedicated routes in this
|
||||
// package; the LangGraphAgent satisfies the runtime's agent interface at
|
||||
// runtime but the generics don't line up across the v1/v2 boundary.
|
||||
agents: { byoc_json_render: byocJsonRenderAgent },
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-byoc-json-render",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
// Dedicated runtime for the Declarative Generative UI (A2UI — Dynamic Schema) cell.
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8123";
|
||||
|
||||
const declarativeGenUiAgent = new LangGraphAgent({
|
||||
deploymentUrl: `${AGENT_URL}/`,
|
||||
graphId: "a2ui_dynamic",
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents: { "declarative-gen-ui": declarativeGenUiAgent },
|
||||
// No runtime `a2ui` config: the page passes a catalog to the provider
|
||||
// (`<CopilotKit a2ui={{ catalog }}>`), which auto-enables A2UI and defaults
|
||||
// tool injection on (CopilotKit >= 1.61.2, PR #5611).
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-declarative-gen-ui",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
// CopilotKit runtime for the MCP Apps cell — shared by two demos:
|
||||
// - headless-complete (frontend wires runtimeUrl="/api/copilotkit-mcp-apps"
|
||||
// and renders activity events via a hand-rolled useRenderActivityMessage
|
||||
// in use-rendered-messages.tsx)
|
||||
// - mcp-apps (frontend relies on CopilotKit's built-in MCPAppsActivityRenderer)
|
||||
//
|
||||
// The runtime's `mcpApps` config auto-applies the MCP Apps middleware to the
|
||||
// agent: when the agent calls a tool backed by an MCP UI resource, the
|
||||
// middleware fetches the resource and emits the activity event that the
|
||||
// built-in `MCPAppsActivityRenderer` (registered by CopilotKit internally)
|
||||
// renders in the chat as a sandboxed iframe.
|
||||
//
|
||||
// Reference:
|
||||
// https://docs.copilotkit.ai/integrations/langgraph/generative-ui/mcp-apps
|
||||
|
||||
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 || "http://localhost:8123";
|
||||
|
||||
const headlessCompleteAgent = new LangGraphAgent({
|
||||
deploymentUrl: `${AGENT_URL}/`,
|
||||
graphId: "headless_complete",
|
||||
});
|
||||
|
||||
const mcpAppsAgent = new LangGraphAgent({
|
||||
deploymentUrl: `${AGENT_URL}/`,
|
||||
graphId: "mcp_apps",
|
||||
});
|
||||
|
||||
// @region[runtime-mcpapps-config]
|
||||
// The `mcpApps.servers` config is all you need server-side. The runtime
|
||||
// auto-applies the MCP Apps middleware to every registered agent: on each
|
||||
// MCP tool call it fetches the associated UI resource and emits an
|
||||
// `activity` event that the built-in `MCPAppsActivityRenderer` renders
|
||||
// inline in the chat.
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore
|
||||
agents: {
|
||||
"headless-complete": headlessCompleteAgent,
|
||||
"mcp-apps": mcpAppsAgent,
|
||||
},
|
||||
mcpApps: {
|
||||
servers: [
|
||||
{
|
||||
type: "http",
|
||||
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
|
||||
// Always pin a stable `serverId`. Without it CopilotKit hashes the
|
||||
// URL, and a URL change silently breaks restoration of persisted
|
||||
// MCP Apps in prior conversation threads.
|
||||
serverId: "excalidraw",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
// @endregion[runtime-mcpapps-config]
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-mcp-apps",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
// Dedicated runtime for the Multimodal Attachments demo (Wave 2b).
|
||||
//
|
||||
// Why its own route? The backing graph (`multimodal`, from
|
||||
// src/agents/multimodal_agent.py) runs a vision-capable model (gpt-4o). Every
|
||||
// other cell in the showcase uses a text-only, cheaper model. Registering
|
||||
// `multimodal` under the shared `/api/copilotkit` runtime would silently upgrade
|
||||
// *all* cells that share that runtime to a vision model whenever the browser
|
||||
// routed to this one — wasting tokens and blurring the per-demo cost boundary.
|
||||
// A dedicated route keeps the vision capability — and its cost — scoped to
|
||||
// exactly the cell that exercises it, matching the pattern used by
|
||||
// `/api/copilotkit-beautiful-chat`.
|
||||
//
|
||||
// The page at src/app/demos/multimodal/page.tsx points its `runtimeUrl` at
|
||||
// this endpoint and sets `agent="multimodal-demo"` (the slug registered below).
|
||||
|
||||
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 || "http://localhost:8123";
|
||||
|
||||
const multimodalAgent = new LangGraphAgent({
|
||||
deploymentUrl: `${AGENT_URL}/`,
|
||||
// graphId references the key in langgraph.json — must match the
|
||||
// "multimodal" entry that resolves to src/agents/src/multimodal_agent.py:graph.
|
||||
graphId: "multimodal",
|
||||
});
|
||||
|
||||
const agents: Record<string, LangGraphAgent> = {
|
||||
// The page's <CopilotKit agent="multimodal-demo"> resolves here.
|
||||
"multimodal-demo": multimodalAgent,
|
||||
// Alias for any internal component that calls `useAgent()` without args
|
||||
// (matches the beautiful-chat route's "default" alias pattern).
|
||||
default: multimodalAgent,
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-multimodal",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts; published CopilotRuntime's `agents`
|
||||
// type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects
|
||||
// plain Records. Fixed in source, pending release.
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
|
||||
// Dedicated runtime for the Open Generative UI demos.
|
||||
// Isolated here because the `openGenerativeUI` runtime flag sets
|
||||
// `openGenerativeUIEnabled: true` globally on the probe response, which
|
||||
// causes the CopilotKit provider's setTools effect to wipe per-demo
|
||||
// `useFrontendTool`/`useComponent` registrations in the default runtime.
|
||||
|
||||
const LANGGRAPH_URL =
|
||||
process.env.AGENT_URL ||
|
||||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
|
||||
"http://localhost:8123";
|
||||
|
||||
const openGenUiAgent = new LangGraphAgent({
|
||||
deploymentUrl: LANGGRAPH_URL,
|
||||
graphId: "open_gen_ui",
|
||||
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
|
||||
});
|
||||
const openGenUiAdvancedAgent = new LangGraphAgent({
|
||||
deploymentUrl: LANGGRAPH_URL,
|
||||
graphId: "open_gen_ui_advanced",
|
||||
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
|
||||
});
|
||||
|
||||
const agents: Record<string, LangGraphAgent> = {
|
||||
"open-gen-ui": openGenUiAgent,
|
||||
"open-gen-ui-advanced": openGenUiAdvancedAgent,
|
||||
// Internal components (headless-chat, example-canvas) call `useAgent()` with
|
||||
// no args, which defaults to agentId "default". Alias so those hooks resolve
|
||||
// instead of throwing "Agent 'default' not found".
|
||||
default: openGenUiAgent,
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-ogui",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
// @region[minimal-runtime-flag]
|
||||
// @region[advanced-runtime-config]
|
||||
// Server-side config is identical for the minimal and advanced cells —
|
||||
// the advanced behaviour (sandbox -> host function calls) is wired
|
||||
// entirely on the frontend via `openGenerativeUI.sandboxFunctions` on
|
||||
// the provider. The single `openGenerativeUI` flag below turns on
|
||||
// Open Generative UI for the listed agent(s); the runtime middleware
|
||||
// converts each agent's streamed `generateSandboxedUi` tool call into
|
||||
// `open-generative-ui` activity events.
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
openGenerativeUI: {
|
||||
agents: ["open-gen-ui", "open-gen-ui-advanced"],
|
||||
},
|
||||
}),
|
||||
// @endregion[advanced-runtime-config]
|
||||
// @endregion[minimal-runtime-flag]
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, stack: error.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
// Dedicated runtime for the /demos/voice cell.
|
||||
//
|
||||
// Goals
|
||||
// -----
|
||||
// 1. Advertise `audioFileTranscriptionEnabled: true` on `/info` so the chat
|
||||
// composer renders the mic button.
|
||||
// 2. Handle `POST /transcribe` by invoking an OpenAI-backed
|
||||
// `TranscriptionServiceOpenAI` (from `@copilotkit/voice`), so recorded
|
||||
// audio is transcribed and the transcript auto-sends.
|
||||
// 3. Return a deterministic 4xx when `OPENAI_API_KEY` is not configured,
|
||||
// instead of an opaque 5xx. The V2 runtime's `handleTranscribe` maps
|
||||
// error messages containing "api key" or "unauthorized" to
|
||||
// `AUTH_FAILED → HTTP 401`, so throwing with that message funnels the
|
||||
// missing-key case into the intended 4xx path.
|
||||
//
|
||||
// Implementation
|
||||
// --------------
|
||||
// Wires the **V2** `CopilotRuntime` directly (from `@copilotkit/runtime/v2`)
|
||||
// because the V1 wrapper in `@copilotkit/runtime` drops the
|
||||
// `transcriptionService` option on the floor (see the TODO on the V1
|
||||
// constructor). V2 URL-routes on `/info`, `/agent/:id/run`, `/transcribe`,
|
||||
// etc., so the route file lives at `[[...slug]]/route.ts` to catch all
|
||||
// sub-paths under `/api/copilotkit-voice`.
|
||||
|
||||
// @region[voice-runtime]
|
||||
// @region[transcription-service-guard]
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
TranscriptionService,
|
||||
createCopilotRuntimeHandler,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import type { TranscribeFileOptions } from "@copilotkit/runtime/v2";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
import { TranscriptionServiceOpenAI } from "@copilotkit/voice";
|
||||
import OpenAI from "openai";
|
||||
|
||||
const LANGGRAPH_URL =
|
||||
process.env.AGENT_URL ||
|
||||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
|
||||
"http://localhost:8123";
|
||||
|
||||
const voiceDemoAgent = new LangGraphAgent({
|
||||
deploymentUrl: `${LANGGRAPH_URL}/`,
|
||||
graphId: "sample_agent",
|
||||
});
|
||||
|
||||
/**
|
||||
* Transcription service wrapper that reports a clean, typed auth error when
|
||||
* OPENAI_API_KEY is not configured. When the key is present we delegate to
|
||||
* the real OpenAI-backed service; any upstream Whisper error keeps its
|
||||
* natural categorization.
|
||||
*/
|
||||
class GuardedOpenAITranscriptionService extends TranscriptionService {
|
||||
private delegate: TranscriptionServiceOpenAI | null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
this.delegate = apiKey
|
||||
? new TranscriptionServiceOpenAI({ openai: new OpenAI({ apiKey }) })
|
||||
: null;
|
||||
}
|
||||
|
||||
async transcribeFile(options: TranscribeFileOptions): Promise<string> {
|
||||
if (!this.delegate) {
|
||||
// "api key" substring → handleTranscribe maps to AUTH_FAILED → 401.
|
||||
throw new Error(
|
||||
"OPENAI_API_KEY not configured for this deployment (api key missing). " +
|
||||
"Set OPENAI_API_KEY to enable voice transcription.",
|
||||
);
|
||||
}
|
||||
return this.delegate.transcribeFile(options);
|
||||
}
|
||||
}
|
||||
// @endregion[transcription-service-guard]
|
||||
|
||||
// Cache the runtime + handler across invocations so the transcription service
|
||||
// is constructed once per Node process instead of per request. The guarded
|
||||
// service reads OPENAI_API_KEY lazily in its transcribeFile call path, so
|
||||
// deferring construction past module load is not required for cold-start
|
||||
// safety under missing-key conditions.
|
||||
let cachedHandler: ((req: Request) => Promise<Response>) | null = null;
|
||||
function getHandler(): (req: Request) => Promise<Response> {
|
||||
if (cachedHandler) return cachedHandler;
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
|
||||
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in
|
||||
// source, pending release.
|
||||
agents: {
|
||||
// The page mounts <CopilotKit agent="voice-demo">; resolve that to
|
||||
// the neutral sample_agent graph.
|
||||
"voice-demo": voiceDemoAgent,
|
||||
// useAgent() with no args defaults to "default"; alias so any internal
|
||||
// default-agent lookups resolve against the same graph.
|
||||
default: voiceDemoAgent,
|
||||
},
|
||||
transcriptionService: new GuardedOpenAITranscriptionService(),
|
||||
});
|
||||
|
||||
cachedHandler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit-voice",
|
||||
});
|
||||
return cachedHandler;
|
||||
}
|
||||
|
||||
// Next.js App Router bindings. This file lives at
|
||||
// `src/app/api/copilotkit-voice/[[...slug]]/route.ts` — the catchall slug
|
||||
// pattern forwards every sub-path (`/info`, `/agent/:id/run`,
|
||||
// `/transcribe`, ...) to the V2 handler so its URL router can dispatch.
|
||||
export const POST = (req: NextRequest) => getHandler()(req);
|
||||
export const GET = (req: NextRequest) => getHandler()(req);
|
||||
export const PUT = (req: NextRequest) => getHandler()(req);
|
||||
export const DELETE = (req: NextRequest) => getHandler()(req);
|
||||
// @endregion[voice-runtime]
|
||||
@@ -0,0 +1,169 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
// Emit a structured server-side error log with a correlation id so the
|
||||
// 500 we return to the client carries no stack/message details (which
|
||||
// can leak internal config, prompts, or upstream URLs) while operators
|
||||
// can still grep logs for the same `errorId` to find the full failure.
|
||||
function logRouteError(err: unknown): string {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const errorId = crypto.randomUUID();
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
at: new Date().toISOString(),
|
||||
level: "error",
|
||||
phase: "setup",
|
||||
errorId,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
}),
|
||||
);
|
||||
return errorId;
|
||||
}
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8123";
|
||||
|
||||
console.log("[copilotkit/route] Initializing CopilotKit runtime");
|
||||
console.log(`[copilotkit/route] AGENT_URL: ${AGENT_URL}`);
|
||||
|
||||
// Per-request request/response logging is gated behind this flag (default off).
|
||||
// Under d6 probe fan-out, unconditional per-request logs flooded Railway's
|
||||
// 500-logs/sec cap and killed the replica ("Messages dropped" → container stop).
|
||||
// Set SHOWCASE_ROUTE_DEBUG=1 to re-enable verbose per-request tracing locally.
|
||||
const ROUTE_DEBUG =
|
||||
process.env.SHOWCASE_ROUTE_DEBUG === "1" ||
|
||||
process.env.SHOWCASE_ROUTE_DEBUG === "true";
|
||||
|
||||
function createAgent(graphId: string = "sample_agent") {
|
||||
return new LangGraphAgent({
|
||||
deploymentUrl: `${AGENT_URL}/`,
|
||||
graphId,
|
||||
});
|
||||
}
|
||||
|
||||
const agentNames = [
|
||||
"agentic_chat",
|
||||
"human_in_the_loop",
|
||||
"gen-ui-tool-based",
|
||||
"gen-ui-agent",
|
||||
"shared-state-read",
|
||||
"shared-state-write",
|
||||
"shared-state-streaming",
|
||||
"prebuilt-sidebar",
|
||||
"prebuilt-popup",
|
||||
"chat-slots",
|
||||
"chat-customization-css",
|
||||
"headless-simple",
|
||||
];
|
||||
|
||||
const agents: Record<string, LangGraphAgent> = {};
|
||||
for (const name of agentNames) {
|
||||
agents[name] = createAgent();
|
||||
}
|
||||
|
||||
// Dedicated-graph agents for tool-rendering + reasoning demos.
|
||||
agents["tool-rendering"] = createAgent("tool_rendering");
|
||||
agents["tool-rendering-default-catchall"] = createAgent("tool_rendering");
|
||||
agents["tool-rendering-custom-catchall"] = createAgent("tool_rendering");
|
||||
agents["tool-rendering-reasoning-chain"] = createAgent(
|
||||
"tool_rendering_reasoning_chain",
|
||||
);
|
||||
agents["agentic-chat-reasoning"] = createAgent("reasoning_agent");
|
||||
agents["reasoning-default-render"] = createAgent("reasoning_agent");
|
||||
|
||||
// Interrupt variants — share the dedicated `interrupt_agent` graph that uses
|
||||
// langgraph's `interrupt()` primitive inside `schedule_meeting`.
|
||||
agents["gen-ui-interrupt"] = createAgent("interrupt_agent");
|
||||
agents["interrupt-headless"] = createAgent("interrupt_agent");
|
||||
|
||||
// Dedicated-graph agents — each cell has its own LangGraph graph with a
|
||||
// tailored system prompt (tools=[], CopilotKitMiddleware attached).
|
||||
agents["frontend_tools"] = createAgent("frontend_tools");
|
||||
agents["frontend-tools-async"] = createAgent("frontend_tools_async");
|
||||
agents["hitl-in-app"] = createAgent("hitl_in_app");
|
||||
// In-Chat HITL via the high-level `useHumanInTheLoop` hook — backend
|
||||
// agent has zero tools; the frontend-registered `book_call` tool is
|
||||
// injected into the LLM's tool list by `CopilotKitMiddleware`. Both
|
||||
// the canonical `hitl-in-chat` demo and the `hitl-in-chat-booking`
|
||||
// alias share the same backend graph.
|
||||
agents["hitl-in-chat"] = createAgent("hitl_in_chat");
|
||||
agents["hitl-in-chat-booking"] = createAgent("hitl_in_chat");
|
||||
// HITL step-selection: dedicated graph with tools=[] + CopilotKitMiddleware.
|
||||
// The `human_in_the_loop` alias in the neutral-assistant loop above maps to
|
||||
// `sample_agent` which has 7+ backend tools and a custom AgentState — the
|
||||
// frontend-only `generate_task_steps` tool (from useHumanInTheLoop) is the
|
||||
// ONLY tool this demo needs, so a minimal graph avoids state/tool contention.
|
||||
agents["human_in_the_loop"] = createAgent("hitl_steps");
|
||||
agents["readonly-state-agent-context"] = createAgent(
|
||||
"readonly_state_agent_context",
|
||||
);
|
||||
|
||||
// Shared State (Read + Write) — bidirectional shared state between UI and
|
||||
// agent. UI writes `preferences` via agent.setState; middleware reads them
|
||||
// into the system prompt; agent writes `notes` back via the `set_notes` tool.
|
||||
agents["shared-state-read-write"] = createAgent("shared_state_read_write");
|
||||
|
||||
// Sub-Agents — supervisor delegates to research_agent / writing_agent /
|
||||
// critique_agent (each a full create_agent under the hood). Every delegation
|
||||
// is appended to `state.delegations` for live UI rendering.
|
||||
agents["subagents"] = createAgent("subagents");
|
||||
|
||||
agents["default"] = createAgent();
|
||||
|
||||
console.log(
|
||||
`[copilotkit/route] Registered ${Object.keys(agents).length} agent names: ${Object.keys(agents).join(", ")}`,
|
||||
);
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-ignore
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await handleRequest(req);
|
||||
if (!response.ok) {
|
||||
console.log(`[copilotkit/route] Response status: ${response.status}`);
|
||||
} else if (ROUTE_DEBUG) {
|
||||
console.log(`[copilotkit/route] Response status: ${response.status}`);
|
||||
}
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
// Log full message + stack server-side under a correlation id; return
|
||||
// only the id to the client so we don't leak internal details (upstream
|
||||
// URLs, env-driven config, prompts, etc.) into HTTP responses.
|
||||
const errorId = logRouteError(error);
|
||||
return NextResponse.json(
|
||||
{ error: "internal runtime error", errorId },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const GET = async () => {
|
||||
let agentStatus = "unknown";
|
||||
try {
|
||||
const res = await fetch(`${AGENT_URL}/ok`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
agentStatus = res.ok ? "reachable" : `error (${res.status})`;
|
||||
} catch (e: unknown) {
|
||||
agentStatus = `unreachable (${(e as Error).message})`;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
agent_url: AGENT_URL,
|
||||
agent_status: agentStatus,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// Token-gated: SHOWCASE_DEBUG_TOKEN must be set in env and matched
|
||||
const token =
|
||||
req.headers.get("x-debug-token") || req.nextUrl.searchParams.get("token");
|
||||
const expectedToken = process.env.SHOWCASE_DEBUG_TOKEN;
|
||||
|
||||
if (!expectedToken || !token || token !== expectedToken) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const AGENT_URL =
|
||||
process.env.AGENT_URL || process.env.LANGGRAPH_DEPLOYMENT_URL || "unknown";
|
||||
|
||||
// Agent connectivity
|
||||
let agentStatus = "unknown";
|
||||
let agentDetail = "";
|
||||
try {
|
||||
const res = await fetch(`${AGENT_URL}/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
agentStatus = res.ok ? "ok" : "error";
|
||||
agentDetail = `HTTP ${res.status}`;
|
||||
} catch (e: unknown) {
|
||||
agentStatus = "down";
|
||||
agentDetail = (e as Error).message;
|
||||
}
|
||||
|
||||
const uptime = process.uptime();
|
||||
const mem = process.memoryUsage();
|
||||
|
||||
return NextResponse.json({
|
||||
integration: "langgraph-fastapi",
|
||||
uptime: `${Math.floor(uptime / 60)}m ${Math.floor(uptime % 60)}s`,
|
||||
agent: { url: AGENT_URL, status: agentStatus, detail: agentDetail },
|
||||
memory: {
|
||||
rss: `${Math.round(mem.rss / 1024 / 1024)}MB`,
|
||||
heapUsed: `${Math.round(mem.heapUsed / 1024 / 1024)}MB`,
|
||||
},
|
||||
env: {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ? "set" : "NOT SET",
|
||||
LANGSMITH_API_KEY: process.env.LANGSMITH_API_KEY ? "set" : "NOT SET",
|
||||
},
|
||||
nodeVersion: process.version,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
integration: "langgraph-fastapi",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const INTEGRATION_SLUG = "langgraph-fastapi";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function GET() {
|
||||
const start = Date.now();
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||
`http://localhost:${process.env.PORT || 3000}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/copilotkit`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
method: "agent/run",
|
||||
params: { agentId: "agentic_chat" },
|
||||
body: {
|
||||
threadId: `smoke-${Date.now()}`,
|
||||
runId: `smoke-run-${Date.now()}`,
|
||||
state: {},
|
||||
messages: [
|
||||
{
|
||||
id: `smoke-msg-${Date.now()}`,
|
||||
role: "user",
|
||||
content: "Respond with exactly: OK",
|
||||
},
|
||||
],
|
||||
tools: [],
|
||||
context: [],
|
||||
forwardedProps: {},
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(45000),
|
||||
});
|
||||
|
||||
const latency = Date.now() - start;
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => "");
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
integration: INTEGRATION_SLUG,
|
||||
stage: "runtime_response",
|
||||
error: `Runtime returned ${res.status}: ${errBody.slice(0, 200)}`,
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
// TTFB: read first chunk only to confirm SSE stream started, then cancel
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
integration: INTEGRATION_SLUG,
|
||||
stage: "response_empty",
|
||||
error: "Runtime returned no readable body",
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
const { value, done } = await reader.read();
|
||||
reader.cancel();
|
||||
if (done || !value || value.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
integration: INTEGRATION_SLUG,
|
||||
stage: "response_empty",
|
||||
error: "Runtime returned empty response body",
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
integration: INTEGRATION_SLUG,
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
const latency = Date.now() - start;
|
||||
|
||||
let stage = "unknown";
|
||||
if (err.name === "AbortError" || err.message.includes("timeout"))
|
||||
stage = "timeout";
|
||||
else if (
|
||||
err.message.includes("fetch") ||
|
||||
err.message.includes("ECONNREFUSED")
|
||||
)
|
||||
stage = "agent_unreachable";
|
||||
else stage = "pipeline_error";
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
integration: INTEGRATION_SLUG,
|
||||
stage,
|
||||
error: err.message,
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
// Shared fallback time-slot generator for the interrupt demos
|
||||
// (`gen-ui-interrupt`, `interrupt-headless`). The interrupt backend
|
||||
// (`src/agents/interrupt_agent.py`) supplies its own candidate slots
|
||||
// inside the interrupt payload — these fallbacks only run if the
|
||||
// payload arrives without them. Generating relative to `Date.now()`
|
||||
// keeps the fallback from rotting, which previously had hardcoded
|
||||
// dates that decayed within a week of being authored.
|
||||
|
||||
export interface TimeSlot {
|
||||
label: string;
|
||||
iso: string;
|
||||
}
|
||||
|
||||
function atLocal(date: Date, hour: number, minute = 0): Date {
|
||||
return new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
hour,
|
||||
minute,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function nextMonday(from: Date): Date {
|
||||
// `getDay()` is 0=Sun, 1=Mon, ..., 6=Sat. We want the next Monday
|
||||
// that's at LEAST 2 days away — otherwise "Monday" would collide
|
||||
// with "Tomorrow" on Sunday (offset would be 1) or with itself on
|
||||
// Monday (offset would be 0). Mirrors interrupt_agent.py.
|
||||
const day = from.getDay();
|
||||
let offset = (1 - day + 7) % 7;
|
||||
if (offset <= 1) offset += 7;
|
||||
const next = new Date(from);
|
||||
next.setDate(from.getDate() + offset);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function generateFallbackSlots(now: Date = new Date()): TimeSlot[] {
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(now.getDate() + 1);
|
||||
const monday = nextMonday(now);
|
||||
|
||||
const candidates: Array<[string, Date]> = [
|
||||
["Tomorrow 10:00 AM", atLocal(tomorrow, 10)],
|
||||
["Tomorrow 2:00 PM", atLocal(tomorrow, 14)],
|
||||
["Monday 9:00 AM", atLocal(monday, 9)],
|
||||
["Monday 3:30 PM", atLocal(monday, 15, 30)],
|
||||
];
|
||||
|
||||
return candidates.map(([label, date]) => ({
|
||||
label,
|
||||
iso: date.toISOString(),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Coerces a tool-call result into a typed object. Tool results arrive
|
||||
// as strings when the agent emits JSON or as already-parsed objects
|
||||
// when the runtime decoded them upstream — this helper handles both
|
||||
// shapes and returns `{}` if the result is missing or unparseable.
|
||||
export function parseJsonResult<T>(result: unknown): T {
|
||||
if (!result) return {} as T;
|
||||
try {
|
||||
return (typeof result === "string" ? JSON.parse(result) : result) as T;
|
||||
} catch {
|
||||
return {} as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Helper for the CopilotChat slot overrides. The slot prop types in
|
||||
// `@copilotkit/react-core` are nominally typed against the *exact*
|
||||
// default component identity, but a custom wrapper that returns a
|
||||
// structurally compatible ReactElement is functionally a drop-in. This
|
||||
// helper names that fact and centralizes the type assertion in one
|
||||
// place — readers see `makeSlotOverride` and know it's about the slot
|
||||
// contract, not arbitrary type-system gymnastics. Once the slot prop
|
||||
// types accept structural compatibility, this helper can be deleted
|
||||
// and the casts will resolve automatically.
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
// `any` on the input is intentional: the helper's purpose is to accept
|
||||
// any component shape and assert it as the slot's expected type. A
|
||||
// stricter constraint would defeat the whole point.
|
||||
export function makeSlotOverride<TDefault>(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
component: ComponentType<any>,
|
||||
): TDefault {
|
||||
return component as unknown as TDefault;
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Badge primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes, no `cn()`/`cva` helpers.
|
||||
*/
|
||||
type Variant = "default" | "secondary" | "outline" | "success";
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
default: "border-transparent bg-neutral-900 text-neutral-50",
|
||||
secondary: "border-transparent bg-neutral-100 text-neutral-900",
|
||||
outline: "border-neutral-200 text-neutral-700 bg-white",
|
||||
success: "border-transparent bg-emerald-100 text-emerald-700",
|
||||
};
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: Variant;
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
className = "",
|
||||
variant = "default",
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium tracking-wide ${variantClasses[variant]} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Button primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes, no `cn()`/`cva` helpers.
|
||||
*/
|
||||
type Variant = "default" | "outline" | "secondary" | "ghost" | "success";
|
||||
type Size = "default" | "sm" | "lg";
|
||||
|
||||
const baseClasses =
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60";
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
default: "bg-neutral-900 text-neutral-50 shadow-sm hover:bg-neutral-800",
|
||||
outline:
|
||||
"border border-neutral-200 bg-white text-neutral-900 shadow-sm hover:bg-neutral-100",
|
||||
secondary: "bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-200",
|
||||
ghost: "hover:bg-neutral-100 hover:text-neutral-900",
|
||||
success:
|
||||
"bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm hover:bg-emerald-50",
|
||||
};
|
||||
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-11 rounded-md px-6",
|
||||
};
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{ className = "", variant = "default", size = "default", ...props },
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Card primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes, no `cn()`/`cva` helpers.
|
||||
*/
|
||||
export const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`rounded-xl border border-neutral-200 bg-white text-neutral-950 shadow-sm ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
export const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`flex flex-col space-y-1.5 p-5 pb-3 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
export const CardTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={`text-base font-semibold leading-none tracking-tight text-neutral-900 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
export const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div ref={ref} className={`p-5 pt-0 ${className}`} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
export const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`flex items-center p-5 pt-0 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Separator primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes; uses a div instead of Radix to keep dependencies minimal.
|
||||
*/
|
||||
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
export function Separator({
|
||||
className = "",
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorProps) {
|
||||
const orientationClasses =
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px";
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation={orientation}
|
||||
className={`shrink-0 bg-neutral-200 ${orientationClasses} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Fixed A2UI catalog — wires definitions to renderers.
|
||||
*
|
||||
* `includeBasicCatalog: true` merges CopilotKit's built-in components
|
||||
* (Card, Column, Row, Text, Button, Divider, …) into this catalog, so
|
||||
* the agent's fixed schema (src/agents/a2ui_schemas/flight_schema.json) can
|
||||
* compose custom and basic components interchangeably.
|
||||
*/
|
||||
// @region[catalog-creation]
|
||||
import { createCatalog } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
import { definitions } from "./definitions";
|
||||
import { renderers } from "./renderers";
|
||||
|
||||
export const CATALOG_ID = "copilotkit://flight-fixed-catalog";
|
||||
|
||||
export const catalog = createCatalog(definitions, renderers, {
|
||||
catalogId: CATALOG_ID,
|
||||
includeBasicCatalog: true,
|
||||
});
|
||||
// @endregion[catalog-creation]
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* A2UI catalog DEFINITIONS — platform-agnostic.
|
||||
*
|
||||
* Each entry declares a component name + its Zod props schema. The basic
|
||||
* catalog (Card, Column, Row, Text, Button, …) ships with CopilotKit and
|
||||
* is mixed in via `createCatalog(..., { includeBasicCatalog: true })`, so
|
||||
* we only declare the project-specific additions and the visual overrides
|
||||
* here. (Custom entries with the same name as a basic component override
|
||||
* the basic one — Catalog dedupes by `comp.name`, last-write-wins.)
|
||||
*
|
||||
* IMPORTANT — path bindings: fields that can be bound to a data-model path
|
||||
* (e.g. `{ path: "/origin" }` in the fixed schema JSON) must declare their
|
||||
* Zod type as a union of `z.string()` and `z.object({ path: z.string() })`.
|
||||
* The A2UI `GenericBinder` uses this union to detect the field as dynamic
|
||||
* and resolve the path against the current data model at render time. Using
|
||||
* plain `z.string()` causes the raw `{ path }` object to reach the
|
||||
* renderer, which React then throws on (error #31 "object with keys {path}").
|
||||
* This matches the canonical catalog's `DynString` helper:
|
||||
* examples/integrations/langgraph-python/src/app/declarative-generative-ui/definitions.ts
|
||||
*/
|
||||
// @region[definitions-types]
|
||||
import { z } from "zod";
|
||||
import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
/**
|
||||
* Dynamic string: literal OR a data-model path binding. The GenericBinder
|
||||
* resolves path bindings to the actual value at render time.
|
||||
*/
|
||||
const DynString = z.union([z.string(), z.object({ path: z.string() })]);
|
||||
|
||||
export const definitions = {
|
||||
/**
|
||||
* Card override: gives the outer flight-card container a ShadCN look
|
||||
* (rounded-xl, neutral-200 border, soft shadow). The basic catalog's
|
||||
* Card uses inline styles; overriding here lets the demo's renderer
|
||||
* adopt the demo's Tailwind aesthetic without touching the schema JSON.
|
||||
*/
|
||||
Card: {
|
||||
description: "A container card with a single child.",
|
||||
props: z.object({
|
||||
child: z.string(),
|
||||
}),
|
||||
},
|
||||
Title: {
|
||||
description: "A prominent heading for the flight card.",
|
||||
props: z.object({
|
||||
text: DynString,
|
||||
}),
|
||||
},
|
||||
Airport: {
|
||||
description: "A 3-letter airport code, displayed large.",
|
||||
props: z.object({
|
||||
code: DynString,
|
||||
}),
|
||||
},
|
||||
Arrow: {
|
||||
description: "A right-pointing arrow used between airports.",
|
||||
props: z.object({}),
|
||||
},
|
||||
AirlineBadge: {
|
||||
description: "A pill-styled airline name tag.",
|
||||
props: z.object({
|
||||
name: DynString,
|
||||
}),
|
||||
},
|
||||
PriceTag: {
|
||||
description: "A stylized price display (e.g. '$289').",
|
||||
props: z.object({
|
||||
amount: DynString,
|
||||
}),
|
||||
},
|
||||
/**
|
||||
* Button override: swaps in an ActionButton renderer that tracks
|
||||
* its own `done` state so clicking "Book flight" visually updates to
|
||||
* a "Booked ✓" confirmation. The basic catalog's Button is stateless,
|
||||
* so without this override the click fires the action but the button
|
||||
* looks unchanged. Mirrors the pattern in beautiful-chat
|
||||
* (src/app/demos/beautiful-chat/declarative-generative-ui/renderers.tsx).
|
||||
*/
|
||||
Button: {
|
||||
description:
|
||||
"An interactive button with an action event. Use 'child' with a Text component ID for the label. After click, the button shows a confirmation state.",
|
||||
props: z.object({
|
||||
child: z
|
||||
.string()
|
||||
.describe(
|
||||
"The ID of the child component (e.g. a Text component for the label).",
|
||||
),
|
||||
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
|
||||
// Union with { event } so GenericBinder resolves this as ACTION → callable () => void.
|
||||
action: z
|
||||
.union([
|
||||
z.object({
|
||||
event: z.object({
|
||||
name: z.string(),
|
||||
context: z.record(z.any()).optional(),
|
||||
}),
|
||||
}),
|
||||
z.null(),
|
||||
])
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
} satisfies CatalogDefinitions;
|
||||
// @endregion[definitions-types]
|
||||
|
||||
export type Definitions = typeof definitions;
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* A2UI catalog RENDERERS — React implementations for the custom components
|
||||
* declared in `./definitions`. TypeScript enforces that the renderer map's
|
||||
* keys and prop shapes match the definitions exactly.
|
||||
*
|
||||
* Visual style: ShadCN aesthetic (neutral palette, rounded-xl, subtle
|
||||
* borders, clean typography). Tailwind utility classes only — no `cn()` /
|
||||
* `cva` helpers, no shadcn CLI install. Inline-cloned primitives live in
|
||||
* `../_components/`.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
import type { Definitions } from "./definitions";
|
||||
import { Card } from "../_components/card";
|
||||
import { Badge } from "../_components/badge";
|
||||
import { Button as UIButton } from "../_components/button";
|
||||
import { Separator } from "../_components/separator";
|
||||
|
||||
// `DynString` props are typed as `string | { path }` (see definitions.ts), but
|
||||
// the A2UI binder resolves path bindings before render — renderers only ever
|
||||
// see resolved strings. One shared helper keeps that narrowing in one place.
|
||||
const s = (v: unknown): string => (typeof v === "string" ? v : "");
|
||||
|
||||
// @region[renderers-tsx]
|
||||
export const renderers: CatalogRenderers<Definitions> = {
|
||||
/**
|
||||
* Card override: ShadCN-style outer container. The basic catalog's Card
|
||||
* uses inline styles; overriding here keeps the demo's tailwind aesthetic.
|
||||
* The flight schema renders Card > Column > [Title, Row, …]; the inner
|
||||
* Column adds the vertical spacing.
|
||||
*/
|
||||
Card: ({ props, children }) => (
|
||||
<Card className="w-full max-w-md p-5" data-testid="a2ui-fixed-card">
|
||||
{props.child ? children(props.child) : null}
|
||||
</Card>
|
||||
),
|
||||
Title: ({ props }) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
|
||||
Itinerary
|
||||
</p>
|
||||
<h3 className="text-base font-semibold leading-none tracking-tight text-neutral-900">
|
||||
{s(props.text)}
|
||||
</h3>
|
||||
</div>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
1-stop · economy
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
Airport: ({ props }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="font-mono text-2xl font-semibold tracking-wider text-neutral-900">
|
||||
{s(props.code)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
Arrow: () => (
|
||||
<div className="flex flex-1 items-center px-3">
|
||||
<Separator className="flex-1 bg-neutral-200" />
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mx-1 text-neutral-400"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
<Separator className="flex-1 bg-neutral-200" />
|
||||
</div>
|
||||
),
|
||||
AirlineBadge: ({ props }) => (
|
||||
<Badge variant="secondary" className="uppercase tracking-[0.08em]">
|
||||
{s(props.name)}
|
||||
</Badge>
|
||||
),
|
||||
PriceTag: ({ props }) => (
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
|
||||
Total
|
||||
</span>
|
||||
<span className="font-mono text-base font-semibold text-neutral-900">
|
||||
{s(props.amount)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
/**
|
||||
* Button override: this is a pure-presentation demo, so the button just
|
||||
* renders its label. The schema declares an `action` for visual fidelity,
|
||||
* but the click handler is inert until the Python SDK exposes
|
||||
* `action_handlers=` on `a2ui.render` (see `src/agents/a2ui_fixed.py`).
|
||||
*/
|
||||
Button: ({ props, children }) => (
|
||||
<UIButton className="w-full">
|
||||
{props.child ? children(props.child) : null}
|
||||
</UIButton>
|
||||
),
|
||||
};
|
||||
// @endregion[renderers-tsx]
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import { useA2UIFixedSchemaSuggestions } from "./suggestions";
|
||||
|
||||
export function Chat() {
|
||||
useA2UIFixedSchemaSuggestions();
|
||||
return (
|
||||
<CopilotChat agentId="a2ui-fixed-schema" className="h-full rounded-2xl" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Declarative Generative UI — A2UI Fixed Schema demo.
|
||||
*
|
||||
* In the fixed-schema flavor of A2UI, the component tree (schema) lives on
|
||||
* the frontend and the agent only streams *data* into the data model. The
|
||||
* flight card is ASSEMBLED from small sub-components in
|
||||
* `src/agents/a2ui_schemas/flight_schema.json` (Card > Column > [Title, Row, …]).
|
||||
*
|
||||
* - Definitions (zod schemas): `./a2ui/definitions.ts`
|
||||
* - Renderers (React): `./a2ui/renderers.tsx`
|
||||
* - Catalog wiring: `./a2ui/catalog.ts` (includes the basic catalog)
|
||||
* - Agent: `src/agents/a2ui_fixed.py` (emits an `a2ui_operations` container)
|
||||
*
|
||||
* Reference:
|
||||
* https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui/fixed-schema
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { CopilotKit } from "@copilotkit/react-core/v2";
|
||||
|
||||
import { catalog } from "./a2ui/catalog";
|
||||
import { Chat } from "./chat";
|
||||
|
||||
export default function A2UIFixedSchemaDemo() {
|
||||
return (
|
||||
// `a2ui.catalog` wires the fixed catalog into the A2UI activity renderer.
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit-a2ui-fixed-schema"
|
||||
agent="a2ui-fixed-schema"
|
||||
a2ui={{ catalog: catalog }}
|
||||
>
|
||||
<div className="flex justify-center items-center h-screen w-full bg-neutral-50">
|
||||
<div className="h-full w-full max-w-4xl border-x border-neutral-200 bg-white">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKit>
|
||||
);
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
||||
|
||||
export function useA2UIFixedSchemaSuggestions() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Find SFO → JFK",
|
||||
message: "Find me a flight from SFO to JFK on United for $289.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import { useA2uiRecoverySuggestions } from "./suggestions";
|
||||
|
||||
// Note: this integration's declarative-gen-ui demo does not ship a
|
||||
// sales-context hook (unlike langgraph-python / strands), so the recovery demo
|
||||
// does not inject one either. The agent's system prompt + the render planner's
|
||||
// composition guide carry the dataset; the aimock fixtures drive heal/exhaust.
|
||||
export function Chat() {
|
||||
useA2uiRecoverySuggestions();
|
||||
return <CopilotChat agentId="a2ui-recovery" className="h-full rounded-2xl" />;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* A2UI Error Recovery demo.
|
||||
*
|
||||
* Same dynamic-schema A2UI setup as declarative-gen-ui (it reuses that demo's
|
||||
* catalog), but it makes the toolkit's validate->retry recovery loop visible.
|
||||
* The dedicated runtime at `/api/copilotkit-a2ui-recovery` is configured with
|
||||
* `injectA2UITool: false` — the backend agent
|
||||
* (`src/agents/src/recovery_agent.py`) owns `generate_a2ui` via
|
||||
* `ag_ui_langgraph.get_a2ui_tools`, whose body runs the forced `render_a2ui`
|
||||
* sub-agent and the recovery loop + recovery-exhausted hard-fail envelope
|
||||
* IN-GRAPH (OSS-158 / OSS-375).
|
||||
*
|
||||
* The two suggestion pills drive aimock fixtures that force:
|
||||
* - HEAL: an invalid first render that recovers to a valid one
|
||||
* (building -> retrying -> painted).
|
||||
* - EXHAUST: an always-invalid render that hits the attempt cap
|
||||
* (a tasteful `failed` state, never a broken surface).
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { CopilotKit } from "@copilotkit/react-core/v2";
|
||||
|
||||
// Reuse the declarative-gen-ui catalog (same components, same catalogId).
|
||||
import { myCatalog } from "../declarative-gen-ui/a2ui/catalog";
|
||||
import { Chat } from "./chat";
|
||||
|
||||
export default function A2uiRecoveryDemo() {
|
||||
return (
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit-a2ui-recovery"
|
||||
agent="a2ui-recovery"
|
||||
a2ui={{ catalog: myCatalog }}
|
||||
>
|
||||
<div className="flex justify-center items-center h-screen w-full">
|
||||
<div className="h-full w-full max-w-4xl">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKit>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user