基于 CopilotKit + Java SSE 构建 AI Agent 的前端实践指南

基于 CopilotKit + Java SSE 构建 AI Agent 的前端实践指南

本文从一个真实落地的生产项目出发,记录使用 CopilotKit 框架对接 Java 流式后端、完成完整 AI 聊天 Agent 的全过程。适合已有一定 React/Next.js 基础、正在或准备接入 CopilotKit 的开发者阅读。


一、整体架构体系

1.1 技术栈全景

scss 复制代码
┌─────────────────────────────────────────────────────────────────┐
│                     用户界面(CEF / iframe)                      │
│                     PC 客户端 / H5 主页面                         │
└──────────────────────────────┬──────────────────────────────────┘
                               │ postMessage / native 事件
┌──────────────────────────────▼──────────────────────────────────┐
│                  Next.js 15  App Router                          │
│                                                                  │
│  /chat  layout.tsx                                               │
│  ├── CopilotKitProvider  (全局 CopilotKit 上下文)                │
│  └── PushQueryHandler  (C++ 推送处理器)                          │
│                                                                  │
│  /chat/[id]  page.tsx (会话页)                                   │
│  ├── HomeAgent  (写入 userInfo 到 agentStates)                   │
│  ├── AssistantSidebar → CopilotSidebar                          │
│  │   ├── CustomSidebarInput  (输入框 + 工具栏)                   │
│  │   ├── CustomUserMessage   (用户气泡)                          │
│  │   └── CustomAssistantMessage (AI 回答 + 打字机 + 工具栏)      │
│  ├── ActionActiveComponent  (活动卡片 action)                    │
│  └── ActionGameDownComponent (下载卡片 action)                   │
│                                                                  │
│  /api/copilotkit  (Next.js API 路由)                             │
│  └── CopilotRuntimeServer + Langgraph4jAdapter                  │
└──────────────────────────────┬──────────────────────────────────┘
                               │ HTTP POST + SSE 流
┌──────────────────────────────▼──────────────────────────────────┐
│                  Java 后端(Spring + LangGraph4j)                │
│          /your-backend/copilotkit                                │
└─────────────────────────────────────────────────────────────────┘

1.2 CopilotKit 在项目中的角色

CopilotKit 承担的核心职责只有三件事:

职责 说明
消息状态管理 useCopilotChat 暴露 visibleMessagesappendMessagesetMessagesreloadMessages
Action 注册与调度 useCopilotAction 将前端组件注册为 LLM 可调用的工具,结果在前端直接渲染
与后端通信 通过 CopilotRuntimeServer + 自定义 CopilotServiceAdapter 转发请求、处理 SSE 流

关键认知 :CopilotKit 本身不处理流式 SSE------它只定义了 eventSource.stream() 接口,具体如何读取后端流、把什么事件翻译成 CopilotKit 内部消息,全由自定义 Adapter 负责。


二、关键流程拆解

2.1 发送一条消息的完整链路

scss 复制代码
用户在 CustomSidebarInput 输入并按 Enter
  ↓
new CustomTextMessage({ role: 'user', content: JSON.stringify([{type:'text', text}]) })
  ↓
  ├── 会话页已加载 (appendMessage):直接 appendMessage(msg)
  └── 从首页发起:dispatch(setNewChatMessage(msg)) → router.push('/chat/newId')
        [id]/page.tsx 挂载后 appendMessageRef.current(msg)
  ↓
CopilotKit 内部序列化 → POST /api/copilotkit
  ↓
Langgraph4jAdapter.process()
  ↓
Java 后端 SSE 流
  ↓
eventStream$ 事件 → visibleMessages 追加新 assistant 消息
  ↓
CustomAssistantMessage 渲染,打字机逐字输出
  ↓
isLoading=false → 保存 visibleMessages 到 Redux globalHistoryData → KV 存储

2.2 SSE 事件处理状态机

Java 后端发出 13 种事件,顺序不固定(这是适配层要处理的最大难点)。以下是 Adapter 内部的处理逻辑:

