# 把 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//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.` + `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//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 层统一返回: ```ts type Rpc = { 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=""`、`-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 安装/卸载(自建) - 目标目录 `/skills//`,`name` 归一化为 lowercase hyphen-case,正则 `^[a-z0-9]+(-[a-z0-9]+){0,9}$` 且 ≤64; - 校验 `SKILL.md` frontmatter 必含非空 `name` + `description`,否则拒绝安装; - 写入走「临时目录 `skills/.staging-` → 原子 `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`; - 删除边界:路径必须落在 `/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 实体化到 `/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(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 仓库)是**两套独立体系**,本期不打通,页面文案要避免误导。