搜索文章

输入关键词开始搜索

OpenCode调用CodeX实现文生图功能

程序设计#AI

最近在给 OpenCode 客户端接一个文生图能力:让用户在对话里直接让 AI 生成图片,图片落到本地工作区,还能在对话里预览。一开始我以为这就是一次图片 API 调用,把代码完整梳理了一遍才发现,实际链路要长得多——四个进程接力,模型自己并不直接生图。这里做个记录。

需求

需求本身不复杂:

  • 在 OpenCode 对话里,用户说一句话就能生图;
  • 生成的图片要保存到本地工作区,可管理、可复用;
  • 对话里能直接看到图。

真正绕的地方在于:OpenCode 是一个通用 AI 客户端,生图能力不在它自己身上,而在 Codex(OpenAI 的官方 CLI)里。怎么把两边安全地接起来,才是主要工作。

一开始的误解

刚看这套代码时,我以为链路是:OpenCode → 某个生图 API → 返回图片,一次请求结束。

实际上完全不是。真实链路是四个进程接力:

OpenCode 客户端
   │  标准插件协议(MCP),通过子进程 stdin/stdout 通信

codex-image-mcp(插件入口,只做校验和转发)
   │  本机 Unix socket(私有协议,文件权限 0600)

codex-image-gateway(核心中转:防重复、状态机、安全校验、落盘)
   │  Codex 官方协议

codex app-server(官方 Codex CLI 的后台模式)
   │  让 Codex agent 调用内置工具 image_gen.imagegen

OpenAI 图片模型 → 生成 PNG

OpenCode、MCP、Gateway 与 Codex 的端到端生图链路

搞清楚这张图,后面的一切都好理解了:模型不直接生图,真正生图的是 Codex 里那个 agent 的内置工具 image_gen.imagegen。我们做的,是把 OpenCode 里发起的请求一路安全地送到这个工具手上,再把图片一路安全地拿回来。

完整流程

整体走一遍:

  1. OpenCode 的配置文件里注册了一个本地插件 narrative-image,启动时拉起 codex-image-mcp 子进程。同时配了一份 skill 说明书,告诉模型什么时候、怎么调用这个工具。
  2. 用户说”生成一张 xxx 的图”,模型调用工具 narrative.image.generate,必填提示词、运行编号、防重复键,还要显式确认 dataEgressConfirmed(同意把提示词发到外部服务)。
  3. MCP 进程校验参数,生成请求 ID,通过本机 socket 发给 gateway。
  4. gateway 用”防重复键 + 请求内容指纹”去重,同一个请求不会重复生图;请求状态写入日志文件,崩溃重启后也能恢复。
  5. gateway 启动时先做一次能力检查:Codex 登录了吗?账号支持生图吗?任何一项不满足就直接拒绝。
  6. gateway 开一个 Codex 会话(只读沙箱、不询问确认),下达指令”用 image_gen.imagegen 生成一次图片,内容是:<提示词>“,默认最多等 180 秒,超时主动中断。
  7. 拿到图片后做安全检查:必须是 PNG、不超过 25MB、和 Codex 落盘的文件逐字节一致,然后原子写入工作区的 images/ 目录,登记进文件清单 project.json
  8. MCP 把结果分两部分返回:结构化的文字信息(图片 ID、相对路径、哈希值)+ 一份临时的小图预览(不超过 5MB)。
  9. OpenCode 把图片转成对话附件,显示在工具卡片里。

生图请求的控制回路与失败门禁

为什么拆这么多进程

看完代码我的理解是:每一层只干一件事,边界清晰,出问题好排查,测试时也好替换。

  • codex-image-mcp:OpenCode 唯一能看到的面。把生图包装成标准 MCP 工具,本身不生图。入口在 narrative/tools/codex-image-mcp/src/server.ts
  • codex-image-gateway:核心中转。防重复、状态机、驱动 Codex、图片安全落盘都在这。代码在 narrative/packages/codex-image-gateway/
  • codex-app-server-adapter:负责启动 codex 进程、收发消息,协议类型由官方 Codex CLI 0.144.5 自动生成。代码在 narrative/packages/codex-app-server-adapter/src/client.ts
  • codex app-server:官方 Codex 进程,凭证和登录态都在它手里,我们的代码完全不碰。

