基于Electron聚合Claude Code与Codex的桌面AI编程客户端实战 1. 从 t3code 这个名字说起它到底想解决什么问题第一次看到 t3code 这个标题很多人会一头雾水——它不像某个具体框架的名字也不像某个库的缩写。但把热搜词摊开来看答案其实很清晰Electron、Claude Code、Codex、Cursor这几个词凑在一起指向的是一个非常具体的场景——把当下主流的 AI 编程助手塞进一个自己可控的桌面客户端里。我接触这个方向是从一个很朴素的痛点开始的。日常写代码Claude Code 和 Codex 这类命令行形态的 AI 助手确实好用但它们都活在终端里。终端有它的好处轻、快、可脚本化。可一旦你需要在多个项目之间来回切换或者想同时盯着好几个会话的上下文纯终端就开始难受了。Cursor 这类 IDE 集成了 AI体验顺滑但它是封闭的你没法把 Claude Code、Codex、以及各种第三方模型随意拼装进去。t3code 这个项目本质上就是在填这个空档用 Electron 搭一个桌面壳把 Claude Code、Codex 这些 AI 编程工具的能力聚合进来做成一个自己能改、能扩展、能接第三方模型的本地客户端。它不是一个从零造轮子的 AI而是一个编排层——负责把已有的 AI 能力组织成一个顺手的桌面工作台。所以这篇文章适合谁看三类人。第一类是想搞明白 Electron AI 编程工具怎么组合的开发者第二类是被 Cursor 的封闭性卡住、想自己搭一套的人第三类是已经在用 Claude Code 或 Codex但觉得终端体验不够、想找个桌面替代方案的人。哪怕你只是想搞清楚Claude Code 和 Codex 到底怎么装、怎么接第三方模型这篇里的实操部分也能直接用。我下面会按为什么这么设计 → 核心细节 → 完整实操 → 踩坑排查的顺序展开尽量把每一步背后的逻辑讲透而不是甩一堆命令让你照抄。2. 整体设计思路为什么是 Electron而不是别的2.1 Electron 在这个场景里到底赢在哪先说结论选 Electron 不是因为它是最好的技术而是因为它在快速做一个跨平台桌面壳这件事上性价比最高。AI 编程助手这类工具核心逻辑其实都在后端——要么是本地跑的命令行进程要么是远程的模型 API。前端需要做的事情是渲染对话界面、管理多个会话、处理文件树、调用本地进程、展示 diff。这些需求里没有一个是必须用原生性能才能扛住的。你不需要 60 帧的游戏级渲染也不需要毫秒级的系统调用。你需要的是一套成熟的 UI 生态、一套能调用本地进程的能力、以及一次开发多平台运行。Electron 恰好全占了。它用 Chromium 做渲染意味着你写前端的那套东西React、Vue、Tailwind直接能用它用 Node.js 做主进程意味着你可以child_process.spawn去拉起 Claude Code 或 Codex 的进程它打包一次Windows、macOS、Linux 都能出包。对比一下其他选项就清楚了。用 Tauri 的话包体积小很多但 Rust 侧和前端侧的通信、以及调用本地 CLI 的成熟度在快速迭代阶段会拖慢你。用纯 Web 的话你根本没法方便地拉起本地进程、读写本地文件系统浏览器沙箱会把你卡死。用原生 Qt 或 SwiftUI 的话跨平台成本直接翻倍。提示Electron 的重是真实存在的一个空壳打包出来就上百 MB。但在 AI 编程工具这个场景里用户本来就要装 Node、装各种 CLI对体积的敏感度远低于对功能完整度的敏感度。这个取舍是划算的。2.2 为什么要把 Claude Code 和 Codex 聚合进来这里有个关键判断不要自己造 AI 能力而是做能力的编排者。Claude Code 和 Codex 各自都是成熟的编程助手它们有自己的模型、自己的工具调用逻辑、自己的上下文管理。你如果试图在 t3code 里重新实现一套读文件、改代码、跑测试的 agent 循环那是在跟两个已经打磨了很久的产品正面竞争几乎必输。正确的做法是把它们当成引擎t3code 只做驾驶舱。具体来说t3code 负责管理多个会话每个会话可以绑定不同的引擎Claude Code 或 Codex提供统一的对话界面屏蔽掉两个引擎在命令行交互上的差异处理文件上下文把当前项目的信息喂给引擎展示引擎返回的 diff、命令执行结果允许接入第三方模型DeepSeek、Qwen、GLM 等通过兼容层转发这个思路的好处是引擎升级了你不用跟着重写只要适配它的输入输出协议就行。坏处是你得处理两个引擎在协议上的不一致——这正是后面常见问题里要重点讲的。2.3 第三方模型接入的架构选择热搜词里反复出现 codex接入deepseek、使用cc switch 接入 deepseek v4, qwen, glm等模型说明第三方模型接入是刚需。这里的核心矛盾是Claude Code 和 Codex 原生只认自家的模型端点你想用 DeepSeek 或 Qwen就得在中间加一层协议转换。常见的做法是本地起一个代理服务把 OpenAI 兼容格式的请求转换成目标模型能理解的格式再把响应转回来。这层代理通常监听在localhost的某个端口然后通过环境变量或配置文件让 Claude Code / Codex 把请求打到这个本地端口。注意这层代理的稳定性直接决定了整个工具链能不能用。热搜里那条 cc switch local proxy failed while handling codex endpoint /responses 就是典型的代理层报错——请求转发到 Codex 的/responses端点时挂了。这类问题的排查思路我在第 4 节会详细拆。架构上我倾向于把代理层做成独立进程而不是塞进 Electron 主进程。原因是代理层崩了不应该拖垮整个 UI而且独立进程方便你单独重启、单独看日志。Electron 主进程只负责拉起代理和监控代理状态。3. 核心细节解析几个绕不开的技术点3.1 Electron 主进程与渲染进程的职责划分这是 Electron 项目最容易做乱的地方。我的划分原则很简单主进程负责所有碰系统的事情拉起 Claude Code / Codex 子进程、读写文件、管理代理服务、处理菜单和快捷键、管理窗口生命周期。渲染进程负责所有碰界面的事情对话渲染、会话列表、设置面板、diff 展示。两者之间通过 IPC 通信。这里有个坑IPC 传的数据必须是可序列化的你不能直接把一个子进程对象从主进程传到渲染进程。所以主进程需要把子进程的输出流翻译成普通的消息对象再通过webContents.send推给渲染进程。// 主进程拉起 Claude Code 并把输出推给渲染进程 const { spawn } require(child_process); const { ipcMain } require(electron); function startClaudeSession(win, sessionId, cwd) { const proc spawn(claude, [], { cwd, env: { ...process.env, FORCE_COLOR: 0 }, shell: process.platform win32 }); proc.stdout.on(data, (chunk) { win.webContents.send(session-output, { sessionId, stream: stdout, data: chunk.toString(utf8) }); }); proc.stderr.on(data, (chunk) { win.webContents.send(session-output, { sessionId, stream: stderr, data: chunk.toString(utf8) }); }); proc.on(exit, (code) { win.webContents.send(session-exit, { sessionId, code }); }); return proc; }注意FORCE_COLOR: 0这一行。Claude Code 在终端里会输出带 ANSI 颜色码的文本如果你直接把这些码丢到网页里渲染会看到一堆\x1b[32m之类的乱码。关掉颜色输出或者在前端做 ANSI 解析二选一。我一般选前者省事。3.2 会话隔离与上下文管理多个会话同时跑最大的风险是上下文串了。比如你在 A 项目里让 AI 改了一个文件结果 B 会话的 AI 读到了这个改动做出了错误判断。解决办法是每个会话绑定一个独立的工作目录和独立的环境变量。spawn的时候把cwd设死环境变量里跟模型相关的配置也按会话隔离。这样即使两个会话同时跑它们看到的文件系统和模型配置也是分开的。上下文管理上我建议每个会话维护一个自己的消息历史不要全局共享。Claude Code 和 Codex 都有自己的上下文窗口管理机制你硬塞历史进去反而会干扰它们。t3code 要做的只是把用户输入和引擎输出按会话存好方便回看。3.3 第三方模型接入的协议转换细节这块是技术含量最高的部分。以 Codex 接入 DeepSeek 为例核心是搞清楚两边的请求格式差异。Codex 走的是 OpenAI 的/responses端点风格请求体里包含model、input、tools等字段。DeepSeek 走的是标准的/chat/completions格式字段是model、messages、tools。两者在工具调用的表达上也有差异。代理层要做的事情就是收到 Codex 格式的请求 → 转换成 DeepSeek 格式 → 转发 → 收到 DeepSeek 响应 → 转换回 Codex 期望的格式 → 返回。# 一个极简的协议转换示意Flask 伪代码 from flask import Flask, request, jsonify import requests app Flask(__name__) UPSTREAM https://api.deepseek.com/v1/chat/completions app.route(/responses, methods[POST]) def responses(): body request.get_json() # Codex 的 input 转成 messages messages [] for item in body.get(input, []): messages.append({ role: item.get(role, user), content: item.get(content, ) }) upstream_body { model: deepseek-chat, messages: messages, stream: body.get(stream, False) } resp requests.post(UPSTREAM, jsonupstream_body, headers{Authorization: fBearer {API_KEY}}) # 这里需要把响应再转回 Codex 期望的结构 return jsonify(convert_back(resp.json()))实际写起来比这复杂得多尤其是流式响应和工具调用的转换。但核心逻辑就是这个收进来、转格式、发出去、转回来的循环。提示转换层最容易出问题的地方是流式响应。Codex 期望的是 SSE 格式的增量事件DeepSeek 返回的也是 SSE但事件结构不一样。如果你不做逐事件的转换前端会收到一堆解析不了的数据表现就是AI 回复到一半卡住。3.4 菜单、快捷键与本地化Electron 的菜单系统是原生的跟网页里的菜单是两套东西。热搜里出现 electron菜单 和 cursor怎么设置中文说明这两块都是用户高频关注的点。菜单这块我建议把高频操作新建会话、切换引擎、打开设置都挂到原生菜单和快捷键上。因为用户在写代码的时候手是在键盘上的让他去点网页里的按钮效率太低。本地化这块Electron 应用的中文支持分两层一层是界面文案这个用 i18n 方案比如 i18next就能解决另一层是 AI 回复的语言这个要在系统提示词里明确要求用中文回复。很多人以为设置里选个中文AI 就会说中文其实不是——界面语言和模型输出语言是两回事。4. 完整实操从零搭一个能跑的 t3code4.1 环境准备与依赖安装先把基础环境搭好。Node.js 建议用 18 或 20 的 LTS 版本太新的版本有时候跟 Electron 的构建工具链会打架。# 检查 Node 版本 node -v # 建议 v18.x 或 v20.x # 初始化项目 mkdir t3code cd t3code npm init -y # 装 Electron 和构建工具 npm install --save-dev electron electron-builder npm install --save-dev electron/rebuild # 装前端框架这里以 React 为例 npm install react react-dom npm install --save-dev vite vitejs/plugin-reactClaude Code 和 Codex 的安装各自有官方渠道。Claude Code 一般通过 npm 全局安装Codex 也有对应的安装包。装完之后在终端里能直接敲命令跑起来才算环境 OK。# 验证 Claude Code 是否可用 claude --version # 验证 Codex 是否可用 codex --version如果这两个命令报找不到说明 PATH 没配好或者安装没成功。Windows 上尤其容易出这个问题因为 npm 全局包的路径有时候不在系统 PATH 里。4.2 主进程骨架搭建主进程是整个应用的入口负责创建窗口、注册 IPC、拉起子进程。// main.js const { app, BrowserWindow, ipcMain, Menu } require(electron); const path require(path); let mainWindow null; const sessions new Map(); // sessionId - child process function createWindow() { mainWindow new BrowserWindow({ width: 1280, height: 800, webPreferences: { preload: path.join(__dirname, preload.js), contextIsolation: true, nodeIntegration: false } }); if (process.env.NODE_ENV development) { mainWindow.loadURL(http://localhost:5173); } else { mainWindow.loadFile(path.join(__dirname, dist/index.html)); } } app.whenReady().then(() { createWindow(); buildMenu(); }); app.on(window-all-closed, () { // 清理所有子进程 for (const proc of sessions.values()) { proc.kill(); } if (process.platform ! darwin) app.quit(); });contextIsolation: true和nodeIntegration: false这两个配置是安全底线别为了图方便关掉。关了之后渲染进程就能直接访问 Node API一旦加载了不可信内容风险很大。4.3 preload 脚本与安全 IPC 通道preload 脚本是主进程和渲染进程之间的安全桥。它运行在一个有 Node 能力、但又能访问 DOM 的环境里负责把主进程的能力以受控的方式暴露给渲染进程。// preload.js const { contextBridge, ipcRenderer } require(electron); contextBridge.exposeInMainWorld(t3api, { startSession: (engine, cwd) ipcRenderer.invoke(session:start, { engine, cwd }), sendInput: (sessionId, text) ipcRenderer.invoke(session:input, { sessionId, text }), stopSession: (sessionId) ipcRenderer.invoke(session:stop, { sessionId }), onOutput: (callback) ipcRenderer.on(session-output, (_e, payload) callback(payload)), onExit: (callback) ipcRenderer.on(session-exit, (_e, payload) callback(payload)) });这样渲染进程只能通过window.t3api这几个明确的方法跟主进程通信不能随便调用任意 IPC 通道。这是 Electron 安全模型的核心。4.4 会话管理器的实现会话管理器负责维护所有活跃会话的状态。我用一个 Map 存sessionId - { proc, engine, cwd, history }。// session-manager.js const { spawn } require(child_process); const { randomUUID } require(crypto); const sessions new Map(); function startSession(win, { engine, cwd }) { const sessionId randomUUID(); const cmd engine claude ? claude : codex; const proc spawn(cmd, [], { cwd, env: { ...process.env, FORCE_COLOR: 0 }, shell: process.platform win32 }); const session { proc, engine, cwd, history: [] }; sessions.set(sessionId, session); proc.stdout.on(data, (chunk) { const text chunk.toString(utf8); session.history.push({ role: assistant, content: text }); win.webContents.send(session-output, { sessionId, stream: stdout, data: text }); }); proc.stderr.on(data, (chunk) { win.webContents.send(session-output, { sessionId, stream: stderr, data: chunk.toString(utf8) }); }); proc.on(exit, (code) { sessions.delete(sessionId); win.webContents.send(session-exit, { sessionId, code }); }); return sessionId; } function sendInput(sessionId, text) { const session sessions.get(sessionId); if (!session) throw new Error(session not found); session.history.push({ role: user, content: text }); session.proc.stdin.write(text \n); } module.exports { startSession, sendInput, sessions };这里有个细节shell: process.platform win32。Windows 上spawn一个.cmd或.bat文件时不加shell: true会报ENOENT。因为 npm 全局安装的 CLI 在 Windows 上通常是.cmd包装脚本不是可执行文件。4.5 第三方模型代理的启动与配置代理服务我建议单独写一个 Node 脚本用child_process在应用启动时拉起。// proxy-launcher.js const { spawn } require(child_process); const path require(path); let proxyProc null; function startProxy() { proxyProc spawn(node, [path.join(__dirname, proxy, server.js)], { env: { ...process.env, PROXY_PORT: 8787, UPSTREAM_BASE: process.env.UPSTREAM_BASE || https://api.deepseek.com/v1, UPSTREAM_KEY: process.env.UPSTREAM_KEY || }, stdio: [ignore, pipe, pipe] }); proxyProc.stdout.on(data, d console.log([proxy], d.toString())); proxyProc.stderr.on(data, d console.error([proxy-err], d.toString())); proxyProc.on(exit, (code) { console.error([proxy] exited with code ${code}, restarting in 2s); setTimeout(startProxy, 2000); }); } module.exports { startProxy };注意那个自动重启逻辑。代理层崩了是常态尤其是转换逻辑没写全的时候。自动重启能保证 UI 不会因为代理挂了而完全不可用。配置 Claude Code 或 Codex 走本地代理一般是通过环境变量# 让 Codex 把请求打到本地代理 export OPENAI_BASE_URLhttp://localhost:8787 export OPENAI_API_KEYdummy-key具体变量名要看引擎版本不同版本可能不一样。这个得对着官方文档确认别照抄。4.6 打包与分发开发跑通了最后一步是打包。Electron 用electron-builder出包。{ build: { appId: com.example.t3code, productName: t3code, directories: { output: release }, files: [dist/**/*, main.js, preload.js, proxy/**/*], mac: { target: dmg }, win: { target: nsis }, linux: { target: AppImage } } }npx electron-builder --mac --win --linux打包时最容易踩的坑是原生依赖。如果你的代理层用了需要编译的 npm 包比如某些加密库打包后可能因为 ABI 不匹配跑不起来。解决办法是用electron/rebuild重新编译或者干脆选纯 JS 实现的依赖。5. 常见问题与排查技巧实录5.1 代理层报错 failed while handling codex endpoint /responses这是热搜里出现频率最高的报错。现象是Codex 发请求到本地代理代理转发到上游模型然后报错。排查顺序我一般是这样排查点检查方法常见原因代理是否在跑curl http://localhost:8787/health进程没起来或端口被占请求格式看代理日志里的原始请求体Codex 版本升级后字段变了上游连通性直接 curl 上游 API网络问题或 key 失效响应转换看代理返回给 Codex 的结构字段名对不上Codex 解析失败流式处理看是否卡在第一个 chunkSSE 事件格式不匹配我遇到最多的情况是上游模型返回的响应结构跟 Codex 期望的不一致。比如 Codex 期望output字段是个数组但上游返回的是字符串Codex 解析的时候就炸了。解决办法是在转换层做严格的字段映射别偷懒直接透传。5.2 Claude Code / Codex 在 Electron 里跑不起来典型表现是终端里能跑Electron 里spawn就报ENOENT。原因通常是 PATH 问题。Electron 启动时的环境变量跟你终端里的环境变量不是一套。终端里claude能找到是因为 shell 加载了.bashrc或.zshrc把 npm 全局路径加进了 PATH。Electron 直接启动的话没有这个加载过程。解决办法有两个一是在spawn的时候显式指定完整路径二是在应用启动时手动把 npm 全局路径拼进process.env.PATH。const os require(os); const path require(path); // 把常见的 npm 全局路径加进去 const extraPaths [ path.join(os.homedir(), .npm-global, bin), /usr/local/bin, /opt/homebrew/bin ]; process.env.PATH [...extraPaths, process.env.PATH].join(path.delimiter);5.3 中文回复设置不生效热搜里 cursor怎么设置中文回复、cursor中文怎么设置 反复出现说明这是个普遍困惑。核心认知是界面语言和 AI 输出语言是两码事。界面语言在设置里选中文就行。但 AI 回复用什么语言取决于你给模型的提示词。很多工具默认用英文回复你得在系统提示词或者项目配置里明确写请用中文回复。在 t3code 里我的做法是在每个会话启动时往引擎的配置里注入一段中文指令。Claude Code 支持项目级的配置文件Codex 也有类似的机制。把语言要求写进去比每次对话都提醒一遍靠谱。5.4 会话卡死与进程泄漏跑久了会发现有些会话明明没在输出但进程还在占着内存。这是典型的进程泄漏。根因通常是子进程的 stdout/stderr 流没有正确关闭或者进程在等待输入但你没给它。我的处理方式是加一个心跳检测如果某个会话超过 N 分钟没有任何输出且没有正在执行的任务就主动 kill 掉。同时应用退出时一定要遍历所有会话进程逐个 kill别指望操作系统帮你回收。function cleanupSessions() { for (const [id, session] of sessions) { try { session.proc.kill(SIGTERM); // 给 3 秒优雅退出不行就强杀 setTimeout(() { if (!session.proc.killed) session.proc.kill(SIGKILL); }, 3000); } catch (e) { console.error(failed to kill session ${id}, e); } } sessions.clear(); }5.5 第三方模型接入后的工具调用失效接了 DeepSeek 或 Qwen 之后发现 AI 不会调用工具了只会干聊。这是因为不同模型对工具调用function calling的支持程度不一样。Claude 和 GPT 系列对工具调用的支持很成熟格式也规范。但一些第三方模型要么不支持要么格式有差异。代理层需要做的是把 Codex 发出的工具定义转换成目标模型能理解的格式如果目标模型根本不支持工具调用那就得降级——把工具调用改成让模型输出一段特定格式的文本然后由代理层解析并执行。这个降级方案虽然土但在很多模型上都能跑通。代价是可靠性下降模型可能输出格式不对的文本需要加容错。提示判断一个第三方模型能不能用于编程助手最简单的测试是给它一个读取某个文件并总结的任务看它会不会主动发起工具调用。如果它只是回复我无法访问文件系统那这个模型在当前配置下就不适合。6. 一些实操下来才明白的事搭这套东西的过程中有几个认知是踩了坑才建立起来的。第一个是别追求一次做全。我一开始想同时支持 Claude Code、Codex、还有好几个第三方模型结果每个都只做了半吊子。后来改成先把 Claude Code 跑通UI、会话管理、进程管理都打磨顺了再往上加 Codex最后才接第三方模型。每加一层只改需要改的地方前面的稳定性不受影响。第二个是日志比调试器有用。Electron 的调试体验一般尤其是主进程和子进程交互的部分。我后来在所有关键节点都加了结构化日志——会话启动、输入发送、输出接收、进程退出每个事件都带 sessionId 和时间戳。出问题的时候翻日志比打断点快得多。第三个是代理层的健壮性决定一切。第三方模型接入这条路成败全在代理层。它要处理格式转换、流式响应、错误重试、超时控制。我现在的做法是给代理层加一个请求录制功能把每个进出的请求都存下来出问题的时候可以直接回放。这个功能救过我好几次。第四个是用户对中文的诉求本质是对低摩擦的诉求。热搜里那么多关于中文设置的搜索背后其实是用户不想在语言上费脑子。所以 t3code 这类工具默认就应该把中文体验做好——界面中文、AI 默认中文回复、错误信息中文。别让用户去翻设置。这套东西后续还能往几个方向扩比如加一个会话模板系统把常用的提示词和配置存成模板一键启动比如加一个 diff 审查面板把 AI 改动的文件用可视化方式展示出来方便逐行确认再比如把代理层做成插件化的不同模型对应不同的转换插件加新模型只要写个插件就行。这些我都还在慢慢磨等有稳定版本了再单独写一篇。