scss 复制代码
事件到达顺序(理想情况):
  RUN_STARTED
  STEP_STARTED(name)        → 创建 __status__ 消息,写入 [Step:name]
  STEP_FINISHED(name)       → 写入 [StepFinished:name]
  TEXT_MESSAGE_START        → 关闭 __status__,打开正文消息
  TEXT_MESSAGE_CONTENT × N  → 追加 delta 到正文消息
  TEXT_MESSAGE_END          → 追加 End_References,关闭正文消息
  RUN_FINISHED              → 结束流

实际会出现的变体(需要在 Adapter 里兜底):
  1. TEXT_MESSAGE_START 先于 STEP_STARTED 到达
     → pendingRealMessageId 暂存,等到 CONTENT 时再打开,保证 status 消息在前
  2. REASONING_MESSAGE_START 先于 TEXT_MESSAGE_START 到达
     → 提前创建正文消息(extractReasoningTargetId),注入 [ThinkStart]
  3. TEXT_MESSAGE_START 但始终没有 CONTENT(纯工具调用)
     → TOOL_CALL_START 时检测 pendingRealMessageId 并 flush
  4. 不发 TEXT_MESSAGE 只发工具调用
     → RUN_FINISHED 时补发 start+end 让 CopilotKit 正常关闭

关键数据结构

typescript 复制代码
// 状态消息(进度指示):id 以 __status__ 开头,不进入 LLM 上下文
let statusMessageId: string | null = null;

// 正文消息 ID 追踪
let realMessageId: string | null = null;

// 延迟打开正文消息(保证 status 消息在前)
let pendingRealMessageId: string | null = null;

// 文本内容是否已开始(之后忽略所有 STEP 事件)
let answerMessageStarted = false;

三、关键功能实现

3.1 CopilotKit Runtime + 自定义 Adapter 的分工

CopilotRuntime 本身只是一个请求路由器:它接收前端发来的 messages/agentStates/context,不关心后端用什么协议返回结果,把这一切都委托给 CopilotServiceAdapter.process()。项目里实现的 Langgraph4jAdapter implements CopilotServiceAdapter 承担了三件事,下面是从真实代码里抽出来的关键片段:

① 协议转换------发起请求、拿到后端地址:

typescript 复制代码
const domain = process.env.NEXT_PUBLIC_CLOUD_REST_DOMAIN;
// 本地开发时后端直连,无网关前缀;其他环境走网关前缀
const isLocal = process.env.NEXT_PUBLIC_ENV === 'local';
const copilotKitPath = isLocal ? '/copilotkit' : '/your-backend/copilotkit';

const response = await fetch(`${domain}${copilotKitPath}`, {
  signal: this.abortController.signal,
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'text/event-stream',
    'token': token,
    'realm': realm,
  },
  body: JSON.stringify(request),
});

再把后端 SSE 流里的 data: 行逐行解析、翻译成 CopilotKit 的 eventStream$.sendXxx() 调用(以最常见的正文文本事件为例):

typescript 复制代码
switch (message.type) {
  case 'TEXT_MESSAGE_START':
    eventStream$.sendTextMessageStart({ messageId: message.message_id });
    break;
  case 'TEXT_MESSAGE_CONTENT':
    eventStream$.sendTextMessageContent({
      messageId: message.message_id,
      content: message.delta,
    });
    break;
  case 'TEXT_MESSAGE_END':
    eventStream$.sendTextMessageEnd({ messageId: message.message_id });
    break;
  case 'RUN_FINISHED':
    fetchEvents = false;
    break;
  // ...TOOL_CALL_*、REASONING_MESSAGE_* 等另外 9 种事件的翻译逻辑,见 2.2
}

② 消息清洗------发给后端之前,先把不该进入 LLM 上下文的消息过滤掉:

typescript 复制代码
if (request.messages) {
  const seenIds = new Set<string>()
  request.messages = (request.messages as any[]).filter((msg: any) => {
    // 1. 按 id 去重,保留首次出现
    if (msg.id) {
      if (seenIds.has(msg.id)) return false
      seenIds.add(msg.id)
    }
    // 2. 过滤状态占位消息(前端 UI 专用,不进入 LLM 上下文)
    if (msg.id?.startsWith('__status__')) return false
    // 3. 过滤 system 消息
    if (msg.role === 'system') return false
    // 4. 过滤空内容的 assistant 消息
    if (
      msg.role === 'assistant' &&
      (msg.content === '' || msg.content == null ||
        (Array.isArray(msg.content) && msg.content.length === 0))
    ) return false
    return true
  })
}

