最近收到不少开发者的同一种困惑:大模型已经够聪明了,网页聊天用起来也顺手,但要做成一个能常驻桌面的 AI 工具,却不知道从哪一行代码开始。
这篇文章会围绕一条非常经典的技术链路展开:Electron 做桌面容器,Vue3.5 写界面,大模型 API 提供对话能力,Cursor 负责把重复代码的产出速度拉满。目标是从零开始搭出一个可运行的跨平台 AI 桌面聊天应用,而不是只讲概念。
先给一个判断:这类应用的技术难度并不高,很多人卡住不是因为框架不会,而是因为缺少一个“最小闭环”。把窗口、页面、IPC 通信、模型流式返回这条链路跑通之后,后面加多少功能都只是扩展。本文会从架构设计、代码实现、运行验证、常见问题四个层面完整展开,建议收藏后用一台装有 Node.js 的电脑跟着做。
1. 这篇文章真正要解决的问题
先看一个出现频率很高的问题:“我想使用 Electron 把 URL 打包进去,是否可行?”答案是可行。Electron 的主窗口既可以加载本地前端资源,也可以用loadURL加载远程页面。很多团队也确实做过“浏览器壳子”形态的工具。
但真实产品里,如果只做一个浏览器壳,价值会非常单薄。远程页面受网络影响,无法利用 Electron 的本地文件系统能力,API Key 等敏感信息也很难安全存放。更合理的做法是:用 Vue 构建本地渲染页面,用 IPC 让页面与主进程通信,再让主进程去调用大模型。这样做的好处是界面响应快、数据可控、扩展空间大。
这篇文章要回答四个问题:
- Electron 的主进程、渲染进程、预加载脚本到底怎么分工?
- Vue3.5 在桌面应用里扮演什么角色?
- 大模型 API 的流式回复应该放在哪一层处理?
- Cursor 在这个过程中能帮我们做什么,哪些事又不该交给它决定?
如果你正准备做 AI 工具、效率软件,或者想把现有网页能力迁移到桌面端,这篇文章会比零散查资料高效得多。
2. 核心概念与角色拆解:四个技术各管哪一段
把这四个技术放在一起,容易让人误以为它们是并列关系。实际上它们的职责完全不同。
2.1 Electron:桌面应用的“骨架”
Electron 基于 Chromium 和 Node.js,可以把网页代码打包成桌面应用,支持 Windows、macOS、Linux。它有三个关键概念:
- 主进程:负责创建窗口、管理应用生命周期、访问 Node.js 能力。
- 渲染进程:负责显示页面,运行 Vue、React 等前端框架。
- 预加载脚本:连接主进程和渲染进程的桥梁,常用于安全暴露能力。
用团队来类比:主进程是项目经理,渲染进程是执行层,预加载脚本是固定的对接接口。
2.2 Vue3.5:渲染进程里的“装修”
Electron 本身不限定前端框架。Vue 在这里负责页面结构、状态管理和交互逻辑。Vue3.5 的 Composition API 写桌面应用时非常直观,聊天消息列表本身就是典型的响应式数据,新增一条消息、更新流式文本,都是ref和数组操作的常规应用。
2.3 大模型:对话能力的“大脑”
大模型 API 通常是一个 HTTP 接口,输入messages数组,返回模型生成的文本。在线 API 的接入成本低、效果稳定,适合快速验证产品;本地模型则是后续优化的方向,它的优势是隐私性和离线可用性,但需要额外管理模型文件和硬件资源。
2.4 Cursor:提升编码效率的“助手”
Cursor 不是应用的一部分,它是开发阶段的 AI 辅助编程工具。它基于 VS Code 生态,可以在编辑器里理解整个项目上下文,快速生成 Vue 组件、Electron 模板代码,也可以解释报错。
需要明确边界:Cursor 擅长生成样板代码和已知 API 模式的实现,但架构设计、安全边界、数据流规划这些决策,仍然需要开发者自己把握。部分中文用户会关心 Cursor 的界面语言,优先在工具设置里查看官方显示偏好;不要随意使用来路不明的手动汉化脚本,因为工具升级后可能被覆盖,也可能带来未知风险。
这四个技术的关系可以这样理解:Electron 是大楼骨架,Vue 是室内装修,大模型是物业的智能服务,Cursor 是施工时的吊车。
3. 环境准备与前置条件
动手前先确认本机环境。
3.1 基础环境
- Node.js 版本:建议 18 及以上,因为主进程代码里会使用原生
fetch,低于 18 需要额外引入node-fetch。 - 包管理器:npm 或 pnpm,本文以 npm 为例。
- 编辑器:VS Code 或 Cursor 均可。
版本细节以你实际安装为准,本文重点演示通用工程思路。Electron 版本更新很快,不建议把某个旧版本号写死到项目中。
3.2 初始化项目
先用 Vite 创建 Vue 项目:
npm create vue@latest ai-desktop-chat cd ai-desktop-chat然后安装 Electron 和开发相关依赖:
npm install npm install vue npm install -D electron vite @vitejs/plugin-vue cross-env wait-on concurrently npm install dotenv这里把concurrently和wait-on加进来,是为了让 Electron 等待 Vite 开发服务器启动后再打开窗口。否则可能会出现窗口已经打开、页面还在编译的“白屏”情况。
3.3 配置脚本
把package.json中的main指向主进程文件,并调整启动脚本:
{ "name": "ai-desktop-chat", "main": "electron/main.js", "scripts": { "dev": "concurrently -k \"vite\" \"wait-on tcp:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron .\"", "build:web": "vite build", "start": "electron ." } }开发模式下,主进程通过VITE_DEV_SERVER_URL判断应该加载 Vite 开发服务器还是打包后的静态文件。
4. 架构设计:主进程、预加载脚本与渲染进程如何分工
在写代码前,先确定整个应用的数据流。聊天应用的完整链路是:
- 用户在 Vue 页面输入问题,点击发送。
- Vue 调用
window.chatAPI.sendMessage(messages)。 - 预加载脚本通过 IPC 把消息转发给主进程。
- 主进程调用大模型 API,拿到流式数据。
- 主进程通过事件把增量文本推给渲染进程。
- Vue 监听事件,把增量文本追加到消息列表。
这个设计的核心是:渲染进程不能直接访问 Node.js,也不能直接保存 API Key。所有敏感操作都在主进程完成,页面只通过预加载脚本暴露的极少量 API 通信。
工程目录建议如下:
ai-desktop-chat/ ├── electron/ │ ├── main.js │ └── preload.js ├── src/ │ ├── main.js │ ├── App.vue │ ├── views/ │ │ └── ChatView.vue │ └── styles.css ├── index.html ├── vite.config.js └── package.jsonvite.config.js里有一项配置很容易被忽略,生产环境加载静态资源时必须设置相对路径:
import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; export default defineConfig({ base: './', plugins: [vue()], server: { port: 5173, strictPort: true, }, });base: './'确保 Electron 用loadFile加载本地 HTML 时,JS 和 CSS 资源路径不会变成绝对路径。
安全配置上,主进程创建窗口时务必开启contextIsolation,关闭nodeIntegration,这样即使页面被注入恶意脚本,也无法直接操作 Node.js 系统能力。
5. 核心代码实现:从窗口到流式对话
现在进入代码实战部分。为了逻辑清晰,按四个文件依次实现。
5.1 主进程:创建窗口并注册 IPC
主进程文件是electron/main.js,它负责创建窗口、注册 IPC 处理器、调用大模型 API。
// electron/main.js require('dotenv').config(); const { app, BrowserWindow, ipcMain } = require('electron'); const path = require('path'); let mainWindow = null; function createWindow() { mainWindow = new BrowserWindow({ width: 1080, height: 720, minWidth: 720, minHeight: 480, title: 'AI 桌面助手', autoHideMenuBar: true, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true, }, }); mainWindow.once('ready-to-show', () => { mainWindow.show(); }); if (process.env.VITE_DEV_SERVER_URL) { mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL); } else { mainWindow.loadFile(path.join(__dirname, '../dist/index.html')); } } app.whenReady().then(() => { createWindow(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); } });这里有几点值得解释。ready-to-show事件能够避免窗口先白屏再渲染内容;autoHideMenuBar用来隐藏默认菜单栏,让界面更干净;sandbox: true是 Electron 的渲染进程沙箱,即使页面代码出问题,也能尽量限制影响范围。
5.2 主进程:处理输入校验与大模型流式调用
ipcMain.handle注册chat:send-message,这是整个应用的核心逻辑。必须强调:渲染进程传来的数据不能直接信任,这里必须做角色白名单和长度限制。
// electron/main.js 追加内容 function sanitizeMessages(input) { if (!Array.isArray(input)) { return []; } const allowedRoles = ['system', 'user', 'assistant']; return input .filter((item) => item && typeof item.content === 'string') .map((item) => ({ role: allowedRoles.includes(item.role) ? item.role : 'user', content: item.content.slice(0, 4000), })) .slice(-20); } ipcMain.handle('chat:send-message', async (event, rawMessages) => { const messages = sanitizeMessages(rawMessages); const apiKey = process.env.LLM_API_KEY; const baseURL = process.env.LLM_BASE_URL || 'https://api.example.com/v1'; const model = process.env.LLM_MODEL || 'your-model'; if (!apiKey) { return '检测到尚未配置 LLM_API_KEY。请在 .env 中配置大模型服务的 Key 后重新启动应用。'; } const response = await fetch(`${baseURL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model, messages, stream: true, temperature: 0.7, }), }); if (!response.ok) { const detail = await response.text(); throw new Error(`模型服务请求失败:HTTP ${response.status} ${detail.slice(0, 200)}`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let answer = ''; try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('data:')) continue; const payload = trimmed.slice(5).trim(); if (!payload || payload === '[DONE]') continue; try { const json = JSON.parse(payload); const delta = json.choices?.[0]?.delta?.content; if (delta) { answer += delta; event.sender.send('chat:chunk', delta); } } catch { // 不完整的 JSON 片段,等待下一行到达后继续解析 } } } } finally { reader.releaseLock(); } return answer; });这里采用的是 OpenAI 兼容的流式响应格式。如果你使用的大模型服务不兼容这种格式,只需根据服务商文档改写解析逻辑,通信层和界面层不需要变动。流式解析的核心是逐行判断data:前缀,遇到[DONE]表示结束。
5.3 预加载脚本:安全暴露 API
预加载脚本是主进程和渲染进程之间的安全桥。只暴露必要的方法和事件监听,不要把整个ipcRenderer暴露出去。
// electron/preload.js const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('chatAPI', { sendMessage: (messages) => ipcRenderer.invoke('chat:send-message', messages), onChunk: (callback) => { if (typeof callback !== 'function') { return () => {}; } const listener = (_event, chunk) => callback(chunk); ipcRenderer.on('chat:chunk', listener); return () => { ipcRenderer.removeListener('chat:chunk', listener); }; }, });onChunk返回一个移除监听的函数,方便 Vue 组件在卸载时清理事件,避免内存泄漏。
5.4 Vue3.5 聊天界面
渲染进程使用 Vue3.5 写聊天界面。先将入口文件的模板指向页面:
<!-- index.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>AI 桌面助手</title> </head> <body> <div id="app"></div> <script type="module" src="/src/main.js"></script> </body> </html>// src/main.js import { createApp } from 'vue'; import App from './App.vue'; import './styles.css'; createApp(App).mount('#app');<!-- src/App.vue --> <script setup> import ChatView from './views/ChatView.vue'; </script> <template> <ChatView /> </template>核心的聊天页面在src/views/ChatView.vue。它要做的事是:维护消息列表、触发 IPC 调用、监听流式事件。
<!-- src/views/ChatView.vue --> <script setup> import { ref, onMounted, onUnmounted } from 'vue'; const messages = ref([ { role: 'assistant', content: '你好,我是桌面版 AI 助手。', streaming: false } ]); const input = ref(''); const loading = ref(false); let removeChunkListener = null; function appendChunk(chunk) { const last = messages.value[messages.value.length - 1]; if (last && last.role === 'assistant' && last.streaming) { last.content += chunk; } else { messages.value.push({ role: 'assistant', content: chunk, streaming: true }); } } async function sendMessage() { const text = input.value.trim(); if (!text || loading.value) return; messages.value.push({ role: 'user', content: text, streaming: false }); messages.value.push({ role: 'assistant', content: '', streaming: true }); input.value = ''; loading.value = true; const history = messages.value .filter((msg) => msg.content.length > 0 || msg.role === 'user') .map(({ role, content }) => ({ role, content })); try { const result = await window.chatAPI.sendMessage(history); const last = messages.value[messages.value.length - 1]; if (!last.content && result) { last.content = result; } } catch (error) { const last = messages.value[messages.value.length - 1]; last.content = '请求失败:' + (error && error.message ? error.message : String(error)); } finally { const last = messages.value[messages.value.length - 1]; last.streaming = false; loading.value = false; } } onMounted(() => { removeChunkListener = window.chatAPI.onChunk((chunk) => { appendChunk(chunk); }); }); onUnmounted(() => { if (removeChunkListener) { removeChunkListener(); } }); </script> <template> <div class="chat-page"> <header class="chat-header"> <h1>AI 桌面助手</h1> <p>Electron + Vue3.5 + 大模型</p> </header> <main class="chat-body"> <div v-for="(msg, index) in messages" :key="index" :class="['chat-message', msg.role]"> <div class="bubble"> <pre>{{ msg.content }}</pre> </div> </div> </main> <footer class="chat-footer"> <input v-model="input" type="text" placeholder="输入你的问题,按 Enter 发送" :disabled="loading" @keyup.enter="sendMessage" /> <button :disabled="loading" @click="sendMessage"> {{ loading ? '生成中' : '发送' }} </button> </footer> </div> </template> <style scoped> .chat-page { display: flex; flex-direction: column; height: 100vh; } .chat-header { padding: 14px 24px; border-bottom: 1px solid #eaeaea; } .chat-header h1 { margin: 0; font-size: 18px; } .chat-header p { margin: 4px 0 0; color: #888; font-size: 12px; } .chat-body { flex: 1; overflow-y: auto; padding: 16px 24px; } .chat-message { display: flex; margin-bottom: 12px; } .chat-message.user { justify-content: flex-end; } .chat-message.assistant { justify-content: flex-start; } .bubble { max-width: 70%; padding: 10px 14px; border-radius: 12px; background: #f3f3f3; white-space: pre-wrap; word-break: break-word; line-height: 1.6; } .chat-message.user .bubble { background: #3478f6; color: #fff; } .chat-footer { display: flex; gap: 12px; padding: 16px 24px; border-top: 1px solid #eaeaea; } .chat-footer input { flex: 1; padding: 10px 14px; border: 1px solid #ddd; border-radius: 8px; outline: none; } .chat-footer button { padding: 10px 20px; border: none; border-radius: 8px; background: #3478f6; color: #fff; cursor: pointer; } .chat-footer button:disabled { opacity: 0.6; cursor: not-allowed; } </style>这段代码的关键点是:window.chatAPI是在预加载脚本中暴露的,渲染进程自身没有 Node.js 能力;sendMessage里传入的是精简后的历史消息,避免把空内容发给模型;流式文本通过appendChunk追加到最后一个 assistant 消息上,用户看到的是逐字输出的效果。
5.5 环境变量配置
最后在项目根目录添加.env.example示例文件:
LLM_BASE_URL=https://api.example.com/v1 LLM_API_KEY= LLM_MODEL=your-model复制为.env后填写你所用大模型服务的信息。.env文件不要提交到 Git,它属于敏感配置。
6. 运行与效果验证
运行开发环境:
npm run dev命令会先启动 Vite 开发服务器,等端口 5173 就绪后再启动 Electron。
预期表现:
- 桌面窗口弹出,标题为“AI 桌面助手”。
- 页面正常显示聊天界面和欢迎消息。
- 如果尚未配置 API Key,在输入框输入问题并回车,会在窗口内看到“尚未配置 LLM_API_KEY”的提示。
- 配置好 API Key 后,发送一条消息,模型回复会逐字出现在气泡中。
第一次跑通时建议分两步验证。
第一步,验证 UI 和 IPC 链路是否正常。不配置 Key,直接发送消息,如果提示出现在页面里,说明 Vue、preload、IPC 已经连通。
第二步,验证模型和流式输出。填写.env,重启应用,发送一条内容较长的消息比如“请用一百字介绍 Vite”,观察是否逐字输出。如果一次性全部出现,说明渲染进程没有正确处理chat:chunk事件,优先检查onMounted里的监听注册和preload.js中的事件转发。
运行失败时,第一步看主进程终端输出,第二步打开渲染进程的开发者工具看 Console 和 Network。大部分问题都能在这两个地方找到线索。
7. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 窗口白屏 | Vite 服务器未启动,或VITE_DEV_SERVER_URL配置不一致 | 查看主进程终端是否打印 Vite 地址;检查网络请求 | 用npm run dev同时启动两端;确认wait-on等待端口一致 |
window.chatAPI is not defined | preload 路径错误,或contextIsolation配置被修改 | 检查主进程webPreferences.preload;打开 DevTools 查看window对象 | 确保 preload 路径使用path.join(__dirname, 'preload.js') |
| 发送消息无反应 | ipcMain.handle未注册,或渲染进程调用方法名不一致 | 查看主进程是否报错;检查 DevTools 报错信息 | 统一 IPC 通道名,例如chat:send-message |
| 流式内容一次性出现 | 渲染进程没有监听chat:chunk | 在 preload 的onChunk中打印日志 | 确认onMounted中调用window.chatAPI.onChunk |
| 模型接口返回 401 | API Key 未配置或无效 | 检查.env是否被正确加载;打印请求状态码 | 确认 Key 和 BaseURL 正确,重启应用 |
| 控制台出现 CSP 警告 | Electron 默认安全提示 | 查看控制台完整警告内容 | 生产环境添加 CSP 配置,开发环境可忽略 |
| Linux 打包后无法启动 | 目标系统缺少运行库或沙箱权限受限 | 在目标系统中尝试带--no-sandbox参数启动 | 在目标系统真机测试,确认依赖和安全策略 |
8. 最佳实践与工程建议
代码跑通只是第一步。真实项目要落地,还需要关注几个工程问题。
8.1 API Key 安全
API Key 绝不能写在 Vue 组件里,也不能放进打包后的前端资源中。开发阶段的建议是放在.env,由主进程读取;生产环境更稳妥的方式是使用 Electron 的safeStorage加密后存储在本地,或者由启动脚本注入环境变量。这样即使别人拿到安装包,也无法直接从 JS 文件里翻出密钥。
8.2 对话上下文管理
大模型 API 通常需要把历史消息一起传过去,但消息越多,消耗的 token 越多,响应也可能变慢。建议在sanitizeMessages中限制最大条数和单条长度,本文的实现保留最近 20 条、每条 4000 字符,这是比较保守的策略。真实项目里可以根据模型上下文长度调整,也可以把超长历史先做摘要再拼接。
8.3 流式请求的中断与错误处理
用户点击“生成中”时不希望一直干等。AbortController 是处理中断的标准方案,可以把 controller 保存在主进程变量中,在渲染进程新增“停止生成”按钮时通知主进程执行controller.abort()。同时要考虑网络闪断、接口限流、模型返回格式异常等情况,给用户一个明确的错误提示,而不是一直停留在线圈状态。
8.4 打包与分发
Electron 应用体积大的根本原因是打包了完整 Chromium。打包时尽量使用代码压缩、移除无用依赖,有条件可以针对不同平台分别构建安装包。如果你需要分发到 Linux 生态比较特殊的系统,不要默认“同一个安装包到哪里都能跑”,最好在目标系统上做一轮真实测试,确认运行库、沙箱权限和显示环境都正常。这属于分发适配工作,不能等到交付阶段才补。
8.5 用 Cursor 提效的正确姿势
Cursor 能显著提升开发速度,关键是给它足够清晰的上下文。生成 Electron 窗口模板时,把“使用 contextIsolation 关闭 nodeIntegration,preload 放在 electron/preload.js”这样的约束写进提示词,它给出的代码会少很多坑。遇到报错时,直接把完整错误信息贴进去,而不是只问“为什么错了”。
但架构决策不要完全交给它。比如“API Key 放哪里”“IPC 通道怎么设计”“要不要支持本地模型”,这些需要开发者自己判断。Cursor 是生产力工具,不是架构师。
9. 总结与后续学习方向
这篇文章完成了一条完整的最小闭环:Electron 创建桌面窗口,preload 安全暴露 IPC,Vue3.5 渲染聊天界面,主进程调用大模型 API 并流式返回结果。这个应用已经具备真实 AI 桌面工具的雏形,后续的语音输入、对话记录持久化、多模型切换,都可以在这个骨架上叠加。
如果你打算继续深入,可以优先做三件事:一是接入本地模型,在完全离线的情况下测试对话体验;二是实现对话的持久化存储,例如把历史记录保存到本地 JSON 文件或 SQLite;三是做一轮打包测试,把安装包分别构建到 Windows、macOS 和 Linux 上跑一遍。
最后提醒一句:任何涉及生产环境、API 凭证和用户数据的变更,都要先在测试环境验证,再做备份和回滚计划。工具越顺手,越要记得给系统留好安全边界。