chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
f0cba3cf6d2173dd849638fd8edcf3d6344db491
|
||||
@@ -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,6 @@
|
||||
# API Keys (shared across integrations)
|
||||
OPENAI_API_KEY=replace-with-your-key
|
||||
ANTHROPIC_API_KEY=replace-with-your-key
|
||||
|
||||
# Showcase
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
@@ -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,97 @@
|
||||
# Stage 1: Build AG-UI Java SDK + Spring Boot agent backend
|
||||
# Align on Temurin 21 (tracks the starter Dockerfile) — matches the
|
||||
# spring-ai community artifacts + SDK source in ag-ui-protocol/ag-ui.
|
||||
FROM maven:3-eclipse-temurin-21 AS java-builder
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# AG-UI community artifacts aren't on Maven Central yet — build from source
|
||||
WORKDIR /build
|
||||
COPY pom.xml ./
|
||||
# Single source of truth for the AG-UI commit (shared with the
|
||||
# `.github/workflows/test_unit-spring-ai.yml` workflow). Bump by editing
|
||||
# the file; both CI and this image rebuild against the new SHA.
|
||||
COPY .ag-ui-sha /tmp/.ag-ui-sha
|
||||
ENV GIT_LFS_SKIP_SMUDGE=1
|
||||
RUN AG_UI_SHA="$(tr -d '[:space:]' < /tmp/.ag-ui-sha)" && \
|
||||
if [ -z "${AG_UI_SHA}" ]; then echo "ERROR: .ag-ui-sha is empty" >&2; exit 1; fi && \
|
||||
git clone --no-checkout https://github.com/ag-ui-protocol/ag-ui.git /ag-ui && \
|
||||
# `git clone` only fetches the default branch by default — if AG_UI_SHA
|
||||
# points at a commit on a PR branch or older history not reachable from
|
||||
# HEAD, the subsequent `checkout` fails with a cryptic "reference is not
|
||||
# a tree". Explicitly fetch the SHA first so checkout is always against
|
||||
# a known-local commit (R7-A2). `--depth 1` keeps the image lean.
|
||||
git -C /ag-ui fetch --depth 1 origin "${AG_UI_SHA}" && \
|
||||
git -C /ag-ui checkout "${AG_UI_SHA}" && \
|
||||
cd /ag-ui/sdks/community/java && \
|
||||
mvn install \
|
||||
-pl servers/spring,integrations/spring-ai -am \
|
||||
-DskipTests -Dgpg.skip=true \
|
||||
-Dmaven.javadoc.skip=true -Djavadoc.skip=true \
|
||||
-Dmaven.source.skip=true -Dcheckstyle.skip=true \
|
||||
-Dmaven.site.skip=true -Dreporting.skip=true \
|
||||
-Dassembly.skipAssembly=true \
|
||||
-B && \
|
||||
cd /build && \
|
||||
mvn dependency:go-offline -B
|
||||
|
||||
COPY src/main/java/ src/main/java/
|
||||
COPY src/main/resources/ src/main/resources/
|
||||
RUN mvn package -DskipTests -B
|
||||
|
||||
# Stage 2: 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 next.config.ts tsconfig.json postcss.config.mjs manifest.yaml ./
|
||||
COPY src/app/ src/app/
|
||||
COPY src/components/ src/components/
|
||||
COPY src/lib/ src/lib/
|
||||
COPY public/ public/
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Production runtime (JRE + Node, no JDK, no Maven)
|
||||
FROM eclipse-temurin:21-jre AS runner
|
||||
|
||||
# Install Node.js 22 runtime (for Next.js). No build tools.
|
||||
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).
|
||||
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
|
||||
|
||||
# Spring Boot fat JAR (from java-builder, not the full Maven workspace)
|
||||
COPY --chown=app:app --from=java-builder /build/target/agent.jar ./agent.jar
|
||||
|
||||
# Next.js build artifacts (from frontend stage — no npm install at runtime)
|
||||
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
|
||||
|
||||
# Entrypoint
|
||||
COPY --chown=app:app entrypoint.sh ./
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# Ensure WORKDIR itself is owned by `app` — `WORKDIR /app` at the top of the
|
||||
# stage creates /app as root, and `COPY --chown=app:app` only reassigns the
|
||||
# copied files, NOT the parent dir. Without this, any subprocess that tries
|
||||
# to mkdir under /app at runtime (Next.js build caches, JVM tmp, etc.) hits
|
||||
# EACCES under the unprivileged user and crashes the container.
|
||||
RUN chown app:app /app
|
||||
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 the Java agent and
|
||||
# any shell subprocesses. entrypoint.sh scopes NODE_ENV=production to the
|
||||
# Next.js invocation only (see `env NODE_ENV=production npx next start`).
|
||||
ENV PORT=10000
|
||||
CMD ["./entrypoint.sh"]
|
||||
@@ -0,0 +1,165 @@
|
||||
# Spring AI Showcase — Parity Notes
|
||||
|
||||
This document tracks demos from the canonical `langgraph-python` showcase
|
||||
manifest that are **not ported** to the Spring AI showcase, along with the
|
||||
specific Spring AI / `ag-ui:spring-ai` primitive that is missing.
|
||||
|
||||
Spring AI is a Java framework with a narrower primitive set than LangGraph
|
||||
for a handful of specific use-cases — especially streaming structured
|
||||
output, multi-agent orchestration, and graph-level interrupts. The demos
|
||||
below are the ones where those primitives are genuinely unavailable.
|
||||
|
||||
## Skipped demos
|
||||
|
||||
### LangGraph graph-control primitives (no Spring AI equivalent)
|
||||
|
||||
- **subagents** — Ported using the tool-composition pattern (each
|
||||
sub-agent is a separate `ChatClient` call wired as a supervisor tool;
|
||||
see `SubagentsController`). This deviates from LangGraph's
|
||||
graph-as-node construct: there is no per-sub-agent interrupt point, and
|
||||
step-started/step-finished events are not emitted. The user-visible
|
||||
semantics — supervisor delegates work, each delegation is logged in
|
||||
shared state, the UI renders a live timeline — match the canonical
|
||||
demo. STATE_SNAPSHOT is emitted after every delegation so the
|
||||
delegation log updates incrementally.
|
||||
|
||||
### `ag-ui:spring-ai` adapter gaps
|
||||
|
||||
- **shared-state-streaming** — Spring AI's `ChatClient.stream()` emits
|
||||
token deltas, but the `ag-ui:spring-ai` adapter does not expose a
|
||||
mid-stream state-delta emission API comparable to LangGraph's
|
||||
`copilotkit_emit_state`. Per-token state patches cannot be forwarded
|
||||
through the AG-UI channel with the current integration. The demo cell
|
||||
is shipped as a stub frontend (`src/app/demos/shared-state-streaming/`)
|
||||
so the UI lights up when the adapter exposes mid-stream emission.
|
||||
|
||||
- **byoc-json-render** — Relies on a streaming structured-output primitive
|
||||
(LangGraph's `with_structured_output` + incremental JSON streaming that
|
||||
yields partial objects matching a Zod schema across the stream). Spring
|
||||
AI has `BeanOutputConverter` / `ParameterizedTypeReference` structured
|
||||
output, but it resolves on the FINAL response only — it does not emit
|
||||
partial schema-conformant objects during the stream. The BYOC renderer
|
||||
needs per-token JSON to progressively paint the UI. Additionally,
|
||||
`@json-render/core` and `@json-render/react` are not currently
|
||||
dependencies of the Spring AI showcase package.
|
||||
|
||||
## Ported with caveats
|
||||
|
||||
- **gen-ui-interrupt** — Ported using **Strategy B** (the same approach
|
||||
used by MS Agent Python). Spring AI has no `interrupt()` primitive, so
|
||||
the backend agent (`InterruptAgentController`) provides a scheduling
|
||||
system prompt with NO backend tool callbacks. The `schedule_meeting`
|
||||
tool is registered entirely on the frontend via `useFrontendTool` with
|
||||
an async handler that renders a `TimePickerCard` and blocks until the
|
||||
user picks a slot or cancels. The UX is identical to the LangGraph
|
||||
version.
|
||||
|
||||
- **interrupt-headless** — Same Strategy B adaptation as
|
||||
`gen-ui-interrupt`, but the time-picker popup renders in the app
|
||||
surface (outside the chat) instead of inline. Both demos share the
|
||||
same backend agent (`InterruptAgentController`).
|
||||
|
||||
- **byoc-hashbrown** — Ported. The hashbrown UI kit
|
||||
(`@hashbrownai/react@0.5.0-beta.4`) consumes streaming text and uses
|
||||
`useJsonParser` to progressively assemble UI from partial JSON. Spring
|
||||
AI's `ChatClient.stream()` streams text tokens, so the hashbrown
|
||||
parser tolerates the per-token feed. Final-shape correctness depends on
|
||||
the model following the example prompt — there is no guarantee like
|
||||
LangGraph's `with_structured_output`.
|
||||
|
||||
- **gen-ui-tool-based** — Ported using `useComponent` per-tool renderers
|
||||
bound to `render_bar_chart` / `render_pie_chart` tools. Args stream as
|
||||
partial JSON; the Zod schemas accept partials so the chart components
|
||||
can render once enough fields are present.
|
||||
|
||||
- **reasoning-custom**, **reasoning-default**,
|
||||
**tool-rendering-reasoning-chain** — frontend code is wired for
|
||||
`REASONING_MESSAGE_*` events, but the Spring AI handler CANNOT emit
|
||||
them. This is a genuine SDK limitation in Spring AI 1.0.1, not an
|
||||
adapter or wiring gap. Details below.
|
||||
|
||||
**What the demo needs.** The reasoning UI mounts only when the backend
|
||||
emits AG-UI `REASONING_MESSAGE_START` / `_CONTENT` / `_END` events
|
||||
(role `"reasoning"`). The canonical `langgraph-python` agent produces
|
||||
these by routing the OpenAI model's reasoning summary through the
|
||||
**OpenAI Responses API** (`reasoning={"effort": "medium", "summary":
|
||||
"detailed"}`). The aimock fixtures for these spring-ai cells
|
||||
(`d6/spring-ai/reasoning.json`,
|
||||
`d6/spring-ai/tool-rendering-reasoning-chain.json`, copied from
|
||||
langgraph-python) carry the reasoning text in a dedicated
|
||||
`response.reasoning` field, which aimock renders over the OpenAI
|
||||
**chat-completions** wire as streaming `delta.reasoning_content`
|
||||
chunks (see `@copilotkit/aimock` `buildTextChunks` —
|
||||
`delta: { reasoning_content: slice }`).
|
||||
|
||||
**Why Spring AI 1.0.1 cannot surface it.** The spring-ai integration
|
||||
speaks OpenAI chat-completions (`spring-ai-starter-model-openai`,
|
||||
`/v1/chat/completions`). In `spring-ai-openai:1.0.1` the streaming
|
||||
delta is bound to the record `OpenAiApi.ChatCompletionMessage`, whose
|
||||
components are exactly `rawContent, role, name, toolCallId, toolCalls,
|
||||
refusal, audioOutput, annotations` — there is **no `reasoning_content`
|
||||
/ `reasoning` field**, no metadata map, and no `@JsonAnySetter`
|
||||
catch-all. The record is annotated `@JsonIgnoreProperties`, so the
|
||||
inbound `reasoning_content` JSON property is **silently discarded at
|
||||
deserialization**. It never reaches `ChatResponse` /
|
||||
`Generation.getOutput()`, so the Java handler has no API to read it.
|
||||
The reasoning-summary channel of the OpenAI **Responses API** is also
|
||||
unavailable: `spring-ai-openai:1.0.1` ships no Responses-API client
|
||||
(only `OpenAiApi` chat-completions classes exist), so the
|
||||
langgraph-python parity path cannot be reproduced either.
|
||||
|
||||
**Why the inline-`<reasoning>`-tag workaround does not apply.** The
|
||||
proven `claude-sdk-python` agent PRIMARILY maps Anthropic's native
|
||||
extended-thinking channel: it enables `thinking={"type": "enabled", ...}`
|
||||
on the Messages API, receives `thinking_delta` blocks, and re-routes
|
||||
them to `REASONING_MESSAGE_*`. Only when no native thinking channel is
|
||||
present does it FALL BACK to prompting the model to wrap its plan in
|
||||
literal `<reasoning>...</reasoning>` text tags inside normal output and
|
||||
parsing those tags out of the text stream. The inline-tag fallback IS
|
||||
expressible in Spring AI (the handler already streams
|
||||
`getOutput().getText()`). But neither claude-sdk path fits these cells:
|
||||
the spring-ai aimock fixtures emit reasoning through the dedicated
|
||||
`reasoning` field (→ `reasoning_content`), NOT via an Anthropic native
|
||||
thinking channel and NOT as inline `<reasoning>` tags in `content`.
|
||||
Rewriting the fixtures to embed inline tags — or hand-fabricating a
|
||||
reasoning block in the handler — would be a demo-weakening fixture hack
|
||||
that misrepresents the integration's real capability, so it is
|
||||
deliberately not done.
|
||||
|
||||
**What a real fix requires (upstream / out of scope here).** Either
|
||||
(a) Spring AI adds a `reasoning_content` (or reasoning-summary) field
|
||||
to its chat-completions delta record and exposes it on
|
||||
`Generation`/output metadata; or (b) Spring AI ships an OpenAI
|
||||
Responses-API client that surfaces the reasoning summary; or (c) a
|
||||
custom `WebClient`-level interceptor parses the raw chat-completions
|
||||
SSE for `delta.reasoning_content` BEFORE Spring AI's binding drops it,
|
||||
bypassing `ChatClient` entirely (a substantial custom-parser effort
|
||||
that re-implements the streaming pipeline). None of these is a
|
||||
showcase-side change. Until one lands, these cells ship as frontend
|
||||
code (so the pattern is documented end-to-end) and the chat behaves as
|
||||
a regular chat with no reasoning block.
|
||||
|
||||
- **multimodal** — the frontend sends image + PDF attachments through
|
||||
CopilotChat's `AttachmentsConfig`. Whether the adapter forwards them
|
||||
into Spring AI's `UserMessage.media()` surface is
|
||||
integration-dependent; the Spring-AI model (`gpt-4.1`) is vision-capable
|
||||
on the provider side.
|
||||
|
||||
- **mcp-apps** — the runtime wires the MCP Apps middleware with the public
|
||||
Excalidraw MCP server. The middleware injects MCP tools into the AG-UI
|
||||
request so the Spring-AI ChatClient sees them, and intercepts tool calls
|
||||
to emit activity events. Whether the `ag-ui:spring-ai` adapter forwards
|
||||
runtime-injected tools into Spring AI's tool-calling surface is
|
||||
integration-dependent; the demo wiring is in place so the cell lights up
|
||||
when the adapter supports it.
|
||||
|
||||
## Ported demos
|
||||
|
||||
The full ported list lives in `manifest.yaml`. Highlights include:
|
||||
agentic-chat, tool-rendering (default + custom + catchall), frontend-tools
|
||||
(+ async), hitl-in-chat (+ booking variant), hitl-in-app, prebuilt-sidebar
|
||||
/ popup, chat-slots, chat-customization-css, headless-simple,
|
||||
headless-complete, beautiful-chat, auth, readonly-state-agent-context,
|
||||
open-gen-ui (+ advanced), voice, agent-config, a2ui-fixed-schema,
|
||||
declarative-gen-ui, multimodal, gen-ui-tool-based, mcp-apps,
|
||||
byoc-hashbrown, and the three reasoning variants.
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"framework": "spring-ai",
|
||||
"features": {
|
||||
"agentic-chat": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/agentic-chat-ui",
|
||||
"shell_docs_path": "/prebuilt-components"
|
||||
},
|
||||
"hitl-in-chat": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/human-in-the-loop",
|
||||
"shell_docs_path": "/generative-ui/your-components/interactive"
|
||||
},
|
||||
"tool-rendering": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/tool-rendering",
|
||||
"shell_docs_path": "/generative-ui/tool-rendering"
|
||||
},
|
||||
"gen-ui-tool-based": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/your-components/display-only",
|
||||
"shell_docs_path": "/generative-ui/your-components/display-only"
|
||||
},
|
||||
"gen-ui-agent": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/state-rendering",
|
||||
"shell_docs_path": "/generative-ui/state-rendering"
|
||||
},
|
||||
"shared-state-read-write": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/shared-state",
|
||||
"shell_docs_path": "/shared-state"
|
||||
},
|
||||
"shared-state-streaming": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/shared-state",
|
||||
"shell_docs_path": "/shared-state"
|
||||
},
|
||||
"subagents": {
|
||||
"og_docs_url": null,
|
||||
"shell_docs_path": "/multi-agent/subagents"
|
||||
}
|
||||
},
|
||||
"missing": [
|
||||
{
|
||||
"feature": "subagents",
|
||||
"reason": "No spring-ai-scoped multi-agent/subagent docs exist; the spring-ai framework root is also a SPA fallback. OG left null rather than pointing at a soft-404; shell points at the unified /multi-agent/subagents content."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Derive SPRING_AI_OPENAI_BASE_URL from the showcase-wide OPENAI_BASE_URL if
|
||||
# not already set. The showcase convention is that OPENAI_BASE_URL includes
|
||||
# "/v1" (e.g. https://aimock.example.com/v1), but Spring AI appends
|
||||
# "/v1/chat/completions" itself, so we must strip the trailing "/v1" to avoid
|
||||
# a doubled path segment.
|
||||
if [ -z "${SPRING_AI_OPENAI_BASE_URL:-}" ] && [ -n "${OPENAI_BASE_URL:-}" ]; then
|
||||
export SPRING_AI_OPENAI_BASE_URL="${OPENAI_BASE_URL%/v1}"
|
||||
echo "[entrypoint] Derived SPRING_AI_OPENAI_BASE_URL=${SPRING_AI_OPENAI_BASE_URL} from OPENAI_BASE_URL=${OPENAI_BASE_URL}"
|
||||
fi
|
||||
|
||||
echo "[entrypoint] Starting Spring Boot agent backend..."
|
||||
# jdk.httpclient.keepalive.timeout=0 disables JDK HttpClient connection pooling.
|
||||
# Required because Spring-AI streams via WebClient + JdkClientHttpConnector and a
|
||||
# pooled connection can be half-closed by some upstreams (aimock/Prism) between
|
||||
# SSE responses, which trips `Connection reset` on the follow-up tool-result
|
||||
# request. Setting this as a JVM arg guarantees it lands before any
|
||||
# java.net.http.HttpClient is constructed. This is the authoritative path;
|
||||
# WebClientConfig's static initializer is a defensive fallback only.
|
||||
#
|
||||
# copilotkit.tool.max-iterations: override the BoundedToolCallingManager's
|
||||
# cap via a JVM property so the pre-built jar picks it up without a
|
||||
# rebuild. The application.properties inside the jar defaults to 5 via
|
||||
# ${COPILOTKIT_TOOL_MAX_ITERATIONS:5}, but passing it as -D here ensures
|
||||
# it takes effect even on images built before that property was added.
|
||||
# D5 fixtures need at least 3 (subagents: research -> writing -> critique);
|
||||
# 5 gives headroom for future multi-tool demos.
|
||||
TOOL_MAX_ITER="${COPILOTKIT_TOOL_MAX_ITERATIONS:-5}"
|
||||
echo "[entrypoint] copilotkit.tool.max-iterations=${TOOL_MAX_ITER}"
|
||||
java -Djdk.httpclient.keepalive.timeout=0 \
|
||||
-Dcopilotkit.tool.max-iterations="${TOOL_MAX_ITER}" \
|
||||
-jar /app/agent.jar &
|
||||
JAVA_PID=$!
|
||||
|
||||
# Wait for Spring Boot to be ready (up to 60 seconds). Cold-start JVM warmup
|
||||
# plus Spring context refresh can legitimately exceed 30s under load — we
|
||||
# also probe the Java PID each tick as a liveness fallback, so a crashing
|
||||
# boot fails fast regardless of the cap.
|
||||
STARTUP_TIMEOUT=60
|
||||
echo "[entrypoint] Waiting for Spring Boot health check (timeout=${STARTUP_TIMEOUT}s)..."
|
||||
SPRING_READY=0
|
||||
for i in $(seq 1 "$STARTUP_TIMEOUT"); do
|
||||
if curl -sf http://localhost:8000/health > /dev/null 2>&1; then
|
||||
echo "[entrypoint] Spring Boot ready after ${i}s"
|
||||
SPRING_READY=1
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$JAVA_PID" 2>/dev/null; then
|
||||
echo "[entrypoint] Spring Boot process (pid=${JAVA_PID}) died during startup"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$SPRING_READY" -ne 1 ]; then
|
||||
# Differentiate "slow" from "dead" so operators know whether to raise
|
||||
# the timeout or debug a crash loop.
|
||||
if kill -0 "$JAVA_PID" 2>/dev/null; then
|
||||
echo "[entrypoint] Spring Boot still alive (pid=${JAVA_PID}) but /health did not return 2xx within ${STARTUP_TIMEOUT}s"
|
||||
else
|
||||
echo "[entrypoint] Spring Boot process (pid=${JAVA_PID}) exited before reporting healthy"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[entrypoint] Starting Next.js frontend on port ${PORT:-10000}..."
|
||||
# Scope NODE_ENV=production to the Next.js invocation ONLY so it doesn't
|
||||
# leak into the Java agent process. See Dockerfile comment for rationale.
|
||||
env NODE_ENV=production npx next start --port ${PORT:-10000} &
|
||||
NODE_PID=$!
|
||||
|
||||
# Watchdog: Railway deploys of showcase packages have been observed to hit a
|
||||
# silent agent hang — the Spring Boot process stays alive (so `wait -n`
|
||||
# never fires and the container never restarts) but stops responding on
|
||||
# :8000. Poll Spring Boot's /health endpoint every 30s; after 3 consecutive
|
||||
# failures (~90s of unreachable agent), kill the java process so `wait -n`
|
||||
# returns and Railway restarts the container. The startup probe above
|
||||
# already gates the initial readiness window; this watchdog takes over for
|
||||
# steady-state monitoring. Generalized from
|
||||
# showcase/integrations/crewai-crews/entrypoint.sh (PRs #4114 + #4115).
|
||||
(
|
||||
FAILS=0
|
||||
while sleep 30; do
|
||||
if ! kill -0 "$JAVA_PID" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
if curl -fsS --max-time 5 http://127.0.0.1:8000/health > /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 $JAVA_PID to trigger container restart"
|
||||
kill -9 "$JAVA_PID" 2>/dev/null || true
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
) &
|
||||
WATCHDOG_PID=$!
|
||||
echo "[entrypoint] Watchdog started (PID: $WATCHDOG_PID, probing http://127.0.0.1:8000/health)"
|
||||
|
||||
# Wait for either process to exit. `wait -n` without PID args works on all
|
||||
# bash >= 4.3 (align with other showcase entrypoints such as google-adk);
|
||||
# the PID-args form requires bash 5.1+ which isn't guaranteed in minimal
|
||||
# container images.
|
||||
#
|
||||
# Disable errexit for the wait + post-mortem block. With `set -e` still active,
|
||||
# a non-zero child-exit code from `wait -n` would terminate the shell BEFORE we
|
||||
# get a chance to run the diagnostic `kill -0` probes below — meaning the
|
||||
# container log would never carry the "which died" line that operators rely on.
|
||||
# We capture the exit code explicitly into EXIT_CODE and the final
|
||||
# `exit "$EXIT_CODE"` propagates the dying child's status, so skipping errexit
|
||||
# here doesn't change the container exit semantics. Restoration of `set -e` is
|
||||
# intentionally omitted (mirrors google-adk's entrypoint).
|
||||
set +e
|
||||
wait -n
|
||||
EXIT_CODE=$?
|
||||
|
||||
# Identify which process exited AND kill the surviving sibling so it doesn't
|
||||
# get orphan-reparented to PID 1 when the container exits. Without this
|
||||
# explicit cleanup, a Java crash would leave Next.js alive (and vice versa)
|
||||
# consuming resources until the container runtime tears down the whole
|
||||
# process tree.
|
||||
SURVIVOR_PID=""
|
||||
if ! kill -0 "$JAVA_PID" 2>/dev/null; then
|
||||
echo "[entrypoint] Java process (pid=${JAVA_PID}) exited (code=${EXIT_CODE})"
|
||||
if kill -0 "$NODE_PID" 2>/dev/null; then
|
||||
SURVIVOR_PID="$NODE_PID"
|
||||
fi
|
||||
elif ! kill -0 "$NODE_PID" 2>/dev/null; then
|
||||
echo "[entrypoint] Node.js process (pid=${NODE_PID}) exited (code=${EXIT_CODE})"
|
||||
if kill -0 "$JAVA_PID" 2>/dev/null; then
|
||||
SURVIVOR_PID="$JAVA_PID"
|
||||
fi
|
||||
else
|
||||
echo "[entrypoint] A child exited (code=${EXIT_CODE}); both PIDs still resolve — race between wait and kill -0"
|
||||
fi
|
||||
|
||||
if [ -n "$SURVIVOR_PID" ]; then
|
||||
# Bounded grace window. A plain `wait` on the survivor could hang
|
||||
# indefinitely (e.g. Node.js stuck flushing a response, Java caught in a
|
||||
# finalizer) — which would push us past the platform's SIGKILL grace
|
||||
# period (typically 10s on Railway/ECS) and cause the runtime to reap
|
||||
# us mid-log-write, losing the structured "who died" line we just
|
||||
# emitted. SIGTERM first, poll `kill -0` for up to SURVIVOR_GRACE_SECS,
|
||||
# then SIGKILL as last resort. Mirrors what the comment above this
|
||||
# block already promised.
|
||||
SURVIVOR_GRACE_SECS=10
|
||||
echo "[entrypoint] Terminating surviving sibling (pid=${SURVIVOR_PID}) to avoid orphan-reparent (grace=${SURVIVOR_GRACE_SECS}s)"
|
||||
kill -TERM "$SURVIVOR_PID" 2>/dev/null
|
||||
for _ in $(seq 1 "$SURVIVOR_GRACE_SECS"); do
|
||||
if ! kill -0 "$SURVIVOR_PID" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if kill -0 "$SURVIVOR_PID" 2>/dev/null; then
|
||||
echo "[entrypoint] Survivor (pid=${SURVIVOR_PID}) did not exit within ${SURVIVOR_GRACE_SECS}s; sending SIGKILL"
|
||||
kill -KILL "$SURVIVOR_PID" 2>/dev/null || true
|
||||
fi
|
||||
# Reap the (now-dead) child so it doesn't become a zombie. wait may
|
||||
# return non-zero; we don't care — we've already captured EXIT_CODE
|
||||
# from the first-to-die child.
|
||||
wait "$SURVIVOR_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Clean up the watchdog if it's still running (e.g. Next.js exited, not Java).
|
||||
# Without this the backgrounded watchdog would continue polling /health on a
|
||||
# dying container until the platform SIGKILLs the process tree.
|
||||
if [ -n "${WATCHDOG_PID:-}" ] && kill -0 "$WATCHDOG_PID" 2>/dev/null; then
|
||||
kill "$WATCHDOG_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
exit "$EXIT_CODE"
|
||||
@@ -0,0 +1,592 @@
|
||||
name: Spring AI
|
||||
slug: spring-ai
|
||||
category: enterprise-platform
|
||||
language: java
|
||||
logo: /logos/spring-ai.svg
|
||||
description: >-
|
||||
CopilotKit integration with Spring AI. Java-based agent backend with full
|
||||
feature coverage including frontend tools, in-chat and in-app human-in-the-
|
||||
loop, generative UI, per-tool renderers, agent context, interrupt-adapted
|
||||
scheduling (Strategy B), per-token state streaming, and prebuilt chat surfaces
|
||||
(sidebar / popup / slots / headless). Demos that depend on LangGraph-specific
|
||||
primitives (interrupt, multi-agent orchestration, MCP-driven UI) are
|
||||
documented as skipped in PARITY_NOTES.md.
|
||||
partner_docs: null
|
||||
repo: https://github.com/CopilotKit/CopilotKit/tree/main/showcase/integrations/spring-ai
|
||||
copilotkit_version: 2.0.0
|
||||
deployed: true
|
||||
docs_mode: hidden
|
||||
sort_order: 170
|
||||
generative_ui:
|
||||
- constrained-explicit
|
||||
- a2ui-dynamic-schema
|
||||
interaction_modalities:
|
||||
- sidebar
|
||||
- embedded
|
||||
- chat
|
||||
not_supported_features:
|
||||
# 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
|
||||
# Reasoning-family NSF: while spring-ai DOES emit REASONING_MESSAGE_* events
|
||||
# for the `reasoning-custom` / `reasoning-default` demos (via the dedicated
|
||||
# ReasoningController at /reasoning/, which writes raw-JSON
|
||||
# REASONING_MESSAGE_START/CONTENT/END frames whose `type` matches the
|
||||
# @ag-ui/core zod schema — workaround for the AG-UI Java SDK gap where
|
||||
# Spring AI 1.0.1's SDK has no REASONING_MESSAGE_* subtypes), the following
|
||||
# canonical reasoning cell-ids are NOT covered by that workaround and
|
||||
# cannot mount here:
|
||||
# * `reasoning-default-render` — alternate render-path for default
|
||||
# reasoning that this Spring backend does not parallel.
|
||||
# * `agentic-chat-reasoning` — agentic-chat composed with reasoning
|
||||
# frames; spring-ai's default agent at `/` does not emit reasoning.
|
||||
# * `tool-rendering-reasoning-chain` — registered against the default
|
||||
# Spring agent at the root path (no reasoning emission), and spring-ai
|
||||
# has no parallel multi-tool-call streaming surface to interleave with
|
||||
# reasoning frames, so the reasoning-chain composition cannot mount.
|
||||
# Skipped-incapable, not regressions. NSF entries are canonical cell-ids
|
||||
# matching the dashboard cell-schema.
|
||||
- reasoning-default-render
|
||||
- agentic-chat-reasoning
|
||||
- tool-rendering-reasoning-chain
|
||||
features:
|
||||
- cli-start
|
||||
- agentic-chat
|
||||
- tool-rendering-default-catchall
|
||||
- tool-rendering-custom-catchall
|
||||
- chat-customization-css
|
||||
- frontend-tools
|
||||
- frontend-tools-async
|
||||
- gen-ui-agent
|
||||
- gen-ui-tool-based
|
||||
- open-gen-ui
|
||||
- open-gen-ui-advanced
|
||||
- declarative-gen-ui
|
||||
- a2ui-fixed-schema
|
||||
- prebuilt-sidebar
|
||||
- prebuilt-popup
|
||||
- chat-slots
|
||||
- headless-simple
|
||||
- headless-complete
|
||||
- beautiful-chat
|
||||
- readonly-state-agent-context
|
||||
- shared-state-read-write
|
||||
- shared-state-streaming
|
||||
- subagents
|
||||
- auth
|
||||
- voice
|
||||
- multimodal
|
||||
- agent-config
|
||||
- hitl-in-chat
|
||||
- hitl-in-chat-booking
|
||||
- hitl-in-app
|
||||
- hitl
|
||||
- tool-rendering
|
||||
- mcp-apps
|
||||
- declarative-hashbrown
|
||||
- declarative-json-render
|
||||
a2ui_pattern: schema-inline
|
||||
agent_config_pattern: shared-state
|
||||
auth_pattern: runtime-onrequest
|
||||
demos:
|
||||
- id: agentic-chat
|
||||
name: Agentic Chat
|
||||
description: Natural conversation with frontend tool execution
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/agentic-chat
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/main/java/com/copilotkit/showcase/springai/AgentConfig.java
|
||||
- src/main/java/com/copilotkit/showcase/springai/AgentController.java
|
||||
- src/app/demos/agentic-chat/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: chat-customization-css
|
||||
name: Chat Customization (CSS)
|
||||
description: Default CopilotChat re-themed via scoped CSS overrides
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/chat-customization-css
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/app/demos/chat-customization-css/page.tsx
|
||||
- src/app/demos/chat-customization-css/theme.css
|
||||
- src/app/api/copilotkit/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/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 and uses the returned result
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/frontend-tools-async
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/app/demos/frontend-tools-async/page.tsx
|
||||
- src/app/demos/frontend-tools-async/notes-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- 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:
|
||||
highlight:
|
||||
- src/app/demos/gen-ui-agent/page.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/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/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/app/demos/chat-slots/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- src/app/demos/chat-slots/slot-wrappers.tsx
|
||||
- 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/app/demos/headless-simple/page.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/app/demos/readonly-state-agent-context/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- 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: open-gen-ui
|
||||
name: Open Generative UI (Minimal)
|
||||
description:
|
||||
Agent streams HTML + CSS into a sandboxed iframe via the built-in
|
||||
OpenGenerativeUIActivityRenderer
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/open-gen-ui
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/app/demos/open-gen-ui/page.tsx
|
||||
- src/app/api/copilotkit-ogui/route.ts
|
||||
- id: open-gen-ui-advanced
|
||||
name: Open Generative UI (Advanced)
|
||||
description: Sandboxed UI can call host-side functions (evaluateExpression,
|
||||
notifyHost) via the provider's sandboxFunctions registration
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/open-gen-ui-advanced
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- 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
|
||||
- id: voice
|
||||
name: Voice
|
||||
description: CopilotChat mic button via a runtime mounted with
|
||||
TranscriptionServiceOpenAI; transcribed text is forwarded to the Spring AI
|
||||
backend
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/voice
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/app/demos/voice/page.tsx
|
||||
- src/app/demos/voice/sample-audio-button.tsx
|
||||
- src/app/api/copilotkit-voice/[[...slug]]/route.ts
|
||||
- id: agent-config
|
||||
name: Agent Config Object
|
||||
description:
|
||||
Frontend forwards typed config (tone, expertise, responseLength) to
|
||||
Spring, which rebuilds the system prompt per request
|
||||
tags:
|
||||
- agent-capabilities
|
||||
route: /demos/agent-config
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/main/java/com/copilotkit/showcase/springai/AgentConfigController.java
|
||||
- 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: a2ui-fixed-schema
|
||||
name: A2UI Fixed Schema
|
||||
description:
|
||||
Dedicated Spring tool emits a fixed flight-card component tree plus
|
||||
a data model; frontend catalog renders each component
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/a2ui-fixed-schema
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/main/java/com/copilotkit/showcase/springai/A2uiFixedSchemaController.java
|
||||
- src/main/java/com/copilotkit/showcase/springai/tools/DisplayFlightTool.java
|
||||
- src/app/demos/a2ui-fixed-schema/page.tsx
|
||||
- src/app/demos/a2ui-fixed-schema/a2ui/definitions.ts
|
||||
- src/app/demos/a2ui-fixed-schema/a2ui/renderers.tsx
|
||||
- src/app/demos/a2ui-fixed-schema/a2ui/catalog.ts
|
||||
- src/app/api/copilotkit-a2ui-fixed-schema/route.ts
|
||||
- id: headless-complete
|
||||
name: Headless Chat (Complete)
|
||||
description: Full chat built from scratch on useAgent plus low-level render
|
||||
hooks (useRenderToolCall, useRenderActivityMessage,
|
||||
useRenderCustomMessages)
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/headless-complete
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- 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: beautiful-chat
|
||||
name: Beautiful Chat
|
||||
description:
|
||||
Polished brand-themed chat surface over the Spring AI agent with
|
||||
seeded suggestion pills
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/beautiful-chat
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/app/demos/beautiful-chat/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: tool-rendering-reasoning-chain
|
||||
name: Tool Rendering (Reasoning Chain)
|
||||
description: Per-tool renderers (WeatherCard, FlightListCard) plus custom
|
||||
reasoningMessage slot composed in a single chat
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/tool-rendering-reasoning-chain
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/app/demos/tool-rendering-reasoning-chain/page.tsx
|
||||
- src/app/demos/tool-rendering-reasoning-chain/reasoning-block.tsx
|
||||
- src/app/demos/tool-rendering-reasoning-chain/weather-card.tsx
|
||||
- src/app/demos/tool-rendering-reasoning-chain/flight-list-card.tsx
|
||||
- src/app/demos/tool-rendering-reasoning-chain/custom-catchall-renderer.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: declarative-gen-ui
|
||||
name: Declarative Generative UI
|
||||
description: A2UI dynamic-schema against a branded catalog; backend owns the
|
||||
`generate_a2ui` tool
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/declarative-gen-ui
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/main/java/com/copilotkit/showcase/springai/tools/GenerateA2uiTool.java
|
||||
- src/app/demos/declarative-gen-ui/page.tsx
|
||||
- src/app/demos/declarative-gen-ui/a2ui/definitions.ts
|
||||
- src/app/demos/declarative-gen-ui/a2ui/renderers.tsx
|
||||
- src/app/demos/declarative-gen-ui/a2ui/catalog.ts
|
||||
- src/app/api/copilotkit-declarative-gen-ui/route.ts
|
||||
- id: shared-state-read-write
|
||||
name: Shared State (Read + Write)
|
||||
description: Bidirectional shared state — UI writes preferences into agent
|
||||
state; the agent's set_notes tool mutates state.notes and emits a
|
||||
STATE_SNAPSHOT back to the UI
|
||||
tags:
|
||||
- agent-state
|
||||
route: /demos/shared-state-read-write
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/main/java/com/copilotkit/showcase/springai/SharedStateReadWriteController.java
|
||||
- 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-shared-state-read-write/route.ts
|
||||
- id: shared-state-streaming
|
||||
name: Shared State Streaming
|
||||
description:
|
||||
Per-token state streaming — the agent's write_document tool streams
|
||||
content into state.document via STATE_SNAPSHOT events; the frontend
|
||||
renders the growing document live
|
||||
tags:
|
||||
- agent-state
|
||||
route: /demos/shared-state-streaming
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/main/java/com/copilotkit/showcase/springai/SharedStateStreamingController.java
|
||||
- src/app/demos/shared-state-streaming/page.tsx
|
||||
- src/app/demos/shared-state-streaming/document-view.tsx
|
||||
- src/app/api/copilotkit-shared-state-streaming/route.ts
|
||||
- id: subagents
|
||||
name: Sub-Agents
|
||||
description:
|
||||
Supervisor delegates to research / writing / critique sub-agents
|
||||
(each its own ChatClient call). Every delegation appends a Delegation
|
||||
entry to state.delegations and emits a STATE_SNAPSHOT for a live UI log
|
||||
tags:
|
||||
- agent-capabilities
|
||||
route: /demos/subagents
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/main/java/com/copilotkit/showcase/springai/SubagentsController.java
|
||||
- src/app/demos/subagents/page.tsx
|
||||
- src/app/demos/subagents/delegation-log.tsx
|
||||
- src/app/api/copilotkit-subagents/route.ts
|
||||
- id: multimodal
|
||||
name: Multimodal Attachments
|
||||
description: CopilotChat AttachmentsConfig for images + PDFs; sample files
|
||||
inject through the same pipeline as the paperclip upload
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/multimodal
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/app/demos/multimodal/page.tsx
|
||||
- src/app/demos/multimodal/sample-attachment-buttons.tsx
|
||||
- src/app/api/copilotkit-multimodal/route.ts
|
||||
- id: hitl-in-chat
|
||||
name: In-Chat Human in the Loop
|
||||
description: User approves agent actions before execution via text input inside the chat
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/hitl-in-chat
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- 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-app
|
||||
name: In-App Human in the Loop
|
||||
description:
|
||||
Agent requests approval via useFrontendTool; the approval UI pops
|
||||
up as an app-level modal OUTSIDE the chat
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/hitl-in-app
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- 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
|
||||
name: Human in the Loop (Steps)
|
||||
description:
|
||||
Multi-step HITL flow where the agent pauses for user confirmation
|
||||
at each step
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/hitl
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/app/demos/hitl/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- 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/main/java/com/copilotkit/showcase/springai/tools/WeatherTool.java
|
||||
- src/app/demos/tool-rendering/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: cli-start
|
||||
name: CLI Start Command
|
||||
description: Copy-paste command to clone the canonical Spring AI starter
|
||||
tags:
|
||||
- chat-ui
|
||||
command: "npx copilotkit@latest init --framework spring-ai"
|
||||
- id: gen-ui-tool-based
|
||||
name: Tool-Based Generative UI
|
||||
description:
|
||||
Frontend registers per-tool component renderers via useComponent;
|
||||
the Spring agent calls render tools that paint chart components inline in
|
||||
the chat
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/gen-ui-tool-based
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/app/demos/gen-ui-tool-based/page.tsx
|
||||
- src/app/demos/gen-ui-tool-based/bar-chart.tsx
|
||||
- src/app/demos/gen-ui-tool-based/pie-chart.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: tool-rendering-default-catchall
|
||||
name: Tool Rendering (Default Catch-all)
|
||||
description:
|
||||
Out-of-the-box tool rendering — the Spring backend defines 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/main/java/com/copilotkit/showcase/springai/AgentConfig.java
|
||||
- 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 Spring 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/main/java/com/copilotkit/showcase/springai/AgentConfig.java
|
||||
- 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 against the Spring AI agent
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/hitl-in-chat
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- 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: mcp-apps
|
||||
name: MCP Apps
|
||||
description:
|
||||
MCP server-driven UI via the runtime mcpApps middleware; sandboxed
|
||||
iframe rendered via the built-in MCPAppsActivityRenderer
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/mcp-apps
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/app/demos/mcp-apps/page.tsx
|
||||
- src/app/api/copilotkit-mcp-apps/route.ts
|
||||
- id: declarative-hashbrown
|
||||
name: "Declarative UI: Hashbrown"
|
||||
description: Streaming structured output via @hashbrownai/react, rendering a sales dashboard catalog (MetricCard + PieChart + BarChart)
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/declarative-hashbrown
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/main/java/com/copilotkit/showcase/springai/ByocHashbrownController.java
|
||||
- src/app/demos/declarative-hashbrown/page.tsx
|
||||
- src/app/demos/declarative-hashbrown/hashbrown-renderer.tsx
|
||||
- src/app/demos/declarative-hashbrown/metric-card.tsx
|
||||
- src/app/demos/declarative-hashbrown/charts/bar-chart.tsx
|
||||
- src/app/demos/declarative-hashbrown/charts/pie-chart.tsx
|
||||
- src/app/demos/declarative-hashbrown/types.ts
|
||||
- src/app/demos/declarative-hashbrown/suggestions.ts
|
||||
- src/app/api/copilotkit-declarative-hashbrown/route.ts
|
||||
- id: declarative-json-render
|
||||
name: "Declarative UI: json-render"
|
||||
description: Streaming structured output via @json-render/react, rendering a sales dashboard catalog (MetricCard + PieChart + BarChart)
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/declarative-json-render
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/main/java/com/copilotkit/showcase/springai/ByocJsonRenderController.java
|
||||
- src/app/demos/declarative-json-render/page.tsx
|
||||
- src/app/demos/declarative-json-render/json-render-renderer.tsx
|
||||
- src/app/demos/declarative-json-render/metric-card.tsx
|
||||
- src/app/demos/declarative-json-render/charts/bar-chart.tsx
|
||||
- src/app/demos/declarative-json-render/charts/pie-chart.tsx
|
||||
- src/app/demos/declarative-json-render/types.ts
|
||||
- src/app/demos/declarative-json-render/suggestions.ts
|
||||
- src/app/api/copilotkit-declarative-json-render/route.ts
|
||||
- id: gen-ui-interrupt
|
||||
name: Generative UI (Interrupt)
|
||||
description:
|
||||
Time-picker card rendered inline via useFrontendTool with an async
|
||||
handler that blocks until the user picks a slot — Strategy B adaptation of
|
||||
LangGraph's interrupt() primitive
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/gen-ui-interrupt
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/main/java/com/copilotkit/showcase/springai/InterruptAgentController.java
|
||||
- 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
|
||||
description:
|
||||
Time-picker popup rendered in the app surface (outside the chat)
|
||||
via useFrontendTool with an async handler — headless variant of the
|
||||
interrupt-adapted pattern
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/interrupt-headless
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/main/java/com/copilotkit/showcase/springai/InterruptAgentController.java
|
||||
- src/app/demos/interrupt-headless/page.tsx
|
||||
- src/app/api/copilotkit/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;
|
||||
+12963
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@copilotkit/showcase-spring-ai",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-ui/client": "0.0.57",
|
||||
"@copilotkit/a2ui-renderer": "1.61.2",
|
||||
"@copilotkit/react-core": "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.5.15",
|
||||
"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.59.1",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.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": "spring-ai",
|
||||
},
|
||||
},
|
||||
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,96 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.5.4</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.copilotkit.showcase</groupId>
|
||||
<artifactId>spring-ai-agent</artifactId>
|
||||
<version>0.1.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<spring-ai.version>1.0.1</spring-ai.version>
|
||||
<ag-ui.version>0.0.1</ag-ui.version>
|
||||
<ag-ui.spring-ai.version>1.0.1</ag-ui.spring-ai.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-bom</artifactId>
|
||||
<version>${spring-ai.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<!-- Spring Boot -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring AI (OpenAI) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- AG-UI Java SDK (installed from source in Dockerfile) -->
|
||||
<dependency>
|
||||
<groupId>com.ag-ui.community</groupId>
|
||||
<artifactId>spring-ai</artifactId>
|
||||
<version>${ag-ui.spring-ai.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ag-ui.community</groupId>
|
||||
<artifactId>java-server</artifactId>
|
||||
<version>${ag-ui.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ag-ui.community</groupId>
|
||||
<artifactId>spring</artifactId>
|
||||
<version>${ag-ui.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Caffeine: bounded, time-expiring cache backing BoundedToolCallingManager's
|
||||
per-ChatOptions iteration counter. Prevents the map from growing unbounded
|
||||
on the happy-path (conversation turn completes naturally without hitting the
|
||||
cap) since Spring-AI's outer loop never signals "turn finished" back to the
|
||||
ToolCallingManager. expireAfterAccess(5m) bounds worst-case residency. -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Test scope: JUnit 5 + Mockito via Spring Boot starter. Kept test-only
|
||||
so the production JAR stays lean. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>agent</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bd4aa7b049f1c3e324dfd15af4068d7f8fbf2eae1dd044df270dddc5f38a5c57
|
||||
size 87078
|
||||
@@ -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,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3da2afae36a1a81fd2c02f15e54bfc38b6c22e41655c31a5b54ff1e0e3daab41
|
||||
size 2486
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:01aa5681de99461247543e9215c1e4da3242e26b2bee11593fcdbe209672d973
|
||||
size 10083
|
||||
@@ -0,0 +1,19 @@
|
||||
# QA: A2UI Fixed Schema — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Spring AI backend is up with A2uiFixedSchemaController mounted at `/a2ui-fixed-schema/run`
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/a2ui-fixed-schema`
|
||||
- [ ] Click the "Find SFO → JFK" suggestion
|
||||
- [ ] Verify a Flight Details card appears with SFO, arrow, JFK, UNITED badge, and a $289 price tag
|
||||
- [ ] Click "Book flight"
|
||||
- [ ] Verify the button transitions to "Booked ✓" (stateful ActionButton)
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Spring tool `display_flight` returns the fixed schema + data model
|
||||
- A2UI middleware on runtime forwards it as ACTIVITY_SNAPSHOT events
|
||||
- Catalog pins Title/Airport/Arrow/AirlineBadge/PriceTag/Button to React renderers
|
||||
@@ -0,0 +1,19 @@
|
||||
# QA: Agent Config — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Spring AI backend is up with the AgentConfigController mounted at `/agent-config/run`
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/agent-config`
|
||||
- [ ] Change Tone to "enthusiastic", Expertise to "beginner", Response length to "detailed"
|
||||
- [ ] Ask "Explain how a database index works"
|
||||
- [ ] Verify the response is enthusiastic, beginner-friendly, and multi-paragraph
|
||||
- [ ] Switch Tone to "professional" and Response length to "concise"
|
||||
- [ ] Ask the same question
|
||||
- [ ] Verify the response is neutral, precise, and 1-3 sentences
|
||||
|
||||
## Expected Results
|
||||
|
||||
- The forwarded `tone` / `expertise` / `responseLength` props drive the system prompt composition on the Spring side
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA: Agentic Chat (Reasoning) — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Spring AI backend is up
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/agentic-chat-reasoning`
|
||||
- [ ] Send a message
|
||||
- [ ] Verify the page loads and chat responds normally
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Page demonstrates the `messageView.reasoningMessage` slot pattern
|
||||
- If the `ag-ui:spring-ai` adapter emits REASONING*MESSAGE*\* events, the custom amber-banner ReasoningBlock renders
|
||||
- If not, the chat behaves as a normal chat (adapter-level limitation documented in PARITY_NOTES.md)
|
||||
@@ -0,0 +1,62 @@
|
||||
# QA: Agentic Chat — Spring AI
|
||||
|
||||
## 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,68 @@
|
||||
# QA: Authentication — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo deployed and accessible at /demos/auth
|
||||
- Spring AI showcase service healthy
|
||||
- OPENAI_API_KEY set on the Spring AI deployment
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Initial authenticated state
|
||||
|
||||
- [ ] Navigate to /demos/auth
|
||||
- [ ] Verify the banner is visible with a green/success appearance (data-testid="auth-banner", data-authenticated="true")
|
||||
- [ ] Verify auth-status text reads "✓ Signed in as demo user"
|
||||
- [ ] Verify the "Sign out" button is visible and enabled (data-testid="auth-sign-out-button")
|
||||
- [ ] Verify the "Sign in" button is NOT present
|
||||
- [ ] Verify <CopilotChat /> is mounted below the banner
|
||||
- [ ] Verify no auth-demo-error surface is shown (data-testid="auth-demo-error" absent)
|
||||
- [ ] Verify no console errors on page load (the `/info` handshake should succeed)
|
||||
|
||||
### 2. Authenticated send → assistant response
|
||||
|
||||
- [ ] Type "Hello" and click send
|
||||
- [ ] Within 30 seconds, an assistant response is rendered in the transcript
|
||||
- [ ] No auth-demo-error surface appears
|
||||
|
||||
### 3. Sign out flips the banner and surfaces 401 without crashing
|
||||
|
||||
- [ ] Click "Sign out"
|
||||
- [ ] Within 1 second, the banner flips to amber/warning appearance (data-authenticated="false")
|
||||
- [ ] Verify auth-status text reads "⚠ Signed out — the agent will reject your messages until you sign in."
|
||||
- [ ] Verify the "Sign in" button is visible (data-testid="auth-authenticate-button")
|
||||
- [ ] Verify the "Sign out" button is no longer present
|
||||
- [ ] Type "Hello again" and click send
|
||||
- [ ] Within 15 seconds, the page-level error surface appears:
|
||||
- `data-testid="auth-demo-error"` visible with text containing "401" and/or "Unauthorized"
|
||||
- [ ] Verify the banner is STILL visible — the page must not white-screen
|
||||
- [ ] Verify no assistant response appears for the unauthenticated send
|
||||
|
||||
### 4. Sign in clears the error and restores sends
|
||||
|
||||
- [ ] Click "Sign in"
|
||||
- [ ] Within 1 second, the banner flips back to green (data-authenticated="true")
|
||||
- [ ] Verify the auth-demo-error surface is cleared
|
||||
- [ ] Type "Hello" and click send
|
||||
- [ ] Within 30 seconds, an assistant response is rendered
|
||||
|
||||
### 5. Refresh resets state to authenticated
|
||||
|
||||
- [ ] Hard-reload the page
|
||||
- [ ] Banner is green on first render (default state is authenticated; state does NOT persist)
|
||||
- [ ] No error surface on first render
|
||||
|
||||
### 6. Error Handling
|
||||
|
||||
- [ ] With DevTools Network panel blocking /api/copilotkit-auth, send a message while authenticated
|
||||
- [ ] Verify a network-level error surfaces cleanly (no uncaught promise rejection in console)
|
||||
- [ ] Restore network; verify sends work again without a page reload
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Page loads authenticated by default — no 401 crash on initial `/info` fetch
|
||||
- Banner state flips within 1s of Sign out / Sign in clicks
|
||||
- Post-sign-out sends produce a visible 401 error within 15s via auth-demo-error
|
||||
- Page never white-screens after sign out — banner and composer remain mounted
|
||||
- Authenticated sends produce an assistant response within 30s
|
||||
- Refresh fully resets auth state (back to authenticated)
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA: Beautiful Chat — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Spring AI backend is up
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/beautiful-chat`
|
||||
- [ ] Verify the polished brand surface (gradient background, rounded card) renders
|
||||
- [ ] Click a suggestion pill (e.g. "Plan a 3-day Tokyo trip")
|
||||
- [ ] Verify the agent responds in the themed chat surface
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat runs against the shared Spring AI ChatClient backend
|
||||
- Brand-themed layout, suggestions, and chat shell all render correctly
|
||||
@@ -0,0 +1,19 @@
|
||||
# QA: Chat Customization (CSS) — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/chat-customization-css`
|
||||
- [ ] Verify the chat renders inside the scoped `.chat-css-demo-scope` wrapper
|
||||
- [ ] Verify user messages have the hot-pink gradient bubble style
|
||||
- [ ] Verify assistant messages have the amber boxy monospace style
|
||||
- [ ] Send a message and verify both bubble styles render correctly
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat loads within 3 seconds
|
||||
- Themed styles only apply inside the demo scope (no global leak)
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA: Chat Slots — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/chat-slots`
|
||||
- [ ] Verify the custom welcome screen renders (`data-testid="custom-welcome-screen"`)
|
||||
- [ ] Verify the custom disclaimer renders (`data-testid="custom-disclaimer"`)
|
||||
- [ ] Send a message
|
||||
- [ ] Verify assistant messages are wrapped in the indigo "slot" card
|
||||
|
||||
## Expected Results
|
||||
|
||||
- All three slot overrides are active: welcome screen, disclaimer, assistantMessage
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA: Declarative Generative UI — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Spring AI backend is up with the `generate_a2ui` tool registered
|
||||
- OPENAI_API_KEY is set (the dynamic-A2UI tool makes a secondary LLM call)
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/declarative-gen-ui`
|
||||
- [ ] Click the "Show a KPI dashboard" suggestion
|
||||
- [ ] Verify a dashboard with 3-4 metric cards renders inline
|
||||
- [ ] Click the "What's the weather like?" suggestion (or similar)
|
||||
- [ ] Verify the agent generates a branded UI card using the catalog components
|
||||
|
||||
## Expected Results
|
||||
|
||||
- `a2ui.injectA2UITool: false` keeps the tool owned by the Spring backend
|
||||
- Catalog schema is serialized into `copilotkit.context` so the Spring tool's LLM knows available components
|
||||
- Rendered components match the catalog definitions in `./a2ui/renderers.tsx`
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA: Frontend Tools (Async) — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/frontend-tools-async`
|
||||
- [ ] Ask "Find my notes about project planning"
|
||||
- [ ] Verify the NotesCard shows a loading state briefly (simulated 500ms latency)
|
||||
- [ ] Verify the NotesCard then renders with matching notes
|
||||
- [ ] Verify the agent's text summary references the found notes
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Async frontend tool resolves correctly and the agent receives the result
|
||||
@@ -0,0 +1,16 @@
|
||||
# QA: Frontend Tools — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/frontend-tools`
|
||||
- [ ] Ask "Change the background to a blue-to-purple gradient"
|
||||
- [ ] Verify the background-container background changes
|
||||
- [ ] Verify the change_background tool returns a success status
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Frontend tool fires and updates the UI state
|
||||
@@ -0,0 +1,62 @@
|
||||
# QA: Agentic Generative UI — Spring AI
|
||||
|
||||
## 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 — Spring AI
|
||||
|
||||
## 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,19 @@
|
||||
# QA: Headless Chat (Complete) — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Spring AI backend is up
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/headless-complete`
|
||||
- [ ] Send "Hi"
|
||||
- [ ] Verify distinct user/assistant bubbles render (hand-rolled, not CopilotChat)
|
||||
- [ ] Ask "What's the weather in Tokyo?"
|
||||
- [ ] Verify the WeatherCard renders via the hand-rolled tool-call renderer
|
||||
- [ ] Press the stop button during a long reply — verify the agent halts
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat is built entirely on `useAgent` + low-level render hooks
|
||||
- MCP-driven activity rendering is a no-op on Spring; tool + A2UI renderers still compose
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA: Headless Simple — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/headless-simple`
|
||||
- [ ] Send "Hi"
|
||||
- [ ] Verify a user bubble appears and the agent responds
|
||||
- [ ] Ask "Show a card about cats"
|
||||
- [ ] Verify a ShowCard component renders via useComponent
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Headless surface renders messages and tool renderings via useAgent + useComponent
|
||||
@@ -0,0 +1,19 @@
|
||||
# QA: In-App Human in the Loop — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/hitl-in-app`
|
||||
- [ ] Verify the tickets panel renders three mock tickets
|
||||
- [ ] Ask "Approve a $50 refund to Jordan Rivera on ticket #12345"
|
||||
- [ ] Verify an ApprovalDialog modal appears outside the chat
|
||||
- [ ] Click Approve with an optional note
|
||||
- [ ] Verify the agent receives the approval and confirms the action
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Modal appears outside the chat surface
|
||||
- Approve/Reject resolves the pending tool Promise
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA: In-Chat HITL (hitl-in-chat) — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/hitl-in-chat`
|
||||
- [ ] Ask "Please book an intro call with the sales team"
|
||||
- [ ] Verify a TimePickerCard renders inline in the chat (`data-testid="time-picker-card"`)
|
||||
- [ ] Click a time slot
|
||||
- [ ] Verify the agent acknowledges the booking with the chosen time
|
||||
|
||||
## Expected Results
|
||||
|
||||
- The useHumanInTheLoop card renders inside the chat and resolves back to the agent
|
||||
@@ -0,0 +1,22 @@
|
||||
# QA: MCP Apps — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Spring AI backend is up
|
||||
- NextJS runtime can reach `https://mcp.excalidraw.com` (or configured MCP_SERVER_URL)
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/mcp-apps`
|
||||
- [ ] Click "Draw a flowchart"
|
||||
- [ ] Verify the agent invokes an MCP tool and the Excalidraw UI resource appears inline as a sandboxed iframe
|
||||
|
||||
## Expected Results
|
||||
|
||||
- `mcpApps.servers` on the runtime auto-adds MCP tools to the forwarded tool list
|
||||
- Agent calls an MCP tool; middleware emits the activity event with the fetched UI resource
|
||||
- Built-in `MCPAppsActivityRenderer` renders the iframe
|
||||
|
||||
## Known gaps
|
||||
|
||||
- If the `ag-ui:spring-ai` adapter does not forward the runtime-injected MCP tools through to the Spring ChatClient's tool-calling surface, the agent will not invoke them. In that case the activity event is never emitted and the iframe does not render; see PARITY_NOTES.md for context.
|
||||
@@ -0,0 +1,19 @@
|
||||
# QA: Multimodal Attachments — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Spring AI backend is up with a vision-capable OpenAI model (e.g. gpt-4.1 / gpt-4o)
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/multimodal`
|
||||
- [ ] Click "Try sample image" to inject the bundled PNG
|
||||
- [ ] Verify the paperclip shows the attachment
|
||||
- [ ] Ask "What do you see in this image?"
|
||||
- [ ] If the `ag-ui:spring-ai` adapter forwards media to `UserMessage.media`, verify the agent describes the image contents
|
||||
- [ ] Try the same with the sample PDF
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Attachment UX (paperclip + sample buttons + drag-drop + clipboard paste) works
|
||||
- Adapter-level image forwarding behavior is integration-dependent; see PARITY_NOTES.md
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA: Open-Ended Generative UI (Advanced) — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- OPENAI_API_KEY is set on the Spring backend
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/open-gen-ui-advanced`
|
||||
- [ ] Click the "Calculator (calls evaluateExpression)" suggestion
|
||||
- [ ] Verify a calculator UI appears inside a sandboxed iframe
|
||||
- [ ] Type `12 * (3 + 4.5)`, press `=`
|
||||
- [ ] Verify the host-side `evaluateExpression` runs and the sandbox displays `94.5`
|
||||
- [ ] Check browser console for `[open-gen-ui/advanced] evaluateExpression`
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Sandbox function round-trips complete end-to-end
|
||||
- Console logs confirm host invocation
|
||||
@@ -0,0 +1,18 @@
|
||||
# QA: Open-Ended Generative UI — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- OPENAI_API_KEY is set on the Spring backend
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/open-gen-ui`
|
||||
- [ ] Click a suggestion (e.g. "3D axis visualization")
|
||||
- [ ] Verify a sandboxed iframe renders an educational visualisation inside the chat stream
|
||||
- [ ] Confirm the visualisation is self-running (no host-side functions required)
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Agent streams HTML + CSS into the built-in OpenGenerativeUIActivityRenderer
|
||||
- The iframe mounts and animates without user interaction
|
||||
@@ -0,0 +1,15 @@
|
||||
# QA: Pre-Built Popup — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/prebuilt-popup`
|
||||
- [ ] Verify the popup launcher is visible and the popup opens by default
|
||||
- [ ] Send a message via the popup and verify the agent responds
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Popup chat surface works with the Spring AI agent
|
||||
@@ -0,0 +1,16 @@
|
||||
# QA: Pre-Built Sidebar — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/prebuilt-sidebar`
|
||||
- [ ] Verify the sidebar is open by default
|
||||
- [ ] Toggle the sidebar via its launcher button
|
||||
- [ ] Send a message and verify the agent responds inside the sidebar
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Sidebar chat surface works with the Spring AI agent
|
||||
@@ -0,0 +1,18 @@
|
||||
# QA: Readonly State / Agent Context — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/readonly-state-agent-context`
|
||||
- [ ] Verify the context card renders with Name, Timezone, Recent Activity
|
||||
- [ ] Ask "What do you know about me?"
|
||||
- [ ] Verify the agent reflects the context values (name, timezone, activities)
|
||||
- [ ] Toggle an activity checkbox
|
||||
- [ ] Ask again and verify the agent sees the updated context
|
||||
|
||||
## Expected Results
|
||||
|
||||
- The agent receives the latest frontend context via useAgentContext
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA: Reasoning (Default Render) — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Spring AI backend is up
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/reasoning-default-render`
|
||||
- [ ] Send a message
|
||||
- [ ] Verify the page loads and chat responds normally
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Zero-config reasoning: no `reasoningMessage` slot is registered
|
||||
- If REASONING*MESSAGE*\* events are emitted, the built-in CopilotChatReasoningMessage renders a collapsible card
|
||||
- If not (current Spring AI adapter default), the chat behaves as a normal chat
|
||||
@@ -0,0 +1,78 @@
|
||||
# QA: Shared State (Read + Write) — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check `/api/health`)
|
||||
- `OPENAI_API_KEY` is set on the Spring backend
|
||||
|
||||
## Architecture under test
|
||||
|
||||
- The UI owns a `preferences = { name, tone, language, interests }` object
|
||||
and writes it into agent state via `agent.setState({ preferences, notes })`.
|
||||
- Spring's `SharedStateReadWriteController` runs a per-request `LocalAgent`
|
||||
subclass that reads `preferences` off the AG-UI envelope and injects them
|
||||
into the system prompt every turn.
|
||||
- The agent has a single tool, `set_notes(notes: List<String>)`, that
|
||||
mutates `state.notes` and emits a `STATE_SNAPSHOT` event so the
|
||||
`useAgent({ updates: [OnStateChanged] })` hook re-renders the page.
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Page load
|
||||
|
||||
- [ ] Navigate to `/demos/shared-state-read-write`.
|
||||
- [ ] Verify both sidebar cards render: `data-testid="preferences-card"` and
|
||||
`data-testid="notes-card"`.
|
||||
- [ ] Verify the chat input is visible on the right.
|
||||
- [ ] Confirm the preferences JSON pre block (`data-testid="pref-state-json"`)
|
||||
shows the seeded defaults: `{ name: "", tone: "casual", language:
|
||||
"English", interests: [] }`.
|
||||
|
||||
### 2. Write side (UI -> agent)
|
||||
|
||||
- [ ] Type "Atai" into `data-testid="pref-name"`.
|
||||
- [ ] Switch tone to "playful" via `data-testid="pref-tone"`.
|
||||
- [ ] Switch language to "Spanish" via `data-testid="pref-language"`.
|
||||
- [ ] Click two interest chips (e.g. "Cooking", "Music").
|
||||
- [ ] Confirm the JSON pre block updates live to reflect every edit.
|
||||
- [ ] Send the message "Greet me." and verify the assistant's reply:
|
||||
- addresses the user as "Atai",
|
||||
- is in Spanish,
|
||||
- has a playful tone (exclamations / light humor OK),
|
||||
- and references at least one selected interest if natural.
|
||||
|
||||
### 3. Read side (agent -> UI)
|
||||
|
||||
- [ ] Send: "Remember that I prefer morning meetings and that I don't eat
|
||||
dairy."
|
||||
- [ ] Verify the agent calls `set_notes` (visible briefly in the chat
|
||||
stream) and that the Notes card shows two list items shortly after.
|
||||
- [ ] Verify `data-testid="notes-list"` is present and contains two
|
||||
`data-testid="note-item"` entries.
|
||||
- [ ] Send another remember-style message: "Also remember I'm vegetarian."
|
||||
- [ ] Verify the notes list grows (the agent should pass the FULL list,
|
||||
not a diff), now showing three items.
|
||||
|
||||
### 4. Bidirectional clear
|
||||
|
||||
- [ ] Click `data-testid="notes-clear-button"` in the Notes card.
|
||||
- [ ] Verify the list is cleared and `data-testid="notes-empty"` appears.
|
||||
- [ ] Send "What did I tell you to remember?" — agent should not hallucinate
|
||||
removed notes (memory persists in chat history but the notes panel
|
||||
is canonical).
|
||||
|
||||
### 5. Error handling
|
||||
|
||||
- [ ] Send an empty message — UI should handle gracefully.
|
||||
- [ ] Disconnect briefly: verify reconnect does not corrupt state.
|
||||
- [ ] No console errors during normal usage.
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Page loads within 3 seconds.
|
||||
- Preferences edits show in the JSON pre block instantly (local state).
|
||||
- Agent replies adapt to preferences within 10 seconds.
|
||||
- Notes panel reflects `set_notes` calls within 1 second after the tool
|
||||
result event lands (driven by the STATE_SNAPSHOT emission).
|
||||
- No UI errors, no orphaned state across renders.
|
||||
@@ -0,0 +1,75 @@
|
||||
# QA: Shared State (Reading) — Spring AI
|
||||
|
||||
## 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 — Spring AI
|
||||
|
||||
## 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) — Spring AI
|
||||
|
||||
## 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,89 @@
|
||||
# QA: Sub-Agents — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check `/api/health`)
|
||||
- `OPENAI_API_KEY` is set on the Spring backend
|
||||
|
||||
## Architecture under test
|
||||
|
||||
- Spring's `SubagentsController` runs a per-request supervisor agent that
|
||||
exposes three tools — `research_agent`, `writing_agent`, `critique_agent`
|
||||
— each backed by its own `ChatClient` call with a dedicated system
|
||||
prompt (research vs. writing vs. critique).
|
||||
- Every tool invocation appends a `Delegation` entry
|
||||
`{ id, sub_agent, task, status, result }` to `state.delegations` and
|
||||
emits a `STATE_SNAPSHOT` event, so the UI's live "delegation log"
|
||||
panel grows entry-by-entry while the supervisor works.
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Page load
|
||||
|
||||
- [ ] Navigate to `/demos/subagents`.
|
||||
- [ ] Verify the delegation log card renders
|
||||
(`data-testid="delegation-log"`).
|
||||
- [ ] Verify it shows the empty-state message
|
||||
("Ask the supervisor to complete a task...").
|
||||
- [ ] Verify `data-testid="delegation-count"` reads "0 calls".
|
||||
- [ ] Verify the chat panel is visible on the right with the
|
||||
"Give the supervisor a task..." placeholder.
|
||||
|
||||
### 2. Single delegation
|
||||
|
||||
- [ ] Send: "Research the basics of magnesium supplementation. Just one
|
||||
delegation, no writing or critique."
|
||||
- [ ] While the supervisor is running, verify
|
||||
`data-testid="supervisor-running"` is visible.
|
||||
- [ ] Verify exactly one `data-testid="delegation-entry"` appears with
|
||||
sub-agent label "Research" and status "completed".
|
||||
- [ ] Verify `data-testid="delegation-count"` reads "1 calls".
|
||||
|
||||
### 3. Full pipeline
|
||||
|
||||
- [ ] Click the "Write a blog post" suggestion (or send the equivalent
|
||||
message manually).
|
||||
- [ ] Watch the delegation log grow in real time:
|
||||
- [ ] First entry: Research (sub_agent = `research_agent`)
|
||||
- [ ] Second entry: Writing (sub_agent = `writing_agent`) — should
|
||||
contain a polished one-paragraph draft.
|
||||
- [ ] Third entry: Critique (sub_agent = `critique_agent`) — should
|
||||
contain 2–3 actionable critiques.
|
||||
- [ ] Verify each entry's "Task" line shows what the supervisor passed
|
||||
to that sub-agent.
|
||||
- [ ] Verify final supervisor message is concise (a short summary, not a
|
||||
replay of all three sub-agent outputs).
|
||||
|
||||
### 4. Live snapshot ordering
|
||||
|
||||
- [ ] Verify the entries appear incrementally — the writing entry should
|
||||
not show up at the same time as the research entry; if it does, the
|
||||
backend is batching state snapshots.
|
||||
|
||||
### 5. Multi-turn
|
||||
|
||||
- [ ] After the pipeline completes, send: "Now do the same for the
|
||||
benefits of cold exposure training."
|
||||
- [ ] Verify three more entries are appended (count = 6).
|
||||
- [ ] Verify older entries remain visible (delegation log is append-only
|
||||
within a thread).
|
||||
|
||||
### 6. Error handling
|
||||
|
||||
- [ ] Send an empty message — UI should handle gracefully.
|
||||
- [ ] If a sub-agent fails (transient OpenAI error), verify the entry's
|
||||
`status` field reads "failed" and its result starts with
|
||||
"Sub-agent failed:".
|
||||
- [ ] No console errors during normal usage.
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Page loads within 3 seconds.
|
||||
- First delegation entry appears within 10 seconds of sending the
|
||||
message; subsequent entries appear as each sub-agent ChatClient call
|
||||
completes.
|
||||
- "Supervisor running" indicator clears as soon as the run finishes.
|
||||
- Final delegation count matches the number of sub-agent calls the
|
||||
supervisor made (typically 1 or 3).
|
||||
- No UI errors, no broken layout, no overlap between log and chat panes.
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA: Tool Rendering (Custom Catch-all) — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/tool-rendering-custom-catchall`
|
||||
- [ ] Ask "What's the weather in San Francisco?"
|
||||
- [ ] Verify the branded custom catch-all card appears (`data-testid="custom-catchall-card"`)
|
||||
- [ ] Verify the card includes tool name, status badge, Arguments and Result sections
|
||||
- [ ] Try "Find flights from SFO to JFK" and verify the same branded card paints
|
||||
|
||||
## Expected Results
|
||||
|
||||
- All tool calls render via the custom catch-all component
|
||||
@@ -0,0 +1,18 @@
|
||||
# QA: Tool Rendering (Default Catch-all) — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/tool-rendering-default-catchall`
|
||||
- [ ] Ask "What's the weather in San Francisco?"
|
||||
- [ ] Verify the default tool-call card appears with status Running -> Done
|
||||
- [ ] Verify Arguments and Result sections populate
|
||||
- [ ] Try "Find flights from SFO to JFK"
|
||||
- [ ] Verify another tool card appears with the same default renderer
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Every backend tool call is painted by the default renderer (no missing tool UI)
|
||||
@@ -0,0 +1,19 @@
|
||||
# QA: Tool Rendering (Reasoning Chain) — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Spring AI backend is up with `get_weather` and `search_flights` tools
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/tool-rendering-reasoning-chain`
|
||||
- [ ] Send: "Check weather in Tokyo, then search for flights SFO to JFK"
|
||||
- [ ] Verify WeatherCard renders for the weather tool call
|
||||
- [ ] Verify FlightListCard renders for the search_flights tool call
|
||||
- [ ] If the adapter emits reasoning events, verify the ReasoningBlock renders between tool calls
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Per-tool custom renderers fire for `get_weather` and `search_flights`
|
||||
- Catchall renderer handles any unexpected tools
|
||||
- Reasoning slot renders when backend provides reasoning content
|
||||
@@ -0,0 +1,61 @@
|
||||
# QA: Tool Rendering — Spring AI
|
||||
|
||||
## 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,20 @@
|
||||
# QA: Voice — Spring AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- OPENAI_API_KEY is set on the NextJS runtime (voice transcription uses Whisper)
|
||||
- Spring AI backend is up
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to `/demos/voice`
|
||||
- [ ] Verify the microphone button appears in the chat composer
|
||||
- [ ] Click "Play sample" to inject the bundled audio
|
||||
- [ ] Confirm the transcribed text appears in the composer textarea
|
||||
- [ ] Press send and verify the agent responds to the transcribed text
|
||||
|
||||
## Expected Results
|
||||
|
||||
- `audioFileTranscriptionEnabled: true` is advertised by the runtime probe
|
||||
- Sample audio transcribes to "What is the weather in Tokyo?"
|
||||
- Agent responds with weather information from `get_weather` tool
|
||||
@@ -0,0 +1,47 @@
|
||||
// Dedicated runtime for the A2UI Fixed Schema demo.
|
||||
//
|
||||
// Routes to the Spring /a2ui-fixed-schema/run endpoint, which registers a
|
||||
// single display_flight tool that emits the fixed flight-card schema and
|
||||
// data model. The frontend catalog (see src/app/demos/a2ui-fixed-schema/a2ui/)
|
||||
// pins the Title, Airport, Arrow, AirlineBadge, PriceTag, and Button
|
||||
// components to React renderers.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
function createAgent(): AbstractAgent {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/a2ui-fixed-schema/run` });
|
||||
}
|
||||
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
"a2ui-fixed-schema": createAgent(),
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
});
|
||||
|
||||
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,47 @@
|
||||
// Dedicated runtime for the Agent Config Object demo.
|
||||
//
|
||||
// Routes to the Spring /agent-config/run endpoint which reads
|
||||
// forwardedProps (tone, expertise, responseLength) off the AG-UI envelope
|
||||
// and rebuilds the SpringAIAgent's system prompt per request.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
function createAgent(): AbstractAgent {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/agent-config/run` });
|
||||
}
|
||||
|
||||
const agentConfigAgent = createAgent();
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
"agent-config-demo": agentConfigAgent,
|
||||
default: agentConfigAgent,
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
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 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
// 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 by
|
||||
// throwing a Response. We validate a static `Authorization: Bearer
|
||||
// <DEMO_TOKEN>` header; mismatch throws 401 before the request reaches the
|
||||
// underlying Spring AI HttpAgent.
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotRuntimeHandler,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
import { DEMO_AUTH_HEADER } from "@/app/demos/auth/demo-token";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const authDemoAgent = new HttpAgent({ url: `${AGENT_URL}/` });
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore - agents type shape as in main route
|
||||
agents: {
|
||||
"auth-demo": authDemoAgent,
|
||||
default: authDemoAgent,
|
||||
},
|
||||
});
|
||||
|
||||
const BASE_PATH = "/api/copilotkit-auth";
|
||||
|
||||
const handler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: BASE_PATH,
|
||||
hooks: {
|
||||
onRequest: ({ request }) => {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (authHeader !== DEMO_AUTH_HEADER) {
|
||||
throw new Response(
|
||||
JSON.stringify({
|
||||
error: "unauthorized",
|
||||
message:
|
||||
"Missing or invalid Authorization header. Click Authenticate above to send messages.",
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = (req: NextRequest) => handler(req);
|
||||
export const GET = (req: NextRequest) => handler(req);
|
||||
@@ -0,0 +1,74 @@
|
||||
// Dedicated runtime for the Beautiful Chat flagship showcase cell (Spring AI).
|
||||
//
|
||||
// Beautiful Chat exercises A2UI (dynamic + fixed schema) and Open
|
||||
// Generative UI. The Spring AI backend serves its main agent at "/" (see
|
||||
// the `@PostMapping("/")` controller); there is no dedicated beautiful-chat
|
||||
// controller, so the cell routes there and the flagship behavior comes from
|
||||
// the runtime flags below plus the frontend's per-cell registrations.
|
||||
//
|
||||
// Isolated on its own endpoint (mirroring beautiful-chat in the canonical)
|
||||
// because the `openGenerativeUI` / `a2ui` runtime flags set global state on
|
||||
// the probe response that would otherwise leak into the other cells sharing
|
||||
// the default `/api/copilotkit` runtime.
|
||||
//
|
||||
// Mirrors showcase/integrations/pydantic-ai/src/app/api/copilotkit-beautiful-chat/route.ts.
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import type { AbstractAgent } from "@ag-ui/client";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
// The beautiful-chat page resolves <CopilotKit agent="beautiful-chat">
|
||||
// here; internal components (headless-chat, example-canvas) also call
|
||||
// `useAgent()` with no args, which defaults to agentId "default". Alias
|
||||
// default to the same backend so those hooks resolve.
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
"beautiful-chat": new HttpAgent({ url: `${AGENT_URL}/` }),
|
||||
default: new HttpAgent({ url: `${AGENT_URL}/` }),
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
openGenerativeUI: true,
|
||||
a2ui: {
|
||||
// The targeted Spring AI agent ("/") already registers the
|
||||
// `generate_a2ui` tool itself (GenerateA2uiTool in AgentConfig), so the
|
||||
// runtime must NOT inject a second copy — that would double-bind the
|
||||
// render tool. Matches pydantic-ai's beautiful-chat (this file's mirror
|
||||
// source).
|
||||
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",
|
||||
},
|
||||
});
|
||||
|
||||
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 };
|
||||
console.error(
|
||||
`[copilotkit-beautiful-chat/route] ERROR: ${e.message}`,
|
||||
e.stack,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
// Dedicated runtime for the BYOC json-render demo (Spring AI).
|
||||
//
|
||||
// The demo page renders the agent's JSON output into a frontend-owned
|
||||
// component catalog via @json-render/react. The Spring AI backend runs a
|
||||
// dedicated controller at /byoc-json-render/run whose system prompt instructs
|
||||
// the LLM to emit a flat-spec JSON object matching the catalog's schema.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
function createAgent(): AbstractAgent {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/byoc-json-render/run` });
|
||||
}
|
||||
|
||||
const byocJsonRenderAgent = createAgent();
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
byoc_json_render: byocJsonRenderAgent,
|
||||
default: byocJsonRenderAgent,
|
||||
};
|
||||
|
||||
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-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 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
// Dedicated runtime for the Declarative Generative UI (A2UI — Dynamic Schema) demo.
|
||||
//
|
||||
// Configured with `a2ui.injectA2UITool: false` — the Spring backend owns the
|
||||
// `generate_a2ui` tool explicitly (see AgentConfig.java / GenerateA2uiTool.java).
|
||||
// The A2UI middleware still serialises the registered catalog schema into
|
||||
// `copilotkit.context` so the Spring-side secondary LLM inside
|
||||
// `generate_a2ui` sees the catalog.
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import type { AbstractAgent } from "@ag-ui/client";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
function createAgent(): AbstractAgent {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/` });
|
||||
}
|
||||
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
"declarative-gen-ui": createAgent(),
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-declarative-gen-ui",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
a2ui: {
|
||||
injectA2UITool: false,
|
||||
// Models follow the tool-usage guide and omit `catalogId`, and the
|
||||
// middleware then falls back to the unregistered spec basic catalog
|
||||
// ("Catalog not found" render error). Pin the catalog the page registers.
|
||||
defaultCatalogId: "declarative-gen-ui-catalog",
|
||||
},
|
||||
}),
|
||||
});
|
||||
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,54 @@
|
||||
// Dedicated runtime for the declarative-hashbrown demo (Spring AI).
|
||||
//
|
||||
// The demo page wraps CopilotChat in HashBrownDashboard and overrides the
|
||||
// assistant message slot with a renderer that consumes hashbrown-shaped
|
||||
// structured output via `@hashbrownai/react`'s `useUiKit` + `useJsonParser`.
|
||||
// The Spring AI backend runs a dedicated controller at /byoc-hashbrown/run
|
||||
// (ByocHashbrownController) whose system prompt instructs the LLM to emit the
|
||||
// hashbrown UI-kit envelope (`{ "ui": [ { "<component>": { "props": {...} } } ] }`);
|
||||
// this route proxies to it rather than the generic "/" agent. The page
|
||||
// mounts <CopilotKit agent="declarative-hashbrown-demo">.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
function createAgent() {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/byoc-hashbrown/run` });
|
||||
}
|
||||
|
||||
const declarativeHashbrownAgent = createAgent();
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
"declarative-hashbrown-demo": declarativeHashbrownAgent,
|
||||
default: declarativeHashbrownAgent,
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-declarative-hashbrown",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-expect-error -- see main route.ts
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
console.error(
|
||||
`[copilotkit-declarative-hashbrown/route] ERROR: ${e.message}`,
|
||||
e.stack,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
// Dedicated runtime for the declarative-json-render demo (Spring AI).
|
||||
//
|
||||
// The demo page renders the agent's JSON output into a frontend-owned
|
||||
// component catalog via @json-render/react. The Spring AI backend runs a
|
||||
// dedicated controller at /byoc-json-render/run whose system prompt instructs
|
||||
// the LLM to emit a flat-spec JSON object matching the catalog's schema. The
|
||||
// demo folder + route surface were renamed from `byoc-json-render` to the
|
||||
// canonical `declarative-json-render`; the agent ID retains its legacy
|
||||
// `byoc_json_render` name.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
function createAgent(): AbstractAgent {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/byoc-json-render/run` });
|
||||
}
|
||||
|
||||
const byocJsonRenderAgent = createAgent();
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
byoc_json_render: byocJsonRenderAgent,
|
||||
default: byocJsonRenderAgent,
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-expect-error -- see main route.ts
|
||||
agents,
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-declarative-json-render",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
console.error(
|
||||
`[copilotkit-declarative-json-render/route] ERROR: ${e.message}`,
|
||||
e.stack,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
// CopilotKit runtime for the MCP Apps cell.
|
||||
//
|
||||
// The MCP Apps middleware runs on the NextJS runtime layer — it adds MCP
|
||||
// server tools to the AG-UI tools list forwarded to the agent, and when
|
||||
// the agent calls an MCP tool the middleware fetches the associated UI
|
||||
// resource and emits an `activity` event. The built-in
|
||||
// `MCPAppsActivityRenderer` registered by CopilotKitProvider renders the
|
||||
// sandboxed iframe.
|
||||
//
|
||||
// For the Spring AI backend, the Spring-AI ChatClient sees the extra MCP
|
||||
// tools via the standard `tools` field in the AG-UI request; the MCP
|
||||
// activity rendering is driven entirely by the runtime + frontend.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
function createAgent(): AbstractAgent {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/` });
|
||||
}
|
||||
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
// headless-complete shares this runtime (its page wires
|
||||
// runtimeUrl="/api/copilotkit-mcp-apps") but is backed by the shared
|
||||
// Spring-AI ChatClient at "/" — the same backend the main route
|
||||
// registers it against.
|
||||
"headless-complete": createAgent(),
|
||||
"mcp-apps": createAgent(),
|
||||
};
|
||||
|
||||
// @region[runtime-mcpapps-config]
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
mcpApps: {
|
||||
servers: [
|
||||
{
|
||||
type: "http",
|
||||
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
|
||||
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,51 @@
|
||||
// Dedicated runtime for the Multimodal Attachments demo.
|
||||
//
|
||||
// Scoped to its own route mostly so the Playwright spec can assert against
|
||||
// exactly one URL. The runtime proxies to the same Spring-AI ChatClient
|
||||
// backend (the existing bean already uses a vision-capable OpenAI model
|
||||
// such as gpt-4.1). Whether the `ag-ui:spring-ai` adapter currently forwards
|
||||
// multipart attachments from CopilotChat into Spring AI's
|
||||
// `UserMessage.media` list is integration-dependent — see PARITY_NOTES.md
|
||||
// for the current state.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
function createAgent(): AbstractAgent {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/` });
|
||||
}
|
||||
|
||||
const multimodalAgent = createAgent();
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
"multimodal-demo": multimodalAgent,
|
||||
default: multimodalAgent,
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-multimodal",
|
||||
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,67 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
||||
|
||||
// Dedicated runtime for Open Generative UI demos.
|
||||
//
|
||||
// Isolated from the shared `/api/copilotkit` route because the
|
||||
// `openGenerativeUI` flag on the runtime sets `openGenerativeUIEnabled: true`
|
||||
// on the probe response. Setting it globally would cause every cell's
|
||||
// provider to re-mount tools with the OGUI middleware active. Scoping to
|
||||
// per-demo keeps the OGUI behavior exactly where expected.
|
||||
//
|
||||
// The underlying agent is the SAME Spring-AI ChatClient the main runtime
|
||||
// routes to — the OGUI behavior is driven entirely by the runtime flag
|
||||
// + provider-side `designSkill` / `sandboxFunctions` props, which get
|
||||
// injected as agent context on the LLM turn. No new Java endpoint needed;
|
||||
// we just point multiple named agents at the same Spring controller URL.
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
function createAgent() {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/` });
|
||||
}
|
||||
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
"open-gen-ui": createAgent(),
|
||||
"open-gen-ui-advanced": createAgent(),
|
||||
};
|
||||
|
||||
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: unknown) {
|
||||
const err = error as Error;
|
||||
console.error(`[copilotkit-ogui/route] ERROR: ${err.message}`);
|
||||
return NextResponse.json(
|
||||
{ error: err.message, stack: err.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
// Dedicated runtime for the Shared State (Read + Write) demo.
|
||||
//
|
||||
// Routes to the Spring `/shared-state-read-write/run` endpoint, which runs a
|
||||
// per-request `LocalAgent` subclass that reads `preferences` off AG-UI state
|
||||
// to compose its system prompt and exposes a `set_notes` tool that mutates
|
||||
// the `notes` slot of state and emits a STATE_SNAPSHOT back to the frontend.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
function createAgent(): AbstractAgent {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/shared-state-read-write/run` });
|
||||
}
|
||||
|
||||
const sharedStateAgent = createAgent();
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
"shared-state-read-write": sharedStateAgent,
|
||||
default: sharedStateAgent,
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-shared-state-read-write",
|
||||
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 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
// Dedicated runtime for the Shared State Streaming demo.
|
||||
//
|
||||
// Routes to the Spring `/shared-state-streaming/run` endpoint, which streams
|
||||
// tool-call arguments for write_document and emits STATE_SNAPSHOT events with
|
||||
// the growing document text so the frontend sees per-token updates.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
function createAgent(): AbstractAgent {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/shared-state-streaming/run` });
|
||||
}
|
||||
|
||||
const sharedStateStreamingAgent = createAgent();
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
"shared-state-streaming": sharedStateStreamingAgent,
|
||||
default: sharedStateStreamingAgent,
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-shared-state-streaming",
|
||||
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,48 @@
|
||||
// Dedicated runtime for the Sub-Agents demo.
|
||||
//
|
||||
// Routes to the Spring `/subagents/run` endpoint, which runs a per-request
|
||||
// supervisor agent that delegates to research / writing / critique
|
||||
// sub-agents (each its own ChatClient call) and emits STATE_SNAPSHOT events
|
||||
// after every delegation so the live "delegation log" panel updates.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
function createAgent(): AbstractAgent {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/subagents/run` });
|
||||
}
|
||||
|
||||
const subagentsAgent = createAgent();
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
subagents: subagentsAgent,
|
||||
default: subagentsAgent,
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-subagents",
|
||||
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,85 @@
|
||||
// Dedicated runtime for the /demos/voice cell (Spring AI).
|
||||
//
|
||||
// Goals
|
||||
// -----
|
||||
// 1. Advertise `audioFileTranscriptionEnabled: true` on `/info` so the chat
|
||||
// composer renders the mic button.
|
||||
// 2. Handle `POST /transcribe` by invoking an OpenAI-backed
|
||||
// `TranscriptionServiceOpenAI` (from `@copilotkit/voice`).
|
||||
// 3. Return a deterministic 4xx when `OPENAI_API_KEY` is not configured.
|
||||
//
|
||||
// Voice is an INPUT MODALITY concern handled in the NextJS runtime -- not
|
||||
// the Spring-AI Java backend. The transcribed text is sent as a plain message
|
||||
// to the same Spring-AI ChatClient the other demos use, via HttpAgent.
|
||||
//
|
||||
// Wires the V2 `CopilotRuntime` directly because the V1 wrapper drops the
|
||||
// `transcriptionService` option. V2 URL-routes on `/info`, `/agent/:id/run`,
|
||||
// `/transcribe`, etc., so the route lives at `[[...slug]]/route.ts`.
|
||||
|
||||
// @region[voice-runtime]
|
||||
// @region[transcription-service-guard]
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
TranscriptionService,
|
||||
createCopilotRuntimeHandler,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import type { TranscribeFileOptions } from "@copilotkit/runtime/v2";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
import { TranscriptionServiceOpenAI } from "@copilotkit/voice";
|
||||
import OpenAI from "openai";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const voiceDemoAgent = new HttpAgent({ url: `${AGENT_URL}/` });
|
||||
|
||||
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) {
|
||||
throw new Error(
|
||||
"OPENAI_API_KEY not configured for this deployment (api key missing). " +
|
||||
"Set OPENAI_API_KEY to enable voice transcription.",
|
||||
);
|
||||
}
|
||||
return this.delegate.transcribeFile(options);
|
||||
}
|
||||
}
|
||||
// @endregion[transcription-service-guard]
|
||||
|
||||
let cachedHandler: ((req: Request) => Promise<Response>) | null = null;
|
||||
function getHandler(): (req: Request) => Promise<Response> {
|
||||
if (cachedHandler) return cachedHandler;
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
|
||||
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in
|
||||
// source, pending release.
|
||||
agents: {
|
||||
"voice-demo": voiceDemoAgent,
|
||||
default: voiceDemoAgent,
|
||||
},
|
||||
transcriptionService: new GuardedOpenAITranscriptionService(),
|
||||
});
|
||||
|
||||
cachedHandler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit-voice",
|
||||
});
|
||||
return cachedHandler;
|
||||
}
|
||||
|
||||
export const POST = (req: NextRequest) => getHandler()(req);
|
||||
export const GET = (req: NextRequest) => getHandler()(req);
|
||||
export const PUT = (req: NextRequest) => getHandler()(req);
|
||||
export const DELETE = (req: NextRequest) => getHandler()(req);
|
||||
// @endregion[voice-runtime]
|
||||
@@ -0,0 +1,200 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
||||
|
||||
// The Spring AI agent backend runs as a separate Java process.
|
||||
// This runtime proxies CopilotKit requests to it via AG-UI protocol.
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
// Per-request request/response logging is gated behind this flag (default off).
|
||||
// Under d6 probe fan-out, unconditional per-request logs flooded Railway's
|
||||
// 500-logs/sec cap and killed the replica ("Messages dropped" → container stop).
|
||||
// Set SHOWCASE_ROUTE_DEBUG=1 to re-enable verbose per-request tracing locally.
|
||||
const ROUTE_DEBUG =
|
||||
process.env.SHOWCASE_ROUTE_DEBUG === "1" ||
|
||||
process.env.SHOWCASE_ROUTE_DEBUG === "true";
|
||||
|
||||
console.log("[copilotkit/route] Initializing CopilotKit runtime");
|
||||
console.log(`[copilotkit/route] AGENT_URL: ${AGENT_URL}`);
|
||||
|
||||
function createAgent(path = "/") {
|
||||
return new HttpAgent({ url: `${AGENT_URL}${path}` });
|
||||
}
|
||||
|
||||
// Register the same Spring AI agent (backed by a single Spring-AI ChatClient
|
||||
// on the Java side) under every name used by the demo pages in this package.
|
||||
// Each entry proxies CopilotKit requests to the same Spring endpoint —
|
||||
// per-demo behavior differences live on the frontend in the form of
|
||||
// useFrontendTool / useRenderTool / useHumanInTheLoop / useAgentContext
|
||||
// hooks registered on each page.
|
||||
const agentNames = [
|
||||
"agentic_chat",
|
||||
"human_in_the_loop",
|
||||
"tool-rendering",
|
||||
"tool-rendering-default-catchall",
|
||||
"tool-rendering-custom-catchall",
|
||||
"gen-ui-tool-based",
|
||||
"gen-ui-agent",
|
||||
"shared-state-read",
|
||||
"shared-state-write",
|
||||
"shared-state-read-write",
|
||||
"shared-state-streaming",
|
||||
"subagents",
|
||||
"chat-customization-css",
|
||||
"frontend_tools",
|
||||
"frontend-tools-async",
|
||||
"hitl-in-chat",
|
||||
"hitl-in-app",
|
||||
"prebuilt-sidebar",
|
||||
"prebuilt-popup",
|
||||
"chat-slots",
|
||||
"headless-simple",
|
||||
"headless-complete",
|
||||
"readonly-state-agent-context",
|
||||
"auth",
|
||||
"open-gen-ui",
|
||||
"beautiful-chat",
|
||||
// NOTE: `tool-rendering-reasoning-chain` is intentionally NOT registered
|
||||
// here. It is declared NSF in manifest.yaml (no parallel multi-tool-call
|
||||
// streaming surface to interleave with reasoning frames on spring-ai), and
|
||||
// routing it to the default Spring agent at `/` silently bypassed the
|
||||
// reasoning workaround and rendered a regular tool-rendering run, hiding
|
||||
// the NSF. With no registration the page errors loudly when probed —
|
||||
// appropriate for a not-supported feature.
|
||||
"mcp-apps",
|
||||
"byoc-hashbrown",
|
||||
];
|
||||
|
||||
// Reasoning agent names — backed by the dedicated Spring-AI reasoning
|
||||
// controller at /reasoning (ReasoningController). The AG-UI Java SDK at
|
||||
// Spring AI 1.0.1 has no REASONING_MESSAGE_* subtypes in its typed event
|
||||
// model (the SDK only knows THINKING_*, which @ag-ui/client silently drops),
|
||||
// so ReasoningController takes over its own SseEmitter and writes raw-JSON
|
||||
// REASONING_MESSAGE_START/CONTENT/END frames whose `type` literal matches
|
||||
// the @ag-ui/core zod schema — the frontend then renders them via the
|
||||
// `reasoningMessage` slot: CopilotKit's built-in card for
|
||||
// `reasoning-default`, the custom amber ReasoningBlock for
|
||||
// `reasoning-custom`. This is the Spring/Java reimplementation of ag2's
|
||||
// reasoning_agent.py. The demo pages use the ids `reasoning-default` /
|
||||
// `reasoning-custom`; both share the one reasoning backend.
|
||||
// Mirrors ag2's route.ts `reasoningAgentNames`. NOTE:
|
||||
// `tool-rendering-reasoning-chain`, `reasoning-default-render`, and
|
||||
// `agentic-chat-reasoning` are NOT listed here — they are declared NSF in
|
||||
// manifest.yaml (no parallel multi-tool-call streaming surface to interleave
|
||||
// with reasoning frames on spring-ai for tool-rendering-reasoning-chain; no
|
||||
// matching backend surface for the other two). With no registration the
|
||||
// pages error loudly when probed — appropriate for not-supported features.
|
||||
const reasoningAgentNames = ["reasoning-default", "reasoning-custom"];
|
||||
|
||||
// Agent names routed to the interrupt-adapted scheduling backend. Both
|
||||
// gen-ui-interrupt and interrupt-headless share the same Spring AI scheduling
|
||||
// agent; only the frontend UX differs (inline picker in chat vs. external
|
||||
// popup driven by useFrontendTool).
|
||||
const interruptAgentNames = ["gen-ui-interrupt", "interrupt-headless"];
|
||||
|
||||
const agents: Record<string, AbstractAgent> = {};
|
||||
for (const name of agentNames) {
|
||||
agents[name] = createAgent();
|
||||
}
|
||||
for (const name of interruptAgentNames) {
|
||||
agents[name] = createAgent("/interrupt-adapted");
|
||||
}
|
||||
for (const name of reasoningAgentNames) {
|
||||
agents[name] = createAgent("/reasoning/");
|
||||
}
|
||||
// gen-ui-agent has a dedicated Java controller (GenUiAgentController @ /gen-ui-agent/run)
|
||||
// that drives the set_steps state-card chain; override the shared-root registration.
|
||||
agents["gen-ui-agent"] = createAgent("/gen-ui-agent/run");
|
||||
// subagents likewise has a dedicated Java controller (SubagentsController @
|
||||
// /subagents/run) that emits the research/writing/critique sub-agent
|
||||
// TOOL_CALL chains the subagent cards render from. The shared-root
|
||||
// registration above routed it to the default StreamingToolAgent, which has
|
||||
// no research_agent/writing_agent/critique_agent tools — the run produced a
|
||||
// final summary but zero subagent cards. Override to the dedicated controller.
|
||||
agents["subagents"] = createAgent("/subagents/run");
|
||||
agents["default"] = createAgent();
|
||||
|
||||
console.log(
|
||||
`[copilotkit/route] Registered ${Object.keys(agents).length} agent names: ${Object.keys(agents).join(", ")}`,
|
||||
);
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
const url = req.url;
|
||||
const contentType = req.headers.get("content-type");
|
||||
if (ROUTE_DEBUG) {
|
||||
console.log(
|
||||
`[copilotkit/route] POST ${url} (content-type: ${contentType})`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-expect-error -- see main route.ts; published CopilotRuntime's `agents`
|
||||
// type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects
|
||||
// plain Records. Fixed in source, pending release.
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await handleRequest(req);
|
||||
if (!response.ok) {
|
||||
console.log(`[copilotkit/route] Response status: ${response.status}`);
|
||||
} else if (ROUTE_DEBUG) {
|
||||
console.log(`[copilotkit/route] Response status: ${response.status}`);
|
||||
}
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
// Log full details server-side (operators grep `errorId` to correlate),
|
||||
// but never echo `err.message` / `err.stack` back to the HTTP client —
|
||||
// that leaks internal paths, dependency versions, and stack traces.
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
const errorId = crypto.randomUUID();
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
at: new Date().toISOString(),
|
||||
level: "error",
|
||||
scope: "copilotkit/route",
|
||||
errorId,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
}),
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "internal runtime error", errorId },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const GET = async () => {
|
||||
if (ROUTE_DEBUG) {
|
||||
console.log("[copilotkit/route] GET /api/copilotkit (health probe)");
|
||||
}
|
||||
|
||||
let agentStatus = "unknown";
|
||||
try {
|
||||
const res = await fetch(`${AGENT_URL}/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
agentStatus = res.ok ? "reachable" : `error (${res.status})`;
|
||||
} catch (e: unknown) {
|
||||
agentStatus = `unreachable (${(e as Error).message})`;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
agent_url: AGENT_URL,
|
||||
agent_status: agentStatus,
|
||||
env: {
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// Token-gated: SHOWCASE_DEBUG_TOKEN must be set in env and matched
|
||||
const token =
|
||||
req.headers.get("x-debug-token") || req.nextUrl.searchParams.get("token");
|
||||
const expectedToken = process.env.SHOWCASE_DEBUG_TOKEN;
|
||||
|
||||
if (!expectedToken || !token || token !== expectedToken) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "unknown";
|
||||
|
||||
// Agent connectivity
|
||||
let agentStatus = "unknown";
|
||||
let agentDetail = "";
|
||||
try {
|
||||
const res = await fetch(`${AGENT_URL}/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
agentStatus = res.ok ? "ok" : "error";
|
||||
agentDetail = `HTTP ${res.status}`;
|
||||
} catch (e: unknown) {
|
||||
agentStatus = "down";
|
||||
agentDetail = (e as Error).message;
|
||||
}
|
||||
|
||||
const uptime = process.uptime();
|
||||
const mem = process.memoryUsage();
|
||||
|
||||
return NextResponse.json({
|
||||
integration: "spring-ai",
|
||||
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: "spring-ai",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const INTEGRATION_SLUG = "spring-ai";
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Card primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes, no `cn()`/`cva` helpers.
|
||||
*/
|
||||
export const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`rounded-xl border border-neutral-200 bg-white text-neutral-950 shadow-sm ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
export const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`flex flex-col space-y-1.5 p-5 pb-3 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
export const CardTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={`text-base font-semibold leading-none tracking-tight text-neutral-900 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
export const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div ref={ref} className={`p-5 pt-0 ${className}`} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
export const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`flex items-center p-5 pt-0 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Separator primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes; uses a div instead of Radix to keep dependencies minimal.
|
||||
*/
|
||||
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
export function Separator({
|
||||
className = "",
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorProps) {
|
||||
const orientationClasses =
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px";
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation={orientation}
|
||||
className={`shrink-0 bg-neutral-200 ${orientationClasses} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Fixed A2UI catalog — wires definitions to renderers.
|
||||
*
|
||||
* `includeBasicCatalog: true` merges CopilotKit's built-in components
|
||||
* (Card, Column, Row, Text, Button, Divider, …) into this catalog, so
|
||||
* the agent's fixed schema (src/agents/a2ui_schemas/flight_schema.json) can
|
||||
* compose custom and basic components interchangeably.
|
||||
*/
|
||||
// @region[catalog-creation]
|
||||
import { createCatalog } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
import { definitions } from "./definitions";
|
||||
import { renderers } from "./renderers";
|
||||
|
||||
export const CATALOG_ID = "copilotkit://flight-fixed-catalog";
|
||||
|
||||
export const catalog = createCatalog(definitions, renderers, {
|
||||
catalogId: CATALOG_ID,
|
||||
includeBasicCatalog: true,
|
||||
});
|
||||
// @endregion[catalog-creation]
|
||||
@@ -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;
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* A2UI catalog RENDERERS — React implementations for the custom components
|
||||
* declared in `./definitions`. TypeScript enforces that the renderer map's
|
||||
* keys and prop shapes match the definitions exactly.
|
||||
*
|
||||
* Visual style: ShadCN aesthetic (neutral palette, rounded-xl, subtle
|
||||
* borders, clean typography). Tailwind utility classes only — no `cn()` /
|
||||
* `cva` helpers, no shadcn CLI install. Inline-cloned primitives live in
|
||||
* `../_components/`.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
import type { Definitions } from "./definitions";
|
||||
import { Card } from "../_components/card";
|
||||
import { Badge } from "../_components/badge";
|
||||
import { Button as UIButton } from "../_components/button";
|
||||
import { Separator } from "../_components/separator";
|
||||
|
||||
// `DynString` props are typed as `string | { path }` (see definitions.ts), but
|
||||
// the A2UI binder resolves path bindings before render — renderers only ever
|
||||
// see resolved strings. One shared helper keeps that narrowing in one place.
|
||||
const s = (v: unknown): string => (typeof v === "string" ? v : "");
|
||||
|
||||
// @region[renderers-tsx]
|
||||
export const renderers: CatalogRenderers<Definitions> = {
|
||||
/**
|
||||
* Card override: ShadCN-style outer container. The basic catalog's Card
|
||||
* uses inline styles; overriding here keeps the demo's tailwind aesthetic.
|
||||
* The flight schema renders Card > Column > [Title, Row, …]; the inner
|
||||
* Column adds the vertical spacing.
|
||||
*/
|
||||
Card: ({ props, children }) => (
|
||||
<Card className="w-full max-w-md p-5" data-testid="a2ui-fixed-card">
|
||||
{props.child ? children(props.child) : null}
|
||||
</Card>
|
||||
),
|
||||
Title: ({ props }) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
|
||||
Itinerary
|
||||
</p>
|
||||
<h3 className="text-base font-semibold leading-none tracking-tight text-neutral-900">
|
||||
{s(props.text)}
|
||||
</h3>
|
||||
</div>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
1-stop · economy
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
Airport: ({ props }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="font-mono text-2xl font-semibold tracking-wider text-neutral-900">
|
||||
{s(props.code)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
Arrow: () => (
|
||||
<div className="flex flex-1 items-center px-3">
|
||||
<Separator className="flex-1 bg-neutral-200" />
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mx-1 text-neutral-400"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
<Separator className="flex-1 bg-neutral-200" />
|
||||
</div>
|
||||
),
|
||||
AirlineBadge: ({ props }) => (
|
||||
<Badge variant="secondary" className="uppercase tracking-[0.08em]">
|
||||
{s(props.name)}
|
||||
</Badge>
|
||||
),
|
||||
PriceTag: ({ props }) => (
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
|
||||
Total
|
||||
</span>
|
||||
<span className="font-mono text-base font-semibold text-neutral-900">
|
||||
{s(props.amount)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
/**
|
||||
* Button override: this is a pure-presentation demo, so the button just
|
||||
* renders its label. The schema declares an `action` for visual fidelity,
|
||||
* but the click handler is inert until the Python SDK exposes
|
||||
* `action_handlers=` on `a2ui.render` (see `src/agents/a2ui_fixed.py`).
|
||||
*/
|
||||
Button: ({ props, children }) => (
|
||||
<UIButton className="w-full">
|
||||
{props.child ? children(props.child) : null}
|
||||
</UIButton>
|
||||
),
|
||||
};
|
||||
// @endregion[renderers-tsx]
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import { useA2UIFixedSchemaSuggestions } from "./suggestions";
|
||||
|
||||
export function Chat() {
|
||||
useA2UIFixedSchemaSuggestions();
|
||||
return (
|
||||
<CopilotChat agentId="a2ui-fixed-schema" className="h-full rounded-2xl" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Declarative Generative UI — A2UI Fixed Schema demo.
|
||||
*
|
||||
* In the fixed-schema flavor of A2UI, the component tree (schema) lives on
|
||||
* the frontend and the agent only streams *data* into the data model. The
|
||||
* flight card is ASSEMBLED from small sub-components in
|
||||
* `src/agents/a2ui_schemas/flight_schema.json` (Card > Column > [Title, Row, …]).
|
||||
*
|
||||
* - Definitions (zod schemas): `./a2ui/definitions.ts`
|
||||
* - Renderers (React): `./a2ui/renderers.tsx`
|
||||
* - Catalog wiring: `./a2ui/catalog.ts` (includes the basic catalog)
|
||||
* - Agent: `src/agents/a2ui_fixed.py` (emits an `a2ui_operations` container)
|
||||
*
|
||||
* Reference:
|
||||
* https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui/fixed-schema
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { CopilotKit } from "@copilotkit/react-core/v2";
|
||||
|
||||
import { catalog } from "./a2ui/catalog";
|
||||
import { Chat } from "./chat";
|
||||
|
||||
export default function A2UIFixedSchemaDemo() {
|
||||
return (
|
||||
// `a2ui.catalog` wires the fixed catalog into the A2UI activity renderer.
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit-a2ui-fixed-schema"
|
||||
agent="a2ui-fixed-schema"
|
||||
a2ui={{ catalog: catalog }}
|
||||
>
|
||||
<div className="flex justify-center items-center h-screen w-full bg-neutral-50">
|
||||
<div className="h-full w-full max-w-4xl border-x border-neutral-200 bg-white">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKit>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
||||
|
||||
export function useA2UIFixedSchemaSuggestions() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Find SFO → JFK",
|
||||
message: "Find me a flight from SFO to JFK on United for $289.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import type { ChangeEvent } from "react";
|
||||
import {
|
||||
type AgentConfig,
|
||||
EXPERTISE_OPTIONS,
|
||||
type Expertise,
|
||||
RESPONSE_LENGTH_OPTIONS,
|
||||
type ResponseLength,
|
||||
TONE_OPTIONS,
|
||||
type Tone,
|
||||
} from "./config-types";
|
||||
|
||||
interface ConfigCardProps {
|
||||
config: AgentConfig;
|
||||
onToneChange: (tone: Tone) => void;
|
||||
onExpertiseChange: (expertise: Expertise) => void;
|
||||
onResponseLengthChange: (length: ResponseLength) => void;
|
||||
}
|
||||
|
||||
export function ConfigCard({
|
||||
config,
|
||||
onToneChange,
|
||||
onExpertiseChange,
|
||||
onResponseLengthChange,
|
||||
}: ConfigCardProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="agent-config-card"
|
||||
className="flex flex-col gap-2 rounded-md border border-[var(--border)] bg-[var(--bg-surface)] p-4 text-sm"
|
||||
>
|
||||
<h2 className="text-sm font-semibold">Agent Config</h2>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
Change these and send a message to see the agent adapt.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">Tone</span>
|
||||
<select
|
||||
data-testid="agent-config-tone-select"
|
||||
value={config.tone}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
||||
onToneChange(e.target.value as Tone)
|
||||
}
|
||||
className="rounded border border-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
|
||||
>
|
||||
{TONE_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">Expertise</span>
|
||||
<select
|
||||
data-testid="agent-config-expertise-select"
|
||||
value={config.expertise}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
||||
onExpertiseChange(e.target.value as Expertise)
|
||||
}
|
||||
className="rounded border border-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
|
||||
>
|
||||
{EXPERTISE_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">Response length</span>
|
||||
<select
|
||||
data-testid="agent-config-length-select"
|
||||
value={config.responseLength}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
||||
onResponseLengthChange(e.target.value as ResponseLength)
|
||||
}
|
||||
className="rounded border border-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
|
||||
>
|
||||
{RESPONSE_LENGTH_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Publishes the current agent-config toggles to the agent runtime via
|
||||
* `useAgentContext`. Lives inside the `<CopilotKit>` provider so the
|
||||
* context store is reachable. The middleware on the Python side reads
|
||||
* this entry off the agent's runtime context on every turn and routes
|
||||
* it into the model's prompt.
|
||||
*/
|
||||
|
||||
import { useAgentContext } from "@copilotkit/react-core/v2";
|
||||
import type { AgentConfig } from "./config-types";
|
||||
|
||||
export function ConfigContextRelay({ config }: { config: AgentConfig }) {
|
||||
useAgentContext({
|
||||
description:
|
||||
"Agent response preferences. Apply tone, expertise level, and response length to every reply.",
|
||||
value: {
|
||||
tone: config.tone,
|
||||
expertise: config.expertise,
|
||||
responseLength: config.responseLength,
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type Tone = "professional" | "casual" | "enthusiastic";
|
||||
export type Expertise = "beginner" | "intermediate" | "expert";
|
||||
export type ResponseLength = "concise" | "detailed";
|
||||
|
||||
export interface AgentConfig {
|
||||
tone: Tone;
|
||||
expertise: Expertise;
|
||||
responseLength: ResponseLength;
|
||||
}
|
||||
|
||||
export const DEFAULT_AGENT_CONFIG: AgentConfig = {
|
||||
tone: "professional",
|
||||
expertise: "intermediate",
|
||||
responseLength: "concise",
|
||||
};
|
||||
|
||||
export const TONE_OPTIONS: Tone[] = ["professional", "casual", "enthusiastic"];
|
||||
export const EXPERTISE_OPTIONS: Expertise[] = [
|
||||
"beginner",
|
||||
"intermediate",
|
||||
"expert",
|
||||
];
|
||||
export const RESPONSE_LENGTH_OPTIONS: ResponseLength[] = [
|
||||
"concise",
|
||||
"detailed",
|
||||
];
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
|
||||
import { ConfigCard } from "./config-card";
|
||||
import type { AgentConfig } from "./config-types";
|
||||
|
||||
interface DemoLayoutProps {
|
||||
config: AgentConfig;
|
||||
onToneChange: (tone: AgentConfig["tone"]) => void;
|
||||
onExpertiseChange: (expertise: AgentConfig["expertise"]) => void;
|
||||
onResponseLengthChange: (length: AgentConfig["responseLength"]) => void;
|
||||
}
|
||||
|
||||
export function DemoLayout({
|
||||
config,
|
||||
onToneChange,
|
||||
onExpertiseChange,
|
||||
onResponseLengthChange,
|
||||
}: DemoLayoutProps) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col gap-3 p-6">
|
||||
<ConfigCard
|
||||
config={config}
|
||||
onToneChange={onToneChange}
|
||||
onExpertiseChange={onExpertiseChange}
|
||||
onResponseLengthChange={onResponseLengthChange}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden rounded-md border border-[var(--border)]">
|
||||
<CopilotChat
|
||||
agentId="agent-config-demo"
|
||||
className="h-full rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Agent Config Object — typed config knobs (tone / expertise / responseLength)
|
||||
* forwarded from the provider into the agent so its behavior changes per turn.
|
||||
*
|
||||
* Wiring: the toggles live in `useAgentConfig`. Each render the resolved
|
||||
* config is published to the agent via `useAgentContext` — the v2 idiom
|
||||
* for "frontend → agent runtime context" in LangGraph 0.6+. The Python
|
||||
* graph picks it up through `CopilotKitMiddleware`, which routes the
|
||||
* context entry into the model's prompt before each call.
|
||||
*
|
||||
* (LangGraph 0.6 deprecated `configurable` in favor of `context`; the
|
||||
* `properties` prop on `<CopilotKit>` still works for v1-style relays
|
||||
* but goes through `forwardedProps` and does not land in `RunnableConfig`
|
||||
* in @ag-ui/langgraph 0.0.31. `useAgentContext` is the supported path.)
|
||||
*/
|
||||
|
||||
import { CopilotKit } from "@copilotkit/react-core/v2";
|
||||
|
||||
import { DemoLayout } from "./demo-layout";
|
||||
import { ConfigContextRelay } from "./config-context-relay";
|
||||
import { useAgentConfig } from "./use-agent-config";
|
||||
|
||||
export default function AgentConfigDemoPage() {
|
||||
const { config, setTone, setExpertise, setResponseLength } = useAgentConfig();
|
||||
|
||||
return (
|
||||
// @region[provider-setup]
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit-agent-config"
|
||||
agent="agent-config-demo"
|
||||
>
|
||||
<ConfigContextRelay config={config} />
|
||||
<DemoLayout
|
||||
config={config}
|
||||
onToneChange={setTone}
|
||||
onExpertiseChange={setExpertise}
|
||||
onResponseLengthChange={setResponseLength}
|
||||
/>
|
||||
</CopilotKit>
|
||||
// @endregion[provider-setup]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
type AgentConfig,
|
||||
DEFAULT_AGENT_CONFIG,
|
||||
type Expertise,
|
||||
type ResponseLength,
|
||||
type Tone,
|
||||
} from "./config-types";
|
||||
|
||||
export interface UseAgentConfigHandle {
|
||||
config: AgentConfig;
|
||||
setTone: (tone: Tone) => void;
|
||||
setExpertise: (expertise: Expertise) => void;
|
||||
setResponseLength: (length: ResponseLength) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useAgentConfig(): UseAgentConfigHandle {
|
||||
const [config, setConfig] = useState<AgentConfig>(DEFAULT_AGENT_CONFIG);
|
||||
|
||||
const setTone = useCallback(
|
||||
(tone: Tone) => setConfig((prev) => ({ ...prev, tone })),
|
||||
[],
|
||||
);
|
||||
const setExpertise = useCallback(
|
||||
(expertise: Expertise) => setConfig((prev) => ({ ...prev, expertise })),
|
||||
[],
|
||||
);
|
||||
const setResponseLength = useCallback(
|
||||
(responseLength: ResponseLength) =>
|
||||
setConfig((prev) => ({ ...prev, responseLength })),
|
||||
[],
|
||||
);
|
||||
const reset = useCallback(() => setConfig(DEFAULT_AGENT_CONFIG), []);
|
||||
|
||||
return { config, setTone, setExpertise, setResponseLength, reset };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Agentic Chat
|
||||
|
||||
## What This Demo Shows
|
||||
|
||||
The simplest CopilotKit surface: a plain agentic chat backed by a LangGraph (Python) agent.
|
||||
|
||||
- **Natural Conversation**: Chat with your Copilot in a familiar chat interface
|
||||
- **Streaming Responses**: Assistant messages stream in token-by-token via AG-UI
|
||||
- **Suggestion Chips**: A starter suggestion is rendered as a quick-action chip
|
||||
|
||||
## How to Interact
|
||||
|
||||
Click the suggestion chip, or type your own prompt. For example:
|
||||
|
||||
- "Write a short sonnet about AI"
|
||||
- "Explain the difference between an LLM and an agent"
|
||||
- "Give me three ideas for a weekend project"
|
||||
|
||||
## Technical Details
|
||||
|
||||
**Provider** — `CopilotKit` wires the page to the runtime:
|
||||
|
||||
- `runtimeUrl="/api/copilotkit"` points at the Next.js route that proxies to the agent
|
||||
- `agent="agentic_chat"` selects the LangGraph agent defined in `langgraph.json`
|
||||
|
||||
**Chat surface** — `CopilotChat` renders the full chat UI with input, message list, and streaming.
|
||||
|
||||
**Suggestions** — `useConfigureSuggestions` registers a static suggestion that appears as a clickable chip below the chat input.
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { CopilotKit, CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import { useAgenticChatSuggestions } from "./suggestions";
|
||||
|
||||
export default function AgenticChatDemo() {
|
||||
return (
|
||||
// @region[provider-setup]
|
||||
<CopilotKit runtimeUrl="/api/copilotkit" agent="agentic_chat">
|
||||
<Chat />
|
||||
</CopilotKit>
|
||||
// @endregion[provider-setup]
|
||||
);
|
||||
}
|
||||
|
||||
// @region[chat-component]
|
||||
function Chat() {
|
||||
useAgenticChatSuggestions();
|
||||
// @region[render-chat]
|
||||
return <CopilotChat agentId="agentic_chat" />;
|
||||
// @endregion[render-chat]
|
||||
}
|
||||
// @endregion[chat-component]
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
||||
|
||||
// @region[configure-suggestions]
|
||||
export function useAgenticChatSuggestions() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{ title: "Write a sonnet", message: "Write a short sonnet about AI." },
|
||||
{
|
||||
title: "Tell me a joke",
|
||||
message: "Tell me a one-line joke.",
|
||||
},
|
||||
{
|
||||
title: "Is 17 prime?",
|
||||
message: "Walk me through whether 17 is prime.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
}
|
||||
// @endregion[configure-suggestions]
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AuthBannerProps {
|
||||
authenticated: boolean;
|
||||
onSignOut: () => void;
|
||||
onSignIn: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status strip rendered above <CopilotChat /> in both authenticated and
|
||||
* post-sign-out states. The post-sign-out (amber) variant exists so the demo
|
||||
* actually showcases what its name promises — the runtime rejecting an
|
||||
* unauthenticated request — instead of bouncing the user back to the gate
|
||||
* page where the rejection never happens.
|
||||
*
|
||||
* Pure presentational — owns no state itself. Testids are stable contract
|
||||
* for QA + Playwright specs.
|
||||
*/
|
||||
export function AuthBanner({
|
||||
authenticated,
|
||||
onSignOut,
|
||||
onSignIn,
|
||||
}: AuthBannerProps) {
|
||||
const classes = authenticated
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-900"
|
||||
: "border-amber-300 bg-amber-50 text-amber-900";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="auth-banner"
|
||||
data-authenticated={authenticated ? "true" : "false"}
|
||||
className={`flex items-center justify-between gap-3 rounded-md border px-4 py-2 text-sm ${classes}`}
|
||||
>
|
||||
<span data-testid="auth-status" className="font-medium">
|
||||
{authenticated
|
||||
? "✓ Signed in as demo user"
|
||||
: "⚠ Signed out — the agent will reject your messages until you sign in."}
|
||||
</span>
|
||||
{authenticated ? (
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="auth-sign-out-button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onSignOut}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="auth-authenticate-button"
|
||||
size="sm"
|
||||
onClick={onSignIn}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Shared demo-token constant imported by both the client
|
||||
* (use-demo-auth.ts) and the server runtime route
|
||||
* (api/copilotkit-auth/route.ts). Keeping the constant in one file
|
||||
* prevents drift: changing the token in one place changes it everywhere.
|
||||
*
|
||||
* This is a DEMO token. Never use a hard-coded shared secret for real auth.
|
||||
*/
|
||||
export const DEMO_TOKEN = "demo-token-123";
|
||||
|
||||
export const DEMO_AUTH_HEADER = `Bearer ${DEMO_TOKEN}`;
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
// Auth demo — framework-native request authentication via the V2 runtime's
|
||||
// `onRequest` hook. The runtime route (/api/copilotkit-auth) rejects any
|
||||
// request whose `Authorization: Bearer <demo-token>` header is missing or
|
||||
// wrong.
|
||||
//
|
||||
// UX shape: the demo defaults to UNAUTHENTICATED on first paint so visitors
|
||||
// land on a clear sign-in card. We don't render `<CopilotKit>` until the user
|
||||
// has signed in at least once — that sidesteps the transport 401 that would
|
||||
// otherwise crash `<CopilotChat>` during its initial `/info` handshake.
|
||||
// After the user signs in once, `<CopilotKit>` stays mounted across the
|
||||
// sign-out → sign-in cycle so the post-sign-out state can actually
|
||||
// demonstrate the runtime rejecting unauthenticated requests in the chat
|
||||
// surface (the whole point of the demo).
|
||||
//
|
||||
// Error surfacing: the post-sign-out 401 is captured via the AGENT-SCOPED
|
||||
// `<CopilotChat onError>` channel, NOT the provider-level `<CopilotKit
|
||||
// onError>` alone. Agent-run errors (`agent_run_failed`) are reliably
|
||||
// delivered to the chat-scoped subscription, whereas the provider-level
|
||||
// handler does not fire for them in this flow — so a demo that relies only
|
||||
// on `<CopilotKit onError>` never renders the rejection banner. We register
|
||||
// the same handler on BOTH channels: `<CopilotKit onError>` covers any
|
||||
// provider-level errors (e.g. the initial `/info` handshake) and
|
||||
// `<CopilotChat onError>` covers agent-run rejections, which is what the
|
||||
// sign-out path produces.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { CopilotKit, CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import type { CopilotKitCoreErrorCode } from "@copilotkit/react-core/v2";
|
||||
import { AuthBanner } from "./auth-banner";
|
||||
import { SignInCard } from "./sign-in-card";
|
||||
import { useDemoAuth } from "./use-demo-auth";
|
||||
import { DEMO_TOKEN } from "./demo-token";
|
||||
|
||||
interface AuthDemoErrorState {
|
||||
message: string;
|
||||
code: CopilotKitCoreErrorCode | string;
|
||||
}
|
||||
|
||||
interface AuthErrorEvent {
|
||||
error?: { message?: string } | null;
|
||||
code: CopilotKitCoreErrorCode;
|
||||
}
|
||||
|
||||
export default function AuthDemoPage() {
|
||||
const {
|
||||
isAuthenticated,
|
||||
authorizationHeader,
|
||||
hasEverSignedIn,
|
||||
signIn,
|
||||
signOut,
|
||||
} = useDemoAuth();
|
||||
|
||||
const headers = useMemo<Record<string, string>>(
|
||||
() => (authorizationHeader ? { Authorization: authorizationHeader } : {}),
|
||||
[authorizationHeader],
|
||||
);
|
||||
|
||||
const [authError, setAuthError] = useState<AuthDemoErrorState | null>(null);
|
||||
|
||||
// Shared error handler wired to BOTH the provider-level and chat-level
|
||||
// `onError` channels (see the file header for why both are needed).
|
||||
const handleAuthError = useCallback((event: AuthErrorEvent) => {
|
||||
setAuthError({
|
||||
message:
|
||||
(event.error?.message && event.error.message.trim()) ||
|
||||
(event.code
|
||||
? `Request rejected (${event.code})`
|
||||
: "The request was rejected."),
|
||||
code: event.code,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Clear stale errors as soon as the user re-authenticates. This is the
|
||||
// ONLY thing that gates the amber error surface on auth state — the render
|
||||
// condition below keys off `authError` alone. Coupling the render to a
|
||||
// second `!isAuthenticated` slice (the obvious-but-wrong guard) created a
|
||||
// post-sign-out race: the rejection's `onError` fires and calls
|
||||
// `setAuthError`, but if that commit landed in a render where the auth
|
||||
// state hadn't yet settled to false, `authError && !isAuthenticated`
|
||||
// evaluated false and the banner never appeared. Driving the surface off
|
||||
// `authError` and clearing it here on re-auth removes the cross-slice
|
||||
// ordering dependency: a rejection always renders, and signing back in
|
||||
// always wipes it.
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) setAuthError(null);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
if (!hasEverSignedIn) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<SignInCard onSignIn={signIn} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// `useSingleEndpoint={false}` opts into the V2 multi-endpoint protocol
|
||||
// (separate /info, /agents/<id>/run, etc.), which is what this demo's
|
||||
// runtime route is wired up for.
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit-auth"
|
||||
agent="auth-demo"
|
||||
headers={headers}
|
||||
useSingleEndpoint={false}
|
||||
onError={handleAuthError}
|
||||
>
|
||||
<div className="flex h-screen flex-col gap-3 p-6">
|
||||
<AuthBanner
|
||||
authenticated={isAuthenticated}
|
||||
onSignOut={signOut}
|
||||
onSignIn={() => signIn(DEMO_TOKEN)}
|
||||
/>
|
||||
<header>
|
||||
<h1 className="text-lg font-semibold">Authentication</h1>
|
||||
</header>
|
||||
{authError && (
|
||||
<div
|
||||
data-testid="auth-demo-error"
|
||||
className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900"
|
||||
>
|
||||
<strong className="font-semibold">
|
||||
Runtime rejected the request:
|
||||
</strong>{" "}
|
||||
<span data-testid="auth-demo-error-message">
|
||||
{authError.message}
|
||||
</span>{" "}
|
||||
<code className="ml-1 rounded bg-amber-100 px-1 py-0.5 font-mono text-xs">
|
||||
{authError.code}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden rounded-md border border-neutral-200">
|
||||
<CopilotChat
|
||||
agentId="auth-demo"
|
||||
className="h-full"
|
||||
onError={handleAuthError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKit>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user