chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
"""_cvdiag_backend.py — schema-v1 backend CVDIAG emitter for langgraph-fastapi.
|
||||
|
||||
This is the langgraph-fastapi realization of the §3 backend layer: it wires
|
||||
the 11 backend boundaries through the shared ``_shared.cvdiag_bootstrap.emit_cvdiag``
|
||||
single-source emitter. It is the middleware-style sibling of the langgraph-python
|
||||
(LGP) emitter (same ``AgentMiddleware`` wrap path, different ``slug``). It runs
|
||||
ALONGSIDE the legacy free-form ``_cvdiag()`` log lines in
|
||||
``_header_forwarding_middleware.py`` (dual-emit during the transition):
|
||||
|
||||
- legacy ``_cvdiag()`` keeps writing the human-grep ``CVDIAG component=...`` line,
|
||||
- this module writes the structured schema-v1 ``CVDIAG {json}`` envelope.
|
||||
|
||||
Guard: every emit here is gated on ``CVDIAG_BACKEND_EMITTER=1`` (default OFF). With
|
||||
the guard off this module is a pure no-op — it never validates, never writes, never
|
||||
throws into the observed boundary.
|
||||
|
||||
The 11 backend boundaries (spec §3 / §5):
|
||||
backend.request.ingress, backend.agent.enter, backend.llm.call.start,
|
||||
backend.llm.call.heartbeat (VERBOSE tier — periodic ~10s asyncio task),
|
||||
backend.llm.call.response, backend.sse.first_byte, backend.sse.event (DEBUG tier),
|
||||
backend.sse.aborted, backend.agent.exit, backend.response.complete,
|
||||
backend.error.caught.
|
||||
|
||||
Pure instrumentation: like the shared emitter, nothing here raises into the caller;
|
||||
the one place we ``await`` (the heartbeat task) is cancelled cleanly in a finally.
|
||||
|
||||
Plan unit: L1-D3 (mirrors L1-I).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from _shared.cvdiag_bootstrap import _resolve_tier, current_tier, emit_cvdiag
|
||||
|
||||
# ── Tier gating ───────────────────────────────────────────────────────────────
|
||||
# The shared bootstrap already resolved the tier (default | verbose | debug) and
|
||||
# applied the §6 fail-closed DEBUG guard. We mirror the §6 tier matrix locally so
|
||||
# VERBOSE-only (heartbeat) and DEBUG-only (sse.event) boundaries are suppressed at
|
||||
# the wrong tier rather than relying on the consumer to filter.
|
||||
_VERBOSE_TIERS = frozenset({"verbose", "debug"})
|
||||
_DEBUG_TIERS = frozenset({"debug"})
|
||||
|
||||
_SLUG = "langgraph-fastapi"
|
||||
_HEARTBEAT_INTERVAL_S = 10.0
|
||||
|
||||
|
||||
def emitter_enabled() -> bool:
|
||||
"""True when the schema-v1 backend emitter is armed (``CVDIAG_BACKEND_EMITTER=1``).
|
||||
|
||||
Default OFF: a missing/any-other value disables every emit in this module.
|
||||
"""
|
||||
return os.environ.get("CVDIAG_BACKEND_EMITTER") == "1"
|
||||
|
||||
|
||||
def _active_tier() -> str:
|
||||
"""Resolve the verbosity tier from a LIVE env read.
|
||||
|
||||
``emitter_enabled()`` reads ``CVDIAG_BACKEND_EMITTER`` live, so the tier MUST
|
||||
be read from the same live source — otherwise flipping ``CVDIAG_VERBOSE`` /
|
||||
``CVDIAG_DEBUG`` AFTER import arms the emitter but the tier stays frozen at
|
||||
the import-time ``setup()`` value, silently no-op'ing every verbose/debug-
|
||||
gated boundary (heartbeat, sse.event). We reuse the bootstrap's
|
||||
``_resolve_tier`` so the §6 fail-closed DEBUG guard still applies (a
|
||||
production / unresolved DEBUG request raises → degrade to the frozen tier).
|
||||
"""
|
||||
try:
|
||||
return _resolve_tier(dict(os.environ))
|
||||
except RuntimeError:
|
||||
# Fail-closed DEBUG refusal: fall back to the import-time resolved tier
|
||||
# (never silently escalate to debug in production).
|
||||
return current_tier()
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _mono_ns() -> int:
|
||||
return time.monotonic_ns()
|
||||
|
||||
|
||||
def _span_id() -> str:
|
||||
"""16 hex chars (8 random bytes) — matches SPAN_ID_PATTERN."""
|
||||
return uuid.uuid4().hex[:16]
|
||||
|
||||
|
||||
def _coerce_test_id(raw: Optional[str]) -> str:
|
||||
"""Return a schema-valid UUIDv7 ``test_id``.
|
||||
|
||||
When the inbound header carries a valid UUIDv7 we keep it (this is the
|
||||
propagation we are measuring). When it is absent/malformed we synthesize a
|
||||
deterministic-shape UUIDv7 so the envelope still validates — but the
|
||||
propagation gate measures the *inbound* presence, not this fallback.
|
||||
"""
|
||||
from _shared.cvdiag_schema import (
|
||||
TEST_ID_PATTERN,
|
||||
) # local import: cheap, avoids cycle
|
||||
import re
|
||||
|
||||
if isinstance(raw, str) and re.match(TEST_ID_PATTERN, raw):
|
||||
return raw
|
||||
# Synthesize a UUIDv7-shaped value (version nibble 7, variant 8..b).
|
||||
hexs = uuid.uuid4().hex
|
||||
return f"{hexs[0:8]}-{hexs[8:12]}-7{hexs[13:16]}-8{hexs[17:20]}-{hexs[20:32]}"
|
||||
|
||||
|
||||
def extract_test_id(headers: Dict[str, str]) -> Optional[str]:
|
||||
"""Return the inbound ``x-test-id`` header value, or ``None`` when absent.
|
||||
|
||||
This is the raw inbound value used by the propagation-reliability gate — it
|
||||
is NOT coerced/synthesized here so the gate can measure true propagation.
|
||||
"""
|
||||
raw = headers.get("x-test-id")
|
||||
return raw if isinstance(raw, str) and raw else None
|
||||
|
||||
|
||||
def _empty_edge_headers() -> Dict[str, Any]:
|
||||
return {
|
||||
"cf-ray": None,
|
||||
"cf-mitigated": None,
|
||||
"cf-cache-status": None,
|
||||
"x-railway-edge": None,
|
||||
"x-railway-request-id": None,
|
||||
"x-hikari-trace": None,
|
||||
"retry-after": None,
|
||||
"via": None,
|
||||
"server": None,
|
||||
}
|
||||
|
||||
|
||||
def _edge_headers_from(headers: Dict[str, str]) -> Dict[str, Any]:
|
||||
"""Project the inbound header bag onto the closed 9-key edge-header shape.
|
||||
|
||||
Only the 9 allow-listed keys are carried; everything else is dropped (the
|
||||
envelope's per-boundary model + EdgeHeaders ``extra=forbid`` enforce this).
|
||||
"""
|
||||
allow = _empty_edge_headers()
|
||||
for key in list(allow.keys()):
|
||||
val = headers.get(key)
|
||||
if isinstance(val, str) and val:
|
||||
allow[key] = val
|
||||
return allow
|
||||
|
||||
|
||||
def _emit(
|
||||
boundary: str,
|
||||
*,
|
||||
headers: Dict[str, str],
|
||||
trace_id: str,
|
||||
outcome: str = "ok",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
demo: str = "chat",
|
||||
tier_gate: Optional[frozenset] = None,
|
||||
) -> None:
|
||||
"""Build + emit one schema-v1 envelope, guarded + tier-filtered.
|
||||
|
||||
No-op when the emitter is disabled OR the boundary's tier gate excludes the
|
||||
resolved tier. Never raises (delegates to the shared emitter's safety).
|
||||
"""
|
||||
if not emitter_enabled():
|
||||
return
|
||||
if tier_gate is not None and _active_tier() not in tier_gate:
|
||||
return
|
||||
envelope = {
|
||||
"schema_version": 1,
|
||||
"test_id": _coerce_test_id(headers.get("x-test-id")),
|
||||
"trace_id": trace_id,
|
||||
"span_id": _span_id(),
|
||||
"parent_span_id": None,
|
||||
"layer": "backend",
|
||||
"boundary": boundary,
|
||||
"slug": _SLUG,
|
||||
"demo": demo,
|
||||
"ts": _now_iso(),
|
||||
"mono_ns": _mono_ns(),
|
||||
"duration_ms": duration_ms,
|
||||
"outcome": outcome,
|
||||
"edge_headers": _edge_headers_from(headers),
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
emit_cvdiag(envelope)
|
||||
|
||||
|
||||
class CvdiagBackendRun:
|
||||
"""Per-model-call CVDIAG run context for the LGP middleware.
|
||||
|
||||
Constructed inside ``awrap_model_call`` (and the sync ``wrap_model_call``);
|
||||
owns the trace correlation id, the ingress monotonic anchor, and the
|
||||
heartbeat asyncio task. All methods are no-ops when the emitter is disabled.
|
||||
"""
|
||||
|
||||
def __init__(self, headers: Dict[str, str]) -> None:
|
||||
self._headers = dict(headers)
|
||||
# Correlate every boundary in this run under one trace_id. Prefer the
|
||||
# inbound x-diag-run-id breadcrumb so probe/backend rows join; fall back
|
||||
# to a synthesized id.
|
||||
self._trace_id = (
|
||||
headers.get("x-diag-run-id") or headers.get("x-test-id") or uuid.uuid4().hex
|
||||
)
|
||||
self._ingress_mono = _mono_ns()
|
||||
self._first_byte_emitted = False
|
||||
self._sse_seq = 0
|
||||
self._heartbeat_task: Optional[asyncio.Task] = None
|
||||
|
||||
# ── Lifecycle boundaries ──────────────────────────────────────────────
|
||||
def request_ingress(self) -> None:
|
||||
_emit(
|
||||
"backend.request.ingress",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
metadata={"method": "POST", "path": "/threads", "content_length": None},
|
||||
tier_gate=_VERBOSE_TIERS,
|
||||
)
|
||||
|
||||
def agent_enter(
|
||||
self, agent_name: Optional[str] = None, model_id: Optional[str] = None
|
||||
) -> None:
|
||||
_emit(
|
||||
"backend.agent.enter",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
metadata={"agent_name": agent_name, "model_id": model_id},
|
||||
)
|
||||
|
||||
def llm_call_start(
|
||||
self, provider: Optional[str] = None, model: Optional[str] = None
|
||||
) -> None:
|
||||
_emit(
|
||||
"backend.llm.call.start",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
metadata={
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"prompt_token_count_estimate": None,
|
||||
},
|
||||
tier_gate=_VERBOSE_TIERS,
|
||||
)
|
||||
|
||||
def llm_call_response(
|
||||
self,
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
latency_ms: Optional[int] = None,
|
||||
error_class: Optional[str] = None,
|
||||
) -> None:
|
||||
_emit(
|
||||
"backend.llm.call.response",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
outcome="err" if error_class else "ok",
|
||||
metadata={
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"response_token_count": None,
|
||||
"latency_ms": latency_ms,
|
||||
"error_class": error_class,
|
||||
},
|
||||
tier_gate=_VERBOSE_TIERS,
|
||||
)
|
||||
|
||||
def sse_first_byte(self) -> None:
|
||||
"""Emit ``backend.sse.first_byte`` once, with the ingress→first-byte delta."""
|
||||
if self._first_byte_emitted:
|
||||
return
|
||||
self._first_byte_emitted = True
|
||||
delta_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
|
||||
_emit(
|
||||
"backend.sse.first_byte",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
metadata={"delta_ms_from_ingress": delta_ms},
|
||||
tier_gate=_VERBOSE_TIERS,
|
||||
)
|
||||
|
||||
def sse_event(
|
||||
self, event_type: Optional[str] = None, payload_size_bytes: Optional[int] = None
|
||||
) -> None:
|
||||
"""Emit ``backend.sse.event`` (DEBUG tier — suppressed below debug)."""
|
||||
self._sse_seq += 1
|
||||
_emit(
|
||||
"backend.sse.event",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
metadata={
|
||||
"event_type": event_type,
|
||||
"payload_size_bytes": payload_size_bytes,
|
||||
"sequence_num": self._sse_seq,
|
||||
},
|
||||
tier_gate=_DEBUG_TIERS,
|
||||
)
|
||||
|
||||
def sse_aborted(
|
||||
self,
|
||||
termination_kind: Optional[str] = None,
|
||||
bytes_before_abort: Optional[int] = None,
|
||||
) -> None:
|
||||
_emit(
|
||||
"backend.sse.aborted",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
outcome="err",
|
||||
metadata={
|
||||
"termination_kind": termination_kind,
|
||||
"bytes_before_abort": bytes_before_abort,
|
||||
},
|
||||
)
|
||||
|
||||
def agent_exit(self, terminal_outcome: str = "ok") -> None:
|
||||
total_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
|
||||
_emit(
|
||||
"backend.agent.exit",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
outcome="err" if terminal_outcome == "err" else "ok",
|
||||
metadata={
|
||||
"terminal_outcome": terminal_outcome,
|
||||
"total_duration_ms": total_ms,
|
||||
},
|
||||
)
|
||||
|
||||
def response_complete(
|
||||
self,
|
||||
http_status: Optional[int] = 200,
|
||||
sse_event_count: Optional[int] = None,
|
||||
) -> None:
|
||||
total_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
|
||||
_emit(
|
||||
"backend.response.complete",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
metadata={
|
||||
"http_status": http_status,
|
||||
"content_length": None,
|
||||
"total_duration_ms": total_ms,
|
||||
"sse_event_count": sse_event_count
|
||||
if sse_event_count is not None
|
||||
else self._sse_seq,
|
||||
},
|
||||
)
|
||||
|
||||
def error_caught(self, exc: BaseException) -> None:
|
||||
_emit(
|
||||
"backend.error.caught",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
outcome="err",
|
||||
metadata={
|
||||
"exception_type": type(exc).__name__,
|
||||
"message_scrubbed": "<scrubbed>",
|
||||
"stack_brief": None,
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
|
||||
# ── Heartbeat (VERBOSE tier — periodic asyncio task) ──────────────────
|
||||
async def _heartbeat_loop(self) -> None:
|
||||
"""Emit ``backend.llm.call.heartbeat`` every ~10s while the LLM call runs."""
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
|
||||
elapsed_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
|
||||
_emit(
|
||||
"backend.llm.call.heartbeat",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
outcome="info",
|
||||
metadata={"elapsed_ms_since_start": elapsed_ms},
|
||||
tier_gate=_VERBOSE_TIERS,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# Clean cancellation when the LLM call finishes — swallow.
|
||||
return
|
||||
|
||||
def start_heartbeat(self) -> None:
|
||||
"""Arm the heartbeat task (no-op when disabled or below VERBOSE tier)."""
|
||||
if not emitter_enabled() or _active_tier() not in _VERBOSE_TIERS:
|
||||
return
|
||||
if self._heartbeat_task is not None:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
self._heartbeat_task = loop.create_task(self._heartbeat_loop())
|
||||
|
||||
async def stop_heartbeat(self) -> None:
|
||||
"""Cancel + await the heartbeat task. Safe to call when never started.
|
||||
|
||||
Cooperative cancellation: the legacy ``except (CancelledError,
|
||||
Exception)`` swallowed the CALLER's CancelledError, breaking cooperative
|
||||
cancellation (a client-disconnect / request-cancel that arrives while we
|
||||
await the heartbeat task would be lost). We suppress ONLY the heartbeat
|
||||
task's OWN cancellation — the one we just requested — and re-raise when
|
||||
THIS task is being cancelled by the caller (a pending cancellation
|
||||
request, ``current_task().cancelling() > 0``). ``Task.cancelling()`` is
|
||||
3.11+ (production runs 3.12); on older runtimes the attribute is absent
|
||||
and we degrade to suppressing (the legacy behavior).
|
||||
"""
|
||||
task = self._heartbeat_task
|
||||
if task is None:
|
||||
return
|
||||
self._heartbeat_task = None
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
current = asyncio.current_task()
|
||||
cancelling = getattr(current, "cancelling", None)
|
||||
if current is not None and cancelling is not None and cancelling() > 0:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - heartbeat body must never throw out
|
||||
return
|
||||
|
||||
def emit_heartbeat_once(self) -> None:
|
||||
"""Synchronous single heartbeat emit (used by the sync wrap path + tests)."""
|
||||
elapsed_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
|
||||
_emit(
|
||||
"backend.llm.call.heartbeat",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
outcome="info",
|
||||
metadata={"elapsed_ms_since_start": elapsed_ms},
|
||||
tier_gate=_VERBOSE_TIERS,
|
||||
)
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
"""Minimal header-forwarding-only AgentMiddleware.
|
||||
|
||||
Some showcase demos (reasoning, tool-rendering-reasoning-chain, the
|
||||
sub-agents in `subagents.py`) intentionally avoid the full
|
||||
`CopilotKitMiddleware` because they don't need its frontend-tool
|
||||
injection, App-Context surfacing, or state-note features — they're
|
||||
minimal demos of LangGraph capabilities.
|
||||
|
||||
But every showcase request goes through aimock (the locally-served
|
||||
LLM mock), and aimock requires the ``x-aimock-context`` header (and
|
||||
friends) on every ``/v1/responses`` and ``/v1/chat/completions``
|
||||
request to match the right fixture. Without middleware to populate
|
||||
the header-forwarding ContextVar from the LangGraph RunnableConfig
|
||||
``configurable``, those requests go out without the header and aimock
|
||||
returns 404, breaking the demo.
|
||||
|
||||
This middleware does ONLY that header propagation — nothing else.
|
||||
It reuses copilotkit's own primitives (kept private but exported by
|
||||
the installed package at the module level) so the propagation logic
|
||||
is identical to the full middleware. No App-Context injection, no
|
||||
tool-merging, no state-to-prompt surfacing, no Bedrock message
|
||||
fix-up.
|
||||
|
||||
CVDIAG instrumentation (diagnostic only — DOES NOT change WHERE
|
||||
headers come from): after the existing
|
||||
``_extract_forwarded_headers_from_config()`` populates copilotkit's
|
||||
forwarded-headers ContextVar, we read it back via
|
||||
``get_forwarded_headers()`` and emit a structured ``CVDIAG`` log line
|
||||
at the configurable-read boundary recording whether
|
||||
``x-aimock-context`` actually arrived on the LangGraph configurable
|
||||
channel (``header_present=false`` is the alarm we are hunting). We
|
||||
also append this layer's hop tag to ``x-diag-hops`` on the SAME
|
||||
ContextVar the httpx hook already forwards from — so the breadcrumb
|
||||
and correlation headers (``x-diag-run-id``, ``x-diag-hops``) ride
|
||||
along on the outbound LLM call exactly the way ``x-aimock-context``
|
||||
does, without introducing any new forwarding source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# CVDIAG bootstrap — folded-in L1-H bootstrap. langgraph-fastapi has no
|
||||
# entrypoint of its own (the langgraph dev/server loads graphs from
|
||||
# langgraph.json), so this header-forwarding middleware module — imported by
|
||||
# every graph's middleware list — is the earliest shared import point. Importing
|
||||
# the bootstrap here configures the root logger so the legacy ``_cvdiag()`` lines
|
||||
# actually EMIT and resolves the verbosity tier + PB writer. It imports
|
||||
# pydantic/starlette only (NOT langchain/langgraph), so it is import-safe.
|
||||
import _shared.cvdiag_bootstrap # noqa: F401 (bootstrap side effects)
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable, Dict
|
||||
|
||||
from langchain.agents.middleware import (
|
||||
AgentMiddleware,
|
||||
AgentState,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
)
|
||||
|
||||
# Reuse the installed copilotkit's existing header-forwarding helpers so
|
||||
# the behaviour stays bit-identical to the full CopilotKitMiddleware's
|
||||
# header-propagation step. These are module-level functions in
|
||||
# copilotkit 0.1.94's copilotkit_lg_middleware module.
|
||||
from copilotkit.copilotkit_lg_middleware import (
|
||||
_extract_forwarded_headers_from_config,
|
||||
_ensure_httpx_hook,
|
||||
)
|
||||
|
||||
# CVDIAG-only: read/append the forwarded-header ContextVar copilotkit
|
||||
# already populates. set_forwarded_headers is used SOLELY to append the
|
||||
# diagnostic hop breadcrumb onto the SAME channel x-aimock-context rides;
|
||||
# it does not introduce a new forwarding source.
|
||||
from copilotkit.header_propagation import (
|
||||
get_forwarded_headers,
|
||||
set_forwarded_headers,
|
||||
)
|
||||
|
||||
# CVDIAG schema-v1 backend emitter (L1-D3, mirrors LGP's L1-I). Dual-emit: this
|
||||
# rides ALONGSIDE the legacy free-form _cvdiag() log lines below — it writes the
|
||||
# structured schema-v1 CVDIAG envelope through the shared single-source emitter,
|
||||
# guarded by CVDIAG_BACKEND_EMITTER (default OFF). With the guard off it is a
|
||||
# pure no-op.
|
||||
from src.agents.src._cvdiag_backend import CvdiagBackendRun
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CVDIAG_COMPONENT = "backend-langgraph-fastapi"
|
||||
_CVDIAG_HOP_TAG = "backend-langgraph-fastapi"
|
||||
|
||||
|
||||
def _cvdiag(
|
||||
boundary: str,
|
||||
headers: Dict[str, str],
|
||||
status: str,
|
||||
*,
|
||||
hop: Any = "-",
|
||||
error: str = "",
|
||||
) -> None:
|
||||
"""Emit a single CVDIAG log line in the shared cross-language convention.
|
||||
|
||||
Never logs full header values — only a 12-char prefix of
|
||||
``x-aimock-context``.
|
||||
"""
|
||||
slug = headers.get("x-aimock-context")
|
||||
header_present = isinstance(slug, str) and len(slug) > 0
|
||||
run_id = headers.get("x-diag-run-id", "none")
|
||||
test_id = headers.get("x-test-id", "none")
|
||||
prefix = slug[:12] if header_present else ""
|
||||
logger.info(
|
||||
"CVDIAG component=%s boundary=%s run_id=%s slug=%s "
|
||||
"header_present=%s header_value_prefix=%s hop=%s status=%s "
|
||||
"test_id=%s error=%s",
|
||||
_CVDIAG_COMPONENT,
|
||||
boundary,
|
||||
run_id,
|
||||
slug if header_present else "MISSING",
|
||||
str(header_present).lower(),
|
||||
prefix,
|
||||
hop,
|
||||
status,
|
||||
test_id,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
def _instrument_and_breadcrumb() -> None:
|
||||
"""Read the configurable-read result, log it, and append the diag hop.
|
||||
|
||||
Called immediately AFTER
|
||||
``_extract_forwarded_headers_from_config()`` has populated the
|
||||
ContextVar. Reads the headers back, emits the configurable-read
|
||||
CVDIAG line (wrapping the previously-silent "no x-aimock-context in
|
||||
configurable" case as an alarm), then — only when x-aimock-context
|
||||
is present — appends this layer's hop tag to ``x-diag-hops`` on the
|
||||
SAME ContextVar so the breadcrumb rides the existing forwarding path.
|
||||
"""
|
||||
headers = dict(get_forwarded_headers())
|
||||
has_context = (
|
||||
isinstance(headers.get("x-aimock-context"), str)
|
||||
and len(headers.get("x-aimock-context", "")) > 0
|
||||
)
|
||||
|
||||
if has_context:
|
||||
_cvdiag("configurable-read", headers, "ok")
|
||||
else:
|
||||
# The alarm we are hunting: the configurable channel reached this
|
||||
# middleware without x-aimock-context. Surface it instead of the
|
||||
# previous silent no-op.
|
||||
_cvdiag(
|
||||
"configurable-read",
|
||||
headers,
|
||||
"miss" if headers else "error",
|
||||
error="x-aimock-context-absent-in-configurable"
|
||||
if headers
|
||||
else "no-forwarded-headers-in-configurable",
|
||||
)
|
||||
# Nothing to breadcrumb onto — do not invent a forwarding source.
|
||||
return
|
||||
|
||||
# Append this layer's hop tag to x-diag-hops on the SAME ContextVar the
|
||||
# httpx hook forwards from. This rides the existing path; no new source.
|
||||
existing_hops = headers.get("x-diag-hops", "")
|
||||
headers["x-diag-hops"] = (
|
||||
f"{existing_hops},{_CVDIAG_HOP_TAG}"
|
||||
if isinstance(existing_hops, str) and existing_hops
|
||||
else _CVDIAG_HOP_TAG
|
||||
)
|
||||
set_forwarded_headers(headers)
|
||||
|
||||
hop = len([h for h in headers["x-diag-hops"].split(",") if h])
|
||||
_cvdiag("outbound-llm", headers, "ok", hop=hop)
|
||||
|
||||
|
||||
class HeaderForwardingMiddleware(AgentMiddleware[AgentState, Any]):
|
||||
"""AgentMiddleware that only forwards inbound x-* headers.
|
||||
|
||||
Behaviourally a no-op except for two calls inside both
|
||||
``wrap_model_call`` and ``awrap_model_call``:
|
||||
|
||||
1. ``_extract_forwarded_headers_from_config()`` — read the
|
||||
``x-*`` keys from the active LangGraph RunnableConfig
|
||||
(``context`` and ``configurable``) and populate the
|
||||
header-forwarding ContextVar.
|
||||
2. ``_ensure_httpx_hook(request.model)`` — install copilotkit's
|
||||
httpx event hook on the model's underlying HTTP client(s)
|
||||
so the next outgoing LLM request picks the headers up.
|
||||
|
||||
No App-Context injection, no tool-merging, no state-surfacing,
|
||||
no Bedrock message fix-up — strictly header propagation.
|
||||
|
||||
CVDIAG: ``_instrument_and_breadcrumb()`` is inserted between the
|
||||
two steps purely to OBSERVE the configurable-read boundary and tag
|
||||
the existing breadcrumb. It does not change where headers come from.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "HeaderForwardingMiddleware"
|
||||
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelResponse:
|
||||
_extract_forwarded_headers_from_config()
|
||||
_instrument_and_breadcrumb()
|
||||
_ensure_httpx_hook(request.model)
|
||||
|
||||
# CVDIAG schema-v1 dual-emit (L1-D3). No-op when CVDIAG_BACKEND_EMITTER off.
|
||||
headers = dict(get_forwarded_headers())
|
||||
run = CvdiagBackendRun(headers)
|
||||
model_name = _model_name(request)
|
||||
run.request_ingress()
|
||||
run.agent_enter(agent_name=self.name, model_id=model_name)
|
||||
run.llm_call_start(provider="langchain", model=model_name)
|
||||
run.emit_heartbeat_once()
|
||||
start_ns = time.monotonic_ns()
|
||||
try:
|
||||
response = handler(request)
|
||||
except BaseException as exc: # noqa: BLE001 - re-raised after observing
|
||||
run.error_caught(exc)
|
||||
run.agent_exit(terminal_outcome="err")
|
||||
raise
|
||||
latency_ms = int((time.monotonic_ns() - start_ns) / 1_000_000)
|
||||
run.llm_call_response(
|
||||
provider="langchain", model=model_name, latency_ms=latency_ms
|
||||
)
|
||||
run.sse_first_byte()
|
||||
run.sse_event(event_type="response", payload_size_bytes=None)
|
||||
run.agent_exit(terminal_outcome="ok")
|
||||
run.response_complete(http_status=200)
|
||||
return response
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelResponse:
|
||||
_extract_forwarded_headers_from_config()
|
||||
_instrument_and_breadcrumb()
|
||||
_ensure_httpx_hook(request.model)
|
||||
|
||||
# CVDIAG schema-v1 dual-emit (L1-D3). No-op when CVDIAG_BACKEND_EMITTER off.
|
||||
headers = dict(get_forwarded_headers())
|
||||
run = CvdiagBackendRun(headers)
|
||||
model_name = _model_name(request)
|
||||
run.request_ingress()
|
||||
run.agent_enter(agent_name=self.name, model_id=model_name)
|
||||
run.llm_call_start(provider="langchain", model=model_name)
|
||||
run.start_heartbeat()
|
||||
start_ns = time.monotonic_ns()
|
||||
try:
|
||||
response = await handler(request)
|
||||
except BaseException as exc: # noqa: BLE001 - re-raised after observing
|
||||
await run.stop_heartbeat()
|
||||
run.error_caught(exc)
|
||||
run.agent_exit(terminal_outcome="err")
|
||||
raise
|
||||
await run.stop_heartbeat()
|
||||
latency_ms = int((time.monotonic_ns() - start_ns) / 1_000_000)
|
||||
run.llm_call_response(
|
||||
provider="langchain", model=model_name, latency_ms=latency_ms
|
||||
)
|
||||
run.sse_first_byte()
|
||||
run.sse_event(event_type="response", payload_size_bytes=None)
|
||||
run.agent_exit(terminal_outcome="ok")
|
||||
run.response_complete(http_status=200)
|
||||
return response
|
||||
|
||||
|
||||
def _model_name(request: ModelRequest) -> str:
|
||||
"""Best-effort model identifier off the ModelRequest (never raises)."""
|
||||
try:
|
||||
model = getattr(request, "model", None)
|
||||
for attr in ("model_name", "model", "model_id"):
|
||||
val = getattr(model, attr, None)
|
||||
if isinstance(val, str) and val:
|
||||
return val
|
||||
except Exception: # noqa: BLE001 - instrumentation must not throw
|
||||
pass
|
||||
return "unknown"
|
||||
@@ -0,0 +1,26 @@
|
||||
"""LangGraph agent for the Declarative Generative UI (A2UI — Dynamic Schema) demo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a demo assistant for Declarative Generative UI (A2UI — Dynamic "
|
||||
"Schema). Whenever a response would benefit from a rich visual — a "
|
||||
"dashboard, status report, KPI summary, card layout, info grid, a "
|
||||
"pie/donut chart of part-of-whole breakdowns, or a bar chart comparing "
|
||||
"values across categories — call `generate_a2ui` to draw it. The tool "
|
||||
"renders the surface automatically from the registered component catalog; "
|
||||
"keep chat replies to one short sentence and let the UI do the talking."
|
||||
)
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4.1"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
LangGraph agent for the Declarative Generative UI (A2UI — Fixed Schema) demo.
|
||||
|
||||
Fixed-schema A2UI: the component tree (schema) is authored ahead of time as
|
||||
JSON and loaded at startup via `a2ui.load_schema(...)`. The agent only
|
||||
streams *data* into the data model at runtime. The frontend registers a
|
||||
matching catalog (see `src/app/demos/a2ui-fixed-schema/a2ui/catalog.ts`)
|
||||
that pins the schema's component names to real React implementations.
|
||||
|
||||
Reference:
|
||||
examples/integrations/langgraph-python/agent/src/a2ui_fixed_schema.py
|
||||
"""
|
||||
|
||||
# @region[backend-render-operations]
|
||||
# @region[backend-schema-json-load]
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
from copilotkit import CopilotKitMiddleware, a2ui
|
||||
from langchain.agents import create_agent
|
||||
from langchain.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
CATALOG_ID = "copilotkit://flight-fixed-catalog"
|
||||
SURFACE_ID = "flight-fixed-schema"
|
||||
|
||||
_SCHEMAS_DIR = Path(__file__).parent / "a2ui_schemas"
|
||||
|
||||
# Schemas are JSON so they can be authored and reviewed independently of the
|
||||
# Python code. `a2ui.load_schema` is just a thin `json.load` wrapper.
|
||||
FLIGHT_SCHEMA = a2ui.load_schema(_SCHEMAS_DIR / "flight_schema.json")
|
||||
BOOKED_SCHEMA = a2ui.load_schema(_SCHEMAS_DIR / "booked_schema.json")
|
||||
# @endregion[backend-schema-json-load]
|
||||
|
||||
|
||||
class Flight(TypedDict):
|
||||
"""Shape the LLM should fill in when calling `display_flight`.
|
||||
|
||||
LangGraph serializes this TypedDict into the tool's JSON schema, so
|
||||
defining it narrowly is how we steer the LLM to produce data that fits
|
||||
the frontend `FlightCard` component's props.
|
||||
"""
|
||||
|
||||
origin: str
|
||||
destination: str
|
||||
airline: str
|
||||
price: str
|
||||
|
||||
|
||||
@tool
|
||||
def display_flight(origin: str, destination: str, airline: str, price: str) -> str:
|
||||
"""Show a flight card for the given trip.
|
||||
|
||||
Use short airport codes (e.g. "SFO", "JFK") for origin/destination and a
|
||||
price string like "$289".
|
||||
"""
|
||||
# The A2UI middleware detects the `a2ui_operations` container in this
|
||||
# tool result and forwards the ops to the frontend renderer. The frontend
|
||||
# catalog resolves component names to the local React components.
|
||||
return a2ui.render(
|
||||
operations=[
|
||||
a2ui.create_surface(SURFACE_ID, catalog_id=CATALOG_ID),
|
||||
a2ui.update_components(SURFACE_ID, FLIGHT_SCHEMA),
|
||||
a2ui.update_data_model(
|
||||
SURFACE_ID,
|
||||
{
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"airline": airline,
|
||||
"price": price,
|
||||
},
|
||||
),
|
||||
],
|
||||
# NOTE: The canonical reference (and the docs at
|
||||
# docs/integrations/langgraph/generative-ui/a2ui/fixed-schema.mdx)
|
||||
# also pass `action_handlers={...}` here to declare optimistic UI
|
||||
# transitions — e.g. swapping to BOOKED_SCHEMA when the card's
|
||||
# `book_flight` button is clicked. The Python SDK's `a2ui.render`
|
||||
# does not yet accept that kwarg (see sdk-python/copilotkit/a2ui.py),
|
||||
# so we omit it for now. The `booked_schema.json` sibling is kept
|
||||
# so the schema is ready to wire up once the SDK exposes handlers.
|
||||
)
|
||||
# @endregion[backend-render-operations]
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[display_flight],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=(
|
||||
"You help users find flights. When asked about a flight, call "
|
||||
"display_flight with origin, destination, airline, and price. "
|
||||
"Keep any chat reply to one short sentence."
|
||||
),
|
||||
)
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"id": "root",
|
||||
"component": "Column",
|
||||
"gap": 8,
|
||||
"children": ["title", "detail"]
|
||||
},
|
||||
{
|
||||
"id": "title",
|
||||
"component": "Text",
|
||||
"text": { "path": "/title" },
|
||||
"variant": "h2"
|
||||
},
|
||||
{
|
||||
"id": "detail",
|
||||
"component": "Text",
|
||||
"text": { "path": "/detail" },
|
||||
"variant": "body"
|
||||
}
|
||||
]
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
[
|
||||
{
|
||||
"id": "root",
|
||||
"component": "Card",
|
||||
"child": "content"
|
||||
},
|
||||
{
|
||||
"id": "content",
|
||||
"component": "Column",
|
||||
"children": ["title", "route", "meta", "bookButton"]
|
||||
},
|
||||
{
|
||||
"id": "title",
|
||||
"component": "Title",
|
||||
"text": "Flight Details"
|
||||
},
|
||||
{
|
||||
"id": "route",
|
||||
"component": "Row",
|
||||
"justify": "spaceBetween",
|
||||
"align": "center",
|
||||
"children": ["from", "arrow", "to"]
|
||||
},
|
||||
{
|
||||
"id": "from",
|
||||
"component": "Airport",
|
||||
"code": { "path": "/origin" }
|
||||
},
|
||||
{
|
||||
"id": "arrow",
|
||||
"component": "Arrow"
|
||||
},
|
||||
{
|
||||
"id": "to",
|
||||
"component": "Airport",
|
||||
"code": { "path": "/destination" }
|
||||
},
|
||||
{
|
||||
"id": "meta",
|
||||
"component": "Row",
|
||||
"justify": "spaceBetween",
|
||||
"align": "center",
|
||||
"children": ["airline", "price"]
|
||||
},
|
||||
{
|
||||
"id": "airline",
|
||||
"component": "AirlineBadge",
|
||||
"name": { "path": "/airline" }
|
||||
},
|
||||
{
|
||||
"id": "price",
|
||||
"component": "PriceTag",
|
||||
"amount": { "path": "/price" }
|
||||
},
|
||||
{
|
||||
"id": "bookButton",
|
||||
"component": "Button",
|
||||
"variant": "primary",
|
||||
"child": "bookButtonLabel",
|
||||
"action": {
|
||||
"event": {
|
||||
"name": "book_flight",
|
||||
"context": {
|
||||
"origin": { "path": "/origin" },
|
||||
"destination": { "path": "/destination" },
|
||||
"airline": { "path": "/airline" },
|
||||
"price": { "path": "/price" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bookButtonLabel",
|
||||
"component": "Text",
|
||||
"text": "Book flight"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
LangGraph agent for the CopilotKit Showcase (FastAPI variant).
|
||||
|
||||
Uses copilotkit's create_agent (wrapping langgraph) with CopilotKitMiddleware
|
||||
so frontend-registered tools (useHumanInTheLoop, useFrontendTool) are properly
|
||||
injected into the LLM's tool list and executed on the frontend rather than
|
||||
locally.
|
||||
"""
|
||||
|
||||
from tools import (
|
||||
get_weather_impl,
|
||||
query_data_impl,
|
||||
schedule_meeting_impl,
|
||||
manage_sales_todos_impl,
|
||||
get_sales_todos_impl,
|
||||
search_flights_impl,
|
||||
build_a2ui_operations_from_tool_call,
|
||||
)
|
||||
from tools.types import SalesTodo, Flight
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_core.tools import tool as lc_tool
|
||||
from langchain_core.messages import SystemMessage
|
||||
from langchain.agents import AgentState as BaseAgentState, create_agent
|
||||
from langchain.tools import ToolRuntime, tool
|
||||
from langchain.messages import ToolMessage
|
||||
from langgraph.types import Command
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
class AgentState(BaseAgentState):
|
||||
todos: list[SalesTodo]
|
||||
|
||||
|
||||
@lc_tool
|
||||
def get_weather(location: str):
|
||||
"""Get the current weather for a location."""
|
||||
return get_weather_impl(location)
|
||||
|
||||
|
||||
@lc_tool
|
||||
def query_data(query: str):
|
||||
"""Query the database. Takes natural language. Always call before showing a chart."""
|
||||
return query_data_impl(query)
|
||||
|
||||
|
||||
@lc_tool
|
||||
def schedule_meeting(reason: str, duration_minutes: int = 30):
|
||||
"""Schedule a meeting. The user will be asked to pick a time via the UI."""
|
||||
return schedule_meeting_impl(reason, duration_minutes)
|
||||
|
||||
|
||||
@lc_tool
|
||||
def search_flights(flights: list[Flight]) -> str:
|
||||
"""Search for flights and display the results as rich cards. Return exactly 2 flights.
|
||||
|
||||
Each flight must have: airline, airlineLogo, flightNumber, origin, destination,
|
||||
date (short readable format like "Tue, Mar 18" -- use near-future dates),
|
||||
departureTime, arrivalTime, duration (e.g. "4h 25m"),
|
||||
status (e.g. "On Time" or "Delayed"),
|
||||
statusColor (hex color for status dot),
|
||||
price (e.g. "$289"), and currency (e.g. "USD").
|
||||
|
||||
For airlineLogo use Google favicon API:
|
||||
https://www.google.com/s2/favicons?domain={airline_domain}&sz=128
|
||||
"""
|
||||
result = search_flights_impl(flights)
|
||||
return json.dumps(result)
|
||||
|
||||
|
||||
@tool
|
||||
def manage_sales_todos(todos: list[SalesTodo], runtime: ToolRuntime) -> Command:
|
||||
"""
|
||||
Manage the current sales todos. Pass the full updated list.
|
||||
"""
|
||||
updated = manage_sales_todos_impl(todos)
|
||||
return Command(
|
||||
update={
|
||||
"todos": updated,
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
content="Successfully updated sales todos",
|
||||
tool_call_id=runtime.tool_call_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def get_sales_todos(runtime: ToolRuntime):
|
||||
"""
|
||||
Get the current sales todos.
|
||||
"""
|
||||
current = runtime.state.get("todos", [])
|
||||
return get_sales_todos_impl(current if current else None)
|
||||
|
||||
|
||||
@lc_tool
|
||||
def render_a2ui(
|
||||
surfaceId: str,
|
||||
catalogId: str,
|
||||
components: list[dict],
|
||||
data: dict | None = None,
|
||||
) -> str:
|
||||
"""Render a dynamic A2UI v0.9 surface."""
|
||||
return "rendered"
|
||||
|
||||
|
||||
@tool()
|
||||
def generate_a2ui(runtime: ToolRuntime[Any]) -> str:
|
||||
"""Generate dynamic A2UI components based on the conversation.
|
||||
|
||||
A secondary LLM designs the UI schema and data.
|
||||
"""
|
||||
t0 = time.time()
|
||||
messages = runtime.state["messages"][:-1]
|
||||
context_entries = runtime.state.get("copilotkit", {}).get("context", [])
|
||||
context_text = "\n\n".join(
|
||||
entry.get("value", "")
|
||||
for entry in context_entries
|
||||
if isinstance(entry, dict) and entry.get("value")
|
||||
)
|
||||
|
||||
model = ChatOpenAI(model="gpt-4.1")
|
||||
model_with_tool = model.bind_tools([render_a2ui], tool_choice="render_a2ui")
|
||||
response = model_with_tool.invoke(
|
||||
[SystemMessage(content=context_text), *messages],
|
||||
)
|
||||
|
||||
if not response.tool_calls:
|
||||
return json.dumps({"error": "LLM did not call render_a2ui"})
|
||||
|
||||
args = response.tool_calls[0]["args"]
|
||||
result = build_a2ui_operations_from_tool_call(args)
|
||||
return json.dumps(result)
|
||||
|
||||
|
||||
model = ChatOpenAI(model="gpt-4o-mini")
|
||||
|
||||
SYSTEM_PROMPT = """You are a polished, professional demo assistant for CopilotKit.
|
||||
Keep responses brief and clear -- 1 to 2 sentences max.
|
||||
|
||||
You can:
|
||||
- Chat naturally with the user
|
||||
- Change the UI background when asked (via frontend tool)
|
||||
- Query data and render charts (via query_data tool)
|
||||
- Get weather information (via get_weather tool)
|
||||
- Schedule meetings with the user (via schedule_meeting tool -- the user picks a time in the UI)
|
||||
- Manage sales pipeline todos (via manage_sales_todos / get_sales_todos tools)
|
||||
- Search flights and display rich A2UI cards (via search_flights tool)
|
||||
- Generate dynamic A2UI dashboards from conversation context (via generate_a2ui tool)
|
||||
- Generate step-by-step plans for user review (human-in-the-loop)
|
||||
"""
|
||||
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=[
|
||||
get_weather,
|
||||
query_data,
|
||||
schedule_meeting,
|
||||
search_flights,
|
||||
generate_a2ui,
|
||||
manage_sales_todos,
|
||||
get_sales_todos,
|
||||
],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
state_schema=AgentState,
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""LangGraph agent backing the Agent Config Object demo.
|
||||
|
||||
Reads three forwarded properties — tone, expertise, responseLength — from the
|
||||
LangGraph run's ``RunnableConfig["configurable"]["properties"]`` dict and
|
||||
builds its system prompt dynamically per turn.
|
||||
|
||||
The CopilotKit provider's ``properties`` prop is wired through the runtime as
|
||||
``forwardedProps`` on each AG-UI run. This graph reads those with defensive
|
||||
defaults (unknown / missing values fall back to the defaults) and composes the
|
||||
system prompt from three small rulebooks before invoking the model.
|
||||
"""
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.graph import END, START, MessagesState, StateGraph
|
||||
|
||||
|
||||
_llm: ChatOpenAI | None = None
|
||||
|
||||
|
||||
def _get_llm() -> ChatOpenAI:
|
||||
"""Lazy-instantiate the LLM so importing this module (e.g. in unit tests)
|
||||
does not require ``OPENAI_API_KEY`` to be set."""
|
||||
global _llm
|
||||
if _llm is None:
|
||||
_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.4)
|
||||
return _llm
|
||||
|
||||
|
||||
Tone = Literal["professional", "casual", "enthusiastic"]
|
||||
Expertise = Literal["beginner", "intermediate", "expert"]
|
||||
ResponseLength = Literal["concise", "detailed"]
|
||||
|
||||
DEFAULT_TONE: Tone = "professional"
|
||||
DEFAULT_EXPERTISE: Expertise = "intermediate"
|
||||
DEFAULT_RESPONSE_LENGTH: ResponseLength = "concise"
|
||||
|
||||
VALID_TONES: set[str] = {"professional", "casual", "enthusiastic"}
|
||||
VALID_EXPERTISE: set[str] = {"beginner", "intermediate", "expert"}
|
||||
VALID_RESPONSE_LENGTHS: set[str] = {"concise", "detailed"}
|
||||
|
||||
|
||||
def read_properties(config: RunnableConfig | None) -> dict[str, str]:
|
||||
"""Read the forwarded ``properties`` object with defensive defaults.
|
||||
|
||||
Any missing or unrecognized value falls back to the corresponding
|
||||
``DEFAULT_*`` constant. The function never raises.
|
||||
"""
|
||||
configurable = (config or {}).get("configurable", {}) or {}
|
||||
properties = configurable.get("properties", {}) or {}
|
||||
|
||||
tone = properties.get("tone", DEFAULT_TONE)
|
||||
expertise = properties.get("expertise", DEFAULT_EXPERTISE)
|
||||
response_length = properties.get("responseLength", DEFAULT_RESPONSE_LENGTH)
|
||||
|
||||
if tone not in VALID_TONES:
|
||||
tone = DEFAULT_TONE
|
||||
if expertise not in VALID_EXPERTISE:
|
||||
expertise = DEFAULT_EXPERTISE
|
||||
if response_length not in VALID_RESPONSE_LENGTHS:
|
||||
response_length = DEFAULT_RESPONSE_LENGTH
|
||||
|
||||
return {
|
||||
"tone": tone,
|
||||
"expertise": expertise,
|
||||
"response_length": response_length,
|
||||
}
|
||||
|
||||
|
||||
def build_system_prompt(tone: str, expertise: str, response_length: str) -> str:
|
||||
"""Compose the system prompt from the three axes."""
|
||||
tone_rules = {
|
||||
"professional": ("Use neutral, precise language. No emoji. Short sentences."),
|
||||
"casual": (
|
||||
"Use friendly, conversational language. Contractions OK. "
|
||||
"Light humor welcome."
|
||||
),
|
||||
"enthusiastic": (
|
||||
"Use upbeat, energetic language. Exclamation points OK. Emoji OK."
|
||||
),
|
||||
}
|
||||
expertise_rules = {
|
||||
"beginner": "Assume no prior knowledge. Define jargon. Use analogies.",
|
||||
"intermediate": (
|
||||
"Assume common terms are understood; explain specialized terms."
|
||||
),
|
||||
"expert": ("Assume technical fluency. Use precise terminology. Skip basics."),
|
||||
}
|
||||
length_rules = {
|
||||
"concise": "Respond in 1-3 sentences.",
|
||||
"detailed": ("Respond in multiple paragraphs with examples where relevant."),
|
||||
}
|
||||
return (
|
||||
"You are a helpful assistant.\n\n"
|
||||
f"Tone: {tone_rules[tone]}\n"
|
||||
f"Expertise level: {expertise_rules[expertise]}\n"
|
||||
f"Response length: {length_rules[response_length]}"
|
||||
)
|
||||
|
||||
|
||||
def call_model(
|
||||
state: MessagesState, config: RunnableConfig | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Single graph node — read forwarded props, build prompt, invoke LLM."""
|
||||
props = read_properties(config)
|
||||
system_prompt = build_system_prompt(
|
||||
props["tone"], props["expertise"], props["response_length"]
|
||||
)
|
||||
messages = [{"role": "system", "content": system_prompt}] + state["messages"]
|
||||
response = _get_llm().invoke(messages)
|
||||
return {"messages": [response]}
|
||||
|
||||
|
||||
graph_builder = StateGraph(MessagesState)
|
||||
graph_builder.add_node("model", call_model)
|
||||
graph_builder.add_edge(START, "model")
|
||||
graph_builder.add_edge("model", END)
|
||||
graph = graph_builder.compile()
|
||||
@@ -0,0 +1,290 @@
|
||||
"""LangGraph agent backing the Beautiful Chat demo.
|
||||
|
||||
Verbatim port of the canonical starter at /examples/integrations/langgraph-python.
|
||||
Reference structure (agent/main.py + agent/src/{todos,query,a2ui_fixed_schema,
|
||||
a2ui_dynamic_schema}.py) is inlined here into a single module to match the
|
||||
showcase cell's flat backend layout.
|
||||
|
||||
Data files (db.csv + schemas/) live alongside this module under
|
||||
`beautiful_chat_data/` to keep the cell self-contained without polluting the
|
||||
shared `a2ui_schemas/` directory (which is owned by a2ui_fixed.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
from copilotkit import (
|
||||
CopilotKitMiddleware,
|
||||
StateItem,
|
||||
StateStreamingMiddleware,
|
||||
a2ui,
|
||||
)
|
||||
from langchain.agents import AgentState as BaseAgentState
|
||||
from langchain.agents import create_agent
|
||||
from langchain.messages import ToolMessage
|
||||
from langchain.tools import ToolRuntime, tool
|
||||
from langchain_core.messages import SystemMessage
|
||||
from langchain_core.tools import tool as lc_tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.types import Command
|
||||
|
||||
|
||||
# ─── Shared state schema ────────────────────────────────────────────
|
||||
|
||||
|
||||
class Todo(TypedDict):
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
emoji: str
|
||||
status: Literal["pending", "completed"]
|
||||
|
||||
|
||||
class AgentState(BaseAgentState):
|
||||
todos: list[Todo]
|
||||
|
||||
|
||||
# ─── Todo tools ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@tool
|
||||
def manage_todos(todos: list[Todo], runtime: ToolRuntime) -> Command:
|
||||
"""
|
||||
Manage the current todos.
|
||||
"""
|
||||
# Ensure all todos have IDs that are unique
|
||||
for todo in todos:
|
||||
if "id" not in todo or not todo["id"]:
|
||||
todo["id"] = str(uuid.uuid4())
|
||||
|
||||
# Update the state
|
||||
return Command(
|
||||
update={
|
||||
"todos": todos,
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
content="Successfully updated todos",
|
||||
tool_call_id=runtime.tool_call_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def get_todos(runtime: ToolRuntime):
|
||||
"""
|
||||
Get the current todos.
|
||||
"""
|
||||
return runtime.state.get("todos", [])
|
||||
|
||||
|
||||
todo_tools = [
|
||||
manage_todos,
|
||||
get_todos,
|
||||
]
|
||||
|
||||
|
||||
# ─── Data query tool ────────────────────────────────────────────────
|
||||
|
||||
# Read data at module load time to avoid file I/O issues in
|
||||
# LangGraph Cloud's sandboxed tool execution environment.
|
||||
_DATA_DIR = Path(__file__).parent / "beautiful_chat_data"
|
||||
_csv_path = _DATA_DIR / "db.csv"
|
||||
with open(_csv_path) as _f:
|
||||
_cached_data = list(csv.DictReader(_f))
|
||||
|
||||
|
||||
@tool
|
||||
def query_data(query: str):
|
||||
"""
|
||||
Query the database, takes natural language. Always call before showing a chart or graph.
|
||||
"""
|
||||
import time
|
||||
|
||||
print(
|
||||
f"[A2UI-DEBUG] query_data called: query='{query[:60]}' at {time.strftime('%H:%M:%S')}"
|
||||
)
|
||||
return _cached_data
|
||||
|
||||
|
||||
# ─── A2UI fixed-schema tool: flight search ──────────────────────────
|
||||
|
||||
CATALOG_ID = "copilotkit://app-dashboard-catalog"
|
||||
SURFACE_ID = "flight-search-results"
|
||||
FLIGHT_SCHEMA = a2ui.load_schema(_DATA_DIR / "schemas" / "flight_schema.json")
|
||||
|
||||
|
||||
class Flight(TypedDict):
|
||||
id: str
|
||||
airline: str
|
||||
airlineLogo: str
|
||||
flightNumber: str
|
||||
origin: str
|
||||
destination: str
|
||||
date: str
|
||||
departureTime: str
|
||||
arrivalTime: str
|
||||
duration: str
|
||||
status: str
|
||||
statusIcon: str
|
||||
price: str
|
||||
|
||||
|
||||
@tool
|
||||
def search_flights(flights: list[Flight]) -> str:
|
||||
"""Search for flights and display the results as rich cards. Return exactly 2 flights.
|
||||
|
||||
Each flight must have: id, airline (e.g. "United Airlines"),
|
||||
airlineLogo (use Google favicon API: https://www.google.com/s2/favicons?domain={airline_domain}&sz=128
|
||||
e.g. "https://www.google.com/s2/favicons?domain=united.com&sz=128" for United,
|
||||
"https://www.google.com/s2/favicons?domain=delta.com&sz=128" for Delta,
|
||||
"https://www.google.com/s2/favicons?domain=aa.com&sz=128" for American,
|
||||
"https://www.google.com/s2/favicons?domain=alaskaair.com&sz=128" for Alaska),
|
||||
flightNumber, origin, destination,
|
||||
date (short readable format like "Tue, Mar 18" — use near-future dates),
|
||||
departureTime, arrivalTime,
|
||||
duration (e.g. "4h 25m"), status (e.g. "On Time" or "Delayed"),
|
||||
statusIcon (colored dot: use "https://placehold.co/12/22c55e/22c55e.png"
|
||||
for On Time, "https://placehold.co/12/eab308/eab308.png" for Delayed,
|
||||
"https://placehold.co/12/ef4444/ef4444.png" for Cancelled),
|
||||
and price (e.g. "$289").
|
||||
"""
|
||||
return a2ui.render(
|
||||
operations=[
|
||||
a2ui.create_surface(SURFACE_ID, catalog_id=CATALOG_ID),
|
||||
a2ui.update_components(SURFACE_ID, FLIGHT_SCHEMA),
|
||||
a2ui.update_data_model(SURFACE_ID, {"flights": flights}),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ─── A2UI dynamic-schema tool: LLM-generated UI ─────────────────────
|
||||
|
||||
CUSTOM_CATALOG_ID = "copilotkit://app-dashboard-catalog"
|
||||
|
||||
|
||||
@lc_tool
|
||||
def render_a2ui(
|
||||
surfaceId: str,
|
||||
catalogId: str,
|
||||
components: list[dict],
|
||||
data: dict | None = None,
|
||||
) -> str:
|
||||
"""Render a dynamic A2UI v0.9 surface.
|
||||
|
||||
Args:
|
||||
surfaceId: Unique surface identifier.
|
||||
catalogId: The catalog ID (use "copilotkit://app-dashboard-catalog").
|
||||
components: A2UI v0.9 component array (flat format). The root
|
||||
component must have id "root".
|
||||
data: Optional initial data model for the surface (e.g. form values,
|
||||
list items for data-bound components).
|
||||
"""
|
||||
return "rendered"
|
||||
|
||||
|
||||
@tool()
|
||||
def generate_a2ui(runtime: ToolRuntime[Any]) -> str:
|
||||
"""Generate dynamic A2UI components based on the conversation.
|
||||
|
||||
A secondary LLM designs the UI schema and data. The result is
|
||||
returned as an a2ui_operations container for the middleware to detect.
|
||||
"""
|
||||
import time
|
||||
|
||||
t0 = time.time()
|
||||
print(f"[A2UI-DEBUG] generate_a2ui STARTED at t=0")
|
||||
|
||||
messages = runtime.state["messages"][:-1]
|
||||
print(f"[A2UI-DEBUG] messages count: {len(messages)}")
|
||||
|
||||
# Get context entries from copilotkit state (catalog capabilities + component schema)
|
||||
context_entries = runtime.state.get("copilotkit", {}).get("context", [])
|
||||
context_text = "\n\n".join(
|
||||
entry.get("value", "")
|
||||
for entry in context_entries
|
||||
if isinstance(entry, dict) and entry.get("value")
|
||||
)
|
||||
print(
|
||||
f"[A2UI-DEBUG] context entries: {len(context_entries)}, context_text_len: {len(context_text)}"
|
||||
)
|
||||
|
||||
prompt = context_text
|
||||
|
||||
model = ChatOpenAI(model="gpt-4.1")
|
||||
model_with_tool = model.bind_tools(
|
||||
[render_a2ui],
|
||||
tool_choice="render_a2ui",
|
||||
)
|
||||
|
||||
print(f"[A2UI-DEBUG] calling secondary LLM at t={time.time() - t0:.1f}s")
|
||||
response = model_with_tool.invoke(
|
||||
[SystemMessage(content=prompt), *messages],
|
||||
)
|
||||
print(f"[A2UI-RESPONSE] {response}")
|
||||
print(f"[A2UI-DEBUG] secondary LLM responded at t={time.time() - t0:.1f}s")
|
||||
|
||||
if not response.tool_calls:
|
||||
print(f"[A2UI-DEBUG] ERROR: no tool calls in response")
|
||||
return json.dumps({"error": "LLM did not call render_a2ui"})
|
||||
|
||||
tool_call = response.tool_calls[0]
|
||||
args = tool_call["args"]
|
||||
|
||||
surface_id = args.get("surfaceId", "dynamic-surface")
|
||||
catalog_id = args.get("catalogId", CUSTOM_CATALOG_ID)
|
||||
components = args.get("components", [])
|
||||
data = args.get("data", {})
|
||||
print(
|
||||
f"[A2UI-DEBUG] components={len(components)} data_keys={list(data.keys()) if data else []} surface={surface_id}"
|
||||
)
|
||||
|
||||
ops = [
|
||||
a2ui.create_surface(surface_id, catalog_id=catalog_id),
|
||||
a2ui.update_components(surface_id, components),
|
||||
]
|
||||
if data:
|
||||
ops.append(a2ui.update_data_model(surface_id, data))
|
||||
|
||||
result = a2ui.render(operations=ops)
|
||||
print(
|
||||
f"[A2UI-DEBUG] generate_a2ui DONE at t={time.time() - t0:.1f}s result_len={len(result)}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ─── Graph ──────────────────────────────────────────────────────────
|
||||
|
||||
model = ChatOpenAI(model="gpt-5.4", model_kwargs={"parallel_tool_calls": False})
|
||||
|
||||
agent = create_agent(
|
||||
model=model,
|
||||
tools=[query_data, *todo_tools, generate_a2ui, search_flights],
|
||||
middleware=[
|
||||
CopilotKitMiddleware(),
|
||||
StateStreamingMiddleware(
|
||||
StateItem(state_key="todos", tool="manage_todos", tool_argument="todos")
|
||||
),
|
||||
],
|
||||
state_schema=AgentState,
|
||||
system_prompt="""
|
||||
You are a polished, professional demo assistant. Keep responses to 1-2 sentences.
|
||||
|
||||
Tool guidance:
|
||||
- Flights: call search_flights to show flight cards with a pre-built schema.
|
||||
- Dashboards & rich UI: call generate_a2ui to create dashboard UIs with metrics,
|
||||
charts, tables, and cards. It handles rendering automatically.
|
||||
- Charts: call query_data first, then render with the chart component.
|
||||
- Todos: enable app mode first, then manage todos.
|
||||
- A2UI actions: when you see a log_a2ui_event result (e.g. "view_details"),
|
||||
respond with a brief confirmation. The UI already updated on the frontend.
|
||||
""",
|
||||
)
|
||||
|
||||
graph = agent
|
||||
@@ -0,0 +1,41 @@
|
||||
date,category,subcategory,amount,type,notes
|
||||
2026-01-05,Revenue,Enterprise Subscriptions,28000,income,3 new enterprise customers (Acme Corp, TechFlow, DataViz Inc)
|
||||
2026-01-05,Revenue,Pro Tier Upgrades,18000,income,24 users upgraded from free to pro
|
||||
2026-01-08,Revenue,API Usage Overages,9500,income,High API usage from top 5 customers
|
||||
2026-01-10,Expenses,Engineering Salaries,42000,expense,7 engineers + 2 contractors
|
||||
2026-01-10,Expenses,Product Team,18000,expense,PM and 2 designers
|
||||
2026-01-12,Expenses,AWS Infrastructure,8200,expense,Increased compute for new AI features
|
||||
2026-01-15,Expenses,Marketing - Paid Ads,12000,expense,Google Ads and LinkedIn campaigns
|
||||
2026-01-18,Revenue,Consulting Services,14500,income,Custom integration for Acme Corp
|
||||
2026-01-20,Expenses,Customer Success,15000,expense,3 CSMs + support tools (Intercom)
|
||||
2026-01-22,Expenses,AI Model Costs,4200,expense,OpenAI API usage for product features
|
||||
2026-01-25,Revenue,Marketplace Sales,12800,income,Template and plugin sales
|
||||
2026-01-28,Expenses,Office & Equipment,3500,expense,New laptops and coworking spaces
|
||||
2026-02-03,Revenue,Enterprise Subscriptions,31000,income,2 new customers + expansion from TechFlow
|
||||
2026-02-03,Revenue,Pro Tier Upgrades,22500,income,31 upgrades + reduced churn
|
||||
2026-02-05,Revenue,API Usage Overages,11800,income,DataViz Inc heavy API usage spike
|
||||
2026-02-07,Expenses,Engineering Salaries,42000,expense,Same headcount as January
|
||||
2026-02-07,Expenses,Product Team,18000,expense,No changes to product team
|
||||
2026-02-10,Expenses,AWS Infrastructure,9500,expense,Traffic spike from viral social post
|
||||
2026-02-12,Expenses,Marketing - Paid Ads,15000,expense,Increased ad spend for Q1 push
|
||||
2026-02-14,Revenue,Consulting Services,18000,income,2 custom projects (TechFlow + new client)
|
||||
2026-02-18,Expenses,Customer Success,16500,expense,Hired 1 additional CSM
|
||||
2026-02-20,Expenses,AI Model Costs,5800,expense,Increased usage from new AI features launch
|
||||
2026-02-22,Revenue,Marketplace Sales,14200,income,Top template hit featured list
|
||||
2026-02-25,Expenses,Conference & Travel,4500,expense,Team attended SaaS Conference 2026
|
||||
2026-02-27,Revenue,Partnership Revenue,11500,income,Referral fees from integration partners
|
||||
2026-03-02,Revenue,Enterprise Subscriptions,35000,income,Major win: Fortune 500 customer signed
|
||||
2026-03-02,Revenue,Pro Tier Upgrades,26000,income,42 upgrades - best month yet
|
||||
2026-03-05,Revenue,API Usage Overages,13200,income,Consistent high usage across top tier
|
||||
2026-03-08,Expenses,Engineering Salaries,48000,expense,Hired 1 senior engineer for AI team
|
||||
2026-03-08,Expenses,Product Team,21000,expense,Promoted designer to senior level
|
||||
2026-03-10,Expenses,AWS Infrastructure,11000,expense,Scaled infrastructure for enterprise client
|
||||
2026-03-12,Expenses,Marketing - Paid Ads,18000,expense,Doubled down on successful campaigns
|
||||
2026-03-14,Revenue,Consulting Services,21500,income,Fortune 500 onboarding + 2 other projects
|
||||
2026-03-16,Expenses,Customer Success,19500,expense,Hired dedicated enterprise CSM
|
||||
2026-03-18,Expenses,AI Model Costs,7200,expense,Fortune 500 client heavy AI usage
|
||||
2026-03-20,Revenue,Marketplace Sales,15800,income,3 new templates in top 10
|
||||
2026-03-22,Expenses,Sales & BD,12000,expense,Hired first sales rep for enterprise
|
||||
2026-03-24,Revenue,Partnership Revenue,14200,income,New integration partnerships launched
|
||||
2026-03-26,Expenses,Security & Compliance,6500,expense,SOC 2 audit and security tools
|
||||
2026-03-28,Revenue,Training & Workshops,10200,income,Conducted 2 customer training sessions
|
||||
|
+37
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"id": "root",
|
||||
"component": "Row",
|
||||
"children": {
|
||||
"componentId": "flight-card",
|
||||
"path": "/flights"
|
||||
},
|
||||
"gap": 16
|
||||
},
|
||||
{
|
||||
"id": "flight-card",
|
||||
"component": "FlightCard",
|
||||
"airline": { "path": "airline" },
|
||||
"airlineLogo": { "path": "airlineLogo" },
|
||||
"flightNumber": { "path": "flightNumber" },
|
||||
"origin": { "path": "origin" },
|
||||
"destination": { "path": "destination" },
|
||||
"date": { "path": "date" },
|
||||
"departureTime": { "path": "departureTime" },
|
||||
"arrivalTime": { "path": "arrivalTime" },
|
||||
"duration": { "path": "duration" },
|
||||
"status": { "path": "status" },
|
||||
"price": { "path": "price" },
|
||||
"action": {
|
||||
"event": {
|
||||
"name": "book_flight",
|
||||
"context": {
|
||||
"flightNumber": { "path": "flightNumber" },
|
||||
"origin": { "path": "origin" },
|
||||
"destination": { "path": "destination" },
|
||||
"price": { "path": "price" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,104 @@
|
||||
"""LangGraph agent backing the byoc-hashbrown demo (Wave 4a).
|
||||
|
||||
Emits hashbrown-shaped structured output that the ported HashBrownDashboard
|
||||
renderer (`src/app/demos/byoc-hashbrown/hashbrown-renderer.tsx`) progressively
|
||||
parses via `@hashbrownai/react`'s `useJsonParser` + `useUiKit`.
|
||||
|
||||
Wire format
|
||||
-----------
|
||||
`@hashbrownai/react`'s `useJsonParser(content, kit.schema)` expects the agent
|
||||
to stream a JSON object literal matching `kit.schema` — NOT the `<ui>...</ui>`
|
||||
XML-style examples shown inside `useUiKit({ examples })`. Those XML examples
|
||||
are the hashbrown prompt DSL that hashbrown compiles into a schema description
|
||||
when driving the LLM directly (e.g. `useUiChat`/`useUiCompletion`). Because
|
||||
this demo drives the LLM via langgraph instead, we must mirror what
|
||||
hashbrown's own schema wire format looks like:
|
||||
|
||||
{
|
||||
"ui": [
|
||||
{ "metric": { "props": { "label": "...", "value": "..." } } },
|
||||
{ "pieChart": { "props": { "title": "...", "data": "[{...}]" } } },
|
||||
{ "barChart": { "props": { "title": "...", "data": "[{...}]" } } },
|
||||
{ "dealCard": { "props": { "title": "...", "stage": "prospect", "value": 100000 } } },
|
||||
{ "Markdown": { "props": { "children": "## heading\\nbody" } } }
|
||||
]
|
||||
}
|
||||
|
||||
Every node is a single-key object `{tagName: {props: {...}}}`. The tag names
|
||||
and prop schemas match `useSalesDashboardKit()` in
|
||||
`hashbrown-renderer.tsx`. `pieChart` and `barChart` receive `data` as a
|
||||
JSON-encoded string (this was intentional in PR #4252 to keep the schema
|
||||
stable under partial streaming).
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
BYOC_HASHBROWN_SYSTEM_PROMPT = """\
|
||||
You are a sales analytics assistant that replies by emitting a single JSON
|
||||
object consumed by a streaming JSON parser on the frontend.
|
||||
|
||||
ALWAYS respond with a single JSON object of the form:
|
||||
|
||||
{
|
||||
"ui": [
|
||||
{ <componentName>: { "props": { ... } } },
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Do NOT wrap the response in code fences. Do NOT include any preface or
|
||||
explanation outside the JSON object. The response MUST be valid JSON.
|
||||
|
||||
Available components and their prop schemas:
|
||||
|
||||
- "metric": { "props": { "label": string, "value": string } }
|
||||
A KPI card. `value` is a pre-formatted string like "$1.2M" or "248".
|
||||
|
||||
- "pieChart": { "props": { "title": string, "data": string } }
|
||||
A donut chart. `data` is a JSON-encoded STRING (embedded JSON) of an
|
||||
array of {label, value} objects with at least 3 segments, e.g.
|
||||
"data": "[{\\"label\\":\\"Enterprise\\",\\"value\\":600000}]".
|
||||
|
||||
- "barChart": { "props": { "title": string, "data": string } }
|
||||
A vertical bar chart. `data` is a JSON-encoded STRING of an array of
|
||||
{label, value} objects with at least 3 bars, typically time-ordered.
|
||||
|
||||
- "dealCard": { "props": { "title": string, "stage": string, "value": number } }
|
||||
A single sales deal. `stage` MUST be one of: "prospect", "qualified",
|
||||
"proposal", "negotiation", "closed-won", "closed-lost". `value` is a
|
||||
raw number (no currency symbol or comma).
|
||||
|
||||
- "Markdown": { "props": { "children": string } }
|
||||
Short explanatory text. Use for section headings and brief summaries.
|
||||
Standard markdown is supported in `children`.
|
||||
|
||||
Rules:
|
||||
- Always produce plausible sample data when the user asks for a dashboard or
|
||||
chart — do not refuse for lack of data.
|
||||
- Prefer 3-6 rows of data in charts; keep labels short.
|
||||
- Use "Markdown" for short headings or linking sentences between visual
|
||||
components. Do not emit long prose.
|
||||
- Do not emit components that are not listed above.
|
||||
- `data` props on charts MUST be a JSON STRING — escape inner quotes.
|
||||
|
||||
Example response (sales dashboard):
|
||||
{"ui":[{"Markdown":{"props":{"children":"## Q4 Sales Summary"}}},{"metric":{"props":{"label":"Total Revenue","value":"$1.2M"}}},{"metric":{"props":{"label":"New Customers","value":"248"}}},{"pieChart":{"props":{"title":"Revenue by Segment","data":"[{\\"label\\":\\"Enterprise\\",\\"value\\":600000},{\\"label\\":\\"SMB\\",\\"value\\":400000},{\\"label\\":\\"Startup\\",\\"value\\":200000}]"}}},{"barChart":{"props":{"title":"Monthly Revenue","data":"[{\\"label\\":\\"Oct\\",\\"value\\":350000},{\\"label\\":\\"Nov\\",\\"value\\":400000},{\\"label\\":\\"Dec\\",\\"value\\":450000}]"}}}]}
|
||||
"""
|
||||
|
||||
# Force JSON-object output mode. The frontend's `useJsonParser` bails to
|
||||
# `null` on any non-JSON prefix (code fences, prose preamble, etc.), so
|
||||
# leaving the model free to wander out of JSON is what left the renderer
|
||||
# empty in practice. `response_format={"type": "json_object"}` tells
|
||||
# OpenAI to refuse to emit anything but a single JSON object, which
|
||||
# aligns the wire-level contract with what the parser accepts.
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(
|
||||
model="gpt-4o-mini",
|
||||
model_kwargs={"response_format": {"type": "json_object"}},
|
||||
),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=BYOC_HASHBROWN_SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""LangGraph agent backing the BYOC json-render demo.
|
||||
|
||||
Emits a single JSON object shaped like `@json-render/react`'s flat spec
|
||||
format (`{ root, elements }`) so the frontend can feed it directly into
|
||||
`<Renderer />` against a Zod-validated catalog of three components —
|
||||
MetricCard, BarChart, PieChart.
|
||||
|
||||
The scenario mirrors Wave 4a (hashbrown) so the two BYOC rows on the
|
||||
dashboard are directly comparable. The only difference is the rendering
|
||||
technology; the catalog shape and suggestion prompts are identical.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """
|
||||
You are a sales-dashboard UI generator for a BYOC json-render demo.
|
||||
|
||||
When the user asks for a UI, respond with **exactly one JSON object** and
|
||||
nothing else — no prose, no markdown fences, no leading explanation. The
|
||||
object must match this schema (the "flat element map" format consumed by
|
||||
`@json-render/react`):
|
||||
|
||||
{
|
||||
"root": "<id of the root element>",
|
||||
"elements": {
|
||||
"<id>": {
|
||||
"type": "<component name>",
|
||||
"props": { ... component-specific props ... },
|
||||
"children": [ "<id>", ... ]
|
||||
},
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
Available components (use each name verbatim as "type"):
|
||||
|
||||
- MetricCard
|
||||
props: { "label": string, "value": string, "trend": string | null }
|
||||
Example trend strings: "+12% vs last quarter", "-3% vs last month", null.
|
||||
|
||||
- BarChart
|
||||
props: {
|
||||
"title": string,
|
||||
"description": string | null,
|
||||
"data": [ { "label": string, "value": number }, ... ]
|
||||
}
|
||||
|
||||
- PieChart
|
||||
props: {
|
||||
"title": string,
|
||||
"description": string | null,
|
||||
"data": [ { "label": string, "value": number }, ... ]
|
||||
}
|
||||
|
||||
Rules:
|
||||
|
||||
1. Output **only** valid JSON. No markdown code fences. No text outside
|
||||
the object.
|
||||
2. Every id referenced in `root` or any `children` array must be a key
|
||||
in `elements`.
|
||||
3. For a multi-component dashboard, use a root MetricCard and list the
|
||||
charts in its `children` array, OR pick any element as root and list
|
||||
the others as its children. Do not emit orphan elements.
|
||||
4. Use realistic sales-domain values (revenue, pipeline, conversion,
|
||||
categories, months) — the demo is a sales dashboard.
|
||||
5. `children` is optional but when present must be an array of strings.
|
||||
6. Never invent component types outside the three listed above.
|
||||
|
||||
### Worked example — "Show me the sales dashboard with metrics and a revenue chart"
|
||||
|
||||
{
|
||||
"root": "revenue-metric",
|
||||
"elements": {
|
||||
"revenue-metric": {
|
||||
"type": "MetricCard",
|
||||
"props": {
|
||||
"label": "Revenue (Q3)",
|
||||
"value": "$1.24M",
|
||||
"trend": "+18% vs Q2"
|
||||
},
|
||||
"children": ["revenue-bar"]
|
||||
},
|
||||
"revenue-bar": {
|
||||
"type": "BarChart",
|
||||
"props": {
|
||||
"title": "Monthly revenue",
|
||||
"description": "Revenue by month across Q3",
|
||||
"data": [
|
||||
{ "label": "Jul", "value": 380000 },
|
||||
{ "label": "Aug", "value": 410000 },
|
||||
{ "label": "Sep", "value": 450000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
### Worked example — "Break down revenue by category as a pie chart"
|
||||
|
||||
{
|
||||
"root": "category-pie",
|
||||
"elements": {
|
||||
"category-pie": {
|
||||
"type": "PieChart",
|
||||
"props": {
|
||||
"title": "Revenue by category",
|
||||
"description": "Share of total revenue by product category",
|
||||
"data": [
|
||||
{ "label": "Enterprise", "value": 540000 },
|
||||
{ "label": "SMB", "value": 310000 },
|
||||
{ "label": "Self-serve", "value": 220000 },
|
||||
{ "label": "Partner", "value": 170000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
### Worked example — "Show me monthly expenses as a bar chart"
|
||||
|
||||
{
|
||||
"root": "expense-bar",
|
||||
"elements": {
|
||||
"expense-bar": {
|
||||
"type": "BarChart",
|
||||
"props": {
|
||||
"title": "Monthly expenses",
|
||||
"description": "Operating expenses by month",
|
||||
"data": [
|
||||
{ "label": "Jul", "value": 210000 },
|
||||
{ "label": "Aug", "value": 225000 },
|
||||
{ "label": "Sep", "value": 240000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Respond with the JSON object only.
|
||||
"""
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(
|
||||
model="gpt-4o-mini",
|
||||
temperature=0.2,
|
||||
model_kwargs={"response_format": {"type": "json_object"}},
|
||||
),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT.strip(),
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""LangGraph agent backing the Chat Customization (CSS) demo.
|
||||
|
||||
The demo is about CSS — the agent has no custom tools or behavior.
|
||||
CopilotKitMiddleware is attached so CopilotKit-specific context is
|
||||
picked up if the frontend ever registers suggestions/components.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt="You are a helpful, concise assistant.",
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""LangGraph agent backing the Frontend Tools (Async) demo.
|
||||
|
||||
This cell demonstrates `useFrontendTool` with an ASYNC handler. The
|
||||
frontend registers a `query_notes` tool whose handler awaits a simulated
|
||||
client-side DB query (500ms latency) and returns matching notes. The
|
||||
agent uses the returned result to summarize what it found.
|
||||
|
||||
Like the sibling `frontend_tools` cell, the backend graph registers no
|
||||
tools of its own — CopilotKit forwards the frontend tool schema(s) to
|
||||
the agent at runtime, and the handler executes in the browser.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant that can search the user's personal notes. "
|
||||
"When the user asks about their notes, call the `query_notes` tool with "
|
||||
"a concise keyword extracted from their request. The tool is provided "
|
||||
"by the frontend at runtime and runs entirely in the user's browser — "
|
||||
"you do not need to implement it yourself. After the tool returns, "
|
||||
"summarize the matching notes clearly and concisely. If no notes match, "
|
||||
"say so plainly and offer to try a different keyword."
|
||||
)
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""LangGraph agent backing the Headless Chat (Complete) demo.
|
||||
|
||||
The cell exists to prove that every CopilotKit rendering surface works
|
||||
when the chat UI is composed manually (no <CopilotChatMessageView /> or
|
||||
<CopilotChatAssistantMessage />). To exercise those surfaces we give
|
||||
this agent:
|
||||
|
||||
- two mock backend tools (get_weather, get_stock_price) — render via
|
||||
app-registered `useRenderTool` renderers on the frontend,
|
||||
- access to a frontend-registered `useComponent` tool
|
||||
(`highlight_note`) — the agent "calls" it and the UI flows through
|
||||
the same `useRenderToolCall` path,
|
||||
- MCP Apps wired through the runtime — the agent can invoke Excalidraw
|
||||
MCP tools and the middleware emits activity events that
|
||||
`useRenderActivityMessage` picks up.
|
||||
|
||||
The system prompt nudges the model toward the right surface per user
|
||||
question and falls back to plain text otherwise.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful, concise assistant wired into a headless chat "
|
||||
"surface that demonstrates CopilotKit's full rendering stack. Pick the "
|
||||
"right surface for each user question and fall back to plain text when "
|
||||
"none of the tools fit.\n\n"
|
||||
"Routing rules:\n"
|
||||
" - If the user asks about weather for a place, call `get_weather` "
|
||||
"with the location.\n"
|
||||
" - If the user asks about a stock or ticker (AAPL, TSLA, MSFT, ...), "
|
||||
"call `get_stock_price` with the ticker.\n"
|
||||
" - If the user asks you to highlight, flag, or mark a short note or "
|
||||
"phrase, call the frontend `highlight_note` tool with the text and a "
|
||||
"color (yellow, pink, green, or blue). Do NOT ask the user for the "
|
||||
"color — pick a sensible one if they didn't say.\n"
|
||||
" - If the user asks to draw, sketch, or diagram something, use the "
|
||||
"Excalidraw MCP tools that are available to you.\n"
|
||||
" - Otherwise, reply in plain text.\n\n"
|
||||
"After a tool returns, write one short sentence summarizing the "
|
||||
"result. Never fabricate data a tool could provide."
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def get_weather(location: str) -> dict:
|
||||
"""Get the current weather for a given location.
|
||||
|
||||
Returns a mock payload with city, temperature in Fahrenheit, humidity,
|
||||
wind speed, and conditions. Use this whenever the user asks about
|
||||
weather anywhere.
|
||||
"""
|
||||
return {
|
||||
"city": location,
|
||||
"temperature": 68,
|
||||
"humidity": 55,
|
||||
"wind_speed": 10,
|
||||
"conditions": "Sunny",
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def get_stock_price(ticker: str) -> dict:
|
||||
"""Get a mock current price for a stock ticker.
|
||||
|
||||
Returns a payload with the ticker symbol (uppercased), price in USD,
|
||||
and percentage change for the day. Use this whenever the user asks
|
||||
about a stock price.
|
||||
"""
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"price_usd": 189.42,
|
||||
"change_pct": 1.27,
|
||||
}
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[get_weather, get_stock_price],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""LangGraph agent backing the In-App HITL (frontend-tool + popup) demo.
|
||||
|
||||
The agent is a support assistant that processes customer-care requests
|
||||
(refunds, account changes, escalations). Any action that materially
|
||||
affects a customer MUST be confirmed by the human operator via the
|
||||
frontend-provided `request_user_approval` tool.
|
||||
|
||||
The tool is defined on the frontend via `useFrontendTool` with an async
|
||||
handler that opens a modal dialog OUTSIDE the chat surface. The handler
|
||||
awaits the user's decision and resolves with
|
||||
`{"approved": bool, "reason": str}`. This agent treats that result as
|
||||
authoritative: if `approved` is `True`, continue; otherwise, stop and
|
||||
explain the decision back to the user.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a support operations copilot working alongside a human operator "
|
||||
"inside an internal support console. The operator can see a list of open "
|
||||
"support tickets on the left side of their screen and is chatting with "
|
||||
"you on the right.\n"
|
||||
"\n"
|
||||
"Whenever the operator asks you to take an action that affects a "
|
||||
"customer — for example: issuing a refund, updating a customer's plan, "
|
||||
"cancelling a subscription, escalating a ticket, or sending an apology "
|
||||
"credit — you MUST first call the frontend-provided "
|
||||
"`request_user_approval` tool to obtain the operator's explicit consent.\n"
|
||||
"\n"
|
||||
"How to use `request_user_approval`:\n"
|
||||
"- `message`: a short, plain-English summary of the exact action you "
|
||||
" are about to take, including concrete numbers (e.g. '$50 refund to "
|
||||
" customer #12345').\n"
|
||||
"- `context`: optional extra context the operator might want to review "
|
||||
" (the ticket ID, the policy rule you're applying, etc.). Keep it to "
|
||||
" one or two short sentences.\n"
|
||||
"\n"
|
||||
"The tool returns an object of the shape "
|
||||
'`{"approved": boolean, "reason": string | null}`.\n'
|
||||
"- If `approved` is `true`: confirm in one short sentence that you are "
|
||||
" processing the action. You do not actually need to call any other "
|
||||
" tool — this is a demo. Just acknowledge.\n"
|
||||
"- If `approved` is `false`: acknowledge the rejection in one short "
|
||||
" sentence and, if `reason` is non-empty, reflect the operator's "
|
||||
" reason back to them. Do NOT retry the action.\n"
|
||||
"\n"
|
||||
"Keep all chat replies to one or two short sentences. Never make up "
|
||||
"customer data — always use whatever the operator told you in the "
|
||||
"prompt."
|
||||
)
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""LangGraph agent backing the In-Chat HITL (useHumanInTheLoop) demo.
|
||||
|
||||
The `book_call` tool is defined on the frontend via `useHumanInTheLoop`,
|
||||
so there is no backend tool here. CopilotKitMiddleware is attached so the
|
||||
frontend suggestions and the time-picker render hook are picked up.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=(
|
||||
"You help users book an onboarding call with the sales team. "
|
||||
"When they ask to book a call, call the frontend-provided "
|
||||
"`book_call` tool with a short topic and the user's name. "
|
||||
"Keep any chat reply to one short sentence."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""LangGraph agent backing the HITL step-selection demo (/demos/hitl).
|
||||
|
||||
Minimal neutral assistant with no backend tools — frontend-registered
|
||||
tools (useHumanInTheLoop's `generate_task_steps`) are injected via
|
||||
CopilotKitMiddleware at runtime. Mirrors the langgraph-python reference's
|
||||
`main.py` (sample_agent) pattern: tools=[], middleware only.
|
||||
|
||||
The heavy `sample_agent` (agent.py) defines 7+ backend tools and a custom
|
||||
AgentState with `todos: list[SalesTodo]`. Routing the HITL step-selection
|
||||
demo through that graph risks state-schema mismatches and tool-dispatch
|
||||
contention when the only tool the demo needs is the frontend-injected
|
||||
`generate_task_steps`. This dedicated graph eliminates that surface area.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt="You are a helpful, concise assistant.",
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""LangGraph agent for the Interrupt-based Generative UI demo.
|
||||
|
||||
Defines a backend tool `schedule_meeting(topic, attendee)` that uses
|
||||
langgraph's `interrupt()` primitive to pause the run and surface the
|
||||
meeting context to the frontend. The frontend `useInterrupt` renderer
|
||||
shows a time picker and resolves with `{chosen_time, chosen_label}` or
|
||||
`{cancelled: true}`, which this tool turns into a human-readable result.
|
||||
"""
|
||||
|
||||
# @region[backend-interrupt-tool]
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.types import interrupt
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a scheduling assistant. Whenever the user asks you to book a "
|
||||
"call / schedule a meeting, you MUST call the `schedule_meeting` tool. "
|
||||
"Pass a short `topic` describing the purpose and `attendee` describing "
|
||||
"who the meeting is with. After the tool returns, confirm briefly "
|
||||
"whether the meeting was scheduled and at what time, or that the user "
|
||||
"cancelled."
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def schedule_meeting(topic: str, attendee: Optional[str] = None) -> str:
|
||||
"""Ask the user to pick a time slot for a call, via an in-chat picker.
|
||||
|
||||
Args:
|
||||
topic: Short human-readable description of the call's purpose.
|
||||
attendee: Who the call is with (optional).
|
||||
|
||||
Returns:
|
||||
Human-readable result string describing the chosen slot or
|
||||
indicating the user cancelled.
|
||||
"""
|
||||
# langgraph's `interrupt()` pauses execution and forwards the payload to
|
||||
# the client. The frontend v2 `useInterrupt` hook renders the picker and
|
||||
# calls `resolve(...)` with the user's selection, which comes back here.
|
||||
response: Any = interrupt({"topic": topic, "attendee": attendee})
|
||||
|
||||
if isinstance(response, dict):
|
||||
if response.get("cancelled"):
|
||||
return f"User cancelled. Meeting NOT scheduled: {topic}"
|
||||
chosen_label = response.get("chosen_label") or response.get("chosen_time")
|
||||
if chosen_label:
|
||||
return f"Meeting scheduled for {chosen_label}: {topic}"
|
||||
|
||||
return f"User did not pick a time. Meeting NOT scheduled: {topic}"
|
||||
|
||||
|
||||
# @endregion[backend-interrupt-tool]
|
||||
|
||||
|
||||
model = ChatOpenAI(model="gpt-4o-mini")
|
||||
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=[schedule_meeting],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
LangGraph agent for the CopilotKit MCP Apps demo.
|
||||
|
||||
This agent has no bespoke tools — the CopilotKit runtime is wired with
|
||||
``mcpApps: { servers: [...] }`` pointing at the public Excalidraw MCP
|
||||
server (see ``src/app/api/copilotkit-mcp-apps/route.ts``). The runtime
|
||||
auto-applies the MCP Apps middleware, which exposes the remote MCP
|
||||
server's tools to this agent at request time and emits the activity
|
||||
events that CopilotKit's built-in ``MCPAppsActivityRenderer`` renders in
|
||||
the chat as a sandboxed iframe.
|
||||
|
||||
Reference:
|
||||
https://docs.copilotkit.ai/integrations/langgraph/generative-ui/mcp-apps
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
You draw simple diagrams in Excalidraw via the MCP tool.
|
||||
|
||||
SPEED MATTERS. Produce a correct-enough diagram fast; do not optimize
|
||||
for polish. Target: one tool call, done in seconds.
|
||||
|
||||
When the user asks for a diagram:
|
||||
1. Call `create_view` ONCE with 3-5 elements total: shapes + arrows +
|
||||
an optional title text.
|
||||
2. Use straightforward shapes (rectangle, ellipse, diamond) with plain
|
||||
`label` fields (`{"text": "...", "fontSize": 18}`) on them.
|
||||
3. Connect with arrows. Endpoints can be element centers or simple
|
||||
coordinates — you don't need edge anchors / fixedPoint bindings.
|
||||
4. Include ONE `cameraUpdate` at the END of the elements array that
|
||||
frames the whole diagram. Use an approved 4:3 size (600x450 or
|
||||
800x600). No opening camera needed.
|
||||
5. Reply with ONE short sentence describing what you drew.
|
||||
|
||||
Every element needs a unique string `id` (e.g. `"b1"`, `"a1"`,
|
||||
`"title"`). Standard sizes: rectangles 160x70, ellipses/diamonds
|
||||
120x80, 40-80px gap between shapes.
|
||||
|
||||
Do NOT:
|
||||
- Call `read_me`. You already know the basic shape API.
|
||||
- Make multiple `create_view` calls.
|
||||
- Iterate or refine. Ship on the first shot.
|
||||
- Add decorative colors / fills / zone backgrounds unless the user
|
||||
explicitly asks for them.
|
||||
- Add labels on arrows unless crucial.
|
||||
|
||||
If the user asks for something specific (colors, more elements,
|
||||
particular layout), follow their lead — but still in ONE call.
|
||||
"""
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
# gpt-4o-mini for speed — Excalidraw element emission is simple
|
||||
# JSON and we're biasing hard toward sub-30s generation. A faster
|
||||
# model produces shorter, quicker outputs with acceptable layouts.
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Multimodal LangGraph agent — accepts image + document (PDF) attachments.
|
||||
|
||||
Wave 2b design: a *dedicated* vision-capable graph scoped to the
|
||||
`/demos/multimodal` cell. Other demos continue to use their own (cheaper,
|
||||
text-only) models — this keeps vision cost isolated to the one demo that
|
||||
exercises it.
|
||||
|
||||
Wire format the agent sees
|
||||
==========================
|
||||
Attachments arrive here after travelling through:
|
||||
|
||||
CopilotChat → AG-UI message content parts → @ag-ui/langgraph runtime
|
||||
(ag-ui → LangChain converter)
|
||||
→ this agent (LangChain HumanMessage content parts)
|
||||
|
||||
The ag-ui-langgraph converter only understands the legacy
|
||||
``{ type: "binary", mimeType, data | url }`` AG-UI part shape — the page
|
||||
at ``src/app/demos/multimodal/page.tsx`` installs an
|
||||
``onRunInitialized`` shim that rewrites the modern
|
||||
``{ type: "image" | "document", source: {...} }`` shape CopilotChat emits
|
||||
to the legacy shape before it hits the runtime. Once the converter has
|
||||
run, every attachment shows up in this agent as a LangChain
|
||||
``image_url`` content part::
|
||||
|
||||
{"type": "image_url", "image_url": {"url": "data:<mime>;base64,<payload>"}}
|
||||
|
||||
regardless of whether the upstream modality was ``image`` or ``document``.
|
||||
|
||||
We therefore route on ``mimeType``, not the part ``type``:
|
||||
``image/*`` parts are forwarded to GPT-4o unchanged (vision-native);
|
||||
``application/pdf`` parts are flattened to inline text via ``pypdf`` so
|
||||
the model can read them without needing file-part support.
|
||||
|
||||
References:
|
||||
- src/agents/main.py, src/agents/agentic_chat.py (baseline pattern)
|
||||
- packages/runtime/src/agent/converters/tanstack.ts (the modern content-
|
||||
part shape — useful context when the runtime gets upgraded and this
|
||||
agent can drop the pypdf flatten)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant. The user may attach images or documents "
|
||||
"(PDFs). When they do, analyze the attachment carefully and answer the "
|
||||
"user's question. If no attachment is present, answer the text question "
|
||||
"normally. Keep responses concise (1-3 sentences) unless asked to go deep."
|
||||
)
|
||||
|
||||
|
||||
def _extract_data_url_parts(url: str) -> tuple[str, str]:
|
||||
"""Split a ``data:<mime>;base64,<payload>`` URL into (mime, base64-payload).
|
||||
|
||||
Returns ("", url) if the input is not a base64 data URL — callers can
|
||||
fall back to treating the url as a fetchable reference.
|
||||
"""
|
||||
if not url.startswith("data:"):
|
||||
return "", url
|
||||
header, _, payload = url.partition(",")
|
||||
# Header looks like "data:application/pdf;base64" — take the piece
|
||||
# between the colon and the first semicolon.
|
||||
if ":" not in header:
|
||||
return "", payload
|
||||
meta = header.split(":", 1)[1]
|
||||
mime = meta.split(";", 1)[0] if ";" in meta else meta
|
||||
return mime, payload
|
||||
|
||||
|
||||
def _extract_pdf_text(b64: str) -> str:
|
||||
"""Decode an inline-base64 PDF and extract its text.
|
||||
|
||||
Returns an empty string if decoding or extraction fails — callers must
|
||||
treat the extracted text as best-effort. Any exception here is logged
|
||||
and swallowed so one malformed attachment does not tank the whole
|
||||
user turn.
|
||||
"""
|
||||
try:
|
||||
raw = base64.b64decode(b64, validate=False)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
print(f"[multimodal_agent] base64 decode failed: {exc}")
|
||||
return ""
|
||||
|
||||
try:
|
||||
# Lazy import — keeps the module importable even if pypdf is missing
|
||||
# at dev-server boot (we only need it when a PDF actually arrives).
|
||||
from pypdf import PdfReader # type: ignore[import-not-found]
|
||||
except ImportError as exc: # pragma: no cover - defensive
|
||||
print(
|
||||
"[multimodal_agent] pypdf not installed — PDF text extraction "
|
||||
f"unavailable: {exc}",
|
||||
)
|
||||
return ""
|
||||
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(raw))
|
||||
pages = [page.extract_text() or "" for page in reader.pages]
|
||||
return "\n\n".join(pages).strip()
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
print(f"[multimodal_agent] pypdf extraction failed: {exc}")
|
||||
return ""
|
||||
|
||||
|
||||
def _classify_attachment_part(part: Any) -> tuple[str, str, str] | None:
|
||||
"""Inspect a content part and return (kind, mime, base64_payload).
|
||||
|
||||
``kind`` is one of ``"image"``, ``"pdf"``, ``"other"``. Returns
|
||||
``None`` if the part is not an attachment we recognise (plain text,
|
||||
unrelated dict, string, etc.).
|
||||
|
||||
Handles the shapes we actually see in practice:
|
||||
|
||||
- ``{"type": "image_url", "image_url": {"url": "data:..."}}``
|
||||
(what the ag-ui-langgraph converter emits for every attachment
|
||||
after the page rewrites to legacy ``binary``).
|
||||
- ``{"type": "image_url", "image_url": "data:..."}``
|
||||
(older LangChain/OpenAI shape where ``image_url`` is a raw string).
|
||||
- ``{"type": "document", "source": {"type": "data",
|
||||
"value": "<base64>", "mimeType": "application/pdf"}}``
|
||||
(modern AG-UI shape — preserved for forward-compat if the runtime
|
||||
ever starts forwarding modern parts directly).
|
||||
"""
|
||||
if not isinstance(part, dict):
|
||||
return None
|
||||
part_type = part.get("type")
|
||||
|
||||
if part_type == "image_url":
|
||||
image_url = part.get("image_url")
|
||||
url: str | None = None
|
||||
if isinstance(image_url, str):
|
||||
url = image_url
|
||||
elif isinstance(image_url, dict):
|
||||
raw_url = image_url.get("url")
|
||||
if isinstance(raw_url, str):
|
||||
url = raw_url
|
||||
if not url:
|
||||
return None
|
||||
mime, payload = _extract_data_url_parts(url)
|
||||
if not payload or not mime:
|
||||
return None
|
||||
if mime.startswith("image/"):
|
||||
return ("image", mime, payload)
|
||||
if "pdf" in mime.lower():
|
||||
return ("pdf", mime, payload)
|
||||
return ("other", mime, payload)
|
||||
|
||||
if part_type == "document":
|
||||
source = part.get("source")
|
||||
if not isinstance(source, dict) or source.get("type") != "data":
|
||||
return None
|
||||
value = source.get("value")
|
||||
mime = source.get("mimeType", "")
|
||||
if not isinstance(value, str) or not isinstance(mime, str):
|
||||
return None
|
||||
if "pdf" in mime.lower():
|
||||
return ("pdf", mime, value)
|
||||
return ("other", mime, value)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _preprocess_part(part: Any) -> Any:
|
||||
"""Flatten PDF attachments to text; pass everything else through.
|
||||
|
||||
Images stay as-is so GPT-4o consumes them natively via its vision
|
||||
adapter. PDFs (which gpt-4o cannot read directly) become a text part
|
||||
prefixed with ``[Attached document]`` and the extracted body. If
|
||||
extraction fails we emit a structured placeholder so the model can
|
||||
tell the user the document was unreadable instead of pretending no
|
||||
attachment was sent.
|
||||
"""
|
||||
classified = _classify_attachment_part(part)
|
||||
if classified is None:
|
||||
return part
|
||||
kind, _mime, payload = classified
|
||||
if kind != "pdf":
|
||||
return part
|
||||
text = _extract_pdf_text(payload)
|
||||
if not text:
|
||||
return {
|
||||
"type": "text",
|
||||
"text": "[Attached document: PDF could not be read.]",
|
||||
}
|
||||
return {"type": "text", "text": f"[Attached document]\n{text}"}
|
||||
|
||||
|
||||
def _rewrite_messages(messages: list[Any]) -> list[Any]:
|
||||
"""Rewrite user messages so non-image attachments become text parts.
|
||||
|
||||
Operates on the messages list stored in agent state. Returns a *new*
|
||||
list; the input list is not mutated.
|
||||
"""
|
||||
rewritten: list[Any] = []
|
||||
for message in messages:
|
||||
# Only touch HumanMessage — assistant/tool messages stay as-is.
|
||||
if not isinstance(message, HumanMessage):
|
||||
rewritten.append(message)
|
||||
continue
|
||||
content = message.content
|
||||
if not isinstance(content, list):
|
||||
rewritten.append(message)
|
||||
continue
|
||||
new_parts = [_preprocess_part(part) for part in content]
|
||||
rewritten.append(HumanMessage(content=new_parts, id=message.id))
|
||||
return rewritten
|
||||
|
||||
|
||||
class _PdfFlattenMiddleware(AgentMiddleware):
|
||||
"""Flatten PDF content parts to text before the model call.
|
||||
|
||||
We run this in ``before_model`` so every model invocation — including
|
||||
retries after tool calls — sees the flattened view. The middleware is
|
||||
idempotent: once a part has been rewritten to ``{"type": "text", ...}``
|
||||
it is returned unchanged on subsequent passes.
|
||||
"""
|
||||
|
||||
def before_model(self, state, runtime): # type: ignore[override]
|
||||
del runtime # unused
|
||||
messages = state.get("messages") if isinstance(state, dict) else None
|
||||
if not messages:
|
||||
return None
|
||||
rewritten = _rewrite_messages(messages)
|
||||
# Only emit a patch if anything actually changed — avoids a
|
||||
# superfluous state update on every model hop.
|
||||
if rewritten == messages:
|
||||
return None
|
||||
return {"messages": rewritten}
|
||||
|
||||
|
||||
# Vision-capable model. gpt-4o consumes `image_url` content parts natively.
|
||||
_MODEL = ChatOpenAI(model="gpt-4o", temperature=0.2)
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=_MODEL,
|
||||
tools=[],
|
||||
middleware=[_PdfFlattenMiddleware(), CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
|
||||
# Re-export under both names — `graph` matches the langgraph.json convention
|
||||
# used by the rest of the package; `multimodal_agent` is a friendlier alias
|
||||
# for any future non-langgraph.json import paths.
|
||||
multimodal_agent = graph
|
||||
|
||||
__all__ = ["graph", "multimodal_agent"]
|
||||
@@ -0,0 +1,90 @@
|
||||
"""LangGraph agent for the Open-Ended Generative UI (Advanced) demo.
|
||||
|
||||
This is the "advanced" variant of the Open Generative UI demo. The key
|
||||
distinguishing feature: the agent-authored, sandboxed UI can invoke
|
||||
frontend-registered **sandbox functions** — functions the app defines on
|
||||
the host page (see `src/app/demos/open-gen-ui-advanced/sandbox-functions.ts`)
|
||||
and makes callable from inside the iframe via
|
||||
`await Websandbox.connection.remote.<name>(args)`.
|
||||
|
||||
How it works end-to-end:
|
||||
- The frontend passes `openGenerativeUI={{ sandboxFunctions }}` to the
|
||||
`CopilotKitProvider`. The provider injects a JSON descriptor of those
|
||||
functions into the agent context.
|
||||
- `CopilotKitMiddleware` here picks up both the frontend-registered
|
||||
`generateSandboxedUi` tool (auto-registered by the provider when OGUI
|
||||
is enabled on the runtime) AND the sandbox-function descriptors (via
|
||||
`copilotkit.context`), and merges them into what the LLM sees.
|
||||
- The LLM then generates HTML + JS that calls
|
||||
`Websandbox.connection.remote.<name>(...)` in response to user
|
||||
interactions.
|
||||
- The runtime's `OpenGenerativeUIMiddleware` converts the streaming
|
||||
`generateSandboxedUi` tool call into `open-generative-ui` activity
|
||||
events that the built-in renderer mounts inside a sandboxed iframe.
|
||||
- The renderer wires each `sandboxFunctions` entry as a `localApi`
|
||||
method on the websandbox connection so in-iframe code can call it.
|
||||
|
||||
The "minimal" sibling (`open_gen_ui_agent.py`) uses the same OGUI
|
||||
pipeline without sandbox functions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are a UI-generating assistant for the Open Generative UI (Advanced) demo.
|
||||
|
||||
On every user turn you MUST call the `generateSandboxedUi` frontend tool
|
||||
exactly once. The generated UI must be INTERACTIVE and must invoke the
|
||||
available host-side sandbox functions described in your agent context
|
||||
(delivered via `copilotkit.context`) in response to user interactions.
|
||||
|
||||
Sandbox-function calling contract (inside the generated iframe):
|
||||
- Call a host function with:
|
||||
await Websandbox.connection.remote.<functionName>(args)
|
||||
The call returns a Promise; await it.
|
||||
- Each handler returns a plain object. Read the return shape from the
|
||||
function's description in your context and use the EXACT field names
|
||||
it returns (e.g. if the description says the handler returns
|
||||
`{ ok, value }`, read `res.value` — not `res.result`).
|
||||
- Descriptions, names, and JSON-schema parameter shapes for every
|
||||
available sandbox function are listed in your context. Read them
|
||||
carefully and wire at least one interactive UI element to call one.
|
||||
|
||||
Sandbox iframe restrictions (CRITICAL):
|
||||
- The iframe runs with `sandbox="allow-scripts"` ONLY. Forms are NOT
|
||||
allowed. You MUST NOT use `<form>` elements or `<button type="submit">`.
|
||||
Clicking a submit button inside a sandboxed form is blocked by the
|
||||
browser BEFORE any onsubmit handler runs, so the sandbox-function call
|
||||
never fires.
|
||||
- Use plain `<button type="button">` elements and wire them with
|
||||
`addEventListener('click', ...)` or an inline click handler. Do the same
|
||||
for "Enter" keypresses on inputs: attach a `keydown` listener that
|
||||
checks `e.key === 'Enter'` and calls your handler directly — do NOT
|
||||
wrap inputs in a `<form>`.
|
||||
|
||||
Generation guidance:
|
||||
- Emit `initialHeight` and `placeholderMessages` first, then CSS, then
|
||||
HTML, then `jsFunctions` / `jsExpressions` if helpful.
|
||||
- Always include a visible result element (e.g. an output div) that you
|
||||
UPDATE after the sandbox function resolves, so the user can *see* the
|
||||
round-trip: "Button clicked -> remote call -> visible result".
|
||||
- Use CDN scripts (Chart.js, D3, etc.) via <script> tags in the HTML head
|
||||
when you need libraries.
|
||||
- Do NOT use fetch/XHR, localStorage, or document.cookie — the sandbox has
|
||||
no same-origin access. ONLY use `Websandbox.connection.remote.*` for
|
||||
host-page interactions.
|
||||
- Keep your own chat message brief (1 sentence max); the rendered UI is
|
||||
the real output.
|
||||
"""
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4.1", model_kwargs={"parallel_tool_calls": False}),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Minimal LangGraph agent for the Open-Ended Generative UI demo.
|
||||
|
||||
The simplest possible example that exercises the open-ended generative UI
|
||||
pipeline. All the interesting work happens outside the agent:
|
||||
|
||||
- `CopilotKitMiddleware` merges the frontend-registered `generateSandboxedUi`
|
||||
tool (auto-registered by `CopilotKitProvider` when the runtime has
|
||||
`openGenerativeUI` enabled) into the agent's tool list. The LLM then sees
|
||||
the tool via the normal AG-UI flow.
|
||||
- When the LLM calls `generateSandboxedUi`, the runtime's
|
||||
`OpenGenerativeUIMiddleware` (enabled via `openGenerativeUI` on the
|
||||
runtime — see `src/app/api/copilotkit-ogui/route.ts`) converts that
|
||||
streaming tool call into `open-generative-ui` activity events that the
|
||||
built-in renderer mounts inside a sandboxed iframe.
|
||||
|
||||
This is the minimal variant: no sandbox functions, no app-side tools. The
|
||||
agent simply asks the LLM to design and emit a single-shot sandboxed UI.
|
||||
The "advanced" sibling (`open_gen_ui_advanced_agent.py`) builds on this
|
||||
with sandbox-to-host function calling via `openGenerativeUI.sandboxFunctions`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are a UI-generating assistant for an Open Generative UI demo
|
||||
focused on intricate, educational visualisations (3D axes / rotations,
|
||||
neural-network activations, sorting-algorithm walkthroughs, Fourier
|
||||
series, wave interference, planetary orbits, etc.).
|
||||
|
||||
On every user turn you MUST call the `generateSandboxedUi` frontend tool
|
||||
exactly once. Design a visually polished, self-contained HTML + CSS +
|
||||
SVG widget that *teaches* the requested concept.
|
||||
|
||||
The frontend injects a detailed "design skill" as agent context
|
||||
describing the palette, typography, labelling, and motion conventions
|
||||
expected — follow it closely. Key invariants:
|
||||
- Use inline SVG (or <canvas>) for geometric content, not stacks of <div>s.
|
||||
- Every axis is labelled; every colour-coded series has a legend.
|
||||
- Prefer CSS @keyframes / transitions over setInterval; loop cyclical
|
||||
concepts with animation-iteration-count: infinite.
|
||||
- Motion must teach — animate the actual step of the concept, not decoration.
|
||||
- No fetch / XHR / localStorage — the sandbox has no same-origin access.
|
||||
|
||||
Output order:
|
||||
- `initialHeight` (typically 480-560 for visualisations) first.
|
||||
- A short `placeholderMessages` array (2-3 lines describing the build).
|
||||
- `css` (complete).
|
||||
- `html` (streams live — keep it tidy). CDN <script> tags for Chart.js /
|
||||
D3 / etc. go inside the html.
|
||||
|
||||
Keep your own chat message brief (1 sentence) — the real output is the
|
||||
rendered visualisation.
|
||||
"""
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4.1", model_kwargs={"parallel_tool_calls": False}),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
"""LangGraph agent backing the Shared State (Agent Read-Only) demo.
|
||||
|
||||
Demonstrates the `useAgentContext` hook from @copilotkit/react-core/v2:
|
||||
the frontend provides READ-ONLY context *to* the agent. This is the
|
||||
reverse direction of writable-shared-state — the UI cannot be edited by
|
||||
the agent, but the agent reads this context on every turn via
|
||||
`CopilotKitMiddleware`, which routes the context entries into the
|
||||
model's message history.
|
||||
|
||||
No custom state, no tools: this is the minimal shape of the
|
||||
useAgentContext pattern. The agent just reads whatever context the
|
||||
frontend registered and answers accordingly.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=(
|
||||
"You are a helpful, concise assistant. The frontend may provide "
|
||||
"read-only context about the user (e.g. name, timezone, recent "
|
||||
"activity) via the `useAgentContext` hook. Always consult that "
|
||||
"context when it is relevant — address the user by name if known, "
|
||||
"respect their timezone when mentioning times, and reference "
|
||||
"recent activity when it helps you answer. Keep responses short."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Reasoning agent — emits AG-UI REASONING_MESSAGE_* events.
|
||||
|
||||
Shared by agentic-chat-reasoning (custom amber ReasoningBlock) and
|
||||
reasoning-default-render (CopilotKit's built-in reasoning slot).
|
||||
|
||||
Why a reasoning model + Responses API:
|
||||
The OpenAI Responses API streams `response.reasoning_summary_text.delta`
|
||||
items only for native reasoning models (gpt-5, o3, o4-mini, etc.).
|
||||
CopilotKit's bridge translates those into AG-UI REASONING_MESSAGE_*
|
||||
events with `role: "reasoning"`, which the frontend renders via the
|
||||
`reasoningMessage` slot. gpt-4o / gpt-4o-mini do not emit reasoning
|
||||
items, so a non-reasoning model would never light up the slot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from deepagents import create_deep_agent
|
||||
from langchain.chat_models import init_chat_model
|
||||
|
||||
from src.agents.src._header_forwarding_middleware import HeaderForwardingMiddleware
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant. For each user question, first think "
|
||||
"step-by-step about the approach, then give a concise answer."
|
||||
)
|
||||
|
||||
REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-5-mini")
|
||||
|
||||
# No full CopilotKitMiddleware — this demo exercises only reasoning-token
|
||||
# streaming through the OpenAI Responses API and doesn't consume frontend
|
||||
# tools or app context. We still attach the minimal HeaderForwardingMiddleware
|
||||
# so the inbound ``x-aimock-context`` (and other ``x-*``) headers reach the
|
||||
# outgoing /v1/responses call; without it the LangGraph run swallows them
|
||||
# inside ``configurable`` and aimock 404s with no fixture match. The minimal
|
||||
# middleware does ONLY header propagation — no App-Context injection, no
|
||||
# tool-merging, no state-surfacing. Mirrors langgraph-python's reasoning agent.
|
||||
graph = create_deep_agent(
|
||||
model=init_chat_model(
|
||||
f"openai:{REASONING_MODEL}",
|
||||
use_responses_api=True,
|
||||
reasoning={"effort": "low", "summary": "auto"},
|
||||
),
|
||||
tools=[],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
middleware=[HeaderForwardingMiddleware()],
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""LangGraph (FastAPI) agent for the A2UI Error Recovery demo (OSS-158 / OSS-375).
|
||||
|
||||
Same dynamic-schema A2UI setup as `a2ui_dynamic.py` (declarative-gen-ui), but
|
||||
with the toolkit's validate->retry recovery loop made *visible*. The two aimock
|
||||
pills drive the inner `render_a2ui` sub-agent two ways:
|
||||
|
||||
- HEAL pill: the model emits FREE-FORM / sloppy A2UI args (components and data
|
||||
as JSON strings rather than structured arrays) — the toolkit heals them via
|
||||
`parse_and_fix` into a valid surface in a single pass, which paints.
|
||||
- EXHAUST pill: every attempt is structurally invalid (the root references a
|
||||
missing child), so the validate->retry loop hits the cap and the tool
|
||||
returns the `a2ui_recovery_exhausted` hard-fail envelope, which the renderer
|
||||
surfaces as a tasteful `failed` state (no broken surface).
|
||||
|
||||
Backend-owned wiring: unlike the declarative-gen-ui demo (which relies on the
|
||||
CopilotKit runtime auto-injecting `generate_a2ui`), this agent OWNS the tool via
|
||||
`ag_ui_langgraph.get_a2ui_tools`, whose body runs the `render_a2ui` sub-agent +
|
||||
the toolkit recovery loop IN-GRAPH. The dedicated route sets
|
||||
`injectA2UITool: false` so the runtime does not inject a second copy.
|
||||
|
||||
Mirrors `showcase/integrations/langgraph-python/src/agents/recovery_agent.py`.
|
||||
Catalog is reused from declarative-gen-ui ("declarative-gen-ui-catalog"); the
|
||||
Vantage Threads sales dataset + composition rules arrive from the frontend via
|
||||
App Context (declarative-gen-ui/sales-context.ts).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from ag_ui_langgraph import get_a2ui_tools
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _log_attempt(record: dict) -> None:
|
||||
"""Dev observability: log each recovery attempt (incl. rejected ones)."""
|
||||
logger.info(
|
||||
"[a2ui recovery] attempt %s: %s %s",
|
||||
record.get("attempt"),
|
||||
"valid" if record.get("ok") else "invalid",
|
||||
record.get("errors"),
|
||||
)
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are the embedded sales analyst for Vantage Threads, the fictional "
|
||||
"B2B apparel company described in your App Context. Answer every business "
|
||||
"question by calling `generate_a2ui` to draw a rich visual surface, and "
|
||||
"keep the chat reply to one short sentence. Ground every number in the "
|
||||
"sales dataset from your App Context. `generate_a2ui` handles the "
|
||||
"rendering — and its automatic recovery — for you."
|
||||
)
|
||||
|
||||
_MODEL = "gpt-4.1"
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model=_MODEL),
|
||||
tools=[
|
||||
get_a2ui_tools(
|
||||
{
|
||||
"model": ChatOpenAI(model=_MODEL),
|
||||
"default_catalog_id": "declarative-gen-ui-catalog",
|
||||
"recovery": {"maxAttempts": 3},
|
||||
"on_a2ui_attempt": _log_attempt,
|
||||
}
|
||||
)
|
||||
],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""LangGraph agent backing the Shared State (Read + Write) demo.
|
||||
|
||||
Demonstrates the full bidirectional shared-state pattern between UI and
|
||||
agent:
|
||||
|
||||
- **UI -> agent (write)**: The UI owns a `preferences` object (the user's
|
||||
profile) that it writes into agent state via `agent.setState(...)`. A
|
||||
middleware reads those preferences every turn and injects them into
|
||||
the system prompt, so the LLM adapts accordingly.
|
||||
- **agent -> UI (read)**: The agent can call `set_notes` to update a
|
||||
`notes` slot in shared state. The UI reflects every update in real
|
||||
time via `useAgent(...)`.
|
||||
|
||||
Together this shows the canonical LangGraph bidirectional shared
|
||||
state: frontend writes, backend reads AND writes, frontend re-renders.
|
||||
|
||||
This is the FastAPI variant — the graph is exported and registered in
|
||||
`langgraph.json`. The FastAPI server (langgraph-cli) hosts the graph;
|
||||
the Next.js runtime route bridges the CopilotKit AG-UI protocol to the
|
||||
LangGraph deployment.
|
||||
"""
|
||||
|
||||
from typing import Any, Awaitable, Callable, TypedDict
|
||||
|
||||
from langchain.agents import AgentState as BaseAgentState, create_agent
|
||||
from langchain.agents.middleware import (
|
||||
AgentMiddleware,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
)
|
||||
from langchain.tools import ToolRuntime, tool
|
||||
from langchain_core.messages import SystemMessage, ToolMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.types import Command
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
class Preferences(TypedDict, total=False):
|
||||
name: str
|
||||
tone: str # "formal" | "casual" | "playful"
|
||||
language: str # "English", "Spanish", ...
|
||||
interests: list[str]
|
||||
|
||||
|
||||
class AgentState(BaseAgentState):
|
||||
"""Bidirectional shared state between UI and agent.
|
||||
|
||||
- `preferences` is written by the UI (via agent.setState).
|
||||
- `notes` is written by the agent (via the `set_notes` tool) and
|
||||
read by the UI.
|
||||
"""
|
||||
|
||||
preferences: Preferences
|
||||
notes: list[str]
|
||||
|
||||
|
||||
@tool
|
||||
def set_notes(notes: list[str], runtime: ToolRuntime) -> Command:
|
||||
"""Replace the notes array in shared state with the full updated list.
|
||||
|
||||
Use this tool whenever the user asks you to "remember" something, or
|
||||
when you have an observation about the user worth surfacing in the
|
||||
UI's notes panel. Always pass the FULL notes list (existing notes +
|
||||
any new ones), not a diff. Keep each note short (< 120 chars).
|
||||
"""
|
||||
return Command(
|
||||
update={
|
||||
"notes": notes,
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
content="Notes updated.",
|
||||
tool_call_id=runtime.tool_call_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PreferencesInjectorMiddleware(AgentMiddleware[AgentState, Any]):
|
||||
"""Injects the UI-supplied `preferences` into the system prompt.
|
||||
|
||||
Every turn, we read the latest `preferences` from agent state and
|
||||
prepend a SystemMessage that tells the LLM about them. This is how
|
||||
UI-written state becomes visible to the agent.
|
||||
"""
|
||||
|
||||
state_schema = AgentState
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "PreferencesInjectorMiddleware"
|
||||
|
||||
def _build_prefs_message(self, prefs: Preferences) -> SystemMessage | None:
|
||||
if not prefs:
|
||||
return None
|
||||
lines = ["The user has shared these preferences with you:"]
|
||||
if prefs.get("name"):
|
||||
lines.append(f"- Name: {prefs['name']}")
|
||||
if prefs.get("tone"):
|
||||
lines.append(f"- Preferred tone: {prefs['tone']}")
|
||||
if prefs.get("language"):
|
||||
lines.append(f"- Preferred language: {prefs['language']}")
|
||||
interests = prefs.get("interests") or []
|
||||
if interests:
|
||||
lines.append(f"- Interests: {', '.join(interests)}")
|
||||
# If `prefs` is non-empty but contains only unknown keys or empty
|
||||
# values, no payload lines were appended — emitting just the
|
||||
# header + trailer would lie to the agent ("preferences exist")
|
||||
# when in fact none do. Bail out instead.
|
||||
if len(lines) == 1:
|
||||
return None
|
||||
lines.append(
|
||||
"Tailor every response to these preferences. Address the user "
|
||||
"by name when appropriate."
|
||||
)
|
||||
return SystemMessage(content="\n".join(lines))
|
||||
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelResponse:
|
||||
prefs = request.state.get("preferences") or {}
|
||||
prefs_message = self._build_prefs_message(prefs)
|
||||
if prefs_message is None:
|
||||
return handler(request)
|
||||
return handler(request.override(messages=[prefs_message, *request.messages]))
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelResponse:
|
||||
prefs = request.state.get("preferences") or {}
|
||||
prefs_message = self._build_prefs_message(prefs)
|
||||
if prefs_message is None:
|
||||
return await handler(request)
|
||||
return await handler(
|
||||
request.override(messages=[prefs_message, *request.messages])
|
||||
)
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[set_notes],
|
||||
middleware=[CopilotKitMiddleware(), PreferencesInjectorMiddleware()],
|
||||
state_schema=AgentState,
|
||||
system_prompt=(
|
||||
"You are a helpful, concise assistant. "
|
||||
"The user's preferences are supplied via shared state and will be "
|
||||
"added as a system message at the start of every turn. Always "
|
||||
"respect them. "
|
||||
"When the user asks you to remember something, or when you observe "
|
||||
"something worth surfacing in the UI, call `set_notes` with the "
|
||||
"FULL updated list of short note strings (existing notes + new)."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""LangGraph agent backing the Sub-Agents demo.
|
||||
|
||||
Demonstrates multi-agent delegation with a visible delegation log.
|
||||
|
||||
A top-level "supervisor" LLM orchestrates three specialized sub-agents,
|
||||
exposed as tools:
|
||||
|
||||
- `research_agent` — gathers facts
|
||||
- `writing_agent` — drafts prose
|
||||
- `critique_agent` — reviews drafts
|
||||
|
||||
Each sub-agent is a full `create_agent(...)` under the hood. Every
|
||||
delegation appends an entry to the `delegations` slot in shared agent
|
||||
state so the UI can render a live "delegation log" as the supervisor
|
||||
fans work out and collects results. This is the canonical LangGraph
|
||||
sub-agents-as-tools pattern, adapted to surface delegation events to
|
||||
the frontend via CopilotKit's shared-state channel.
|
||||
|
||||
This is the FastAPI variant — the graph is exported and registered in
|
||||
`langgraph.json`. Identical agent topology to the langgraph-python
|
||||
reference; only the server framework differs.
|
||||
"""
|
||||
|
||||
# @region[supervisor-delegation-tools]
|
||||
# @region[subagent-setup]
|
||||
import uuid
|
||||
from operator import add
|
||||
from typing import Annotated, Literal, TypedDict
|
||||
|
||||
from langchain.agents import AgentState as BaseAgentState, create_agent
|
||||
from langchain.tools import ToolRuntime, tool
|
||||
from langchain_core.messages import HumanMessage, ToolMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.types import Command
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Delegation(TypedDict):
|
||||
id: str
|
||||
sub_agent: Literal["research_agent", "writing_agent", "critique_agent"]
|
||||
task: str
|
||||
status: Literal["running", "completed", "failed"]
|
||||
result: str
|
||||
|
||||
|
||||
class AgentState(BaseAgentState):
|
||||
"""Shared state. `delegations` is rendered as a live log in the UI.
|
||||
|
||||
`delegations` uses `operator.add` as its channel reducer so concurrent
|
||||
tool calls within a single supervisor turn each contribute their own
|
||||
entry. Without a reducer, parallel `tool_calls` would each read the
|
||||
same snapshot and the channel would last-write-wins, silently dropping
|
||||
every delegation but one from the UI log.
|
||||
"""
|
||||
|
||||
delegations: Annotated[list[Delegation], add]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sub-agents (real LLM agents under the hood)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Each sub-agent is a full-fledged `create_agent(...)` with its own
|
||||
# system prompt. They don't share memory or tools with the supervisor —
|
||||
# the supervisor only sees their return value.
|
||||
_sub_model = ChatOpenAI(model="gpt-4o-mini")
|
||||
|
||||
_research_agent = create_agent(
|
||||
model=_sub_model,
|
||||
tools=[],
|
||||
system_prompt=(
|
||||
"You are a research sub-agent. Given a topic, produce a concise "
|
||||
"bulleted list of 3-5 key facts. No preamble, no closing."
|
||||
),
|
||||
)
|
||||
|
||||
_writing_agent = create_agent(
|
||||
model=_sub_model,
|
||||
tools=[],
|
||||
system_prompt=(
|
||||
"You are a writing sub-agent. Given a brief and optional source "
|
||||
"facts, produce a polished 1-paragraph draft. Be clear and "
|
||||
"concrete. No preamble."
|
||||
),
|
||||
)
|
||||
|
||||
_critique_agent = create_agent(
|
||||
model=_sub_model,
|
||||
tools=[],
|
||||
system_prompt=(
|
||||
"You are an editorial critique sub-agent. Given a draft, give "
|
||||
"2-3 crisp, actionable critiques. No preamble."
|
||||
),
|
||||
)
|
||||
# @endregion[subagent-setup]
|
||||
|
||||
|
||||
def _invoke_sub_agent(agent, task: str) -> str:
|
||||
"""Run a sub-agent on `task` and return its final message content."""
|
||||
result = agent.invoke({"messages": [HumanMessage(content=task)]})
|
||||
messages = result.get("messages", [])
|
||||
if not messages:
|
||||
return ""
|
||||
return str(messages[-1].content)
|
||||
|
||||
|
||||
def _delegation_command(
|
||||
sub_agent: str,
|
||||
task: str,
|
||||
status: Literal["completed", "failed"],
|
||||
result: str,
|
||||
tool_call_id: str,
|
||||
) -> Command:
|
||||
"""Build a Command that appends a single new delegation entry.
|
||||
|
||||
Emits ONLY the new entry under `delegations`. The channel reducer
|
||||
(`operator.add` on `AgentState.delegations`) extends the existing
|
||||
list, so parallel tool calls within one supervisor turn each
|
||||
contribute their own entry instead of clobbering each other via a
|
||||
last-write-wins read-modify-write.
|
||||
"""
|
||||
entry: Delegation = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"sub_agent": sub_agent, # type: ignore[typeddict-item]
|
||||
"task": task,
|
||||
"status": status,
|
||||
"result": result,
|
||||
}
|
||||
return Command(
|
||||
update={
|
||||
"delegations": [entry],
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
content=result,
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _delegate(
|
||||
sub_agent_name: str,
|
||||
agent,
|
||||
task: str,
|
||||
tool_call_id: str,
|
||||
) -> Command:
|
||||
"""Invoke a sub-agent and turn the outcome into a Command.
|
||||
|
||||
Wrapped in try/except so that a sub-agent LLM failure (rate limit,
|
||||
transport error, missing API key, etc.) is recorded as a `failed`
|
||||
delegation entry and surfaced to the supervisor as a ToolMessage,
|
||||
instead of propagating and crashing the supervisor turn. The
|
||||
user-facing `result` is scrubbed to the exception class name only;
|
||||
full details are captured server-side via the standard logging path
|
||||
when the exception is re-raised at the caller's discretion (we do
|
||||
not re-raise here — recovery is the supervisor's job).
|
||||
"""
|
||||
try:
|
||||
result = _invoke_sub_agent(agent, task)
|
||||
return _delegation_command(
|
||||
sub_agent_name, task, "completed", result, tool_call_id
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - intentional broad catch
|
||||
# Keep the message generic; class name only, no exception args
|
||||
# (which can contain prompts, keys, or other sensitive data).
|
||||
message = (
|
||||
f"sub-agent call failed: {exc.__class__.__name__} "
|
||||
f"(see server logs for details)"
|
||||
)
|
||||
return _delegation_command(
|
||||
sub_agent_name, task, "failed", message, tool_call_id
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supervisor tools (each tool delegates to one sub-agent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Each @tool wraps a sub-agent invocation. The supervisor LLM "calls"
|
||||
# these tools to delegate work; each call synchronously runs the
|
||||
# matching sub-agent, records the delegation into shared state, and
|
||||
# returns the sub-agent's output as a ToolMessage the supervisor can
|
||||
# read on its next step.
|
||||
@tool
|
||||
def research_agent(task: str, runtime: ToolRuntime) -> Command:
|
||||
"""Delegate a research task to the research sub-agent.
|
||||
|
||||
Use for: gathering facts, background, definitions, statistics.
|
||||
Returns a bulleted list of key facts.
|
||||
"""
|
||||
return _delegate("research_agent", _research_agent, task, runtime.tool_call_id)
|
||||
|
||||
|
||||
@tool
|
||||
def writing_agent(task: str, runtime: ToolRuntime) -> Command:
|
||||
"""Delegate a drafting task to the writing sub-agent.
|
||||
|
||||
Use for: producing a polished paragraph, draft, or summary. Pass
|
||||
relevant facts from prior research inside `task`.
|
||||
"""
|
||||
return _delegate("writing_agent", _writing_agent, task, runtime.tool_call_id)
|
||||
|
||||
|
||||
@tool
|
||||
def critique_agent(task: str, runtime: ToolRuntime) -> Command:
|
||||
"""Delegate a critique task to the critique sub-agent.
|
||||
|
||||
Use for: reviewing a draft and suggesting concrete improvements.
|
||||
"""
|
||||
return _delegate("critique_agent", _critique_agent, task, runtime.tool_call_id)
|
||||
|
||||
|
||||
# @endregion[supervisor-delegation-tools]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supervisor (the graph we export)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"),
|
||||
tools=[research_agent, writing_agent, critique_agent],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
state_schema=AgentState,
|
||||
system_prompt=(
|
||||
"You are a supervisor agent that coordinates three specialized "
|
||||
"sub-agents to produce high-quality deliverables.\n\n"
|
||||
"Available sub-agents (call them as tools):\n"
|
||||
" - research_agent: gathers facts on a topic.\n"
|
||||
" - writing_agent: turns facts + a brief into a polished draft.\n"
|
||||
" - critique_agent: reviews a draft and suggests improvements.\n\n"
|
||||
"For most non-trivial user requests, delegate in sequence: "
|
||||
"research -> write -> critique. Pass the relevant facts/draft "
|
||||
"through the `task` argument of each tool. Keep your own "
|
||||
"messages short — explain the plan once, delegate, then return "
|
||||
"a concise summary once done. The UI shows the user a live log "
|
||||
"of every sub-agent delegation."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
LangGraph agent for the CopilotKit Tool Rendering demos.
|
||||
|
||||
Backs the three tool-rendering cells:
|
||||
- tool-rendering-default-catchall (no frontend renderers)
|
||||
- tool-rendering-custom-catchall (wildcard renderer on frontend)
|
||||
- tool-rendering (per-tool + catch-all on frontend)
|
||||
- tool-rendering-reasoning-chain (testing — also streams reasoning)
|
||||
|
||||
All cells share this backend — they differ only in how the frontend
|
||||
renders the same tool calls. Kept separate from `agent.py` so the
|
||||
tool-rendering demo has a tightly-scoped tool set.
|
||||
"""
|
||||
|
||||
# @region[weather-tool-backend]
|
||||
from random import choice, randint
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
# Multi-tool chaining prompt.
|
||||
#
|
||||
# The goal of this demo is to surface MULTIPLE tool-call cards per turn so
|
||||
# the rendering patterns (per-tool + catch-all) get exercised visibly. The
|
||||
# prompt nudges the model toward an explore-then-enrich pattern (e.g.
|
||||
# `get_weather("Tokyo")` -> `search_flights(..., "Tokyo")`) without forcing
|
||||
# a rigid recipe: we describe the *habit*, not a chain.
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful travel & lifestyle concierge. You have mock tools "
|
||||
"for weather, flights, stock prices, and dice rolls - they all return "
|
||||
"fake data, so call them liberally.\n\n"
|
||||
"Your habit is to CHAIN tools when one answer naturally invites another. "
|
||||
"For a single user question, call at least TWO tools in succession when "
|
||||
"the topic allows before composing your final reply. Examples of "
|
||||
"helpful chains you should default to:\n"
|
||||
" - 'What's the weather in Tokyo?' -> call get_weather('Tokyo'), then "
|
||||
"call search_flights(origin='SFO', destination='Tokyo') so the user "
|
||||
"also sees how to get there.\n"
|
||||
" - 'How is AAPL doing?' -> call get_stock_price('AAPL'), then call "
|
||||
"get_stock_price on a related ticker (e.g. 'MSFT' or 'GOOGL') for "
|
||||
"comparison.\n"
|
||||
" - 'Roll a d20' -> call roll_dice(20), then call roll_dice again with "
|
||||
"a different number of sides so the user sees a contrast.\n"
|
||||
" - 'Find flights from SFO to JFK' -> call search_flights, then call "
|
||||
"get_weather on the destination city.\n\n"
|
||||
"Only skip chaining when the user has clearly asked for a single, "
|
||||
"atomic answer and more tool calls would feel intrusive. Never "
|
||||
"fabricate data that a tool could provide."
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def get_weather(location: str) -> dict:
|
||||
"""Get the current weather for a given location.
|
||||
|
||||
Useful on its own for weather questions, and a great companion to
|
||||
`search_flights` - always consider checking the weather at a
|
||||
destination the user is flying to, and checking flights to any
|
||||
city whose weather the user has just asked about.
|
||||
"""
|
||||
return {
|
||||
"city": location,
|
||||
"temperature": 68,
|
||||
"humidity": 55,
|
||||
"wind_speed": 10,
|
||||
"conditions": "Sunny",
|
||||
}
|
||||
|
||||
|
||||
# @endregion[weather-tool-backend]
|
||||
|
||||
|
||||
@tool
|
||||
def search_flights(origin: str, destination: str) -> dict:
|
||||
"""Search mock flights from an origin airport to a destination airport.
|
||||
|
||||
Pairs naturally with `get_weather`: after searching flights, check
|
||||
the weather at the destination so the user can plan. When the user
|
||||
mentions a city without a matching origin, default the origin to
|
||||
'SFO'.
|
||||
"""
|
||||
return {
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"flights": [
|
||||
{
|
||||
"airline": "United",
|
||||
"flight": "UA231",
|
||||
"depart": "08:15",
|
||||
"arrive": "16:45",
|
||||
"price_usd": 348,
|
||||
},
|
||||
{
|
||||
"airline": "Delta",
|
||||
"flight": "DL412",
|
||||
"depart": "11:20",
|
||||
"arrive": "19:55",
|
||||
"price_usd": 312,
|
||||
},
|
||||
{
|
||||
"airline": "JetBlue",
|
||||
"flight": "B6722",
|
||||
"depart": "17:05",
|
||||
"arrive": "01:30",
|
||||
"price_usd": 289,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def get_stock_price(ticker: str) -> dict:
|
||||
"""Get a mock current price for a stock ticker.
|
||||
|
||||
When the user asks about a single ticker, consider also pulling a
|
||||
related ticker for context (e.g. if they ask about 'AAPL', also
|
||||
fetch 'MSFT' or 'GOOGL' so the reply can compare).
|
||||
"""
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"price_usd": round(100 + randint(0, 400) + randint(0, 99) / 100, 2),
|
||||
"change_pct": round(choice([-1, 1]) * (randint(0, 300) / 100), 2),
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def roll_dice(sides: int = 6) -> dict:
|
||||
"""Roll a single die with the given number of sides.
|
||||
|
||||
When the user asks for a roll, consider rolling twice with different
|
||||
numbers of sides so the reply can show a contrast (e.g. a d6 AND a
|
||||
d20).
|
||||
"""
|
||||
return {"sides": sides, "result": randint(1, max(2, sides))}
|
||||
|
||||
|
||||
model = ChatOpenAI(model="gpt-4o-mini")
|
||||
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=[get_weather, search_flights, get_stock_price, roll_dice],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
"""Tool Rendering (Reasoning Chain) — minimal deep agent with tools.
|
||||
|
||||
Routes through a reasoning-capable OpenAI model via the Responses API
|
||||
so the chain of thought streams as AG-UI REASONING_MESSAGE_* events
|
||||
alongside the tool calls. See `reasoning_agent.py` for the rationale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from random import choice, randint
|
||||
|
||||
from deepagents import create_deep_agent
|
||||
from langchain.chat_models import init_chat_model
|
||||
from langchain.tools import tool
|
||||
|
||||
from src.agents.src._header_forwarding_middleware import HeaderForwardingMiddleware
|
||||
|
||||
|
||||
@tool
|
||||
def get_weather(location: str) -> dict:
|
||||
"""Get the current weather for a given location."""
|
||||
return {
|
||||
"city": location,
|
||||
"temperature": 68,
|
||||
"humidity": 55,
|
||||
"wind_speed": 10,
|
||||
"conditions": "Sunny",
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def search_flights(origin: str, destination: str) -> dict:
|
||||
"""Search mock flights from an origin airport to a destination airport."""
|
||||
return {
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"flights": [
|
||||
{
|
||||
"airline": "United",
|
||||
"flight": "UA231",
|
||||
"depart": "08:15",
|
||||
"arrive": "16:45",
|
||||
"price_usd": 348,
|
||||
},
|
||||
{
|
||||
"airline": "Delta",
|
||||
"flight": "DL412",
|
||||
"depart": "11:20",
|
||||
"arrive": "19:55",
|
||||
"price_usd": 312,
|
||||
},
|
||||
{
|
||||
"airline": "JetBlue",
|
||||
"flight": "B6722",
|
||||
"depart": "17:05",
|
||||
"arrive": "01:30",
|
||||
"price_usd": 289,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def get_stock_price(ticker: str) -> dict:
|
||||
"""Get a mock current price for a stock ticker."""
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"price_usd": round(100 + randint(0, 400) + randint(0, 99) / 100, 2),
|
||||
"change_pct": round(choice([-1, 1]) * (randint(0, 300) / 100), 2),
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def roll_dice(sides: int = 6) -> dict:
|
||||
"""Roll a single die with the given number of sides."""
|
||||
return {"sides": sides, "result": randint(1, max(2, sides))}
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a travel & lifestyle concierge. When a user asks a question, "
|
||||
"reason step-by-step and call 2+ tools in succession when relevant."
|
||||
)
|
||||
|
||||
REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-5-mini")
|
||||
|
||||
# No full CopilotKitMiddleware — this demo exercises only reasoning-token
|
||||
# streaming alongside tool calls and doesn't consume frontend tools or app
|
||||
# context. We attach the minimal HeaderForwardingMiddleware so the inbound
|
||||
# ``x-aimock-context`` (and other ``x-*``) headers reach the outgoing
|
||||
# /v1/responses call. Mirrors langgraph-python's tool_rendering_reasoning_chain.
|
||||
graph = create_deep_agent(
|
||||
model=init_chat_model(
|
||||
f"openai:{REASONING_MODEL}",
|
||||
use_responses_api=True,
|
||||
reasoning={"effort": "low", "summary": "auto"},
|
||||
),
|
||||
tools=[get_weather, search_flights, get_stock_price, roll_dice],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
middleware=[HeaderForwardingMiddleware()],
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
from langchain_core.messages import ToolCall
|
||||
|
||||
|
||||
def should_route_to_tool_node(tool_calls: list[ToolCall], fe_tools: list[ToolCall]):
|
||||
"""
|
||||
Returns True if none of the tool calls are frontend tools.
|
||||
|
||||
Args:
|
||||
tool_calls: List of tool calls from the model response
|
||||
fe_tools: List of frontend tool names
|
||||
|
||||
Returns:
|
||||
bool: True if all tool calls are backend tools, False if any are frontend tools
|
||||
"""
|
||||
if not tool_calls:
|
||||
return False
|
||||
|
||||
# Get the set of frontend tool names for faster lookup
|
||||
fe_tool_names = {tool.get("name") for tool in fe_tools}
|
||||
|
||||
# Check if any tool call is a frontend tool
|
||||
for tool_call in tool_calls:
|
||||
tool_name = (
|
||||
tool_call.get("name")
|
||||
if isinstance(tool_call, dict)
|
||||
else getattr(tool_call, "name", None)
|
||||
)
|
||||
if tool_name in fe_tool_names:
|
||||
return False
|
||||
|
||||
# None of the tool calls are frontend tools
|
||||
return True
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
// Dedicated runtime for the A2UI — Fixed Schema cell. Splitting into its own
|
||||
// endpoint (mirroring beautiful-chat) lets us set
|
||||
// `a2ui.injectA2UITool: false` — the backend agent owns the `display_flight`
|
||||
// tool which emits its own `a2ui_operations` container via `a2ui.render(...)`.
|
||||
//
|
||||
// Reference:
|
||||
// - src/app/api/copilotkit/route.ts (LF main runtime)
|
||||
// - src/agents/src/a2ui_fixed.py (the backend graph)
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8123";
|
||||
|
||||
const a2uiFixedSchemaAgent = new LangGraphAgent({
|
||||
deploymentUrl: `${AGENT_URL}/`,
|
||||
graphId: "a2ui_fixed",
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents: { "a2ui-fixed-schema": a2uiFixedSchemaAgent },
|
||||
a2ui: {
|
||||
// The backend emits its own `a2ui_operations` container via
|
||||
// `a2ui.render(...)` inside `display_flight` (see src/agents/src/a2ui_fixed.py).
|
||||
// We still run the A2UI middleware so it detects the container in tool
|
||||
// results and forwards surfaces to the frontend — but we do NOT inject a
|
||||
// runtime `render_a2ui` tool on top of the agent's existing tools.
|
||||
injectA2UITool: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-a2ui-fixed-schema",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
// Dedicated runtime for the A2UI Error Recovery demo.
|
||||
// `a2ui.injectA2UITool: false` — the backend LangGraph agent OWNS
|
||||
// `generate_a2ui` via `ag_ui_langgraph.get_a2ui_tools` (see
|
||||
// src/agents/src/recovery_agent.py), whose body runs the `render_a2ui`
|
||||
// sub-agent + the toolkit validate->retry recovery loop + the
|
||||
// recovery-exhausted hard-fail envelope IN-GRAPH (OSS-158 / OSS-375). The
|
||||
// runtime must NOT inject a second copy (double-bind); this `false` is
|
||||
// load-bearing post CopilotKit#5611 (the provider catalog otherwise defaults
|
||||
// injectA2UITool to true). The middleware still renders the building ->
|
||||
// retrying (N/M) -> painted / failed lifecycle.
|
||||
//
|
||||
// The demo reuses the declarative-gen-ui catalog. The aimock fixtures force the
|
||||
// inner render_a2ui sub-agent to emit free-form/sloppy args the middleware heals
|
||||
// (heal pill) or a structurally-invalid surface on every attempt (exhaust pill).
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8123";
|
||||
|
||||
const recoveryAgent = new LangGraphAgent({
|
||||
deploymentUrl: `${AGENT_URL}/`,
|
||||
graphId: "a2ui_recovery",
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents: { "a2ui-recovery": recoveryAgent },
|
||||
a2ui: {
|
||||
injectA2UITool: false,
|
||||
// Reuse the catalog the page registers (shared with declarative-gen-ui).
|
||||
defaultCatalogId: "declarative-gen-ui-catalog",
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-a2ui-recovery",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
// Dedicated runtime for the Agent Config Object demo.
|
||||
//
|
||||
// This runtime hosts a single LangGraph agent (`agent_config_agent`).
|
||||
// The Python graph reads three properties — tone / expertise / responseLength
|
||||
// — from `RunnableConfig["configurable"]["properties"]` to build its system
|
||||
// prompt dynamically per turn (see `src/agents/agent_config_agent.py`).
|
||||
//
|
||||
// ── Property-forwarding regression note ────────────────────────────
|
||||
// Previously this route used a custom `AgentConfigLangGraphAgent` subclass
|
||||
// that repacked the CopilotKit provider's `properties` into
|
||||
// `forwardedProps.config.configurable.properties` so the Python graph could
|
||||
// read them. That stopped working with `@ag-ui/langgraph@0.0.31`, which
|
||||
// builds the LangGraph SDK request as
|
||||
// `{ ..., config, context: { ...input.context, ...config.configurable } }`
|
||||
// — i.e. it merges `configurable` INTO `context`. LangGraph 0.6.0+ rejects
|
||||
// any request that sets both `configurable` and `context`:
|
||||
//
|
||||
// HTTP 400: "Cannot specify both configurable and context. Prefer setting
|
||||
// context alone. Context was introduced in LangGraph 0.6.0 and is the long
|
||||
// term planned replacement for configurable."
|
||||
//
|
||||
// Net effect: any forwardedProps that landed in `configurable.<key>` made
|
||||
// the chat round-trip 400 unconditionally — the user message rendered, but
|
||||
// no assistant reply ever came back.
|
||||
//
|
||||
// To unbreak the chat round-trip, this route now uses the plain
|
||||
// `LangGraphAgent` and stops repacking properties into `configurable`. The
|
||||
// Python graph falls back to its `DEFAULT_*` constants, so the demo's
|
||||
// frontend toggles no longer affect the agent's response style. The
|
||||
// property-forwarding feature is tracked as a known regression pending an
|
||||
// `@ag-ui/langgraph` fix that decouples `context` from `configurable`.
|
||||
//
|
||||
// References:
|
||||
// - src/agents/agent_config_agent.py — the graph (still reads
|
||||
// configurable.properties; falls back to DEFAULT_* when missing)
|
||||
// - src/app/demos/agent-config/page.tsx — the provider config
|
||||
// - node_modules/.pnpm/@ag-ui+langgraph@0.0.31_*/dist/index.js — the
|
||||
// prepareStream merge that introduces the conflict
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
|
||||
const LANGGRAPH_URL =
|
||||
process.env.AGENT_URL ||
|
||||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
|
||||
"http://localhost:8123";
|
||||
|
||||
const agentConfigAgent = new LangGraphAgent({
|
||||
deploymentUrl: LANGGRAPH_URL,
|
||||
graphId: "agent_config_agent",
|
||||
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
|
||||
});
|
||||
|
||||
const agents: Record<string, LangGraphAgent> = {
|
||||
// The page's <CopilotKitProvider agent="agent-config-demo"> resolves here.
|
||||
"agent-config-demo": agentConfigAgent,
|
||||
// Internal components (headless-chat, example-canvas) call `useAgent()` with
|
||||
// no args, which defaults to agentId "default". Alias to the same graph so
|
||||
// those component hooks resolve instead of throwing "Agent 'default' not
|
||||
// found".
|
||||
default: agentConfigAgent,
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
|
||||
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in
|
||||
// source, pending release.
|
||||
agents,
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-agent-config",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
// Dedicated runtime for the /demos/auth cell.
|
||||
//
|
||||
// Demonstrates framework-native request authentication via the V2 runtime's
|
||||
// `onRequest` hook, which runs before routing and can short-circuit the
|
||||
// request by throwing a Response. We validate a static `Authorization: Bearer
|
||||
// <DEMO_TOKEN>` header; mismatch throws 401 before the request reaches the
|
||||
// agent.
|
||||
//
|
||||
// Implementation note: the V1 Next.js adapter
|
||||
// (`copilotRuntimeNextJSAppRouterEndpoint`) does NOT forward the `hooks`
|
||||
// option to the V2 fetch handler. To get `onRequest` wired, this route uses
|
||||
// `createCopilotRuntimeHandler` from `@copilotkit/runtime/v2` directly — the
|
||||
// framework-agnostic fetch handler that returns a plain
|
||||
// `(Request) => Promise<Response>`, which composes cleanly with a Next.js
|
||||
// App Router route export.
|
||||
//
|
||||
// References:
|
||||
// - packages/runtime/src/v2/runtime/core/hooks.ts (onRequest semantics)
|
||||
// - packages/runtime/src/v2/runtime/__tests__/hooks.test.ts (throw Response pattern)
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotRuntimeHandler,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
import { DEMO_AUTH_HEADER } from "@/app/demos/auth/demo-token";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8123";
|
||||
|
||||
// Reuse the neutral `sample_agent` graph for the authenticated path. The
|
||||
// point of this demo is the gate mechanism, not per-user agent branching —
|
||||
// authenticated users get the same behavior as any other neutral demo.
|
||||
const authDemoAgent = new LangGraphAgent({
|
||||
deploymentUrl: `${AGENT_URL}/`,
|
||||
graphId: "sample_agent",
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
"auth-demo": authDemoAgent,
|
||||
// Fallback: useAgent() with no args resolves "default" — alias to the
|
||||
// same agent so hooks in the demo page resolve cleanly.
|
||||
default: authDemoAgent,
|
||||
},
|
||||
});
|
||||
|
||||
const BASE_PATH = "/api/copilotkit-auth";
|
||||
|
||||
// Framework-agnostic fetch handler with the auth gate wired up.
|
||||
const handler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: BASE_PATH,
|
||||
hooks: {
|
||||
onRequest: ({ request }) => {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (authHeader !== DEMO_AUTH_HEADER) {
|
||||
// Throwing a Response short-circuits the pipeline. The runtime maps
|
||||
// thrown Responses to the HTTP response verbatim (status + body).
|
||||
throw new Response(
|
||||
JSON.stringify({
|
||||
error: "unauthorized",
|
||||
message:
|
||||
"Missing or invalid Authorization header. Click Authenticate above to send messages.",
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Next.js App Router bindings. The handler is framework-agnostic — it takes
|
||||
// a web Request and returns a web Response — so it drops straight into the
|
||||
// POST/GET exports without any adapter shim.
|
||||
export const POST = (req: NextRequest) => handler(req);
|
||||
export const GET = (req: NextRequest) => handler(req);
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
// Dedicated runtime for the Beautiful Chat flagship showcase cell.
|
||||
//
|
||||
// Beautiful Chat simultaneously exercises A2UI (dynamic + fixed schema),
|
||||
// Open Generative UI, and MCP Apps. The canonical reference
|
||||
// (examples/integrations/langgraph-python) ships all three flags on a single
|
||||
// runtime, but the 4085 showcase splits those concerns into per-feature
|
||||
// endpoints so non-flagship cells keep their per-demo `useFrontendTool` /
|
||||
// `useComponent` registrations isolated. This route restores the canonical's
|
||||
// combined runtime for just the one cell that needs it.
|
||||
//
|
||||
// References:
|
||||
// - examples/integrations/langgraph-python/src/app/api/copilotkit/[[...slug]]/route.ts
|
||||
// - src/app/api/copilotkit-ogui/route.ts (scoping pattern)
|
||||
// - src/app/api/copilotkit-mcp-apps/route.ts (mcpApps config pattern)
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
|
||||
const LANGGRAPH_URL =
|
||||
process.env.LANGGRAPH_DEPLOYMENT_URL || "http://localhost:8123";
|
||||
|
||||
const beautifulChatAgent = new LangGraphAgent({
|
||||
deploymentUrl: LANGGRAPH_URL,
|
||||
graphId: "beautiful_chat",
|
||||
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
|
||||
});
|
||||
|
||||
const agents: Record<string, LangGraphAgent> = {
|
||||
// The page's <CopilotKit agent="beautiful-chat"> resolves here.
|
||||
"beautiful-chat": beautifulChatAgent,
|
||||
// Internal components (headless-chat, example-canvas) call `useAgent()`
|
||||
// with no args, which defaults to agentId "default". Alias to the same
|
||||
// graph so those component hooks resolve instead of throwing
|
||||
// "Agent 'default' not found". This matches the canonical's
|
||||
// `agents: { default: defaultAgent }` shape.
|
||||
default: beautifulChatAgent,
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
// Canonical: openGenerativeUI: true, a2ui.injectA2UITool: false, mcpApps.
|
||||
openGenerativeUI: true,
|
||||
a2ui: {
|
||||
// The backend graph has its own `generate_a2ui` tool, so we must NOT
|
||||
// inject the runtime's default A2UI tool on top (that would double-bind
|
||||
// the tool slot and confuse the LLM).
|
||||
injectA2UITool: false,
|
||||
// Models follow the tool-usage guide and omit `catalogId`, and the
|
||||
// middleware then falls back to the unregistered spec basic catalog
|
||||
// ("Catalog not found" render error). Pin the catalog the page registers.
|
||||
defaultCatalogId: "copilotkit://app-dashboard-catalog",
|
||||
},
|
||||
mcpApps: {
|
||||
servers: [
|
||||
{
|
||||
type: "http",
|
||||
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
|
||||
// Stable serverId so persisted threads keep restoring the same MCP
|
||||
// server across URL changes.
|
||||
serverId: "beautiful_chat_mcp",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-beautiful-chat",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
// Dedicated runtime for the byoc-hashbrown demo (Wave 4a).
|
||||
//
|
||||
// The demo page (`src/app/demos/byoc-hashbrown/page.tsx`) wraps CopilotChat
|
||||
// in the HashBrownDashboard provider and overrides the assistant message
|
||||
// slot with a renderer that consumes hashbrown-shaped structured output via
|
||||
// `@hashbrownai/react`'s `useUiKit` + `useJsonParser`. The agent behind this
|
||||
// endpoint (`byoc_hashbrown_agent`) has a system prompt tuned to emit that
|
||||
// shape — see `src/agents/byoc_hashbrown_agent.py`.
|
||||
//
|
||||
// Reference:
|
||||
// - src/app/api/copilotkit-a2ui-fixed-schema/route.ts (topology this mirrors)
|
||||
// - src/agents/byoc_hashbrown_agent.py (the backend graph)
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
|
||||
const LANGGRAPH_URL =
|
||||
process.env.AGENT_URL ||
|
||||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
|
||||
"http://localhost:8123";
|
||||
|
||||
const byocHashbrownAgent = new LangGraphAgent({
|
||||
deploymentUrl: LANGGRAPH_URL,
|
||||
graphId: "byoc_hashbrown",
|
||||
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
|
||||
});
|
||||
|
||||
const agents: Record<string, LangGraphAgent> = {
|
||||
"byoc-hashbrown-demo": byocHashbrownAgent,
|
||||
// Internal components (headless-chat, example-canvas) call `useAgent()` with
|
||||
// no args, which defaults to agentId "default". Alias to the same graph so
|
||||
// those component hooks resolve instead of throwing "Agent 'default' not
|
||||
// found".
|
||||
default: byocHashbrownAgent,
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-byoc-hashbrown",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Dedicated runtime for the BYOC json-render demo.
|
||||
*
|
||||
* Splitting into its own endpoint (mirroring beautiful-chat +
|
||||
* declarative-gen-ui) keeps the `byoc_json_render` agent isolated from the
|
||||
* default multi-agent `/api/copilotkit` runtime. The frontend's demo page
|
||||
* (src/app/demos/byoc-json-render/page.tsx) points `<CopilotKit
|
||||
* runtimeUrl>` here.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
|
||||
const LANGGRAPH_URL =
|
||||
process.env.AGENT_URL ||
|
||||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
|
||||
"http://localhost:8123";
|
||||
|
||||
const byocJsonRenderAgent = new LangGraphAgent({
|
||||
deploymentUrl: LANGGRAPH_URL,
|
||||
graphId: "byoc_json_render",
|
||||
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- same-shape mismatch as the other dedicated routes in this
|
||||
// package; the LangGraphAgent satisfies the runtime's agent interface at
|
||||
// runtime but the generics don't line up across the v1/v2 boundary.
|
||||
agents: { byoc_json_render: byocJsonRenderAgent },
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-byoc-json-render",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
// Dedicated runtime for the Declarative Generative UI (A2UI — Dynamic Schema) cell.
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8123";
|
||||
|
||||
const declarativeGenUiAgent = new LangGraphAgent({
|
||||
deploymentUrl: `${AGENT_URL}/`,
|
||||
graphId: "a2ui_dynamic",
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents: { "declarative-gen-ui": declarativeGenUiAgent },
|
||||
// No runtime `a2ui` config: the page passes a catalog to the provider
|
||||
// (`<CopilotKit a2ui={{ catalog }}>`), which auto-enables A2UI and defaults
|
||||
// tool injection on (CopilotKit >= 1.61.2, PR #5611).
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-declarative-gen-ui",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
// CopilotKit runtime for the MCP Apps cell — shared by two demos:
|
||||
// - headless-complete (frontend wires runtimeUrl="/api/copilotkit-mcp-apps"
|
||||
// and renders activity events via a hand-rolled useRenderActivityMessage
|
||||
// in use-rendered-messages.tsx)
|
||||
// - mcp-apps (frontend relies on CopilotKit's built-in MCPAppsActivityRenderer)
|
||||
//
|
||||
// The runtime's `mcpApps` config auto-applies the MCP Apps middleware to the
|
||||
// agent: when the agent calls a tool backed by an MCP UI resource, the
|
||||
// middleware fetches the resource and emits the activity event that the
|
||||
// built-in `MCPAppsActivityRenderer` (registered by CopilotKit internally)
|
||||
// renders in the chat as a sandboxed iframe.
|
||||
//
|
||||
// Reference:
|
||||
// https://docs.copilotkit.ai/integrations/langgraph/generative-ui/mcp-apps
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8123";
|
||||
|
||||
const headlessCompleteAgent = new LangGraphAgent({
|
||||
deploymentUrl: `${AGENT_URL}/`,
|
||||
graphId: "headless_complete",
|
||||
});
|
||||
|
||||
const mcpAppsAgent = new LangGraphAgent({
|
||||
deploymentUrl: `${AGENT_URL}/`,
|
||||
graphId: "mcp_apps",
|
||||
});
|
||||
|
||||
// @region[runtime-mcpapps-config]
|
||||
// The `mcpApps.servers` config is all you need server-side. The runtime
|
||||
// auto-applies the MCP Apps middleware to every registered agent: on each
|
||||
// MCP tool call it fetches the associated UI resource and emits an
|
||||
// `activity` event that the built-in `MCPAppsActivityRenderer` renders
|
||||
// inline in the chat.
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore
|
||||
agents: {
|
||||
"headless-complete": headlessCompleteAgent,
|
||||
"mcp-apps": mcpAppsAgent,
|
||||
},
|
||||
mcpApps: {
|
||||
servers: [
|
||||
{
|
||||
type: "http",
|
||||
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
|
||||
// Always pin a stable `serverId`. Without it CopilotKit hashes the
|
||||
// URL, and a URL change silently breaks restoration of persisted
|
||||
// MCP Apps in prior conversation threads.
|
||||
serverId: "excalidraw",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
// @endregion[runtime-mcpapps-config]
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-mcp-apps",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
// Dedicated runtime for the Multimodal Attachments demo (Wave 2b).
|
||||
//
|
||||
// Why its own route? The backing graph (`multimodal`, from
|
||||
// src/agents/multimodal_agent.py) runs a vision-capable model (gpt-4o). Every
|
||||
// other cell in the showcase uses a text-only, cheaper model. Registering
|
||||
// `multimodal` under the shared `/api/copilotkit` runtime would silently upgrade
|
||||
// *all* cells that share that runtime to a vision model whenever the browser
|
||||
// routed to this one — wasting tokens and blurring the per-demo cost boundary.
|
||||
// A dedicated route keeps the vision capability — and its cost — scoped to
|
||||
// exactly the cell that exercises it, matching the pattern used by
|
||||
// `/api/copilotkit-beautiful-chat`.
|
||||
//
|
||||
// The page at src/app/demos/multimodal/page.tsx points its `runtimeUrl` at
|
||||
// this endpoint and sets `agent="multimodal-demo"` (the slug registered below).
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8123";
|
||||
|
||||
const multimodalAgent = new LangGraphAgent({
|
||||
deploymentUrl: `${AGENT_URL}/`,
|
||||
// graphId references the key in langgraph.json — must match the
|
||||
// "multimodal" entry that resolves to src/agents/src/multimodal_agent.py:graph.
|
||||
graphId: "multimodal",
|
||||
});
|
||||
|
||||
const agents: Record<string, LangGraphAgent> = {
|
||||
// The page's <CopilotKit agent="multimodal-demo"> resolves here.
|
||||
"multimodal-demo": multimodalAgent,
|
||||
// Alias for any internal component that calls `useAgent()` without args
|
||||
// (matches the beautiful-chat route's "default" alias pattern).
|
||||
default: multimodalAgent,
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-multimodal",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts; published CopilotRuntime's `agents`
|
||||
// type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects
|
||||
// plain Records. Fixed in source, pending release.
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
|
||||
// Dedicated runtime for the Open Generative UI demos.
|
||||
// Isolated here because the `openGenerativeUI` runtime flag sets
|
||||
// `openGenerativeUIEnabled: true` globally on the probe response, which
|
||||
// causes the CopilotKit provider's setTools effect to wipe per-demo
|
||||
// `useFrontendTool`/`useComponent` registrations in the default runtime.
|
||||
|
||||
const LANGGRAPH_URL =
|
||||
process.env.AGENT_URL ||
|
||||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
|
||||
"http://localhost:8123";
|
||||
|
||||
const openGenUiAgent = new LangGraphAgent({
|
||||
deploymentUrl: LANGGRAPH_URL,
|
||||
graphId: "open_gen_ui",
|
||||
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
|
||||
});
|
||||
const openGenUiAdvancedAgent = new LangGraphAgent({
|
||||
deploymentUrl: LANGGRAPH_URL,
|
||||
graphId: "open_gen_ui_advanced",
|
||||
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
|
||||
});
|
||||
|
||||
const agents: Record<string, LangGraphAgent> = {
|
||||
"open-gen-ui": openGenUiAgent,
|
||||
"open-gen-ui-advanced": openGenUiAdvancedAgent,
|
||||
// Internal components (headless-chat, example-canvas) call `useAgent()` with
|
||||
// no args, which defaults to agentId "default". Alias so those hooks resolve
|
||||
// instead of throwing "Agent 'default' not found".
|
||||
default: openGenUiAgent,
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-ogui",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
// @region[minimal-runtime-flag]
|
||||
// @region[advanced-runtime-config]
|
||||
// Server-side config is identical for the minimal and advanced cells —
|
||||
// the advanced behaviour (sandbox -> host function calls) is wired
|
||||
// entirely on the frontend via `openGenerativeUI.sandboxFunctions` on
|
||||
// the provider. The single `openGenerativeUI` flag below turns on
|
||||
// Open Generative UI for the listed agent(s); the runtime middleware
|
||||
// converts each agent's streamed `generateSandboxedUi` tool call into
|
||||
// `open-generative-ui` activity events.
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
openGenerativeUI: {
|
||||
agents: ["open-gen-ui", "open-gen-ui-advanced"],
|
||||
},
|
||||
}),
|
||||
// @endregion[advanced-runtime-config]
|
||||
// @endregion[minimal-runtime-flag]
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, stack: error.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
// Dedicated runtime for the /demos/voice cell.
|
||||
//
|
||||
// Goals
|
||||
// -----
|
||||
// 1. Advertise `audioFileTranscriptionEnabled: true` on `/info` so the chat
|
||||
// composer renders the mic button.
|
||||
// 2. Handle `POST /transcribe` by invoking an OpenAI-backed
|
||||
// `TranscriptionServiceOpenAI` (from `@copilotkit/voice`), so recorded
|
||||
// audio is transcribed and the transcript auto-sends.
|
||||
// 3. Return a deterministic 4xx when `OPENAI_API_KEY` is not configured,
|
||||
// instead of an opaque 5xx. The V2 runtime's `handleTranscribe` maps
|
||||
// error messages containing "api key" or "unauthorized" to
|
||||
// `AUTH_FAILED → HTTP 401`, so throwing with that message funnels the
|
||||
// missing-key case into the intended 4xx path.
|
||||
//
|
||||
// Implementation
|
||||
// --------------
|
||||
// Wires the **V2** `CopilotRuntime` directly (from `@copilotkit/runtime/v2`)
|
||||
// because the V1 wrapper in `@copilotkit/runtime` drops the
|
||||
// `transcriptionService` option on the floor (see the TODO on the V1
|
||||
// constructor). V2 URL-routes on `/info`, `/agent/:id/run`, `/transcribe`,
|
||||
// etc., so the route file lives at `[[...slug]]/route.ts` to catch all
|
||||
// sub-paths under `/api/copilotkit-voice`.
|
||||
|
||||
// @region[voice-runtime]
|
||||
// @region[transcription-service-guard]
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
TranscriptionService,
|
||||
createCopilotRuntimeHandler,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import type { TranscribeFileOptions } from "@copilotkit/runtime/v2";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
import { TranscriptionServiceOpenAI } from "@copilotkit/voice";
|
||||
import OpenAI from "openai";
|
||||
|
||||
const LANGGRAPH_URL =
|
||||
process.env.AGENT_URL ||
|
||||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
|
||||
"http://localhost:8123";
|
||||
|
||||
const voiceDemoAgent = new LangGraphAgent({
|
||||
deploymentUrl: `${LANGGRAPH_URL}/`,
|
||||
graphId: "sample_agent",
|
||||
});
|
||||
|
||||
/**
|
||||
* Transcription service wrapper that reports a clean, typed auth error when
|
||||
* OPENAI_API_KEY is not configured. When the key is present we delegate to
|
||||
* the real OpenAI-backed service; any upstream Whisper error keeps its
|
||||
* natural categorization.
|
||||
*/
|
||||
class GuardedOpenAITranscriptionService extends TranscriptionService {
|
||||
private delegate: TranscriptionServiceOpenAI | null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
this.delegate = apiKey
|
||||
? new TranscriptionServiceOpenAI({ openai: new OpenAI({ apiKey }) })
|
||||
: null;
|
||||
}
|
||||
|
||||
async transcribeFile(options: TranscribeFileOptions): Promise<string> {
|
||||
if (!this.delegate) {
|
||||
// "api key" substring → handleTranscribe maps to AUTH_FAILED → 401.
|
||||
throw new Error(
|
||||
"OPENAI_API_KEY not configured for this deployment (api key missing). " +
|
||||
"Set OPENAI_API_KEY to enable voice transcription.",
|
||||
);
|
||||
}
|
||||
return this.delegate.transcribeFile(options);
|
||||
}
|
||||
}
|
||||
// @endregion[transcription-service-guard]
|
||||
|
||||
// Cache the runtime + handler across invocations so the transcription service
|
||||
// is constructed once per Node process instead of per request. The guarded
|
||||
// service reads OPENAI_API_KEY lazily in its transcribeFile call path, so
|
||||
// deferring construction past module load is not required for cold-start
|
||||
// safety under missing-key conditions.
|
||||
let cachedHandler: ((req: Request) => Promise<Response>) | null = null;
|
||||
function getHandler(): (req: Request) => Promise<Response> {
|
||||
if (cachedHandler) return cachedHandler;
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
|
||||
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in
|
||||
// source, pending release.
|
||||
agents: {
|
||||
// The page mounts <CopilotKit agent="voice-demo">; resolve that to
|
||||
// the neutral sample_agent graph.
|
||||
"voice-demo": voiceDemoAgent,
|
||||
// useAgent() with no args defaults to "default"; alias so any internal
|
||||
// default-agent lookups resolve against the same graph.
|
||||
default: voiceDemoAgent,
|
||||
},
|
||||
transcriptionService: new GuardedOpenAITranscriptionService(),
|
||||
});
|
||||
|
||||
cachedHandler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit-voice",
|
||||
});
|
||||
return cachedHandler;
|
||||
}
|
||||
|
||||
// Next.js App Router bindings. This file lives at
|
||||
// `src/app/api/copilotkit-voice/[[...slug]]/route.ts` — the catchall slug
|
||||
// pattern forwards every sub-path (`/info`, `/agent/:id/run`,
|
||||
// `/transcribe`, ...) to the V2 handler so its URL router can dispatch.
|
||||
export const POST = (req: NextRequest) => getHandler()(req);
|
||||
export const GET = (req: NextRequest) => getHandler()(req);
|
||||
export const PUT = (req: NextRequest) => getHandler()(req);
|
||||
export const DELETE = (req: NextRequest) => getHandler()(req);
|
||||
// @endregion[voice-runtime]
|
||||
@@ -0,0 +1,169 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
// Emit a structured server-side error log with a correlation id so the
|
||||
// 500 we return to the client carries no stack/message details (which
|
||||
// can leak internal config, prompts, or upstream URLs) while operators
|
||||
// can still grep logs for the same `errorId` to find the full failure.
|
||||
function logRouteError(err: unknown): string {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const errorId = crypto.randomUUID();
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
at: new Date().toISOString(),
|
||||
level: "error",
|
||||
phase: "setup",
|
||||
errorId,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
}),
|
||||
);
|
||||
return errorId;
|
||||
}
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8123";
|
||||
|
||||
console.log("[copilotkit/route] Initializing CopilotKit runtime");
|
||||
console.log(`[copilotkit/route] AGENT_URL: ${AGENT_URL}`);
|
||||
|
||||
// Per-request request/response logging is gated behind this flag (default off).
|
||||
// Under d6 probe fan-out, unconditional per-request logs flooded Railway's
|
||||
// 500-logs/sec cap and killed the replica ("Messages dropped" → container stop).
|
||||
// Set SHOWCASE_ROUTE_DEBUG=1 to re-enable verbose per-request tracing locally.
|
||||
const ROUTE_DEBUG =
|
||||
process.env.SHOWCASE_ROUTE_DEBUG === "1" ||
|
||||
process.env.SHOWCASE_ROUTE_DEBUG === "true";
|
||||
|
||||
function createAgent(graphId: string = "sample_agent") {
|
||||
return new LangGraphAgent({
|
||||
deploymentUrl: `${AGENT_URL}/`,
|
||||
graphId,
|
||||
});
|
||||
}
|
||||
|
||||
const agentNames = [
|
||||
"agentic_chat",
|
||||
"human_in_the_loop",
|
||||
"gen-ui-tool-based",
|
||||
"gen-ui-agent",
|
||||
"shared-state-read",
|
||||
"shared-state-write",
|
||||
"shared-state-streaming",
|
||||
"prebuilt-sidebar",
|
||||
"prebuilt-popup",
|
||||
"chat-slots",
|
||||
"chat-customization-css",
|
||||
"headless-simple",
|
||||
];
|
||||
|
||||
const agents: Record<string, LangGraphAgent> = {};
|
||||
for (const name of agentNames) {
|
||||
agents[name] = createAgent();
|
||||
}
|
||||
|
||||
// Dedicated-graph agents for tool-rendering + reasoning demos.
|
||||
agents["tool-rendering"] = createAgent("tool_rendering");
|
||||
agents["tool-rendering-default-catchall"] = createAgent("tool_rendering");
|
||||
agents["tool-rendering-custom-catchall"] = createAgent("tool_rendering");
|
||||
agents["tool-rendering-reasoning-chain"] = createAgent(
|
||||
"tool_rendering_reasoning_chain",
|
||||
);
|
||||
agents["agentic-chat-reasoning"] = createAgent("reasoning_agent");
|
||||
agents["reasoning-default-render"] = createAgent("reasoning_agent");
|
||||
|
||||
// Interrupt variants — share the dedicated `interrupt_agent` graph that uses
|
||||
// langgraph's `interrupt()` primitive inside `schedule_meeting`.
|
||||
agents["gen-ui-interrupt"] = createAgent("interrupt_agent");
|
||||
agents["interrupt-headless"] = createAgent("interrupt_agent");
|
||||
|
||||
// Dedicated-graph agents — each cell has its own LangGraph graph with a
|
||||
// tailored system prompt (tools=[], CopilotKitMiddleware attached).
|
||||
agents["frontend_tools"] = createAgent("frontend_tools");
|
||||
agents["frontend-tools-async"] = createAgent("frontend_tools_async");
|
||||
agents["hitl-in-app"] = createAgent("hitl_in_app");
|
||||
// In-Chat HITL via the high-level `useHumanInTheLoop` hook — backend
|
||||
// agent has zero tools; the frontend-registered `book_call` tool is
|
||||
// injected into the LLM's tool list by `CopilotKitMiddleware`. Both
|
||||
// the canonical `hitl-in-chat` demo and the `hitl-in-chat-booking`
|
||||
// alias share the same backend graph.
|
||||
agents["hitl-in-chat"] = createAgent("hitl_in_chat");
|
||||
agents["hitl-in-chat-booking"] = createAgent("hitl_in_chat");
|
||||
// HITL step-selection: dedicated graph with tools=[] + CopilotKitMiddleware.
|
||||
// The `human_in_the_loop` alias in the neutral-assistant loop above maps to
|
||||
// `sample_agent` which has 7+ backend tools and a custom AgentState — the
|
||||
// frontend-only `generate_task_steps` tool (from useHumanInTheLoop) is the
|
||||
// ONLY tool this demo needs, so a minimal graph avoids state/tool contention.
|
||||
agents["human_in_the_loop"] = createAgent("hitl_steps");
|
||||
agents["readonly-state-agent-context"] = createAgent(
|
||||
"readonly_state_agent_context",
|
||||
);
|
||||
|
||||
// Shared State (Read + Write) — bidirectional shared state between UI and
|
||||
// agent. UI writes `preferences` via agent.setState; middleware reads them
|
||||
// into the system prompt; agent writes `notes` back via the `set_notes` tool.
|
||||
agents["shared-state-read-write"] = createAgent("shared_state_read_write");
|
||||
|
||||
// Sub-Agents — supervisor delegates to research_agent / writing_agent /
|
||||
// critique_agent (each a full create_agent under the hood). Every delegation
|
||||
// is appended to `state.delegations` for live UI rendering.
|
||||
agents["subagents"] = createAgent("subagents");
|
||||
|
||||
agents["default"] = createAgent();
|
||||
|
||||
console.log(
|
||||
`[copilotkit/route] Registered ${Object.keys(agents).length} agent names: ${Object.keys(agents).join(", ")}`,
|
||||
);
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-ignore
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await handleRequest(req);
|
||||
if (!response.ok) {
|
||||
console.log(`[copilotkit/route] Response status: ${response.status}`);
|
||||
} else if (ROUTE_DEBUG) {
|
||||
console.log(`[copilotkit/route] Response status: ${response.status}`);
|
||||
}
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
// Log full message + stack server-side under a correlation id; return
|
||||
// only the id to the client so we don't leak internal details (upstream
|
||||
// URLs, env-driven config, prompts, etc.) into HTTP responses.
|
||||
const errorId = logRouteError(error);
|
||||
return NextResponse.json(
|
||||
{ error: "internal runtime error", errorId },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const GET = async () => {
|
||||
let agentStatus = "unknown";
|
||||
try {
|
||||
const res = await fetch(`${AGENT_URL}/ok`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
agentStatus = res.ok ? "reachable" : `error (${res.status})`;
|
||||
} catch (e: unknown) {
|
||||
agentStatus = `unreachable (${(e as Error).message})`;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
agent_url: AGENT_URL,
|
||||
agent_status: agentStatus,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// Token-gated: SHOWCASE_DEBUG_TOKEN must be set in env and matched
|
||||
const token =
|
||||
req.headers.get("x-debug-token") || req.nextUrl.searchParams.get("token");
|
||||
const expectedToken = process.env.SHOWCASE_DEBUG_TOKEN;
|
||||
|
||||
if (!expectedToken || !token || token !== expectedToken) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const AGENT_URL =
|
||||
process.env.AGENT_URL || process.env.LANGGRAPH_DEPLOYMENT_URL || "unknown";
|
||||
|
||||
// Agent connectivity
|
||||
let agentStatus = "unknown";
|
||||
let agentDetail = "";
|
||||
try {
|
||||
const res = await fetch(`${AGENT_URL}/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
agentStatus = res.ok ? "ok" : "error";
|
||||
agentDetail = `HTTP ${res.status}`;
|
||||
} catch (e: unknown) {
|
||||
agentStatus = "down";
|
||||
agentDetail = (e as Error).message;
|
||||
}
|
||||
|
||||
const uptime = process.uptime();
|
||||
const mem = process.memoryUsage();
|
||||
|
||||
return NextResponse.json({
|
||||
integration: "langgraph-fastapi",
|
||||
uptime: `${Math.floor(uptime / 60)}m ${Math.floor(uptime % 60)}s`,
|
||||
agent: { url: AGENT_URL, status: agentStatus, detail: agentDetail },
|
||||
memory: {
|
||||
rss: `${Math.round(mem.rss / 1024 / 1024)}MB`,
|
||||
heapUsed: `${Math.round(mem.heapUsed / 1024 / 1024)}MB`,
|
||||
},
|
||||
env: {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ? "set" : "NOT SET",
|
||||
LANGSMITH_API_KEY: process.env.LANGSMITH_API_KEY ? "set" : "NOT SET",
|
||||
},
|
||||
nodeVersion: process.version,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
integration: "langgraph-fastapi",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const INTEGRATION_SLUG = "langgraph-fastapi";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function GET() {
|
||||
const start = Date.now();
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||
`http://localhost:${process.env.PORT || 3000}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/copilotkit`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
method: "agent/run",
|
||||
params: { agentId: "agentic_chat" },
|
||||
body: {
|
||||
threadId: `smoke-${Date.now()}`,
|
||||
runId: `smoke-run-${Date.now()}`,
|
||||
state: {},
|
||||
messages: [
|
||||
{
|
||||
id: `smoke-msg-${Date.now()}`,
|
||||
role: "user",
|
||||
content: "Respond with exactly: OK",
|
||||
},
|
||||
],
|
||||
tools: [],
|
||||
context: [],
|
||||
forwardedProps: {},
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(45000),
|
||||
});
|
||||
|
||||
const latency = Date.now() - start;
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => "");
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
integration: INTEGRATION_SLUG,
|
||||
stage: "runtime_response",
|
||||
error: `Runtime returned ${res.status}: ${errBody.slice(0, 200)}`,
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
// TTFB: read first chunk only to confirm SSE stream started, then cancel
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
integration: INTEGRATION_SLUG,
|
||||
stage: "response_empty",
|
||||
error: "Runtime returned no readable body",
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
const { value, done } = await reader.read();
|
||||
reader.cancel();
|
||||
if (done || !value || value.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
integration: INTEGRATION_SLUG,
|
||||
stage: "response_empty",
|
||||
error: "Runtime returned empty response body",
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
integration: INTEGRATION_SLUG,
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
const latency = Date.now() - start;
|
||||
|
||||
let stage = "unknown";
|
||||
if (err.name === "AbortError" || err.message.includes("timeout"))
|
||||
stage = "timeout";
|
||||
else if (
|
||||
err.message.includes("fetch") ||
|
||||
err.message.includes("ECONNREFUSED")
|
||||
)
|
||||
stage = "agent_unreachable";
|
||||
else stage = "pipeline_error";
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
integration: INTEGRATION_SLUG,
|
||||
stage,
|
||||
error: err.message,
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
// Shared fallback time-slot generator for the interrupt demos
|
||||
// (`gen-ui-interrupt`, `interrupt-headless`). The interrupt backend
|
||||
// (`src/agents/interrupt_agent.py`) supplies its own candidate slots
|
||||
// inside the interrupt payload — these fallbacks only run if the
|
||||
// payload arrives without them. Generating relative to `Date.now()`
|
||||
// keeps the fallback from rotting, which previously had hardcoded
|
||||
// dates that decayed within a week of being authored.
|
||||
|
||||
export interface TimeSlot {
|
||||
label: string;
|
||||
iso: string;
|
||||
}
|
||||
|
||||
function atLocal(date: Date, hour: number, minute = 0): Date {
|
||||
return new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
hour,
|
||||
minute,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function nextMonday(from: Date): Date {
|
||||
// `getDay()` is 0=Sun, 1=Mon, ..., 6=Sat. We want the next Monday
|
||||
// that's at LEAST 2 days away — otherwise "Monday" would collide
|
||||
// with "Tomorrow" on Sunday (offset would be 1) or with itself on
|
||||
// Monday (offset would be 0). Mirrors interrupt_agent.py.
|
||||
const day = from.getDay();
|
||||
let offset = (1 - day + 7) % 7;
|
||||
if (offset <= 1) offset += 7;
|
||||
const next = new Date(from);
|
||||
next.setDate(from.getDate() + offset);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function generateFallbackSlots(now: Date = new Date()): TimeSlot[] {
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(now.getDate() + 1);
|
||||
const monday = nextMonday(now);
|
||||
|
||||
const candidates: Array<[string, Date]> = [
|
||||
["Tomorrow 10:00 AM", atLocal(tomorrow, 10)],
|
||||
["Tomorrow 2:00 PM", atLocal(tomorrow, 14)],
|
||||
["Monday 9:00 AM", atLocal(monday, 9)],
|
||||
["Monday 3:30 PM", atLocal(monday, 15, 30)],
|
||||
];
|
||||
|
||||
return candidates.map(([label, date]) => ({
|
||||
label,
|
||||
iso: date.toISOString(),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Coerces a tool-call result into a typed object. Tool results arrive
|
||||
// as strings when the agent emits JSON or as already-parsed objects
|
||||
// when the runtime decoded them upstream — this helper handles both
|
||||
// shapes and returns `{}` if the result is missing or unparseable.
|
||||
export function parseJsonResult<T>(result: unknown): T {
|
||||
if (!result) return {} as T;
|
||||
try {
|
||||
return (typeof result === "string" ? JSON.parse(result) : result) as T;
|
||||
} catch {
|
||||
return {} as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Helper for the CopilotChat slot overrides. The slot prop types in
|
||||
// `@copilotkit/react-core` are nominally typed against the *exact*
|
||||
// default component identity, but a custom wrapper that returns a
|
||||
// structurally compatible ReactElement is functionally a drop-in. This
|
||||
// helper names that fact and centralizes the type assertion in one
|
||||
// place — readers see `makeSlotOverride` and know it's about the slot
|
||||
// contract, not arbitrary type-system gymnastics. Once the slot prop
|
||||
// types accept structural compatibility, this helper can be deleted
|
||||
// and the casts will resolve automatically.
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
// `any` on the input is intentional: the helper's purpose is to accept
|
||||
// any component shape and assert it as the slot's expected type. A
|
||||
// stricter constraint would defeat the whole point.
|
||||
export function makeSlotOverride<TDefault>(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
component: ComponentType<any>,
|
||||
): TDefault {
|
||||
return component as unknown as TDefault;
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Badge primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes, no `cn()`/`cva` helpers.
|
||||
*/
|
||||
type Variant = "default" | "secondary" | "outline" | "success";
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
default: "border-transparent bg-neutral-900 text-neutral-50",
|
||||
secondary: "border-transparent bg-neutral-100 text-neutral-900",
|
||||
outline: "border-neutral-200 text-neutral-700 bg-white",
|
||||
success: "border-transparent bg-emerald-100 text-emerald-700",
|
||||
};
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: Variant;
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
className = "",
|
||||
variant = "default",
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium tracking-wide ${variantClasses[variant]} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Button primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes, no `cn()`/`cva` helpers.
|
||||
*/
|
||||
type Variant = "default" | "outline" | "secondary" | "ghost" | "success";
|
||||
type Size = "default" | "sm" | "lg";
|
||||
|
||||
const baseClasses =
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60";
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
default: "bg-neutral-900 text-neutral-50 shadow-sm hover:bg-neutral-800",
|
||||
outline:
|
||||
"border border-neutral-200 bg-white text-neutral-900 shadow-sm hover:bg-neutral-100",
|
||||
secondary: "bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-200",
|
||||
ghost: "hover:bg-neutral-100 hover:text-neutral-900",
|
||||
success:
|
||||
"bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm hover:bg-emerald-50",
|
||||
};
|
||||
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-11 rounded-md px-6",
|
||||
};
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{ className = "", variant = "default", size = "default", ...props },
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Card primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes, no `cn()`/`cva` helpers.
|
||||
*/
|
||||
export const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`rounded-xl border border-neutral-200 bg-white text-neutral-950 shadow-sm ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
export const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`flex flex-col space-y-1.5 p-5 pb-3 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
export const CardTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={`text-base font-semibold leading-none tracking-tight text-neutral-900 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
export const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div ref={ref} className={`p-5 pt-0 ${className}`} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
export const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`flex items-center p-5 pt-0 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Separator primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes; uses a div instead of Radix to keep dependencies minimal.
|
||||
*/
|
||||
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
export function Separator({
|
||||
className = "",
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorProps) {
|
||||
const orientationClasses =
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px";
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation={orientation}
|
||||
className={`shrink-0 bg-neutral-200 ${orientationClasses} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Fixed A2UI catalog — wires definitions to renderers.
|
||||
*
|
||||
* `includeBasicCatalog: true` merges CopilotKit's built-in components
|
||||
* (Card, Column, Row, Text, Button, Divider, …) into this catalog, so
|
||||
* the agent's fixed schema (src/agents/a2ui_schemas/flight_schema.json) can
|
||||
* compose custom and basic components interchangeably.
|
||||
*/
|
||||
// @region[catalog-creation]
|
||||
import { createCatalog } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
import { definitions } from "./definitions";
|
||||
import { renderers } from "./renderers";
|
||||
|
||||
export const CATALOG_ID = "copilotkit://flight-fixed-catalog";
|
||||
|
||||
export const catalog = createCatalog(definitions, renderers, {
|
||||
catalogId: CATALOG_ID,
|
||||
includeBasicCatalog: true,
|
||||
});
|
||||
// @endregion[catalog-creation]
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* A2UI catalog DEFINITIONS — platform-agnostic.
|
||||
*
|
||||
* Each entry declares a component name + its Zod props schema. The basic
|
||||
* catalog (Card, Column, Row, Text, Button, …) ships with CopilotKit and
|
||||
* is mixed in via `createCatalog(..., { includeBasicCatalog: true })`, so
|
||||
* we only declare the project-specific additions and the visual overrides
|
||||
* here. (Custom entries with the same name as a basic component override
|
||||
* the basic one — Catalog dedupes by `comp.name`, last-write-wins.)
|
||||
*
|
||||
* IMPORTANT — path bindings: fields that can be bound to a data-model path
|
||||
* (e.g. `{ path: "/origin" }` in the fixed schema JSON) must declare their
|
||||
* Zod type as a union of `z.string()` and `z.object({ path: z.string() })`.
|
||||
* The A2UI `GenericBinder` uses this union to detect the field as dynamic
|
||||
* and resolve the path against the current data model at render time. Using
|
||||
* plain `z.string()` causes the raw `{ path }` object to reach the
|
||||
* renderer, which React then throws on (error #31 "object with keys {path}").
|
||||
* This matches the canonical catalog's `DynString` helper:
|
||||
* examples/integrations/langgraph-python/src/app/declarative-generative-ui/definitions.ts
|
||||
*/
|
||||
// @region[definitions-types]
|
||||
import { z } from "zod";
|
||||
import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
/**
|
||||
* Dynamic string: literal OR a data-model path binding. The GenericBinder
|
||||
* resolves path bindings to the actual value at render time.
|
||||
*/
|
||||
const DynString = z.union([z.string(), z.object({ path: z.string() })]);
|
||||
|
||||
export const definitions = {
|
||||
/**
|
||||
* Card override: gives the outer flight-card container a ShadCN look
|
||||
* (rounded-xl, neutral-200 border, soft shadow). The basic catalog's
|
||||
* Card uses inline styles; overriding here lets the demo's renderer
|
||||
* adopt the demo's Tailwind aesthetic without touching the schema JSON.
|
||||
*/
|
||||
Card: {
|
||||
description: "A container card with a single child.",
|
||||
props: z.object({
|
||||
child: z.string(),
|
||||
}),
|
||||
},
|
||||
Title: {
|
||||
description: "A prominent heading for the flight card.",
|
||||
props: z.object({
|
||||
text: DynString,
|
||||
}),
|
||||
},
|
||||
Airport: {
|
||||
description: "A 3-letter airport code, displayed large.",
|
||||
props: z.object({
|
||||
code: DynString,
|
||||
}),
|
||||
},
|
||||
Arrow: {
|
||||
description: "A right-pointing arrow used between airports.",
|
||||
props: z.object({}),
|
||||
},
|
||||
AirlineBadge: {
|
||||
description: "A pill-styled airline name tag.",
|
||||
props: z.object({
|
||||
name: DynString,
|
||||
}),
|
||||
},
|
||||
PriceTag: {
|
||||
description: "A stylized price display (e.g. '$289').",
|
||||
props: z.object({
|
||||
amount: DynString,
|
||||
}),
|
||||
},
|
||||
/**
|
||||
* Button override: swaps in an ActionButton renderer that tracks
|
||||
* its own `done` state so clicking "Book flight" visually updates to
|
||||
* a "Booked ✓" confirmation. The basic catalog's Button is stateless,
|
||||
* so without this override the click fires the action but the button
|
||||
* looks unchanged. Mirrors the pattern in beautiful-chat
|
||||
* (src/app/demos/beautiful-chat/declarative-generative-ui/renderers.tsx).
|
||||
*/
|
||||
Button: {
|
||||
description:
|
||||
"An interactive button with an action event. Use 'child' with a Text component ID for the label. After click, the button shows a confirmation state.",
|
||||
props: z.object({
|
||||
child: z
|
||||
.string()
|
||||
.describe(
|
||||
"The ID of the child component (e.g. a Text component for the label).",
|
||||
),
|
||||
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
|
||||
// Union with { event } so GenericBinder resolves this as ACTION → callable () => void.
|
||||
action: z
|
||||
.union([
|
||||
z.object({
|
||||
event: z.object({
|
||||
name: z.string(),
|
||||
context: z.record(z.any()).optional(),
|
||||
}),
|
||||
}),
|
||||
z.null(),
|
||||
])
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
} satisfies CatalogDefinitions;
|
||||
// @endregion[definitions-types]
|
||||
|
||||
export type Definitions = typeof definitions;
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* A2UI catalog RENDERERS — React implementations for the custom components
|
||||
* declared in `./definitions`. TypeScript enforces that the renderer map's
|
||||
* keys and prop shapes match the definitions exactly.
|
||||
*
|
||||
* Visual style: ShadCN aesthetic (neutral palette, rounded-xl, subtle
|
||||
* borders, clean typography). Tailwind utility classes only — no `cn()` /
|
||||
* `cva` helpers, no shadcn CLI install. Inline-cloned primitives live in
|
||||
* `../_components/`.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
import type { Definitions } from "./definitions";
|
||||
import { Card } from "../_components/card";
|
||||
import { Badge } from "../_components/badge";
|
||||
import { Button as UIButton } from "../_components/button";
|
||||
import { Separator } from "../_components/separator";
|
||||
|
||||
// `DynString` props are typed as `string | { path }` (see definitions.ts), but
|
||||
// the A2UI binder resolves path bindings before render — renderers only ever
|
||||
// see resolved strings. One shared helper keeps that narrowing in one place.
|
||||
const s = (v: unknown): string => (typeof v === "string" ? v : "");
|
||||
|
||||
// @region[renderers-tsx]
|
||||
export const renderers: CatalogRenderers<Definitions> = {
|
||||
/**
|
||||
* Card override: ShadCN-style outer container. The basic catalog's Card
|
||||
* uses inline styles; overriding here keeps the demo's tailwind aesthetic.
|
||||
* The flight schema renders Card > Column > [Title, Row, …]; the inner
|
||||
* Column adds the vertical spacing.
|
||||
*/
|
||||
Card: ({ props, children }) => (
|
||||
<Card className="w-full max-w-md p-5" data-testid="a2ui-fixed-card">
|
||||
{props.child ? children(props.child) : null}
|
||||
</Card>
|
||||
),
|
||||
Title: ({ props }) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
|
||||
Itinerary
|
||||
</p>
|
||||
<h3 className="text-base font-semibold leading-none tracking-tight text-neutral-900">
|
||||
{s(props.text)}
|
||||
</h3>
|
||||
</div>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
1-stop · economy
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
Airport: ({ props }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="font-mono text-2xl font-semibold tracking-wider text-neutral-900">
|
||||
{s(props.code)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
Arrow: () => (
|
||||
<div className="flex flex-1 items-center px-3">
|
||||
<Separator className="flex-1 bg-neutral-200" />
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mx-1 text-neutral-400"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
<Separator className="flex-1 bg-neutral-200" />
|
||||
</div>
|
||||
),
|
||||
AirlineBadge: ({ props }) => (
|
||||
<Badge variant="secondary" className="uppercase tracking-[0.08em]">
|
||||
{s(props.name)}
|
||||
</Badge>
|
||||
),
|
||||
PriceTag: ({ props }) => (
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
|
||||
Total
|
||||
</span>
|
||||
<span className="font-mono text-base font-semibold text-neutral-900">
|
||||
{s(props.amount)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
/**
|
||||
* Button override: this is a pure-presentation demo, so the button just
|
||||
* renders its label. The schema declares an `action` for visual fidelity,
|
||||
* but the click handler is inert until the Python SDK exposes
|
||||
* `action_handlers=` on `a2ui.render` (see `src/agents/a2ui_fixed.py`).
|
||||
*/
|
||||
Button: ({ props, children }) => (
|
||||
<UIButton className="w-full">
|
||||
{props.child ? children(props.child) : null}
|
||||
</UIButton>
|
||||
),
|
||||
};
|
||||
// @endregion[renderers-tsx]
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import { useA2UIFixedSchemaSuggestions } from "./suggestions";
|
||||
|
||||
export function Chat() {
|
||||
useA2UIFixedSchemaSuggestions();
|
||||
return (
|
||||
<CopilotChat agentId="a2ui-fixed-schema" className="h-full rounded-2xl" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Declarative Generative UI — A2UI Fixed Schema demo.
|
||||
*
|
||||
* In the fixed-schema flavor of A2UI, the component tree (schema) lives on
|
||||
* the frontend and the agent only streams *data* into the data model. The
|
||||
* flight card is ASSEMBLED from small sub-components in
|
||||
* `src/agents/a2ui_schemas/flight_schema.json` (Card > Column > [Title, Row, …]).
|
||||
*
|
||||
* - Definitions (zod schemas): `./a2ui/definitions.ts`
|
||||
* - Renderers (React): `./a2ui/renderers.tsx`
|
||||
* - Catalog wiring: `./a2ui/catalog.ts` (includes the basic catalog)
|
||||
* - Agent: `src/agents/a2ui_fixed.py` (emits an `a2ui_operations` container)
|
||||
*
|
||||
* Reference:
|
||||
* https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui/fixed-schema
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { CopilotKit } from "@copilotkit/react-core/v2";
|
||||
|
||||
import { catalog } from "./a2ui/catalog";
|
||||
import { Chat } from "./chat";
|
||||
|
||||
export default function A2UIFixedSchemaDemo() {
|
||||
return (
|
||||
// `a2ui.catalog` wires the fixed catalog into the A2UI activity renderer.
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit-a2ui-fixed-schema"
|
||||
agent="a2ui-fixed-schema"
|
||||
a2ui={{ catalog: catalog }}
|
||||
>
|
||||
<div className="flex justify-center items-center h-screen w-full bg-neutral-50">
|
||||
<div className="h-full w-full max-w-4xl border-x border-neutral-200 bg-white">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKit>
|
||||
);
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
||||
|
||||
export function useA2UIFixedSchemaSuggestions() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Find SFO → JFK",
|
||||
message: "Find me a flight from SFO to JFK on United for $289.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import { useA2uiRecoverySuggestions } from "./suggestions";
|
||||
|
||||
// Note: this integration's declarative-gen-ui demo does not ship a
|
||||
// sales-context hook (unlike langgraph-python / strands), so the recovery demo
|
||||
// does not inject one either. The agent's system prompt + the render planner's
|
||||
// composition guide carry the dataset; the aimock fixtures drive heal/exhaust.
|
||||
export function Chat() {
|
||||
useA2uiRecoverySuggestions();
|
||||
return <CopilotChat agentId="a2ui-recovery" className="h-full rounded-2xl" />;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* A2UI Error Recovery demo.
|
||||
*
|
||||
* Same dynamic-schema A2UI setup as declarative-gen-ui (it reuses that demo's
|
||||
* catalog), but it makes the toolkit's validate->retry recovery loop visible.
|
||||
* The dedicated runtime at `/api/copilotkit-a2ui-recovery` is configured with
|
||||
* `injectA2UITool: false` — the backend agent
|
||||
* (`src/agents/src/recovery_agent.py`) owns `generate_a2ui` via
|
||||
* `ag_ui_langgraph.get_a2ui_tools`, whose body runs the forced `render_a2ui`
|
||||
* sub-agent and the recovery loop + recovery-exhausted hard-fail envelope
|
||||
* IN-GRAPH (OSS-158 / OSS-375).
|
||||
*
|
||||
* The two suggestion pills drive aimock fixtures that force:
|
||||
* - HEAL: an invalid first render that recovers to a valid one
|
||||
* (building -> retrying -> painted).
|
||||
* - EXHAUST: an always-invalid render that hits the attempt cap
|
||||
* (a tasteful `failed` state, never a broken surface).
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { CopilotKit } from "@copilotkit/react-core/v2";
|
||||
|
||||
// Reuse the declarative-gen-ui catalog (same components, same catalogId).
|
||||
import { myCatalog } from "../declarative-gen-ui/a2ui/catalog";
|
||||
import { Chat } from "./chat";
|
||||
|
||||
export default function A2uiRecoveryDemo() {
|
||||
return (
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit-a2ui-recovery"
|
||||
agent="a2ui-recovery"
|
||||
a2ui={{ catalog: myCatalog }}
|
||||
>
|
||||
<div className="flex justify-center items-center h-screen w-full">
|
||||
<div className="h-full w-full max-w-4xl">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKit>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
||||
|
||||
// Two pills exercise the recovery loop deterministically via aimock fixtures
|
||||
// (showcase/aimock/d6/langgraph-fastapi/a2ui-recovery.json). Prompts are unique
|
||||
// within the langgraph-fastapi context so they don't collide with the
|
||||
// declarative-gen-ui (a2ui_dynamic) fixtures.
|
||||
// - "heal": inner render_a2ui returns free-form/sloppy args (components &
|
||||
// data as JSON strings) -> middleware parse_and_fix heals them
|
||||
// into a valid surface in a single pass -> painted.
|
||||
// - "exhaust": inner render_a2ui is invalid on every attempt -> attempt cap
|
||||
// hit -> a2ui_recovery_exhausted -> tasteful `failed` state.
|
||||
export function useA2uiRecoverySuggestions() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Recover a bad render",
|
||||
message:
|
||||
"Put together a quarterly metrics overview and repair a malformed first attempt.",
|
||||
},
|
||||
{
|
||||
title: "Show an unrecoverable failure",
|
||||
message:
|
||||
"Put together an overview whose every render is invalid so I can see the fallback.",
|
||||
},
|
||||
],
|
||||
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>
|
||||
);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Publishes the current agent-config toggles to the agent runtime via
|
||||
* `useAgentContext`. Lives inside the `<CopilotKit>` provider so the
|
||||
* context store is reachable. The middleware on the Python side reads
|
||||
* this entry off the agent's runtime context on every turn and routes
|
||||
* it into the model's prompt.
|
||||
*/
|
||||
|
||||
import { useAgentContext } from "@copilotkit/react-core/v2";
|
||||
import type { AgentConfig } from "./config-types";
|
||||
|
||||
export function ConfigContextRelay({ config }: { config: AgentConfig }) {
|
||||
useAgentContext({
|
||||
description:
|
||||
"Agent response preferences. Apply tone, expertise level, and response length to every reply.",
|
||||
value: {
|
||||
tone: config.tone,
|
||||
expertise: config.expertise,
|
||||
responseLength: config.responseLength,
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type Tone = "professional" | "casual" | "enthusiastic";
|
||||
export type Expertise = "beginner" | "intermediate" | "expert";
|
||||
export type ResponseLength = "concise" | "detailed";
|
||||
|
||||
export interface AgentConfig {
|
||||
tone: Tone;
|
||||
expertise: Expertise;
|
||||
responseLength: ResponseLength;
|
||||
}
|
||||
|
||||
export const DEFAULT_AGENT_CONFIG: AgentConfig = {
|
||||
tone: "professional",
|
||||
expertise: "intermediate",
|
||||
responseLength: "concise",
|
||||
};
|
||||
|
||||
export const TONE_OPTIONS: Tone[] = ["professional", "casual", "enthusiastic"];
|
||||
export const EXPERTISE_OPTIONS: Expertise[] = [
|
||||
"beginner",
|
||||
"intermediate",
|
||||
"expert",
|
||||
];
|
||||
export const RESPONSE_LENGTH_OPTIONS: ResponseLength[] = [
|
||||
"concise",
|
||||
"detailed",
|
||||
];
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
|
||||
import { ConfigCard } from "./config-card";
|
||||
import type { AgentConfig } from "./config-types";
|
||||
|
||||
interface DemoLayoutProps {
|
||||
config: AgentConfig;
|
||||
onToneChange: (tone: AgentConfig["tone"]) => void;
|
||||
onExpertiseChange: (expertise: AgentConfig["expertise"]) => void;
|
||||
onResponseLengthChange: (length: AgentConfig["responseLength"]) => void;
|
||||
}
|
||||
|
||||
export function DemoLayout({
|
||||
config,
|
||||
onToneChange,
|
||||
onExpertiseChange,
|
||||
onResponseLengthChange,
|
||||
}: DemoLayoutProps) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col gap-3 p-6">
|
||||
<ConfigCard
|
||||
config={config}
|
||||
onToneChange={onToneChange}
|
||||
onExpertiseChange={onExpertiseChange}
|
||||
onResponseLengthChange={onResponseLengthChange}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden rounded-md border border-[var(--border)]">
|
||||
<CopilotChat
|
||||
agentId="agent-config-demo"
|
||||
className="h-full rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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]
|
||||
);
|
||||
}
|
||||
+39
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { DEMO_TOKEN } from "./demo-token";
|
||||
|
||||
interface SignInCardProps {
|
||||
onSignIn: (token: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthenticated landing card for the auth demo. Surfaces the demo bearer
|
||||
* token in plain text so visitors can see exactly what gets sent on the
|
||||
* `Authorization` header — there's no real form because the value is fixed
|
||||
* by the runtime gate. Clicking "Sign in" stores the token via
|
||||
* `useDemoAuth()`, which causes the parent to mount `<CopilotKit>`.
|
||||
*/
|
||||
export function SignInCard({ onSignIn }: SignInCardProps) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<Card data-testid="auth-sign-in-card" className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in to start chatting</CardTitle>
|
||||
<CardDescription>
|
||||
The runtime rejects requests without an{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
|
||||
Authorization
|
||||
</code>{" "}
|
||||
header. Sign in below to mount the chat with a demo bearer token
|
||||
attached.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Demo token
|
||||
</p>
|
||||
<code
|
||||
data-testid="auth-demo-token"
|
||||
className="mt-1 block rounded-md border bg-muted px-3 py-2 font-mono text-sm"
|
||||
>
|
||||
{DEMO_TOKEN}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Real apps should issue per-user tokens via your identity provider
|
||||
and never hard-code shared secrets.
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="auth-sign-in-button"
|
||||
className="w-full"
|
||||
onClick={() => onSignIn(DEMO_TOKEN)}
|
||||
>
|
||||
Sign in with demo token
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { DEMO_TOKEN } from "./demo-token";
|
||||
|
||||
const STORAGE_KEY = "copilotkit:auth-demo:token";
|
||||
|
||||
export interface DemoAuthHandle {
|
||||
isAuthenticated: boolean;
|
||||
/** The token string when authenticated, otherwise null. */
|
||||
token: string | null;
|
||||
/** The full `Bearer <token>` value when authenticated, otherwise null. */
|
||||
authorizationHeader: string | null;
|
||||
/**
|
||||
* Whether the user has signed in at least once during the current page
|
||||
* session. Used by the page to decide between the first-paint SignInCard
|
||||
* (never signed in) and the persistent chat-with-amber-banner state
|
||||
* (signed in, then signed out) — the latter is the only state that
|
||||
* actually showcases the runtime rejecting unauthenticated requests.
|
||||
* Resets on full page reload by design.
|
||||
*/
|
||||
hasEverSignedIn: boolean;
|
||||
/** Sign in with the provided token. */
|
||||
signIn: (token: string) => void;
|
||||
/** Clear the stored token. */
|
||||
signOut: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent demo auth state for the /demos/auth showcase cell. Tokens are
|
||||
* stored in localStorage so a page reload doesn't kick the user back out;
|
||||
* first paint of a fresh visitor is unauthenticated, which lets the demo
|
||||
* showcase its sign-in CTA up front.
|
||||
*
|
||||
* This is a DEMO. Never store real bearer tokens in localStorage in a
|
||||
* production application — that exposes them to any script running on the
|
||||
* page.
|
||||
*/
|
||||
export function useDemoAuth(): DemoAuthHandle {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [hasEverSignedIn, setHasEverSignedIn] = useState(false);
|
||||
|
||||
// Hydrate from localStorage after mount. Reading on initial render would
|
||||
// mismatch SSR (where window is undefined); deferring to useEffect keeps
|
||||
// first paint unauthenticated and avoids hydration warnings.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
setToken(stored);
|
||||
setHasEverSignedIn(true);
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable (privacy mode, etc.) — fall back to
|
||||
// in-memory only.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const signIn = useCallback((nextToken: string) => {
|
||||
setToken(nextToken);
|
||||
setHasEverSignedIn(true);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, nextToken);
|
||||
} catch {
|
||||
// Ignore — in-memory state still works.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
setToken(null);
|
||||
// hasEverSignedIn intentionally stays true so the page keeps showing
|
||||
// the chat surface (with amber banner) after sign-out. That is the
|
||||
// state that demonstrates the runtime returning 401.
|
||||
try {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The runtime gate compares against a fixed token, so anything other than
|
||||
// DEMO_TOKEN won't actually authenticate against the API. We still allow
|
||||
// arbitrary strings here because validation is the runtime's job — the UI
|
||||
// just owns "what header are we sending".
|
||||
return {
|
||||
isAuthenticated: token !== null,
|
||||
token,
|
||||
authorizationHeader: token ? `Bearer ${token}` : null,
|
||||
hasEverSignedIn,
|
||||
signIn,
|
||||
signOut,
|
||||
};
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useAgent } from "@copilotkit/react-core/v2";
|
||||
import { TodoList } from "./todo-list";
|
||||
|
||||
export function ExampleCanvas() {
|
||||
const { agent } = useAgent({ agentId: "beautiful-chat" });
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-[--background]">
|
||||
<div className="max-w-4xl mx-auto px-8 py-10 h-full">
|
||||
<TodoList
|
||||
todos={agent.state?.todos || []}
|
||||
onUpdate={(updatedTodos) => agent.setState({ todos: updatedTodos })}
|
||||
isAgentRunning={agent.isRunning}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Card } from "../ui/card";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import { Button } from "../ui/button";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface Todo {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
status: "pending" | "completed";
|
||||
}
|
||||
|
||||
interface TodoCardProps {
|
||||
todo: Todo;
|
||||
onToggleStatus: (todo: Todo) => void;
|
||||
onDelete: (todo: Todo) => void;
|
||||
onUpdateTitle: (todoId: string, title: string) => void;
|
||||
onUpdateDescription: (todoId: string, description: string) => void;
|
||||
onUpdateEmoji: (todoId: string, emoji: string) => void;
|
||||
}
|
||||
|
||||
const EMOJI_OPTIONS = ["✅", "🔥", "🎯", "💡", "🚀"];
|
||||
|
||||
export function TodoCard({
|
||||
todo,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
onUpdateTitle,
|
||||
onUpdateDescription,
|
||||
onUpdateEmoji,
|
||||
}: TodoCardProps) {
|
||||
const [editingField, setEditingField] = useState<
|
||||
"title" | "description" | null
|
||||
>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const isCompleted = todo.status === "completed";
|
||||
const truncatedDescription =
|
||||
todo.description.length > 120
|
||||
? todo.description.slice(0, 120) + "..."
|
||||
: todo.description;
|
||||
|
||||
const startEdit = (field: "title" | "description") => {
|
||||
setEditingField(field);
|
||||
setEditValue(field === "title" ? todo.title : todo.description);
|
||||
};
|
||||
|
||||
const saveEdit = (field: "title" | "description") => {
|
||||
if (editValue.trim()) {
|
||||
if (field === "title") {
|
||||
onUpdateTitle(todo.id, editValue.trim());
|
||||
} else {
|
||||
onUpdateDescription(todo.id, editValue.trim());
|
||||
}
|
||||
}
|
||||
setEditingField(null);
|
||||
setEditValue("");
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingField(null);
|
||||
setEditValue("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height =
|
||||
textareaRef.current.scrollHeight + "px";
|
||||
}
|
||||
}, [editValue]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"group relative p-5 transition-all duration-150",
|
||||
isCompleted && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{/* Delete — top right on hover */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(todo)}
|
||||
className="absolute top-3 right-3 h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label="Delete todo"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
{/* Emoji avatar */}
|
||||
<div className="relative inline-block mb-3">
|
||||
<button
|
||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||
className={cn(
|
||||
"block text-3xl leading-none cursor-pointer rounded-xl p-2 transition-colors",
|
||||
isCompleted ? "bg-[var(--muted)]" : "bg-[var(--secondary)]",
|
||||
)}
|
||||
aria-label="Change emoji"
|
||||
>
|
||||
{todo.emoji}
|
||||
</button>
|
||||
|
||||
{showEmojiPicker && (
|
||||
<div className="absolute top-0 left-full ml-2 z-10 flex gap-1 p-1.5 rounded-full bg-[var(--card)] border border-[var(--border)] shadow-lg">
|
||||
{EMOJI_OPTIONS.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => {
|
||||
onUpdateEmoji(todo.id, emoji);
|
||||
setShowEmojiPicker(false);
|
||||
}}
|
||||
className="text-lg w-8 h-8 flex items-center justify-center rounded-full cursor-pointer transition-colors hover:bg-[var(--secondary)]"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isCompleted}
|
||||
onCheckedChange={() => onToggleStatus(todo)}
|
||||
className="mt-[2px]"
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingField === "title" ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={() => saveEdit("title")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveEdit("title");
|
||||
if (e.key === "Escape") cancelEdit();
|
||||
}}
|
||||
className="w-full text-base font-semibold focus:outline-none bg-transparent text-[var(--foreground)] border-b-2 border-[var(--primary)] pb-[2px]"
|
||||
autoFocus
|
||||
aria-label="Edit todo title"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => startEdit("title")}
|
||||
className={cn(
|
||||
"text-base font-semibold cursor-text break-words leading-snug",
|
||||
isCompleted
|
||||
? "text-[var(--muted-foreground)] line-through"
|
||||
: "text-[var(--foreground)]",
|
||||
)}
|
||||
>
|
||||
{todo.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingField === "description" ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={() => saveEdit("description")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") cancelEdit();
|
||||
}}
|
||||
className="w-full mt-1.5 text-sm leading-relaxed focus:outline-none resize-none bg-transparent text-[var(--muted-foreground)] border-b-2 border-[var(--primary)] pb-[2px]"
|
||||
rows={1}
|
||||
autoFocus
|
||||
aria-label="Edit todo description"
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
onClick={() => startEdit("description")}
|
||||
className={cn(
|
||||
"mt-1.5 text-sm leading-relaxed cursor-text",
|
||||
isCompleted
|
||||
? "text-[var(--muted-foreground)] line-through"
|
||||
: "text-[var(--muted-foreground)]",
|
||||
)}
|
||||
>
|
||||
{truncatedDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { TodoCard } from "./todo-card";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
interface Todo {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
status: "pending" | "completed";
|
||||
}
|
||||
|
||||
interface TodoColumnProps {
|
||||
title: string;
|
||||
todos: Todo[];
|
||||
emptyMessage: string;
|
||||
showAddButton?: boolean;
|
||||
onAddTodo?: () => void;
|
||||
onToggleStatus: (todo: Todo) => void;
|
||||
onDelete: (todo: Todo) => void;
|
||||
onUpdateTitle: (todoId: string, title: string) => void;
|
||||
onUpdateDescription: (todoId: string, description: string) => void;
|
||||
onUpdateEmoji: (todoId: string, emoji: string) => void;
|
||||
isAgentRunning: boolean;
|
||||
}
|
||||
|
||||
export function TodoColumn({
|
||||
title,
|
||||
todos,
|
||||
emptyMessage,
|
||||
showAddButton = false,
|
||||
onAddTodo,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
onUpdateTitle,
|
||||
onUpdateDescription,
|
||||
onUpdateEmoji,
|
||||
isAgentRunning,
|
||||
}: TodoColumnProps) {
|
||||
return (
|
||||
<section aria-label={`${title} column`} className="flex-1 min-w-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-bold tracking-tight text-[var(--foreground)]">
|
||||
{title}
|
||||
</h2>
|
||||
<Badge variant="secondary">{todos.length}</Badge>
|
||||
</div>
|
||||
{showAddButton && onAddTodo && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onAddTodo}
|
||||
disabled={isAgentRunning}
|
||||
aria-label="Add new todo"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="space-y-3">
|
||||
{todos.length === 0 ? (
|
||||
<div className="text-center text-sm rounded-[var(--radius)] border-2 border-dashed border-[var(--border)] p-5 min-h-[151px] flex items-center justify-center text-[var(--muted-foreground)]">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
todos.map((todo) => (
|
||||
<TodoCard
|
||||
key={todo.id}
|
||||
todo={todo}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onDelete={onDelete}
|
||||
onUpdateTitle={onUpdateTitle}
|
||||
onUpdateDescription={onUpdateDescription}
|
||||
onUpdateEmoji={onUpdateEmoji}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { TodoColumn } from "./todo-column";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface Todo {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
status: "pending" | "completed";
|
||||
}
|
||||
|
||||
interface TodoListProps {
|
||||
todos: Todo[];
|
||||
onUpdate: (todos: Todo[]) => void;
|
||||
isAgentRunning: boolean;
|
||||
}
|
||||
|
||||
export function TodoList({ todos, onUpdate, isAgentRunning }: TodoListProps) {
|
||||
const pendingTodos = todos.filter((t) => t.status === "pending");
|
||||
const completedTodos = todos.filter((t) => t.status === "completed");
|
||||
|
||||
const toggleStatus = (todo: Todo) => {
|
||||
const updated = todos.map((t) =>
|
||||
t.id === todo.id
|
||||
? {
|
||||
...t,
|
||||
status: (t.status === "completed" ? "pending" : "completed") as
|
||||
| "pending"
|
||||
| "completed",
|
||||
}
|
||||
: t,
|
||||
);
|
||||
onUpdate(updated);
|
||||
};
|
||||
|
||||
const deleteTodo = (todo: Todo) => {
|
||||
onUpdate(todos.filter((t) => t.id !== todo.id));
|
||||
};
|
||||
|
||||
const updateTitle = (todoId: string, title: string) => {
|
||||
const updated = todos.map((t) => (t.id === todoId ? { ...t, title } : t));
|
||||
onUpdate(updated);
|
||||
};
|
||||
|
||||
const updateDescription = (todoId: string, description: string) => {
|
||||
const updated = todos.map((t) =>
|
||||
t.id === todoId ? { ...t, description } : t,
|
||||
);
|
||||
onUpdate(updated);
|
||||
};
|
||||
|
||||
const updateEmoji = (todoId: string, emoji: string) => {
|
||||
const updated = todos.map((t) => (t.id === todoId ? { ...t, emoji } : t));
|
||||
onUpdate(updated);
|
||||
};
|
||||
|
||||
const addTodo = () => {
|
||||
const newTodo: Todo = {
|
||||
id: crypto.randomUUID(),
|
||||
title: "New Todo",
|
||||
description: "Add a description",
|
||||
emoji: "🎯",
|
||||
status: "pending",
|
||||
};
|
||||
onUpdate([...todos, newTodo]);
|
||||
};
|
||||
|
||||
if (!todos || todos.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||
<div className="text-5xl">✏️</div>
|
||||
<p className="text-base font-semibold text-[--foreground]">
|
||||
No todos yet
|
||||
</p>
|
||||
<p className="text-sm text-[--muted-foreground]">
|
||||
Create your first task to get started
|
||||
</p>
|
||||
<Button onClick={addTodo} disabled={isAgentRunning} className="mt-2">
|
||||
Add a task
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-8 h-full">
|
||||
<TodoColumn
|
||||
title="To Do"
|
||||
todos={pendingTodos}
|
||||
emptyMessage="No pending todos"
|
||||
showAddButton
|
||||
onAddTodo={addTodo}
|
||||
onToggleStatus={toggleStatus}
|
||||
onDelete={deleteTodo}
|
||||
onUpdateTitle={updateTitle}
|
||||
onUpdateDescription={updateDescription}
|
||||
onUpdateEmoji={updateEmoji}
|
||||
isAgentRunning={isAgentRunning}
|
||||
/>
|
||||
<TodoColumn
|
||||
title="Done"
|
||||
todos={completedTodos}
|
||||
emptyMessage="No completed todos yet"
|
||||
onToggleStatus={toggleStatus}
|
||||
onDelete={deleteTodo}
|
||||
onUpdateTitle={updateTitle}
|
||||
onUpdateDescription={updateDescription}
|
||||
onUpdateEmoji={updateEmoji}
|
||||
isAgentRunning={isAgentRunning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { ModeToggle } from "./mode-toggle";
|
||||
import { useFrontendTool } from "@copilotkit/react-core/v2";
|
||||
|
||||
interface ExampleLayoutProps {
|
||||
chatContent: ReactNode;
|
||||
appContent: ReactNode;
|
||||
}
|
||||
|
||||
export function ExampleLayout({ chatContent, appContent }: ExampleLayoutProps) {
|
||||
const [mode, setMode] = useState<"chat" | "app">("chat");
|
||||
|
||||
useFrontendTool({
|
||||
name: "enableAppMode",
|
||||
description:
|
||||
"Enable app mode, make sure its open when interacting with todos.",
|
||||
handler: async () => {
|
||||
setMode("app");
|
||||
},
|
||||
});
|
||||
|
||||
useFrontendTool({
|
||||
name: "enableChatMode",
|
||||
description: "Enable chat mode",
|
||||
handler: async () => {
|
||||
setMode("chat");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-row pb-6">
|
||||
<ModeToggle mode={mode} onModeChange={setMode} />
|
||||
|
||||
{/* Chat Content */}
|
||||
<div
|
||||
className={`max-h-full flex flex-col dark:bg-stone-950 ${
|
||||
mode === "app"
|
||||
? "w-1/3 px-6 max-lg:hidden" // Hide on mobile in app mode
|
||||
: "flex-1 max-lg:px-4"
|
||||
}`}
|
||||
>
|
||||
<div className="shrink-0 pt-6 pl-6 pb-2 max-lg:pl-4 max-lg:pt-4 flex gap-1.5 items-center align-center">
|
||||
<span className="font-extrabold text-2xl pb-1.5">CopilotKit</span>
|
||||
<img
|
||||
src="/copilotkit-logo-mark.svg"
|
||||
alt="CopilotKit"
|
||||
className="h-7"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">{chatContent}</div>
|
||||
</div>
|
||||
|
||||
{/* State Panel */}
|
||||
<div
|
||||
className={`h-full overflow-hidden ${
|
||||
mode === "app"
|
||||
? "w-2/3 max-lg:w-full border-l border-[var(--border)] max-lg:border-l-0" // Full width on mobile
|
||||
: "w-0 border-l-0"
|
||||
}`}
|
||||
>
|
||||
<div className="w-full lg:w-[66.666vw] h-full">{appContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
interface ModeToggleProps {
|
||||
mode: "chat" | "app";
|
||||
onModeChange: (mode: "chat" | "app") => void;
|
||||
}
|
||||
|
||||
export function ModeToggle({ mode, onModeChange }: ModeToggleProps) {
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 flex rounded-full border border-[var(--border)] bg-[var(--secondary)] p-0.5 max-lg:top-2 max-lg:right-2 max-lg:scale-90">
|
||||
<button
|
||||
onClick={() => onModeChange("chat")}
|
||||
className={`px-4 py-1.5 rounded-full text-[13px] font-medium transition-all cursor-pointer ${
|
||||
mode === "chat"
|
||||
? "bg-[var(--card)] text-[var(--card-foreground)] shadow-sm"
|
||||
: "text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
>
|
||||
Chat
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onModeChange("app")}
|
||||
className={`px-4 py-1.5 rounded-full text-[13px] font-medium transition-all cursor-pointer ${
|
||||
mode === "app"
|
||||
? "bg-[var(--card)] text-[var(--card-foreground)] shadow-sm"
|
||||
: "text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
>
|
||||
App
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
BarChart as RechartsBarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Rectangle,
|
||||
} from "recharts";
|
||||
import { z } from "zod";
|
||||
import { CHART_COLORS, CHART_CONFIG } from "./config";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "../../ui/card";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
|
||||
export const BarChartProps = z.object({
|
||||
title: z.string().describe("Chart title"),
|
||||
description: z.string().describe("Brief description or subtitle"),
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type BarChartProps = z.infer<typeof BarChartProps>;
|
||||
|
||||
/** Tracks seen indices so only NEW bars get the fade-in animation. */
|
||||
function useSeenIndices() {
|
||||
const seen = useRef(new Set<number>());
|
||||
return {
|
||||
isNew(index: number) {
|
||||
if (seen.current.has(index)) return false;
|
||||
seen.current.add(index);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function AnimatedBar(props: any) {
|
||||
const { isNew, ...rest } = props;
|
||||
return (
|
||||
<g
|
||||
style={
|
||||
isNew
|
||||
? {
|
||||
animation: "barSlideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Rectangle {...rest} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarChart({ title, description, data }: BarChartProps) {
|
||||
const { isNew } = useSeenIndices();
|
||||
|
||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<Card className="max-w-2xl mx-auto my-4">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-[var(--muted-foreground)]" />
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
|
||||
No data available
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="max-w-2xl mx-auto my-4 overflow-hidden">
|
||||
{/* Scoped keyframe — no globals.css needed */}
|
||||
<style>{`
|
||||
@keyframes barSlideIn {
|
||||
from { transform: translateY(40px); opacity: 0; }
|
||||
20% { opacity: 1; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center h-6 w-6 rounded-md bg-[var(--secondary)]">
|
||||
<BarChart3 className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
|
||||
</div>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<RechartsBarChart
|
||||
data={data}
|
||||
margin={{ top: 12, right: 12, bottom: 4, left: -8 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={CHART_CONFIG.tooltipStyle}
|
||||
cursor={{ fill: "var(--secondary)", opacity: 0.5 }}
|
||||
/>
|
||||
<Bar
|
||||
isAnimationActive={false}
|
||||
dataKey="value"
|
||||
radius={[6, 6, 0, 0]}
|
||||
maxBarSize={48}
|
||||
shape={
|
||||
((props: Record<string, unknown>) => (
|
||||
<AnimatedBar
|
||||
{...props}
|
||||
isNew={isNew(props.index as number)}
|
||||
/>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
)) as any
|
||||
}
|
||||
>
|
||||
{data.map((_, index) => (
|
||||
<Cell
|
||||
key={index}
|
||||
fill={CHART_COLORS[index % CHART_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* CopilotKit brand chart palette — Plus Jakarta Sans / brand color system.
|
||||
*/
|
||||
export const CHART_COLORS = [
|
||||
"#BEC2FF", // lilac-400
|
||||
"#85ECCE", // mint-400
|
||||
"#FFAC4D", // orange-400
|
||||
"#FFF388", // yellow-400
|
||||
"#189370", // mint-800
|
||||
"#EEE6FE", // primary-100
|
||||
"#FA5F67", // red-400
|
||||
] as const;
|
||||
|
||||
export const CHART_CONFIG = {
|
||||
tooltipStyle: {
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "10px",
|
||||
padding: "10px 14px",
|
||||
color: "var(--foreground)",
|
||||
fontSize: "13px",
|
||||
fontFamily: "var(--font-body)",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
|
||||
},
|
||||
};
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import { z } from "zod";
|
||||
import { CHART_COLORS } from "./config";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "../../ui/card";
|
||||
|
||||
export const PieChartProps = z.object({
|
||||
title: z.string().describe("Chart title"),
|
||||
description: z.string().describe("Brief description or subtitle"),
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type PieChartProps = z.infer<typeof PieChartProps>;
|
||||
|
||||
/** Custom SVG donut chart built with <circle> + stroke-dasharray. */
|
||||
function DonutChart({
|
||||
data,
|
||||
size = 240,
|
||||
strokeWidth = 40,
|
||||
}: {
|
||||
data: { label: string; value: number }[];
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const center = size / 2;
|
||||
|
||||
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
|
||||
|
||||
// Calculate each slice's arc length and starting position
|
||||
let accumulated = 0;
|
||||
const slices = data.map((item, index) => {
|
||||
const val = Number(item.value) || 0;
|
||||
const ratio = total > 0 ? val / total : 0;
|
||||
const arc = ratio * circumference;
|
||||
const startAt = accumulated;
|
||||
accumulated += arc;
|
||||
return {
|
||||
...item,
|
||||
arc,
|
||||
gap: circumference - arc,
|
||||
// Negative dashoffset shifts the dash forward (clockwise) to the correct position
|
||||
dashoffset: -startAt,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="100%"
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="block mx-auto"
|
||||
style={{ maxWidth: size, transform: "scaleX(-1)" }}
|
||||
>
|
||||
{/* Background ring */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--secondary)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Data slices */}
|
||||
{slices.map((slice, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={slice.color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${slice.arc} ${slice.gap}`}
|
||||
strokeDashoffset={slice.dashoffset}
|
||||
strokeLinecap="butt"
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PieChart({ title, description, data }: PieChartProps) {
|
||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<Card className="max-w-lg mx-auto my-4">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
|
||||
No data available
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg mx-auto my-4 overflow-hidden">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<DonutChart data={data} />
|
||||
|
||||
{/* Legend */}
|
||||
<div className="space-y-2 pt-4">
|
||||
{data.map((item, index) => {
|
||||
const val = Number(item.value) || 0;
|
||||
const pct = total > 0 ? ((val / total) * 100).toFixed(0) : 0;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-3 text-sm transition-opacity duration-300 ease-out"
|
||||
style={{ opacity: 1 }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full shrink-0"
|
||||
style={{
|
||||
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
<span className="flex-1 text-[var(--foreground)] truncate">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-[var(--muted-foreground)] tabular-nums">
|
||||
{val.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-[var(--muted-foreground)] text-sm w-10 text-right tabular-nums">
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent } from "../ui/card";
|
||||
import { Button } from "../ui/button";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Spinner } from "../ui/spinner";
|
||||
import { Check, X, Clock, ChevronRight } from "lucide-react";
|
||||
|
||||
export interface TimeSlot {
|
||||
date: string;
|
||||
time: string;
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
export interface MeetingTimePickerProps {
|
||||
status: "inProgress" | "executing" | "complete";
|
||||
respond?: (response: string) => void;
|
||||
reasonForScheduling?: string;
|
||||
meetingDuration?: number;
|
||||
title?: string;
|
||||
timeSlots?: TimeSlot[];
|
||||
}
|
||||
|
||||
export function MeetingTimePicker({
|
||||
status,
|
||||
respond,
|
||||
reasonForScheduling,
|
||||
meetingDuration,
|
||||
title = "Schedule a Meeting",
|
||||
timeSlots = [
|
||||
{ date: "Tomorrow", time: "2:00 PM", duration: "30 min" },
|
||||
{ date: "Friday", time: "10:00 AM", duration: "30 min" },
|
||||
{ date: "Next Monday", time: "3:00 PM", duration: "30 min" },
|
||||
],
|
||||
}: MeetingTimePickerProps) {
|
||||
const displayTitle = reasonForScheduling || title;
|
||||
const slots = meetingDuration
|
||||
? timeSlots.map((slot) => ({ ...slot, duration: `${meetingDuration} min` }))
|
||||
: timeSlots;
|
||||
const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(null);
|
||||
const [declined, setDeclined] = useState(false);
|
||||
|
||||
const handleSelectSlot = (slot: TimeSlot) => {
|
||||
setSelectedSlot(slot);
|
||||
respond?.(
|
||||
`Meeting scheduled for ${slot.date} at ${slot.time}${slot.duration ? ` (${slot.duration})` : ""}.`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
setDeclined(true);
|
||||
respond?.(
|
||||
"The user declined all proposed meeting times. Please suggest alternative times or ask for their availability.",
|
||||
);
|
||||
};
|
||||
|
||||
// Confirmed state
|
||||
if (selectedSlot) {
|
||||
return (
|
||||
<Card className="max-w-md w-full mx-auto mb-4 overflow-hidden">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col items-center text-center gap-3">
|
||||
<div className="flex items-center justify-center h-10 w-10 rounded-full bg-[#189370]">
|
||||
<Check className="h-5 w-5 text-white" strokeWidth={3} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-[var(--foreground)]">
|
||||
Meeting Scheduled
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
{selectedSlot.date} at {selectedSlot.time}
|
||||
</p>
|
||||
</div>
|
||||
{selectedSlot.duration && (
|
||||
<Badge variant="secondary">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
{selectedSlot.duration}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Declined state
|
||||
if (declined) {
|
||||
return (
|
||||
<Card className="max-w-md w-full mx-auto mb-4 overflow-hidden">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col items-center text-center gap-3">
|
||||
<div className="flex items-center justify-center h-12 w-12 rounded-full bg-[var(--secondary)]">
|
||||
<X className="h-6 w-6 text-[var(--muted-foreground)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-[var(--foreground)]">
|
||||
No Time Selected
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
Looking for a better time that works for you
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Selection state
|
||||
return (
|
||||
<Card className="max-w-md w-full mx-auto mb-4 overflow-hidden">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col items-center text-center mb-5">
|
||||
<div className="flex items-center justify-center h-12 w-12 rounded-full bg-[var(--accent)] mb-3">
|
||||
<Clock className="h-6 w-6 text-[#BEC2FF]" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-[var(--foreground)]">
|
||||
{displayTitle}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
{status === "inProgress"
|
||||
? "Finding available times..."
|
||||
: "Pick a time that works for you"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status === "inProgress" && (
|
||||
<div className="flex justify-center py-6">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "executing" && (
|
||||
<div className="space-y-3">
|
||||
{slots.map((slot, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleSelectSlot(slot)}
|
||||
className="group w-full px-6 py-5 rounded-[var(--radius)]
|
||||
border border-[var(--border)]
|
||||
hover:border-[var(--ring)] hover:bg-[var(--accent)]
|
||||
transition-all duration-150 cursor-pointer
|
||||
flex items-center gap-4"
|
||||
>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-semibold text-base text-[var(--foreground)]">
|
||||
{slot.date}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)] mt-0.5">
|
||||
{slot.time}
|
||||
</div>
|
||||
</div>
|
||||
{slot.duration && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 text-sm px-3 py-1"
|
||||
>
|
||||
{slot.duration}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-[var(--muted-foreground)] opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full mt-1 text-xs text-[var(--muted-foreground)]"
|
||||
onClick={handleDecline}
|
||||
>
|
||||
None of these work
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Wrench, Check, ChevronDown } from "lucide-react";
|
||||
import { Spinner } from "./ui/spinner";
|
||||
|
||||
interface ToolReasoningProps {
|
||||
name: string;
|
||||
args?: object | unknown;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.length} items]`;
|
||||
if (typeof value === "object" && value !== null)
|
||||
return `{${Object.keys(value).length} keys}`;
|
||||
if (typeof value === "string") return `"${value}"`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function ToolReasoning({ name, args, status }: ToolReasoningProps) {
|
||||
const entries = args ? Object.entries(args) : [];
|
||||
const detailsRef = useRef<HTMLDetailsElement>(null);
|
||||
const isRunning = status === "executing" || status === "inProgress";
|
||||
|
||||
// Auto-open while executing, auto-close when complete
|
||||
useEffect(() => {
|
||||
if (!detailsRef.current) return;
|
||||
detailsRef.current.open = isRunning;
|
||||
}, [isRunning]);
|
||||
|
||||
const statusIcon = isRunning ? (
|
||||
<Spinner size="sm" className="h-3 w-3" />
|
||||
) : (
|
||||
<Check className="h-3 w-3 text-emerald-500" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="my-1.5">
|
||||
{entries.length > 0 ? (
|
||||
<details ref={detailsRef} open className="group">
|
||||
<summary className="flex items-center gap-2 cursor-pointer list-none text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors">
|
||||
{statusIcon}
|
||||
<Wrench className="h-3 w-3" />
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ fontFamily: "var(--font-code)" }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 ml-auto transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<div className="ml-5 mt-1.5 rounded-md bg-[var(--secondary)] px-3 py-2 space-y-1">
|
||||
{entries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex gap-2 min-w-0 text-xs"
|
||||
style={{ fontFamily: "var(--font-code)" }}
|
||||
>
|
||||
<span className="text-[var(--muted-foreground)] shrink-0">
|
||||
{key}:
|
||||
</span>
|
||||
<span className="text-[var(--foreground)] truncate">
|
||||
{formatValue(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
|
||||
{statusIcon}
|
||||
<Wrench className="h-3 w-3" />
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ fontFamily: "var(--font-code)" }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-[var(--primary)] text-[var(--primary-foreground)]",
|
||||
secondary:
|
||||
"border-transparent bg-[var(--secondary)] text-[var(--secondary-foreground)]",
|
||||
outline: "border-[var(--border)] text-[var(--foreground)]",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "secondary",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius)] text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:pointer-events-none disabled:opacity-50 cursor-pointer",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90",
|
||||
secondary:
|
||||
"bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:opacity-80",
|
||||
outline:
|
||||
"border border-[var(--border)] bg-[var(--background)] hover:bg-[var(--secondary)]",
|
||||
ghost:
|
||||
"hover:bg-[var(--secondary)] hover:text-[var(--secondary-foreground)]",
|
||||
destructive:
|
||||
"bg-[var(--destructive)] text-[var(--destructive-foreground)] hover:opacity-90",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-6",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-[var(--radius)] border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-[var(--muted-foreground)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ComponentRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-5 w-5 shrink-0 rounded-md border border-[var(--border)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[var(--primary)] data-[state=checked]:text-[var(--primary-foreground)] data-[state=checked]:border-transparent cursor-pointer transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
|
||||
<Check className="h-3.5 w-3.5" strokeWidth={3} />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-[var(--radius)] border border-[var(--input)] bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-[var(--muted-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ComponentRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-[var(--border)]",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface SpinnerProps {
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: "h-4 w-4 border-2",
|
||||
md: "h-6 w-6 border-2",
|
||||
lg: "h-8 w-8 border-3",
|
||||
};
|
||||
|
||||
export function Spinner({ className, size = "md" }: SpinnerProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block rounded-full border-[var(--muted)] border-t-[var(--primary)] animate-spin",
|
||||
sizeMap[size],
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Demonstration Catalog — Component Definitions
|
||||
*
|
||||
* Platform-agnostic definitions: component names, props (Zod), descriptions.
|
||||
* This is the contract between the app and the AI agent. Agents receive these
|
||||
* definitions as context so they know what components are available.
|
||||
*
|
||||
* Renderers (React, React Native, etc.) import these definitions and provide
|
||||
* platform-specific implementations, type-checked against the Zod schemas.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Dynamic string: accepts either a literal string or a data-model path binding
|
||||
* like `{ path: "airline" }`. The GenericBinder resolves path bindings to the
|
||||
* actual value at render time.
|
||||
*/
|
||||
const DynString = z.union([z.string(), z.object({ path: z.string() })]);
|
||||
|
||||
export const demonstrationCatalogDefinitions = {
|
||||
Title: {
|
||||
description: "A heading. Use for section titles and page headers.",
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
level: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
// Custom Row/Column: override the basic catalog's versions so we can
|
||||
// honour `gap` (basic Row/Column from web_core ignores it). Children may
|
||||
// be a literal-string array (flat trees) OR a structural template form
|
||||
// `{ componentId, path }` so the GenericBinder expands per-row templates
|
||||
// from the data model — required for fixed-schema flows like
|
||||
// flight_schema.json (Row.children = { componentId, path: "/flights" }).
|
||||
Row: {
|
||||
description: "Horizontal layout container.",
|
||||
props: z.object({
|
||||
gap: z.number().optional(),
|
||||
align: z.string().optional(),
|
||||
justify: z.string().optional(),
|
||||
// Union with { componentId, path } so GenericBinder treats this as
|
||||
// STRUCTURAL and resolves template children from the data model.
|
||||
children: z.union([
|
||||
z.array(z.string()),
|
||||
z.object({ componentId: z.string(), path: z.string() }),
|
||||
]),
|
||||
}),
|
||||
},
|
||||
|
||||
Column: {
|
||||
description: "Vertical layout container.",
|
||||
props: z.object({
|
||||
gap: z.number().optional(),
|
||||
align: z.string().optional(),
|
||||
// Same union as Row — required for template children support.
|
||||
children: z.union([
|
||||
z.array(z.string()),
|
||||
z.object({ componentId: z.string(), path: z.string() }),
|
||||
]),
|
||||
}),
|
||||
},
|
||||
|
||||
DashboardCard: {
|
||||
description:
|
||||
"A card container with title and optional subtitle. Has a 'child' slot for content (chart, metrics, etc). Use 'child' with a single component ID.",
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
child: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Metric: {
|
||||
description:
|
||||
"A key metric display with label, value, and optional trend indicator. Great for KPIs and stats.",
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
trend: z.enum(["up", "down", "neutral"]).optional(),
|
||||
trendValue: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
PieChart: {
|
||||
description:
|
||||
"A pie/donut chart. Provide data as array of {label, value, color} objects.",
|
||||
props: z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
color: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
innerRadius: z.number().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
BarChart: {
|
||||
description:
|
||||
"A bar chart. Provide data as array of {label, value} objects.",
|
||||
props: z.object({
|
||||
data: z.array(z.object({ label: z.string(), value: z.number() })),
|
||||
color: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Badge: {
|
||||
description:
|
||||
"A small status badge/tag. Use for labels, statuses, categories.",
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z
|
||||
.enum(["success", "warning", "error", "info", "neutral"])
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
DataTable: {
|
||||
description: "A data table with columns and rows.",
|
||||
props: z.object({
|
||||
columns: z.array(z.object({ key: z.string(), label: z.string() })),
|
||||
rows: z.array(z.record(z.any())),
|
||||
}),
|
||||
},
|
||||
|
||||
Button: {
|
||||
description:
|
||||
"An interactive button with an action event. Use 'child' with a Text component ID for the label. 'action' is dispatched on click.",
|
||||
props: z.object({
|
||||
child: z
|
||||
.string()
|
||||
.describe(
|
||||
"The ID of the child component (e.g. a Text component for the label).",
|
||||
),
|
||||
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
|
||||
// Union with { event } so GenericBinder resolves this as ACTION → callable () => void.
|
||||
action: z
|
||||
.union([
|
||||
z.object({
|
||||
event: z.object({
|
||||
name: z.string(),
|
||||
context: z.record(z.any()).optional(),
|
||||
}),
|
||||
}),
|
||||
z.null(),
|
||||
])
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
FlightCard: {
|
||||
description:
|
||||
"A rich flight result card. Displays airline, flight number, route, times, duration, status, and price. Use inside a Row for side-by-side layout.",
|
||||
props: z.object({
|
||||
airline: DynString,
|
||||
airlineLogo: DynString,
|
||||
flightNumber: DynString,
|
||||
origin: DynString,
|
||||
destination: DynString,
|
||||
date: DynString,
|
||||
departureTime: DynString,
|
||||
arrivalTime: DynString,
|
||||
duration: DynString,
|
||||
status: DynString,
|
||||
statusColor: DynString.optional(),
|
||||
price: DynString,
|
||||
action: z
|
||||
.union([
|
||||
z.object({
|
||||
event: z.object({
|
||||
name: z.string(),
|
||||
context: z.record(z.any()).optional(),
|
||||
}),
|
||||
}),
|
||||
z.null(),
|
||||
])
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/** Type helper for renderers */
|
||||
export type DemonstrationCatalogDefinitions =
|
||||
typeof demonstrationCatalogDefinitions;
|
||||
+606
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* A2UI Catalog — React Renderers
|
||||
*
|
||||
* Each renderer maps a component name from definitions.ts to a React
|
||||
* implementation. Props are type-checked against the Zod schemas.
|
||||
*
|
||||
* To add a component: define its schema in definitions.ts, then add a
|
||||
* renderer here. See README.md "Adding a custom component" for details.
|
||||
*
|
||||
* The assembled catalog is registered in layout.tsx via
|
||||
* <CopilotKit a2ui={{ catalog: demonstrationCatalog }}>.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import type { JSX } from "react";
|
||||
import {
|
||||
PieChart as RechartsPie,
|
||||
Pie,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
BarChart as RechartsBar,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import { createCatalog } from "@copilotkit/a2ui-renderer";
|
||||
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
|
||||
import { demonstrationCatalogDefinitions } from "./definitions";
|
||||
import type { DemonstrationCatalogDefinitions } from "./definitions";
|
||||
|
||||
// ─── Theme-aware colors ─────────────────────────────────────────────
|
||||
|
||||
const c = {
|
||||
card: "var(--card)",
|
||||
cardFg: "var(--card-foreground)",
|
||||
border: "var(--border)",
|
||||
muted: "var(--muted-foreground)",
|
||||
divider: "color-mix(in srgb, var(--border) 50%, var(--card))",
|
||||
shadow: "0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04)",
|
||||
btnBg: "color-mix(in srgb, var(--muted) 40%, var(--card))",
|
||||
btnDoneBg: "color-mix(in srgb, #22c55e 10%, var(--card))",
|
||||
};
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
doneLabel,
|
||||
action,
|
||||
children: child,
|
||||
}: {
|
||||
label: string;
|
||||
doneLabel: string;
|
||||
action: any;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const [done, setDone] = useState(false);
|
||||
return (
|
||||
<button
|
||||
disabled={done}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 16px",
|
||||
borderRadius: "10px",
|
||||
border: done ? "1px solid #bbf7d0" : `1px solid ${c.border}`,
|
||||
background: done ? c.btnDoneBg : c.btnBg,
|
||||
color: done ? "#059669" : c.cardFg,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
cursor: done ? "default" : "pointer",
|
||||
transition: "all 0.2s ease",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "6px",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!done) {
|
||||
action?.();
|
||||
setDone(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{done && (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#059669"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
{done ? doneLabel : (child ?? label)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Renderers (type-checked against schema definitions) ────────────
|
||||
|
||||
const demonstrationCatalogRenderers: CatalogRenderers<DemonstrationCatalogDefinitions> =
|
||||
{
|
||||
Title: ({ props }) => {
|
||||
const Tag = (
|
||||
props.level === "h1" ? "h1" : props.level === "h3" ? "h3" : "h2"
|
||||
) as keyof JSX.IntrinsicElements;
|
||||
const sizes: Record<string, string> = {
|
||||
h1: "1.75rem",
|
||||
h2: "1.25rem",
|
||||
h3: "1rem",
|
||||
};
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
margin: 0,
|
||||
fontWeight: 600,
|
||||
fontSize: sizes[props.level ?? "h2"],
|
||||
color: c.cardFg,
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
|
||||
Row: ({ props, children }) => {
|
||||
const justifyMap: Record<string, string> = {
|
||||
start: "flex-start",
|
||||
center: "center",
|
||||
end: "flex-end",
|
||||
spaceBetween: "space-between",
|
||||
};
|
||||
const items = Array.isArray(props.children) ? props.children : [];
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: `${props.gap ?? 16}px`,
|
||||
alignItems: props.align ?? "stretch",
|
||||
justifyContent:
|
||||
justifyMap[props.justify ?? "start"] ?? "flex-start",
|
||||
flexWrap: "wrap",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{items.map((item: any, i: number) => {
|
||||
if (typeof item === "string")
|
||||
return (
|
||||
<div
|
||||
key={`${item}-${i}`}
|
||||
style={{ flex: "1 1 0", minWidth: 0 }}
|
||||
>
|
||||
{children(item)}
|
||||
</div>
|
||||
);
|
||||
if (item && typeof item === "object" && "id" in item)
|
||||
return (
|
||||
<div
|
||||
key={`${item.id}-${i}`}
|
||||
style={{ flex: "1 1 0", minWidth: 0 }}
|
||||
>
|
||||
{(children as any)(item.id, item.basePath)}
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Column: ({ props, children }) => {
|
||||
const items = Array.isArray(props.children) ? props.children : [];
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${props.gap ?? 12}px`,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{items.map((item: any, i: number) => {
|
||||
if (typeof item === "string")
|
||||
return (
|
||||
<React.Fragment key={`${item}-${i}`}>
|
||||
{children(item)}
|
||||
</React.Fragment>
|
||||
);
|
||||
if (item && typeof item === "object" && "id" in item)
|
||||
return (
|
||||
<React.Fragment key={`${item.id}-${i}`}>
|
||||
{(children as any)(item.id, item.basePath)}
|
||||
</React.Fragment>
|
||||
);
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
DashboardCard: ({ props, children }) => (
|
||||
<div
|
||||
style={{
|
||||
background: c.card,
|
||||
borderRadius: "12px",
|
||||
border: `1px solid ${c.border}`,
|
||||
padding: "20px",
|
||||
boxShadow: c.shadow,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: "0.9rem", color: c.cardFg }}>
|
||||
{props.title}
|
||||
</div>
|
||||
{props.subtitle && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: c.muted,
|
||||
marginTop: "2px",
|
||||
}}
|
||||
>
|
||||
{props.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{props.child && children(props.child)}
|
||||
</div>
|
||||
),
|
||||
|
||||
Metric: ({ props }) => {
|
||||
const trendColors: Record<string, string> = {
|
||||
up: "#059669",
|
||||
down: "#dc2626",
|
||||
neutral: c.muted,
|
||||
};
|
||||
const trendIcons: Record<string, string> = {
|
||||
up: "↑",
|
||||
down: "↓",
|
||||
neutral: "→",
|
||||
};
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: c.muted,
|
||||
fontWeight: 500,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: "8px" }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 700,
|
||||
color: c.cardFg,
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
{props.value}
|
||||
</span>
|
||||
{props.trend && props.trendValue && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 500,
|
||||
color: trendColors[props.trend] ?? c.muted,
|
||||
}}
|
||||
>
|
||||
{trendIcons[props.trend]} {props.trendValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
PieChart: ({ props }) => {
|
||||
const COLORS = [
|
||||
"#3b82f6",
|
||||
"#8b5cf6",
|
||||
"#ec4899",
|
||||
"#f59e0b",
|
||||
"#10b981",
|
||||
"#6366f1",
|
||||
];
|
||||
const data = props.data ?? [];
|
||||
return (
|
||||
<div style={{ width: "100%", height: 200 }}>
|
||||
<ResponsiveContainer>
|
||||
<RechartsPie>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="label"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={props.innerRadius ?? 40}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{data.map((entry: any, i: number) => (
|
||||
<Cell
|
||||
key={i}
|
||||
fill={entry.color ?? COLORS[i % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</RechartsPie>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
BarChart: ({ props }) => {
|
||||
const data = props.data ?? [];
|
||||
return (
|
||||
<div style={{ width: "100%", height: 200 }}>
|
||||
<ResponsiveContainer>
|
||||
<RechartsBar data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={c.divider} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: c.muted }} />
|
||||
<YAxis tick={{ fontSize: 11, fill: c.muted }} />
|
||||
<Tooltip />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={props.color ?? "#3b82f6"}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</RechartsBar>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Badge: ({ props }) => {
|
||||
const variants: Record<string, { bg: string; color: string }> = {
|
||||
success: { bg: "#dcfce7", color: "#166534" },
|
||||
warning: { bg: "#fef3c7", color: "#92400e" },
|
||||
error: { bg: "#fee2e2", color: "#991b1b" },
|
||||
info: { bg: "#dbeafe", color: "#1e40af" },
|
||||
neutral: { bg: "var(--muted)", color: c.cardFg },
|
||||
};
|
||||
const v = variants[props.variant ?? "neutral"] ?? variants.neutral;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "9999px",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 500,
|
||||
background: v.bg,
|
||||
color: v.color,
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
|
||||
DataTable: ({ props }) => {
|
||||
const cols = props.columns ?? [];
|
||||
const rows = props.rows ?? [];
|
||||
return (
|
||||
<div style={{ overflowX: "auto", width: "100%" }}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{cols.map((col: any) => (
|
||||
<th
|
||||
key={col.key}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "8px 12px",
|
||||
borderBottom: `2px solid ${c.border}`,
|
||||
color: c.muted,
|
||||
fontWeight: 600,
|
||||
fontSize: "0.7rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row: any, i: number) => (
|
||||
<tr key={i} style={{ borderBottom: `1px solid ${c.divider}` }}>
|
||||
{cols.map((col: any) => (
|
||||
<td
|
||||
key={col.key}
|
||||
style={{ padding: "8px 12px", color: c.cardFg }}
|
||||
>
|
||||
{String(row[col.key] ?? "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Button: ({ props, children }) => {
|
||||
return (
|
||||
<ActionButton label="Click" doneLabel="Done" action={props.action}>
|
||||
{props.child ? children(props.child) : null}
|
||||
</ActionButton>
|
||||
);
|
||||
},
|
||||
|
||||
FlightCard: ({ props: rawProps }) => {
|
||||
// The binder resolves path bindings to strings at runtime.
|
||||
const props = rawProps as Record<string, any>;
|
||||
const statusColors: Record<string, string> = {
|
||||
"On Time": "#22c55e",
|
||||
Delayed: "#eab308",
|
||||
Cancelled: "#ef4444",
|
||||
};
|
||||
const dotColor =
|
||||
props.statusColor ?? statusColors[props.status] ?? "#22c55e";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: `1px solid ${c.border}`,
|
||||
borderRadius: "16px",
|
||||
padding: "20px",
|
||||
background: c.card,
|
||||
color: c.cardFg,
|
||||
minWidth: 260,
|
||||
maxWidth: 340,
|
||||
flex: "1 1 260px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
boxShadow: c.shadow,
|
||||
}}
|
||||
>
|
||||
{/* Header: airline + price */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<img
|
||||
src={props.airlineLogo}
|
||||
alt={props.airline}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>
|
||||
{props.airline}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontWeight: 700, fontSize: "1.15rem" }}>
|
||||
{props.price}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.8rem",
|
||||
color: c.muted,
|
||||
}}
|
||||
>
|
||||
<span>{props.flightNumber}</span>
|
||||
<span>{props.date}</span>
|
||||
</div>
|
||||
|
||||
<hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: `1px solid ${c.divider}`,
|
||||
margin: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Times */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 700, fontSize: "1.1rem" }}>
|
||||
{props.departureTime}
|
||||
</span>
|
||||
<span style={{ fontSize: "0.75rem", color: c.muted }}>
|
||||
{props.duration}
|
||||
</span>
|
||||
<span style={{ fontWeight: 700, fontSize: "1.1rem" }}>
|
||||
{props.arrivalTime}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Route */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
fontSize: "0.95rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<span>{props.origin}</span>
|
||||
<span style={{ color: c.muted }}>→</span>
|
||||
<span>{props.destination}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: `1px solid ${c.divider}`,
|
||||
margin: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Status */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: dotColor,
|
||||
display: "inline-block",
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "0.8rem", color: c.muted }}>
|
||||
{props.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ActionButton
|
||||
label="Select"
|
||||
doneLabel="Selected"
|
||||
action={props.action}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Assembled Catalog ───────────────────────────────────────────────
|
||||
|
||||
export const demonstrationCatalog = createCatalog(
|
||||
demonstrationCatalogDefinitions,
|
||||
demonstrationCatalogRenderers,
|
||||
{
|
||||
catalogId: "copilotkit://app-dashboard-catalog",
|
||||
// Required: merges the basic A2UI primitives (Row, Column, Text, Card,
|
||||
// Button, …) into this catalog so structural-children expansion works
|
||||
// for templates like flight_schema.json's
|
||||
// `Row { children: { componentId: "flight-card", path: "/flights" } }`.
|
||||
// Both sibling working demos (a2ui-fixed-schema, declarative-gen-ui)
|
||||
// already set this — beautiful-chat was the outlier.
|
||||
includeBasicCatalog: true,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import { ExampleLayout } from "./components/example-layout";
|
||||
import { ExampleCanvas } from "./components/example-canvas";
|
||||
import { useGenerativeUIExamples, useExampleSuggestions } from "./hooks";
|
||||
|
||||
export function HomePage() {
|
||||
useGenerativeUIExamples();
|
||||
useExampleSuggestions();
|
||||
|
||||
return (
|
||||
<ExampleLayout
|
||||
chatContent={
|
||||
<CopilotChat
|
||||
attachments={{ enabled: true }}
|
||||
input={{ disclaimer: () => null, className: "pb-6" }}
|
||||
/>
|
||||
}
|
||||
appContent={<ExampleCanvas />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user