掘金 人工智能 04月01日 11:07
Electron 构建一个集成MCP
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文探讨了如何使用Electron构建一个集成“模型上下文协议”(MCP)的聊天室应用,旨在帮助开发者理解MCP的概念和实现。文章详细介绍了MCP在LLM应用中的重要性,以及如何在Electron应用中实现MCP来管理与LLM的对话。此外,它还涵盖了Electron的基础开发流程,包括主进程、渲染器进程、IPC通信,以及如何集成LLM API来驱动聊天机器人的响应,并构建一个基础但功能完整的聊天界面,最终实现一个流畅自然的聊天体验。

💬 MCP是管理大语言模型(LLM)上下文信息的规则,主要用于维护对话历史、系统指令等,以确保LLM响应的准确性和连贯性。

💻 在聊天应用中,MCP负责维护对话状态、构建有效输入、控制上下文长度,并支持多轮对话和特定功能,从而实现流畅自然的AI交互。

⚙️ Electron是一个使用Web技术构建跨平台桌面应用的框架,它结合了Chromium和Node.js,便于开发者利用熟悉的技术栈开发和调试MCP。

🧩 Electron应用由主进程和渲染器进程组成,主进程负责创建窗口、管理MCP核心逻辑和LLM API调用,而渲染器进程负责UI渲染和用户交互,两者通过IPC进行通信。

💡 实现MCP时,通常使用对象数组来表示对话历史,包括role(system、user、assistant)和content,并结合截断、摘要等策略来处理上下文窗口的限制。

我们来深入探讨如何使用 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 数组):

[  { 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 指令。保留首尾: 保留第一条(通常是系统指令)和最后几条消息。
    摘要 (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 (推荐):

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 tiktokenyarn 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 响应格式可能不符合预期 (进入 elsecatch 块)。在这些情况下,callLLMApi 会抛出错误。这个错误会在 ipcMain.onasync 回调中被 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 调用:callLLMApifetch 请求体中设置 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.jscatch 块中,可以根据不同的错误类型(网络错误、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.jssetSendingState 中,除了禁用按钮,可以在 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 安全: 坚持使用 contextBridgepreload.js,避免在渲染器中开启 nodeIntegration 或禁用 contextIsolation。只暴露必要且安全的函数给渲染器。依赖项安全: 定期更新 npm 依赖 (npm audit fix),关注安全漏洞。

7.6 打包与分发 Electron 应用

当你准备好分享你的应用时,需要将其打包成可执行文件。

    Electron Forge: 如果你使用了 Forge,打包非常简单:
      npm run makeyarn 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.jscontextBridge)进行安全通信。LLM API 集成: 了解了如何从 Electron 主进程安全地调用外部 API(如 OpenAI),处理认证、请求和响应(包括错误处理)。前后端分离思想: 即使在桌面应用中,也将 UI 逻辑(Renderer)和核心业务逻辑(Main,包含 MCP 和 API 调用)分离。

8.2 常见问题与排错思路

    API Key 错误: 检查环境变量是否正确设置、是否在正确的终端启动应用、Key 本身是否有效、是否还有额度。IPC 不工作:
      确认 preload.jsBrowserWindowwebPreferences 中正确配置。确认 contextIsolation: true (推荐) 并且使用了 contextBridge.exposeInMainWorld。检查 preload.jsrenderer.js 中使用的通道名称 ('send-message', 'ai-reply') 是否与 main.jsipcMain.on/handleevent.sender.send 使用的名称完全一致。在 preload.jsrenderer.js 的开头添加 console.log 确认脚本是否被加载。在 main.jsipcMain.on 回调开头添加 console.log 确认主进程是否收到消息。打开开发者工具 (Main: mainWindow.webContents.openDevTools()) 查看渲染器进程的 Console 输出和网络请求。
    HTML/CSS/JS 不加载或样式错误: 检查 main.jsmainWindow.loadFilemainWindow.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 版本。

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

Electron MCP LLM 聊天室 IPC
相关文章