关键代码

挑三段最能说明问题的。

第一段是能力检查,gateway 启动时先问 Codex”登录了吗、账号能生图吗”,不行就直接拒绝服务(capability-gate.ts:4-16):

const account = await client.request("account/read", { refreshToken: false })
if (!account.account || typeof account.account !== "object") {
  throw new CodexRuntimeError("CODEX_AUTH_REQUIRED", "Codex sign-in is required")
}

const capabilities = await client.request("modelProvider/capabilities/read", {})
if (capabilities.namespaceTools !== true || capabilities.imageGeneration !== true) {
  throw new CodexRuntimeError(
    "CODEX_IMAGE_CAPABILITY_UNAVAILABLE",
    "The current Codex account cannot generate images",
  )
}

第二段是发起生图任务,把请求翻译成 Codex 的一次 Turn,超时 180 秒主动中断(app-server-backend.ts:122-140):

const turn = await this.client.request("turn/start", {
  threadId,
  model: this.model,
  input: [{ type: "text", text: imageInstruction(request), text_elements: [] }],
})
timeout = setTimeout(() => {
  void this.client.request("turn/interrupt", { threadId, turnId }).catch(() => undefined)
  fail(new CodexRuntimeError("CODEX_TIMEOUT", "Codex image generation timed out"))
}, this.options.generationTimeoutMs ?? 180_000)

第三段是图片落盘,收到图之后的一连串检查(artifact-importer.ts:30-50):

const inline = decodeBase64(item.result)
const bytes = item.savedPath ? await this.readGeneratedImage(item.savedPath) : inline
if (item.savedPath && !bytes.equals(inline)) {
  throw new CodexRuntimeError("ASSET_IMPORT_FAILED", "Codex saved image does not match the typed image result")
}
requirePng(bytes)                                  // 必须是 PNG
const sha256 = createHash("sha256").update(bytes).digest("hex")
const artifactId = `image_${sha256.slice(0, 24)}`  // 用内容哈希当图片 ID
await writeAtomic(temporary, target, bytes)        // 先写临时文件再改名

PNG artifact 从生成、校验到安全落盘的生命周期

设计上值得记录的几点

凭证不过手。 登录态完全由官方 codex 进程自己管理(读 ~/.codex),我们的代码只负责启动它,不读、不复制、不记录任何凭证。这一开始就定死了,后面省掉很多麻烦。

防重复是硬约束。 靠”防重复键 + 内容指纹”去重;进程崩溃重启后,未完成的请求标记为”待人工核对”,绝不自动重复提交。生图是要花钱的,重复提交的代价比留着不处理大得多。

隐私边界分两层。 写给 AI 模型和日志的,只有 ID、相对路径、哈希这类安全信息;图片本体只作为临时预览返回,不进任何日志和清单文件。返回前还有一次独立的二次校验。

超时层层设防。 普通请求 30 秒、生图任务 180 秒、socket 调用 190 秒、插件侧建议 300 秒,超时逐级主动取消,不会出现”谁在等谁”搞不清楚的情况。

经验

  • 两个系统对接的需求,先把完整链路图画出来再谈代码。一开始没画图时,我以为瓶颈在 API 调用,画完图才发现真正的工作量全在中转层的校验和状态管理上。
  • 进程拆得细不是过度设计。每一层只做一件事,测试时就能用假 gateway、假模型逐层替换,这套代码的端到端测试就是这么跑的。
  • 凡是要出本机的数据,显式确认 + 最小化返回,比事后补救便宜得多。