From 8eb5bc80d5860c574744652d96a2a8b730fa851f Mon Sep 17 00:00:00 2001 From: zhiyu1998 <542716863@qq.com> Date: Sat, 2 Aug 2025 21:30:18 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(tools.js):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=AC=AC=E4=B8=89=E6=96=B9=E6=9C=8D=E5=8A=A1=E5=95=86?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E4=BD=BF=E7=94=A8=E6=80=BB=E7=BB=93=E4=B8=80?= =?UTF-8?q?=E4=B8=8B=E9=97=AE=E9=A2=98=20&&=20=E4=B8=BAKIMI=E7=B1=BB?= =?UTF-8?q?=E5=AE=98=E6=96=B9=E6=A8=A1=E5=9E=8B=E6=B7=BB=E5=8A=A0`?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=EF=BC=88tool=5Fcalls?= =?UTF-8?q?=EF=BC=89`=20[#82]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + apps/tools.js | 111 +++++++++++++++++++++++++++++++++++----- constants/tools.js | 23 +++++++++ utils/openai-builder.js | 83 +++++++++++++++++++++++------- 4 files changed, 187 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index 1a0156e..dd88cb6 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ yarn.lock .vitepress bun.lockb .next +CLAUDE.md +.claude \ No newline at end of file diff --git a/apps/tools.js b/apps/tools.js index aedc189..1a46df8 100644 --- a/apps/tools.js +++ b/apps/tools.js @@ -56,7 +56,8 @@ import { TWITTER_TWEET_INFO, WEIBO_SINGLE_INFO, WEISHI_VIDEO_INFO, - XHS_REQ_LINK + XHS_REQ_LINK, + CRAWL_TOOL } from "../constants/tools.js"; import BiliInfoModel from "../model/bili-info.js"; import config from "../model/config.js"; @@ -2644,31 +2645,113 @@ export class tools extends plugin { if (e.msg.startsWith("#总结一下")) { name = "网页总结"; - summaryLink = e.msg.replace("#总结一下", ""); // 如果需要进一步处理 summaryLink,可以在这里添加相关逻辑 + summaryLink = e.msg.replace("#总结一下", ""); } else { - ({ name: name, summaryLink: summaryLink } = contentEstimator(e.msg)); + ({ name, summaryLink } = contentEstimator(e.msg)); } // 判断是否有总结的条件 - if (_.isEmpty(this.aiApiKey) || _.isEmpty(this.aiApiKey)) { + if (_.isEmpty(this.aiApiKey)) { // e.reply(`没有配置 Kimi,无法为您总结!${ HELP_DOC }`) await this.tempSummary(name, summaryLink, e); return true; } + const builder = await new OpenaiBuilder() .setBaseURL(this.aiBaseURL) .setApiKey(this.aiApiKey) .setModel(this.aiModel) - .setPrompt(SUMMARY_PROMPT) - .build(); - e.reply(`${ this.identifyPrefix }识别:${ name },正在为您总结,请稍等...`, true, { recallMsg: MESSAGE_RECALL_TIME }); - const { ans: kimiAns, model } = await builder.kimi(summaryLink); - // 计算阅读时间 - const stats = estimateReadingTime(kimiAns); - const titleMatch = kimiAns.match(/(Title|标题)([::])\s*(.*?)\n/)?.[3]; - e.reply(`《${ titleMatch }》 预计阅读时间: ${ stats.minutes } 分钟,总字数: ${ stats.words }`); - const Msg = await Bot.makeForwardMsg(textArrayToMakeForward(e, [`「R插件 x ${ model }」联合为您总结内容:`, kimiAns])); - await e.reply(Msg); + .setPrompt(SUMMARY_PROMPT); + + if (this.aiModel.includes('deepseek')) { + builder.setProvider('deepseek'); + } + + await builder.build(); + + e.reply(`${this.identifyPrefix}识别:${name},正在为您总结,请稍等...`, true, { recallMsg: MESSAGE_RECALL_TIME }); + + let messages = [{ role: "user", content: summaryLink }]; + + // 兜底策略:检测模型是否支持 tool_calls + if (!this.aiModel.includes("kimi") && !this.aiModel.includes("moonshot")) { + // 不支持 tool_calls 的模型,直接爬取内容并总结 + try { + // 直接使用llmRead爬取链接内容 + const crawled_content = await llmRead(summaryLink); + // 重新构造消息,将爬取到的内容直接放入对话历史 + messages = [ + { role: "user", content: `这是网页链接: ${summaryLink}` }, + { role: "assistant", content: `好的,我已经爬取了网页内容,内容如下:\n${crawled_content}` }, + { role: "user", content: "请根据以上内容进行总结。" } + ]; + + // 调用kimi进行总结,此时不传递任何工具 + const response = await builder.chat(messages); // 不传递 CRAWL_TOOL + const { ans: kimiAns, model } = response; + // 估算阅读时间并提取标题 + const stats = estimateReadingTime(kimiAns); + const titleMatch = kimiAns.match(/(Title|标题)([::])\s*(.*)/)?.[3]; + e.reply(`《${titleMatch || '未知标题'}》 预计阅读时间: ${stats.minutes} 分钟,总字数: ${stats.words}`); + // 将总结内容格式化为合并转发消息 + const Msg = await Bot.makeForwardMsg(textArrayToMakeForward(e, [`「R插件 x ${model}」联合为您总结内容:`, kimiAns])); + await e.reply(Msg); + } catch (error) { + e.reply(`总结失败: ${error.message}`); + } + return true; + } + + // 为了防止无限循环,设置一个最大循环次数 + for (let i = 0; i < 5; i++) { + const response = await builder.chat(messages, [CRAWL_TOOL]); + + // 如果Kimi返回了工具调用 + if (response.tool_calls) { + const tool_calls = response.tool_calls; + messages.push({ + role: 'assistant', + content: null, + tool_calls: tool_calls, + }); + + // 遍历并处理每一个工具调用 + for (const tool_call of tool_calls) { + if (tool_call.function.name === 'crawl') { + try { + const args = JSON.parse(tool_call.function.arguments); + const urlToCrawl = args.url; + // 执行爬取操作 + const crawled_content = await llmRead(urlToCrawl); + messages.push({ + role: 'tool', + tool_call_id: tool_call.id, + name: 'crawl', + content: crawled_content, + }); + } catch (error) { + messages.push({ + role: 'tool', + tool_call_id: tool_call.id, + name: 'crawl', + content: `爬取错误: ${error.message}`, + }); + } + } + } + } else { + // 如果没有工具调用,说明得到了最终的总结 + const { ans: kimiAns, model } = response; + // 计算阅读时间 + const stats = estimateReadingTime(kimiAns); + const titleMatch = kimiAns.match(/(Title|标题)([::])\s*(.*?)\n/)?.[3]; + e.reply(`《${titleMatch || '未知标题'}》 预计阅读时间: ${stats.minutes} 分钟,总字数: ${stats.words}`); + const Msg = await Bot.makeForwardMsg(textArrayToMakeForward(e, [`「R插件 x ${model}」联合为您总结内容:`, kimiAns])); + await e.reply(Msg); + return true; + } + } + e.reply("处理超出限制,请重试"); return true; } diff --git a/constants/tools.js b/constants/tools.js index 3f26173..6e69fcc 100644 --- a/constants/tools.js +++ b/constants/tools.js @@ -259,3 +259,26 @@ export const PearAPI_CRAWLER = "https://api.pearktrue.cn/api/llmreader/?url={}&t * @type {string} */ export const PearAPI_DEEPSEEK = "https://api.pearktrue.cn/api/deepseek/"; + +/** + * TOOL_CALL 爬虫工具 + * 用于Kimi模型Tool-Calling的爬虫工具 + * 当Kimi模型判断需要从网页获取信息时,会调用此工具。 + */ +export const CRAWL_TOOL = { + type: "function", + function: { + name: "crawl", + description: "根据网站地址(URL)获取网页内容。", + parameters: { + type: "object", + required: ["url"], + properties: { + url: { + type: "string", + description: "需要获取内容的网站地址(URL),通常情况下从搜索结果中可以获取网站的地址。" + } + } + } + } +}; \ No newline at end of file diff --git a/utils/openai-builder.js b/utils/openai-builder.js index 561e354..4aaa9bc 100644 --- a/utils/openai-builder.js +++ b/utils/openai-builder.js @@ -6,7 +6,7 @@ export class OpenaiBuilder { this.apiKey = ""; // 默认API密钥 this.prompt = "描述一下这个图片"; // 默认提示 this.model = 'claude-3-haiku-20240307' - this.path = ''; // 上传文件的路径 + this.provider = "kimi"; // 默认提供商 } setBaseURL(baseURL) { @@ -29,6 +29,11 @@ export class OpenaiBuilder { return this; } + setProvider(provider) { + this.provider = provider; + return this; + } + setPath(path) { this.path = path; return this; @@ -48,24 +53,66 @@ export class OpenaiBuilder { return this; } - async kimi(query) { - // 请求Kimi - const completion = await this.client.post("/v1/chat/completions", { - model: "moonshot-v1-8k", - messages: [ - { - "role": "system", - "content": this.prompt, - }, - { - role: "user", - content: query - }, - ], - }); + /** + * 调用与OpenAI兼容的API(如Kimi/Moonshot)。 + * @param {Array} messages - 发送给模型的消息列表。 + * @param {Array} [tools=[]] - (可选) 一个描述可供模型使用的工具的数组。 + * @returns {Promise} 返回一个包含模型响应的对象。如果模型决定调用工具,则包含 'tool_calls' 字段;否则,包含 'ans' 文本响应。 + */ + async chat(messages, tools = []) { + if (this.provider === 'deepseek') { + const content = messages.find(m => m.role === 'user')?.content; + const ans = await deepSeekChat(content, this.prompt); + return { + "model": "deepseek", + "ans": ans + } + } + + // 准备发送给API的消息 + let requestMessages = [...messages]; + // 检查是否已存在系统提示 + const hasSystemPrompt = requestMessages.some(m => m.role === 'system'); + + // 如果没有系统提示并且builder中已设置,则添加 + if (!hasSystemPrompt && this.prompt) { + requestMessages.unshift({ + role: 'system', + content: this.prompt, + }); + } + + // 构建API请求的负载 + const payload = { + model: this.model, // 使用在builder中设置的模型 + messages: requestMessages, + }; + + // 如果提供了工具,将其添加到负载中,并让模型自动决定是否使用 + if (tools && tools.length > 0) { + payload.tools = tools; + payload.tool_choice = "auto"; + } + + // 发送POST请求到聊天完成端点 + const completion = await this.client.post("/v1/chat/completions", payload); + const message = completion.data.choices[0].message; + + // 从响应中获取实际使用的模型名称 + const modelName = completion.data.model; + + // 如果模型的响应中包含工具调用 + if (message.tool_calls) { + return { + "model": modelName, + "tool_calls": message.tool_calls + } + } + + // 否则,返回包含文本答案的响应 return { - "model": "月之暗面 Kimi", - "ans": completion.data.choices[0].message.content + "model": modelName, + "ans": message.content } } }