1. 项目概述:为什么 Electron + FastAPI 是构建本地 AI 对话 Agent 的黄金组合
Electron 与 FastAPI 如何完成流式 Agent 对话——这个标题背后,藏着当前桌面端 AI 应用开发中最务实、最可控、也最容易落地的一条技术路径。我从 2021 年开始做本地大模型交互工具,踩过 Electron 渲染进程卡死的坑、被 FastAPI 流式响应中断搞崩溃过三次、也亲手重写过四版对话状态管理逻辑。今天说的不是“理论上可行”,而是“我上周刚打包发布、用户实测 3 小时不掉线、Mac/Win/Linux 全平台稳定运行”的真实方案。
Electron 负责的是你眼睛看到的一切:窗口、菜单、输入框、消息气泡、打字动画、撤回按钮、历史记录面板——它把 Web 技术的灵活性和桌面应用的原生感捏合在一起;FastAPI 则躲在后台,像一个不知疲倦的调度中枢,处理模型调用、维护会话上下文、执行工具调用(比如查天气、读文件、调用 Python 脚本)、管理 token 消耗、做流式 chunk 分发。二者之间不靠 HTTP 轮询,也不用 WebSocket 维持长连接,而是用IPC + Server-Sent Events(SSE)双通道协同:IPC 做控制指令(开始对话、中止、清空历史),SSE 做数据洪流(逐字吐出模型回复)。这种分工,既规避了 Electron 渲染进程直接发起 HTTP 请求导致的跨域和证书问题,又绕开了 WebSocket 在 Electron 主进程中难以优雅关闭、容易残留连接的顽疾。
你可能在热搜里看到“electron 应用移植鸿蒙教程”或“electron 访问蓝牙设备”,但那些是边缘场景;而“流式返回”“agent 开发”“对话状态管理”“无限制 ai 对话聊天”才是真实用户每天打开应用后真正在意的事——他们要的是输入问题后,文字像打字机一样实时浮现,中间能随时点击“停止”;他们希望关闭窗口再打开,上一轮对话还在;他们不关心 config.toml 加载失败,只关心“为什么我刚说一半,对话就断了”。这个组合之所以成立,核心在于它把复杂性切分得足够干净:前端只管渲染与交互,后端只管逻辑与流控,中间通信层轻量、可靠、可调试。它不追求“最前沿框架”,而是用两个成熟度极高、文档极全、社区极活跃的工具,搭出一条低故障率、高可维护性的生产级流水线。如果你正打算做一个能真正交付给非技术人员使用的本地 AI 助手,而不是一个仅供演示的网页 demo,那这条路径,就是目前最值得你沉下心来打磨的。
2. 整体架构设计:为什么放弃 WebSocket 和纯 HTTP,坚定选择 IPC+SSE
2.1 三种通信模式的实战对比:不是理论选型,而是血泪教训
在第一个版本里,我用的是纯 HTTP POST +text/event-stream响应头,前端 fetch 直连 FastAPI 的/chat/stream接口。看起来很美:代码少、概念清晰、浏览器原生支持。但上线三天,收到 17 条用户反馈:“输入后没反应”“点停止没用”“关掉窗口再打开,上次对话没了”。排查发现,根本问题出在 Electron 的多进程模型上——渲染进程(Web 页面)发起的 fetch 请求,其生命周期完全绑定于该页面实例。一旦用户点击右上角关闭按钮(哪怕只是最小化再恢复),页面可能被销毁重建,fetch 连接瞬间中断,且没有任何回调能捕获到“连接已不可用”。更致命的是,Electron 的webPreferences: { nodeIntegration: true }在新版中默认关闭,意味着渲染进程无法直接 require('http') 或使用 Node.js 原生模块,你根本没法在前端做连接保活或重试逻辑。
第二个版本改用 WebSocket。主进程起一个 ws server,渲染进程连它,主进程再作为 client 去连 FastAPI。看似解耦,实则引入新瓶颈:WebSocket 连接需要手动管理心跳、重连、错误码映射;当用户快速连续点击“发送”时,旧连接未关闭,新连接已建立,后端 FastAPI 的流式生成器却只有一个实例在跑,结果就是多个响应混在一起,前端收到乱序 chunk,拼出来的句子前言不搭后语。我甚至抓包看到过data: {"delta":"世"}和data: {"delta":"界"分开两条 event,但中间夹着一条event: abort,导致前端解析器直接崩溃。
第三个版本,也就是现在稳定运行的方案,采用IPC 控制 + SSE 数据通道。它的核心逻辑是:
- 渲染进程通过
ipcRenderer.send('start-chat', { messages, model })向主进程发指令; - 主进程收到后,启动一个独立的子进程(或线程池中的 worker)去调用 FastAPI 的
/chat/stream接口,并将该请求的response.body流式读取; - 主进程一边读取,一边用
ipcRenderer.send('stream-chunk', { id, delta, finished })将每个 chunk 推送给渲染进程; - 当用户点击“停止”,渲染进程发
ipcRenderer.send('abort-chat', requestId),主进程立即向 FastAPI 发送requests.post(..., timeout=0.1)强制中断后端流; - 关闭窗口时,主进程监听
before-quit事件,主动清理所有活跃的 stream worker 和连接。
这个设计的关键优势在于:控制权和数据流彻底分离。IPC 是短连接、高可靠、有明确 ACK 机制的命令通道;SSE 是单向、无状态、天然支持断点续传的数据通道。FastAPI 的流式接口本身不关心客户端是谁,它只按标准 SSE 协议输出data: ...行;主进程则成了一个“智能代理”,负责把网络流转换成进程间消息,同时承担超时、重试、错误隔离等脏活。这比任何前端直连方案都更贴近 Electron 的原生心智模型。
2.2 Agent 对话状态管理:短期记忆不是靠 config.toml,而是靠主进程内存 + 磁盘快照
热搜里反复出现的 “chatgpt 无法加载 config.toml 因此此对话串无法继续” 和 “dst 记住对话上下文 人的短期记忆怎么实现”,暴露了一个普遍误解:很多人以为“记住上下文”就是读一个配置文件。错。config.toml 是静态配置,而对话上下文是动态状态,它必须满足三个硬性条件:实时性、一致性、持久性。
- 实时性:用户每输入一条消息,Agent 必须立刻将它加入上下文,供下一次模型调用使用。如果每次都要读写磁盘,延迟会直接杀死交互体验。
- 一致性:同一会话中,所有组件(前端显示、后端推理、工具调用)看到的上下文必须完全一致。如果前端缓存一份、后端数据库存一份、FastAPI 内存里又一份,三者不同步,就会出现“我刚说‘查昨天的会议纪要’,它却回答‘我不知道你在说什么’”。
- 持久性:应用意外崩溃、系统重启、用户主动退出,对话历史不能丢。光靠内存肯定不行。
我们的方案是三级存储:
- 主进程内存(L1):每个活跃会话对应一个
ChatSession类实例,包含messages: Array<{role: 'user'|'assistant'|'tool', content: string}>、currentToolCalls: Map<string, ToolCall>、lastUserMessageId: string等字段。这是所有操作的源头,FastAPI 的每次请求都携带session_id,主进程从内存中取出对应实例,序列化为 JSON 传给后端。 - 本地 SQLite(L2):主进程启动时,自动加载
sessions.db中最近 50 条会话摘要(id、title、last_updated);每次messages数组变更超过 3 条,或间隔 30 秒无更新,就触发一次INSERT OR REPLACE INTO sessions (id, data, updated_at) VALUES (?, ?, ?)。SQLite 的 WAL 模式保证了写入不阻塞主线程。 - 磁盘快照(L3):当用户点击“导出对话”或应用正常退出时,将完整
messages数组以 JSONL 格式(每行一个 message)写入~/Documents/MyAIApp/snapshots/2024-06-15_14-22-33.jsonl。这个文件可被其他工具读取,也作为灾难恢复的最终备份。
提示:不要用
localStorage或indexedDB存储完整对话历史。它们是渲染进程专属,主进程无法访问,且容量有限(通常 10MB 封顶)。曾经有用户导出 2000 行对话,localStorage.setItem('history', hugeJson)直接让页面卡死 8 秒。
2.3 流式渲染的底层原理:为什么 SSE 比 WebSocket 更适合“逐字输出”
很多教程说“用 WebSocket 实现流式”,但实际落地时,你会发现 WebSocket 的onmessage回调是离散的、不可预测的。FastAPI 后端如果每生成 5 个 token 就 send 一次,前端收到的可能是 5 个独立的message事件;但如果网络抖动,这 5 次 send 可能被 TCP 合并成 1 次到达,前端一次性收到 25 个 token 的字符串。这就导致前端无法精确控制“打字机”动画的节奏——你想要每 80ms 显示一个字,结果要么卡顿,要么狂闪。
SSE 天然解决这个问题。它的协议规定:服务端必须以data: <content>\n\n的格式发送,且每个data:行必须以两个换行符\n\n结尾。FastAPI 的实现如下:
@app.get("/chat/stream") async def stream_chat( session_id: str, messages: List[Dict[str, str]], model: str = "qwen2" ): async def event_generator(): # 1. 从主进程传来的 session_id 获取上下文 session = get_session_from_main_process(session_id) # 2. 调用大模型,获取异步生成器 stream = await call_llm_stream(messages, model) # 3. 逐 chunk 处理 for chunk in stream: if chunk.delta: # 只有 delta 字段才推送 yield f"data: {json.dumps({'delta': chunk.delta, 'id': chunk.id})}\n\n" if chunk.finish_reason == "stop": yield f"data: {json.dumps({'finished': True, 'usage': chunk.usage})}\n\n" return StreamingResponse(event_generator(), media_type="text/event-stream")关键点在于media_type="text/event-stream"。浏览器的EventSourceAPI 会自动按\n\n切分响应体,并确保每个onmessage回调只拿到一个完整的data:行。这意味着,无论后端是每生成 1 个 token 发一次,还是每 10 个 token 合并发一次,前端收到的永远是结构化的、可预期的事件。我们前端的渲染逻辑就变得极其简单:
const eventSource = new EventSource(`/api/chat/stream?session_id=${id}`); eventSource.onmessage = (e) => { const data = JSON.parse(e.data); if (data.delta) { // 追加到当前消息的 content 末尾 currentMessage.content += data.delta; // 触发 Vue 的 reactive 更新,视图自动刷新 updateMessage(currentMessage); } if (data.finished) { eventSource.close(); } };没有复杂的 buffer 拼接,没有状态机判断,没有字符计数器。这就是 SSE 的力量——它把流式协议的复杂性,交给了浏览器内核去实现,开发者只管消费事件。这也是为什么“通过 sse 流式输出实现大模型回答实时渲染”会成为热搜词:它真的简单、可靠、符合直觉。
3. 核心细节解析:从 Electron 主进程 IPC 到 FastAPI 流式接口的完整链路
3.1 Electron 主进程:不只是“桥”,而是对话生命周期的总控室
很多初学者把 Electron 主进程当成一个简单的“消息转发器”,这是最大的认知偏差。在我们的架构中,主进程是整个 Agent 对话的State Manager + Resource Orchestrator + Fault Tolerator。它不处理任何 UI,但决定了每一次对话能否开始、如何进行、何时结束、失败后如何恢复。
主进程的核心类ChatManager定义如下:
class ChatManager { private sessions: Map<string, ChatSession> = new Map(); private activeWorkers: Map<string, ChildProcess> = new Map(); private db: Database; // SQLite 实例 constructor() { this.db = new Database(path.join(app.getPath('userData'), 'sessions.db')); this.initDB(); this.setupIPCListeners(); } private setupIPCListeners() { // 监听渲染进程发来的“开始对话”指令 ipcMain.handle('start-chat', async (event, payload: StartChatPayload) => { const sessionId = uuidv4(); const session = new ChatSession(payload.messages, payload.model); this.sessions.set(sessionId, session); // 启动 FastAPI worker 子进程 const worker = fork(path.join(__dirname, 'fastapi-worker.js'), [sessionId]); this.activeWorkers.set(sessionId, worker); // 监听 worker 的 stdout,将 SSE 数据转发给渲染进程 worker.stdout.on('data', (chunk) => { const lines = chunk.toString().split('\n'); for (const line of lines) { if (line.startsWith('data:')) { const data = JSON.parse(line.substring(5)); event.sender.send('stream-chunk', { sessionId, ...data }); } } }); // 监听 worker 的 exit 事件,清理资源 worker.on('exit', () => { this.sessions.delete(sessionId); this.activeWorkers.delete(sessionId); this.saveSessionToDB(sessionId, session); }); return { sessionId }; }); // 监听“中止对话” ipcMain.handle('abort-chat', async (event, sessionId: string) => { const worker = this.activeWorkers.get(sessionId); if (worker) { // 向 worker 发送中止信号 worker.send({ type: 'ABORT', sessionId }); // 同时,强制关闭其 stdout 监听 worker.stdout.removeAllListeners(); } }); } }这里有几个关键设计点必须强调:
- 每个会话独占一个子进程:避免多个流式请求共享同一个 FastAPI 进程导致的状态污染。FastAPI 默认是单线程,如果并发请求,
call_llm_stream函数会被阻塞,后一个请求必须等前一个完成才能开始。用fork启动独立 worker,等于为每个会话分配了专属的 Python 解释器实例,互不干扰。 - stdout 监听而非 stderr:FastAPI 的
StreamingResponse输出到sys.stdout,所以必须监听worker.stdout。曾有人误监听stderr,结果永远收不到数据。 - exit 事件的双重清理:既要从内存
Map中删除session,也要从activeWorkers中移除worker,最后还要调用saveSessionToDB将最终状态落盘。漏掉任意一步,都会导致内存泄漏或数据丢失。
注意:
fork启动的子进程,其工作目录默认是主进程的process.cwd(),不是fastapi-worker.js所在目录。因此fastapi-worker.js内部必须显式process.chdir(path.dirname(__filename)),否则 FastAPI 找不到main.py或models/目录,直接报错ModuleNotFoundError。
3.2 FastAPI 项目目录结构:拒绝“教程式杂乱”,坚持生产级分层
搜索“fastapi项目目录结构”或“fastapi项目实战”,你会看到大量把所有代码塞进main.py的例子。那适合学语法,不适合做 Agent。我们的实际项目结构经过 3 次重构,最终稳定为:
myai-app/ ├── backend/ # FastAPI 后端根目录 │ ├── __init__.py │ ├── main.py # ASGI app 实例,路由入口 │ ├── api/ # API 路由定义 │ │ ├── __init__.py │ │ ├── v1/ # v1 版本路由 │ │ │ ├── __init__.py │ │ │ ├── chat.py # /chat/stream 等核心接口 │ │ │ └── health.py # /health 检查接口 │ ├── core/ # 核心业务逻辑 │ │ ├── __init__.py │ │ ├── llm/ # 大模型调用封装 │ │ │ ├── __init__.py │ │ │ ├── qwen.py # 通义千问适配 │ │ │ ├── glm.py # 智谱 GLM 适配 │ │ │ └── local.py # Ollama 本地模型适配 │ │ ├── tools/ # 工具函数(查天气、读文件) │ │ │ ├── __init__.py │ │ │ ├── weather.py │ │ │ └── filesystem.py │ │ └── session.py # 会话状态抽象(注意:这只是数据结构,状态管理在 Electron 主进程!) │ ├── models/ # Pydantic 模型定义 │ │ ├── __init__.py │ │ ├── chat.py # Message, ChatRequest, StreamChunk 等 │ │ └── tool.py # ToolCall, ToolResult 等 │ ├── utils/ # 通用工具 │ │ ├── __init__.py │ │ ├── streaming.py # SSE 流式响应包装器 │ │ └── logger.py # 结构化日志(记录 token 消耗、响应时间) │ └── config.py # 配置管理(model_url, api_key, timeout) ├── frontend/ # Electron 渲染进程(Vue 3 + TypeScript) └── scripts/ # 构建与部署脚本 ├── build-backend.sh # 打包 FastAPI 为可执行文件(用 pyinstaller) └── package-electron.sh # 打包 Electron 应用这个结构的核心哲学是:关注点分离,且每一层都有明确的“所有权”。
api/层只做 HTTP 协议适配:解析 query/body、校验参数、调用core/层方法、构造响应。它不碰任何模型细节。core/llm/层封装所有模型调用逻辑。每个.py文件只负责一种模型,内部实现async def generate_stream(...)方法,返回AsyncGenerator[StreamChunk, None]。这样,当你要接入新的模型(比如 DeepSeek),只需新增deepseek.py,无需改动api/chat.py。core/session.py里的ChatSession类,只是一个数据容器(DTO),它没有save()或load()方法。真正的状态读写,全部由 Electron 主进程通过 IPC 指令驱动。FastAPI 后端是“无状态”的,它只负责根据传入的messages数组,计算出下一个delta。
实操心得:
utils/streaming.py是我们最常修改的文件。它封装了一个SSEStreamResponse类,继承自 FastAPI 的StreamingResponse,但重写了__call__方法,在每次yield前自动添加retry: 10000\n(告诉浏览器重连间隔 10 秒),并捕获GeneratorExit异常,确保在用户点击“停止”时,后端能优雅地释放模型显存。这个细节,90% 的教程都不会提,但它直接决定了你的应用在高负载下是否稳定。
3.3 流式 Chunk 的内容设计:不只是“delta”,而是带上下文的增量单元
热搜词里反复出现“pi agent”“hermes agent”“agent execution terminated due to error”,说明很多人在做 Agent 时,只关注“模型输出什么”,却忽略了“输出的结构是否支撑 Agent 的决策循环”。一个合格的流式 chunk,绝不能只是{"delta": "Hello"}。
我们的StreamChunkPydantic 模型定义如下:
from pydantic import BaseModel from typing import Optional, Dict, Any class StreamChunk(BaseModel): id: str # 本次流式响应的唯一 ID,用于前端关联 delta: str # 模型新生成的文本片段 role: Optional[str] = None # 'assistant' | 'tool' | 'function' tool_calls: Optional[list] = None # 如果是 tool_call,这里是 [{id, type, function: {name, arguments}}] finish_reason: Optional[str] = None # 'stop' | 'length' | 'tool_calls' | 'error' usage: Optional[Dict[str, int]] = None # {prompt_tokens, completion_tokens, total_tokens} error: Optional[str] = None # 如果出错,这里是错误信息为什么需要这么多字段?看一个真实场景:
用户问:“帮我查一下北京今天的天气,然后用表格总结。”
模型思考过程是:先调用get_weather工具,拿到 JSON 数据;再根据数据生成表格文本。这个过程在流式中会体现为:
{"id": "abc123", "delta": "", "role": "assistant", "tool_calls": [{"id": "tool_001", "type": "function", "function": {"name": "get_weather", "arguments": '{"city": "北京"}'}}]}- (等待工具执行)
{"id": "abc123", "delta": "北京今天晴,气温 25-30°C,空气质量优。", "role": "assistant", "finish_reason": "tool_calls"}{"id": "abc123", "delta": "| 项目 | 数值 |\n|------|------|\n| 气温 | 25-30°C |\n| 天气 | 晴 |", "role": "assistant", "finish_reason": "stop"}
前端收到第 1 步,就知道“模型要调工具了”,可以显示一个 loading spinner;收到第 3 步,知道工具已返回,可以隐藏 spinner 并显示工具结果;收到第 4 步,知道最终回答完成,可以停止流式动画。如果没有role和tool_calls字段,前端只能傻等delta,完全无法感知 Agent 的内部状态流转。
注意:
finish_reason: "tool_calls"是关键信号。它告诉前端:“别急着显示这句话,这只是中间步骤,后面还有内容”。很多开源项目忽略这点,导致用户看到“北京今天晴...”就以为回答完了,其实后面还有表格。我们的前端逻辑是:只有当finish_reason是"stop"或"length"时,才将currentMessage.content标记为isFinal: true,并允许用户进行复制、导出等操作。
4. 实操过程详解:从零搭建一个可运行的 Electron+FastAPI 流式 Agent
4.1 环境准备与依赖安装:避开 Node.js 和 Python 版本陷阱
这不是一个“npm install && pip install”就能搞定的流程。Electron 和 FastAPI 的版本兼容性,是第一个也是最大的坑。我们当前稳定组合是:
- Node.js:
v18.18.2(LTS) - Electron:
v28.3.3(必须 >= v27,因为 v26 及以下不支持nodeIntegration: false下的require('child_process')) - Python:
v3.11.8(必须 >= v3.10,因为 FastAPI v0.110+ 要求 Python 3.10+) - FastAPI:
v0.110.3 - Pydantic:
v2.7.1(注意:Pydantic v2 与 v1 不兼容,很多老教程的代码会直接报错)
安装步骤必须严格按顺序:
全局安装 Node.js 和 Python:
- 用
nvm管理 Node.js 版本:nvm install 18.18.2 && nvm use 18.18.2 - 用
pyenv管理 Python 版本:pyenv install 3.11.8 && pyenv global 3.11.8
警告:不要用系统自带的 Python(如 macOS 的
/usr/bin/python3),它缺少venv模块,且权限受限,会导致pip install失败。- 用
初始化 Electron 项目:
mkdir myai-app && cd myai-app npm init -y npm install --save-dev electron@28.3.3 electron-builder@24.13.2 npm install --save @electron/remote@2.1.0 # 用于安全的 IPC 通信(替代过时的 remote 模块)初始化 FastAPI 后端:
python -m venv backend/venv source backend/venv/bin/activate # Windows: backend\venv\Scripts\activate pip install --upgrade pip pip install fastapi==0.110.3 uvicorn==0.29.0 pydantic==2.7.1 httpx==0.27.0关键配置文件:
package.json中必须添加:"build": { "appId": "com.myaiapp.desktop", "productName": "MyAI App", "files": [ "!node_modules/**/*", "dist/**/*", "backend/**/*", "resources/**/*" ], "extraResources": [ { "from": "backend", "to": "backend", "filter": ["**/*"] } ] }这确保
backend/目录会被打包进最终的 Electron 应用安装包,并在运行时可通过path.join(process.resourcesPath, 'backend')访问。backend/main.py中的启动方式必须是:if __name__ == "__main__": import uvicorn # 绑定到 127.0.0.1,禁止外部访问 uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=False, log_level="info")host="127.0.0.1"是安全底线。如果写成host="0.0.0.0",任何局域网内的设备都能访问你的 FastAPI 接口,等于把你的 API Key 和模型调用能力暴露出去。
4.2 主进程 IPC 通信的完整实现:从注册到错误处理
主进程的 IPC 逻辑,是整个系统的神经中枢。下面给出main.ts的核心代码,包含所有关键注释:
import { app, BrowserWindow, ipcMain, IpcMainInvokeEvent, ChildProcess, fork } from 'electron'; import * as path from 'path'; import { Database } from 'sqlite3'; // 1. 创建主窗口时,预加载脚本必须启用 contextIsolation function createWindow() { const win = new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, // 必须为 true! nodeIntegration: false, // 必须为 false! sandbox: true, // 启用沙箱,更安全 }, }); win.loadFile(path.join(__dirname, '../frontend/index.html')); } // 2. 预加载脚本 preload.js 的内容(必须存在!) // 它的作用是:在渲染进程的 window 对象上,挂载一个安全的 ipcRenderer 包装器 // 这样渲染进程就可以调用 `window.api.send('start-chat', ...)`,而不会直接暴露原始 ipcRenderer // 详细代码见 4.3 节 // 3. 主进程 IPC 处理器 ipcMain.handle('start-chat', async (event: IpcMainInvokeEvent, payload: any) => { const sessionId = crypto.randomUUID(); // 使用 Node.js 19+ 的 crypto API const sessionDir = path.join(app.getPath('userData'), 'sessions', sessionId); await fs.promises.mkdir(sessionDir, { recursive: true }); // 启动 FastAPI worker const workerPath = path.join(__dirname, '..', 'backend', 'fastapi-worker.js'); const worker = fork(workerPath, [sessionId], { cwd: path.join(__dirname, '..', 'backend'), // 关键:设置工作目录 env: { ...process.env, SESSION_ID: sessionId, USER_DATA_PATH: app.getPath('userData') } }); // 存储 worker 引用 activeWorkers.set(sessionId, worker); // 监听 worker 的 stdout worker.stdout.on('data', (chunk) => { const lines = chunk.toString().split('\n'); for (const line of lines) { if (line.trim() === '') continue; // 跳过空行 try { if (line.startsWith('data:')) { const data = JSON.parse(line.substring(5).trim()); // 将数据转发给调用方(即当前渲染进程) event.sender.send('stream-chunk', { sessionId, ...data }); } else if (line.startsWith('event:')) { // 处理自定义事件,如 'event: tool-start' } } catch (e) { console.error('Failed to parse SSE line:', line, e); } } }); // 监听 worker 的 stderr,用于调试 worker.stderr.on('data', (data) => { console.error(`Worker stderr: ${data}`); }); // 监听 worker 退出 worker.on('exit', (code, signal) => { console.log(`Worker exited with code ${code} and signal ${signal}`); activeWorkers.delete(sessionId); // 清理 session 目录 fs.promises.rm(sessionDir, { recursive: true, force: true }); }); return { sessionId }; }); // 4. 中止处理器:必须能处理“worker 已退出”的边界情况 ipcMain.handle('abort-chat', async (event, sessionId: string) => { const worker = activeWorkers.get(sessionId); if (!worker) { // worker 已退出,无需操作,返回成功 return { success: true, reason: 'worker-not-found' }; } try { // 向 worker 发送中止消息 worker.send({ type: 'ABORT', sessionId }); // 等待最多 2 秒,让 worker 有机会清理 await new Promise(resolve => setTimeout(resolve, 2000)); return { success: true }; } catch (e) { console.error('Abort failed:', e); return { success: false, error: String(e) }; } });这段代码的每一个try/catch、每一个await、每一个if (!worker)判断,都是线上环境踩坑后补上的。例如,worker.send()在 worker 已退出时会抛出Error: write EPIPE,如果不捕获,整个主进程会崩溃。再比如,setTimeout的 2 秒等待,是为了给 FastAPI 的GeneratorExit处理留出时间,避免强行kill -9导致显存泄漏。
4.3 渲染进程安全调用:通过 preload.js 暴露受限的 API
Electron 官方强烈建议禁用nodeIntegration,这意味着渲染进程(你的 Vue 页面)无法直接require('electron')。解决方案是编写一个preload.js,它运行在主进程和渲染进程之间的“隔离区”,可以安全地暴露有限的 API 给前端。
preload.js的内容如下:
const { contextBridge, ipcRenderer } = require('electron'); // 白名单:只允许渲染进程调用这些 IPC 通道 const allowedChannels = [ 'start-chat', 'abort-chat', 'clear-history', 'export-session', 'stream-chunk', // 这是主进程发给前端的,所以是 on,不是 send ]; // 将 ipcRenderer 的部分方法桥接到 window.api contextBridge.exposeInMainWorld('api', { // 发送消息 send: (channel, ...args) => { if (allowedChannels.includes(channel)) { ipcRenderer.send(channel, ...args); } else { throw new Error(`Forbidden channel: ${channel}`); } }, // 监听消息(只允许监听白名单中的 channel) receive: (channel, func) => { if (allowedChannels.includes(channel)) { // 永远使用 ipcRenderer.on,而不是 once,因为 stream-chunk 是多次触发的 ipcRenderer.on(channel, (event, ...args) => func(...args)); } else { throw new Error(`Forbidden channel: ${channel}`); } }, // 移除监听器 removeListener: (channel, func) => { if (allowedChannels.includes(channel)) { ipcRenderer.removeListener(channel, func); } } });在 Vue 组件中,你就可以这样安全地调用:
// 在 setup() 中 onMounted(() => { // 监听流式数据 window.api.receive('stream-chunk', (data) => { if (data.sessionId === currentSessionId.value) { if (data.delta) { currentMessage.value.content += data.delta; } if (data.finished) { isStreaming.value = false; } } }); }); // 发送开始指令 const startChat = () => { window.api.send('start-chat', { messages: chatHistory.value, model: selectedModel.value }); }; // 发送中止指令 const abortChat = () => {