Thomas_Cai头像
前端接入 OpenCode 对话流,我踩过的那些坑封面图

前端接入 OpenCode 对话流,我踩过的那些坑

在这里插入图片描述

如果你正在把 OpenCode 的对话能力接进自己的前端,这篇文章能帮你少走一大半弯路。我把踩过的坑、看过的源码、总结出的正确姿势,一次性讲清楚。


先问你一个问题

你有没有遇到过这种情况:后端把对话接口给你了,你兴冲冲接上,结果——

  • 消息一会儿出来一会儿消失,像抽风

  • 明明模型还在思考,前端却以为它"说完了",直接收尾

  • 用户点了个工具权限弹窗,结果整个对话卡死不动

  • 切了个项目,消息全串到别的会话里去了

这些我全踩过。而且我后来发现,问题几乎都不在大模型,而在"你根本没搞懂对话数据是怎么流过来的"。

这篇文章就是把我搞懂的东西掰开揉碎讲给你。不绕弯子,全是干货。


一、先搞懂:对话数据其实分"三层"

在这里插入图片描述

很多人一上来就盯着接口文档看,越看越晕。其实 OpenCode 的对话数据就三层,你只要分清这三层,后面全通了:

  1. REST 历史消息 —— 你打开聊天页时,用来把以前的记录拉出来

  2. SSE 实时事件 —— 模型正在生成时,一条一条推给你

  3. 交互式请求 —— 权限、提问这类需要用户"点一下"的东西

记住一句话:历史靠 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 块数据来源什么时候出现
历史 usermessages 里 role=user加载历史 / 收尾同步后
历史 assistant连续 assistant 合并同上
乐观 userpendingUserText发送后、服务端还没入库前
流式 assistantstreamParts + loading模型生成中

“乐观 user” 是个很妙的做法:用户一发消息,前端立刻把这句话显示出来(哪怕服务端还没收到),体验上零延迟。等服务端确认了再替换成正式记录。

还有个细节:OpenCode 可能返回连续多条 assistant 消息(比如工具调用后再次回复)。前端要写个 groupChatMessages() 把它们合并成一轮展示,不然会出现"同一个回答拆成好几段"的怪相。


八、权限和提问:最容易把对话"卡死"的地方

这两类不会走普通消息 parts,而是走全局弹窗队列。很多人忽略这个,结果对话莫名其妙卡住。

权限流程:

  1. SSE 收到 permission.asked → 加入待处理队列

  2. 用户点「允许一次 / 始终允许 / 拒绝」→ 调 POST /permission/{id}/reply

  3. 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

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--