Electron 构建一个集成MCP

我们来深入探讨如何使用 Electron 构建一个集成"模型上下文协议"(Model Context Protocol - MCP)的聊天室应用,主要目的是学习 MCP 的概念和实现。

核心目标:

  1. 理解什么是"模型上下文协议"(MCP)及其在 LLM 应用中的重要性。
  2. 学习如何在一个 Electron 应用中实现 MCP 来管理与 LLM 的对话。
  3. 掌握 Electron 的基本开发流程(主进程、渲染器进程、IPC 通信)。
  4. 集成一个 LLM API(以 OpenAI API 为例)来驱动聊天机器人的响应。
  5. 构建一个基础但功能完整的聊天界面。

免责声明: "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 能够理解对话的连贯性、记住之前的讨论内容,甚至遵循特定的指令或扮演某个角色,我们需要在每次请求时,将相关的"上下文"信息一起发送给它。

"上下文"通常包括:

  1. 系统指令 (System Prompt): 定义 LLM 的角色、行为准则、输出格式等。例如:"你是一个乐于助人的 AI 助手。"
  2. 对话历史 (Conversation History): 用户和 AI 之间之前的交互记录。
  3. 当前用户输入 (Current User Input): 用户最新发送的消息。
  4. (可选) 额外信息 (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 必须包含上下文管理策略来应对这个问题。常见的策略有:

  1. 截断 (Truncation):
    • 简单截断: 保留最新的 N 条消息,或保留总 Token 数不超过限制的最新消息。这是最简单但也可能丢失重要早期信息的方法。通常会优先丢弃最旧的 user/assistant 对话,但保留 system 指令。
    • 保留首尾: 保留第一条(通常是系统指令)和最后几条消息。
  2. 摘要 (Summarization):
    • 使用另一个 LLM 调用(或者简单的规则)将较早的对话历史进行总结,用一个简短的摘要替换掉冗长的旧消息。这能保留一些长期记忆,但会增加复杂性和潜在的成本。
  3. 基于 Token 的滑动窗口: 精确计算每条消息的 Token 数,从旧到新累加,直到接近窗口上限,只发送窗口内的消息。这需要一个 Tokenizer 库(如 tiktoken for OpenAI)。
  4. 向量数据库 / 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 模式:

  1. 渲染器 -> 主进程 (单向): 渲染器发送消息,主进程处理。例如,用户点击发送按钮后,渲染器通过 ipcRenderer.send() 将消息内容发送给主进程。
  2. 渲染器 -> 主进程 -> 渲染器 (请求/响应): 渲染器发送请求,主进程处理后将结果发回给该渲染器。例如,渲染器请求主进程调用 LLM API,主进程完成后通过 event.reply()webContents.send() 将 AI 的响应发回。这需要使用 ipcRenderer.invoke()ipcMain.handle() (推荐,基于 Promise) 或 ipcRenderer.on() 配合 event.reply()

为了安全,通常不直接在渲染器进程中暴露 ipcRenderer。而是通过 preload.js 脚本选择性地将安全的 IPC 功能暴露给渲染器。

2.4 开发环境准备

你需要安装:

  1. Node.js:Node.js 官网 下载并安装 LTS 版本。这将同时安装 npm (Node Package Manager)。
  2. (可选) Yarn: 另一个流行的包管理器,可以替代 npm。 (npm install -g yarn)
  3. 代码编辑器: 如 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 会生成一个包含基本结构、构建脚本和热重载功能的项目模板,极大地简化了开发流程。

手动配置 (更底层,有助于理解):

  1. 创建项目目录 my-mcp-chat-app 并进入。

  2. 初始化 npm 项目: npm init -y

  3. 安装 Electron: npm install --save-dev electron

  4. 创建核心文件:

    • main.js: 主进程入口。
    • index.html: 渲染器进程的 UI 页面。
    • renderer.js: 渲染器进程的 JavaScript。
    • (可选) preload.js: 预加载脚本。
  5. 修改 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" // 版本号可能不同
      }
    }
  6. 编写 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)

这个文件负责处理用户界面上的交互:

  1. 获取 DOM 元素的引用。
  2. 监听发送按钮的点击事件和输入框的回车事件。
  3. 获取用户输入的消息。
  4. 将用户消息显示在界面上。
  5. 通过 IPC 将用户消息发送给主进程处理 (这是与 MCP 和 LLM 集成的关键)
  6. 监听来自主进程的 AI 响应消息,并将其显示在界面上。
  7. (可选) 在等待 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 的精确截断,你需要:

  1. 安装一个 Tokenizer 库,如 tiktoken (适用于 OpenAI 模型): npm install tiktokenyarn add tiktoken
  2. main.js 中引入并使用它来计算每条消息的 Token 数。
  3. 修改 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 密钥及安全管理

  1. 注册 OpenAI 账户: 访问 OpenAI 官网 并创建一个账户。
  2. 获取 API Key: 登录后,在 API Keys 页面创建一个新的 Secret Key。这个 Key 非常重要,绝不能直接硬编码在代码中!
  3. 安全存储 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 函数:

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 响应格式可能不符合预期 (进入 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 生成回复时,逐字或逐句地显示出来,而不是等待整个回复生成完毕。

这需要:

  1. 修改 API 调用:callLLMApifetch 请求体中设置 stream: true
  2. 处理流式数据: API 不会一次性返回 JSON,而是返回一个 Server-Sent Events (SSE) 流。你需要读取这个流,解析每一块数据(通常是 JSON 片段),提取其中的 delta.content(增量内容)。
  3. 修改 IPC: 不能只在最后发送一次 ai-reply。需要定义新的 IPC 通道,例如 ai-reply-stream-chunk,每收到一小块文本就通过这个通道发送给渲染器。还需要一个 ai-reply-stream-end 通道来告知渲染器流结束了。
  4. 修改渲染器: 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 整体数据流图

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 (主进程核心代码 - 非流式版本)

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.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

    • 使用:

      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.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 版本。
相关推荐
uzong3 小时前
技术故障复盘模版
后端
GetcharZp4 小时前
基于 Dify + 通义千问的多模态大模型 搭建发票识别 Agent
后端·llm·agent
加班是不可能的,除非双倍日工资4 小时前
css预编译器实现星空背景图
前端·css·vue3
桦说编程4 小时前
Java 中如何创建不可变类型
java·后端·函数式编程
IT毕设实战小研4 小时前
基于Spring Boot 4s店车辆管理系统 租车管理系统 停车位管理系统 智慧车辆管理系统
java·开发语言·spring boot·后端·spring·毕业设计·课程设计
wyiyiyi5 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip5 小时前
vite和webpack打包结构控制
前端·javascript
excel5 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国6 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼6 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin