基于 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 暴露 visibleMessages、appendMessage、setMessages、reloadMessages |
| 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_id 是 reasoning_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 的传递方式与设计原因
后端处理每一次对话请求时,都需要知道"这是谁在问"(用于鉴权、个性化、审计),也就是 token、realm、用户身份等信息。这些信息不属于对话内容本身,不适合塞进 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)与页面宿主环境匹配