soft-stone-chub.md 20 KB

把 Noobi.ai 的 Codex 封装移植进 zsjz-ai + AI 菜单下新增「插件」页

Context

ai-electron/ 已用 electron-egg(ee-core 5.0.1)把前端包成桌面端,现在要把桌面端升级成能真正驱动 Codex agent 的客户端。参考项目 E:\workspace\ai\Noobi.ai(Electron + React,基于 @openai/codex 的 app-server 协议)已经有一套很干净的 Codex 调用层,但它的产品逻辑(Godot/Three 游戏生成)和 React 前端我们都不用。

要解决的问题:

  1. Noobi 的文本模型硬依赖 ChatGPT 账号登录(src/main/main.ts:671 有 if(!status.account) throw '请先登录 ChatGPT' 门禁),且全仓没有任何 model_provider / base_url 写法 —— 免登录 + 自定义模型是新增工作,不是搬运。
  2. 需要一个「插件」页面管理 Skill 与 MCP,Codex app-server 原生只给了 MCP 全量 CRUD 和 Skill 的列表/启停,安装/卸载 Skill 必须自建。

预期结果:桌面端里点「AI 数据分析 → 插件」即可配置本机 Codex 运行时用哪个模型(复用后端模型管理表)、增删 MCP server、安装/启停/卸载 Skill;全程不需要任何 Codex/OpenAI 账号登录。

已实测事实(方案的地基,均已在实机核实)

事实 证据
已装 codex-cli 0.155.1(Noobi 用 0.148) ai-electron/node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc/bin/codex.exe --version
wire_api = "chat" 已下线,只支持 responses 该 exe 二进制内含原文 `wire_api = "chat"` is no longer supported.
本地模型有原生通道 --oss / --local-provider ollama\|lmstudio codex.exe --help
-c key=value 可覆盖任意配置(含 model_providers.*),不传 --strict-config 时未知字段只告警 codex.exe --help(--strict-config 释义:*Error out when config.toml contains fields that are not recognized*)
Skill 目录规范:$CODEX_HOME/skills/<kebab-case>/SKILL.md,frontmatter 必填 name + description;内置在 skills/.system/;app-server 只有 skills/list、skills/config/write(启停)、skills/extraRoots/set、通知 skills/changed,无安装/删除方法 0.155.1 内嵌 skill-creator 文档 + app-server 方法名取证
MCP 走 mcp_servers.<id> + config/value/write + config/mcpServer/reload,热生效不用重启 E:\workspace\ai\Noobi.ai\src\main\mcpConfigManager.ts:84-93
ee-core 的 controller 自动注册成 IPC channel controller/<文件名>/<方法>,glob 含 .ts,esbuild bundle 成 CJS(target node20) ee-core/dist/cjs/socket/ipcServer.js:88-118、ee-bin/dist/cjs/plugins/bundle_registry_plugin.js:73
渲染进程桥现状:contextIsolation:false + nodeIntegration:true,webPreferences.preload 是注释掉的 → window.electron 不存在,只有 require('electron') 可用 ai-electron/electron/config/config.default.ts:20-24;旁证:frontend/src/core/components/PersonAvatar/src/PersonAvatar.vue:73-78 调的 controller/os/readBinaryFileRange 在本仓根本没有对应 controller
前端实体只有 ai-electron/frontend/(仓库根 ai-frontend/ 已被移走、不存在) ls E:/workspace/zsjz-ai

未获答复的三个提问,本计划按以下默认值编写(批准前可直接改):第三方 chat-only 端点 → 先做实测再决定是否上桥接代理(见 P3);「插件」页 → 只管 Skill + MCP;会话链路 → 建好 IPC 契约但不做对话 UI。

移植清单(源 → 目标)

E:\workspace\ai\Noobi.ai\src\main\ → E:\workspace\zsjz-ai\ai-electron\electron\service\codex\

