🚗🚗🚗我用 Electron 搭了一个桌面 Agent🚗🚗🚗

25 阅读10分钟

一、有哪些问题

  1. 为什么需要「桌面 Agent」
  2. 技术栈与仓库怎么铺:Electron / Vue / MCP 各放哪
  3. 怎么分层:Electron 三进程 + MCP 子进程
  4. 一次对话怎么跑通:Agent Loop、工具调用、流式回复
  5. 数据放哪:为什么不用 Pinia / SQLite,而用主进程 JSON
  6. 几个值得带走的设计取舍与踩坑

ChatGPT Image 2026年8月10日 15_39_44.png


二、背景

目标很简单:在桌面上跑一个「能聊、能调工具」的 Agent

它把三件事串在一起:

  1. 对话:通过 OpenAI 兼容网关调用大模型(Base URL / API Key / Model 可配)
  2. 工具:从本地 mcp.json 拉起 MCP(stdio,与 Cursor / Claude Desktop 同款格式)
  3. 手机能力:默认可对接手机侧 MCP 能力包,支持附件上传、文件与设备相关操作

一句话:

Electron 壳 + Vue 界面 + 主进程 Agent Loop + 本地 MCP。

初步形态:

image.png

image.png

技术栈

层级技术说明
桌面运行时Electron 35主进程 Node + 渲染进程 Chromium
工程化electron-vite 3 + Vite 6同时构建 main / preload / renderer
语言TypeScript 5.8主进程 tsc,渲染进程 vue-tsc
UI 框架Vue 3.5Composition API, Pinia / Vuex / Redux
组件库Naive UI 2 + @vicons/ionicons5设置、侧栏、消息区等
MCP@modelcontextprotocol/sdkStdioClientTransport 拉起子进程
LLM自研 OpenAI 兼容 HTTP 客户端stream: true SSE 流式
持久化主进程读写 JSON 文件 SQLite / electron-store / localStorage
打包electron-builderWindows NSIS;另有便携 Node 与自定义 Setup
运行环境Node ≥ 20;安装包内置 Node 22保证 npx 拉 MCP 不依赖系统 Node

刻意没用的东西: Redux / Zustand / Pinia、electron-store、IndexedDB、SQLite。状态在 Vue 组件 ref 里;权威数据在主进程 userData 文件中。


三、第一性原理:谁能碰密钥和子进程?

做 Electron Agent,最先要拍板的不是 UI,而是信任边界

Renderer(Vue)          只做 UI,不碰 Node / 网络 / 子进程
        ↕ window.api
Preload(contextBridge) 白名单 IPC,参数 toPlain 后再 invoke
        ↕ ipcMain.handle / webContents.send
Main(Node)             密钥、fetch、spawn MCP、读写磁盘、Agent 编排
        ↕ stdio
MCP Server 子进程        由 mcp.json 配置 → 手机 / 文件等工具
进程能做什么不能做什么
Renderer(页面)画界面、收输入无 Node、无直连网络、无 spawn
Preload暴露白名单 window.api不把任意 IPC 敞开
Main密钥、fetch、spawn MCP、写盘、跑 Loop
MCP 子进程通过 stdio 提供工具不进渲染进程

ChatGPT Image 2026年8月7日 10_02_24.png 落地配置很朴素,但必须到位:

配置取值含义
contextIsolation开启渲染进程与 preload 隔离
nodeIntegrationfalse页面不能直接 require('fs')
对外 APIwindow.apipreload 暴露的方法列表即全部能力面

生产环境再藏菜单栏、禁常见 DevTools 快捷键。

分享结论 1:
Agent 的「脑子」和「手」都放在 Main;Renderer 只是遥控器。密钥进页面,后面很难收。


四、状态管理:我们为什么没上 Pinia?

常见直觉是「前端项目 = 全局 store」。这个项目反着来:

