diff --git a/apps/bym.js b/apps/bym.js index b7ee2dc..25df8ff 100644 --- a/apps/bym.js +++ b/apps/bym.js @@ -10,7 +10,7 @@ import { SendVideoTool } from '../utils/tools/SendBilibiliTool.js' import { SendMusicTool } from '../utils/tools/SendMusicTool.js' import { SendPictureTool } from '../utils/tools/SendPictureTool.js' import { WebsiteTool } from '../utils/tools/WebsiteTool.js' -import { convertFaces } from '../utils/face.js' +import { convertFaces, faceMap } from '../utils/face.js' import { WeatherTool } from '../utils/tools/WeatherTool.js' import { EditCardTool } from '../utils/tools/EditCardTool.js' import { JinyanTool } from '../utils/tools/JinyanTool.js' @@ -18,6 +18,8 @@ import { KickOutTool } from '../utils/tools/KickOutTool.js' import { SetTitleTool } from '../utils/tools/SetTitleTool.js' import { SerpTool } from '../utils/tools/SerpTool.js' import { SendMessageToSpecificGroupOrUserTool } from '../utils/tools/SendMessageToSpecificGroupOrUserTool.js' +import { MultiTool } from '../utils/tools/MultiTool.js' +import { UrlExtractionTool } from '../utils/tools/UrlExtractionTool.js' export class bym extends plugin { constructor () { @@ -95,7 +97,7 @@ export class bym extends plugin { return `${sender.card || sender.nickname}(${sender.user_id}) :${chat.raw_message}` }) .join('\n') + - `\n你的回复应该尽可能简练,像人类一样随意,不要附加任何奇怪的东西,如聊天记录的格式(比如${Config.assistantLabel}:),禁止重复聊天记录。` + `\n你的回复应该尽可能简练,像人类一样随意,但是也要保留“${Config.assistantLabel}”的角色风格,不要附加任何奇怪的东西,不能模仿聊天记录的格式,要以第一人称视角对话,禁止重复聊天记录。` let client = new CustomGoogleGeminiClient({ e, @@ -117,6 +119,8 @@ export class bym extends plugin { new SendVideoTool(), new SendMusicTool(), new SendPictureTool(), + new MultiTool(), + new UrlExtractionTool(), new WebsiteTool(), new WeatherTool(), new SendMessageToSpecificGroupOrUserTool() diff --git a/utils/tools/MultiTool.js b/utils/tools/MultiTool.js new file mode 100644 index 0000000..3206976 --- /dev/null +++ b/utils/tools/MultiTool.js @@ -0,0 +1,260 @@ +import { AbstractTool } from './AbstractTool.js'; +import fetch from 'node-fetch'; +import { Config } from '../config.js'; +import common from '../../../../lib/common/common.js'; + +/** + * 多功能工具类 - 支持搜索和代码执行 + * @class MultiTool + * @extends {AbstractTool} + */ +export class MultiTool extends AbstractTool { + name = 'MultiTool'; + + parameters = { + properties: { + query: { + type: 'string', + description: '要处理的内容(可以是搜索查询或代码)', + }, + type: { + type: 'string', + description: '操作类型:search(搜索)或 code(代码执行)', + enum: ['search', 'code'] + }, + language: { + type: 'string', + description: '当type为code时的编程语言', + }, + length: { + type: 'integer', + description: '当type为search时的摘要长度(句子数),默认为3', + } + }, + required: ['query', 'type'], + }; + + description = '多功能工具:支持使用 Gemini API 进行智能搜索和代码执行。'; + + /** + * 工具执行函数 + * @param {Object} opt - 工具参数 + * @param {string} opt.query - 处理内容 + * @param {string} opt.type - 操作类型 + * @param {string} [opt.language] - 编程语言 + * @param {number} [opt.length] - 摘要长度 + * @param {Object} e - 事件对象 + */ + func = async function (opt, e) { + const { query, type, language, length = 3 } = opt; + + if (!query?.trim()) { + throw new Error('处理内容不能为空'); + } + + try { + const result = await this.processRequest(query, type, language, length); + console.debug(`[MultiTool] 处理结果:`, result); + + // 构建转发消息 + const forwardMsg = this.constructForwardMessage(result, type); + e.reply(await common.makeForwardMsg(e, forwardMsg, `${e.sender.card || e.sender.nickname || e.user_id}的${type === 'search' ? '搜索' : '代码执行'}结果`)); + + return result; + } catch (error) { + console.error('[MultiTool] 处理失败:', error); + throw new Error(`操作失败: ${error.message}`); + } + }; + + /** + * 处理请求 + * @param {string} query - 处理内容 + * @param {string} type - 操作类型 + * @param {string} language - 编程语言 + * @param {number} length - 摘要长度 + */ + async processRequest(query, type, language, length) { + const apiKey = Config.geminiKey; + const apiBaseUrl = Config.geminiBaseUrl; + const apiUrl = `${apiBaseUrl}/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${apiKey}`; + + if (!apiKey || !apiBaseUrl) { + throw new Error('Gemini API 配置缺失'); + } + + const requestBody = { + "systemInstruction": { + "parts": [{ + "text": this.getSystemInstruction(type) + }] + }, + "contents": [{ + "parts": [{ + "text": this.constructPrompt(query, type, language, length) + }], + "role": "user" + }], + "tools": [{ + "googleSearch": {} + }, { + "code_execution": {} + }], + "generationConfig": { + "temperature": 0.1, + "topK": 1, + "topP": 1, + "maxOutputTokens": 2048, + } + }; + + try { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(`API 请求失败: ${data.error?.message || '未知错误'}`); + } + + return this.processGeminiResponse(data, type); + } catch (error) { + console.error('[MultiTool] API调用失败:', error); + throw error; + } + } + + /** + * 获取系统指令 + * @param {string} type - 操作类型 + */ + getSystemInstruction(type) { + if (type === 'search') { + return "你是一个有用的助手,你更喜欢说中文。你会根据用户的问题,通过搜索引擎获取最新的信息来回答问题。你的回答会尽可能准确、客观。"; + } else { + return "你是一个代码执行助手。请执行用户提供的代码并返回结果。同时提供代码分析和改进建议。如果有错误,请提供详细的错误信息和修复方案。"; + } + } + + /** + * 构建提示词 + * @param {string} query - 处理内容 + * @param {string} type - 操作类型 + * @param {string} language - 编程语言 + * @param {number} length - 摘要长度 + */ + constructPrompt(query, type, language, length) { + if (type === 'search') { + return `请对以下内容进行搜索并提供${length}句话的详细总结。 + 需要搜索的内容: ${query} + 请确保回答准确、客观,并包含相关事实和信息。`; + } else { + return `请执行以下${language}代码并提供执行结果和分析:\n\`\`\`${language}\n${query}\n\`\`\`\n请提供:\n1. 代码执行结果\n2. 代码分析和可能的改进建议`; + } + } + + /** + * 处理 Gemini API 响应 + * @param {Object} data - API 响应数据 + * @param {string} type - 操作类型 + */ + processGeminiResponse(data, type) { + if (!data?.candidates?.[0]?.content?.parts) { + throw new Error('无效的 API 响应'); + } + + const response = data.candidates[0].content.parts + .map(part => part.text) + .filter(Boolean) + .join('\n'); + + if (type === 'search') { + let sources = []; + if (data.candidates?.[0]?.groundingMetadata?.groundingChunks) { + sources = data.candidates[0].groundingMetadata.groundingChunks + .filter(chunk => chunk.web) + .map(chunk => ({ + title: chunk.web.title || '未知标题', + url: this.processUrl(chunk.web.uri) + })) + .filter((v, i, a) => + a.findIndex(t => (t.title === v.title && t.url === v.url)) === i + ); + } + return { answer: response, sources }; + } else { + let output = ''; + let explanation = ''; + let error = null; + + if (response.includes('执行结果:')) { + const parts = response.split(/(?=执行结果:|代码分析:)/); + parts.forEach(part => { + if (part.startsWith('执行结果:')) { + output = part.replace('执行结果:', '').trim(); + } else if (part.startsWith('代码分析:')) { + explanation = part.replace('代码分析:', '').trim(); + } + }); + } else if (response.includes('错误:')) { + error = response; + } else { + output = response; + } + + return { output, explanation, error, executionTime: Date.now() }; + } + } + + /** + * 处理URL + * @param {string} url - 原始URL + */ + processUrl(url) { + if (url.includes('https://vertexaisearch.cloud.google.com/grounding-api-redirect')) { + return url.replace( + 'https://vertexaisearch.cloud.google.com/grounding-api-redirect', + 'https://miao.news' + ); + } + return url; + } + + /** + * 构建转发消息 + * @param {Object} result - 处理结果 + * @param {string} type - 操作类型 + */ + constructForwardMessage(result, type) { + const forwardMsg = []; + + if (type === 'search') { + const { answer, sources } = result; + forwardMsg.push(answer); + if (sources && sources.length > 0) { + forwardMsg.push('信息来源:'); + sources.forEach((source, index) => { + forwardMsg.push(`${index + 1}. ${source.title}\n${source.url}`); + }); + } + } else { + const { output, explanation, error } = result; + if (error) { + forwardMsg.push(`执行出错:\n${error}`); + } else { + forwardMsg.push(`执行结果:\n${output}`); + if (explanation) { + forwardMsg.push(`\n代码分析:\n${explanation}`); + } + } + } + + return forwardMsg; + } +} \ No newline at end of file diff --git a/utils/tools/UrlExtractionTool.js b/utils/tools/UrlExtractionTool.js new file mode 100644 index 0000000..61d5aa8 --- /dev/null +++ b/utils/tools/UrlExtractionTool.js @@ -0,0 +1,175 @@ +import { AbstractTool } from './AbstractTool.js' +import fetch from 'node-fetch' + +// 假设的日志记录器,实际应用中应替换为专业的日志库 +const logger = { + mark: console.log, + error: console.error, +}; + +/** + * URL提取工具类 + * @class UrlExtractionTool + * @extends {AbstractTool} + */ +export class UrlExtractionTool extends AbstractTool { + // 工具名称 + name = 'UrlExtractionTool'; + + // 工具参数 + parameters = { + properties: { + message: { + type: 'string', + description: 'The message containing URLs to be extracted.', + }, + }, + required: ['message'], + }; + + // 工具描述 + description = 'Extracts URLs from a given message and retrieves the content of those URLs. Returns the extracted content to the AI.'; // 更新描述 + + /** + * 工具执行函数 + * @param {Object} opt - 工具参数 + * @param {Object} ai - AI对象 (未使用) + * @returns {Promise} - 提取的URL内容 + */ + func = async function (opt, ai) { + let { message } = opt; + if (!message) { + return 'The message parameter is required.'; + } + + try { + const result = await processMessageWithUrls(message); + logger.mark(`[URL Extraction] Processed message: ${message}, Extracted content: ${result}`); + return result; // 直接返回提取的内容 + } catch (error) { + logger.error(`[URL Extraction] URL extraction failed: ${error.message}`); + return `URL extraction failed, please check the logs. ${error.message}`; + } + }; +} + +/** + * 检查URL是否为不需要提取内容的文件类型 + * @param {string} url URL地址 + * @returns {boolean} 是否为不需要提取的文件类型 + */ +function isSkippedUrl(url) { + // 使用 Set 存储扩展名以提高查找效率 + const skippedExtensions = new Set([ + // 图片 + 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif', 'raw', 'cr2', 'nef', 'arw', 'dng', 'heif', 'heic', 'avif', 'jfif', 'psd', 'ai', + // 视频 + 'mp4', 'webm', 'mkv', 'flv', 'avi', 'mov', 'wmv', 'rmvb', 'm4v', '3gp', 'mpeg', 'mpg', 'ts', 'mts', + // 可执行文件和二进制文件 + 'exe', 'msi', 'dll', 'sys', 'bin', 'dat', 'iso', 'img', 'dmg', 'pkg', 'deb', 'rpm', 'apk', 'ipa', 'jar', 'class', 'pyc', 'o', 'so', 'dylib', + // 压缩文件 + 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'tgz', 'tbz', 'cab', 'ace', 'arc', + ]); + + // 优化关键词匹配 + const skipKeywords = /\/(images?|photos?|pics?|videos?|medias?|downloads?|uploads?|binaries|assets)\//i; + + // 从URL中提取扩展名 + const extension = url.split('.').pop().toLowerCase(); + + return skippedExtensions.has(extension) || skipKeywords.test(url); +} + +/** + * 从文本中提取URL + * @param {string} text 需要提取URL的文本 + * @returns {string[]} URL数组 + */ +function extractUrls(text) { + // 更精确的正则表达式来匹配 URL + const urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/g; + const matches = text.match(urlRegex) || []; + + // 简化 URL 清理逻辑 + return matches.map(url => { + try { + // 解码URL + return decodeURIComponent(url); + } catch (e) { + // 解码失败则返回原URL + return url; + } + }); +} + +/** + * 从URL提取内容 + * @param {string} url 需要提取内容的URL + * @returns {Promise} 提取的内容 + */ +async function extractUrlContent(url) { + if (isSkippedUrl(url)) { + logger.mark(`[URL提取]跳过不需要处理的URL类型: ${url}`); + return null; + } + + try { + logger.mark(`[URL提取]开始从URL获取内容: ${url}`); + const response = await fetch(`https://lbl.news/api/extract?url=${encodeURIComponent(url)}`); + if (!response.ok) { + // 更详细的错误处理 + const errorMsg = await response.text(); + throw new Error(`提取内容失败: ${response.status} ${response.statusText} - ${errorMsg}`); + } + const data = await response.json(); + logger.mark(`[URL提取]成功获取URL内容: ${url}`); + return data; + } catch (error) { + logger.error(`[URL提取]提取内容失败: ${error.message}, URL: ${url}`); + return null; + } +} + +/** + * 处理消息中的URL并提取内容 + * @param {string} message 用户消息 + * @returns {Promise} 提取的URL内容 + */ +async function processMessageWithUrls(message) { + const urls = extractUrls(message); + if (urls.length === 0) { + return ''; // 没有 URL 则返回空字符串 + } + + logger.mark(`[URL处理]从消息中提取到${urls.length}个URL`); + let extractedContent = ''; + + // 使用 Promise.all 并发处理多个 URL + const contents = await Promise.all( + urls.map(async url => { + if (isSkippedUrl(url)) { + logger.mark(`[URL处理]跳过URL: ${url}`); + return null; + } + + logger.mark(`[URL处理]开始处理URL: ${url}`); + const content = await extractUrlContent(url); + if (content) { + logger.mark(`[URL处理]成功提取URL内容: ${url}`); + // 格式化提取的内容 + return { url, content: content.content }; + } + return null; + }) + ); + + // 组合提取的内容 + contents.forEach(item => { + if (item) { + const urlContent = `\n\n提取的URL内容(${item.url}):\n内容: ${item.content}`; + extractedContent += urlContent; + } + }); + + return extractedContent; // 返回提取的内容字符串 +} \ No newline at end of file