③ 短路控制------纯前端渲染的 Action 结果,不需要后端再跑一轮 LLM:

typescript 复制代码
const FRONTEND_ONLY_ACTIONS = new Set(['activity_card', 'download_card'])
const lastMsg = request.messages?.[request.messages.length - 1]
if (lastMsg?.isResultMessage() && FRONTEND_ONLY_ACTIONS.has((lastMsg as any).actionName)) {
  eventSource.stream(async (eventStream$) => {
    eventStream$.complete()
  })
  return { threadId }
}

原理是:这些卡片类 Action 的结果已经在前端渲染完毕,如果照常把 ResultMessage 转发给后端,会触发额外的一轮 LLM 调用,而这轮调用其实什么都不需要做------直接 complete() 结束流程,省掉一次无意义的后端往返。

之所以要自己写 Adapter 而不是用官方现成的(如 OpenAI Adapter),是因为后端是自研的 LangGraph4j 服务,返回的事件类型、字段命名、乱序特性都是定制的,官方 Adapter 没有对应的协议解析能力。Adapter 是这个架构里唯一需要完全自己实现的部分,也是接入任意自定义后端时的核心工作量所在。

消息清洗(发送前过滤) :CopilotKit 的 onBeforeRequest 中间件无法真正替换掉 runtime 传给 Adapter 的 request.messages 引用,所以过滤必须直接在 process() 里对 request.messages 做原地操作:

typescript 复制代码
if (request.messages) {
  const seenIds = new Set<string>()
  request.messages = (request.messages as any[]).filter((msg: any) => {
    // 1. 按 id 去重,保留首次出现
    if (msg.id) {
      if (seenIds.has(msg.id)) return false
      seenIds.add(msg.id)
    }
    // 2. 过滤状态占位消息(前端 UI 专用,不进入 LLM 上下文)
    if (msg.id?.startsWith('__status__')) return false
    // 3. 过滤 system 消息
    if (msg.role === 'system') return false
    // 4. 过滤空内容的 assistant 消息
    if (
      msg.role === 'assistant' &&
      (msg.content === '' || msg.content == null ||
        (Array.isArray(msg.content) && msg.content.length === 0))
    ) return false
    return true
  })
}

短路控制(跳过后端请求):某些 Action 的渲染结果完全在前端完成(比如一张卡片),如果照常把 ResultMessage 转发给后端,会触发一次多余的 LLM 轮次。Adapter 在请求入口就识别并短路掉这类消息:

typescript 复制代码
const FRONTEND_ONLY_ACTIONS = new Set(['activity_card', 'download_card'])
const lastMsg = request.messages?.[request.messages.length - 1]
if (lastMsg?.isResultMessage() && FRONTEND_ONLY_ACTIONS.has((lastMsg as any).actionName)) {
  eventSource.stream(async (eventStream$) => {
    eventStream$.complete()
  })
  return { threadId }
}

SSE 分帧解析fetch 拿到的是 ReadableStream,一次 reader.read() 读到的 chunk 未必是完整的一行 data:...,也可能一次包含多行。Adapter 用一个 buffer 变量累积未处理完的数据,按 \n 切分后,把最后一段不完整的行留在 buffer 里等下一个 chunk 补全:

typescript 复制代码
let buffer = ''
const fetchMessages = (value: string | undefined) => {
  buffer += value
  const lines = buffer.split('\n')
  const lastLine = lines.pop() // 最后一行可能不完整,留到下一轮
  const regex = /^data:(.+)$/m
  const messages: Message[] = []

  for (const line of lines) {
    if (!line.trim() || !line.startsWith('data:')) continue
    const match = line.match(regex)
    if (match) {
      try { messages.push(JSON.parse(match[1])) }
      catch (error) { console.warn('Failed to parse message:', error) }
    }
  }

  if (lastLine && lastLine.trim()) {
    buffer = lastLine // 不完整的行留在 buffer,下次 chunk 到达后再拼接解析
  } else {
    buffer = ''
  }
  return messages
}

