Skip to main content
CopilotKit 提供了一个完整的 React 聊天运行时,当您希望代理返回结构化 UI 有效载荷而不仅仅是纯文本时,它与 LangGraph 配合得特别好。在此模式中,您的 LangGraph 部署同时提供图 API 和自定义 CopilotKit 端点,而前端则将助理消息解析为动态 React 组件。 当您希望满足以下条件时,这种方法非常有用:
  • 一个现成的聊天运行时,而不是自己连接 stream.messages
  • 一个自定义服务器端点,可以在已部署的图旁边添加特定于提供程序的行为
  • 从受限组件注册表渲染的结构化生成式 UI
有关 CopilotKit 特定的 API、UI 模式和运行时配置,请参阅 CopilotKit 文档

工作原理

从高层次来看,CopilotKit 位于您的 React 应用和 LangGraph 部署之间。前端将对话状态发送到与图 API 一起挂载的自定义 /api/copilotkit 路由,该路由将请求转发给 LangGraph,响应返回时包含助理消息以及您的组件注册表可以渲染的任何结构化 UI 有效载荷。
  1. 像往常一样部署图,使用 LangSmith 或 LangGraph 开发服务器。
  2. 使用 HTTP 应用扩展部署,该应用在图 API 旁边挂载 CopilotKit 路由。
  3. 将前端包装在 CopilotKit,并将其指向该自定义运行时 URL。
  4. 注册动态 UI 组件,并在渲染时将助理响应解析为这些组件。

安装

对于后端端点:
uv add copilotkit ag-ui-langgraph fastapi uvicorn
对于前端应用:
bun add @copilotkit/react-core @copilotkit/react-ui @hashbrownai/core @hashbrownai/react

使用自定义端点扩展 LangGraph 部署

关键思想是 LangGraph 部署不仅提供图。它还可以加载 HTTP 应用,这允许您在部署本身旁边挂载额外的路由。 langgraph.json 中,将 http.app 指向您的自定义应用入口点:
{
  "dependencies": ["."],
  "graphs": {
    "copilotkit_shadify": "./main.py:agent"
  },
  "http": {
    "app": "./main.py:app"
  }
}
在 Python 中,创建一个 FastAPI 应用,并通过 CopilotKit 的 AG-UI 桥接暴露 LangGraph 代理:
main.py
from typing import Any, TypedDict

from ag_ui_langgraph import add_langgraph_fastapi_endpoint
from copilotkit import CopilotKitMiddleware, CopilotKitState, LangGraphAGUIAgent
from fastapi import FastAPI
from langchain.agents import create_agent

from src.middleware import apply_structured_output_schema, normalize_context


class AgentState(CopilotKitState):
    pass


class AgentContext(TypedDict, total=False):
    output_schema: dict[str, Any]


agent = create_agent(
    model="openai:gpt-5.2",
    middleware=[
        normalize_context,
        CopilotKitMiddleware(),
        apply_structured_output_schema,
    ],
    context_schema=AgentContext,
    state_schema=AgentState,
    system_prompt=(
        "You are a helpful UI assistant. Build visual responses using the "
        "available components."
    ),
)

app = FastAPI()

add_langgraph_fastapi_endpoint(
    app=app,
    agent=LangGraphAGUIAgent(
        name="copilotkit_shadify",
        description="A UI assistant that returns structured component payloads.",
        graph=agent,
    ),
    path="/",
)
这个自定义应用是重要的扩展点:它挂载了一个支持 CopilotKit 的运行时,而不会替换底层的 LangGraph 部署。 在 Python 中,等效的工作发生在中间件中:规范化 CopilotKit 上下文,并将 useAgentContext(...) 中的 output_schema 转发到模型的结构化输出配置中。
src/middleware.py
import json
from collections.abc import Mapping

from langchain.agents.middleware import before_agent, wrap_model_call
from langchain.agents.structured_output import ProviderStrategy


@wrap_model_call
async def apply_structured_output_schema(request, handler):
    schema = None
    runtime = getattr(request, "runtime", None)
    runtime_context = getattr(runtime, "context", None)

    if isinstance(runtime_context, Mapping):
        schema = runtime_context.get("output_schema")

    if schema is None and isinstance(getattr(request, "state", None), dict):
        copilot_context = request.state.get("copilotkit", {}).get("context")
        if isinstance(copilot_context, list):
            for item in copilot_context:
                if isinstance(item, dict) and item.get("description") == "output_schema":
                    schema = item.get("value")
                    break

    if isinstance(schema, str):
        try:
            schema = json.loads(schema)
        except json.JSONDecodeError:
            schema = None

    if isinstance(schema, dict):
        request = request.override(
            response_format=ProviderStrategy(schema=schema, strict=True),
        )

    return await handler(request)


@before_agent
def normalize_context(state, runtime):
    copilotkit_state = state.get("copilotkit", {})
    context = copilotkit_state.get("context")

    if isinstance(context, list):
        normalized = [
            item.model_dump() if hasattr(item, "model_dump") else item
            for item in context
        ]
        return {"copilotkit": {**copilotkit_state, "context": normalized}}

    return None
