diff --git a/README.md b/README.md index 1c0688c7..153464f9 100644 --- a/README.md +++ b/README.md @@ -1,169 +1,113 @@ -![chatgpt-plugin](https://socialify.git.ci/ikechan8370/chatgpt-plugin/image?description=1&font=Jost&forks=1&issues=1&language=1&name=1&owner=1&pulls=1&stargazers=1&theme=Light) -
+# chatgpt-plugin - - - - -
+基于 Chaite 内核的 Yunzai 对话插件,支持多模型渠道、工具调用、记忆系统、伪人模式,以及标准 MCP 工具桥接(stdio / streamable-http)。 +旧版说明文档已保留为 `readme_old.md`。 +## 主要能力 -![26224FE397F1E74104C1C007C1A32DDE](https://user-images.githubusercontent.com/21212372/227718994-4d33da74-6886-41d5-afd0-73986b086df0.gif) - +- 多渠道与预设管理(可在面板或命令行操作) +- 群上下文注入与多轮会话管理 +- 工具调用与推理过程回显 +- 群记忆 / 用户记忆(可独立开关) +- 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 服务并重建桥接工具,不必重启机器人。 +## 故障排查 -![Alt](https://repobeats.axiom.co/api/embed/076d597ede41432208435f233d18cb20052fb90a.svg "Repobeats analytics image") +- 提示 `No API-key provided`:检查 `DASHSCOPE_API_KEY` 是否注入到 PM2 环境。 +- 启动后无 MCP 工具: + - 确认 `mcp.enable=true` + - 确认服务项 `enable=true` + - 发送 `#chatgpt刷新MCP` 强制重载 +- `stdio` 服务连接失败:检查 `command/args` 是否可在当前机器执行。 -## Star History +## 目录说明 -[![Star History Chart](https://api.star-history.com/svg?repos=ikechan8370/chatgpt-plugin&type=Date)](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 @@ +![chatgpt-plugin](https://socialify.git.ci/ikechan8370/chatgpt-plugin/image?description=1&font=Jost&forks=1&issues=1&language=1&name=1&owner=1&pulls=1&stargazers=1&theme=Light) +
+ + + + + +
+ + + +![26224FE397F1E74104C1C007C1A32DDE](https://user-images.githubusercontent.com/21212372/227718994-4d33da74-6886-41d5-afd0-73986b086df0.gif) + + + + +> 插件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 + +## 贡献者 + +感谢以下贡献者 + + + + + + +![Alt](https://repobeats.axiom.co/api/embed/076d597ede41432208435f233d18cb20052fb90a.svg "Repobeats analytics image") + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=ikechan8370/chatgpt-plugin&type=Date)](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 {