diff --git a/README.md b/README.md
index 1c0688c7..153464f9 100644
--- a/README.md
+++ b/README.md
@@ -1,169 +1,113 @@
-
-
+# chatgpt-plugin
-

-

-

-

-
+基于 Chaite 内核的 Yunzai 对话插件,支持多模型渠道、工具调用、记忆系统、伪人模式,以及标准 MCP 工具桥接(stdio / streamable-http)。
+旧版说明文档已保留为 `readme_old.md`。
+## 主要能力
-
-
+- 多渠道与预设管理(可在面板或命令行操作)
+- 群上下文注入与多轮会话管理
+- 工具调用与推理过程回显
+- 群记忆 / 用户记忆(可独立开关)
+- BYM 伪人模式
+- MCP 兼容桥接:
+ - 本地 `stdio` MCP 服务
+ - 远程 `streamable-http` MCP 服务
+ - 动态生成 Chaite 工具并加入工具池
+## 安装
+```bash
+cd plugins
+git clone https://github.com/ikechan8370/chatgpt-plugin.git
+cd chatgpt-plugin
+pnpm install
+```
-> 插件v3大幅重构中,基本可用,持续完善中。遇到问题请提issue,欢迎PR。
-> todo列表:
-> - [x] 插件v3重构完成,插件基本功能可用,持续完善中。
-> - [ ] RAG知识库
-> - [ ] 预设更详细的配置
-> - [x] 自定义触发器
-> - [ ] 自定义插件
-> - [ ] 兼容mcp
+然后重启 Yunzai。
+## 关键配置
-## 插件简介
+运行时配置文件:`plugins/chatgpt-plugin/data/config.json`
-ChatGPT-Plugin 以 Chaite 为内核,将多模型渠道、工具、处理器、触发器、RAG 和管控面板封装成一套适配 Miao-Yunzai / Yunzai-Bot 的插件方案。通过 Chaite 的 API 服务器与可插拔的存储层(默认 SQLite),插件可以在本地完成高并发对话、知识库检索、伪人陪聊以及记忆管理,亦可接入 Chaite Cloud 复用在线渠道与工具。
+基础配置示例:
-## 核心特性
+```json
+{
+ "basic": {
+ "toggleMode": "at",
+ "togglePrefix": "#chat",
+ "commandPrefix": "#chatgpt"
+ }
+}
+```
-- **多渠道与预设体系**:依托 Chaite 的 ChannelsManager 与 ChatPresetManager,支持为不同模型配置流量、负载均衡与个性化 prompt,群友也可在授权后自助切换预设。
-- **高级消息适配**:前后文触发方式支持 `@Bot` 与前缀;自动处理引用、图片、语音等多模态输入,并在工具调用或推理阶段通过转发消息回显。
-- **群上下文与伪人模式**:可按配置注入指定条数的群聊记录;BYM 伪人模式支持概率触发、关键词命中、预设覆盖及限时撤回,营造更拟人的陪聊体验。
-- **记忆与 RAG**:内置 memoryService + vectra 向量索引,提供群记忆、私人记忆与外部知识库(RAGManager)注入能力,支持混合检索与手动管理。
-- **可视化与指令双管控**:`#chatgpt管理面板` 一键获取面板 token,Web 端即可操作渠道、工具、触发器;同时保留完整的命令行 CRUD 指令。
-- **自动更新与依赖管理**:`#chatgpt更新` / `#chatgpt强制更新` 调用 git 同步仓库并自动更新 chaite 依赖,减少手动维护成本。
+## MCP 配置示例
-## 快速安装
+### 1) 本地 stdio(示例:AntV 图表)
-1. **克隆代码**
- ```bash
- cd plugins
- git clone https://github.com/ikechan8370/chatgpt-plugin.git
- ```
-2. **安装依赖**(推荐 Node.js ≥ 18 + pnpm ≥ 8)
- ```bash
- cd chatgpt-plugin
- pnpm install
- ```
- 本地记忆功能使用宿主环境常见的 `sqlite3` 驱动;`sqlite-vec` 仅用于向量检索增强,作为可选依赖处理。若 `sqlite-vec` 在当前平台不可用,记忆功能仍会保留基础存储、关键词检索与 LIKE 回退。
-3. **在 Yunzai 中启用插件**
- - 重启机器人或运行 `node app` 让插件自动加载。
- - 首次启动会在 `plugins/chatgpt-plugin/config/` 下生成 `config.json / config.yaml`。
-4. **保持更新**
- - 主人账号发送 `#chatgpt更新` 获取最新版本。
- - `#chatgpt强制更新` 会放弃本地修改后重新拉取,请谨慎使用。
+```json
+{
+ "id": "antv_chart",
+ "enable": true,
+ "transport": "stdio",
+ "command": "npx",
+ "args": ["-y", "@antv/mcp-server-chart"]
+}
+```
-## 配置指引
+### 2) 远程 streamable-http(示例:阿里云代码解释器)
+
+```json
+{
+ "id": "code_interpreter_mcp",
+ "enable": true,
+ "type": "streamableHttp",
+ "transport": "streamable-http",
+ "baseUrl": "https://dashscope.aliyuncs.com/api/v1/mcps/code_interpreter_mcp/mcp",
+ "headers": {
+ "Authorization": "Bearer ${DASHSCOPE_API_KEY}"
+ }
+}
+```
-配置文件默认位于 `plugins/chatgpt-plugin/config/config.json`,也可改写为 YAML。常用字段示例:
+环境变量建议放在:`config/pm2.yaml`
```yaml
-basic:
- toggleMode: at # at / prefix
- togglePrefix: "#chat" # prefix 模式下的触发词
- commandPrefix: "#chatgpt" # 管理指令前缀
-llm:
- defaultModel: "gpt-4o-mini"
- defaultChatPresetId: "default"
- enableGroupContext: true
- groupContextLength: 20
-bym:
- enable: false
- probability: 0.02
- defaultPreset: "bym_default"
-chaite:
- cloudApiKey: "" # 可选,接入 Chaite Cloud
- host: "0.0.0.0"
- port: 48370
- publicBaseUrl: "" # 可选,自定义访问地址,如 https://example.com
-memory:
- group:
- enable: false
- enabledGroups: ["123456"]
- user:
- enable: false
- whitelist: ["123456789"]
+env:
+ app_type: pm2
+ DASHSCOPE_API_KEY: "你的 DashScope Key"
```
-- **basic**:控制触发方式、调试与命令前缀。
-- **llm**:定义默认模型、嵌入模型、群上下文等。`defaultChatPresetId` 需在面板或命令中提前创建。
-- **chaite**:`storage` 默认 SQLite,会在 `plugins/chatgpt-plugin/data/data.db` 生成数据文件;如接入 Chaite Cloud,请填入 `cloudApiKey` 并开放 `host/port`。`publicBaseUrl` 会作为“自定义地址”单独发送;插件还会列出本机网卡内网地址,并通过 ip.sb / ip.me 尝试获取公网 IPv4 / IPv6。
-- **bym**:配置伪人触发概率、关键词映射、撤回与思考内容开关。
-- **memory**:为群记忆或私人记忆开启检索、模型与提示词,可按需启用 `extensions.simple` 以加载自定义词典。
-
-修改后保存文件,插件会自动热加载;在 Chaite 面板修改配置时也会反向写回本地文件。
-
-## 使用方式
-
-### 基础对话
-
-- `@Bot 你好` 或 `#chat 今天天气如何` 触发默认预设,插件会保持用户 `conversationId` 与 `messageId`,自动续写多轮对话。
-- 回复图片/文本可作为上下文输入,模型返回的图片、语音与思考内容会自动转换为 QQ 消息或转发记录。
-- `#结束对话` 仅清空自己的会话;`#结束全部对话` 需主人权限。
-
-### 管理命令 & 面板
-
-- `#chatgpt管理面板`:私聊发送一条转发消息,包含自定义地址、内网地址、外网地址三类一次性登录入口;公网、NAT 或反代场景请配置 `chaite.publicBaseUrl`。
-- CRUD 命令示例(均支持 `列表 / 添加 / 查看 / 删除`):
- ```
- #chatgpt渠道列表
- #chatgpt预设添加 角色扮演 {...}
- #chatgpt工具删除 web-search
- #chatgpt处理器查看 markdown
- ```
-- `#chatgpt调试模式开关`、`#chatgpt伪人开关` 等指令可快速切换全局开关。
-
-### 伪人(BYM)模式
-
-1. 在配置中启用 `bym.enable` 并指定 `defaultPreset` 或 `presetMap`。
-2. 伪人会在命中关键词或达到概率阈值时主动发言,可通过 `presetPrefix` 调整统一人设,`temperature/maxTokens` 控制语气与长度。
-3. 支持为不同关键词配置 `recall` 秒数,实现“发完撤回”效果。
-
-### 记忆系统
-
-- **群记忆指令**
- ```
- #群记忆 #仅群聊
- #删除群记忆 1 #主人或群管
- #记忆列表 #主人查看全局开关
- ```
-- **私人记忆指令**
- ```
- #记忆 / 我的记忆
- #他的记忆 @xxx #群聊内查看他人(需其授权)
- #删除记忆 1
- ```
-- 记忆抽取依赖配置中的 `memory.group` / `memory.user` 模型与预设,collector 会定期读取群聊历史,必要时可在 `enabledGroups` 中按群号白名单控制。
-
-### 更新与维护
-
-- `#chatgpt更新`:git pull 插件仓库并使用 pnpm/npm 更新 chaite 依赖。
-- `#chatgpt强制更新`:在更新前执行 `git checkout .`,用于舍弃本地改动。
-- 日志会通过转发消息发送最近 20 条 commit,方便追踪版本变化。
-
-
-## 赞助
-
-如果觉得本项目好玩或者对你有帮助,愿意的话可以赞助我一口快乐水:
+## 常用命令
-https://afdian.net/a/ikechan8370
+- `#chatgpt管理面板`
+- `#chatgpt查看状态`
+- `#结束对话`
+- `#chatgpt开启思考转发`
+- `#chatgpt关闭思考转发`
-## 贡献者
+MCP 相关:
-感谢以下贡献者
+- `#chatgpt刷新MCP`
+- `#chatgpt重载MCP工具`
-
-
-
+这两个命令会重新连接已启用 MCP 服务并重建桥接工具,不必重启机器人。
+## 故障排查
-
+- 提示 `No API-key provided`:检查 `DASHSCOPE_API_KEY` 是否注入到 PM2 环境。
+- 启动后无 MCP 工具:
+ - 确认 `mcp.enable=true`
+ - 确认服务项 `enable=true`
+ - 发送 `#chatgpt刷新MCP` 强制重载
+- `stdio` 服务连接失败:检查 `command/args` 是否可在当前机器执行。
-## Star History
+## 目录说明
-[](https://star-history.com/#ikechan8370/chatgpt-plugin&Date)
+- `apps/`:命令与消息入口
+- `models/`:Chaite 与存储实现
+- `utils/mcp/`:MCP 注册与桥接管理
+- `data/`:运行时配置与数据
diff --git a/apps/bym.js b/apps/bym.js
index 2fef08a8..117a11c7 100644
--- a/apps/bym.js
+++ b/apps/bym.js
@@ -3,7 +3,7 @@ import { Chaite } from 'chaite'
import { intoUserMessage, toYunzai } from '../utils/message.js'
import common from '../../../lib/common/common.js'
import { getGroupContextPrompt, buildGroupContextMessages, getGroupHistory } from '../utils/group.js'
-import { formatTimeToBeiJing } from '../utils/common.js'
+import { formatTimeToBeiJing, parseBooleanFlag } from '../utils/common.js'
import { extractTextFromUserMessage, processUserMemory } from '../models/memory/userMemoryManager.js'
import { buildMemoryPrompt } from '../models/memory/prompt.js'
import { isVisualModelForSendOptions, visionService } from '../utils/vision.js'
@@ -131,8 +131,10 @@ export class bym extends plugin {
if (msgs.length > 0) {
await e.reply(msgs)
}
- for (let forwardElement of forward) {
- this.reply(forwardElement)
+ if (parseBooleanFlag(ChatGPTConfig.bym.sendReasoning, false)) {
+ for (let forwardElement of forward) {
+ this.reply(forwardElement)
+ }
}
}
// === 缓存友好的消息顺序:稳定内容在前,动态内容在后 ===
@@ -250,7 +252,7 @@ export class bym extends plugin {
await common.sleep(Math.floor(Math.random() * 2000) + 1000)
}
}
- if (ChatGPTConfig.bym.sendReasoning) {
+ if (parseBooleanFlag(ChatGPTConfig.bym.sendReasoning, false)) {
for (let forwardElement of forward) {
await e.reply(forwardElement, false, { recallMsg: recall ? 10 : 0 })
}
diff --git a/apps/chat.js b/apps/chat.js
index eed778fa..402a6f46 100644
--- a/apps/chat.js
+++ b/apps/chat.js
@@ -5,6 +5,7 @@ import { YunzaiUserState } from '../models/chaite/storage/lowdb/user_state_stora
import { getGroupContextPrompt, buildGroupContextMessages, getGroupHistory } from '../utils/group.js'
import { buildMemoryPrompt } from '../models/memory/prompt.js'
import { extractTextFromUserMessage, processUserMemory } from '../models/memory/userMemoryManager.js'
+import { parseBooleanFlag } from '../utils/common.js'
import { isVisualModelForSendOptions, visionService } from '../utils/vision.js'
import * as crypto from 'node:crypto'
import fetch from 'node-fetch'
@@ -64,8 +65,10 @@ export class Chat extends plugin {
if (msgs.length > 0) {
await e.reply(msgs)
}
- for (let forwardElement of forward) {
- this.reply(forwardElement)
+ if (parseBooleanFlag(Config.basic.sendReasoning, true)) {
+ for (let forwardElement of forward) {
+ this.reply(forwardElement)
+ }
}
}
const userMessage = await intoUserMessage(e, {
@@ -214,8 +217,7 @@ export class Chat extends plugin {
if (msgs.length > 0) {
await e.reply(msgs, true)
}
- // 与 bym 模式共用 sendReasoning 开关
- if (Config.bym.sendReasoning) {
+ if (parseBooleanFlag(Config.bym.sendReasoning, true)) {
for (let forwardElement of forward) {
this.reply(forwardElement)
}
diff --git a/apps/management.js b/apps/management.js
index 39513298..32e384a2 100644
--- a/apps/management.js
+++ b/apps/management.js
@@ -5,6 +5,8 @@ import * as crypto from 'node:crypto'
import * as os from 'node:os'
import fetch from 'node-fetch'
import common from '../../../lib/common/common.js'
+import { parseBooleanFlag } from '../utils/common.js'
+import { initMcpCompatibility } from '../utils/mcp/manager.js'
export class ChatGPTManagement extends plugin {
constructor () {
@@ -29,13 +31,23 @@ export class ChatGPTManagement extends plugin {
fnc: 'setDefaultBymPreset',
permission: 'master'
},
+ {
+ reg: `^${cmdPrefix}(开启|关闭)(普通对话)?(思考|推理)(过程)?(转发|回显)$`,
+ fnc: 'toggleChatReasoningForward',
+ permission: 'master'
+ },
{
reg: `^${cmdPrefix}(查看)?(当前)?(配置|信息|统计信息|状态)$`,
fnc: 'currentStatus',
permission: 'master'
},
{
- reg: `^${cmdPrefix}确认MCP\s+[a-zA-Z0-9-]+$`,
+ reg: `^${cmdPrefix}(刷新|重载)(MCP|mcp)(工具)?$`,
+ fnc: 'refreshMcpTools',
+ permission: 'master'
+ },
+ {
+ reg: `^${cmdPrefix}确认MCP\\s+[a-zA-Z0-9-]+$`,
fnc: 'confirmMcpDraft',
permission: 'master'
}
@@ -135,6 +147,16 @@ export class ChatGPTManagement extends plugin {
}
}
+ toggleChatReasoningForward (e) {
+ const enable = e.msg.includes('开启')
+ ChatGPTConfig.basic.sendReasoning = enable
+ if (typeof ChatGPTConfig.saveToFile === 'function') {
+ ChatGPTConfig.saveToFile('code')
+ }
+ logger.info(`[ChatGPT-Plugin] set basic.sendReasoning=${ChatGPTConfig.basic.sendReasoning} (type=${typeof ChatGPTConfig.basic.sendReasoning})`)
+ this.reply(`普通对话思考过程转发已${enable ? '开启' : '关闭'}`)
+ }
+
async destroyConversation (e) {
if (e.msg.includes('全部')) {
if (!e.isMaster) {
@@ -197,6 +219,9 @@ export class ChatGPTManagement extends plugin {
const defaultChatPresetId = ChatGPTConfig.llm.defaultChatPresetId
const currentPreset = await Chaite.getInstance().getChatPresetManager().getInstance(defaultChatPresetId)
msgs.push(`当前预设:${currentPreset?.name || '未设置'}${currentPreset ? ('\n\n' + currentPreset.toFormatedString(false)) : ''}`)
+ const chatReasoningEnabled = parseBooleanFlag(ChatGPTConfig.basic.sendReasoning, true)
+ const bymReasoningEnabled = parseBooleanFlag(ChatGPTConfig.bym.sendReasoning, false)
+ msgs.push(`普通对话思考过程转发:${chatReasoningEnabled ? '开启' : '关闭'}\n伪人思考过程转发:${bymReasoningEnabled ? '开启' : '关闭'}`)
const allTools = await Chaite.getInstance().getToolsManager().listInstances()
let toolsMsg = `工具总数:${allTools.length}\n`
@@ -217,6 +242,30 @@ export class ChatGPTManagement extends plugin {
const m = await common.makeForwardMsg(e, msgs, e.msg)
e.reply(m)
}
+
+ async refreshMcpTools (e) {
+ if (!ChatGPTConfig.mcp?.enable) {
+ await this.reply('MCP 未开启,请先在配置中启用 mcp.enable')
+ return false
+ }
+
+ const toolsManager = Chaite.getInstance().getToolsManager()
+ const before = await toolsManager.listInstances()
+
+ await this.reply('开始刷新 MCP 工具,请稍候...')
+ try {
+ await initMcpCompatibility(toolsManager)
+ const after = await toolsManager.listInstances()
+ const mcpPrefix = `${ChatGPTConfig.mcp?.toolNamePrefix || 'mcp'}_`
+ const mcpCount = after.filter(t => String(t?.name || '').startsWith(mcpPrefix)).length
+ await this.reply(`MCP 工具刷新完成\n工具总数:${before.length} -> ${after.length}\n桥接工具数:${mcpCount}`)
+ return true
+ } catch (err) {
+ logger.error('[MCP] 手动刷新工具失败:', err)
+ await this.reply(`MCP 工具刷新失败:${err?.message || err}`)
+ return false
+ }
+ }
}
function getCustomPanelBaseUrlEntries () {
diff --git a/config/config.js b/config/config.js
index c2038c3a..a4790a52 100644
--- a/config/config.js
+++ b/config/config.js
@@ -13,6 +13,7 @@ class ChatGPTConfig {
* 基本配置
* @type {{
* toggleMode: 'at' | 'prefix',
+ * sendReasoning: boolean,
* debug: boolean,
* }}
*/
@@ -21,6 +22,8 @@ class ChatGPTConfig {
toggleMode: 'at',
// 触发前缀,仅在前缀触发时有效
togglePrefix: '#chat',
+ // @ 或前缀触发对话时,是否发送思考/工具过程转发消息
+ sendReasoning: false,
// 是否开启调试模式
debug: false,
// 一般命令的开头
@@ -119,7 +122,7 @@ class ChatGPTConfig {
// 如果blockStrategy为mask,屏蔽词的替换字符
blockWordMask: '***',
// 是否开启群组上下文
- enableGroupContext: false,
+ enableGroupContext: true,
// 是否在多轮普通对话中保留每轮动态上下文(群聊记录、时间、记忆)。
// 关闭可显著减少 token;支持上下文缓存的渠道可改为开启。
retainDynamicContextHistory: false,
@@ -229,6 +232,47 @@ class ChatGPTConfig {
enableGroupContextImages: true
}
+ /**
+ * MCP 兼容配置(标准 Model Context Protocol)
+ * @type {{
+ * enable: boolean,
+ * toolNamePrefix: string,
+ * removeStaleBridgeToolsOnStart: boolean,
+ * servers: Array<{
+ * id: string,
+ * enable: boolean,
+ * transport: 'stdio' | 'sse' | 'streamable-http',
+ * command?: string,
+ * args?: string[],
+ * env?: Record,
+ * cwd?: string,
+ * url?: string,
+ * includeTools?: string[],
+ * excludeTools?: string[]
+ * }>
+ * }}
+ */
+ mcp = {
+ enable: false,
+ // 生成到 Chaite 工具池中的前缀,避免与本地工具重名
+ toolNamePrefix: 'mcp',
+ // 启动时不清理旧桥接工具,避免触发工具文件频繁 unlink/rescan 日志刷屏
+ removeStaleBridgeToolsOnStart: false,
+ servers: [
+ {
+ id: 'filesystem',
+ enable: false,
+ transport: 'stdio',
+ command: '',
+ args: [],
+ env: {},
+ cwd: '',
+ includeTools: [],
+ excludeTools: []
+ }
+ ]
+ }
+
/**
* 记忆系统配置
* @type {{
@@ -629,7 +673,7 @@ Return a JSON array of strings only, without any other characters including \`\`
return result
}
- const sections = ['version', 'basic', 'bym', 'llm', 'management', 'chaite', 'vision', 'memory']
+ const sections = ['version', 'basic', 'bym', 'llm', 'management', 'chaite', 'mcp', 'vision', 'memory']
for (const key of sections) {
const loadedValue = loadedConfig[key]
if (loadedValue === undefined) {
@@ -679,6 +723,7 @@ Return a JSON array of strings only, without any other characters including \`\`
llm: this.llm,
management: this.management,
chaite: this.chaite,
+ mcp: this.mcp,
vision: this.vision,
memory: this.memory
}
@@ -702,6 +747,7 @@ Return a JSON array of strings only, without any other characters including \`\`
llm: this.llm,
management: this.management,
chaite: this.chaite,
+ mcp: this.mcp,
vision: this.vision,
memory: this.memory
}
diff --git a/index.js b/index.js
index 12672820..d38ae11c 100644
--- a/index.js
+++ b/index.js
@@ -36,7 +36,13 @@ global.chatgpt = {
}
-ChatGPTConfig.startSync('./plugins/chatgpt-plugin/data')
+// startSync returns proxied objects; bind them back so nested writes auto-persist
+const proxiedConfig = ChatGPTConfig.startSync('./plugins/chatgpt-plugin/data')
+for (const key of ['basic', 'bym', 'llm', 'management', 'chaite', 'mcp', 'memory']) {
+ if (proxiedConfig?.[key]) {
+ ChatGPTConfig[key] = proxiedConfig[key]
+ }
+}
initChaite()
logger.info('chatgpt-plugin加载成功')
logger.info(`当前版本${ChatGPTConfig.version}`)
diff --git a/models/chaite/cloud.js b/models/chaite/cloud.js
index 2098f44c..e9ea7c19 100644
--- a/models/chaite/cloud.js
+++ b/models/chaite/cloud.js
@@ -35,6 +35,7 @@ import SQLiteTriggerStorage from './storage/sqlite/trigger_storage.js'
import LowDBTriggerStorage from './storage/lowdb/trigger_storage,.js'
import { createChaiteVectorizer } from './vectorizer.js'
import { MemoryRouter, authenticateMemoryRequest } from '../memory/router.js'
+import { disposeMcpCompatibility, initMcpCompatibility } from '../../utils/mcp/manager.js'
import { SQLiteOperationLogStorage } from './storage/sqlite/operation_log_storage.js'
import { SQLiteMcpServerStorage } from './storage/sqlite/mcp_server_storage.js'
import { LowDBMcpServerStorage } from './storage/lowdb/mcp_server_storage.js'
@@ -160,6 +161,7 @@ export async function initChaite () {
}
}
await initRagManager(ChatGPTConfig.llm.embeddingModel, ChatGPTConfig.llm.dimensions)
+ await initMcpCompatibility(toolsManager)
if (!ChatGPTConfig.chaite.authKey) {
ChatGPTConfig.chaite.authKey = Chaite.getInstance().getFrontendAuthHandler().generateToken(0, true)
}
@@ -167,8 +169,8 @@ export async function initChaite () {
// 监听Chaite配置变化,同步需要同步的配置
chaite.on('config-change', obj => {
const { key, newVal, oldVal } = obj
- if (key === 'authKey') {
- ChatGPTConfig.serverAuthKey = newVal
+ if (key === 'authKey' && newVal && ChatGPTConfig.chaite?.authKey !== newVal) {
+ ChatGPTConfig.chaite.authKey = newVal
}
logger.debug(`Chaite config changed: ${key} from ${oldVal} to ${newVal}`)
})
@@ -180,6 +182,16 @@ export async function initChaite () {
ChatGPTConfig._saveOrigin = 'chaite'
try {
+ const currentAuthKey = ChatGPTConfig.chaite?.authKey || chaite.getGlobalConfig().getAuthKey()
+ if (config?.chaite && typeof config.chaite === 'object') {
+ const incomingAuthKey = typeof config.chaite.authKey === 'string'
+ ? config.chaite.authKey.trim()
+ : config.chaite.authKey
+ if (!incomingAuthKey) {
+ delete config.chaite.authKey
+ }
+ }
+
Object.keys(config).forEach(key => {
if (typeof config[key] === 'object' && config[key] !== null && ChatGPTConfig[key]) {
deepMerge(ChatGPTConfig[key], config[key])
@@ -188,9 +200,15 @@ export async function initChaite () {
}
})
+ if (!ChatGPTConfig.chaite.authKey && currentAuthKey) {
+ ChatGPTConfig.chaite.authKey = currentAuthKey
+ }
+
// 回传部分需要同步的配置
chaite.getGlobalConfig().setDebug(ChatGPTConfig.basic.debug)
- chaite.getGlobalConfig().setAuthKey(ChatGPTConfig.chaite.authKey)
+ if (ChatGPTConfig.chaite.authKey && chaite.getGlobalConfig().getAuthKey() !== ChatGPTConfig.chaite.authKey) {
+ chaite.getGlobalConfig().setAuthKey(ChatGPTConfig.chaite.authKey)
+ }
operationLogStorage?.setMaxEntries(ChatGPTConfig.chaite.operationLogLimit).catch(error => logger.warn(`更新操作日志保留条数失败: ${error.message}`))
// 使用新的触发保存方法,而不是直接调用saveToFile
@@ -213,6 +231,10 @@ export async function initChaite () {
}, {
frontendDir: path.resolve('./plugins/chatgpt-plugin/resources/admin')
})
+
+ process.once('beforeExit', async () => {
+ await disposeMcpCompatibility()
+ })
}
function registerManagementPanelAutoLogin (app) {
diff --git a/models/memory/extractor.js b/models/memory/extractor.js
index a87b1649..ed29da59 100644
--- a/models/memory/extractor.js
+++ b/models/memory/extractor.js
@@ -13,6 +13,38 @@ function collectTextFromResponse (response) {
.trim()
}
+function stripJsonComments (text) {
+ // Character-based JSON comment stripper.
+ // Removes // line comments outside of JSON string values.
+ let result = ''
+ let inString = false
+ let i = 0
+ while (i < text.length) {
+ const ch = text[i]
+ if (inString) {
+ result += ch
+ if (ch === '\\') {
+ if (i + 1 < text.length) result += text[++i]
+ } else if (ch === '"') {
+ inString = false
+ }
+ } else {
+ if (ch === '\\') {
+ if (i + 1 < text.length) result += text[++i]
+ } else if (ch === '"') {
+ inString = true
+ result += ch
+ } else if (ch === '/' && text[i + 1] === '/') {
+ while (i < text.length && text[i] !== '\n') i++
+ } else {
+ result += ch
+ }
+ }
+ i++
+ }
+ return result
+}
+
function parseJSON (text) {
if (!text) {
return null
@@ -23,8 +55,13 @@ function parseJSON (text) {
try {
return JSON.parse(payload)
} catch (err) {
- logger.warn('Failed to parse JSON from memory extractor response:', text)
- return null
+ // Retry after stripping JSON comments (some models like DeepSeek-R1 add // comments)
+ try {
+ return JSON.parse(stripJsonComments(payload))
+ } catch (err2) {
+ logger.warn('Failed to parse JSON from memory extractor response:', text)
+ return null
+ }
}
}
@@ -222,15 +259,11 @@ async function callModel ({ prompt, systemPrompt, model, maxToken = 4096, temper
}
const resolvedModel = options.model
const { client } = await getClientForModel(resolvedModel)
- const response = await client.sendMessage({
- role: 'user',
- content: [
- {
- type: 'text',
- text: prompt
- }
- ]
- }, SendMessageOption.create({
+ // 记忆提取不需要工具调用,清空 client 上的工具列表,防止不支持 function calling 的模型报 400
+ client.tools = []
+ client.options.tools = []
+ client.options.builtinToolCategories = []
+ const smeOption = SendMessageOption.create({
...options,
model: options.model,
temperature: options.temperature ?? temperature,
@@ -238,8 +271,24 @@ async function callModel ({ prompt, systemPrompt, model, maxToken = 4096, temper
systemOverride: systemPrompt ?? options.systemOverride,
disableHistoryRead: true,
disableHistorySave: true,
- stream: false
- }))
+ stream: false,
+ builtinToolCategories: []
+ })
+ const userMsg = {
+ role: 'user',
+ content: [
+ {
+ type: 'text',
+ text: prompt
+ }
+ ]
+ }
+ let response
+ try {
+ response = await client.sendMessage(userMsg, smeOption)
+ } catch (err) {
+ throw err
+ }
return collectTextFromResponse(response)
}
@@ -303,6 +352,10 @@ export async function extractGroupFacts (messages) {
logger.debug('[Memory] group fact extraction returned non-array content')
return []
} catch (err) {
+ const detail = err?.error ?? err?.body ?? err?.response ?? err?.cause
+ if (detail) {
+ logger.error('Failed to extract group facts (API detail):', typeof detail === 'object' ? JSON.stringify(detail) : detail)
+ }
logger.error('Failed to extract group facts:', err)
return []
}
@@ -352,6 +405,10 @@ export async function extractUserMemories (messages, existingMemories = []) {
logger.debug('[Memory] user memory extraction returned non-array content')
return []
} catch (err) {
+ const detail = err?.error ?? err?.body ?? err?.response ?? err?.cause
+ if (detail) {
+ logger.error('Failed to extract user memories:', typeof detail === 'object' ? JSON.stringify(detail) : detail)
+ }
logger.error('Failed to extract user memories:', err)
return []
}
diff --git a/package.json b/package.json
index 011cd89b..e8f501fd 100644
--- a/package.json
+++ b/package.json
@@ -4,18 +4,21 @@
"type": "module",
"author": "ikechan8370",
"dependencies": {
+ "@modelcontextprotocol/sdk": "^1.29.0",
"adm-zip": "^0.5.10",
- "chaite": "^1.8.2",
+ "better-sqlite3": "^12.8.0",
+ "chaite": "^1.13.0",
"js-yaml": "^4.1.0",
"keyv": "^5.3.1",
"keyv-file": "^5.1.2",
"lowdb": "^7.0.1",
+ "puppeteer-extra": "^3.3.6",
+ "puppeteer-extra-plugin-stealth": "^2.11.2",
+ "sqlite-vec": "^0.1.7-alpha.2",
+ "turndown": "^7.2.2",
"vectra": "^0.9.0",
"zod": "^3.25.76"
},
- "optionalDependencies": {
- "sqlite-vec": "^0.1.7-alpha.2"
- },
"peerDependencies": {
"sqlite3": ">=5 <6"
},
diff --git a/readme_old.md b/readme_old.md
new file mode 100644
index 00000000..37ea4144
--- /dev/null
+++ b/readme_old.md
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+> 插件v3大幅重构中,基本可用,持续完善中。遇到问题请提issue,欢迎PR。
+> todo列表:
+> - [x] 插件v3重构完成,插件基本功能可用,持续完善中。
+> - [ ] RAG知识库
+> - [ ] 预设更详细的配置
+> - [x] 自定义触发器
+> - [ ] 自定义插件
+> - [ ] 兼容mcp
+
+
+## 插件简介
+
+ChatGPT-Plugin 以 Chaite 为内核,将多模型渠道、工具、处理器、触发器、RAG 和管控面板封装成一套适配 Miao-Yunzai / Yunzai-Bot 的插件方案。通过 Chaite 的 API 服务器与可插拔的存储层(默认 SQLite),插件可以在本地完成高并发对话、知识库检索、伪人陪聊以及记忆管理,亦可接入 Chaite Cloud 复用在线渠道与工具。
+
+## 核心特性
+
+- **多渠道与预设体系**:依托 Chaite 的 ChannelsManager 与 ChatPresetManager,支持为不同模型配置流量、负载均衡与个性化 prompt,群友也可在授权后自助切换预设。
+- **高级消息适配**:前后文触发方式支持 `@Bot` 与前缀;自动处理引用、图片、语音等多模态输入,并在工具调用或推理阶段通过转发消息回显。
+- **群上下文与伪人模式**:可按配置注入指定条数的群聊记录;BYM 伪人模式支持概率触发、关键词命中、预设覆盖及限时撤回,营造更拟人的陪聊体验。
+- **记忆与 RAG**:内置 memoryService + vectra 向量索引,提供群记忆、私人记忆与外部知识库(RAGManager)注入能力,支持混合检索与手动管理。
+- **可视化与指令双管控**:`#chatgpt管理面板` 一键获取面板 token,Web 端即可操作渠道、工具、触发器;同时保留完整的命令行 CRUD 指令。
+- **自动更新与依赖管理**:`#chatgpt更新` / `#chatgpt强制更新` 调用 git 同步仓库并自动更新 chaite 依赖,减少手动维护成本。
+
+## 快速安装
+
+1. **克隆代码**
+ ```bash
+ cd plugins
+ git clone https://github.com/ikechan8370/chatgpt-plugin.git
+ ```
+2. **安装依赖**(推荐 Node.js ≥ 18 + pnpm ≥ 8)
+ ```bash
+ cd chatgpt-plugin
+ pnpm install
+ ```
+ 若安装日志出现 `Ignored build scripts: better-sqlite3` 或运行时报错找不到 `better-sqlite3` bindings,可执行 `pnpm approve-builds`,在交互列表中勾选全部或仅 `better-sqlite3` 以允许编译。然后再次 `pnpm install`
+3. **在 Yunzai 中启用插件**
+ - 重启机器人或运行 `node app` 让插件自动加载。
+ - 首次启动会在 `plugins/chatgpt-plugin/config/` 下生成 `config.json / config.yaml`。
+4. **保持更新**
+ - 主人账号发送 `#chatgpt更新` 获取最新版本。
+ - `#chatgpt强制更新` 会放弃本地修改后重新拉取,请谨慎使用。
+
+## 配置指引
+
+配置文件默认位于 `plugins/chatgpt-plugin/config/config.json`,也可改写为 YAML。常用字段示例:
+
+```yaml
+basic:
+ toggleMode: at # at / prefix
+ togglePrefix: "#chat" # prefix 模式下的触发词
+ sendReasoning: true # 普通对话是否转发思考/工具过程
+ commandPrefix: "#chatgpt" # 管理指令前缀
+llm:
+ defaultModel: "gpt-4o-mini"
+ defaultChatPresetId: "default"
+ enableGroupContext: true
+ groupContextLength: 20
+bym:
+ enable: false
+ probability: 0.02
+ defaultPreset: "bym_default"
+chaite:
+ cloudApiKey: "" # 可选,接入 Chaite Cloud
+ host: "0.0.0.0"
+ port: 48370
+memory:
+ group:
+ enable: false
+ enabledGroups: ["123456"]
+ user:
+ enable: false
+ whitelist: ["123456789"]
+```
+
+- **basic**:控制触发方式、普通对话思考过程转发、调试与命令前缀。
+- **llm**:定义默认模型、嵌入模型、群上下文等。`defaultChatPresetId` 需在面板或命令中提前创建。
+- **chaite**:`storage` 默认 SQLite,会在 `plugins/chatgpt-plugin/data/data.db` 生成数据文件;如接入 Chaite Cloud,请填入 `cloudApiKey` 并开放 `host/port`。
+- **bym**:配置伪人触发概率、关键词映射、撤回与思考内容开关。
+- **memory**:为群记忆或私人记忆开启检索、模型与提示词,可按需启用 `extensions.simple` 以加载自定义词典。
+
+修改后保存文件,插件会自动热加载;在 Chaite 面板修改配置时也会反向写回本地文件。
+
+## 使用方式
+
+### 基础对话
+
+- `@Bot 你好` 或 `#chat 今天天气如何` 触发默认预设,插件会保持用户 `conversationId` 与 `messageId`,自动续写多轮对话。
+- 回复图片/文本可作为上下文输入,模型返回的图片、语音与思考内容会自动转换为 QQ 消息或转发记录。
+- `#结束对话` 仅清空自己的会话;`#结束全部对话` 需主人权限。
+
+### 管理命令 & 面板
+
+- `#chatgpt管理面板`:生成一次性 token,访问 `http://:` 即可使用 Chaite Web 面板。
+- `#chatgpt开启思考转发` / `#chatgpt关闭思考转发`:控制 `@Bot` 或前缀触发普通对话时是否发送思考过程转发消息。
+- CRUD 命令示例(均支持 `列表 / 添加 / 查看 / 删除`):
+ ```
+ #chatgpt渠道列表
+ #chatgpt预设添加 角色扮演 {...}
+ #chatgpt工具删除 web-search
+ #chatgpt处理器查看 markdown
+ ```
+- `#chatgpt调试模式开关`、`#chatgpt伪人开关` 等指令可快速切换全局开关。
+
+### 伪人(BYM)模式
+
+1. 在配置中启用 `bym.enable` 并指定 `defaultPreset` 或 `presetMap`。
+2. 伪人会在命中关键词或达到概率阈值时主动发言,可通过 `presetPrefix` 调整统一人设,`temperature/maxTokens` 控制语气与长度。
+3. 支持为不同关键词配置 `recall` 秒数,实现“发完撤回”效果。
+
+### 记忆系统
+
+- **群记忆指令**
+ ```
+ #群记忆 #仅群聊
+ #删除群记忆 1 #主人或群管
+ #记忆列表 #主人查看全局开关
+ ```
+- **私人记忆指令**
+ ```
+ #记忆 / 我的记忆
+ #他的记忆 @xxx #群聊内查看他人(需其授权)
+ #删除记忆 1
+ ```
+- 记忆抽取依赖配置中的 `memory.group` / `memory.user` 模型与预设,collector 会定期读取群聊历史,必要时可在 `enabledGroups` 中按群号白名单控制。
+
+### 更新与维护
+
+- `#chatgpt更新`:git pull 插件仓库并使用 pnpm/npm 更新 chaite 依赖。
+- `#chatgpt强制更新`:在更新前执行 `git checkout .`,用于舍弃本地改动。
+- 日志会通过转发消息发送最近 20 条 commit,方便追踪版本变化。
+
+
+## 赞助
+
+如果觉得本项目好玩或者对你有帮助,愿意的话可以赞助我一口快乐水:
+
+https://afdian.net/a/ikechan8370
+
+## 贡献者
+
+感谢以下贡献者
+
+
+
+
+
+
+
+
+## Star History
+
+[](https://star-history.com/#ikechan8370/chatgpt-plugin&Date)
+
diff --git a/utils/common.js b/utils/common.js
index bb697f90..409ff52c 100644
--- a/utils/common.js
+++ b/utils/common.js
@@ -75,3 +75,29 @@ if (!fs.existsSync(dataDir)) {
export function generateId () {
return Date.now().toString(36) + Math.random().toString(36).substring(2, 15)
}
+
+/**
+ * Parse config flag values from panel/file into boolean.
+ * Accepts: true/false, 1/0, on/off, yes/no, enable/disable.
+ * @param {any} value
+ * @param {boolean} defaultValue
+ * @returns {boolean}
+ */
+export function parseBooleanFlag (value, defaultValue = false) {
+ if (typeof value === 'boolean') {
+ return value
+ }
+ if (typeof value === 'number') {
+ return value !== 0
+ }
+ if (typeof value === 'string') {
+ const normalized = value.trim().toLowerCase()
+ if (['true', '1', 'on', 'yes', 'y', 'enable', 'enabled', '开启'].includes(normalized)) {
+ return true
+ }
+ if (['false', '0', 'off', 'no', 'n', 'disable', 'disabled', '关闭'].includes(normalized)) {
+ return false
+ }
+ }
+ return defaultValue
+}
diff --git a/utils/mcp/manager.js b/utils/mcp/manager.js
new file mode 100644
index 00000000..d5f4f361
--- /dev/null
+++ b/utils/mcp/manager.js
@@ -0,0 +1,335 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js'
+import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import ChatGPTConfig from '../../config/config.js'
+import { md5 } from '../common.js'
+import {
+ clearMcpToolRoutes,
+ disposeMcpRegistry,
+ registerMcpServerClient,
+ registerMcpToolRoute
+} from './registry.js'
+
+const BRIDGE_TOOL_ID_PREFIX = 'mcp_bridge_'
+
+function resolveEnvPlaceholders (value) {
+ if (typeof value !== 'string') {
+ return value
+ }
+ return value.replace(/\$\{([A-Z0-9_]+)\}/gi, (_m, varName) => {
+ const envVal = process.env[varName]
+ return envVal == null ? '' : String(envVal)
+ })
+}
+
+function normalizeTransportType (server) {
+ const raw = String(server.transport || server.type || 'stdio').trim().toLowerCase()
+ if (raw === 'streamablehttp' || raw === 'streamable-http' || raw === 'streamable_http') {
+ return 'streamable-http'
+ }
+ if (raw === 'sse') {
+ return 'sse'
+ }
+ return 'stdio'
+}
+
+function normalizeServerId (server) {
+ return String(server.id || server.name || '').trim()
+}
+
+function normalizeServerEnabled (server) {
+ if (typeof server.enable === 'boolean') {
+ return server.enable
+ }
+ if (typeof server.isActive === 'boolean') {
+ return server.isActive
+ }
+ return false
+}
+
+function sanitizeToolName (name = '') {
+ return String(name).replace(/[^a-zA-Z0-9_]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '')
+}
+
+function buildBridgeToolName (serverId, mcpToolName) {
+ const prefix = sanitizeToolName(ChatGPTConfig.mcp?.toolNamePrefix || 'mcp') || 'mcp'
+ const s = sanitizeToolName(serverId) || 'server'
+ const t = sanitizeToolName(mcpToolName) || 'tool'
+ return `${prefix}_${s}_${t}`
+}
+
+function normalizeSchemaTypeName (typeName) {
+ if (typeof typeName !== 'string') {
+ return typeName
+ }
+
+ const lowered = typeName.trim().toLowerCase()
+ switch (lowered) {
+ case 'bool':
+ return 'boolean'
+ case 'int':
+ return 'integer'
+ case 'float':
+ case 'double':
+ return 'number'
+ default:
+ return lowered
+ }
+}
+
+function normalizeJsonSchema (schema) {
+ if (Array.isArray(schema)) {
+ return schema.map(normalizeJsonSchema)
+ }
+
+ if (!schema || typeof schema !== 'object') {
+ return schema
+ }
+
+ const out = {}
+ for (const [k, v] of Object.entries(schema)) {
+ if (k === 'type') {
+ out[k] = Array.isArray(v)
+ ? v.map(normalizeSchemaTypeName)
+ : normalizeSchemaTypeName(v)
+ continue
+ }
+ out[k] = normalizeJsonSchema(v)
+ }
+
+ return out
+}
+
+function buildBridgeToolCode (className, bridgeToolName, schema, description = '') {
+ const normalizedSchema = normalizeJsonSchema(schema)
+ const fnSchema = JSON.stringify({
+ name: bridgeToolName,
+ description: description || `MCP bridge tool: ${bridgeToolName}`,
+ parameters: normalizedSchema && typeof normalizedSchema === 'object'
+ ? normalizedSchema
+ : {
+ type: 'object',
+ properties: {},
+ required: []
+ }
+ }, null, 2)
+
+ return `import { CustomTool } from 'chaite'\nimport { callMcpBridgeTool } from '../mcp/registry.js'\n\nclass ${className} extends CustomTool {\n name = '${bridgeToolName}'\n\n function = ${fnSchema}\n\n async run(args) {\n return callMcpBridgeTool('${bridgeToolName}', args || {})\n }\n}\n\nexport default new ${className}()\n`
+}
+
+function shouldIncludeTool (server, mcpToolName) {
+ const includes = Array.isArray(server.includeTools) ? server.includeTools : []
+ const excludes = Array.isArray(server.excludeTools) ? server.excludeTools : []
+
+ if (includes.length > 0 && !includes.includes(mcpToolName)) {
+ return false
+ }
+ if (excludes.includes(mcpToolName)) {
+ return false
+ }
+ return true
+}
+
+async function clearOldBridgeTools (toolsManager) {
+ const all = await toolsManager.listInstances()
+ for (const t of all) {
+ if (t?.id?.startsWith(BRIDGE_TOOL_ID_PREFIX)) {
+ try {
+ await toolsManager.deleteInstance(t.id)
+ } catch (err) {
+ logger.warn(`[MCP] 删除旧桥接工具失败 ${t.id}: ${err?.message || err}`)
+ }
+ }
+ }
+}
+
+async function createStdioTransport (server) {
+ const serverId = normalizeServerId(server)
+ if (!server.command || !String(server.command).trim()) {
+ throw new Error(`MCP server ${serverId} 缺少 command 配置`)
+ }
+
+ const transport = new StdioClientTransport({
+ command: String(server.command).trim(),
+ args: Array.isArray(server.args) ? server.args.map(a => String(a)) : [],
+ env: server.env && typeof server.env === 'object'
+ ? Object.fromEntries(Object.entries(server.env).map(([k, v]) => [String(k), String(v)]))
+ : undefined,
+ cwd: server.cwd && String(server.cwd).trim()
+ ? String(server.cwd).trim()
+ : undefined,
+ stderr: 'pipe'
+ })
+
+ if (transport.stderr) {
+ transport.stderr.on('data', (buf) => {
+ const line = String(buf || '').trim()
+ if (line) {
+ logger.debug(`[MCP:${server.id}:stderr] ${line}`)
+ }
+ })
+ }
+
+ return transport
+}
+
+async function createStreamableHttpTransport (server) {
+ const serverId = normalizeServerId(server)
+ const rawUrl = String(server.baseUrl || server.url || '').trim()
+ if (!rawUrl) {
+ throw new Error(`MCP server ${serverId} 缺少 baseUrl/url 配置`)
+ }
+
+ const rawHeaders = server.headers && typeof server.headers === 'object'
+ ? server.headers
+ : {}
+
+ const headers = {}
+ for (const [k, v] of Object.entries(rawHeaders)) {
+ headers[String(k)] = resolveEnvPlaceholders(String(v || ''))
+ }
+
+ const auth = headers.Authorization || headers.authorization
+ if (/\$\{[A-Z0-9_]+\}/i.test(String(rawHeaders.Authorization || rawHeaders.authorization || ''))) {
+ const authValue = String(auth || '').trim()
+ const hasToken = /^Bearer\s+\S+/i.test(authValue)
+ if (!hasToken) {
+ logger.warn(`[MCP] 服务 ${serverId} 的 Authorization 占位变量未设置,已跳过连接`)
+ return null
+ }
+ }
+
+ const requestInit = {}
+ if (Object.keys(headers).length > 0) {
+ requestInit.headers = headers
+ }
+
+ return new StreamableHTTPClientTransport(new URL(rawUrl), {
+ requestInit
+ })
+}
+
+async function connectServer (server) {
+ const transportType = normalizeTransportType(server)
+ const serverId = normalizeServerId(server)
+
+ const client = new Client(
+ {
+ name: 'chatgpt-plugin-mcp-client',
+ version: ChatGPTConfig.version || '3.0.0'
+ },
+ {
+ capabilities: {}
+ }
+ )
+
+ let transport
+ if (transportType === 'stdio') {
+ transport = await createStdioTransport(server)
+ } else if (transportType === 'streamable-http') {
+ transport = await createStreamableHttpTransport(server)
+ if (!transport) {
+ return null
+ }
+ } else {
+ logger.warn(`[MCP] 暂不支持 transport=${transportType},server=${serverId}`)
+ return null
+ }
+
+ await client.connect(transport)
+ return { client, transport }
+}
+
+/**
+ * 初始化 MCP 兼容层:把 MCP tools 动态桥接成 Chaite CustomTool
+ * @param {import('chaite').ToolManager} toolsManager
+ */
+export async function initMcpCompatibility (toolsManager) {
+ const conf = ChatGPTConfig.mcp
+ if (!conf?.enable) {
+ return
+ }
+
+ const servers = Array.isArray(conf.servers) ? conf.servers : []
+ if (servers.length === 0) {
+ logger.warn('[MCP] 已开启但未配置 servers')
+ return
+ }
+
+ await disposeMcpRegistry()
+ clearMcpToolRoutes()
+
+ if (conf.removeStaleBridgeToolsOnStart !== false) {
+ await clearOldBridgeTools(toolsManager)
+ }
+
+ const nameSet = new Set()
+
+ for (const server of servers) {
+ if (!normalizeServerEnabled(server)) {
+ continue
+ }
+ const serverId = normalizeServerId(server)
+ if (!serverId) {
+ logger.warn('[MCP] 忽略一个未设置 id 的 server 配置')
+ continue
+ }
+
+ try {
+ const connected = await connectServer(server)
+ if (!connected) {
+ continue
+ }
+
+ registerMcpServerClient(serverId, connected)
+ const toolList = await connected.client.listTools()
+ const tools = Array.isArray(toolList?.tools) ? toolList.tools : []
+
+ logger.info(`[MCP] 服务 ${serverId} 已连接,发现工具 ${tools.length} 个`)
+
+ for (const tool of tools) {
+ const mcpToolName = tool?.name
+ if (!mcpToolName || !shouldIncludeTool(server, mcpToolName)) {
+ continue
+ }
+
+ let bridgeToolName = buildBridgeToolName(serverId, mcpToolName)
+ if (nameSet.has(bridgeToolName)) {
+ bridgeToolName = `${bridgeToolName}_${md5(`${serverId}:${mcpToolName}`).slice(0, 6)}`
+ }
+ nameSet.add(bridgeToolName)
+
+ registerMcpToolRoute(bridgeToolName, {
+ serverId,
+ mcpToolName
+ })
+
+ const className = `McpBridgeTool_${md5(`${serverId}:${mcpToolName}`).slice(0, 10)}`
+ const toolCode = buildBridgeToolCode(
+ className,
+ bridgeToolName,
+ tool.inputSchema,
+ `[MCP:${serverId}] ${tool.description || mcpToolName}`
+ )
+
+ await toolsManager.addInstance({
+ id: `${BRIDGE_TOOL_ID_PREFIX}${md5(`${serverId}:${mcpToolName}`)}`,
+ name: bridgeToolName,
+ description: `[MCP:${serverId}] ${tool.description || mcpToolName}`,
+ code: toolCode,
+ permission: 'private',
+ status: 'enabled'
+ })
+ }
+ } catch (err) {
+ logger.error(`[MCP] 初始化 server=${serverId} 失败:`, err)
+ }
+ }
+}
+
+/**
+ * 关闭 MCP 连接
+ */
+export async function disposeMcpCompatibility () {
+ await disposeMcpRegistry()
+}
diff --git a/utils/mcp/registry.js b/utils/mcp/registry.js
new file mode 100644
index 00000000..01abc9eb
--- /dev/null
+++ b/utils/mcp/registry.js
@@ -0,0 +1,112 @@
+const mcpServerClients = new Map()
+const mcpToolRoutes = new Map()
+
+/**
+ * 注册 MCP 服务器客户端
+ * @param {string} serverId
+ * @param {{ client: any, transport: any }} payload
+ */
+export function registerMcpServerClient (serverId, payload) {
+ mcpServerClients.set(serverId, payload)
+}
+
+/**
+ * 注册 Chaite 工具名到 MCP 工具路由
+ * @param {string} chaiteToolName
+ * @param {{ serverId: string, mcpToolName: string }} route
+ */
+export function registerMcpToolRoute (chaiteToolName, route) {
+ mcpToolRoutes.set(chaiteToolName, route)
+}
+
+/**
+ * 清空所有工具路由
+ */
+export function clearMcpToolRoutes () {
+ mcpToolRoutes.clear()
+}
+
+/**
+ * 关闭并清空所有 MCP 客户端
+ */
+export async function disposeMcpRegistry () {
+ const tasks = []
+ for (const [serverId, payload] of mcpServerClients.entries()) {
+ tasks.push((async () => {
+ try {
+ await payload?.transport?.close?.()
+ } catch (err) {
+ logger.warn(`[MCP] 关闭服务 ${serverId} 失败: ${err?.message || err}`)
+ }
+ })())
+ }
+ await Promise.allSettled(tasks)
+ mcpServerClients.clear()
+ mcpToolRoutes.clear()
+}
+
+function normalizeMcpCallResult (result) {
+ if (!result) {
+ return ''
+ }
+
+ const chunks = []
+ if (Array.isArray(result.content)) {
+ for (const item of result.content) {
+ if (!item) {
+ continue
+ }
+ if (item.type === 'text') {
+ chunks.push(item.text || '')
+ continue
+ }
+ if (item.type === 'image') {
+ chunks.push(`[image] ${item.mimeType || ''}`.trim())
+ continue
+ }
+ chunks.push(JSON.stringify(item))
+ }
+ }
+
+ if (chunks.length === 0 && result.structuredContent !== undefined) {
+ chunks.push(JSON.stringify(result.structuredContent, null, 2))
+ }
+
+ if (chunks.length === 0) {
+ chunks.push(JSON.stringify(result, null, 2))
+ }
+
+ const text = chunks.join('\n').trim()
+ if (result.isError) {
+ return `MCP工具调用失败: ${text || 'unknown error'}`
+ }
+ return text
+}
+
+/**
+ * 由桥接工具调用 MCP 服务
+ * @param {string} chaiteToolName
+ * @param {Record} args
+ */
+export async function callMcpBridgeTool (chaiteToolName, args = {}) {
+ const route = mcpToolRoutes.get(chaiteToolName)
+ if (!route) {
+ return `MCP路由不存在: ${chaiteToolName}`
+ }
+
+ const payload = mcpServerClients.get(route.serverId)
+ if (!payload?.client) {
+ return `MCP服务未连接: ${route.serverId}`
+ }
+
+ try {
+ const result = await payload.client.callTool({
+ name: route.mcpToolName,
+ arguments: args || {}
+ })
+ return normalizeMcpCallResult(result)
+ } catch (err) {
+ logger.error(`[MCP] 调用工具失败 ${route.serverId}/${route.mcpToolName}:`, err)
+ return `MCP工具调用异常: ${err?.message || String(err)}`
+ }
+}
diff --git a/utils/message.js b/utils/message.js
index d94300bd..0f4f1be9 100644
--- a/utils/message.js
+++ b/utils/message.js
@@ -259,7 +259,8 @@ export async function toYunzai (e, contents) {
if (forward.length > 1) {
const newForward = [await common.makeForwardMsg(e, forward, '多次思考过程')]
return {
- msgs: msgs.filter(i => !!i), newForward
+ msgs: msgs.filter(i => !!i),
+ forward: newForward
}
}
return {