
如果你正在把 OpenCode 的对话能力接进自己的前端,这篇文章能帮你少走一大半弯路。我把踩过的坑、看过的源码、总结出的正确姿势,一次性讲清楚。
文章目录
先问你一个问题
你有没有遇到过这种情况:后端把对话接口给你了,你兴冲冲接上,结果——
-
消息一会儿出来一会儿消失,像抽风
-
明明模型还在思考,前端却以为它"说完了",直接收尾
-
用户点了个工具权限弹窗,结果整个对话卡死不动
-
切了个项目,消息全串到别的会话里去了
这些我全踩过。而且我后来发现,问题几乎都不在大模型,而在"你根本没搞懂对话数据是怎么流过来的"。
这篇文章就是把我搞懂的东西掰开揉碎讲给你。不绕弯子,全是干货。
一、先搞懂:对话数据其实分"三层"

很多人一上来就盯着接口文档看,越看越晕。其实 OpenCode 的对话数据就三层,你只要分清这三层,后面全通了:
-
REST 历史消息 —— 你打开聊天页时,用来把以前的记录拉出来
-
SSE 实时事件 —— 模型正在生成时,一条一条推给你
-
交互式请求 —— 权限、提问这类需要用户"点一下"的东西
记住一句话:历史靠 REST,实时靠 SSE,弹窗靠交互请求。 三层各管各的,别混。
二、核心数据结构:一条消息 = 元信息 + 内容片段
每条消息其实是一个"壳子",里面装着元信息和一堆内容片段:
export type MessageBundle = {
info: MessageInfo // 元信息:id、角色、模型、时间、token
parts: Part[] // 内容片段:正文、推理、工具调用、附件……
}
info** 是这条消息的"身份证"**,告诉你它是谁发的、用的什么模型、花了多少 token:
| 字段 | 说明 |
|---|---|
id | 消息 ID |
role | 角色:user / assistant |
sessionID | 属于哪个会话 |
error? | 服务端错误(比如模型不支持附件) |
model? | 用的哪个模型 |
time? | 创建 / 完成时间 |
tokens? / cost? | Token 和费用 |
parts** 是这条消息的"内容",按 type 区分**,前端渲染全靠它:
| type | 含义 | 前端怎么展示 |
|---|---|---|
text | 正文 | 用户是纯文本,助手是 Markdown |
reasoning | 推理过程 | 折叠在"思考过程"里 |
tool | 工具调用 | 工具卡片(状态/参数/输出) |
file | 附件 | 文件名、类型、路径 |
step-start / step-finish | 步骤边界 | 不展示,直接忽略 |
patch / snapshot | 补丁 / 快照 | 通用 JSON 卡片 |
| 其他未知 type | — | 通用 JSON 卡片兜底 |
这里有个很重要的设计:Part**** 是开放的,未来可能冒出新类型。所以前端千万别写死"我只认 text 和 tool",遇到未知 type 要有兜底展示,而不是直接丢掉。
工具调用的 ToolPart 长这样,重点是 state.status:
export type ToolPart = Part & {
type: 'tool'
tool?: string
callID?: string
state?: {
status: 'pending' | 'running' | 'completed' | 'error'
title?: string
input?: unknown
output?: unknown
error?: unknown
time?: unknown
}
}
三、REST 和 SSE:双通道,缺一不可