源文件 处置
jsonRpcPeer.ts 原样搬(JSONL 分帧、出站 16MiB/入站单行 48MiB、30s 超时、stop() 的 endOutput→SIGTERM→SIGKILL 全部保留)+ jsonRpcPeer.test.ts
codexLocator.ts 搬 + 改写探测顺序:ZSJZ_CODEX_BIN → @openai/codex-<平台>/vendor/<triple>/bin/codex(.exe)(含 app.asar→app.asar.unpacked 替换)→ which/where codex;删掉 ChatGPT.app 分支
codexAppServer.ts → codexRuntime.ts:删 account/login/start、account/logout(:342-355)与 account/read 调用;--strict-config 不再传;thread/start 参数保持 camelCase(modelProvider/approvalPolicy/sandbox/cwd)
approvalBroker.ts 原样搬(redact() 已屏蔽 token/apiKey/secret)+ 单测。默认 approvalPolicy:'never',保留 broker 以便后续接审批 UI
eventMapper.ts 搬,整块删除 stage 推断(游戏流水线专用),+ 单测(删 imageGeneration/dynamicToolCall 用例)
eventLog.ts 原样搬,落盘改 userData/zsjz-codex/events/yyyy-MM-dd.jsonl(不放 CODEX_HOME,避免被 Codex 扫描)+ 单测
mcpConfigManager.ts → mcpService.ts,校验阈值全保留(id 正则、args≤64/单条≤2000、command≤500、http 仅 https 或本机、URL 禁内嵌凭据)
contracts.ts 裁出 AgentEvent / Approval* / provider 子集 → service/codex/types.ts(前后端契约单一真源)
main.ts 只借范式(handle() 包装、broadcast、事件绑定),落在 controller/codexCtl.ts;:671 登录门禁、20 个游戏 import 全部不移植
src/generated/codex/(751 文件、1.3MB) 不移植。手写代码零引用、tsconfig.main.json 也不 include,纯协议参考文档
其余 21 个(gameHarness / godot / three / asset / media / previewServer / workspaceTemplate …) 不移植
scripts/codex-smoke.ts 重写为离线冒烟 ai-electron/scripts/codex-smoke.mjs(见验证章节)

TS 书写约束:相对 import 一律省略扩展名(ee-bin 的 copy 分支会 bundle:false 输出字面 require('./x.js'),带 .js 后缀在 CJS 下会炸);禁用 import.meta.url,用 __dirname / getBaseDir()。

分层与 IPC 契约

controller/codexCtl.ts        ← 唯一 IPC 入口 + 唯一 try/catch 层
  service/codex/providerService.ts   后端模型 → Codex provider(新增,非移植)
  service/codex/mcpService.ts        MCP CRUD(移植)
  service/codex/skillService.ts      Skill 安装/卸载(新增,原生无此能力)
  service/codex/codexRuntime.ts      1 方法 ↔ 1 RPC
  service/codex/{jsonRpcPeer,codexLocator,codexHome,approvalBroker,eventMapper,eventLog}.ts

ipcMain.handle 抛错会被 Electron 包成 "Error occurred in handler for…"(ipcServer.js:113),所以 controller 层统一返回:

type Rpc<T> = { ok: true; data: T } | { ok: false; message: string };

channel 前缀 controller/codexCtl/,方法清单:

  • 运行时:ping(只探测二进制,不 spawn)→ {available,version,binaryPath,codexHome,reason?};status → {running,defaultModel,providerId,degraded};start / stop / restart
  • 模型:listModels、providerCapabilities、applyProvider{modelId}、clearProvider
  • MCP:mcpList / mcpSave(McpServerInput) / mcpRemove{id}
  • Skill:skillList{forceReload?} / skillRead{path} / skillInstallFolder{srcPath} / skillInstallZip{zipPath} / skillRemove{path} / skillSetEnabled{path,enabled} / skillOpenFolder{path?}
  • 会话(本期只建契约,UI 由你写):threadStart{cwd?,model?,approvalPolicy?='never',sandbox?='workspace-write'} / turnRun{threadId,input} / turnInterrupt / threadUnsubscribe
  • 运维:logsTail{limit?}

免登录 + 模型配置(复用后端 agent_model)

真源仍是后端 agent_model / agent_model_provider(ai-server/.../controller/AgentModelController.java:32-74,前端已有「模型管理」页 frontend/src/ai/views/aiModel/index.vue)。插件页只做「选一条后端模型 → 应用到本机 Codex 运行时」。