另一个容易踩坑的细节是 REASONING_MESSAGE_* 事件:后端约定它的 message_idreasoning_msg_<正文消息id> 的格式,Adapter 需要按固定前缀长度截取出真正的正文消息 id,同时兜底处理 id 异常短的情况,避免截出空字符串传给 CopilotKit:

typescript 复制代码
function extractReasoningTargetId(messageId: string): string {
  const REASONING_PREFIX_LEN = 14 // "reasoning_msg_".length === 14
  if (messageId.length < REASONING_PREFIX_LEN) {
    console.warn(`REASONING_MESSAGE message_id "${messageId}" 短于预期前缀长度,回退使用原始 id`)
    return messageId
  }
  return messageId.slice(REASONING_PREFIX_LEN)
}

3.2 userInfo 的传递方式与设计原因

后端处理每一次对话请求时,都需要知道"这是谁在问"(用于鉴权、个性化、审计),也就是 tokenrealm、用户身份等信息。这些信息不属于对话内容本身,不适合塞进 messages,CopilotKit 提供的机制是 agentStates(协作式状态,coagent state)。

css 复制代码
HomeAgent(应用挂载后立即执行)
  └── setCoagentStatesWithRef({ '': { state: { userInfo } } })

Adapter 侧对应的解析代码:

typescript 复制代码
let contextString = { context: [], userInfo: { Token: '', Realm: '' } };
try {
  const state = request.agentStates?.[0]?.state || '{}';
  contextString = JSON.parse(state);
} catch (error) {
  console.error('解析 context 失败:', error);
}

let token = '';
let realm = '';
try {
  const temp = contextString?.userInfo;
  token = temp?.Token || '';
  realm = temp?.Realm || '';
} catch (error) {
  console.error('从 context 中提取 Token 和 Realm 失败:', error);
}
// token / realm 随后被写进 fetch 请求的 header,而不是拼进 messages

选择 agentStates 而不是其他方式的原因:

  • 和对话内容解耦messages 数组会被完整发给 LLM 作为上下文,混入身份信息等于把无关内容喂给模型,还有信息泄露风险(LLM 输出可能意外回显)。agentStates 只在 Adapter 层被读取,不会进入 LLM 的输入。
  • 实时生效,无需等待渲染setCoagentStatesWithRef 直接写 ref,而不是走 setState 触发重渲染再传给 CopilotKit。CopilotKit 发请求时是从 ref 里同步读最新值,写入之后立刻可用,不存在"状态还没更新完就发请求"的时序问题。
  • 贯穿整个会话生命周期agentStates 作为请求的固定字段,每次请求都会带上,不需要在每个发消息的地方手动传参,新增调用点时不会漏传认证信息。

3.3 路由设计:首页 → 会话页

产品上有两个入口:首页(/chat,尚无 session)和会话页(/chat/[id],已有 sessionId)。核心设计目标是首页发的第一条消息不能丢 ,同时不能因为跳转重建 CopilotKit 上下文

scss 复制代码
用户在首页输入框发消息
  ↓
dispatch(setNewChatMessage(msg))     ← 消息先存进 Redux,不直接 append
router.push('/chat/newSessionId')    ← 路由跳转,生成新 sessionId
  ↓
/chat/[id]/page.tsx 挂载
  useEffect(() => {
    if (newChatMessage) {
      appendMessageRef.current(newChatMessage)   ← 用 CopilotKit 的 appendMessage 真正发出
      dispatch(setNewChatMessage(null))           ← 消费后清空,避免重复发送
    }
  }, [newChatMessage])

关键的架构决策是:CopilotKitProvider 放在共享的 layout.tsx 里,首页和会话页共用同一个 Provider 实例。

bash 复制代码
layout.tsx
└── CopilotKitProvider(全程存活,跨路由不重建)
    ├── /chat      → page.tsx(首页,无消息列表,仅负责收集第一条消息)
    └── /chat/[id] → page.tsx(会话页,负责渲染 + 后续对话)
        ↑ 路由切换属于同一个 React 子树的重新渲染,CopilotKit 内部状态不丢失

