我们来深入探讨如何使用 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)的挑战与策略
- 第二部分:Electron 基础与项目搭建
- 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 项目结构概览
- 第三部分:构建聊天界面 (UI)
- 3.1 选择前端技术栈 (HTML, CSS, Vanilla JS 或框架)
- 3.2 HTML 结构设计 (消息列表、输入框、发送按钮)
- 3.3 CSS 样式实现 (基础布局与美化)
- 3.4 JavaScript 交互逻辑 (DOM 操作、事件监听)
- 第四部分:在 Electron 中实现 MCP 逻辑
- 4.1 数据结构定义 (JavaScript 对象数组)
- 4.2 在渲染器进程中管理本地消息状态
- 4.3 通过 IPC 将用户消息和 MCP 请求发送到主进程
- 4.4 在主进程中维护和更新完整的 MCP 上下文
- 4.5 实现上下文截断策略 (例如:固定长度、Token 限制)
- 4.6 将 MCP 数据格式化为 LLM API 请求格式
- 第五部分:集成大语言模型 (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 数组):
javascript
[
{ role: 'system', content: '你是一个翻译助手,将用户输入翻译成英文。' },
{ role: 'user', content: '你好,世界!' },
{ role: 'assistant', content: 'Hello, world!' },
{ role: 'user', content: '今天天气怎么样?' }
// 下一次请求时,会将这个数组连同新的用户信息一起发送给 LLM
]
这个结构清晰地记录了对话的流程和各方发言。在每次需要调用 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
指令。 - 保留首尾: 保留第一条(通常是系统指令)和最后几条消息。
- 简单截断: 保留最新的 N 条消息,或保留总 Token 数不超过限制的最新消息。这是最简单但也可能丢失重要早期信息的方法。通常会优先丢弃最旧的
- 摘要 (Summarization):
- 使用另一个 LLM 调用(或者简单的规则)将较早的对话历史进行总结,用一个简短的摘要替换掉冗长的旧消息。这能保留一些长期记忆,但会增加复杂性和潜在的成本。
- 基于 Token 的滑动窗口: 精确计算每条消息的 Token 数,从旧到新累加,直到接近窗口上限,只发送窗口内的消息。这需要一个 Tokenizer 库(如
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 密钥并执行网络请求。
- 渲染器进程 (Renderer Process):
- 每个
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 (推荐):
bash
# 创建项目 (会提示选择模板,可以选择如 `webpack` 或 `vite` 模板)
npm init electron-app@latest my-mcp-chat-app --template=webpack # 或 --template=vite
# 或使用 yarn
# yarn create electron-app my-mcp-chat-app --template=webpack
# 进入项目目录
cd my-mcp-chat-app
# 安装依赖
npm install # 或 yarn install
# 启动开发环境
npm run start # 或 yarn 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
,添加入口点和启动脚本: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 模板为例)
csharp
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
)
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<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"> <!-- 引入 CSS 文件 -->
</head>
<body>
<div class="chat-container">
<h1>MCP 学习聊天室</h1>
<div id="message-list" class="message-list">
<!-- 消息将通过 JavaScript 动态添加到这里 -->
<!-- 示例消息结构:
<div class="message user-message">
<span class="message-sender">你:</span>
<p class="message-content">你好!</p>
</div>
<div class="message assistant-message">
<span class="message-sender">AI:</span>
<p class="message-content">你好!有什么可以帮你的吗?</p>
</div>
-->
</div>
<div class="input-area">
<textarea id="message-input" placeholder="输入消息..."></textarea>
<button id="send-button">发送</button>
</div>
</div>
<!-- 引入渲染器脚本 -->
<!-- 注意:如果使用 Electron Forge + Webpack/Vite,入口点可能是由打包工具处理的 -->
<!-- 查看你的模板配置,可能不需要手动引入 renderer.js -->
<!-- <script src="./renderer.js"></script> -->
</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
文件 (或根据你的模板调整路径) 并添加基础样式:
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; /* AI 消息靠左 */
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; /* 让 padding 不增加总高度 */
}
#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 响应时显示加载状态。
javascript
// src/renderer.js
// 获取 DOM 元素
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`); // 'user-message' or 'assistant-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; // 简单处理文本,实际应用可能需要处理 Markdown 或 HTML
// messageElement.appendChild(senderElement); // 可以选择是否显示发送者标签
messageElement.appendChild(contentElement);
messageList.appendChild(messageElement);
// 滚动到底部
messageList.scrollTop = messageList.scrollHeight;
}
// --- 发送消息处理 ---
function sendMessage() {
const messageText = messageInput.value.trim();
if (messageText === '') {
return; // 不发送空消息
}
// 1. 在界面上显示用户自己的消息
displayMessage('user', messageText, 'user');
// 2. 清空输入框
messageInput.value = '';
// 3. 禁用发送按钮和输入框,防止重复发送 (可选,但推荐)
setSendingState(true);
// 4. 通过 IPC 将消息发送给主进程处理
// 我们将使用 preload.js 暴露的 'sendMessageToMain' 函数
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'); // 借用 assistant 样式
setSendingState(false); // 发生错误,恢复输入
}
}
// --- 设置发送状态 (禁用/启用输入) ---
function setSendingState(isSending) {
messageInput.disabled = isSending;
sendButton.disabled = isSending;
if (isSending) {
sendButton.textContent = '发送中...';
} else {
sendButton.textContent = '发送';
messageInput.focus(); // AI响应后,输入框重新获得焦点
}
}
// --- 监听发送按钮点击 ---
sendButton.addEventListener('click', sendMessage);
// --- 监听输入框回车 (Ctrl+Enter 或 Shift+Enter 发送,普通 Enter 换行 - 可选) ---
messageInput.addEventListener('keydown', (event) => {
// 按下 Enter 发送 (如果需要 Enter 换行,Ctrl+Enter 发送,则逻辑不同)
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault(); // 阻止默认的换行行为
sendMessage();
}
});
// --- 监听来自主进程的 AI 响应 ---
// 我们将使用 preload.js 暴露的 'handleAIMessage' 函数来注册回调
if (window.electronAPI && window.electronAPI.handleAIMessage) {
window.electronAPI.handleAIMessage((event, aiMessage) => {
console.log('Renderer: Received AI message from main:', aiMessage);
// 在界面上显示 AI 的消息
displayMessage('assistant', aiMessage, 'assistant');
// AI 响应回来后,恢复输入状态
setSendingState(false);
});
// (可选) 监听来自主进程的错误消息
window.electronAPI.handleError((event, errorMessage) => {
console.error('Renderer: Received error from main:', errorMessage);
displayMessage('system', `错误: ${errorMessage}`, 'assistant'); // 借用 assistant 样式显示错误
setSendingState(false); // 出错后也要恢复输入
});
} else {
console.error("electronAPI or handleAIMessage/handleError not found! Check preload.js");
displayMessage('system', '错误:无法接收后台消息。', 'assistant');
}
// --- 初始化 ---
// 可以在这里发送一条欢迎消息,或者从主进程请求历史记录(如果做了持久化)
// displayMessage('assistant', '你好!我是 MCP 学习助手,请开始提问吧。', '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 上下文,结构如下:
javascript
// 示例 (在 main.js 中)
let conversationHistory = [
// 可以预设一个 system prompt
{ 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 模块。
javascript
// src/preload.js
const { contextBridge, ipcRenderer } = require('electron');
console.log('Preload script loaded.');
// 使用 contextBridge 暴露安全的 API 给渲染器进程
contextBridge.exposeInMainWorld('electronAPI', {
// --- 从渲染器发送到主进程 ---
// 发送用户消息
sendMessageToMain: (message) => {
console.log('Preload: Sending "send-message" IPC event with:', message);
ipcRenderer.send('send-message', message); // 'send-message' 是我们约定的通道名称
},
// --- 从主进程接收消息 ---
// 注册处理 AI 回复的回调函数
handleAIMessage: (callback) => {
console.log('Preload: Registering handler for "ai-reply" IPC event.');
// 使用 ipcRenderer.on 来持续监听来自主进程的 'ai-reply' 通道
// 注意:为了避免内存泄漏,在窗口关闭或重新加载时应该移除监听器
// 但在这个简单示例中,我们暂时省略清理逻辑
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));
}
// 如果需要请求/响应模式 (例如,请求历史记录)
// invokeSomething: async (data) => {
// return await ipcRenderer.invoke('some-channel', data);
// }
});
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'
)。
javascript
// src/main.js (部分代码 - IPC 监听和 MCP 管理)
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
// --- MCP 上下文管理 ---
let conversationHistory = [
{ role: 'system', content: '你是一个使用 Electron 构建的聊天机器人,请简洁回答。' }
];
const MAX_CONTEXT_MESSAGES = 10; // 示例:最多保留最近 10 条 user/assistant 消息 + system
// --- LLM API 调用函数 (将在下一部分实现) ---
async function callLLMApi(context) {
console.log("Main: Calling LLM API with context:", JSON.stringify(context, null, 2));
// 模拟 API 调用延迟
await new Promise(resolve => setTimeout(resolve, 1500));
// TODO: 在第五部分替换为真实的 API 调用
// 模拟 AI 回复
const lastUserMessage = context[context.length - 1]?.content || "空消息";
const aiResponse = `我是 AI,收到了你的消息:"${lastUserMessage}"。MCP 学习进展如何?`;
console.log("Main: Mock LLM API response:", aiResponse);
return aiResponse; // 返回模拟响应
// 真实场景下,这里会处理 API 的成功或失败
}
// --- 窗口创建函数 ---
function createWindow() {
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
// __dirname 指向当前文件所在目录 (打包后可能会变化,要小心)
// path.join 确保路径在不同操作系统下都正确
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, // 推荐开启,增强安全性
nodeIntegration: false, // 推荐关闭,渲染器不直接访问 Node API
}
});
// 加载 index.html
// 如果使用 Electron Forge + Webpack/Vite,入口点可能不同
// 请参考你的模板配置
if (process.env.NODE_ENV === 'development') {
// 开发环境通常加载本地服务器 URL,支持热重载
// 这个 URL 取决于你的模板 (Webpack dev server, Vite dev server)
// mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY); // Webpack 模板示例
// mainWindow.loadURL('http://localhost:5173'); // Vite 模板示例 (端口可能不同)
mainWindow.loadFile(path.join(__dirname, '../src/index.html')); // 简单加载文件
} else {
// 生产环境加载打包后的文件
mainWindow.loadFile(path.join(__dirname, '../src/index.html'));
}
// 打开开发者工具 (可选)
mainWindow.webContents.openDevTools();
// --- IPC 监听器 ---
ipcMain.on('send-message', async (event, userMessage) => {
console.log('Main: Received "send-message" IPC event with:', userMessage);
// 1. 更新 MCP 上下文
conversationHistory.push({ role: 'user', content: userMessage });
// 2. (可选) 应用上下文截断策略
applyContextTruncation(); // 实现见下文
try {
// 3. 调用 LLM API (目前是模拟调用)
// 将当前的对话历史作为上下文传递
const aiReply = await callLLMApi(conversationHistory);
// 4. 将 AI 的回复也加入 MCP 上下文
conversationHistory.push({ role: 'assistant', content: aiReply });
// 再次截断,确保添加 AI 回复后也不超长 (虽然可能有点冗余,但保险)
applyContextTruncation();
// 5. 通过 IPC 将 AI 回复发送回渲染器进程
console.log('Main: Sending "ai-reply" IPC event back to renderer with:', aiReply);
// event.sender 指向发送消息的那个渲染器窗口
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}`);
// 可以考虑是否从 history 中移除最后一条 user message
// conversationHistory.pop();
}
});
// (如果需要请求/响应模式)
// ipcMain.handle('some-channel', async (event, data) => {
// const result = await doSomething(data);
// return result;
// });
} // end createWindow
// --- 应用生命周期事件 ---
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();
});
// --- MCP 上下文截断函数 ---
function applyContextTruncation() {
// 保留 system prompt (如果有)
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); // 取最后 N 条
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 数,确保总数不超过模型的限制。
javascript
// 示例:使用 tiktoken 进行截断 (需要安装 tiktoken)
// const { getEncoding } = require("tiktoken");
// const encoding = getEncoding("cl100k_base"); // 适用于 gpt-3.5-turbo 和 gpt-4
// const MAX_CONTEXT_TOKENS = 4000; // 示例 Token 限制
// function applyTokenBasedTruncation() {
// let currentTokens = 0;
// const messagesToSend = [];
// const systemPrompt = conversationHistory.find(msg => msg.role === 'system');
// // 计算 System Prompt 的 Token (如果存在)
// if (systemPrompt) {
// currentTokens += encoding.encode(systemPrompt.content).length;
// }
// // 从最新消息开始向前遍历
// for (let i = conversationHistory.length - 1; i >= 0; i--) {
// const message = conversationHistory[i];
// if (message.role === 'system') continue; // System prompt 已单独处理
// const messageTokens = encoding.encode(message.content).length;
// // 加上这条消息的 token 是否会超出限制?
// // (需要为模型响应预留一些 token,比如预留 500 token)
// const PRESERVE_FOR_RESPONSE = 500;
// if (currentTokens + messageTokens < MAX_CONTEXT_TOKENS - PRESERVE_FOR_RESPONSE) {
// messagesToSend.unshift(message); // 加到结果数组的开头,保持顺序
// currentTokens += messageTokens;
// } else {
// // 超出限制,停止添加更早的消息
// console.log(`Main: Token limit reached. Stopped adding older messages.`);
// break;
// }
// }
// // 组合最终的上下文
// conversationHistory = systemPrompt ? [systemPrompt, ...messagesToSend] : messagesToSend;
// console.log(`Main: Context truncated based on tokens. Kept ${conversationHistory.length} messages, ~${currentTokens} tokens.`);
// }
// 在 ipcMain.on('send-message', ...) 中调用 applyTokenBasedTruncation() 替换之前的调用
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
读取。
- 在终端 (Linux/macOS):
.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 这种敏感信息,环境变量或专门的密钥管理方案更好。
- 环境变量: 这是推荐的方式。在启动 Electron 应用前设置一个环境变量,例如
我们将使用环境变量的方式。
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
函数:
javascript
// src/main.js (修改 callLLMApi 部分)
const fetch = require('node-fetch'); // 引入 node-fetch
// --- 从环境变量读取 API Key ---
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
if (!OPENAI_API_KEY) {
console.error("错误:未设置 OPENAI_API_KEY 环境变量!");
// 在应用启动时就应该提示用户或退出
// 这里我们暂时允许应用继续运行,但 API 调用会失败
}
const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';
const MODEL_NAME = 'gpt-3.5-turbo'; // 或者 'gpt-4', 'gpt-4-turbo-preview' 等
// --- 替换原来的模拟函数 ---
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, // 直接使用我们的 MCP 上下文数组
// temperature: 0.7, // 控制创造性,可选
// max_tokens: 150, // 限制回复长度,可选
// stream: false, // 设置为 true 以启用流式响应 (见 5.6)
})
});
if (!response.ok) {
// 处理 API 返回的错误
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();
// 提取 AI 的回复内容
// 检查响应结构是否符合预期
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);
// 将错误向上抛出,让调用者 (IPC handler) 处理
throw error; // 重新抛出错误,以便在 IPC 处理程序中捕获并发送给渲染器
}
}
关键更改:
- 引入
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
修改思路:
javascript
// src/main.js (流式处理概念)
// ... (需要修改 callLLMApi 和 IPC handler)
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 = ""; // 用于累积完整回复以存入 history
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 });
// SSE 数据通常以 "data: " 开头,可能有多行,以 "\n\n" 分隔
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(); // 释放读取器锁
}
// 如果循环正常结束但没有收到 [DONE] (异常情况)
eventSender.send('ai-reply-stream-end'); // 确保发送结束信号
return fullResponse;
}
// --- 修改 IPC Handler ---
ipcMain.on('send-message', async (event, userMessage) => {
console.log('Main: Received "send-message" IPC event with:', userMessage);
conversationHistory.push({ role: 'user', content: userMessage });
applyContextTruncation(); // Or token based truncation
try {
// 调用流式处理函数,传入 event.sender
const fullAIReply = await handleStreamedLLMResponse(conversationHistory, event.sender);
// 流结束后,将完整回复添加到历史记录
if (fullAIReply) {
conversationHistory.push({ role: 'assistant', content: fullAIReply });
applyContextTruncation(); // Apply truncation again
} else {
console.warn("Main: Stream completed but no content accumulated.");
// 可能需要发送一个空回复或错误给渲染器?
}
} catch (error) {
console.error('Main: Error processing message or calling LLM API (stream):', error);
// 错误已在 handleStreamedLLMResponse 内部或这里发送给渲染器
if (!event.sender.isDestroyed()) { // 检查窗口是否还存在
event.sender.send('app-error', `处理消息时出错: ${error.message}`);
// 如果流已经开始,可能需要额外发送一个 stream-end 信号
event.sender.send('ai-reply-stream-end');
}
}
});
// --- preload.js 和 renderer.js 也需要相应修改 ---
// preload.js: 添加 handleStreamChunk 和 handleStreamEnd
// renderer.js:
// - 在 sendMessage 后,创建一个空的 assistant 消息 div
// - 监听 chunk,将内容追加到该 div
// - 监听 end,完成追加,恢复输入状态
注意: 流式处理实现细节较多,需要仔细处理各种边界情况和错误。对于初学,可以先实现非流式版本。
第六部分:整合流程与代码示例
现在我们将前面各部分的代码整合起来,展示关键文件的核心内容。
6.1 整体数据流图
流式响应数据流 (简化版):
6.2 main.js
(主进程核心代码 - 非流式版本)
javascript
// src/main.js
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const fetch = require('node-fetch');
// require('dotenv').config(); // 如果使用 .env 文件
// --- 配置 ---
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; // 基于消息数量的截断
// --- MCP 上下文 ---
let conversationHistory = [
{ role: 'system', content: '你是一个基于 Electron 和 MCP 概念构建的 AI 聊天助手。' }
];
// --- 检查 API Key ---
if (!OPENAI_API_KEY) {
console.error("错误:启动前必须设置 OPENAI_API_KEY 环境变量!");
// 实际应用中可能需要更友好的提示或阻止应用启动
// 为了演示,我们继续,但 API 调用会失败
}
// --- LLM API 调用函数 ---
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,
// stream: false, // 非流式
})
});
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;
}
}
// --- MCP 上下文截断函数 ---
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,
}
});
// --- 加载 HTML ---
// !! 重要: 根据你的 Electron 项目模板调整这里的加载方式 !!
// Electron Forge with Webpack/Vite 通常有特定的入口变量
// Example for Vite template (check your main process template file):
// if (process.env.VITE_DEV_SERVER_URL) {
// mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
// } else {
// mainWindow.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`));
// }
// Fallback to simple loadFile if template specifics are unknown:
mainWindow.loadFile(path.join(__dirname, '../src/index.html')) // 假设 HTML 在 src 目录
.catch(err => console.error('Failed to load HTML:', err));
// 打开开发者工具 (便于调试)
if (process.env.NODE_ENV !== 'production') {
mainWindow.webContents.openDevTools();
}
// --- IPC 监听器 ---
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}`);
}
// 考虑是否移除失败的用户消息
// conversationHistory.pop();
}
});
}
// --- App 生命周期 ---
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 安全暴露)
javascript
// src/preload.js
const { contextBridge, ipcRenderer } = require('electron');
console.log('Preload script loaded.');
contextBridge.exposeInMainWorld('electronAPI', {
// Renderer -> Main
sendMessageToMain: (message) => {
ipcRenderer.send('send-message', message);
},
// Main -> Renderer (Callbacks)
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));
}
// --- 如果实现流式响应,需要添加 ---
// handleStreamChunk: (callback) => {
// ipcRenderer.removeAllListeners('ai-reply-stream-chunk');
// ipcRenderer.on('ai-reply-stream-chunk', (event, ...args) => callback(event, ...args));
// },
// handleStreamEnd: (callback) => {
// ipcRenderer.removeAllListeners('ai-reply-stream-end');
// ipcRenderer.on('ai-reply-stream-end', (event, ...args) => callback(event, ...args));
// }
});
6.4 renderer.js
(渲染器进程逻辑)
(与第三部分 3.4 中的代码基本一致,确保 window.electronAPI
的调用和回调注册正确无误。)
javascript
// src/renderer.js
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; // 注意:实际应用可能需要处理 Markdown 或 XSS
messageElement.appendChild(contentElement);
messageList.appendChild(messageElement);
messageList.scrollTop = messageList.scrollHeight;
if (type === 'assistant' && !isComplete) {
currentAssistantMessageElement = contentElement; // 保存对当前 AI 消息内容的引用
} 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);
// 如果是流式,可以在这里立即创建一个空的 assistant 消息占位符
// displayMessage('assistant', '', 'assistant', false); // 创建一个不完整的消息
} 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.isComposing 防止输入法未完成时触发
event.preventDefault();
sendMessage();
}
});
// --- IPC 监听 ---
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'); // 用 assistant 样式显示错误
setSendingState(false);
currentAssistantMessageElement = null; // 出错时也重置流状态
});
// --- 如果实现流式响应,需要添加/修改监听器 ---
/*
let streamingOccurred = false;
window.electronAPI.handleStreamChunk((event, chunk) => {
console.log('Renderer: Received stream chunk:', chunk);
if (!streamingOccurred) {
// 第一次收到 chunk 时,确保有个空的 assistant 消息元素
if (!currentAssistantMessageElement) {
displayMessage('assistant', '', 'assistant', false);
}
streamingOccurred = true;
}
appendToCurrentAssistantMessage(chunk);
});
window.electronAPI.handleStreamEnd((event) => {
console.log('Renderer: Received stream end signal.');
currentAssistantMessageElement = null; // 流结束,不再追加
setSendingState(false); // 流结束后恢复输入
streamingOccurred = false; // 重置流状态
});
// 修改 sendMessage,在发送后立即调用 displayMessage('assistant', '', 'assistant', false);
// 取消或注释掉 handleAIMessage 的监听 (因为它不会再被非流式调用触发)
*/
} else {
console.error("window.electronAPI is not defined! Check preload script and contextIsolation settings.");
displayMessage('system', '错误:应用初始化失败。', 'assistant');
}
// --- 初始化 ---
setSendingState(false);
console.log('Renderer process initialized.');
// displayMessage('assistant', '你好!输入消息开始聊天吧。', 'assistant'); // 初始欢迎语
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
-
使用:
javascript// main.js const Store = require('electron-store'); const store = new Store(); // 读取历史记录 (应用启动时) let conversationHistory = store.get('chatHistory', [ { role: 'system', content: '...' } // 默认值 ]); // 保存历史记录 (每次更新后) function saveHistory() { store.set('chatHistory', conversationHistory); } // 在 ipcMain.on('send-message', ...) 成功处理完一次对话后调用 saveHistory() // 在 applyContextTruncation 后也可能需要保存,取决于策略
-
-
数据库: 对于更复杂的场景或大量数据,可以使用 SQLite (
sqlite3
npm 包) 或其他嵌入式数据库。
7.3 优化 MCP 策略
-
基于 Token 的截断: 如前所述,使用
tiktoken
实现更精确的控制。 -
摘要: 当历史变得很长时,定期调用 LLM 对旧的部分进行摘要,用摘要替换旧消息。这需要额外的 API 调用和逻辑。
javascript// 概念:在 applyContextTruncation 触发时 if (historyTooLong) { const oldMessages = conversationHistory.slice(1, -KEEP_RECENT_COUNT); // 保留 system 和最近的 const summaryPrompt = `请将以下对话摘要成一段话,保留关键信息:\n${JSON.stringify(oldMessages)}`; const summary = await callLLMApi([{ role: 'user', content: summaryPrompt }]); // 用专门的 prompt 请求摘要 conversationHistory = [ conversationHistory[0], // system prompt { 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
)。你可以配置为特定平台打包。
- Electron Builder: 另一个流行的打包工具,提供更多配置选项。
- 安装:
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 输出和网络请求。
- 确认
- HTML/CSS/JS 不加载或样式错误: 检查
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 提供商文档:
- OpenAI API Documentation (尤其是 Chat Completions API 部分)
- Anthropic Documentation
- Google AI Documentation
- 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 版本。