写入方式优先用启动参数而非改文件,把密钥彻底留在内存:

  1. spawn 时追加 -c model_provider="zsjz"、-c model="<modelId>"、-c 'model_providers.zsjz={ name="…", base_url="…", env_key="ZSJZ_CODEX_API_KEY", wire_api="responses", requires_openai_auth=false }';
  2. 同一次 spawn 的 env.ZSJZ_CODEX_API_KEY = 后端返回的 apiKey → key 不落任何盘(后端库里是明文,属既有现状,见风险 3);无 key 的本地服务注入占位值;
  3. 需要持久化的只有非敏感项,走原生 config/value/write(与 MCP 同一通道,Codex 自己也持有该文件写权,所以绝不整文件覆盖)。
  4. 换 key 必须重启子进程(env 不热更新)→ applyProvider 内部固定 stop()+start()。

字段映射:baseUrl → base_url(规范化补 /v1,不含 /v1 给提示)、headersJson → http_headers、config JSONB 里只放行 request_max_retries/stream_max_retries/stream_idle_timeout_ms/query_params,白名单外键丢弃并 warn;type==='OLLAMA' → 走 --oss --local-provider ollama 原生路径。

三处必须兜底:不调 account/read(返回 null 视为正常态);model/list 空/失败时回落顺序「后端 defaultModel → 上次应用的 model → 常量」并在 status 标 degraded:true;不传 --strict-config,改为写入前 config/read 回读比对。

Skill 安装/卸载(自建)

  • 目标目录 <codexHome>/skills/<name>/,name 归一化为 lowercase hyphen-case,正则 ^[a-z0-9]+(-[a-z0-9]+){0,9}$ 且 ≤64;
  • 校验 SKILL.md frontmatter 必含非空 name + description,否则拒绝安装;
  • 写入走「临时目录 skills/.staging-<uuid> → 原子 rename」,目标已存在需显式 overwrite;
  • zip 安全:逐 entry path.resolve 后必须以目标目录为前缀,拒绝绝对路径/../symlink/__MACOSX;限额:单文件 ≤2MiB、解压总量 ≤25MiB、条目 ≤300、SKILL.md ≤64KiB、扩展名白名单(md/txt/json/yaml/py/js/ts/sh/ps1/png/jpg/svg/csv);
  • 启停仍用原生 skills/config/write;安装/删除后 skills/list 带 forceReload;
  • 删除边界:路径必须落在 <codexHome>/skills 内、首段目录名不以 . 开头(排除 .system/.curated/.experimental 内置项)、且非 system/admin scope;只删该 skill 目录,另外把 skills/config/write 的残留叶子写 null 清掉。

P3 决策门:chat-only 端点怎么跑通(最大风险,先实测再决定)

0.155.1 只剩 responses,而 DashScope/DeepSeek/Kimi/GLM 的 OpenAI 兼容模式多数只有 /v1/chat/completions。实施到 P2 结束时安排一次不超过半天的实测:拿库里 2-3 个真实 baseUrl 探 /v1/responses 是否可达,并测本机 Ollama 版本是否已支持 Responses。结果三选一:

  • A. 内置 Responses→Chat 转换代理(覆盖面最广,工作量最大的新增件):主进程起 127.0.0.1:<随机端口>,codex 的 base_url 指向它,代理把 /v1/responses(含 SSE 事件流、工具调用、多轮)翻译成 /v1/chat/completions。落点 electron/service/codex/responsesBridge.ts,applyProvider 检测到 chat-only 时自动把 base_url 改指向代理。
  • B. 只支持 Responses 端点 + Ollama 原生路径,页面探测到 chat-only 直接给不兼容提示(最省,但大部分现有模型不可用)。
  • C. 交给外部网关(new-api/LiteLLM 等做 responses→chat 转发),我方只写校验与部署说明。

默认倾向 A(你的诉求原文是「要配置第三方模型。或者本地模型。」),但要等实测数据——若多数端点已支持 Responses,选 B 省下一整个组件。

P2b 实测结论(2026-09-22,已回填)→ 采用 B + 端点探测,A 降级为后续增量

无凭据探测各厂商 /v1/responses,并用「故意不存在的路径」做对照组排除网关统一 401 的干扰:

厂商 假路径 /v1/responses 结论
阿里 DashScope(compatible-mode) 404 401 ✅ 已实现 Responses
Moonshot / Kimi 404 401 ✅ 已实现
SiliconFlow 404 404 ❌ 未实现
DeepSeek 401 401 网关对任意路径都 401,无法判定
智谱 GLM(paas/v4) 401 401 同上,无法判定
OpenAI / OpenRouter 000 000 本机网络不通,未测
本机 Ollama — — 未安装;Codex 有内置 model_provider="ollama",走内置通道

