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 前端我们都不用。
要解决的问题:
src/main/main.ts:671 有 if(!status.account) throw '请先登录 ChatGPT' 门禁),且全仓没有任何 model_provider / base_url 写法 —— 免登录 + 自定义模型是新增工作,不是搬运。预期结果:桌面端里点「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()。
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 / restartlistModels、providerCapabilities、applyProvider{modelId}、clearProvidermcpList / mcpSave(McpServerInput) / mcpRemove{id}skillList{forceReload?} / skillRead{path} / skillInstallFolder{srcPath} / skillInstallZip{zipPath} / skillRemove{path} / skillSetEnabled{path,enabled} / skillOpenFolder{path?}threadStart{cwd?,model?,approvalPolicy?='never',sandbox?='workspace-write'} / turnRun{threadId,input} / turnInterrupt / threadUnsubscribelogsTail{limit?}agent_model)真源仍是后端 agent_model / agent_model_provider(ai-server/.../controller/AgentModelController.java:32-74,前端已有「模型管理」页 frontend/src/ai/views/aiModel/index.vue)。插件页只做「选一条后端模型 → 应用到本机 Codex 运行时」。
写入方式优先用启动参数而非改文件,把密钥彻底留在内存:
-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 }';env.ZSJZ_CODEX_API_KEY = 后端返回的 apiKey → key 不落任何盘(后端库里是明文,属既有现状,见风险 3);无 key 的本地服务注入占位值;config/value/write(与 MCP 同一通道,Codex 自己也持有该文件写权,所以绝不整文件覆盖)。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 回读比对。
<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;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 清掉。0.155.1 只剩 responses,而 DashScope/DeepSeek/Kimi/GLM 的 OpenAI 兼容模式多数只有 /v1/chat/completions。实施到 P2 结束时安排一次不超过半天的实测:拿库里 2-3 个真实 baseUrl 探 /v1/responses 是否可达,并测本机 Ollama 版本是否已支持 Responses。结果三选一:
127.0.0.1:<随机端口>,codex 的 base_url 指向它,代理把 /v1/responses(含 SSE 事件流、工具调用、多轮)翻译成 /v1/chat/completions。落点 electron/service/codex/responsesBridge.ts,applyProvider 检测到 chat-only 时自动把 base_url 改指向代理。默认倾向 A(你的诉求原文是「要配置第三方模型。或者本地模型。」),但要等实测数据——若多数端点已支持 Responses,选 B 省下一整个组件。
无凭据探测各厂商 /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。
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":""}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)。ai-electron/frontend/uno.config.ts:21-46 safelist 补 i-mdi:puzzle-outline 等图标(menu.json 不在 UnoCSS 扫描范围)。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 去重握手。close → running=false,进行中的 turn 以 interrupted 返回;不自动重启(自动重启会吞掉 provider 配置错误),由用户点「重启运行时」。ai-electron/electron/preload/lifecycle.ts 的 beforeClose 里 await codexService.dispose()(3s 兜底强杀)——不改 main.ts。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 起即可并行推进。
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。ai-electron 根目录 npm run dev(前端 vite 固定 3100,与 cmd/bin.js 端口一致)→ 插件页选一条后端模型 → 应用 → 装一个样例 skill → 加一个 stdio MCP(如 npx -y @modelcontextprotocol/server-everything)→ 看 connected 与 toolCount → 用 P6 的 turnRun 跑一句让它调用该 MCP 工具的话。frontend 侧 pnpm type:check 与 eslint --max-warnings 0 不新增错误;浏览器 Web 模式下新菜单可打开且只降级提示,其余页面不受影响。wire_api="chat" 下线)——产品级阻塞,P2b 用实测数据定 A/B/C。thread/start 参数、skills/* 返回结构仍需逐个 smoke;升级 codex 前必须重跑 codex-smoke。agent_model.api_key 后端明文入库且明文下发,桌面端必然拿到它。本期只做「内存 + 子进程 env + 日志 redact」,长期要后端脱敏下发或按需申请。sql/ 缺 agent_model/agent_model_provider 建表脚本(QUICK_START.md:115 已记为已知缺口)→ 新环境无表时 applyProvider 会 500。contextIsolation:false+nodeIntegration:true 是脚手架默认值,本方案会在渲染进程直连主进程能力(能读写 CODEX_HOME、起子进程)。P0 若决定启用 preload + contextIsolation:true,则 IPC 契约不变但要改 config.default.ts —— 需你确认是否顺带收紧。config.toml 写权,我方「只写自己那段」靠 config/value/write 局部写保证,禁止整文件覆盖。module/agent/mcp/*(我方作为 MCP Server 对外暴露)和 module/agent/config/SkillRepositorySupport.java(未接线的服务端 skill 仓库)是两套独立体系,本期不打通,页面文案要避免误导。