一、有哪些问题
- 为什么需要「桌面 Agent」
- 技术栈与仓库怎么铺:Electron / Vue / MCP 各放哪
- 怎么分层:Electron 三进程 + MCP 子进程
- 一次对话怎么跑通:Agent Loop、工具调用、流式回复
- 数据放哪:为什么不用 Pinia / SQLite,而用主进程 JSON
- 几个值得带走的设计取舍与踩坑
二、背景
目标很简单:在桌面上跑一个「能聊、能调工具」的 Agent。
它把三件事串在一起:
- 对话:通过 OpenAI 兼容网关调用大模型(Base URL / API Key / Model 可配)
- 工具:从本地
mcp.json拉起 MCP(stdio,与 Cursor / Claude Desktop 同款格式) - 手机能力:默认可对接手机侧 MCP 能力包,支持附件上传、文件与设备相关操作
一句话:
Electron 壳 + Vue 界面 + 主进程 Agent Loop + 本地 MCP。
初步形态:
技术栈
| 层级 | 技术 | 说明 |
|---|---|---|
| 桌面运行时 | Electron 35 | 主进程 Node + 渲染进程 Chromium |
| 工程化 | electron-vite 3 + Vite 6 | 同时构建 main / preload / renderer |
| 语言 | TypeScript 5.8 | 主进程 tsc,渲染进程 vue-tsc |
| UI 框架 | Vue 3.5 | Composition API,无 Pinia / Vuex / Redux |
| 组件库 | Naive UI 2 + @vicons/ionicons5 | 设置、侧栏、消息区等 |
| MCP | @modelcontextprotocol/sdk | StdioClientTransport 拉起子进程 |
| LLM | 自研 OpenAI 兼容 HTTP 客户端 | stream: true SSE 流式 |
| 持久化 | 主进程读写 JSON 文件 | 非 SQLite / electron-store / localStorage |
| 打包 | electron-builder | Windows 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 提供工具 | 不进渲染进程 |
落地配置很朴素,但必须到位:
| 配置 | 取值 | 含义 |
|---|---|---|
contextIsolation | 开启 | 渲染进程与 preload 隔离 |
nodeIntegration | false | 页面不能直接 require('fs') |
| 对外 API | 仅 window.api | preload 暴露的方法列表即全部能力面 |
生产环境再藏菜单栏、禁常见 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.json、conversations/*、mcp.json) - 同步:
invoke拉/推;MCP 状态、流式文本用事件推送 - 需要落盘时调
window.api.*(如listConversations/setSettings/saveConversation),Main 写完返回 plain 对象,再赋给ref
渲染进程是视图缓存,改完写回主进程。
没上 SQLite / electron-store / localStorage,原因也很现实:
- MVP 阶段会话量不大,JSON 够用、好调试
- 权威数据本来就该在 Main(和密钥、MCP 同进程)
- 少一层抽象,排查「为什么没落盘」更快
分享结论 2:
桌面 Agent 里,「状态库」往往不如「谁拥有真相」重要。真相在 Main,UI 跟着刷新即可。
五、一次对话怎么走完?
用户点发送
→ 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.send→onChatChunk)
取舍:IPC 面小、落盘路径不变、UI 也能边出字。
2)节流约 32ms
每个 token 都打 IPC,会把主进程和 Vue 打满。Main 侧合并 delta,大约 30fps 刷一次。
3)requestId 防串流
快速连发、切换会话时,旧流不能写到新气泡。UI 只认当前 requestId。
分享结论 3:
流式要拆成「体验通道」和「结果通道」——体验走事件,结果走 invoke,落盘只信最终结果。
关键代码:推流(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? },过程中多次 |
| invoke | invoke('chat:send') | AgentLoopResult + requestId,整轮一次 |
六、Agent Loop:工具不是永远挂着的
不是每轮都把全部 MCP tools 塞给模型。我们有一层 shouldOfferTools:看最近一条用户消息是否像「要动手机/文件」(关键词或 [附件] 标记)。
好处:
- 闲聊少误触发工具
- 省一轮「模型犹豫要不要 call tool」的延迟与费用
工具名对外是 {serverName}__{toolName},避免多 MCP 服务撞名。
工具失败不打断循环:错误字符串当 tool content 回传,让模型自己解释。
单工具超时 60s;整轮最多 8 次工具;用户可 chat:cancel。
主进程核心模块(讨论时可翻):
| 模块 | 文件 | 干什么 |
|---|---|---|
| IPC 总线 | electron/main/index.ts | registerIpc() 注册全部 channel |
| Agent Loop | agent/loop.ts | 多轮 LLM ↔ 工具;onDelta |
| LLM | llm/openaiCompatible.ts | SSE chat/completions |
| MCP | mcp/manager.ts | 连接、listTools、callTool |
| 设置 | store/settings.ts | 多网关 + 模型列表 |
| 会话 | store/conversations.ts | manifest + 按 id 会话文件 |
| 附件 | files/staging.ts + upload.ts | staging → 发送前上传 |
七、MCP:配置即能力面
和 Cursor 一样:用户编辑 mcp.json,应用 spawn stdio 子进程。
启动时若没有配置,从模板拷一份到 userData。
打包场景多一个坑:用户机器上的 Node / PATH 不可控。我们把便携 Node 打进安装包,npx 解析到内置 node.exe,并隔离 PATH,避免「开发机能跑、安装后起不来」。
附件路径也和 MCP 绑在一起:
- Composer 拖文件 →
files:stage进userData/staging - 发送时自动
upload_file(s) - 结果写进用户消息的
[附件]区,模型直接引用,不用再问本地路径
八、会话与本地存储:真相在 userData
根目录:app.getPath('userData')(Windows:%APPDATA%\<app-name>\)
| 路径 | 内容 |
|---|---|
settings.json | 网关列表、当前网关、模型 |
mcp.json | MCP servers(Cursor 兼容,仅 stdio) |
conversations/manifest.json | activeId + 会话摘要(侧栏) |
conversations/{id}.json | messages(UI)+ history(LLM 上下文) |
staging/{uuid}/… | 发送前临时附件 |
单会话刻意拆两份字段:
messages:给 UI 看的(可滤掉 tool 角色)history:给下一轮 LLM 的完整上下文
列表只读 manifest;点开再读全文。首次保证至少有一个默认会话。
IPC 传参前后都会做一轮 JSON 结构化克隆(去掉 Vue Proxy)——Electron 过桥时的老坑,提前规避。
九、工程与发布
| 脚本 | 作用 |
|---|---|
npm run dev | electron-vite 开发热更新 |
npm run build | typecheck + 生产构建到 out/ |
npm run prepare:node | 下载/准备便携 Node 22 |
npm run dist:app | Windows NSIS 安装包(含便携 Node) |
npm run dist:setup | 目录包 + 自定义 Setup 链路 |
构建产物在 out/;安装包输出到 release/。
extraResources 带上便携 Node;打包后用内置 Node 解析 npx,避免系统 Node 过旧或 PATH 污染。
运行时依赖很少:vue、naive-ui、MCP SDK、图标库;LLM 客户端自己写,不绑重型 Agent 框架。
改功能时的直觉导航:
- UI →
src/components/ - 协议与落盘 →
electron/main/ - 对外能力面 →
electron/preload/index.ts的window.api
十、踩坑与经验(可直接带走)
-
密钥与 spawn 只放 Main
RenderernodeIntegration打开图一时方便,后面合规和审计都痛。 -
流式别和落盘绑死
边出字边写盘容易半截会话;我们选择「展示跟 chunk 走,落盘跟最终结果走」。 -
必须处理过期流
没有requestId,取消和切会话一定会花屏。 -
打包后的 Node 环境要自备
MCP 靠npx时,系统 Node 版本是最大不稳定因素。 -
Vue Proxy 过不了 IPC
统一toPlain/serializeForIpc,少调一次踩一次。 -
工具失败要可恢复
当 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.ts → llm/openaiCompatible.ts → src/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 / set | R↔M | 网关 / 模型 |
mcp:status / getRaw / setRaw / reload | R↔M | MCP 状态与配置 |
mcp:changed | M→R | 状态推送 |
files:stage | R→M | 附件暂存 |
chat:send / chunk / cancel | R↔M / M→R | 对话与流式 |
conversations:* | R↔M | 会话 CRUD |
Preload 聚合为 window.api.*。
十五、附录 C:选型小结
| 维度 | 选择 |
|---|---|
| 形态 | Electron 桌面 Agent 宿主 |
| UI | Vue 3 + Naive UI,组件内 ref |
| 智能 | 主进程 Agent Loop + OpenAI 兼容流式网关 |
| 扩展 | 本地 MCP(stdio),默认可接手机能力 |
| 数据 | userData 下 JSON,经白名单 IPC 同步 |
| 工程 | electron-vite + TypeScript + electron-builder |
结束语
做桌面 Agent,难点往往不在「调一次 chat/completions」,而在:
把权限边界、工具循环、流式体验、本地持久化,拧成一条可取消、可落盘、可打包的链路。
欢迎讨论。