决策:本期按 B 实现——provider 一律 wire_api="responses",applyProvider 前先探测 base_url + /responses,404/405 就在页面上给出明确的「该端点未实现 Responses API」提示并阻止应用;本地模型走 Codex 内置 provider。A(内置 Responses→Chat 转换代理)作为后续独立增量,等确实要接 SiliconFlow/DeepSeek 这类 chat-only 端点时再做——它是一个大且易随 codex 升级漂移的组件,不该在证据不足时先建。

补充实测:model/list 在未登录时也会返回 Codex 内置模型目录(如 gpt-6-astra),与自定义 provider 无关 → defaultModel 必须优先取 provider 配置的模型,否则页面会显示一个根本用不了的模型。skills/list 未登录可用,且 Codex 会把内置 skill 实体化到 <codexHome>/skills/.system/(imagegen / plugin-creator / review-agent / skill-creator / skill-installer),P5 的删除边界必须排除 .system。

前端登记(4 处,均为既有约定)

  1. frontend/src/core/store/modules/menu.json:在 id 10263(「AI 数据分析」)的 children 末尾(现结构结束于 :657)追加一条,字段形状对齐 10265: {"path":"/aiPlugin/index","component":"/aiPlugin/index","children":[],"meta":{"icon":"i-mdi:puzzle-outline","hideMenu":false,"color":"","title":"插件"},"name":"ViewsAiPluginIndex","id":"10266","leaf":false,"url":"/aiPlugin/index","target":""}
  2. frontend/src/core/router/helper/routeHelper.ts:53-61 的 explicitDynamicViewMap 加 '/aiPlugin/index': () => import('@/ai/views/aiPlugin/index.vue')(AI_AGENT.md:372-374 要求显式映射,否则 :74 的 import.meta.glob 会命中 node_modules 同名 views)。
  3. ai-electron/frontend/uno.config.ts:21-46 safelist 补 i-mdi:puzzle-outline 等图标(menu.json 不在 UnoCSS 扫描范围)。
  4. 新增 frontend/src/ai/api/codexApi.ts:ipcInvoke<T>(method, params) 封装 + 在 src/ai/api/index.ts 登记出口。不走 src/ai/api/http.ts(那是 /js/a HTTP 壳,IPC 无前缀无 token);只有「选后端模型」的下达拉取复用既有 HTTP。

页面 frontend/src/ai/views/aiPlugin/index.vue:a-tabs(destroy-inactive-tab-pane)分 MCP / Skill / Codex 运行时三块。表格与弹窗复用既有范式:列表写法照 src/ai/views/aiModel/index.vue:32-140(原生 a-table + 手写 columns,全量不分页),表单照 src/ai/components/ModelFormModal.vue。非 Electron 环境用 require?.('electron') 探测,取不到 ipcRenderer 就整页渲染一条 a-alert「本功能仅在桌面客户端可用」且不发任何调用;错误统一 message.error(res.message),provider 不兼容用 notification.warning 常驻。

无 i18n(菜单 title 与 AI 页文案都是中文字面量)、无菜单 SQL(全仓不建 js_sys_menu,菜单是前端静态 JSON)。