如果每个路由各自包一层 CopilotKitProvider,跳转时组件树会被整个卸载重建,threadId、已注册的 Action 等全部重置,首页发的第一条消息很容易在上下文重建过程中丢失或对不上 threadId。用 Redux 暂存消息 + 会话页挂载后消费,把"发消息"这个动作精确移到 Provider 已经稳定之后执行,从根本上避免了这个竞态。

3.4 历史记录的存储方式

聊天历史需要跨会话持久化,两种典型方案是浏览器 IndexedDB 或客户端提供的 Native KV 存储。项目采用 Native KV,原因是产品的运行环境是页面被嵌入在 C++ 客户端里(CEF 容器或 iframe),而不是独立的浏览器标签页:

  • IndexedDB 是浏览器域(origin)级别的存储,如果页面运行在 iframe 里,或者 C++ 容器清空 WebView 缓存/更换渲染进程,IndexedDB 数据可能跟着丢失,无法保证跨进程、跨窗口生命周期的稳定性。
  • Native KV 由 C++ 客户端进程管理,生命周期和"用户的客户端账户"绑定,而不是和某个网页 origin 绑定,页面刷新、iframe 重建都不影响数据。
scss 复制代码
保存:
  visibleMessages 变化 → buildPersistedChatMessages(messages, qaId, !isLoading)
    → dispatch(setGlobalHistoryData({ sessionId, data: JSON.stringify({content, type}) }))
    → storageManager → setKVFile('AgentHistory', ...)

恢复(进入 /chat/[id]):
  getKVFile('AgentHistory')
    → 取出 sessionId 对应的记录
    → withRuntimeEmptyAnswerPlaceholders(messages)  ← 补空回答占位(防止异常中断的对话渲染出错)
    → translateToMessage(messages)                  ← 转成 CopilotKit 的 Message 对象
    → setMessagesRef.current(sessionMessages)

如果你的项目是纯 Web 应用(不嵌入客户端容器),用 IndexedDB 存储聊天记录是更常见、更简单的选择:不需要跨语言桥接,浏览器原生支持结构化数据和较大存储配额,读写 API也更贴近前端习惯。是否需要 Native KV,本质取决于页面的宿主环境是否是纯浏览器。


四、快速 checklist

接入时用这张表自查,可以避开常见的基础问题:

  • SSE Adapter 里对每种事件类型都有兜底处理,尤其是 RUN_FINISHED 时关闭所有未关闭消息
  • 状态占位消息(如 __status__)在发给后端前被过滤掉,不进入 LLM 上下文
  • 空内容 assistant 消息在发给后端前被过滤掉
  • 前端专用 Action 的 ResultMessage 在 Adapter 入口被短路,不重复触发后端 LLM 调用
  • appendMessage / setMessages 用 ref 持有,避免直接放进 useEffect 依赖数组
  • 身份/认证信息通过 agentStates 传递,不混入 messages
  • 首页和会话页共用同一个 CopilotKitProvider,路由切换不重建上下文
  • 历史记录的存储介质(IndexedDB / Native KV)与页面宿主环境匹配
相关推荐
铁皮饭盒4 小时前
网页端, 6.5MB人脸识别模型, 谷歌框架, 又快又准
前端·javascript·后端
xiaohaiAIgeo4 小时前
【2026年】ASHRAE 110与EN 14175通风柜测试标准对比:进口与国产品牌性能差距
java·前端·数据库·科普知识
用户2930750976695 小时前
TypeScript 必考题:Type 与 Interface 全面对比
前端
hunterandroid5 小时前
[鸿蒙从零到一] HarmonyOS 媒体能力实战:图片、音频与视频处理
前端
打呵欠的猫5 小时前
我让 AI 封装了一个 ImageUpload 组件,它设计的 5 层校验链路比我想的周全
前端·ai编程
AlexMaybeBot5 小时前
躺在沙发上开发 Openclaw 的移动端APP
前端·flutter
用户2181697049305 小时前
Flutter(十三)Text Image TextField
前端
ClouGence5 小时前
从 5 天到 2 小时:AI Agent 如何搭建高效、可复用的全自动化测试体系?
agent·测试
程序员黑豆5 小时前
深入解析Java数据类型:基本类型与引用类型的本质区别与实战选择
前端·ai编程·全栈