我们来深入探讨如何使用 Electron 构建一个集成“模型上下文协议”(Model Context Protocol - MCP)的聊天室应用,主要目的是学习 MCP 的概念和实现。
核心目标:
- 理解什么是“模型上下文协议”(MCP)及其在 LLM 应用中的重要性。学习如何在一个 Electron 应用中实现 MCP 来管理与 LLM 的对话。掌握 Electron 的基本开发流程(主进程、渲染器进程、IPC 通信)。集成一个 LLM API(以 OpenAI API 为例)来驱动聊天机器人的响应。构建一个基础但功能完整的聊天界面。
免责声明: "Model Context Protocol (MCP)" 并不是一个广泛公认的、标准化的协议名称(如 HTTP 或 TCP/IP)。在这里,我们将其理解为一套管理和组织发送给大语言模型(LLM)的上下文信息的规则、结构或策略。不同的应用或框架可能有自己的实现方式,但其核心目标是相似的:有效地维护对话历史、系统指令和其他相关信息,以获得更准确、更连贯的 LLM 响应。
目录
- 第一部分:理解模型上下文协议 (MCP)
- 1.1 MCP 的核心概念:为什么需要管理上下文?1.2 MCP 在聊天应用中的作用1.3 设计一个基础的 MCP 结构1.4 上下文窗口(Context Window)的挑战与策略
- 2.1 Electron 简介:是什么,为什么选择它?2.2 核心概念:主进程 (Main Process) 与渲染器进程 (Renderer Process)2.3 进程间通信 (IPC):连接两个世界的桥梁2.4 开发环境准备 (Node.js, npm/yarn)2.5 创建第一个 Electron 项目 (使用 Electron Forge 或手动配置)2.6 项目结构概览
- 3.1 选择前端技术栈 (HTML, CSS, Vanilla JS 或框架)3.2 HTML 结构设计 (消息列表、输入框、发送按钮)3.3 CSS 样式实现 (基础布局与美化)3.4 JavaScript 交互逻辑 (DOM 操作、事件监听)
- 4.1 数据结构定义 (JavaScript 对象数组)4.2 在渲染器进程中管理本地消息状态4.3 通过 IPC 将用户消息和 MCP 请求发送到主进程4.4 在主进程中维护和更新完整的 MCP 上下文4.5 实现上下文截断策略 (例如:固定长度、Token 限制)4.6 将 MCP 数据格式化为 LLM API 请求格式
- 5.1 选择 LLM API (以 OpenAI API 为例)5.2 获取 API 密钥及安全管理5.3 在主进程中使用 Node.js 发起 HTTP 请求 (
node-fetch
, axios
)5.4 处理 API 响应 (成功、失败、错误处理)5.5 将 LLM 的响应通过 IPC 发送回渲染器进程5.6 (进阶) 处理流式响应 (Streaming Responses)- 6.1 整体数据流图6.2
main.js
(主进程核心代码)6.3 preload.js
(预加载脚本与 IPC 安全暴露)6.4 renderer.js
(渲染器进程逻辑)6.5 index.html
(UI 结构)6.6 关键函数示例 (MCP 更新、API 调用、IPC 处理)- 7.1 错误处理与用户友好提示7.2 对话历史的持久化存储 (如
electron-store
)7.3 优化 MCP 策略 (例如:摘要、RAG 雏形)7.4 UI 改进 (加载状态、滚动条、代码高亮等)7.5 安全性考量 (API 密钥、数据传输)7.6 打包与分发 Electron 应用- 8.1 项目回顾与关键学习点8.2 常见问题与排错思路8.3 进一步学习 MCP 和 LLM 应用开发的资源
第一部分:理解模型上下文协议 (MCP)
1.1 MCP 的核心概念:为什么需要管理上下文?
大语言模型(LLM)通常是无状态的。这意味着,如果你连续向 LLM 发送两条消息,它默认不会记得第一条消息的内容。每次 API 调用都是独立的。为了让 LLM 能够理解对话的连贯性、记住之前的讨论内容,甚至遵循特定的指令或扮演某个角色,我们需要在每次请求时,将相关的“上下文”信息一起发送给它。
“上下文”通常包括:
- 系统指令 (System Prompt): 定义 LLM 的角色、行为准则、输出格式等。例如:“你是一个乐于助人的 AI 助手。”对话历史 (Conversation History): 用户和 AI 之间之前的交互记录。当前用户输入 (Current User Input): 用户最新发送的消息。(可选) 额外信息 (Additional Context): 比如用户信息、当前时间、文档片段(用于 RAG)等。
模型上下文协议 (MCP) 就是我们用来结构化、组织和管理这些上下文信息的一套方法或规则。没有良好的 MCP,LLM 的响应可能会:
- 前后矛盾: 忘记之前说过的话。缺乏连贯性: 无法理解当前问题与之前讨论的关系。偏离主题: 无法保持对话的焦点。忽略指令: 忘记了系统设定的角色或要求。
1.2 MCP 在聊天应用中的作用
在聊天应用中,MCP 至关重要。用户期望与 AI 的对话是流畅自然的,就像和人聊天一样。MCP 的作用体现在:
- 维护对话状态: 准确记录用户和 AI 的每一轮交互。构建有效输入: 将对话历史和当前输入整合成 LLM 能理解的格式。控制上下文长度: LLM 的输入有长度限制(称为 Context Window),MCP 需要策略来处理过长的对话历史。支持多轮对话: 让 AI 基于之前的讨论进行有意义的回应。实现特定功能: 如角色扮演、遵循特定输出格式等。
1.3 设计一个基础的 MCP 结构
一个常见且有效的 MCP 结构是使用一个对象数组来表示对话历史。每个对象代表对话中的一条消息,并包含关键信息,如:
role
: 消息发送者的角色。常见的角色有:system
: 系统指令,通常放在最前面,用于设定 AI 的行为。user
: 代表用户的输入。assistant
: 代表 AI (LLM) 的回复。(可选) function
/tool
: 用于函数调用/工具使用的场景。content
: 消息的具体文本内容。示例 MCP 结构 (JavaScript 数组):
[ { role: 'system', content: '你是一个翻译助手,将用户输入翻译成英文。' }, { role: 'user', content: '你好,世界!' }, { role: 'assistant', content: 'Hello, world!' }, { role: 'user', content: '今天天气怎么样?' } ]
这个结构清晰地记录了对话的流程和各方发言。在每次需要调用 LLM API 时,我们会构建或更新这个数组,并将其作为输入的一部分发送。
1.4 上下文窗口(Context Window)的挑战与策略
LLM 不是无限记忆的。它们能处理的上下文信息量有一个上限,这叫做“上下文窗口”(Context Window),通常以 Token 数量(大致可以理解为单词或字符块)来衡量。例如,GPT-3.5 Turbo 的某个版本可能有 4k 或 16k 的 Token 限制,GPT-4 则有更大的窗口。
当对话历史变得非常长,超过模型的上下文窗口限制时,就会出现问题。如果不加处理,API 调用可能会失败,或者模型只能看到最近的部分信息,导致“失忆”。
因此,MCP 必须包含上下文管理策略来应对这个问题。常见的策略有:
- 截断 (Truncation):
- 简单截断: 保留最新的 N 条消息,或保留总 Token 数不超过限制的最新消息。这是最简单但也可能丢失重要早期信息的方法。通常会优先丢弃最旧的
user
/assistant
对话,但保留 system
指令。保留首尾: 保留第一条(通常是系统指令)和最后几条消息。- 使用另一个 LLM 调用(或者简单的规则)将较早的对话历史进行总结,用一个简短的摘要替换掉冗长的旧消息。这能保留一些长期记忆,但会增加复杂性和潜在的成本。
tiktoken
for OpenAI)。向量数据库 / RAG (Retrieval-Augmented Generation): 对于需要参考大量外部知识或非常长历史的情况,可以将历史信息或文档存储在向量数据库中。当用户提问时,先检索最相关的片段,然后将这些片段连同最近的对话历史一起注入上下文。这是更高级的技术。对于学习目的,我们将首先实现简单的截断策略。
第二部分:Electron 基础与项目搭建
2.1 Electron 简介:是什么,为什么选择它?
Electron 是一个使用 Web 技术(HTML, CSS, JavaScript)构建跨平台桌面应用程序的框架。它结合了 Chromium(Google Chrome 的开源内核,用于渲染界面)和 Node.js(用于访问底层系统功能和后端逻辑)。
为什么选择 Electron 来学习 MCP?
- 熟悉的技术栈: 如果你熟悉 Web 开发,上手 Electron 会相对容易。强大的 Node.js 后端: Electron 应用内建了一个完整的 Node.js 环境,可以直接在其中运行服务器端逻辑、访问文件系统、调用原生模块、发起网络请求(如调用 LLM API),而无需单独部署后端服务。这对于集成 LLM API 非常方便。跨平台: 一套代码可以打包成 Windows, macOS, Linux 应用。本地运行: 适合构建需要离线功能或希望将数据(如 API 密钥、聊天记录)保留在本地的应用。学习隔离: 将 UI(渲染器进程)和后端逻辑(主进程,包括 MCP 管理和 API 调用)清晰地分开,有助于理解软件架构。
2.2 核心概念:主进程 (Main Process) 与渲染器进程 (Renderer Process)
这是 Electron 最核心的概念:
- 主进程 (Main Process):
- 每个 Electron 应用只有一个主进程。它是应用的入口点,通常运行在
main.js
或类似的文件中。负责创建和管理应用的窗口(BrowserWindow
实例)。拥有完整的 Node.js API 访问权限,可以执行任何 Node.js 能做的事情(文件系统、网络、操作系统交互等)。我们的 MCP 核心逻辑和 LLM API 调用将主要放在主进程中,因为这里可以安全地管理 API 密钥并执行网络请求。- 每个
BrowserWindow
实例都运行在一个独立的渲染器进程中。负责渲染 Web 页面(HTML, CSS, JS),也就是用户看到的界面。本质上是一个嵌入了 Node.js 能力的 Chromium 浏览器环境。出于安全原因,默认情况下渲染器进程不能直接访问 Node.js 的全部 API 或文件系统。 对系统资源的访问需要通过主进程进行。我们的聊天界面 UI 逻辑(显示消息、处理用户输入)将运行在渲染器进程中。2.3 进程间通信 (IPC):连接两个世界的桥梁
由于主进程和渲染器进程是独立的,它们需要一种机制来相互通信。这就是 IPC (Inter-Process Communication)。Electron 提供了几个模块来实现 IPC:
ipcMain
(在主进程中使用): 监听来自渲染器进程的消息,并可以向特定的渲染器进程发送消息。ipcRenderer
(在渲染器进程中使用): 向主进程发送消息,并监听来自主进程的消息。常见的 IPC 模式:
- 渲染器 -> 主进程 (单向): 渲染器发送消息,主进程处理。例如,用户点击发送按钮后,渲染器通过
ipcRenderer.send()
将消息内容发送给主进程。渲染器 -> 主进程 -> 渲染器 (请求/响应): 渲染器发送请求,主进程处理后将结果发回给该渲染器。例如,渲染器请求主进程调用 LLM API,主进程完成后通过 event.reply()
或 webContents.send()
将 AI 的响应发回。这需要使用 ipcRenderer.invoke()
和 ipcMain.handle()
(推荐,基于 Promise) 或 ipcRenderer.on()
配合 event.reply()
。为了安全,通常不直接在渲染器进程中暴露 ipcRenderer
。而是通过 preload.js
脚本选择性地将安全的 IPC 功能暴露给渲染器。
2.4 开发环境准备
你需要安装:
- Node.js: 从 Node.js 官网 下载并安装 LTS 版本。这将同时安装
npm
(Node Package Manager)。(可选) Yarn: 另一个流行的包管理器,可以替代 npm
。 (npm install -g yarn
)代码编辑器: 如 VS Code,它对 JavaScript 和 Electron 有良好的支持。2.5 创建第一个 Electron 项目
推荐使用官方的 create-electron-app
工具或 Electron Forge:
使用 Electron Forge (推荐):
npm init electron-app@latest my-mcp-chat-app --template=webpack cd my-mcp-chat-appnpm install npm run start
Electron Forge 会生成一个包含基本结构、构建脚本和热重载功能的项目模板,极大地简化了开发流程。
手动配置 (更底层,有助于理解):
创建项目目录 my-mcp-chat-app
并进入。
初始化 npm 项目: npm init -y
安装 Electron: npm install --save-dev electron
创建核心文件:
main.js
: 主进程入口。index.html
: 渲染器进程的 UI 页面。renderer.js
: 渲染器进程的 JavaScript。(可选) preload.js
: 预加载脚本。修改 package.json
,添加入口点和启动脚本:
{ "name": "my-mcp-chat-app", "version": "1.0.0", "description": "", "main": "main.js", "scripts": { "start": "electron ." }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "electron": "^28.0.0" }}
编写 main.js
, index.html
, renderer.js
的基本内容 (后续章节会详细介绍)。
2.6 项目结构概览 (以 Electron Forge Webpack 模板为例)
my-mcp-chat-app/├── node_modules/ # 项目依赖├── out/ # 打包输出目录├── src/ # 源代码目录│ ├── main.js # 主进程入口 (或 index.js)│ ├── preload.js # 预加载脚本 (或 index.js)│ ├── renderer.js # 渲染器进程入口 (或 index.js)│ └── index.html # HTML 页面├── forge.config.js # Electron Forge 配置文件├── package.json # 项目元数据和依赖└── webpack.main.config.js # Webpack 主进程配置└── webpack.renderer.config.js # Webpack 渲染器进程配置└── webpack.rules.js # Webpack 规则└── ... (其他配置文件)
不同模板结构可能略有差异,但核心文件(main.js
, preload.js
, renderer.js
, index.html
)的概念是通用的。
第三部分:构建聊天界面 (UI)
我们将使用简单的 HTML, CSS 和 Vanilla JavaScript 来构建界面,专注于核心功能,避免引入前端框架的复杂性,以便更清晰地展示 Electron 和 MCP 的集成。
3.1 选择前端技术栈
- HTML: 定义页面结构。CSS: 设置样式和布局。Vanilla JavaScript: 处理用户交互、DOM 操作以及与主进程的通信。
你当然也可以使用 React, Vue, Angular 等框架,Electron 对它们都有良好的支持。但为了简化学习,我们这里用原生技术。
3.2 HTML 结构设计 (src/index.html
)
<!DOCTYPE html><html><head> <meta charset="UTF-8"> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"> <meta http-equiv="X-Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"> <title>MCP 学习聊天室</title> <link rel="stylesheet" href="styles.css"> </head><body> <div class="chat-container"> <h1>MCP 学习聊天室</h1> <div id="message-list" class="message-list"> </div> <div class="input-area"> <textarea id="message-input" placeholder="输入消息..."></textarea> <button id="send-button">发送</button> </div> </div> </body></html>
- Content-Security-Policy (CSP): 这是 Electron 推荐的安全设置,限制了资源加载的来源,防止 XSS 攻击。
chat-container
: 包裹整个聊天界面的容器。message-list
: 用于显示消息的区域。我们会用 JS 动态添加消息元素。message
: 代表单条消息的容器。user-message
, assistant-message
: 用于区分用户和 AI 消息的 CSS 类,方便设置不同样式。message-sender
, message-content
: 显示发送者和内容。input-area
: 包含输入框和发送按钮。message-input
: textarea
用于多行输入。send-button
: 发送按钮。3.3 CSS 样式实现 (src/styles.css
或放在 <style>
标签内)
创建一个 src/styles.css
文件 (或根据你的模板调整路径) 并添加基础样式:
body { font-family: sans-serif; margin: 0; background-color: #f4f4f4; display: flex; justify-content: center; align-items: center; min-height: 100vh;}.chat-container { width: 90%; max-width: 600px; height: 80vh; background-color: #fff; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); display: flex; flex-direction: column; overflow: hidden; }h1 { text-align: center; padding: 15px; margin: 0; background-color: #444; color: white; font-size: 1.2em;}.message-list { flex-grow: 1; overflow-y: auto; padding: 15px; border-bottom: 1px solid #eee; display: flex; flex-direction: column; }.message { margin-bottom: 15px; max-width: 80%; padding: 10px 15px; border-radius: 18px; line-height: 1.4;}.user-message { background-color: #dcf8c6; align-self: flex-end; border-bottom-right-radius: 5px;}.assistant-message { background-color: #eee; align-self: flex-start; border-bottom-left-radius: 5px;}.message-sender { font-weight: bold; font-size: 0.8em; color: #555; display: block; margin-bottom: 3px;}.message-content { margin: 0; word-wrap: break-word; }.input-area { display: flex; padding: 15px; border-top: 1px solid #eee; background-color: #f9f9f9;}#message-input { flex-grow: 1; padding: 10px; border: 1px solid #ccc; border-radius: 20px; resize: none; margin-right: 10px; font-size: 1em; height: 40px; box-sizing: border-box; }#send-button { padding: 10px 20px; background-color: #007bff; color: white; border: none; border-radius: 20px; cursor: pointer; font-size: 1em; transition: background-color 0.2s ease;}#send-button:hover { background-color: #0056b3;}#send-button:disabled { background-color: #ccc; cursor: not-allowed;}
这些样式提供了一个基础的、类似常见聊天应用的布局。
3.4 JavaScript 交互逻辑 (src/renderer.js
)
这个文件负责处理用户界面上的交互:
- 获取 DOM 元素的引用。监听发送按钮的点击事件和输入框的回车事件。获取用户输入的消息。将用户消息显示在界面上。通过 IPC 将用户消息发送给主进程处理 (这是与 MCP 和 LLM 集成的关键)。监听来自主进程的 AI 响应消息,并将其显示在界面上。(可选) 在等待 AI 响应时显示加载状态。
const messageList = document.getElementById('message-list');const messageInput = document.getElementById('message-input');const sendButton = document.getElementById('send-button');function displayMessage(sender, content, type) { const messageElement = document.createElement('div'); messageElement.classList.add('message', `${type}-message`); const senderElement = document.createElement('span'); senderElement.classList.add('message-sender'); senderElement.textContent = sender === 'user' ? '你:' : 'AI:'; const contentElement = document.createElement('p'); contentElement.classList.add('message-content'); contentElement.textContent = content; messageElement.appendChild(contentElement); messageList.appendChild(messageElement); messageList.scrollTop = messageList.scrollHeight;}function sendMessage() { const messageText = messageInput.value.trim(); if (messageText === '') { return; } displayMessage('user', messageText, 'user'); messageInput.value = ''; setSendingState(true); if (window.electronAPI && window.electronAPI.sendMessageToMain) { console.log('Renderer: Sending message to main:', messageText); window.electronAPI.sendMessageToMain(messageText); } else { console.error("electronAPI or sendMessageToMain not found! Check preload.js"); displayMessage('system', '错误:无法连接到后台服务。', 'assistant'); setSendingState(false); }}function setSendingState(isSending) { messageInput.disabled = isSending; sendButton.disabled = isSending; if (isSending) { sendButton.textContent = '发送中...'; } else { sendButton.textContent = '发送'; messageInput.focus(); }}sendButton.addEventListener('click', sendMessage);messageInput.addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendMessage(); }});if (window.electronAPI && window.electronAPI.handleAIMessage) { window.electronAPI.handleAIMessage((event, aiMessage) => { console.log('Renderer: Received AI message from main:', aiMessage); displayMessage('assistant', aiMessage, 'assistant'); setSendingState(false); }); window.electronAPI.handleError((event, errorMessage) => { console.error('Renderer: Received error from main:', errorMessage); displayMessage('system', `错误: ${errorMessage}`, 'assistant'); setSendingState(false); });} else { console.error("electronAPI or handleAIMessage/handleError not found! Check preload.js"); displayMessage('system', '错误:无法接收后台消息。', 'assistant');}setSendingState(false); console.log('Renderer process loaded.');
关键点:
displayMessage
函数: 封装了将消息添加到 UI 的逻辑,包括创建 DOM 元素、添加 CSS 类和滚动到底部。sendMessage
函数: 获取输入、显示用户消息、清空输入框、调用 window.electronAPI.sendMessageToMain(messageText)
将消息发送到主进程。setSendingState
函数: 控制输入框和按钮的禁用状态,提供用户反馈。window.electronAPI.handleAIMessage(...)
: 注册一个回调函数,当主进程通过 IPC 发送 AI 响应回来时,这个回调会被触发,然后调用 displayMessage
显示 AI 消息并恢复输入状态。window.electronAPI.handleError(...)
: 注册错误处理回调。window.electronAPI
: 这是我们将在 preload.js
中定义的对象,用于安全地暴露 IPC 功能给渲染器进程。现在,我们有了 UI 的骨架和基础交互。下一步是在 Electron 中实现 MCP 逻辑和连接主/渲染器进程。
第四部分:在 Electron 中实现 MCP 逻辑
这部分是核心,我们将连接主进程和渲染器进程,并在主进程中实现 MCP 上下文管理。
4.1 数据结构定义 (回顾)
我们将在主进程中维护一个数组来存储 MCP 上下文,结构如下:
let conversationHistory = [ { role: 'system', content: '你是一个简洁明了的 AI 助手。' }];
4.2 在渲染器进程中管理本地消息状态 (已在 renderer.js
中实现)
renderer.js
已经实现了将用户输入和 AI 响应显示在界面上的逻辑。它本身维护了 UI 上的消息列表状态。但它不维护用于发送给 LLM 的完整 MCP 上下文。
4.3 通过 IPC 将用户消息和 MCP 请求发送到主进程
我们需要设置 IPC 通道。这涉及三个文件:main.js
, preload.js
, renderer.js
。
1. preload.js
(安全桥梁)
preload.js
脚本在渲染器进程加载 Web 页面之前运行,并且可以访问 Node.js API 和 DOM API。它的主要作用是选择性地、安全地将主进程的功能(通过 IPC)暴露给渲染器进程,而不是直接暴露 ipcRenderer
或其他 Node.js 模块。
const { contextBridge, ipcRenderer } = require('electron');console.log('Preload script loaded.');contextBridge.exposeInMainWorld('electronAPI', { sendMessageToMain: (message) => { console.log('Preload: Sending "send-message" IPC event with:', message); ipcRenderer.send('send-message', message); }, handleAIMessage: (callback) => { console.log('Preload: Registering handler for "ai-reply" IPC event.'); ipcRenderer.on('ai-reply', (event, ...args) => callback(event, ...args)); }, handleError: (callback) => { console.log('Preload: Registering handler for "app-error" IPC event.'); ipcRenderer.on('app-error', (event, ...args) => callback(event, ...args)); } });
contextBridge.exposeInMainWorld('electronAPI', ...)
: 这是关键。它创建了一个 window.electronAPI
对象,渲染器进程的 JavaScript (renderer.js
) 可以安全地访问这个对象及其定义的方法。ipcRenderer.send('channel-name', data)
: 向主进程发送消息。ipcRenderer.on('channel-name', callback)
: 监听来自主进程的指定通道的消息。回调函数会接收到 event
对象和主进程发送的数据。2. main.js
(主进程 - 监听 IPC)
主进程需要监听 preload.js
中使用的 IPC 通道 ('send-message'
)。
const { app, BrowserWindow, ipcMain } = require('electron');const path = require('path');let conversationHistory = [ { role: 'system', content: '你是一个使用 Electron 构建的聊天机器人,请简洁回答。' }];const MAX_CONTEXT_MESSAGES = 10; async function callLLMApi(context) { console.log("Main: Calling LLM API with context:", JSON.stringify(context, null, 2)); await new Promise(resolve => setTimeout(resolve, 1500)); const lastUserMessage = context[context.length - 1]?.content || "空消息"; const aiResponse = `我是 AI,收到了你的消息:"${lastUserMessage}"。MCP 学习进展如何?`; console.log("Main: Mock LLM API response:", aiResponse); return aiResponse; }function createWindow() { const mainWindow = new BrowserWindow({ width: 800, height: 600, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, } }); if (process.env.NODE_ENV === 'development') { mainWindow.loadFile(path.join(__dirname, '../src/index.html')); } else { mainWindow.loadFile(path.join(__dirname, '../src/index.html')); } mainWindow.webContents.openDevTools(); ipcMain.on('send-message', async (event, userMessage) => { console.log('Main: Received "send-message" IPC event with:', userMessage); conversationHistory.push({ role: 'user', content: userMessage }); applyContextTruncation(); try { const aiReply = await callLLMApi(conversationHistory); conversationHistory.push({ role: 'assistant', content: aiReply }); applyContextTruncation(); console.log('Main: Sending "ai-reply" IPC event back to renderer with:', aiReply); event.sender.send('ai-reply', aiReply); } catch (error) { console.error('Main: Error processing message or calling LLM API:', error); event.sender.send('app-error', `处理消息时出错: ${error.message}`); } }); } app.whenReady().then(() => { createWindow(); app.on('activate', function () { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });});app.on('window-all-closed', function () { if (process.platform !== 'darwin') app.quit();});function applyContextTruncation() { const systemPrompt = conversationHistory.find(msg => msg.role === 'system'); const chatMessages = conversationHistory.filter(msg => msg.role !== 'system'); if (chatMessages.length > MAX_CONTEXT_MESSAGES) { const messagesToKeep = chatMessages.slice(-MAX_CONTEXT_MESSAGES); conversationHistory = systemPrompt ? [systemPrompt, ...messagesToKeep] : messagesToKeep; console.log(`Main: Context truncated. Kept ${conversationHistory.length} messages.`); }}
关键更改和添加:
conversationHistory
: 在主进程中定义,用于存储完整的 MCP 上下文。MAX_CONTEXT_MESSAGES
: 定义上下文窗口大小(以消息数量计,简单策略)。callLLMApi
函数: 目前是模拟函数,接收上下文,等待一下,然后返回一个固定的 AI 回复。我们将在下一部分替换它。ipcMain.on('send-message', ...)
: 监听来自渲染器的 'send-message'
事件。- 接收到用户消息 (
userMessage
)。将其添加到 conversationHistory
。调用 applyContextTruncation
来管理上下文长度。调用 (模拟的) callLLMApi
,传入当前 conversationHistory
。将 AI 返回的 aiReply
也添加到 conversationHistory
。再次调用截断函数 (可选但安全)。使用 event.sender.send('ai-reply', aiReply)
将 AI 回复通过 'ai-reply'
通道发回给发送消息的那个渲染器窗口。添加了 try...catch
来处理潜在错误,并通过 'app-error'
通道发送错误信息。applyContextTruncation
函数: 实现了简单的截断逻辑:保留 system prompt (如果存在),然后只保留最新的 MAX_CONTEXT_MESSAGES
条用户/助手消息。3. renderer.js
(调用暴露的 API)
我们之前写的 renderer.js
已经在使用 window.electronAPI
了,现在它应该可以正常工作了:
window.electronAPI.sendMessageToMain(messageText)
会触发 preload.js
中的 ipcRenderer.send('send-message', ...)
。window.electronAPI.handleAIMessage((event, aiMessage) => { ... })
会注册一个回调,当 main.js
中的 event.sender.send('ai-reply', ...)
执行时,这个回调会被 preload.js
中的 ipcRenderer.on('ai-reply', ...)
触发。至此,我们已经打通了渲染器 -> 主进程 -> (模拟 LLM) -> 主进程 -> 渲染器的完整流程,并且在主进程中初步实现了 MCP 上下文的存储和简单截断管理。
4.4 在主进程中维护和更新完整的 MCP 上下文 (已在 main.js
中实现)
main.js
中的 conversationHistory
数组现在就是我们维护的 MCP 上下文。每次用户发送消息和 AI 回复后,都会更新这个数组。
4.5 实现上下文截断策略 (已在 main.js
中实现)
applyContextTruncation
函数实现了基于消息数量的简单截断。
更复杂的截断 (基于 Token):
如果需要基于 Token 的精确截断,你需要:
- 安装一个 Tokenizer 库,如
tiktoken
(适用于 OpenAI 模型): npm install tiktoken
或 yarn add tiktoken
。在 main.js
中引入并使用它来计算每条消息的 Token 数。修改 applyContextTruncation
逻辑,累加 Token 数,确保总数不超过模型的限制。
4.6 将 MCP 数据格式化为 LLM API 请求格式
我们当前的 conversationHistory
数组 ([{ role: 'user', content: '...' }, ...]
) 已经非常接近许多 LLM API(如 OpenAI)要求的格式了。在 callLLMApi
函数内部,当准备发送请求时,这个数组可以直接用作 API 请求体中 messages
字段的值。
第五部分:集成大语言模型 (LLM) API
现在,我们将用真实的 LLM API 调用替换掉 main.js
中的模拟函数。这里以 OpenAI API (GPT 系列模型) 为例。
5.1 选择 LLM API
- OpenAI API: 常用,性能强大,提供多种模型 (GPT-4, GPT-3.5-Turbo)。需要注册账号并获取 API Key,按使用量付费。Anthropic Claude API: 另一个强大的竞争者,有不同的特点和定价。Google Gemini API: Google 提供的模型 API。本地模型 (通过 Ollama, LM Studio 等): 如果你希望在本地运行模型(无需 API Key,数据不离开本地),可以使用 Ollama 或 LM Studio 等工具启动一个本地服务,它们通常提供与 OpenAI 兼容的 API 接口。这样,你的 Electron 应用只需将请求发送到本地地址(如
http://localhost:11434/v1/chat/completions
)。我们将使用 OpenAI API 作为示例。
5.2 获取 API 密钥及安全管理
- 注册 OpenAI 账户: 访问 OpenAI 官网 并创建一个账户。获取 API Key: 登录后,在 API Keys 页面创建一个新的 Secret Key。这个 Key 非常重要,绝不能直接硬编码在代码中!安全存储 API Key:
- 环境变量: 这是推荐的方式。在启动 Electron 应用前设置一个环境变量,例如
OPENAI_API_KEY
。- 在终端 (Linux/macOS):
export OPENAI_API_KEY='your_key_here'
在终端 (Windows CMD): set OPENAI_API_KEY=your_key_here
在终端 (Windows PowerShell): $env:OPENAI_API_KEY='your_key_here'
然后从这个终端启动你的 Electron 应用 (npm run start
)。在 main.js
中可以通过 process.env.OPENAI_API_KEY
读取。.env
文件: 使用 dotenv
包 (npm install dotenv
)。在项目根目录创建 .env
文件,写入 OPENAI_API_KEY=your_key_here
。在 main.js
顶部加载它:require('dotenv').config();
。确保将 .env
文件添加到 .gitignore
中,防止意外提交!Electron Store (不推荐用于敏感密钥): 可以用 electron-store
存储配置,但对于 API Key 这种敏感信息,环境变量或专门的密钥管理方案更好。我们将使用环境变量的方式。
5.3 在主进程中使用 Node.js 发起 HTTP 请求
我们需要一个 HTTP 客户端库来向 OpenAI API 发送 POST 请求。node-fetch
(v2 支持 CommonJS) 或 axios
都是不错的选择。
安装 node-fetch
(v2):npm install node-fetch@2
(v3+ 是 ESM only,在默认的 CommonJS Electron 项目中使用 v2 更方便)
修改 main.js
中的 callLLMApi
函数:
const fetch = require('node-fetch'); const OPENAI_API_KEY = process.env.OPENAI_API_KEY;if (!OPENAI_API_KEY) { console.error("错误:未设置 OPENAI_API_KEY 环境变量!"); }const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';const MODEL_NAME = 'gpt-3.5-turbo'; async function callLLMApi(context) { console.log(`Main: Calling OpenAI API (${MODEL_NAME}) with context size: ${context.length}`); if (!OPENAI_API_KEY) { throw new Error("OpenAI API Key 未配置。"); } try { const response = await fetch(OPENAI_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${OPENAI_API_KEY}` }, body: JSON.stringify({ model: MODEL_NAME, messages: context, }) }); if (!response.ok) { const errorBody = await response.json(); console.error('Main: OpenAI API Error:', response.status, response.statusText, errorBody); throw new Error(`OpenAI API 请求失败: ${response.status} ${response.statusText} - ${errorBody?.error?.message || '未知错误'}`); } const data = await response.json(); if (data.choices && data.choices.length > 0 && data.choices[0].message && data.choices[0].message.content) { const aiResponse = data.choices[0].message.content.trim(); console.log("Main: OpenAI API Response received:", aiResponse); return aiResponse; } else { console.error("Main: OpenAI API 响应格式不符合预期:", data); throw new Error("无法从 API 响应中提取有效的回复内容。"); } } catch (error) { console.error('Main: Error calling OpenAI API:', error); throw error; }}
关键更改:
- 引入
node-fetch
。从 process.env.OPENAI_API_KEY
读取密钥,并添加检查。定义 API URL 和模型名称。使用 fetch
发送 POST 请求:- 设置
Authorization
header。将我们的 context
(即 conversationHistory
) 数组直接放在请求体的 messages
字段中。response.ok
来判断请求是否成功 (HTTP 状态码 2xx)。如果失败,尝试解析错误体并抛出更详细的错误。如果成功,解析 JSON 响应体 (response.json()
)。从响应的 data.choices[0].message.content
中提取 AI 的回复文本。添加了更健壮的错误处理和日志记录。5.4 处理 API 响应 (已在 callLLMApi
和 IPC handler 中实现)
- 成功:
callLLMApi
函数解析响应并返回 AI 的文本内容。ipcMain.on('send-message', ...)
的回调接收到这个内容,将其添加到 conversationHistory
,并通过 event.sender.send('ai-reply', aiReply)
发送给渲染器。失败/错误:fetch
本身可能因网络问题失败 (进入 catch
块)。API 可能返回非 2xx 状态码 (如 401 未授权, 400 错误请求, 429 请求过多, 500 服务器错误) (被 !response.ok
捕获)。API 响应格式可能不符合预期 (进入 else
或 catch
块)。在这些情况下,callLLMApi
会抛出错误。这个错误会在 ipcMain.on
的 async
回调中被 try...catch
块捕获,然后通过 event.sender.send('app-error', ...)
将错误消息发送给渲染器。renderer.js
中的 window.electronAPI.handleError
回调会接收并显示这个错误。5.5 将 LLM 的响应通过 IPC 发送回渲染器进程 (已实现)
event.sender.send('ai-reply', aiReply)
和 event.sender.send('app-error', errorMessage)
负责将成功结果或错误信息发送回渲染器。
5.6 (进阶) 处理流式响应 (Streaming Responses)
对于聊天应用,用户体验通常可以通过流式响应得到改善:AI 生成回复时,逐字或逐句地显示出来,而不是等待整个回复生成完毕。
这需要:
- 修改 API 调用: 在
callLLMApi
的 fetch
请求体中设置 stream: true
。处理流式数据: API 不会一次性返回 JSON,而是返回一个 Server-Sent Events (SSE) 流。你需要读取这个流,解析每一块数据(通常是 JSON 片段),提取其中的 delta.content
(增量内容)。修改 IPC: 不能只在最后发送一次 ai-reply
。需要定义新的 IPC 通道,例如 ai-reply-stream-chunk
,每收到一小块文本就通过这个通道发送给渲染器。还需要一个 ai-reply-stream-end
通道来告知渲染器流结束了。修改渲染器: renderer.js
需要监听新的流式通道。收到 chunk
时,将文本追加到当前 AI 消息的末尾。收到 end
时,标记消息完成,并恢复输入状态。实现流式响应会显著增加复杂度,但能极大提升交互感。 这里提供一个概念性的 main.js
修改思路:
async function handleStreamedLLMResponse(context, eventSender) { if (!OPENAI_API_KEY) throw new Error("OpenAI API Key 未配置。"); const response = await fetch(OPENAI_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${OPENAI_API_KEY}` }, body: JSON.stringify({ model: MODEL_NAME, messages: context, stream: true, }) }); if (!response.ok) { const errorBody = await response.json(); throw new Error(`OpenAI API 请求失败: ${response.status} ${response.statusText} - ${errorBody?.error?.message || '未知错误'}`); } let fullResponse = ""; const reader = response.body.getReader(); const decoder = new TextDecoder(); try { while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); const lines = chunk.split('\n\n'); for (const line of lines) { if (line.startsWith('data: ')) { const dataStr = line.substring(6).trim(); if (dataStr === '[DONE]') { eventSender.send('ai-reply-stream-end'); console.log("Main: Stream ended."); return fullResponse; } try { const jsonData = JSON.parse(dataStr); const deltaContent = jsonData.choices?.[0]?.delta?.content; if (deltaContent) { eventSender.send('ai-reply-stream-chunk', deltaContent); fullResponse += deltaContent; } } catch (parseError) { console.warn('Main: Failed to parse stream chunk JSON:', dataStr, parseError); } } } } } catch (streamError) { console.error("Main: Error reading stream:", streamError); eventSender.send('app-error', `读取 AI 响应流时出错: ${streamError.message}`); throw streamError; } finally { reader.releaseLock(); } eventSender.send('ai-reply-stream-end'); return fullResponse;}ipcMain.on('send-message', async (event, userMessage) => { console.log('Main: Received "send-message" IPC event with:', userMessage); conversationHistory.push({ role: 'user', content: userMessage }); applyContextTruncation(); try { const fullAIReply = await handleStreamedLLMResponse(conversationHistory, event.sender); if (fullAIReply) { conversationHistory.push({ role: 'assistant', content: fullAIReply }); applyContextTruncation(); } else { console.warn("Main: Stream completed but no content accumulated."); } } catch (error) { console.error('Main: Error processing message or calling LLM API (stream):', error); if (!event.sender.isDestroyed()) { event.sender.send('app-error', `处理消息时出错: ${error.message}`); event.sender.send('ai-reply-stream-end'); } }});
注意: 流式处理实现细节较多,需要仔细处理各种边界情况和错误。对于初学,可以先实现非流式版本。
第六部分:整合流程与代码示例
现在我们将前面各部分的代码整合起来,展示关键文件的核心内容。
6.1 整体数据流图
sequenceDiagram participant R as Renderer (UI - renderer.js) participant P as Preload (preload.js) participant M as Main (main.js) participant LLM as LLM API (e.g., OpenAI) R->>P: User types message & clicks Send P->>M: ipcRenderer.send('send-message', userMsg) Note over M: Add userMsg to conversationHistory Note over M: Apply context truncation (MCP) M->>LLM: fetch(API_URL, { messages: conversationHistory, ... }) alt Successful Response LLM-->>M: API Response (JSON with AI reply) Note over M: Add aiReply to conversationHistory Note over M: Apply context truncation (MCP) M->>P: event.sender.send('ai-reply', aiReply) P->>R: ipcRenderer.on('ai-reply') callback fired Note over R: Display AI reply in UI Note over R: Enable input else API Error or Network Error LLM-->>M: Error (e.g., 4xx, 5xx) or Fetch fails Note over M: Catch error M->>P: event.sender.send('app-error', errorMsg) P->>R: ipcRenderer.on('app-error') callback fired Note over R: Display error message in UI Note over R: Enable input end
流式响应数据流 (简化版):
sequenceDiagram participant R as Renderer (UI - renderer.js) participant P as Preload (preload.js) participant M as Main (main.js) participant LLM as LLM API (e.g., OpenAI) R->>P: User types message & clicks Send P->>M: ipcRenderer.send('send-message', userMsg) Note over M: Add userMsg to conversationHistory Note over M: Apply context truncation (MCP) M->>LLM: fetch(API_URL, { messages: conversationHistory, stream: true }) LLM-->>M: SSE Stream starts (multiple data chunks) loop Receive Chunks M->>P: event.sender.send('ai-reply-stream-chunk', deltaContent) P->>R: ipcRenderer.on('ai-reply-stream-chunk') callback fired Note over R: Append deltaContent to current AI message div end M->>P: Stream ends (e.g., receives [DONE] or error) Note over M: Accumulate full reply in main process Note over M: Add full reply to conversationHistory Note over M: Apply context truncation (MCP) M->>P: event.sender.send('ai-reply-stream-end') P->>R: ipcRenderer.on('ai-reply-stream-end') callback fired Note over R: Finalize AI message display Note over R: Enable input opt Error during stream M->>P: event.sender.send('app-error', errorMsg) P->>R: ipcRenderer.on('app-error') callback fired Note over R: Display error Note over R: Enable input (possibly after stream-end signal) end
6.2 main.js
(主进程核心代码 - 非流式版本)
const { app, BrowserWindow, ipcMain } = require('electron');const path = require('path');const fetch = require('node-fetch');const OPENAI_API_KEY = process.env.OPENAI_API_KEY;const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';const MODEL_NAME = 'gpt-3.5-turbo';const MAX_CONTEXT_MESSAGES = 10; let conversationHistory = [ { role: 'system', content: '你是一个基于 Electron 和 MCP 概念构建的 AI 聊天助手。' }];if (!OPENAI_API_KEY) { console.error("错误:启动前必须设置 OPENAI_API_KEY 环境变量!"); }async function callLLMApi(context) { console.log(`Main: Calling OpenAI API (${MODEL_NAME}) with context size: ${context.length}`); if (!OPENAI_API_KEY) throw new Error("OpenAI API Key 未配置。"); try { const response = await fetch(OPENAI_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${OPENAI_API_KEY}` }, body: JSON.stringify({ model: MODEL_NAME, messages: context, }) }); if (!response.ok) { const errorBody = await response.json().catch(() => ({})); console.error('Main: OpenAI API Error:', response.status, response.statusText, errorBody); throw new Error(`OpenAI API 请求失败: ${response.status} ${response.statusText} - ${errorBody?.error?.message || '无法解析错误详情'}`); } const data = await response.json(); if (data.choices?.[0]?.message?.content) { const aiResponse = data.choices[0].message.content.trim(); console.log("Main: OpenAI API Response received."); return aiResponse; } else { console.error("Main: OpenAI API 响应格式不符合预期:", data); throw new Error("无法从 API 响应中提取有效的回复内容。"); } } catch (error) { console.error('Main: Error calling OpenAI API:', error); throw error; }}function applyContextTruncation() { const systemPrompt = conversationHistory.find(msg => msg.role === 'system'); const chatMessages = conversationHistory.filter(msg => msg.role !== 'system'); if (chatMessages.length > MAX_CONTEXT_MESSAGES) { const messagesToKeep = chatMessages.slice(-MAX_CONTEXT_MESSAGES); conversationHistory = systemPrompt ? [systemPrompt, ...messagesToKeep] : messagesToKeep; console.log(`Main: Context truncated. Kept ${conversationHistory.length} messages.`); }}function createWindow() { const mainWindow = new BrowserWindow({ width: 800, height: 650, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, } }); mainWindow.loadFile(path.join(__dirname, '../src/index.html')) .catch(err => console.error('Failed to load HTML:', err)); if (process.env.NODE_ENV !== 'production') { mainWindow.webContents.openDevTools(); } ipcMain.on('send-message', async (event, userMessage) => { console.log('Main: Received "send-message":', userMessage); conversationHistory.push({ role: 'user', content: userMessage }); applyContextTruncation(); try { const aiReply = await callLLMApi(conversationHistory); conversationHistory.push({ role: 'assistant', content: aiReply }); applyContextTruncation(); if (!event.sender.isDestroyed()) { console.log('Main: Sending "ai-reply" back to renderer.'); event.sender.send('ai-reply', aiReply); } } catch (error) { console.error('Main: Error processing message:', error); if (!event.sender.isDestroyed()) { event.sender.send('app-error', `处理消息时出错: ${error.message}`); } } });}app.whenReady().then(createWindow);app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); }});app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); }});
6.3 preload.js
(预加载脚本与 IPC 安全暴露)
const { contextBridge, ipcRenderer } = require('electron');console.log('Preload script loaded.');contextBridge.exposeInMainWorld('electronAPI', { sendMessageToMain: (message) => { ipcRenderer.send('send-message', message); }, handleAIMessage: (callback) => { ipcRenderer.removeAllListeners('ai-reply'); ipcRenderer.on('ai-reply', (event, ...args) => callback(event, ...args)); }, handleError: (callback) => { ipcRenderer.removeAllListeners('app-error'); ipcRenderer.on('app-error', (event, ...args) => callback(event, ...args)); } });
6.4 renderer.js
(渲染器进程逻辑)
(与第三部分 3.4 中的代码基本一致,确保 window.electronAPI
的调用和回调注册正确无误。)
const messageList = document.getElementById('message-list');const messageInput = document.getElementById('message-input');const sendButton = document.getElementById('send-button');let currentAssistantMessageElement = null; function displayMessage(sender, content, type, isComplete = true) { const messageElement = document.createElement('div'); messageElement.classList.add('message', `${type}-message`); const contentElement = document.createElement('p'); contentElement.classList.add('message-content'); contentElement.textContent = content; messageElement.appendChild(contentElement); messageList.appendChild(messageElement); messageList.scrollTop = messageList.scrollHeight; if (type === 'assistant' && !isComplete) { currentAssistantMessageElement = contentElement; } else { currentAssistantMessageElement = null; }}function appendToCurrentAssistantMessage(textChunk) { if (currentAssistantMessageElement) { currentAssistantMessageElement.textContent += textChunk; messageList.scrollTop = messageList.scrollHeight; } else { console.warn("Tried to append chunk but no active assistant message element."); }}function sendMessage() { const messageText = messageInput.value.trim(); if (messageText === '' || sendButton.disabled) return; displayMessage('user', messageText, 'user'); messageInput.value = ''; setSendingState(true); if (window.electronAPI?.sendMessageToMain) { console.log('Renderer: Sending message to main:', messageText); window.electronAPI.sendMessageToMain(messageText); } else { console.error("electronAPI.sendMessageToMain not found!"); displayMessage('system', '错误:无法发送消息。', 'assistant'); setSendingState(false); }}function setSendingState(isSending) { messageInput.disabled = isSending; sendButton.disabled = isSending; sendButton.textContent = isSending ? '...' : '发送'; if (!isSending) messageInput.focus();}sendButton.addEventListener('click', sendMessage);messageInput.addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); sendMessage(); }});if (window.electronAPI) { window.electronAPI.handleAIMessage((event, aiMessage) => { console.log('Renderer: Received AI message:', aiMessage); displayMessage('assistant', aiMessage, 'assistant'); setSendingState(false); }); window.electronAPI.handleError((event, errorMessage) => { console.error('Renderer: Received error:', errorMessage); displayMessage('system', `错误: ${errorMessage}`, 'assistant'); setSendingState(false); currentAssistantMessageElement = null; }); } else { console.error("window.electronAPI is not defined! Check preload script and contextIsolation settings."); displayMessage('system', '错误:应用初始化失败。', 'assistant');}setSendingState(false);console.log('Renderer process initialized.');
6.5 index.html
(UI 结构)
(与第三部分 3.2 中的代码一致)
6.6 关键函数示例 (已分散在上述文件中)
- MCP 更新:
main.js
中的 conversationHistory.push(...)
MCP 截断: main.js
中的 applyContextTruncation()
API 调用: main.js
中的 callLLMApi()
(非流式或流式版本)IPC 发送 (Renderer->Main): renderer.js
调用 window.electronAPI.sendMessageToMain()
-> preload.js
执行 ipcRenderer.send()
IPC 监听 (Main): main.js
中的 ipcMain.on('send-message', ...)
IPC 发送 (Main->Renderer): main.js
中的 event.sender.send('ai-reply', ...)
或 event.sender.send('app-error', ...)
(或流式 chunk/end)IPC 监听 (Renderer): renderer.js
调用 window.electronAPI.handleAIMessage(...)
或 handleError(...)
(或流式) -> preload.js
执行 ipcRenderer.on(...)
注册回调第七部分:进阶功能与优化
这个基础聊天室已经可以运行并让你学习 MCP 了,但还可以添加许多改进:
7.1 错误处理与用户友好提示
- 更具体的错误: 在
main.js
的 catch
块中,可以根据不同的错误类型(网络错误、API 认证错误、API 限流错误、解析错误等)向渲染器发送更具体的错误代码或消息。UI 提示: renderer.js
收到错误时,除了在消息列表显示,还可以在 UI 顶部或底部显示一个短暂的错误提示条 (Toast Notification)。重试机制: 对于临时的网络错误或 API 限流 (如 429 Too Many Requests),可以在 main.js
中实现简单的自动重试逻辑(带延迟和次数限制)。7.2 对话历史的持久化存储
目前 conversationHistory
只存在于内存中,应用关闭后就丢失了。
electron-store
: 一个简单易用的库,用于在本地 JSON 文件中持久化存储数据。- 安装:
npm install electron-store
使用:const Store = require('electron-store');const store = new Store();let conversationHistory = store.get('chatHistory', [ { role: 'system', content: '...' } ]);function saveHistory() { store.set('chatHistory', conversationHistory);}
数据库: 对于更复杂的场景或大量数据,可以使用 SQLite (sqlite3
npm 包) 或其他嵌入式数据库。7.3 优化 MCP 策略
- 基于 Token 的截断: 如前所述,使用
tiktoken
实现更精确的控制。摘要: 当历史变得很长时,定期调用 LLM 对旧的部分进行摘要,用摘要替换旧消息。这需要额外的 API 调用和逻辑。if (historyTooLong) { const oldMessages = conversationHistory.slice(1, -KEEP_RECENT_COUNT); const summaryPrompt = `请将以下对话摘要成一段话,保留关键信息:\n${JSON.stringify(oldMessages)}`; const summary = await callLLMApi([{ role: 'user', content: summaryPrompt }]); conversationHistory = [ conversationHistory[0], { role: 'system', content: `之前的对话摘要: ${summary}` }, ...conversationHistory.slice(-KEEP_RECENT_COUNT) ];}
RAG (Retrieval-Augmented Generation) 雏形: 如果聊天需要引用特定文档或知识库,可以将文档内容分块、向量化(需要 embedding 模型 API 调用,如 OpenAI Embeddings API)并存入向量数据库(如 ChromaDB、FAISS 的本地实现,或专门的 JS 库)。用户提问时,先将其问题向量化,在数据库中搜索最相似的文本块,然后将这些文本块作为上下文的一部分,连同对话历史一起发送给 LLM。这是一个相当高级的主题。7.4 UI 改进
- 加载状态: 在
renderer.js
的 setSendingState
中,除了禁用按钮,可以在 AI 消息区域显示一个加载动画或占位符。滚动条样式: 美化 message-list
的滚动条。代码高亮: 如果 AI 的回复包含代码块,使用 highlight.js
或类似库进行语法高亮。需要在 displayMessage
中检测代码块并应用高亮。Markdown 支持: 使用 marked
或类似库将 AI 回复中的 Markdown 格式渲染为 HTML。注意要进行 XSS 清理(如使用 DOMPurify
)。输入框自动调整高度: 让 textarea
根据内容自动增高。复制按钮: 为 AI 的消息添加一个“复制”按钮。7.5 安全性考量
- API 密钥: 强调绝不硬编码,使用环境变量或安全的密钥管理方式。输入清理: 虽然 LLM API 通常能处理各种输入,但在显示用户输入或 AI 回复(特别是渲染 HTML 时)要警惕 XSS 攻击。使用
textContent
而不是 innerHTML
来显示纯文本内容可以避免大部分问题。如果需要渲染 HTML(如 Markdown),必须使用 DOMPurify
等库进行清理。IPC 安全: 坚持使用 contextBridge
和 preload.js
,避免在渲染器中开启 nodeIntegration
或禁用 contextIsolation
。只暴露必要且安全的函数给渲染器。依赖项安全: 定期更新 npm 依赖 (npm audit fix
),关注安全漏洞。7.6 打包与分发 Electron 应用
当你准备好分享你的应用时,需要将其打包成可执行文件。
- Electron Forge: 如果你使用了 Forge,打包非常简单:
npm run make
或 yarn make
这会根据 forge.config.js
的配置,在 out
目录下生成适用于当前操作系统的安装包或可执行文件(如 .exe
, .dmg
, .deb
, .rpm
)。你可以配置为特定平台打包。- 安装:
npm install --save-dev electron-builder
配置 package.json
中的 build
字段。运行: npx electron-builder
打包过程会处理代码压缩、依赖捆绑、图标设置、签名(需要证书)等。
第八部分:总结与后续学习
8.1 项目回顾与关键学习点
通过构建这个 Electron 聊天室,你应该学习和实践了:
- MCP 核心思想: 理解了为什么需要管理 LLM 上下文,以及 MCP 的基本组成(System Prompt, History, User Input)。MCP 实现: 学会了使用 JavaScript 数组来组织上下文,并实现了简单的截断策略来处理上下文窗口限制。Electron 基础: 掌握了主进程、渲染器进程的概念,以及如何使用 IPC(通过
preload.js
和 contextBridge
)进行安全通信。LLM API 集成: 了解了如何从 Electron 主进程安全地调用外部 API(如 OpenAI),处理认证、请求和响应(包括错误处理)。前后端分离思想: 即使在桌面应用中,也将 UI 逻辑(Renderer)和核心业务逻辑(Main,包含 MCP 和 API 调用)分离。8.2 常见问题与排错思路
- API Key 错误: 检查环境变量是否正确设置、是否在正确的终端启动应用、Key 本身是否有效、是否还有额度。IPC 不工作:
- 确认
preload.js
在 BrowserWindow
的 webPreferences
中正确配置。确认 contextIsolation: true
(推荐) 并且使用了 contextBridge.exposeInMainWorld
。检查 preload.js
和 renderer.js
中使用的通道名称 ('send-message'
, 'ai-reply'
) 是否与 main.js
中 ipcMain.on/handle
和 event.sender.send
使用的名称完全一致。在 preload.js
和 renderer.js
的开头添加 console.log
确认脚本是否被加载。在 main.js
的 ipcMain.on
回调开头添加 console.log
确认主进程是否收到消息。打开开发者工具 (Main: mainWindow.webContents.openDevTools()
) 查看渲染器进程的 Console 输出和网络请求。main.js
中 mainWindow.loadFile
或 mainWindow.loadURL
的路径是否正确。检查 HTML 中 CSS 和 JS 文件的引用路径。使用开发者工具的 Elements 和 Console 面板调试。上下文似乎丢失 (AI "失忆"):- 在
main.js
中打印每次调用 callLLMApi
前的 conversationHistory
,确认历史是否按预期累积。检查 applyContextTruncation
的逻辑是否符合预期,是否过早地丢弃了重要信息。确认 API 调用时 messages
字段确实包含了正确的上下文数组。package.json
中的 main
字段是否指向正确的入口文件 (main.js
)。查看终端的错误输出。8.3 进一步学习 MCP 和 LLM 应用开发的资源
- LLM 提供商文档:Electron 官方文档: www.electronjs.org/docs/latest… (非常全面)Tokenizer 库: tiktoken (Python/JS)上下文管理策略深入: 搜索 "LLM context management strategies", "LLM long context handling", "Retrieval-Augmented Generation (RAG)"。LangChain / LlamaIndex: 这些是流行的 LLM 应用开发框架,它们封装了许多 MCP、RAG、Agent 等高级概念的实现,可以极大地简化开发。虽然它们抽象了很多细节,但在理解了底层原理(如此项目)后学习它们会更容易。它们也有 JavaScript/TypeScript 版本。