生命周期与打包

  • 懒启动:ping 不 spawn;首次 applyProvider/mcp*/skill*/threadStart 才拉起。#starting Promise 去重握手。
  • 崩溃:peer close → running=false,进行中的 turn 以 interrupted 返回;不自动重启(自动重启会吞掉 provider 配置错误),由用户点「重启运行时」。
  • 回收:在 ai-electron/electron/preload/lifecycle.ts 的 beforeClose 里 await codexService.dispose()(3s 兜底强杀)——不改 main.ts。
  • CODEX_HOME:固定 userData/codex-home(mkdir mode 0700),启动时补建空 skills/。
  • 打包必改 ai-electron/cmd/builder.json:加 "asarUnpack": ["node_modules/@openai/codex-*/vendor/**"],否则 asar 里的 exe spawn 不到;files 已含 node_modules/**。Mac/Linux 的 builder-*.json 同步加对应平台包 glob。

实施顺序与每步验收

# 内容 验收
P0 先打通渲染→主进程通道(当前 window.electron 根本不存在):空 codexCtl.ping + 探针,确定用 require('electron').ipcRenderer 还是启用 preload/bridge.js+contextIsolation 页面能拿到 {ok:true,data:{version:'codex-cli 0.155.1'}}
P1 搬 jsonRpcPeer/codexLocator/codexHome + 3 个纯单测 codex-smoke 之外的单测全绿;locator 在本机返回真实 exe 路径
P2 codexRuntime(spawn/握手/stop/model/list),不传 --strict-config status().running===true;listModels() 非空或明确 degraded
P2b P3 决策门实测(探后端真实端点 + Ollama 的 /v1/responses) 产出 A/B/C 结论并回填本计划
P3 provider:applyProvider + env 注入 + 重启;(选 A 则加 responsesBridge.ts) 跑一次真实 turnRun 产出文件;在 config.toml 里 grep 不到 api_key
P4 mcpService 移植 存一个 stdio server → mcpList 出现 connected:true,toolCount>0,无需重启
P5 skillService + 上传安全单测(越界 zip/超大 zip/缺 frontmatter 各一例) 装入样例 skill → skills/list 可见且能启停;三类恶意包全被拒
P6 会话契约 threadStart/turnRun/turnInterrupt + eventMapper/eventLog/webContents.send 事件推送 logsTail 有内容,turn 期间前端能收进度
P7 菜单/路由/safelist + codexApi.ts + 三 Tab 页面 三块 CRUD 全可用;浏览器 Web 模式显示降级提示
P8 asarUnpack + prod 打包冒烟 ee-bin build --cmds=electron && ee-bin build --cmds=win64 后装包里 ping 成功

P1/P2 与 P4/P5 可并行(都只依赖 P0);P7 自 P0 起即可并行推进。

端到端验证

  1. 离线冒烟 ai-electron/scripts/codex-smoke.mjs(不需要任何登录):起临时 HTTP 服务实现 POST /v1/responses(返回含一次 write_file 的固定流)→ 建临时 CODEX_HOME → spawn app-server --listen stdio://(带 RUST_LOG=warn LOG_FORMAT=json、ZSJZ_SMOKE_KEY 占位)→ initialize/initialized → thread/start{approvalPolicy:'never',sandbox:'workspace-write',cwd:tmpdir} → turn/start → 断言文件产出 → stop(),失败退出码非 0。
  2. 桌面端手工链路:ai-electron 根目录 npm run dev(前端 vite 固定 3100,与 cmd/bin.js 端口一致)→ 插件页选一条后端模型 → 应用 → 装一个样例 skill → 加一个 stdio MCP(如 npx -y @modelcontextprotocol/server-everything)→ 看 connected 与 toolCount → 用 P6 的 turnRun 跑一句让它调用该 MCP 工具的话。
  3. 回归:frontend 侧 pnpm type:check 与 eslint --max-warnings 0 不新增错误;浏览器 Web 模式下新菜单可打开且只降级提示,其余页面不受影响。

风险清单

  1. chat-only 端点不可用(已实测 wire_api="chat" 下线)——产品级阻塞,P2b 用实测数据定 A/B/C。
  2. 0.148→0.155 协议漂移:Noobi 用到的 19 个 method 在 0.155.1 中已确认存在,但 thread/start 参数、skills/* 返回结构仍需逐个 smoke;升级 codex 前必须重跑 codex-smoke。
  3. agent_model.api_key 后端明文入库且明文下发,桌面端必然拿到它。本期只做「内存 + 子进程 env + 日志 redact」,长期要后端脱敏下发或按需申请。
  4. sql/ 缺 agent_model/agent_model_provider 建表脚本(QUICK_START.md:115 已记为已知缺口)→ 新环境无表时 applyProvider 会 500。
  5. ee-core 的 contextIsolation:false+nodeIntegration:true 是脚手架默认值,本方案会在渲染进程直连主进程能力(能读写 CODEX_HOME、起子进程)。P0 若决定启用 preload + contextIsolation:true,则 IPC 契约不变但要改 config.default.ts —— 需你确认是否顺带收紧。
  6. Codex 自身也持有 config.toml 写权,我方「只写自己那段」靠 config/value/write 局部写保证,禁止整文件覆盖。
  7. 与后端既有的 module/agent/mcp/*(我方作为 MCP Server 对外暴露)和 module/agent/config/SkillRepositorySupport.java(未接线的服务端 skill 仓库)是两套独立体系,本期不打通,页面文案要避免误导。