组件负责的本地 ref
App.vue网关、模型、MCP 概览、Tab
ChatShell.vue会话列表、当前 activeId
ChatView.vue消息流、history、busy、发送与落盘
Composer.vue输入、附件 staging
SettingsPanel.vue设置编辑态
  • 持久化真相:Main 写的 JSON(settings.jsonconversations/*mcp.json
  • 同步invoke 拉/推;MCP 状态、流式文本用事件推送
  • 需要落盘时调 window.api.*(如 listConversations / setSettings / saveConversation),Main 写完返回 plain 对象,再赋给 ref

渲染进程是视图缓存,改完写回主进程。

没上 SQLite / electron-store / localStorage,原因也很现实:

  • MVP 阶段会话量不大,JSON 够用、好调试
  • 权威数据本来就该在 Main(和密钥、MCP 同进程)
  • 少一层抽象,排查「为什么没落盘」更快

分享结论 2:
桌面 Agent 里,「状态库」往往不如「谁拥有真相」重要。真相在 Main,UI 跟着刷新即可。


五、一次对话怎么走完?

ChatGPT Image 2026年8月6日 20_00_20.png

用户点发送
  → window.api.sendChat(...)
  → Main:生成 requestId,必要时先把附件经 MCP 上传到手机
  → runAgentLoop(最多 8 轮)
       → 按关键词决定要不要把 tools 暴露给模型(shouldOfferTools)
       → POST /v1/chat/completions(stream: true)
       → SSE delta → onDelta → chat:chunk → UI 往气泡里追加字
       → 若有 tool_calls → 调 MCP → 把结果塞回 messages → 再问模型
  → sendChat Promise resolve(最终 AgentLoopResult)
  → UI saveConversation(messages + history

读写类 API(如列会话)形态更短,也能代表同步习惯:

ChatShell.bootstrap()
  → window.api.listConversations()
  → preload: ipcRenderer.invoke('conversations:list')
  → main: listConversationState() → 读 manifest.json
  → serializeForIpc → Vue 写入 items / activeId

这里有三个「看起来小、其实关键」的设计:

1)流式:invoke 拿结果 + 事件推增量

没有做成纯事件驱动的 start/chunk/done 状态机,而是:

  • chat:send:仍然 invoke,整轮结束返回最终结果(方便取消、落盘)
  • chat:chunk:推 { requestId, type: start|delta, text? }webContents.sendonChatChunk

取舍:IPC 面小、落盘路径不变、UI 也能边出字。

2)节流约 32ms

每个 token 都打 IPC,会把主进程和 Vue 打满。Main 侧合并 delta,大约 30fps 刷一次。

3)requestId 防串流

快速连发、切换会话时,旧流不能写到新气泡。UI 只认当前 requestId

分享结论 3:
流式要拆成「体验通道」和「结果通道」——体验走事件,结果走 invoke,落盘只信最终结果。

ChatGPT Image 2026年8月10日 15_12_52.png

关键代码:推流(chat:chunk

Main — 节流后推送

      const emitChunk = (chunk: { requestId, type: 'start' | 'delta', text? }) => {
        if (signal.aborted) return
        mainWindow.webContents.send('chat:chunk', chunk)
      }

      const queueDelta = (text: string): void => {
        if (!text || signal.aborted) return
        pendingDelta += text
        if (flushTimer) return
        flushTimer = setTimeout(() => {
          flushTimer = null
          flushPendingDelta()  // 合并后 emitChunk({ type: 'delta', text })
        }, 32)
      }

      emitChunk({ requestId, type: 'start' })
      // ...
      onDelta: queueDelta   // runAgentLoop 里 LLM 每个 SSE delta 进这里

Preload — 订阅事件

  onChatChunk: (cb) => {
    const listener = (_event, chunk) => cb(chunk)
    ipcRenderer.on('chat:chunk', listener)
    return () => ipcRenderer.removeListener('chat:chunk', listener)
  },

ChatView — 攒字 + 按帧刷气泡

function onChatChunk(chunk: ChatChunk): void {
  if (chunk.type === 'start') { activeRequestId = chunk.requestId; return }
  if (chunk.type !== 'delta' || !chunk.text) return
  if (chunk.requestId !== activeRequestId) return   // 防串流
  pendingUiDelta += chunk.text
  if (!uiFlushRaf) {
    uiFlushRaf = requestAnimationFrame(flushUiDelta)  // bubble.content += add
  }
}
onMounted(() => {
  unsubscribeChunk = window.api.onChatChunk(onChatChunk)
})

关键代码:sendChat(invoke)

Preload — invoke 入口

  sendChat: (payload): Promise<AgentLoopResult> => invoke('chat:send', payload),

Main — 注册 handler,整轮结束后 return

  ipcMain.handle('chat:send', async (_event, payload) => {
    activeAbort = new AbortController()
    const requestId = randomUUID()
    const signal = activeAbort.signal
  // ... emit start、upload 附件 ...
    const result = await runAgentLoop({
      settings, messages, tools,
      callTool: (name, args) => mcpManager.callTool(name, args),
      signal,
      onDelta: queueDelta,
    })
    flushPendingDelta()
    return serializeForIpc({ ...result, requestId })
  })

Agent Loop — 把 SSE delta 交给 onDelta

      const response = await chatCompletionsStream({
        settings: opts.settings,
        messages: llmMessages,
        tools: activeTools.length ? activeTools : undefined,
        signal: opts.signal,
        onDelta: opts.onDelta
      })

ChatView — await 终稿,对齐气泡并落盘

    const result = await window.api.sendChat({ messages: history.value, attachments })
    // 先 flush 剩余 pendingUiDelta
    if (result.assistantText) {
      finalizeAssistantBubble(result.assistantText)
    } else if (result.aborted) {
      finalizeAssistantBubble(partial || '已取消。')
    }
    history.value = result.messages.filter((m) => m.role !== 'system')
  } finally {
    await persist()   // saveConversation
  }
通道关键 API返回 / 载荷
推流webContents.send('chat:chunk')onChatChunk{ requestId, type, text? },过程中多次
invokeinvoke('chat:send')AgentLoopResult + requestId,整轮一次

六、Agent Loop:工具不是永远挂着的

不是每轮都把全部 MCP tools 塞给模型。我们有一层 shouldOfferTools:看最近一条用户消息是否像「要动手机/文件」(关键词或 [附件] 标记)。

好处:

  • 闲聊少误触发工具
  • 省一轮「模型犹豫要不要 call tool」的延迟与费用

工具名对外是 {serverName}__{toolName},避免多 MCP 服务撞名。
工具失败不打断循环:错误字符串当 tool content 回传,让模型自己解释。
单工具超时 60s;整轮最多 8 次工具;用户可 chat:cancel

主进程核心模块(讨论时可翻):

模块文件干什么
IPC 总线electron/main/index.tsregisterIpc() 注册全部 channel
Agent Loopagent/loop.ts多轮 LLM ↔ 工具;onDelta
LLMllm/openaiCompatible.tsSSE chat/completions
MCPmcp/manager.ts连接、listTools、callTool
设置store/settings.ts多网关 + 模型列表
会话store/conversations.tsmanifest + 按 id 会话文件
附件files/staging.ts + upload.tsstaging → 发送前上传

七、MCP:配置即能力面

和 Cursor 一样:用户编辑 mcp.json,应用 spawn stdio 子进程。

启动时若没有配置,从模板拷一份到 userData
打包场景多一个坑:用户机器上的 Node / PATH 不可控。我们把便携 Node 打进安装包,npx 解析到内置 node.exe,并隔离 PATH,避免「开发机能跑、安装后起不来」。

附件路径也和 MCP 绑在一起:

  1. Composer 拖文件 → files:stageuserData/staging
  2. 发送时自动 upload_file(s)
  3. 结果写进用户消息的 [附件] 区,模型直接引用,不用再问本地路径

八、会话与本地存储:真相在 userData

根目录:app.getPath('userData')(Windows:%APPDATA%\<app-name>\

路径内容
settings.json网关列表、当前网关、模型
mcp.jsonMCP servers(Cursor 兼容,仅 stdio)
conversations/manifest.jsonactiveId + 会话摘要(侧栏)
conversations/{id}.jsonmessages(UI)+ history(LLM 上下文)
staging/{uuid}/…发送前临时附件

单会话刻意拆两份字段:

  • messages:给 UI 看的(可滤掉 tool 角色)
  • history:给下一轮 LLM 的完整上下文

列表只读 manifest;点开再读全文。首次保证至少有一个默认会话。

IPC 传参前后都会做一轮 JSON 结构化克隆(去掉 Vue Proxy)——Electron 过桥时的老坑,提前规避。


九、工程与发布

脚本作用
npm run develectron-vite 开发热更新
npm run buildtypecheck + 生产构建到 out/
npm run prepare:node下载/准备便携 Node 22
npm run dist:appWindows NSIS 安装包(含便携 Node)
npm run dist:setup目录包 + 自定义 Setup 链路

构建产物在 out/;安装包输出到 release/
extraResources 带上便携 Node;打包后用内置 Node 解析 npx,避免系统 Node 过旧或 PATH 污染。

运行时依赖很少:vuenaive-ui、MCP SDK、图标库;LLM 客户端自己写,不绑重型 Agent 框架。

改功能时的直觉导航:

  • UI → src/components/
  • 协议与落盘 → electron/main/
  • 对外能力面 → electron/preload/index.tswindow.api

十、踩坑与经验(可直接带走)

  1. 密钥与 spawn 只放 Main
    Renderer nodeIntegration 打开图一时方便,后面合规和审计都痛。

  2. 流式别和落盘绑死
    边出字边写盘容易半截会话;我们选择「展示跟 chunk 走,落盘跟最终结果走」。

  3. 必须处理过期流
    没有 requestId,取消和切会话一定会花屏。

  4. 打包后的 Node 环境要自备
    MCP 靠 npx 时,系统 Node 版本是最大不稳定因素。

  5. Vue Proxy 过不了 IPC
    统一 toPlain / serializeForIpc,少调一次踩一次。

  6. 工具失败要可恢复
    当 tool result 回传,比直接抛崩整个 Loop 更符合 Agent 习惯。


十一、合规提醒(分享场合也适用)

  • 不把项目或密钥传到公网、不生成公网分享链接
  • 不建议内网穿透 / 公网暴露 MCP 或 LLM 端点
  • 优先公司内网网关与内网 npm 镜像

十二、Q&A 预备

Q:为什么不用 LangChain / 某 Agent 框架?
A:MVP 只要 OpenAI tools + 本地 MCP;自研 Loop 几十行量级可控,调试成本更低。

Q:为什么 stdio only?
A:桌面场景子进程最稳;HTTP MCP 留到有远程托管需求再做。

Q:多窗口并发流式呢?
A:当前按「单活跃请求」设计;多窗口要另做请求编排。

Q:代码从哪看起?
A:electron/main/index.ts(IPC)→ agent/loop.tsllm/openaiCompatible.tssrc/components/ChatView.vue


十三、附录 A:仓库目录结构

<repo-root>/
├── electron/                 # 主进程 + Preload(Node 侧)
│   ├── main/
│   │   ├── index.ts          # 窗口、IPC、聊天编排、chunk 推送
│   │   ├── agent/loop.ts     # Agent 多轮:LLM ↔ tool_calls
│   │   ├── llm/openaiCompatible.ts
│   │   ├── mcp/
│   │   │   ├── manager.ts    # 连接、listTools、callTool
│   │   │   ├── config.ts     # mcp.json 读写与默认模板
│   │   │   └── bundledNode.ts
│   │   ├── store/
│   │   │   ├── settings.ts   # settings.json
│   │   │   └── conversations.ts
│   │   ├── files/            # 附件 staging + 上传
│   │   └── ipcSerialize.ts
│   └── preload/
│       ├── index.ts          # contextBridge → window.api
│       └── index.d.ts
├── src/                      # 渲染进程(Vue)
│   ├── App.vue               # 顶栏、Tab、网关/模型切换
│   ├── components/           # ChatShell、ChatView、Composer、Settings…
│   ├── utils/                # IPC 序列化、消息格式、文件工具
│   ├── main.ts
│   └── styles.css
├── resources/                # mcp 模板、便携 Node、可选预装 MCP 包
├── scripts/                  # 便携 Node、安装器资源、打包脚本
├── setup/                    # 自定义 Setup(可选)
├── docs/                     # 本文、diagrams、specs / plans
├── electron.vite.config.ts
├── electron-builder.*.json
└── package.json

十四、附录 B:IPC 速查

Channel方向说明
settings:get / setR↔M网关 / 模型
mcp:status / getRaw / setRaw / reloadR↔MMCP 状态与配置
mcp:changedM→R状态推送
files:stageR→M附件暂存
chat:send / chunk / cancelR↔M / M→R对话与流式
conversations:*R↔M会话 CRUD

Preload 聚合为 window.api.*


十五、附录 C:选型小结

维度选择
形态Electron 桌面 Agent 宿主
UIVue 3 + Naive UI,组件内 ref
智能主进程 Agent Loop + OpenAI 兼容流式网关
扩展本地 MCP(stdio),默认可接手机能力
数据userData 下 JSON,经白名单 IPC 同步
工程electron-vite + TypeScript + electron-builder

结束语

做桌面 Agent,难点往往不在「调一次 chat/completions」,而在:

把权限边界、工具循环、流式体验、本地持久化,拧成一条可取消、可落盘、可打包的链路。

欢迎讨论。