Electron+Vue3+TypeScript 智能会议纪要工具开发:架构、IPC与打包实战
做会议纪要这件事我相信每个团队都头疼过。我们部门每周至少有六七场会产品评审、技术方案、进度同步每次开完会还得有人花半小时甚至一小时去整理纪要。整理出来的东西呢——有人记流水账有人漏掉关键结论最要命的是待办事项经常没人认领下次开会又吵一遍。折腾了几次之后我决定自己动手写一个顺手的工具。于是就有了这个基于 Electron Vue3 TypeScript 的智能会议纪要工具本地录音、实时转写、AI 自动总结、一键导出能跑在 Windows 和 macOS 上目前已经在内部稳定使用了两个多月。这篇文章我会从技术选型、进程架构、IPC 通信、核心链路实现、打包发布到体验打磨把整个项目的关键决策和踩坑过程完整写下来。适合想用 Electron 做桌面工具、想在 Vue3 和 TypeScript 项目里把 IPC 通信做得更规范的同学参考也适合那种“看着教程能跑一上真实项目就各种翻车”的阶段——因为我基本把能踩的坑都替你们踩了一遍。1. 为什么是 Electron Vue3 TypeScript而不是别的组合在选择技术方案之前我其实先做过一个网页版的会议纪要工具功能不复杂浏览器里录音用 WebSocket 把音频流送到后端转写再把结果渲染到页面上。看起来一切正常但真实用了两周就发现问题了。浏览器录音的致命弱点是标签页后台运行会被节流只要你切到别的标签页或者把窗口最小化录音流就断断续续识别结果直接稀碎。浏览器文件系统也不可控导出纪要得走下载跨平台行为不一致。最要命的是会议中经常要临时打开别的软件这种场景浏览器根本罩不住。所以我转向了 Electron它本质上把 Chromium 和 Node.js 放在同一个运行时里既能写网页那套 UI又能通过主进程碰操作系统能力这一下就解决了我的核心痛点。Vue3 的选择反而更简单。我对比过 React 和 Vue3项目里大部分界面逻辑是“表单 列表 实时更新的文本流”Vue3 的组合式 API 很适合把这类逻辑聚在一起模板语法对后端转前端的同事也友好。加上 Naive UI 这类组件库在桌面场景下开箱即用界面做出来不会太丑。而且 Vite 的开发体验是真的快Electron 项目最怕改一行代码等三秒热更新Vite 基本能做到毫秒级响应。TypeScript 在这个项目里不是锦上添花是刚需。因为 Electron 应用天然分成主进程和渲染进程两边靠 IPC 通信消息里传的常常是“音频块”、“识别结果”、“总结增量”这种复杂结构。没有类型约束MessageChannel 里跑的数据就是个黑盒子字段名拼错、类型传错全都要等运行期才爆出来。我后面会专门讲怎么用 TypeScript 把 IPC 设计成“类型安全契约”这一点是这个项目里我觉得最值回票价的部分。有个小坑顺便提一下项目初始化阶段很容易忽略 TypeScript 配置尤其是从 Webpack 老项目迁移过来的。我在一个分支上继承了旧的 tsconfig启动编译时冒出一行警告“选项‘moduleResolutionnode10’已弃用并将停止在 TypeScript 7.0 中运行。指定 compilerOptions.moduleResolution”。这玩意不是报错所以很多人不管但它其实会影响你后续用 Vite 的项目引入方式。新版 Vite 模板默认是moduleResolution: bundler配合module: esnext才合适。如果是 Electron 项目里主进程和渲染进程共用一套 tsconfig建议直接参考这个配置{ compilerOptions: { target: ES2022, module: ESNext, moduleResolution: bundler, strict: true, jsx: preserve, sourceMap: true, resolveJsonModule: true, isolatedModules: true } }如果主进程代码用了 CommonJS 的 require 写法那就在主进程目录单独放一份 tsconfig把module设成nodenext渲染进程继续保持bundler。刚开始就分开配后面会少很多麻烦。2. 主进程与渲染进程的职责切分IPC 消息如何做到类型安全Electron 应用有两个进程模型这是它和纯网页开发最大的区别。主进程是应用入口能完整使用 Node.js 能力操作系统窗口、读写文件、访问底层设备都靠它渲染进程就是每个窗口里的网页跑着你的 Vue3 代码能力边界和普通浏览器一致。两者不能直接访问对方的变量只能通过 IPC 传消息。很多人第一次写 Electron 时会困惑到底哪些逻辑应该放主进程哪些放渲染进程我的划分原则很简单——凡是“碰系统资源”和“不能被页面刷新影响的”都放主进程凡是要“渲染到界面上的”都放渲染进程。拿我这个纪要工具举例主进程负责音频文件的落盘和持久化调用流式转写服务的网络连接大模型总结接口的调用原生菜单、托盘、全局快捷键文件保存对话框和导出渲染进程负责会议信息表单主题、参会人、时间录音状态控制和波形显示实时识别文本的分段展示AI 总结结果的增量渲染历史会议列表和编辑区这个切分在录音环节尤其重要。早期我图省事把录音完全放在渲染进程结果就是浏览器标签页后台节流的问题在 Electron 里原样复现了——窗口最小化或者被其他窗口遮挡Chromium 照样会减少渲染进程的定时器和网络活动录音数据流变得不稳定。虽然在webPreferences里设置backgroundThrottling: false能缓解一部分但更稳妥的做法是渲染进程负责采集主进程负责持续处理和落盘。采集完的音频块通过 IPC 源源不断发到主进程这样才能保证无论如何切窗口、最小化底层的流式转写都能持续跑完。接下来就是重头戏IPC 消息的类型安全。Electron 自带的ipcRenderer.invoke和ipcMain.handle的类型都是宽泛的你不写类型系统也不会拦你。但这种自由在复杂项目里就是灾难。我见过不少项目IPC 通道名用字符串散落在各个文件里改一个通道名全局搜索都搜不全。我的做法是建一个channels.ts把所有通道名集中定义同时把每个通道的请求和响应类型写成显式的 interface// src/shared/channels.ts export const IPC { startRecording: recording:start, stopRecording: recording:stop, audioChunk: recording:audio-chunk, asrPartial: asr:partial, asrFinal: asr:final, generateSummary: llm:generate-summary, summaryDelta: llm:summary-delta, exportFile: file:export } as const export interface RecordingStartRequest { deviceId: string sampleRate: number } export type RecordingStartResponse | { ok: true; filePath: string } | { ok: false; error: string } export interface AudioChunkPayload { chunk: ArrayBuffer timestamp: number } export interface AsrPartialPayload { segmentId: string text: string isFinal: boolean }然后在主进程注册 handler 时把请求参数和返回值都用上面的类型标注出来// src/main/ipc.ts ipcMain.handle( IPC.startRecording, async (_event, req: RecordingStartRequest): PromiseRecordingStartResponse { // 具体录音初始化逻辑 } )渲染进程调用侧同样有类型保障// src/renderer/api/recording.ts const resp await ipcRenderer.invoke(IPC.startRecording, { deviceId: selectedDeviceId.value, sampleRate: 16000 } satisfies RecordingStartRequest) if (resp.ok) { filePath.value resp.filePath } else { showError(resp.error) }这样设计之后IPC 通道名就像一份协议文档——新增一个跨进程消息先改channels.ts让类型系统逼着你把双方都改完。实际体验下来项目上线前几乎没有出现因为 IPC 字段拼错而导致的线上 bug。经常有人问Electron 的 IPC 通信跟 Vue 有关系吗答案是没有直接关系。IPC 是 Electron 的进程通信机制Vue 是渲染进程里的界面框架。但在实际项目里二者一定会结合你在 Vue 组件里调用ipcRenderer.invoke()拿到 Promise 结果后赋值给响应式变量界面自然更新。我习惯把这层调用封装成组合式函数比如录音功能// src/renderer/composables/useRecording.ts export function useRecording() { const isRecording ref(false) const errorMessage ref() async function startRecording(deviceId?: string) { const resp await ipcRenderer.invoke(IPC.startRecording, { deviceId: deviceId ?? , sampleRate: 16000 }) if (resp.ok) { isRecording.value true } else { errorMessage.value resp.error } } async function stopRecording() { const resp await ipcRenderer.invoke(IPC.stopRecording) if (resp.ok) { isRecording.value false } } return { isRecording, errorMessage, startRecording, stopRecording } }组合式函数的好处是组件里只关心状态IPC 的细节被隔离在这层封装里后面就算换消息传递方式界面代码也不受影响。3. 录音到纪要文本的完整链路采集、转写、总结、导出这就是工具的核心功能链路了。整个流程走通之后才会发现每一步都有细节要考虑。我从头展开讲。3.1 录音采集渲染进程采集主进程落盘录音采集走的是渲染进程的navigator.mediaDevices.getUserMedia()这是 Chromium 的能力Electron 里可以正常用。关键配置是在创建窗口时加backgroundThrottling: false同时记得处理必要的权限// src/main/window.ts const win new BrowserWindow({ webPreferences: { preload: path.join(__dirname, preload.js), contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })背景节流关掉之后窗口最小化或切后台定时器和录音流都不会被 Chromium 降频。但光有采集还不够音频数据得持续送到主进程。做法是用 MediaRecorder 拿到音频块通过 IPC 批量发送const mediaRecorder new MediaRecorder(stream, { mimeType: audio/webm;codecsopus, audioBitsPerSecond: 16000 }) mediaRecorder.ondataavailable (event) { if (event.data.size 0) { ipcRenderer.send(IPC.audioChunk, { chunk: event.data, timestamp: Date.now() } satisfies AudioChunkPayload) } }这里我踩过一个坑就是 chunk 发送太频繁会把主进程的事件循环塞满。MediaRecorder 默认每 1000ms 触发一次ondataavailable但在低比特率下音频数据积压会导致转写延迟。后来我把timeslice参数显式设成 100ms让音频块更均匀地流过去实测转写延迟降低了不少。主进程收到音频块后一边追加写入本地的临时音频文件一边把音频数据转成适合转写服务的格式PCM 或直接转发压缩流送往转写服务。这样即使在转写服务临时不可用的情况下原始录音也没丢事后可以补跑离线转写。3.2 流式转写IPC 转发与增量渲染转写服务我用的是 WebSocket 流式接口长连接由主进程维护渲染进程不直接接触网络这是当时定下的原则所有网络请求都走主进程渲染进程只管界面。音频块到达主进程后主进程按约定的帧长度通常是每 100ms 的音频数据转发给转写服务服务端会返回两种结果识别中的中间结果partial和一句话的最终结果final。主进程拿到这些结果后通过webContents.send推给渲染进程// 主进程收到转写服务消息 const segmentId result.segmentId webContents.send(IPC.asrPartial, { segmentId, text: result.text, isFinal: result.isFinal } satisfies AsrPartialPayload)渲染进程里维护一个按段聚合的结构用 Vue3 的响应式状态去承接增量结果const segments refSegment[]([]) function handlePartial(payload: AsrPartialPayload) { const seg segments.value.find(s s.id payload.segmentId) if (seg) { seg.text payload.text } else { segments.value.push({ id: payload.segmentId, text: payload.text, isFinal: payload.isFinal }) } }这一段的难点是处理 partial 结果对 UI 的抖动影响。最初的实现是每收到一个 partial 就整体重渲染页面明显闪烁而且用户看到识别文本在不停地跳。后来做了两层优化第一partial 结果统一放入一个“待稳定区”只有 final 结果才落到正式纪要区第二视图层使用v-memo或者在组件层面按段 id 缓存 dom 节点避免整列表 diff 带来的闪烁。这两个优化做完之后实时转写区域才算真正“可用”了。3.3 AI 总结与 Vue3 computed 的应用转写完成只算做到了 50%另一半价值在 AI 总结。原始转写文本往往几百行读完不比开会轻松。我的想法是点击“生成纪要”后把全文交给大模型接口让它输出“会议结论”、“待办事项”、“风险项”和“决议记录”四个部分按 Markdown 结构返回。这里我选择的是流式返回用 fetch 读取 SSE 流边读边把增量文本发给渲染进程。渲染进程维护一个summaryContent的响应式字符串流式追加const summaryContent ref() async function generateSummary() { const rawText allFinalSegments.value.join(\n) const resp await ipcRenderer.invoke(IPC.generateSummary, { rawText }) // 返回一个 taskId主进程持续推送 summaryDelta } // 主进程推送增量 function handleSummaryDelta(delta: string) { summaryContent.value delta }流式返回对用户的心理体验提升非常大——如果是一两分钟后一次性弹出完整结果用户会怀疑系统卡死了但看到文字一个字一个字地生成用户会感觉到系统“正在工作”对等待的容忍度会高很多。Vue3 的 computed 在这里有个很典型的应用。转写过程中每个 final 段落都带有时间戳我用 computed 去派生“当前段落已经停顿了多久”从而判断是否需要自动分段const lastActiveAt computed(() { const final segments.value.filter(s s.isFinal) if (final.length 0) return 0 return final[final.length - 1].timestamp }) const idleSeconds ref(0) watch(lastActiveAt, (val) { if (val 0) { idleSeconds.value 0 } })另一个用途是全局搜索。历史纪要多了以后我直接在顶部加了一个搜索框过滤列表用 computed 实时计算索引结果几百条记录的性能在响应式缓存下没有任何压力。这就是 Vue3 真正的价值所在——复杂的派生状态用 computed 管起来比在模板里写一堆方法调用要高效得多。3.4 导出三种格式导出是纪要工具的基础功能但就是这种基础功能里藏着不少细节。我实现了三种导出格式Markdown、纯文本 TXT、PDF。前两种的做法很直白把内容拼成字符串让主进程弹一个保存对话框const { canceled, filePath } await dialog.showSaveDialog({ title: 导出会议纪要, defaultPath: 会议纪要-${meetingTitle}.md, filters: [ { name: Markdown, extensions: [md] }, { name: 纯文本, extensions: [txt] } ] }) if (!canceled filePath) { await fs.writeFile(filePath, content, utf8) }这里有个实际经验Windows 记事本打开没有 BOM 的 UTF-8 文件会乱码。所以导出 TXT 时记得在内容开头加\uFEFF。这个坑在 macOS 上不存在如果你只在一台机器上测过很难发现。PDF 导出我没有在主进程装重量级库而是在渲染进程里构造一个隐藏的打印窗口把纪要内容渲染成排版好的 HTML然后调用webContents.printToPDF()这样能直接复用 Chromium 的排版引擎输出效果和页面所见基本一致而且不需要额外处理中文字体问题。4. 打包发布阶段最容易踩的四个坑开发环境跑得再顺也不代表打包出来就能用。这轮我栽的跟头最多挑四个有代表性的讲都是网上资料不太会写明的那种。4.1 第一个坑开发正常打包后白屏这是 Electron Vite 项目最经典的坑几乎没有谁能完全避开。现象是npm run dev一切正常electron-builder打出来的包双击运行窗口打开但全白控制台报资源 404。根因在于 Vite 默认把资源路径写成绝对路径/assets/index-xxx.js这在 dev 服务器下没问题但在生产模式走loadFile加载本地 html 时文件协议下绝对路径指向的是本地磁盘根目录自然加载不到。解法很简单在vite.config.ts里加一行export default defineConfig({ base: ./, // 其余配置 })这样所有资源都变成相对路径走 file 协议也能正常加载。但有个后遗症如果项目里某个地方用了动态 import 且路由是 history 模式改 base 之后还得同步调整路由。所以我的建议是项目一开始初始化 Vite 配置时就把base: ./写上别等打包再想。4.2 第二个坑TypeScript 7.0 警告与配置老化这个坑在建项目初期和升级依赖时都可能出现。如果你从老项目拷贝 tsconfig很可能会遇到moduleResolutionnode10的弃用警告。TypeScript 团队已经明确要在 7.0 移除这种旧解析模式继续留在那里只会积累技术债。Vite 项目应该用moduleResolution: bundler这一点我在第 1 部分已经给出配置了。在主进程代码里如果用了__dirname等 CommonJS 相关 API配合bundler解析也会有问题。我的做法是把主进程代码单独建立一个tsconfig.main.json继承基础配置然后覆盖 module 和 moduleResolution{ extends: ./tsconfig.json, compilerOptions: { module: NodeNext, moduleResolution: NodeNext, types: [node] }, include: [src/main/**/*.ts, src/shared/**/*.ts] }需要注意extends的配置里数组是覆盖而不是追加所以涉及 include 的时候要写全。这算是一个比较少有人讲的细节。4.3 第三个坑安装包体积失控Electron 应用体积大是原罪刚打包出来 180MB压缩后才 150 多MB。这个数字其实不完全可控因为 Electron 里 Chromium 本身就是大头。但还是有优化空间。electron-builder 的配置里默认会把整个node_modules塞进 asar 包实际上很多运行时不需要的依赖完全没必要带进去。我通过files字段明确白名单files: [ dist/**/*, dist-electron/**/*, package.json ]package.json 里的 dependencies 只保留运行时真正需要的开发依赖全部放 devDependencies。这样能有效地把体积压下来。另外electron-builder 默认会生成包含调试符号的二进制设置compression: maximum也有一点点帮助。4.4 第四个坑默认菜单和快捷键很多 Electron 教程不会提醒你默认菜单栏是英文的而且包含了“File”、“Edit”、“View”这些对普通会议用户毫无意义的菜单项。我一开始没管后来同事反馈说应用看起来“像是开发工具”。后来我直接用Menu.buildFromTemplate重写了一套精简菜单只保留“文件”、“编辑”、“视图”和“帮助”并且在菜单里定义了全局快捷键const template: Electron.MenuItemConstructorOptions[] [ { label: 文件, submenu: [ { label: 新建纪要, accelerator: CmdOrCtrlN, click: createNewMeeting }, { label: 保存纪要, accelerator: CmdOrCtrlS, click: saveTranscript }, { type: separator }, { label: 导出为 Markdown, click: exportAsMarkdown }, { label: 退出, role: quit } ] }, // 其他菜单 ]注意 macOS 上还要处理role的兼容问题比如窗口最小化、关闭这些动作如果全部自定义菜单又不设置 role会导致 Mac 上 CmdW、CmdM 等系统级快捷键失效。最好的做法是模板里保留常用的 role 菜单项再叠加自定义快捷键。5. 打磨使用体验时我做的几件事功能跑通后距离“团队愿意每天用”还有很长一段路。这个阶段我做的事情更杂挑几个有代表性的记录一下。5.1 后台运行与托盘开会的人有个习惯开完会可能会直接关窗口但录音转写任务可能还在跑。为了让关闭窗口不打断转写我做了两个处理窗口关闭事件默认拦截改成隐藏到系统托盘托盘菜单提供“完全退出”和“打开主界面”两个选项。这样用户即使误点关闭任务也能在后台继续跑完。托盘图标用了Tray和nativeImage在 macOS 和 Windows 上各做一套。macOS 的托盘图标要支持 template image也就是纯黑色加 alpha 通道系统会自动切换深浅色Windows 则用正常的 PNG 就行。这个差异不算技术难题但不处理的话总有一端看起来别扭。5.2 文档分段和发言人标记原始转写文本如果只是堆在一起阅读体验非常差。我在转写渲染层做了两层加工。第一层是自动分段。识别文本里带有时间戳当两个 final 结果之间的间隔超过 2 秒我就认为是一个段落分界点如果间隔超过 5 秒就额外插入一个“长时间停顿”的标记方便用户快速定位冷场或思考节点。第二层是发言人标记。我做不了自动声纹识别就采取了一个务实方案在转写界面左侧放一个人物列表用户拖拽到某个段落上就给该段落挂上发言人名字。这比纯手动输入快很多在各部门例会里已经完全够用。5.3 历史记录与本地索引为了保证隐私我一开始就没有选择把纪要数据放云端而是存本地 SQLite。Electron 主进程里用better-sqlite3作为数据库驱动建了三张表会议记录表、转写片段表、总结记录表。搜索历史纪要时直接 SQL 模糊匹配标题和转写文本数据量在几千条级别下毫秒级返回比搞一套 Elasticsearch 或者内存倒排索引划算得多。数据库文件放在app.getPath(userData)目录下这样即使用户升级软件版本、重新安装数据也不会丢。导出功能还做了把这一条会议记录连同转写、总结、参会人信息整体导出为 HTML 报告的能力分享给没安装工具的同事特别方便。5.4 数据安全与私有化部署会议纪要这种内容包含大量的业务信息我认为无论如何都不应该往公开第三方 API 送。在这个项目里转写服务和总结模型我都对接的是公司内网自建的服务用的和 OpenAI 兼容的接口协议。如果你的团队没有内网模型服务建议优先考虑本地部署开源的语音识别和语言模型方案哪怕效果稍弱也比把会议内容送到外部服务让人安心。Electron 应用本身不做核心数据的持久化上传这是我从一开始就坚持的底线。整个项目从最初的原型到现在稳定运行最让我有成就感的不是某个炫酷的技术点而是一个可以被同事天天打开、真正把开会这件事变轻松的工具。最后再分享一个小经验如果你想在团队里推广一个内部工具一定要把“导出”这个看似不起眼的环节做好——只有能无缝地输出成大家习惯的文档格式这个工具才真正进入了团队的日常工作流。否则即便转写得再准、总结得再好它也只是你个人项目库里的一个精美玩具。