结果是关注点的清晰分离:
  • LangGraph 仍然拥有图执行和持久化
  • CopilotKit 拥有面向聊天的运行时契约
  • 您的自定义端点将它们粘合在一个部署中

构建前端应用结构

在前端,将您的应用包装在 CopilotKit 中,并将其指向自定义运行时 URL:
import { CopilotKit } from "@copilotkit/react-core";
import { CopilotChat, useAgentContext } from "@copilotkit/react-core/v2";
import { s } from "@hashbrownai/core";

import { useChatKit } from "@/components/chat/chat-kit";
import { chatTheme } from "@/lib/chat-theme";

export function App() {
  return (
    <CopilotKit runtimeUrl={import.meta.env.VITE_RUNTIME_URL ?? "/api/copilotkit"}>
      <Page />
    </CopilotKit>
  );
}

function Page() {
  const chatKit = useChatKit();

  useAgentContext({
    description: "output_schema",
    value: s.toJsonSchema(chatKit.schema),
  });

  return <CopilotChat {...chatTheme} />;
}
这里有两点很重要:
  • runtimeUrl="/api/copilotkit" 将聊天发送到您的自定义后端路由,而不是直接发送到原始 LangGraph API
  • useAgentContext(...) 将 UI 模式发送给代理,以便模型知道它应该产生什么结构化输出格式

注册动态组件

组件注册表位于 useChatKit() 中。在这里,您定义代理允许发出的组件集,例如卡片、行、列、图表、代码块和按钮。
import { s } from "@hashbrownai/core";
import { exposeComponent, exposeMarkdown, useUiKit } from "@hashbrownai/react";

import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { CodeBlock } from "@/components/ui/code-block";
import { Row, Column } from "@/components/ui/layout";
import { SimpleChart } from "@/components/ui/simple-chart";

export function useChatKit() {
  return useUiKit({
    components: [
      exposeMarkdown(),
      exposeComponent(Card, {
        name: "card",
        description: "Card to wrap generative UI content.",
        children: "any",
      }),
      exposeComponent(Row, {
        name: "row",
        props: {
          gap: s.string("Tailwind gap size") as never,
        },
        children: "any",
      }),
      exposeComponent(Column, {
        name: "column",
        children: "any",
      }),
      exposeComponent(SimpleChart, {
        name: "chart",
        props: {
          labels: s.array("Category labels", s.string("A label")),
          values: s.array("Numeric values", s.number("A value")),
        },
        children: false,
      }),
      exposeComponent(CodeBlock, {
        name: "code_block",
        props: {
          code: s.streaming.string("The code to display"),
          language: s.string("Programming language") as never,
        },
        children: false,
      }),
      exposeComponent(Button, {
        name: "button",
        children: "text",
      }),
    ],
  });
}
此注册表成为代理和 UI 之间的契约。模型不会生成任意的 JSX。它生成必须根据您公开的组件和属性进行验证的结构化数据。

将助理消息渲染为动态 UI

一旦助理响应到达,自定义消息渲染器决定如何显示它。在此示例中:
  • 助理消息根据 UI 工具包模式解析为结构化 JSON
  • 有效的结构化输出被渲染为真实的 React 组件
  • 用户消息被渲染为普通的聊天气泡
import type { AssistantMessage } from "@ag-ui/core";
import type { RenderMessageProps } from "@copilotkit/react-ui";
import { useJsonParser } from "@hashbrownai/react";
import { memo } from "react";

import { useChatKit } from "@/components/chat/chat-kit";
import { Squircle } from "@/components/squircle";

const AssistantMessageRenderer = memo(function AssistantMessageRenderer({
  message,
}: {
  message: AssistantMessage;
}) {
  const kit = useChatKit();
  const { value } = useJsonParser(message.content ?? "", kit.schema);

  if (!value) return null;

  return (
    <div className="group/msg mt-2 flex w-full justify-start">
      <div className="magic-text-output w-full px-1 py-1">{kit.render(value)}</div>
    </div>
  );
});

export function CustomMessageRenderer({ message }: RenderMessageProps) {
  if (message.role === "assistant") {
    return <AssistantMessageRenderer message={message} />;
  }

  return (
    <div className="flex w-full justify-end">
      <Squircle className="w-full max-w-[64ch] px-4 py-3">
        <pre>{typeof message.content === "string" ? message.content : JSON.stringify(message.content, null, 2)}</pre>
      </Squircle>
    </div>
  );
}
这种渲染器模式使集成感觉原生:
  • CopilotKit 处理聊天状态和传输
  • 自定义渲染器决定助理有效载荷如何成为 UI
  • Hashbrown 将经过验证的结构化数据转换为具体的 React 元素

最佳实践

  • 保持自定义端点精简:使用它来使 CopilotKit 适应您的图部署,而不是复制图中已有的业务逻辑
  • 显式发送模式useAgentContext 应在每次页面挂载时描述 UI 契约
  • 注册受限组件集:仅公开您实际希望模型使用的组件和属性
  • 将渲染视为解析步骤:在渲染之前根据您的模式解析助理内容
  • 保持用户消息为纯文本:只有助理消息需要结构化渲染器;用户消息可以保持正常的聊天气泡