这是新手最容易踩的坑——只靠一条通道。
REST:拉历史
| 接口 | 干什么 |
|---|---|
GET /session/{id}/message | 加载历史消息 |
POST /session/{id}/prompt_async | 异步发送,结果走 SSE |
GET /session/status | 看会话是 busy 还是 idle |
GET /permission / GET /question | 拉待处理的权限 / 提问 |
SSE:收实时
连的是 /global/event(全局流),注意别用 /event——它只监听你创建连接时绑定的那个工作区,切了项目就会漏掉 session.idle 这类关键事件。
SSE 的载荷有两种格式,解析时要兼容:
{ type, properties }
{ directory, payload: { type, ... } }
对话相关的事件就这几个,重点记 message.part.updated:
| 事件 | 作用 |
|---|---|
message.part.updated | 流式更新内容片段(含增量文本) |
message.updated | 消息元信息更新 |
session.idle | 会话生成结束 |
session.status | 状态变化(busy → idle) |
session.error | 会话级错误 |
正确姿势是双通道配合:打开页面用 REST 拉历史,模型生成时用 SSE 流式刷,生成结束再调一次 REST 同步落库。只靠 SSE 会漏,只靠 REST 轮询没有流式体验。
四、字段命名不一致?别慌,做兼容
代码里同时存在 sessionID 和 sessionId、messageID 和 messageId。这不是 bug,是历史遗留。解析的时候做个兼容函数就行:
function eventSessionId(props: Record<string, unknown> | undefined): string | undefined {
if (!props) return undefined
const id = props.sessionID ?? props.sessionId
return typeof id === 'string' ? id : undefined
}
五、SSE 事件怎么解析?看这几个典型载荷
message.part.updated(流式核心)
{
"type": "message.part.updated",
"properties": {
"delta": "这是一段",
"part": {
"id": "part_abc",
"type": "text",
"text": "这是一段增量文本",
"messageID": "msg_xyz",
"sessionID": "sess_123",
"role": "assistant"
}
}
}
合并逻辑就三条:
-
有
delta→ 追加到已有文本 -
有完整
text→ 按前缀关系决定覆盖还是保留 -
用
id/callID定位同一条 part,避免重复渲染
工具调用也是这个事件,只是 part.type 变成 tool,状态在 state.status 里流转:running → completed(带上 output)。
session.idle(别急着收尾)
{ "type": "session.idle", "properties": { "sessionID": "sess_123" } }
idle** 只是"可以收尾"的信号之一,不能单独依赖它。** 判断"生成真的结束了"要看几个条件叠加(下面讲)。
session.error(立即收尾)
{
"type": "session.error",
"properties": {
"sessionID": "sess_123",
"error": {
"name": "ProviderError",
"data": { "message": "'file part media type application/pdf' functionality not supported" }
}
}
}
收到这个,立即结束流式,并把错误挂到 assistant 消息上展示。
六、怎么判断"生成结束了"?这是最容易错的地方
很多人以为收到 session.idle 就完事了,结果界面经常"闪一下"就没了。正确做法是多个信号叠加判断,别只看一个:
-
message.info.time.completed存在,且内容可展示 -
session.status.type === 'idle' -
之前确实观察到过 busy 状态
-
发送后至少 400ms 内不要过早收尾(防止空回复闪一下消失)
另外,SSE 可能会丢。所以要有兜底:SSE 断了就轮询 getSessionStatuses() + listMessages() 把数据补回来。
七、渲染:历史 + 流式,两套数据并行
前端其实同时维护两套数据:历史 messages 和 流式 streamParts。界面分四种块:
| UI 块 | 数据来源 | 什么时候出现 |
|---|---|---|
| 历史 user | messages 里 role=user | 加载历史 / 收尾同步后 |
| 历史 assistant | 连续 assistant 合并 | 同上 |
| 乐观 user | pendingUserText | 发送后、服务端还没入库前 |
| 流式 assistant | streamParts + loading | 模型生成中 |
“乐观 user” 是个很妙的做法:用户一发消息,前端立刻把这句话显示出来(哪怕服务端还没收到),体验上零延迟。等服务端确认了再替换成正式记录。
还有个细节:OpenCode 可能返回连续多条 assistant 消息(比如工具调用后再次回复)。前端要写个 groupChatMessages() 把它们合并成一轮展示,不然会出现"同一个回答拆成好几段"的怪相。
八、权限和提问:最容易把对话"卡死"的地方
这两类不会走普通消息 parts,而是走全局弹窗队列。很多人忽略这个,结果对话莫名其妙卡住。
权限流程:
-
SSE 收到
permission.asked→ 加入待处理队列 -
用户点「允许一次 / 始终允许 / 拒绝」→ 调
POST /permission/{id}/reply -
SSE 收到
permission.replied→ 从队列移除
注意:权限被阻塞时,assistant 的工具卡片会一直停在 running,显示"等待结果…"。不处理这个弹窗,对话就会一直卡住。 这是最常见的"假死"原因。
提问流程:回复格式是 answers: string[][],每个问题对应一组选中的 option label(注意是 label 不是 index):
// 两道题,第一题单选,第二题多选
await replyQuestion(req.id, [
["选项 A"],
["选项 B", "选项 C"]
])
九、渲染分区:assistant 的内容要分三块展示
assistant 的内容别全糊在一起,MessagePartsView 把它拆成三块:
| 区域 | 包含 | 展示方式 |
|---|---|---|
| 过程区 | reasoning、tool 等 | 可折叠"思考中 / 已完成" |
| 结果区 | 所有 text 合并 | Markdown + 复制/重新生成 |
| 附件区 | file | 文件名卡片 |
用户消息只展示 text 和 file,不展示 reasoning 和 tool——用户不需要看到模型"怎么想的"。
十、常见坑清单:每一个都是真金白银换来的
1. 过早结束流式
发送后 400ms 内且没内容,别急着收尾,否则空回复闪一下消失。
2. SSE 断连
要有指数退避重连;再配一个 1s 轮询兜底。至少做其一,不然丢事件。
3. 切换会话要清状态
切 session 时要重置 streamParts、pendingUserText、loading。但如果当前会话正在生成,就不能重置——用 isStreamingSelection 判断一下。
4. 用户消息 echo
部分模型会把用户的问题原样 echo 到 assistant 的 text 里。用 hideEchoOf 隐藏首个相同文本。
5. 附件类型不支持
错误藏在 MessageError.data.message 里,要转成友好中文,别直接甩原始英文给用户。
6. 流式 text 比落库的长
收尾时如果流式内容比落库的长,保留流式的写回去,避免丢字。
7. 只有 error、没有 parts
某些失败场景 assistant 只有 info.error、parts 是空的。这时候"生成结束"的判断可能触发不了,轮询里要专门处理这种情况。
8. workspace 请求头
API 要带 x-opencode-directory(当前项目路径),否则 session 和消息会串到错误的工作区。切换项目时必须重新设置。
十一、最小接入 Checklist
如果你要在新页面接 OpenCode 对话,按这个顺序核对一遍,基本不会翻车:
-
挂载全局 Provider(SSE + 权限/提问)
-
切换项目时设置工作区目录
-
用现成的 Hook 拿
messages / streamParts / send / abort -
渲染:历史 + 乐观 user + 流式 assistant 三块
-
挂载权限弹窗 + 提问弹窗
-
错误用友好化函数,abort 用专门的判断
-
附件处理、展示文本清洗
-
不要假设 part type 固定,未知 type 要有兜底
最后说两句
接对话流这件事,难的不是"调通一个接口",而是把"历史、实时、交互"三层数据在同一个界面上协调好。很多人卡住,不是代码写不出来,而是没想清楚数据是怎么流的。
记住这个心法:
历史靠 REST,实时靠 SSE,弹窗靠交互。
判断"结束"别只看一个信号。
切会话先清状态。
权限弹窗不处理就会卡死。
把这四句话记牢,你就能少踩我踩过的 90% 的坑。
如果你也接过 OpenCode 或者类似的 Agent 对话流,欢迎在评论区聊聊你踩过的坑——说不定你的经验能帮到下一个开发者。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/ThomasCai001/article/details/163925203




