From 948d1a7827adb1d6e809651da4a56a01961f0270 Mon Sep 17 00:00:00 2001 From: baiyu-yu <135424680+baiyu-yu@users.noreply.github.com> Date: Mon, 3 Nov 2025 19:46:40 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0markdown/html?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=B8=B2=E6=9F=93=E5=B7=A5=E5=85=B7=E5=92=8C?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E5=90=8E=E7=AB=AF=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/config_backend.ts | 4 +- src/tool/tool.ts | 2 + src/tool/tool_render.ts | 131 +++++ .../render.js" | 479 ++++++++++++++++++ 4 files changed, 615 insertions(+), 1 deletion(-) create mode 100644 src/tool/tool_render.ts create mode 100644 "\347\233\270\345\205\263\345\220\216\347\253\257\351\241\271\347\233\256/md\345\222\214html\345\233\276\347\211\207\346\270\262\346\237\223/render.js" diff --git a/src/config/config_backend.ts b/src/config/config_backend.ts index 4bd44ad..5977c67 100644 --- a/src/config/config_backend.ts +++ b/src/config/config_backend.ts @@ -11,6 +11,7 @@ export class BackendConfig { seal.ext.registerStringConfig(BackendConfig.ext, "联网搜索", "https://searxng.fishwhite.top", '可自行搭建'); seal.ext.registerStringConfig(BackendConfig.ext, "网页读取", "https://webread.fishwhite.top", '可自行搭建'); seal.ext.registerStringConfig(BackendConfig.ext, "用量图表", "http://usagechart.error2913.com", '可自行搭建'); + seal.ext.registerStringConfig(BackendConfig.ext, "md和html图片渲染", "https://md.fishwhite.top", '可自行搭建'); } static get() { @@ -19,7 +20,8 @@ export class BackendConfig { imageTobase64Url: seal.ext.getStringConfig(BackendConfig.ext, "图片转base64"), webSearchUrl: seal.ext.getStringConfig(BackendConfig.ext, "联网搜索"), webReadUrl: seal.ext.getStringConfig(BackendConfig.ext, "网页读取"), - usageChartUrl: seal.ext.getStringConfig(BackendConfig.ext, "用量图表") + usageChartUrl: seal.ext.getStringConfig(BackendConfig.ext, "用量图表"), + renderUrl: seal.ext.getStringConfig(BackendConfig.ext, "md和html图片渲染"), } } } diff --git a/src/tool/tool.ts b/src/tool/tool.ts index 8f521c2..c3a932f 100644 --- a/src/tool/tool.ts +++ b/src/tool/tool.ts @@ -21,6 +21,7 @@ import { registerQQList } from "./tool_qq_list" import { registerSetTrigger } from "./tool_trigger" import { registerMusicPlay } from "./tool_music" import { registerMeme } from "./tool_meme" +import { registerRender } from "./tool_render" import { logger } from "../logger" import { Image } from "../AI/image"; import { fixJsonString } from "../utils/utils_string"; @@ -203,6 +204,7 @@ export class ToolManager { registerSetTrigger(); registerMusicPlay(); registerMeme(); + registerRender(); } /** diff --git a/src/tool/tool_render.ts b/src/tool/tool_render.ts new file mode 100644 index 0000000..7351bb5 --- /dev/null +++ b/src/tool/tool_render.ts @@ -0,0 +1,131 @@ +import { logger } from "../logger"; +import { Tool } from "./tool"; +import { ConfigManager } from "../config/config"; + +interface RenderResponse { + status: string; + imageId?: string; + url?: string; + fileName?: string; + contentType?: string; + message?: string; +} + +/** + * 渲染内容为图片 + * @param content - 要渲染的内容(Markdown 或 HTML) + * @param contentType - 内容类型:'auto'(自动检测), 'markdown', 'html' + * @param theme - 主题:'light', 'dark', 'gradient' + * @param width - 图片宽度 + */ +async function renderContent( + content: string, + contentType: 'auto' | 'markdown' | 'html' = 'auto', + theme: string = 'light', + width: number = 1200 +): Promise { + try { + const { renderUrl } = ConfigManager.backend; + const res = await fetch(renderUrl + "/render", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + content, + contentType, + theme, + width, + quality: 90 + }) + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + + const json: RenderResponse = await res.json(); + return json; + } catch (err) { + throw new Error("渲染内容失败: " + err.message); + } +} + +export function registerRender() { + const tool = new Tool({ + type: "function", + function: { + name: "render_content", + description: `将 Markdown 或 HTML 内容渲染为精美的图片。支持: +• Markdown 语法:标题、列表、代码块、表格、引用等; +• HTML 标签:可以直接使用 HTML 进行更灵活的排版; +• LaTeX 数学公式:行内公式 $...$,块级公式 $$...$$; +适合用于展示格式化的文本内容、教程、说明文档、数学公式等。`, + parameters: { + type: "object", + properties: { + content: { + type: "string", + description: "要渲染的内容(Markdown 或 HTML 格式)。支持 LaTeX 数学公式,使用前后 $ 包裹行内公式,前后 $$ 包裹块级公式。" + }, + contentType: { + type: "string", + description: "内容类型:auto(自动检测,推荐), markdown, html", + enum: ["auto", "markdown", "html"], + }, + theme: { + type: "string", + description: "主题样式: light(亮色主题,白色背景), dark(暗色主题,深色背景), gradient(渐变主题,紫色渐变背景)", + enum: ["light", "dark", "gradient"], + } + }, + required: ["content"] + } + } + }); + + tool.solve = async (ctx, msg, _, args) => { + const { + content, + contentType = 'auto', + theme = 'light' + } = args; + + if (!content || content.trim() === '') { + return { content: `内容不能为空`, images: [] }; + } + + const validContentTypes = ['auto', 'markdown', 'html']; + if (!validContentTypes.includes(contentType)) { + return { + content: `无效的内容类型: ${contentType}。支持: ${validContentTypes.join(', ')}`, + images: [] + }; + } + + const validThemes = ['light', 'dark', 'gradient']; + if (!validThemes.includes(theme)) { + return { + content: `无效的主题: ${theme}。支持: ${validThemes.join(', ')}`, + images: [] + }; + } + + try { + const result = await renderContent(content, contentType as any, theme, 1200); + + if (result.status === "success" && result.url) { + const actualType = result.contentType || contentType; + logger.info(`渲染成功,实际类型: ${actualType}, URL: ${result.url}`); + + seal.replyToSender(ctx, msg, `[CQ:image,file=${result.url}]`); + return { content: `渲染成功,已发送 (${actualType})`, images: [] }; + } else { + throw new Error(result.message || "渲染失败"); + } + } catch (err) { + logger.error(`内容渲染失败: ${err.message}`); + return { content: `渲染图片失败: ${err.message}`, images: [] }; + } + }; +} \ No newline at end of file diff --git "a/\347\233\270\345\205\263\345\220\216\347\253\257\351\241\271\347\233\256/md\345\222\214html\345\233\276\347\211\207\346\270\262\346\237\223/render.js" "b/\347\233\270\345\205\263\345\220\216\347\253\257\351\241\271\347\233\256/md\345\222\214html\345\233\276\347\211\207\346\270\262\346\237\223/render.js" new file mode 100644 index 0000000..1c8ad9c --- /dev/null +++ "b/\347\233\270\345\205\263\345\220\216\347\253\257\351\241\271\347\233\256/md\345\222\214html\345\233\276\347\211\207\346\270\262\346\237\223/render.js" @@ -0,0 +1,479 @@ +const express = require('express'); +const puppeteer = require('puppeteer'); +const { marked } = require('marked'); +const crypto = require('crypto'); +const path = require('path'); +const fs = require('fs').promises; + +const app = express(); +const port = 37632; + +// 配置 marked 选项 +marked.setOptions({ + breaks: true, + gfm: true, + headerIds: true, + mangle: false, + pedantic: false, + sanitize: false, + smartLists: true, + smartypants: false +}); + +// JSON 解析中间件,添加错误处理 +app.use(express.json({ + limit: '10mb', + strict: false +})); + +// 错误处理中间件 +app.use((err, req, res, next) => { + if (err instanceof SyntaxError && err.status === 400 && 'body' in err) { + console.error('JSON 解析错误:', err.message); + return res.status(400).json({ + status: 'error', + message: 'Invalid JSON format: ' + err.message + }); + } + next(err); +}); + +app.use('/images', express.static('generated_images')); + +const IMAGE_DIR = path.join(__dirname, 'generated_images'); + +async function ensureImageDir() { + try { + await fs.access(IMAGE_DIR); + } catch { + await fs.mkdir(IMAGE_DIR, { recursive: true }); + } +} + +function generateImageId() { + return crypto.randomBytes(16).toString('hex'); +} + +function detectContentType(content) { + const htmlPattern = /<(?:html|head|body|div|p|h[1-6]|table|ul|ol|li|span|a)\b[^>]*>/i; + return htmlPattern.test(content) ? 'html' : 'markdown'; +} + +// HTML模板 +function generateHTML(content, contentType = 'auto', theme = 'light', style = 'github') { + if (contentType === 'auto') { + contentType = detectContentType(content); + } + + const bodyContent = contentType === 'markdown' ? marked(content) : content; + + const themes = { + light: { + bg: '#ffffff', + text: '#24292e', + border: '#e1e4e8', + code_bg: '#f6f8fa', + blockquote_text: '#6a737d' + }, + dark: { + bg: '#0d1117', + text: '#c9d1d9', + border: '#30363d', + code_bg: '#161b22', + blockquote_text: '#8b949e' + }, + gradient: { + bg: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + text: '#ffffff', + border: 'rgba(255,255,255,0.2)', + code_bg: 'rgba(0,0,0,0.2)', + blockquote_text: 'rgba(255,255,255,0.8)' + } + }; + + const selectedTheme = themes[theme] || themes.light; + + return ` + + + + + + + + + + +
+ ${bodyContent} +
+ + + `; +} + +// 渲染内容为图片 +async function renderToImage(content, options = {}) { + const { + contentType = 'auto', + theme = 'light', + style = 'github', + width = 1200, + quality = 90 + } = options; + + const browser = await puppeteer.launch({ + headless: 'new', + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-web-security' + ] + }); + + try { + const page = await browser.newPage(); + + await page.setViewport({ + width, + height: 600, + deviceScaleFactor: 2 + }); + + const html = generateHTML(content, contentType, theme, style); + await page.setContent(html, { + waitUntil: 'networkidle0', + timeout: 30000 + }); + + await new Promise(r => setTimeout(r, 500)); + + const dimensions = await page.evaluate(() => { + const container = document.querySelector('.container'); + return { + width: container.offsetWidth, + height: container.offsetHeight + }; + }); + + const actualWidth = dimensions.width + 80; + const actualHeight = dimensions.height + 80; + + await page.setViewport({ + width: actualWidth, + height: actualHeight, + deviceScaleFactor: 2 + }); + + const imageId = generateImageId(); + const fileName = `${imageId}.png`; + const filePath = path.join(IMAGE_DIR, fileName); + + await page.screenshot({ + path: filePath, + type: 'png', + omitBackground: false + }); + + return { imageId, fileName, filePath }; + } finally { + await browser.close(); + } +} + +// API端点 + +app.get('/themes', (req, res) => { + res.json({ + themes: ['light', 'dark', 'gradient'], + contentTypes: ['auto', 'markdown', 'html'], + default: { + theme: 'light', + contentType: 'auto' + } + }); +}); + +app.post('/render', async (req, res) => { + try { + const { + content, + markdown, + html, + contentType = 'auto', + theme = 'light', + width = 1200, + quality = 90 + } = req.body; + + const inputContent = content || markdown || html; + + if (!inputContent) { + return res.status(400).json({ + status: 'error', + message: 'Content is required (use "content", "markdown", or "html" field)' + }); + } + + await ensureImageDir(); + + const result = await renderToImage(inputContent, { + contentType, + theme, + width, + quality + }); + + const imageUrl = `${req.protocol}://${req.get('host')}/images/${result.fileName}`; + + const detectedType = contentType === 'auto' + ? detectContentType(inputContent) + : contentType; + + res.json({ + status: 'success', + imageId: result.imageId, + url: imageUrl, + fileName: result.fileName, + contentType: detectedType, + theme: theme + }); + } catch (error) { + console.error('Render error:', error); + res.status(500).json({ + status: 'error', + message: error.message + }); + } +}); + +app.delete('/images/:imageId', async (req, res) => { + try { + const { imageId } = req.params; + const filePath = path.join(IMAGE_DIR, `${imageId}.png`); + + await fs.unlink(filePath); + + res.json({ + status: 'success', + message: 'Image deleted successfully' + }); + } catch (error) { + res.status(500).json({ + status: 'error', + message: error.message + }); + } +}); + +app.get('/health', (req, res) => { + res.json({ status: 'ok' }); +}); + +app.listen(port, () => { + console.log(`Content renderer service running on http://localhost:${port}`); + console.log(`Supports: Markdown, HTML, LaTeX formulas`); + console.log(`Themes: light, dark, gradient`); +}); \ No newline at end of file From 640edbb2e739a81f9fa211be8410fc5099f5426f Mon Sep 17 00:00:00 2001 From: error2913 <2913949387@qq.com> Date: Tue, 11 Nov 2025 22:20:55 +0800 Subject: [PATCH 2/7] =?UTF-8?q?style=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tool/tool_render.ts | 55 ++++++++++++----------------------------- 1 file changed, 16 insertions(+), 39 deletions(-) diff --git a/src/tool/tool_render.ts b/src/tool/tool_render.ts index 7351bb5..d56cfc6 100644 --- a/src/tool/tool_render.ts +++ b/src/tool/tool_render.ts @@ -7,7 +7,7 @@ interface RenderResponse { imageId?: string; url?: string; fileName?: string; - contentType?: string; + contentType?: string; message?: string; } @@ -19,9 +19,9 @@ interface RenderResponse { * @param width - 图片宽度 */ async function renderContent( - content: string, + content: string, contentType: 'auto' | 'markdown' | 'html' = 'auto', - theme: string = 'light', + theme: 'light' | 'dark' | 'gradient' = 'light', width: number = 1200 ): Promise { try { @@ -32,18 +32,18 @@ async function renderContent( "Content-Type": "application/json" }, body: JSON.stringify({ - content, - contentType, + content, + contentType, theme, width, quality: 90 }) }); - + if (!res.ok) { throw new Error(`HTTP ${res.status}: ${res.statusText}`); } - + const json: RenderResponse = await res.json(); return json; } catch (err) { @@ -56,26 +56,22 @@ export function registerRender() { type: "function", function: { name: "render_content", - description: `将 Markdown 或 HTML 内容渲染为精美的图片。支持: -• Markdown 语法:标题、列表、代码块、表格、引用等; -• HTML 标签:可以直接使用 HTML 进行更灵活的排版; -• LaTeX 数学公式:行内公式 $...$,块级公式 $$...$$; -适合用于展示格式化的文本内容、教程、说明文档、数学公式等。`, + description: `渲染Markdown或HTML内容为图片`, parameters: { type: "object", properties: { content: { type: "string", - description: "要渲染的内容(Markdown 或 HTML 格式)。支持 LaTeX 数学公式,使用前后 $ 包裹行内公式,前后 $$ 包裹块级公式。" + description: "要渲染的内容。支持 LaTeX 数学公式,使用前后 $ 包裹行内公式,前后 $$ 包裹块级公式。" }, contentType: { type: "string", - description: "内容类型:auto(自动检测,推荐), markdown, html", + description: "内容类型", enum: ["auto", "markdown", "html"], }, theme: { type: "string", - description: "主题样式: light(亮色主题,白色背景), dark(暗色主题,深色背景), gradient(渐变主题,紫色渐变背景)", + description: "主题样式,其中gradient为紫色渐变背景", enum: ["light", "dark", "gradient"], } }, @@ -85,31 +81,12 @@ export function registerRender() { }); tool.solve = async (ctx, msg, _, args) => { - const { - content, - contentType = 'auto', - theme = 'light' - } = args; + const { content, contentType = 'auto', theme = 'light' } = args; - if (!content || content.trim() === '') { - return { content: `内容不能为空`, images: [] }; - } + if (!content.trim()) return { content: `内容不能为空`, images: [] }; - const validContentTypes = ['auto', 'markdown', 'html']; - if (!validContentTypes.includes(contentType)) { - return { - content: `无效的内容类型: ${contentType}。支持: ${validContentTypes.join(', ')}`, - images: [] - }; - } - - const validThemes = ['light', 'dark', 'gradient']; - if (!validThemes.includes(theme)) { - return { - content: `无效的主题: ${theme}。支持: ${validThemes.join(', ')}`, - images: [] - }; - } + if (!['auto', 'markdown', 'html'].includes(contentType)) return { content: `无效的内容类型: ${contentType}。支持: auto, markdown, html`, images: [] }; + if (!['light', 'dark', 'gradient'].includes(theme)) return { content: `无效的主题: ${theme}。支持: light, dark, gradient`, images: [] }; try { const result = await renderContent(content, contentType as any, theme, 1200); @@ -117,7 +94,7 @@ export function registerRender() { if (result.status === "success" && result.url) { const actualType = result.contentType || contentType; logger.info(`渲染成功,实际类型: ${actualType}, URL: ${result.url}`); - + seal.replyToSender(ctx, msg, `[CQ:image,file=${result.url}]`); return { content: `渲染成功,已发送 (${actualType})`, images: [] }; } else { From 3808dffa73c1ff21446dc073bcc597287b5e9c5a Mon Sep 17 00:00:00 2001 From: baiyu-yu <135424680+baiyu-yu@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:41:53 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat:=E5=B0=86md=E5=92=8Chtml=E6=8B=86?= =?UTF-8?q?=E5=88=86=E6=88=90=E4=B8=A4=E4=B8=AA=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tool/tool_render.ts | 117 +++--- .../render.js" | 379 +++++++++--------- 2 files changed, 248 insertions(+), 248 deletions(-) diff --git a/src/tool/tool_render.ts b/src/tool/tool_render.ts index d56cfc6..9b2622c 100644 --- a/src/tool/tool_render.ts +++ b/src/tool/tool_render.ts @@ -1,6 +1,6 @@ import { logger } from "../logger"; import { Tool } from "./tool"; -import { ConfigManager } from "../config/config"; +import { ConfigManager } from "../config/configManager"; interface RenderResponse { status: string; @@ -11,33 +11,13 @@ interface RenderResponse { message?: string; } -/** - * 渲染内容为图片 - * @param content - 要渲染的内容(Markdown 或 HTML) - * @param contentType - 内容类型:'auto'(自动检测), 'markdown', 'html' - * @param theme - 主题:'light', 'dark', 'gradient' - * @param width - 图片宽度 - */ -async function renderContent( - content: string, - contentType: 'auto' | 'markdown' | 'html' = 'auto', - theme: 'light' | 'dark' | 'gradient' = 'light', - width: number = 1200 -): Promise { +async function postToRenderEndpoint(endpoint: string, bodyData: any): Promise { try { const { renderUrl } = ConfigManager.backend; - const res = await fetch(renderUrl + "/render", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - content, - contentType, - theme, - width, - quality: 90 - }) + const res = await fetch(renderUrl + endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(bodyData) }); if (!res.ok) { @@ -47,31 +27,36 @@ async function renderContent( const json: RenderResponse = await res.json(); return json; } catch (err) { - throw new Error("渲染内容失败: " + err.message); + throw new Error('渲染内容失败: ' + err.message); } } +// Markdown 渲染 +async function renderMarkdown(markdown: string, theme: 'light' | 'dark' | 'gradient' = 'light', width = 1200) { + return postToRenderEndpoint('/render/markdown', { markdown, theme, width, quality: 90 }); +} + +// HTML 渲染 +async function renderHtml(html: string, width = 1200) { + return postToRenderEndpoint('/render/html', { html, width, quality: 90 }); +} + export function registerRender() { - const tool = new Tool({ + const toolMd = new Tool({ type: "function", function: { - name: "render_content", - description: `渲染Markdown或HTML内容为图片`, + name: "render_markdown", + description: `渲染 Markdown 内容为图片`, parameters: { type: "object", properties: { content: { type: "string", - description: "要渲染的内容。支持 LaTeX 数学公式,使用前后 $ 包裹行内公式,前后 $$ 包裹块级公式。" - }, - contentType: { - type: "string", - description: "内容类型", - enum: ["auto", "markdown", "html"], + description: "要渲染的 Markdown 内容。支持 LaTeX 数学公式,使用前后 $ 包裹行内公式,前后 $$ 包裹块级公式。" }, theme: { type: "string", - description: "主题样式,其中gradient为紫色渐变背景", + description: "主题样式,其中 gradient 为紫色渐变背景", enum: ["light", "dark", "gradient"], } }, @@ -80,29 +65,61 @@ export function registerRender() { } }); - tool.solve = async (ctx, msg, _, args) => { - const { content, contentType = 'auto', theme = 'light' } = args; - - if (!content.trim()) return { content: `内容不能为空`, images: [] }; - - if (!['auto', 'markdown', 'html'].includes(contentType)) return { content: `无效的内容类型: ${contentType}。支持: auto, markdown, html`, images: [] }; + toolMd.solve = async (ctx, msg, _, args) => { + const { content, theme = 'light' } = args; + if (!content || !content.trim()) return { content: `内容不能为空`, images: [] }; if (!['light', 'dark', 'gradient'].includes(theme)) return { content: `无效的主题: ${theme}。支持: light, dark, gradient`, images: [] }; try { - const result = await renderContent(content, contentType as any, theme, 1200); - + const result = await renderMarkdown(content, theme, 1200); if (result.status === "success" && result.url) { - const actualType = result.contentType || contentType; - logger.info(`渲染成功,实际类型: ${actualType}, URL: ${result.url}`); + logger.info(`Markdown 渲染成功, URL: ${result.url}`); + seal.replyToSender(ctx, msg, `[CQ:image,file=${result.url}]`); + return { content: `渲染成功,已发送`, images: [] }; + } else { + throw new Error(result.message || "渲染失败"); + } + } catch (err) { + logger.error(`Markdown 渲染失败: ${err.message}`); + return { content: `渲染图片失败: ${err.message}`, images: [] }; + } + } + const toolHtml = new Tool({ + type: "function", + function: { + name: "render_html", + description: `渲染 HTML 内容为图片`, + parameters: { + type: "object", + properties: { + content: { + type: "string", + description: "要渲染的 HTML 内容。支持 LaTeX 数学公式,使用前后 $ 包裹行内公式,前后 $$ 包裹块级公式。" + } + }, + required: ["content"] + } + } + }); + + toolHtml.solve = async (ctx, msg, _, args) => { + const { content } = args; + if (!content || !content.trim()) return { content: `内容不能为空`, images: [] }; + + try { + const result = await renderHtml(content, 1200); + if (result.status === "success" && result.url) { + logger.info(`HTML 渲染成功, URL: ${result.url}`); seal.replyToSender(ctx, msg, `[CQ:image,file=${result.url}]`); - return { content: `渲染成功,已发送 (${actualType})`, images: [] }; + return { content: `渲染成功,已发送`, images: [] }; } else { throw new Error(result.message || "渲染失败"); } } catch (err) { - logger.error(`内容渲染失败: ${err.message}`); + logger.error(`HTML 渲染失败: ${err.message}`); return { content: `渲染图片失败: ${err.message}`, images: [] }; } - }; + } + } \ No newline at end of file diff --git "a/\347\233\270\345\205\263\345\220\216\347\253\257\351\241\271\347\233\256/md\345\222\214html\345\233\276\347\211\207\346\270\262\346\237\223/render.js" "b/\347\233\270\345\205\263\345\220\216\347\253\257\351\241\271\347\233\256/md\345\222\214html\345\233\276\347\211\207\346\270\262\346\237\223/render.js" index 1c8ad9c..2cf4973 100644 --- "a/\347\233\270\345\205\263\345\220\216\347\253\257\351\241\271\347\233\256/md\345\222\214html\345\233\276\347\211\207\346\270\262\346\237\223/render.js" +++ "b/\347\233\270\345\205\263\345\220\216\347\253\257\351\241\271\347\233\256/md\345\222\214html\345\233\276\347\211\207\346\270\262\346\237\223/render.js" @@ -10,7 +10,7 @@ const port = 37632; // 配置 marked 选项 marked.setOptions({ - breaks: true, + breaks: true, gfm: true, headerIds: true, mangle: false, @@ -20,8 +20,8 @@ marked.setOptions({ smartypants: false }); -// JSON 解析中间件,添加错误处理 -app.use(express.json({ +// JSON 解析中间件 +app.use(express.json({ limit: '10mb', strict: false })); @@ -54,19 +54,10 @@ function generateImageId() { return crypto.randomBytes(16).toString('hex'); } -function detectContentType(content) { - const htmlPattern = /<(?:html|head|body|div|p|h[1-6]|table|ul|ol|li|span|a)\b[^>]*>/i; - return htmlPattern.test(content) ? 'html' : 'markdown'; -} - // HTML模板 -function generateHTML(content, contentType = 'auto', theme = 'light', style = 'github') { - if (contentType === 'auto') { - contentType = detectContentType(content); - } - +function generateHTML(content, contentType, theme = 'light', style = 'github') { const bodyContent = contentType === 'markdown' ? marked(content) : content; - + const themes = { light: { bg: '#ffffff', @@ -93,7 +84,8 @@ function generateHTML(content, contentType = 'auto', theme = 'light', style = 'g const selectedTheme = themes[theme] || themes.light; - return ` + if (contentType === 'markdown') { + return ` @@ -126,6 +118,7 @@ function generateHTML(content, contentType = 'auto', theme = 'light', style = 'g min-height: 100vh; display: flex; justify-content: center; + align-items: flex-start; } .container { @@ -147,42 +140,16 @@ function generateHTML(content, contentType = 'auto', theme = 'light', style = 'g line-height: 1.25; color: ${selectedTheme.text}; } - - h1:first-child, h2:first-child, h3:first-child { - margin-top: 0; - } - - h1 { - font-size: 2em; - border-bottom: 1px solid ${selectedTheme.border}; - padding-bottom: 0.3em; - } - - h2 { - font-size: 1.5em; - border-bottom: 1px solid ${selectedTheme.border}; - padding-bottom: 0.3em; - } - + h1:first-child, h2:first-child, h3:first-child { margin-top: 0; } + h1 { font-size: 2em; border-bottom: 1px solid ${selectedTheme.border}; padding-bottom: 0.3em; } + h2 { font-size: 1.5em; border-bottom: 1px solid ${selectedTheme.border}; padding-bottom: 0.3em; } h3 { font-size: 1.25em; } h4 { font-size: 1em; } h5 { font-size: 0.875em; } h6 { font-size: 0.85em; } - - p { - margin-bottom: 16px; - color: ${selectedTheme.text}; - } - - strong, b { - font-weight: 600; - color: ${selectedTheme.text}; - } - - em, i { - font-style: italic; - } - + p { margin-bottom: 16px; color: ${selectedTheme.text}; } + strong, b { font-weight: 600; color: ${selectedTheme.text}; } + em, i { font-style: italic; } code { background: ${selectedTheme.code_bg}; padding: 0.2em 0.4em; @@ -192,7 +159,6 @@ function generateHTML(content, contentType = 'auto', theme = 'light', style = 'g color: ${selectedTheme.text}; border: 1px solid ${selectedTheme.border}; } - pre { background: ${selectedTheme.code_bg}; padding: 16px; @@ -201,44 +167,18 @@ function generateHTML(content, contentType = 'auto', theme = 'light', style = 'g margin-bottom: 16px; border: 1px solid ${selectedTheme.border}; } - - pre code { - background: none; - padding: 0; - border: none; - font-size: 0.9em; - line-height: 1.45; - } - + pre code { background: none; padding: 0; border: none; font-size: 0.9em; line-height: 1.45; } blockquote { border-left: 4px solid ${selectedTheme.border}; padding-left: 16px; margin: 16px 0; color: ${selectedTheme.blockquote_text}; } - - blockquote > :first-child { - margin-top: 0; - } - - blockquote > :last-child { - margin-bottom: 0; - } - - ul, ol { - margin-bottom: 16px; - padding-left: 2em; - } - - li { - margin-bottom: 8px; - color: ${selectedTheme.text}; - } - - li > p { - margin-bottom: 8px; - } - + blockquote > :first-child { margin-top: 0; } + blockquote > :last-child { margin-bottom: 0; } + ul, ol { margin-bottom: 16px; padding-left: 2em; } + li { margin-bottom: 8px; color: ${selectedTheme.text}; } + li > p { margin-bottom: 8px; } table { border-collapse: collapse; width: 100%; @@ -246,33 +186,16 @@ function generateHTML(content, contentType = 'auto', theme = 'light', style = 'g display: block; overflow-x: auto; } - th, td { border: 1px solid ${selectedTheme.border}; padding: 8px 12px; text-align: left; color: ${selectedTheme.text}; } - - th { - background: ${selectedTheme.code_bg}; - font-weight: 600; - } - - a { - color: #58a6ff; - text-decoration: none; - } - - a:hover { - text-decoration: underline; - } - - img { - max-width: 100%; - height: auto; - } - + th { background: ${selectedTheme.code_bg}; font-weight: 600; } + a { color: #58a6ff; text-decoration: none; } + a:hover { text-decoration: underline; } + img { max-width: 100%; height: auto; } hr { border: none; border-top: 1px solid ${selectedTheme.border}; @@ -280,28 +203,10 @@ function generateHTML(content, contentType = 'auto', theme = 'light', style = 'g background: none; height: 1px; } - - /* KaTeX 样式 */ - .katex { - font-size: 1.1em; - } - - .katex-display { - margin: 1em 0; - overflow-x: auto; - overflow-y: hidden; - } - - /* 任务列表 */ - input[type="checkbox"] { - margin-right: 0.5em; - } - - /* 删除线 */ - del { - text-decoration: line-through; - opacity: 0.7; - } + .katex { font-size: 1.1em; } + .katex-display { margin: 1em 0; overflow-x: auto; overflow-y: hidden; } + input[type="checkbox"] { margin-right: 0.5em; } + del { text-decoration: line-through; opacity: 0.7; } @@ -311,22 +216,60 @@ function generateHTML(content, contentType = 'auto', theme = 'light', style = 'g `; + } else { + // 移除所有外层样式,让传入的 HTML 自行决定外观,但是不传外层样式的时候是不是太怪了 + return ` + + + + + + + + + + + ${bodyContent} + + + `; + } } // 渲染内容为图片 async function renderToImage(content, options = {}) { const { - contentType = 'auto', + contentType, theme = 'light', style = 'github', - width = 1200, + width = 1200, quality = 90 } = options; const browser = await puppeteer.launch({ headless: 'new', args: [ - '--no-sandbox', + '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-web-security' @@ -335,47 +278,81 @@ async function renderToImage(content, options = {}) { try { const page = await browser.newPage(); - - await page.setViewport({ - width, - height: 600, - deviceScaleFactor: 2 + + await page.setViewport({ + width, + height: 3000, + deviceScaleFactor: 2 }); const html = generateHTML(content, contentType, theme, style); - await page.setContent(html, { + + await page.setContent(html, { waitUntil: 'networkidle0', timeout: 30000 }); - await new Promise(r => setTimeout(r, 500)); - - const dimensions = await page.evaluate(() => { - const container = document.querySelector('.container'); - return { - width: container.offsetWidth, - height: container.offsetHeight - }; - }); - - const actualWidth = dimensions.width + 80; - const actualHeight = dimensions.height + 80; - - await page.setViewport({ - width: actualWidth, - height: actualHeight, - deviceScaleFactor: 2 - }); + await new Promise(r => setTimeout(r, 1500)); const imageId = generateImageId(); const fileName = `${imageId}.png`; const filePath = path.join(IMAGE_DIR, fileName); - await page.screenshot({ - path: filePath, - type: 'png', - omitBackground: false - }); + let clip; + let omitBackground = false; + + if (contentType === 'markdown') { + clip = await page.evaluate(() => { + const container = document.querySelector('.container'); + if (!container) return null; + const rect = container.getBoundingClientRect(); + return { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height + }; + }); + + if (!clip) { + throw new Error('Could not find .container element for Markdown rendering.'); + } + omitBackground = false; + + } else { + clip = await page.evaluate(() => { + const body = document.body; + return { + x: 0, + y: 0, + width: body.scrollWidth, + height: body.scrollHeight + }; + }); + omitBackground = false; + } + + if (!clip || clip.width === 0 || clip.height === 0) { + console.warn('Clipping failed, screenshotting full page as fallback.'); + await page.screenshot({ + path: filePath, + type: 'png', + omitBackground: omitBackground, + fullPage: true + }); + } else { + await page.screenshot({ + path: filePath, + type: 'png', + omitBackground: omitBackground, + clip: { + x: clip.x, + y: clip.y, + width: Math.ceil(clip.width), + height: Math.ceil(clip.height) + } + }); + } return { imageId, fileName, filePath }; } finally { @@ -383,76 +360,79 @@ async function renderToImage(content, options = {}) { } } -// API端点 - -app.get('/themes', (req, res) => { - res.json({ - themes: ['light', 'dark', 'gradient'], - contentTypes: ['auto', 'markdown', 'html'], - default: { - theme: 'light', - contentType: 'auto' +// 渲染 Markdown +app.post('/render/markdown', async (req, res) => { + try { + const { markdown, theme = 'light', width = 1200, quality = 90 } = req.body; + if (!markdown) { + return res.status(400).json({ status: 'error', message: 'Field "markdown" is required' }); } - }); + + await ensureImageDir(); + + const result = await renderToImage(markdown, { + contentType: 'markdown', + theme, + width, + quality + }); + + const imageUrl = `${req.protocol}://${req.get('host')}/images/${result.fileName}`; + + res.json({ + status: 'success', + imageId: result.imageId, + url: imageUrl, + fileName: result.fileName, + contentType: 'markdown', + theme + }); + } catch (error) { + console.error('Render markdown error:', error); + res.status(500).json({ status: 'error', message: error.message }); + } }); -app.post('/render', async (req, res) => { +// 渲染 HTML +app.post('/render/html', async (req, res) => { try { - const { - content, - markdown, - html, - contentType = 'auto', - theme = 'light', - width = 1200, - quality = 90 - } = req.body; - - const inputContent = content || markdown || html; - - if (!inputContent) { - return res.status(400).json({ - status: 'error', - message: 'Content is required (use "content", "markdown", or "html" field)' - }); + const { html, width = 1200, quality = 90 } = req.body; + if (!html) { + return res.status(400).json({ status: 'error', message: 'Field "html" is required' }); } await ensureImageDir(); - const result = await renderToImage(inputContent, { - contentType, - theme, + const result = await renderToImage(html, { + contentType: 'html', width, quality }); const imageUrl = `${req.protocol}://${req.get('host')}/images/${result.fileName}`; - const detectedType = contentType === 'auto' - ? detectContentType(inputContent) - : contentType; - res.json({ status: 'success', imageId: result.imageId, url: imageUrl, fileName: result.fileName, - contentType: detectedType, - theme: theme + contentType: 'html' }); } catch (error) { - console.error('Render error:', error); - res.status(500).json({ - status: 'error', - message: error.message - }); + console.error('Render html error:', error); + res.status(500).json({ status: 'error', message: error.message }); } }); app.delete('/images/:imageId', async (req, res) => { try { const { imageId } = req.params; - const filePath = path.join(IMAGE_DIR, `${imageId}.png`); + const safeImageId = path.basename(imageId); + if (safeImageId !== imageId) { + return res.status(400).json({ status: 'error', message: 'Invalid image ID' }); + } + + const filePath = path.join(IMAGE_DIR, `${safeImageId}.png`); await fs.unlink(filePath); @@ -461,6 +441,9 @@ app.delete('/images/:imageId', async (req, res) => { message: 'Image deleted successfully' }); } catch (error) { + if (error.code === 'ENOENT') { + return res.status(404).json({ status: 'error', message: 'Image not found' }); + } res.status(500).json({ status: 'error', message: error.message From 4da17315ca7176e419f677ec1be75e6d8bc380ee Mon Sep 17 00:00:00 2001 From: error2913 <2913949387@qq.com> Date: Fri, 14 Nov 2025 18:35:26 +0800 Subject: [PATCH 4/7] style --- src/config/config_backend.ts | 2 +- src/tool/tool_render.ts | 23 +++++++++-------------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/config/config_backend.ts b/src/config/config_backend.ts index 5977c67..30cb262 100644 --- a/src/config/config_backend.ts +++ b/src/config/config_backend.ts @@ -21,7 +21,7 @@ export class BackendConfig { webSearchUrl: seal.ext.getStringConfig(BackendConfig.ext, "联网搜索"), webReadUrl: seal.ext.getStringConfig(BackendConfig.ext, "网页读取"), usageChartUrl: seal.ext.getStringConfig(BackendConfig.ext, "用量图表"), - renderUrl: seal.ext.getStringConfig(BackendConfig.ext, "md和html图片渲染"), + renderUrl: seal.ext.getStringConfig(BackendConfig.ext, "md和html图片渲染") } } } diff --git a/src/tool/tool_render.ts b/src/tool/tool_render.ts index 9b2622c..9721285 100644 --- a/src/tool/tool_render.ts +++ b/src/tool/tool_render.ts @@ -20,9 +20,7 @@ async function postToRenderEndpoint(endpoint: string, bodyData: any): Promise { const { content, theme = 'light' } = args; if (!content || !content.trim()) return { content: `内容不能为空`, images: [] }; @@ -92,17 +89,16 @@ export function registerRender() { description: `渲染 HTML 内容为图片`, parameters: { type: "object", - properties: { - content: { - type: "string", - description: "要渲染的 HTML 内容。支持 LaTeX 数学公式,使用前后 $ 包裹行内公式,前后 $$ 包裹块级公式。" - } - }, - required: ["content"] + properties: { + content: { + type: "string", + description: "要渲染的 HTML 内容。支持 LaTeX 数学公式,使用前后 $ 包裹行内公式,前后 $$ 包裹块级公式。" + } + }, + required: ["content"] } } }); - toolHtml.solve = async (ctx, msg, _, args) => { const { content } = args; if (!content || !content.trim()) return { content: `内容不能为空`, images: [] }; @@ -121,5 +117,4 @@ export function registerRender() { return { content: `渲染图片失败: ${err.message}`, images: [] }; } } - } \ No newline at end of file From 66fcf03fc4b8b28eebc2bb849351835fe80ef5a7 Mon Sep 17 00:00:00 2001 From: baiyu-yu <135424680+baiyu-yu@users.noreply.github.com> Date: Sat, 15 Nov 2025 15:25:48 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat:=E4=BF=AE=E6=94=B9=E4=B8=BAbase64?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=EF=BC=8C=E5=85=81=E8=AE=B8=E9=95=BF=E4=B9=85?= =?UTF-8?q?=E5=82=A8=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tool/tool_render.ts | 100 ++++++++++++++---- .../render.js" | 31 ++---- 2 files changed, 90 insertions(+), 41 deletions(-) diff --git a/src/tool/tool_render.ts b/src/tool/tool_render.ts index 9721285..a92084b 100644 --- a/src/tool/tool_render.ts +++ b/src/tool/tool_render.ts @@ -1,6 +1,8 @@ import { logger } from "../logger"; import { Tool } from "./tool"; import { ConfigManager } from "../config/configManager"; +import { AIManager } from "../AI/AI"; +import { Image, ImageManager } from "../AI/image"; interface RenderResponse { status: string; @@ -8,6 +10,7 @@ interface RenderResponse { url?: string; fileName?: string; contentType?: string; + base64?: string; message?: string; } @@ -52,27 +55,56 @@ export function registerRender() { type: "string", description: "要渲染的 Markdown 内容。支持 LaTeX 数学公式,使用前后 $ 包裹行内公式,前后 $$ 包裹块级公式" }, + name: { + type: "string", + description: "名称,对内容大致描述" + }, theme: { type: "string", description: "主题样式,其中 gradient 为紫色渐变背景", enum: ["light", "dark", "gradient"], + }, + save: { + type: "boolean", + description: "是否保存图片" } }, - required: ["content"] + required: ["content", "name", "save"] } } }); - toolMd.solve = async (ctx, msg, _, args) => { - const { content, theme = 'light' } = args; + + toolMd.solve = async (ctx, msg, ai, args) => { + const { content, name, theme = 'light', save } = args; if (!content || !content.trim()) return { content: `内容不能为空`, images: [] }; + if (!name || !name.trim()) return { content: `图片名称不能为空`, images: [] }; if (!['light', 'dark', 'gradient'].includes(theme)) return { content: `无效的主题: ${theme}。支持: light, dark, gradient`, images: [] }; + // 切换到当前会话ai + if (!ctx.isPrivate) ai = AIManager.getAI(ctx.group.groupId); + try { const result = await renderMarkdown(content, theme, 1200); - if (result.status === "success" && result.url) { - logger.info(`Markdown 渲染成功, URL: ${result.url}`); - seal.replyToSender(ctx, msg, `[CQ:image,file=${result.url}]`); - return { content: `渲染成功,已发送`, images: [] }; + if (result.status === "success" && result.base64) { + logger.info(`Markdown 渲染成功`); + + const base64 = result.base64; + const file = seal.base64ToImage(base64); + + const img = new Image(file); + img.id = ImageManager.generateImageId(ctx, ai, `render_markdown_${name}`); + img.isUrl = false; + img.base64 = base64; + img.content = `Markdown 渲染图片<|img:${img.id}|> +主题:${theme}`; + + if (save) { + const kws = ["render", "markdown", name, theme]; + ai.memory.addMemory(ctx, ai, [], [], kws, [img], img.content); + } + + seal.replyToSender(ctx, msg, ImageManager.getImageCQCode(img)); + return { content: `渲染成功:<|img:${img.id}|>`, images: [img] }; } else { throw new Error(result.message || "渲染失败"); } @@ -89,26 +121,54 @@ export function registerRender() { description: `渲染 HTML 内容为图片`, parameters: { type: "object", - properties: { - content: { - type: "string", - description: "要渲染的 HTML 内容。支持 LaTeX 数学公式,使用前后 $ 包裹行内公式,前后 $$ 包裹块级公式。" - } - }, - required: ["content"] + properties: { + content: { + type: "string", + description: "要渲染的 HTML 内容。支持 LaTeX 数学公式,使用前后 $ 包裹行内公式,前后 $$ 包裹块级公式。" + }, + name: { + type: "string", + description: "名称,对内容大致描述" + }, + save: { + type: "boolean", + description: "是否保存图片" + } + }, + required: ["content", "name", "save"] } } }); - toolHtml.solve = async (ctx, msg, _, args) => { - const { content } = args; + + toolHtml.solve = async (ctx, msg, ai, args) => { + const { content, name, save } = args; if (!content || !content.trim()) return { content: `内容不能为空`, images: [] }; + if (!name || !name.trim()) return { content: `图片名称不能为空`, images: [] }; + + // 切换到当前会话ai + if (!ctx.isPrivate) ai = AIManager.getAI(ctx.group.groupId); try { const result = await renderHtml(content, 1200); - if (result.status === "success" && result.url) { - logger.info(`HTML 渲染成功, URL: ${result.url}`); - seal.replyToSender(ctx, msg, `[CQ:image,file=${result.url}]`); - return { content: `渲染成功,已发送`, images: [] }; + if (result.status === "success" && result.base64) { + logger.info(`HTML 渲染成功`); + + const base64 = result.base64; + const file = seal.base64ToImage(base64); + + const img = new Image(file); + img.id = ImageManager.generateImageId(ctx, ai, `render_html_${name}`); + img.isUrl = false; + img.base64 = base64; + img.content = `HTML 渲染图片<|img:${img.id}|>`; + + if (save) { + const kws = ["render", "html", name]; + ai.memory.addMemory(ctx, ai, [], [], kws, [img], img.content); + } + + seal.replyToSender(ctx, msg, ImageManager.getImageCQCode(img)); + return { content: `渲染成功:<|img:${img.id}|>`, images: [img] }; } else { throw new Error(result.message || "渲染失败"); } diff --git "a/\347\233\270\345\205\263\345\220\216\347\253\257\351\241\271\347\233\256/md\345\222\214html\345\233\276\347\211\207\346\270\262\346\237\223/render.js" "b/\347\233\270\345\205\263\345\220\216\347\253\257\351\241\271\347\233\256/md\345\222\214html\345\233\276\347\211\207\346\270\262\346\237\223/render.js" index 2cf4973..6f43c76 100644 --- "a/\347\233\270\345\205\263\345\220\216\347\253\257\351\241\271\347\233\256/md\345\222\214html\345\233\276\347\211\207\346\270\262\346\237\223/render.js" +++ "b/\347\233\270\345\205\263\345\220\216\347\253\257\351\241\271\347\233\256/md\345\222\214html\345\233\276\347\211\207\346\270\262\346\237\223/render.js" @@ -295,8 +295,6 @@ async function renderToImage(content, options = {}) { await new Promise(r => setTimeout(r, 1500)); const imageId = generateImageId(); - const fileName = `${imageId}.png`; - const filePath = path.join(IMAGE_DIR, fileName); let clip; let omitBackground = false; @@ -332,17 +330,17 @@ async function renderToImage(content, options = {}) { omitBackground = false; } + let base64; if (!clip || clip.width === 0 || clip.height === 0) { console.warn('Clipping failed, screenshotting full page as fallback.'); - await page.screenshot({ - path: filePath, + base64 = await page.screenshot({ type: 'png', omitBackground: omitBackground, - fullPage: true + fullPage: true, + encoding: 'base64' }); } else { - await page.screenshot({ - path: filePath, + base64 = await page.screenshot({ type: 'png', omitBackground: omitBackground, clip: { @@ -350,11 +348,12 @@ async function renderToImage(content, options = {}) { y: clip.y, width: Math.ceil(clip.width), height: Math.ceil(clip.height) - } + }, + encoding: 'base64' }); } - return { imageId, fileName, filePath }; + return { imageId, base64 }; } finally { await browser.close(); } @@ -368,8 +367,6 @@ app.post('/render/markdown', async (req, res) => { return res.status(400).json({ status: 'error', message: 'Field "markdown" is required' }); } - await ensureImageDir(); - const result = await renderToImage(markdown, { contentType: 'markdown', theme, @@ -377,13 +374,10 @@ app.post('/render/markdown', async (req, res) => { quality }); - const imageUrl = `${req.protocol}://${req.get('host')}/images/${result.fileName}`; - res.json({ status: 'success', imageId: result.imageId, - url: imageUrl, - fileName: result.fileName, + base64: result.base64, contentType: 'markdown', theme }); @@ -401,21 +395,16 @@ app.post('/render/html', async (req, res) => { return res.status(400).json({ status: 'error', message: 'Field "html" is required' }); } - await ensureImageDir(); - const result = await renderToImage(html, { contentType: 'html', width, quality }); - const imageUrl = `${req.protocol}://${req.get('host')}/images/${result.fileName}`; - res.json({ status: 'success', imageId: result.imageId, - url: imageUrl, - fileName: result.fileName, + base64: result.base64, contentType: 'html' }); } catch (error) { From 0617bb46f0abca8d89fbe7de62081553d2e01112 Mon Sep 17 00:00:00 2001 From: error2913 <2913949387@qq.com> Date: Sat, 15 Nov 2025 17:13:21 +0800 Subject: [PATCH 6/7] style --- src/tool/tool_render.ts | 83 ++++++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 43 deletions(-) diff --git a/src/tool/tool_render.ts b/src/tool/tool_render.ts index a92084b..5cf6570 100644 --- a/src/tool/tool_render.ts +++ b/src/tool/tool_render.ts @@ -2,7 +2,8 @@ import { logger } from "../logger"; import { Tool } from "./tool"; import { ConfigManager } from "../config/configManager"; import { AIManager } from "../AI/AI"; -import { Image, ImageManager } from "../AI/image"; +import { Image } from "../AI/image"; +import { generateId } from "../utils/utils"; interface RenderResponse { status: string; @@ -74,7 +75,7 @@ export function registerRender() { } }); - toolMd.solve = async (ctx, msg, ai, args) => { + toolMd.solve = async (ctx, _, ai, args) => { const { content, name, theme = 'light', save } = args; if (!content || !content.trim()) return { content: `内容不能为空`, images: [] }; if (!name || !name.trim()) return { content: `图片名称不能为空`, images: [] }; @@ -83,28 +84,26 @@ export function registerRender() { // 切换到当前会话ai if (!ctx.isPrivate) ai = AIManager.getAI(ctx.group.groupId); + const kws = ["render", "markdown", name, theme]; + try { const result = await renderMarkdown(content, theme, 1200); if (result.status === "success" && result.base64) { - logger.info(`Markdown 渲染成功`); - const base64 = result.base64; - const file = seal.base64ToImage(base64); - - const img = new Image(file); - img.id = ImageManager.generateImageId(ctx, ai, `render_markdown_${name}`); - img.isUrl = false; + if (!base64) { + logger.error(`生成的base64为空`); + return { content: "生成的base64为空", images: [] }; + } + + const img = new Image(); + img.id = `${name}_${generateId()}`; img.base64 = base64; img.content = `Markdown 渲染图片<|img:${img.id}|> 主题:${theme}`; - if (save) { - const kws = ["render", "markdown", name, theme]; - ai.memory.addMemory(ctx, ai, [], [], kws, [img], img.content); - } + if (save) ai.memory.addMemory(ctx, ai, [], [], kws, [img], img.content); - seal.replyToSender(ctx, msg, ImageManager.getImageCQCode(img)); - return { content: `渲染成功:<|img:${img.id}|>`, images: [img] }; + return { content: `渲染成功,请使用<|img:${img.id}|>发送`, images: [img] }; } else { throw new Error(result.message || "渲染失败"); } @@ -121,26 +120,26 @@ export function registerRender() { description: `渲染 HTML 内容为图片`, parameters: { type: "object", - properties: { - content: { - type: "string", - description: "要渲染的 HTML 内容。支持 LaTeX 数学公式,使用前后 $ 包裹行内公式,前后 $$ 包裹块级公式。" - }, - name: { - type: "string", - description: "名称,对内容大致描述" - }, - save: { - type: "boolean", - description: "是否保存图片" - } + properties: { + content: { + type: "string", + description: "要渲染的 HTML 内容。支持 LaTeX 数学公式,使用前后 $ 包裹行内公式,前后 $$ 包裹块级公式。" + }, + name: { + type: "string", + description: "名称,对内容大致描述" }, - required: ["content", "name", "save"] + save: { + type: "boolean", + description: "是否保存图片" + } + }, + required: ["content", "name", "save"] } } }); - toolHtml.solve = async (ctx, msg, ai, args) => { + toolHtml.solve = async (ctx, _, ai, args) => { const { content, name, save } = args; if (!content || !content.trim()) return { content: `内容不能为空`, images: [] }; if (!name || !name.trim()) return { content: `图片名称不能为空`, images: [] }; @@ -148,27 +147,25 @@ export function registerRender() { // 切换到当前会话ai if (!ctx.isPrivate) ai = AIManager.getAI(ctx.group.groupId); + const kws = ["render", "html", name]; + try { const result = await renderHtml(content, 1200); if (result.status === "success" && result.base64) { - logger.info(`HTML 渲染成功`); - const base64 = result.base64; - const file = seal.base64ToImage(base64); - - const img = new Image(file); - img.id = ImageManager.generateImageId(ctx, ai, `render_html_${name}`); - img.isUrl = false; + if (!base64) { + logger.error(`生成的base64为空`); + return { content: "生成的base64为空", images: [] }; + } + + const img = new Image(); + img.id = `${name}_${generateId()}`; img.base64 = base64; img.content = `HTML 渲染图片<|img:${img.id}|>`; - if (save) { - const kws = ["render", "html", name]; - ai.memory.addMemory(ctx, ai, [], [], kws, [img], img.content); - } + if (save) ai.memory.addMemory(ctx, ai, [], [], kws, [img], img.content); - seal.replyToSender(ctx, msg, ImageManager.getImageCQCode(img)); - return { content: `渲染成功:<|img:${img.id}|>`, images: [img] }; + return { content: `渲染成功,请使用<|img:${img.id}|>发送`, images: [img] }; } else { throw new Error(result.message || "渲染失败"); } From 88252215d0407eeec62faad172b57ba64501ac87 Mon Sep 17 00:00:00 2001 From: error2913 <2913949387@qq.com> Date: Sun, 16 Nov 2025 01:09:39 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E5=AF=B9image=E8=BF=9B=E8=A1=8C=E9=87=8D?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/AI/AI.ts | 173 ++++++++++++----------- src/AI/image.ts | 287 ++++++++++++++++++-------------------- src/index.ts | 56 ++++---- src/service.ts | 92 ++---------- src/tool/tool.ts | 10 +- src/tool/tool_image.ts | 38 ++--- src/tool/tool_meme.ts | 1 + src/tool/tool_render.ts | 9 +- src/update.ts | 4 +- src/utils/utils_string.ts | 24 +--- 10 files changed, 295 insertions(+), 399 deletions(-) diff --git a/src/AI/AI.ts b/src/AI/AI.ts index 67bcb8e..7f17854 100644 --- a/src/AI/AI.ts +++ b/src/AI/AI.ts @@ -1,4 +1,4 @@ -import { ImageManager } from "./image"; +import { Image, ImageManager } from "./image"; import { ConfigManager } from "../config/configManager"; import { replyToSender, revive, transformMsgId } from "../utils/utils"; import { endStream, pollStream, sendChatRequest, startStream } from "../service"; @@ -7,7 +7,7 @@ import { MemoryManager } from "./memory"; import { handleMessages, parseBody } from "../utils/utils_message"; import { ToolManager } from "../tool/tool"; import { logger } from "../logger"; -import { checkRepeat, handleReply, MessageSegment, transformArrayToContent } from "../utils/utils_string"; +import { checkRepeat, handleReply, MessageSegment, transformArrayToContent, transformTextToArray } from "../utils/utils_string"; import { TimerManager } from "../timer"; export interface GroupInfo { @@ -103,19 +103,37 @@ export class AI { await ai.context.addMessage(ctx, msg, ai, content, images, 'user', transformMsgId(msg.rawId)); } - async chat(ctx: seal.MsgContext, msg: seal.Message, reason: string = ''): Promise { - logger.info('触发回复:', reason || '未知原因'); + async reply(ctx: seal.MsgContext, msg: seal.Message, contextArray: string[], replyArray: string[], images: Image[]) { + for (let i = 0; i < contextArray.length; i++) { + const content = contextArray[i]; + const reply = replyArray[i]; + const msgId = await replyToSender(ctx, msg, this, reply); + await this.context.addMessage(ctx, msg, this, content, images, 'assistant', msgId); + } - const { bucketLimit, fillInterval } = ConfigManager.received; - // 补充并检查触发次数 - if (Date.now() - this.bucket.lastTime > fillInterval * 1000) { - const fillCount = (Date.now() - this.bucket.lastTime) / (fillInterval * 1000); - this.bucket.count = Math.min(this.bucket.count + fillCount, bucketLimit); - this.bucket.lastTime = Date.now(); + //发送偷来的图片 + const { p } = ConfigManager.image; + if (Math.random() * 100 <= p) { + const img = await this.imageManager.drawImage(); + if (img) seal.replyToSender(ctx, msg, img.CQCode); } - if (this.bucket.count <= 0) { - logger.warning(`触发次数不足,无法回复`); - return; + } + + async chat(ctx: seal.MsgContext, msg: seal.Message, reason: string = '', tool_choice?: string): Promise { + logger.info('触发回复:', reason || '未知原因'); + + if (reason !== '函数回调触发') { + const { bucketLimit, fillInterval } = ConfigManager.received; + // 补充并检查触发次数 + if (Date.now() - this.bucket.lastTime > fillInterval * 1000) { + const fillCount = (Date.now() - this.bucket.lastTime) / (fillInterval * 1000); + this.bucket.count = Math.min(this.bucket.count + fillCount, bucketLimit); + this.bucket.lastTime = Date.now(); + } + if (this.bucket.count <= 0) { + logger.warning(`触发次数不足,无法回复`); + return; + } } // 检查toolsNotAllow状态 @@ -143,53 +161,66 @@ export class AI { return; } - let result = { - contextArray: [], - replyArray: [], - images: [] - } + + const { isTool, usePromptEngineering } = ConfigManager.tool; + const toolInfos = this.tool.getToolsInfo(msg.messageType); + + let result = { contextArray: [], replyArray: [], images: [] }; const MaxRetry = 3; for (let retry = 1; retry <= MaxRetry; retry++) { // 处理messages const messages = await handleMessages(ctx, this); //获取处理后的回复 - const raw_reply = await sendChatRequest(ctx, msg, this, messages, "auto"); - result = await handleReply(ctx, msg, this, raw_reply); - - if (!checkRepeat(this.context, result.contextArray.join('')) || result.replyArray.join('').trim() === '') { - break; + const { content: raw_reply, tool_calls } = await sendChatRequest(messages, toolInfos, tool_choice || "auto"); + + if (isTool) { + if (usePromptEngineering) { + const match = raw_reply.match(/<[\|│|]?function(?:_call)?>([\s\S]*)<\/function(?:_call)?>/); + if (match) { + const messageArray = transformTextToArray(match[0]); + const { content, images } = await transformArrayToContent(ctx, this, messageArray); + await this.context.addMessage(ctx, msg, this, content, images, "assistant", ''); + try { + await ToolManager.handlePromptToolCall(ctx, msg, this, match[1]); + await this.chat(ctx, msg, '函数回调触发'); + } catch (e) { + logger.error(`在handlePromptToolCall中出错:`, e.message); + } + } + } else { + if (tool_calls.length > 0) { + logger.info(`触发工具调用`); + this.context.addToolCallsMessage(tool_calls); + try { + tool_choice = await ToolManager.handleToolCalls(ctx, msg, this, tool_calls); + await this.chat(ctx, msg, '函数回调触发', tool_choice); + } catch (e) { + logger.error(`在handleToolCalls中出错:`, e.message); + } + } + } } - if (retry > MaxRetry) { - logger.warning(`发现复读,已达到最大重试次数,清除AI上下文`); - this.context.clearMessages('assistant', 'tool'); - break; + // 检查是否为复读 + result = await handleReply(ctx, msg, this, raw_reply); + if (checkRepeat(this.context, result.contextArray.join('')) && result.replyArray.join('').trim()) { + if (retry > MaxRetry) { + logger.warning(`发现复读,已达到最大重试次数,清除AI上下文`); + this.context.clearMessages('assistant', 'tool'); + break; + } + + logger.warning(`发现复读,一秒后进行重试:[${retry}/3]`); + await new Promise(resolve => setTimeout(resolve, 1000)); + continue; } - logger.warning(`发现复读,一秒后进行重试:[${retry}/3]`); - await new Promise(resolve => setTimeout(resolve, 1000)); + break; } const { contextArray, replyArray, images } = result; - - for (let i = 0; i < contextArray.length; i++) { - const content = contextArray[i]; - const reply = replyArray[i]; - const msgId = await replyToSender(ctx, msg, this, reply); - await this.context.addMessage(ctx, msg, this, content, images, 'assistant', msgId); - } - - //发送偷来的图片 - const { p } = ConfigManager.image; - if (Math.random() * 100 <= p) { - const file = await this.imageManager.drawImageFile(); - - if (file) { - seal.replyToSender(ctx, msg, `[CQ:image,file=${file}]`); - } - } - + await this.reply(ctx, msg, contextArray, replyArray, images); AIManager.saveAI(this.id); } @@ -200,9 +231,7 @@ export class AI { const messages = await handleMessages(ctx, this); const id = await startStream(messages); - if (id === '') { - return; - } + if (!id) return; this.stream.id = id; let status = 'processing'; @@ -214,15 +243,10 @@ export class AI { status = result.status; const raw_reply = result.reply; - if (raw_reply.length <= 8) { - interval = 1500; - } else if (raw_reply.length <= 20) { - interval = 1000; - } else if (raw_reply.length <= 30) { - interval = 500; - } else { - interval = 200; - } + if (raw_reply.length <= 8) interval = 1500; + else if (raw_reply.length <= 20) interval = 1000; + else if (raw_reply.length <= 30) interval = 500; + else interval = 200; if (raw_reply.trim() === '') { after = result.nextAfter; @@ -239,25 +263,13 @@ export class AI { const match = raw_reply.match(/([\s\S]*)<[\|│|]?function(?:_call)?>/); if (match && match[1].trim()) { const { contextArray, replyArray, images } = await handleReply(ctx, msg, this, match[1]); - - if (this.stream.id !== id) { - return; - } - - for (let i = 0; i < contextArray.length; i++) { - const content = contextArray[i]; - const reply = replyArray[i]; - const msgId = await replyToSender(ctx, msg, this, reply); - await this.context.addMessage(ctx, msg, this, content, images, 'assistant', msgId); - } + if (this.stream.id !== id) return; + await this.reply(ctx, msg, contextArray, replyArray, images); } - this.stream.toolCallStatus = true; } - if (this.stream.id !== id) { - return; - } + if (this.stream.id !== id) return; if (this.stream.toolCallStatus) { this.stream.reply += raw_reply; @@ -295,17 +307,8 @@ export class AI { } const { contextArray, replyArray, images } = await handleReply(ctx, msg, this, raw_reply); - - if (this.stream.id !== id) { - return; - } - - for (let i = 0; i < contextArray.length; i++) { - const content = contextArray[i]; - const reply = replyArray[i]; - const msgId = await replyToSender(ctx, msg, this, reply); - await this.context.addMessage(ctx, msg, this, content, images, 'assistant', msgId); - } + if (this.stream.id !== id) return; + this.reply(ctx, msg, contextArray, replyArray, images); after = result.nextAfter; await new Promise(resolve => setTimeout(resolve, interval)); diff --git a/src/AI/image.ts b/src/AI/image.ts index 0915384..3fde616 100644 --- a/src/AI/image.ts +++ b/src/AI/image.ts @@ -8,7 +8,7 @@ import { MessageSegment } from "../utils/utils_string"; export class Image { static validKeys: (keyof Image)[] = ['id', 'file', 'content']; id: string; - file: string; + file: string; // 图片url或本地路径 content: string; constructor() { @@ -17,17 +17,118 @@ export class Image { this.content = ''; } - get isUrl(): boolean { - return this.file.startsWith('http'); + get type(): 'url' | 'local' | 'base64' { + if (this.file.startsWith('http')) return 'url'; + if (this.format) return 'base64'; + return 'local'; } get base64(): string { return ConfigManager.ext.storageGet(`base64_${this.id}`) || ''; } - set base64(value: string) { + this.file = ''; ConfigManager.ext.storageSet(`base64_${this.id}`, value); } + + get format(): string { + return ConfigManager.ext.storageGet(`format_${this.id}`) || ''; + } + set format(value: string) { + ConfigManager.ext.storageSet(`format_${this.id}`, value); + } + + get CQCode(): string { + const file = this.type === 'base64' ? seal.base64ToImage(this.base64) : this.file; + return `[CQ:image,file=${file}]`; + } + + async checkImageUrl(): Promise { + if (this.type !== 'url') return true; + let isValid = false; + try { + const response = await fetch(this.file, { method: 'GET' }); + + if (response.ok) { + const contentType = response.headers.get('Content-Type'); + if (contentType && contentType.startsWith('image')) { + logger.info('URL有效且未过期'); + isValid = true; + } else { + logger.warning(`URL有效但未返回图片 Content-Type: ${contentType}`); + } + } else { + if (response.status === 500) { + logger.warning(`URL不知道有没有效 状态码: ${response.status}`); + isValid = true; + } else { + logger.warning(`URL无效或过期 状态码: ${response.status}`); + } + } + } catch (error) { + logger.error('在checkImageUrl中请求出错:', error); + } + return isValid; + } + + async urlToBase64() { + if (this.type !== 'url') return; + const { imageTobase64Url } = ConfigManager.backend; + try { + const response = await fetch(`${imageTobase64Url}/image-to-base64`, { + method: 'POST', + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + }, + body: JSON.stringify({ url: this.file }) + }); + + const text = await response.text(); + if (!response.ok) throw new Error(`请求失败! 状态码: ${response.status}\n响应体: ${text}`); + if (!text) throw new Error("响应体为空"); + + try { + const data = JSON.parse(text); + if (data.error) throw new Error(`请求失败! 错误信息: ${data.error.message}`); + if (!data.base64 || !data.format) throw new Error(`响应体中缺少base64或format字段`); + this.base64 = data.base64; + this.format = data.format; + } catch (e) { + throw new Error(`解析响应体时出错:${e}\n响应体:${text}`); + } + } catch (error) { + logger.error("在imageUrlToBase64中请求出错:", error); + } + } + + async imageToText(prompt = '') { + const { defaultPrompt, urlToBase64, maxChars } = ConfigManager.image; + + if (urlToBase64 == '总是' && this.type === 'url') await this.urlToBase64(); + + const messages = [{ + role: "user", + content: [{ + "type": "image_url", + "image_url": { "url": this.type === 'base64' ? `data:image/${this.format};base64,${this.base64}` : this.file } + }, { + "type": "text", + "text": prompt ? prompt : defaultPrompt + }] + }] + + this.content = (await sendITTRequest(messages)).slice(0, maxChars); + + if (!this.content && urlToBase64 === '自动' && this.type === 'url') { + logger.info(`图片${this.id}第一次识别失败,自动尝试使用转换为base64`); + await this.urlToBase64(); + messages[0].content[0].image_url.url = `data:image/${this.format};base64,${this.base64}`; + this.content = (await sendITTRequest(messages)).slice(0, maxChars); + } + + if (!this.content) logger.error(`图片${this.id}识别失败`); + } } export class ImageManager { @@ -40,48 +141,50 @@ export class ImageManager { this.stealStatus = false; } - static getImageCQCode(img: Image): string { - if (!img) return ''; - const file = img.base64 ? seal.base64ToImage(img.base64) : img.file; - return `[CQ:image,file=${file}]`; - } - stealImages(images: Image[]) { const { maxStolenImageNum } = ConfigManager.image; - this.stolenImages = this.stolenImages.concat(images.filter(item => item.isUrl)).slice(-maxStolenImageNum); + this.stolenImages = this.stolenImages.concat(images).slice(-maxStolenImageNum); } - drawLocalImageFile(): string { + drawLocalImage(): Image { const { localImagePathMap } = ConfigManager.image; - const ids = Object.keys(localImagePathMap); - if (ids.length == 0) return ''; - const index = Math.floor(Math.random() * ids.length); - return localImagePathMap[ids[index]]; + const images = Object.keys(localImagePathMap).map(id => { + const image = new Image(); + image.id = id; + image.file = localImagePathMap[id]; + return image; + }); + if (images.length == 0) return null; + const index = Math.floor(Math.random() * images.length); + return images[index]; } - async drawStolenImageFile(): Promise { - if (this.stolenImages.length === 0) return ''; + async drawStolenImage(): Promise { + if (this.stolenImages.length === 0) return null; const index = Math.floor(Math.random() * this.stolenImages.length); - const image = this.stolenImages.splice(index, 1)[0]; - const url = image.file; + const img = this.stolenImages.splice(index, 1)[0]; - if (!await ImageManager.checkImageUrl(url)) { + if (!await img.checkImageUrl()) { await new Promise(resolve => setTimeout(resolve, 500)); - return await this.drawStolenImageFile(); + return await this.drawStolenImage(); } - return url; + return img; } - async drawImageFile(): Promise { + async drawImage(): Promise { const { localImagePathMap } = ConfigManager.image; - const files = Object.values(localImagePathMap); - if (this.stolenImages.length == 0 && files.length == 0) return ''; - - const index = Math.floor(Math.random() * (files.length + this.stolenImages.length)); - return index < files.length ? files[index] : await this.drawStolenImageFile(); + const localImages = Object.keys(localImagePathMap).map(id => { + const image = new Image(); + image.id = id; + image.file = localImagePathMap[id]; + return image; + }); + if (this.stolenImages.length == 0 && localImages.length == 0) return null; + const index = Math.floor(Math.random() * (localImages.length + this.stolenImages.length)); + return index < localImages.length ? localImages[index] : await this.drawStolenImage(); } /** @@ -102,11 +205,9 @@ export class ImageManager { const image = new Image(); image.file = file; - if (image.isUrl) { - const { condition } = ConfigManager.image; - const fmtCondition = parseInt(seal.format(ctx, `{${condition}}`)); - if (fmtCondition === 1) image.content = await ImageManager.imageToText(file); - } + const { condition } = ConfigManager.image; + const fmtCondition = parseInt(seal.format(ctx, `{${condition}}`)); + if (fmtCondition === 1) await image.imageToText(); content += image.content ? `<|img:${image.id}:${image.content}|>` : `<|img:${image.id}|>`; images.push(image); @@ -118,114 +219,6 @@ export class ImageManager { return { content, images }; } - static async checkImageUrl(url: string): Promise { - let isValid = false; - - try { - const response = await fetch(url, { method: 'GET' }); - - if (response.ok) { - const contentType = response.headers.get('Content-Type'); - if (contentType && contentType.startsWith('image')) { - logger.info('URL有效且未过期'); - isValid = true; - } else { - logger.warning(`URL有效但未返回图片 Content-Type: ${contentType}`); - } - } else { - if (response.status === 500) { - logger.warning(`URL不知道有没有效 状态码: ${response.status}`); - isValid = true; - } else { - logger.warning(`URL无效或过期 状态码: ${response.status}`); - } - } - } catch (error) { - logger.error('在checkImageUrl中请求出错:', error); - } - - return isValid; - } - - static async imageToText(imageUrl: string, text = ''): Promise { - const { defaultPrompt, urlToBase64 } = ConfigManager.image; - - let useBase64 = false; - let imageContent = { - "type": "image_url", - "image_url": { "url": imageUrl } - } - if (urlToBase64 == '总是') { - const { base64, format } = await ImageManager.imageUrlToBase64(imageUrl); - if (!base64 || !format) { - logger.warning(`转换为base64失败`); - return ''; - } - - useBase64 = true; - imageContent = { - "type": "image_url", - "image_url": { "url": `data:image/${format};base64,${base64}` } - } - } - - const textContent = { - "type": "text", - "text": text ? text : defaultPrompt - } - - const messages = [{ - role: "user", - content: [imageContent, textContent] - }] - - const { maxChars } = ConfigManager.image; - - const raw_reply = await sendITTRequest(messages, useBase64); - const reply = raw_reply.slice(0, maxChars); - - return reply; - } - - static async imageUrlToBase64(imageUrl: string): Promise<{ base64: string, format: string }> { - const { imageTobase64Url } = ConfigManager.backend; - - try { - const response = await fetch(`${imageTobase64Url}/image-to-base64`, { - method: 'POST', - headers: { - "Content-Type": "application/json", - "Accept": "application/json" - }, - body: JSON.stringify({ url: imageUrl }) - }); - - const text = await response.text(); - if (!response.ok) { - throw new Error(`请求失败! 状态码: ${response.status}\n响应体: ${text}`); - } - if (!text) { - throw new Error("响应体为空"); - } - - try { - const data = JSON.parse(text); - if (data.error) { - throw new Error(`请求失败! 错误信息: ${data.error.message}`); - } - if (!data.base64 || !data.format) { - throw new Error(`响应体中缺少base64或format字段`); - } - return data; - } catch (e) { - throw new Error(`解析响应体时出错:${e}\n响应体:${text}`); - } - } catch (error) { - logger.error("在imageUrlToBase64中请求出错:", error); - return { base64: '', format: '' }; - } - } - static async extractExistingImagesToSave(ctx: seal.MsgContext, ai: AI, s: string): Promise { const images = []; const match = s.match(/[<<][\|│|]img:.+?(?:[\|│|][>>]|[\|│|>>])/g); @@ -234,15 +227,7 @@ export class ImageManager { const id = match[i].match(/[<<][\|│|]img:(.+?)(?:[\|│|][>>]|[\|│|>>])/)[1]; const image = ai.context.findImage(ctx, id); if (image) { - if (image.isUrl) { - const { base64 } = await ImageManager.imageUrlToBase64(image.file); - if (!base64) { - logger.error(`图片${id}转换为base64失败`); - continue; - } - image.file = ''; - image.base64 = base64; - } + if (image.type === 'url') await image.urlToBase64(); images.push(image); } } diff --git a/src/index.ts b/src/index.ts index 7413276..16ef0af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,10 @@ import { AIManager } from "./AI/AI"; -import { ImageManager } from "./AI/image"; import { ToolManager } from "./tool/tool"; import { ConfigManager } from "./config/configManager"; import { buildSystemMessage, getRoleSetting } from "./utils/utils_message"; import { triggerConditionMap } from "./tool/tool_trigger"; import { logger } from "./logger"; -import { fmtDate, transformTextToArray } from "./utils/utils_string"; +import { fmtDate, transformArrayToContent, transformTextToArray } from "./utils/utils_string"; import { checkUpdate } from "./utils/utils_update"; import { get_chart_url } from "./service"; import { TimerManager } from "./timer"; @@ -899,7 +898,7 @@ ${JSON.stringify(tool.info.function.parameters.properties, null, 2)} .then(({ content, images }) => seal.replyToSender(ctx, msg, `返回内容: ${content} 返回图片: -${images.map(img => ImageManager.getImageCQCode(img)).join('\n')}`)); +${images.map(img => img.CQCode).join('\n')}`)); return ret; } catch (e) { const s = `调用函数 (${val3}) 失败:${e.message}`; @@ -1394,22 +1393,22 @@ ${images.map(img => ImageManager.getImageCQCode(img)).join('\n')}`)); const type = cmdArgs.getArgN(2); switch (aliasToCmd(type)) { case 'local': { - const file = ai.imageManager.drawLocalImageFile(); - if (!file) { + const img = ai.imageManager.drawLocalImage(); + if (!img) { seal.replyToSender(ctx, msg, '暂无本地图片'); return ret; } - seal.replyToSender(ctx, msg, `[CQ:image,file=${file}]`); + seal.replyToSender(ctx, msg, img.CQCode); return ret; } case 'steal': { - ai.imageManager.drawStolenImageFile() - .then(file => seal.replyToSender(ctx, msg, file ? `[CQ:image,file=${file}]` : '暂无偷取图片')); + ai.imageManager.drawStolenImage() + .then(img => seal.replyToSender(ctx, msg, img ? img.CQCode : '暂无偷取图片')); return ret; } case 'all': { - ai.imageManager.drawImageFile() - .then(file => seal.replyToSender(ctx, msg, file ? `[CQ:image,file=${file}]` : '暂无图片')); + ai.imageManager.drawImage() + .then(img => seal.replyToSender(ctx, msg, img ? img.CQCode : '暂无图片')); return ret; } default: { @@ -1423,18 +1422,18 @@ ${images.map(img => ImageManager.getImageCQCode(img)).join('\n')}`)); switch (aliasToCmd(op)) { case 'on': { ai.imageManager.stealStatus = true; - seal.replyToSender(ctx, msg, `图片偷取已开启,当前偷取数量:${ai.imageManager.stolenImages.filter(img => img.isUrl).length}`); + seal.replyToSender(ctx, msg, `图片偷取已开启,当前偷取数量:${ai.imageManager.stolenImages.length}`); AIManager.saveAI(id); return ret; } case 'off': { ai.imageManager.stealStatus = false; - seal.replyToSender(ctx, msg, `图片偷取已关闭,当前偷取数量:${ai.imageManager.stolenImages.filter(img => img.isUrl).length}`); + seal.replyToSender(ctx, msg, `图片偷取已关闭,当前偷取数量:${ai.imageManager.stolenImages.length}`); AIManager.saveAI(id); return ret; } default: { - seal.replyToSender(ctx, msg, `图片偷取状态:${ai.imageManager.stealStatus},当前偷取数量:${ai.imageManager.stolenImages.filter(img => img.isUrl).length}`); + seal.replyToSender(ctx, msg, `图片偷取状态:${ai.imageManager.stealStatus},当前偷取数量:${ai.imageManager.stolenImages.length}`); return ret; } } @@ -1454,30 +1453,27 @@ ${images.map(img => ImageManager.getImageCQCode(img)).join('\n')}`)); switch (aliasToCmd(val2)) { case 'random': { - ai.imageManager.drawStolenImageFile() - .then(url => { - if (!url) { + ai.imageManager.drawStolenImage() + .then(img => { + if (!img) { seal.replyToSender(ctx, msg, '图片偷取为空'); return; } - const text = cmdArgs.getRestArgsFrom(3); - ImageManager.imageToText(url, text) - .then(s => seal.replyToSender(ctx, msg, `[CQ:image,file=${url}]\n` + s)); + img.imageToText(cmdArgs.getRestArgsFrom(3)) + .then(() => seal.replyToSender(ctx, msg, img.CQCode + `\n` + img.content)); }); return ret; } default: { - const messageItem0 = transformTextToArray(val2)?.[0]; - const url = messageItem0?.data?.url || messageItem0?.data?.file; - if (messageItem0?.type !== 'image' || !url) { - seal.replyToSender(ctx, msg, '请附带图片'); - return ret; - } - const text = cmdArgs.getRestArgsFrom(3); - ImageManager.imageToText(url, text) - .then(s => seal.replyToSender(ctx, msg, `[CQ:image,file=${url}]\n` + s)); - } + const messageArray = transformTextToArray(val2); + transformArrayToContent(ctx, ai, messageArray).then(({ images }) => { + if (images.length === 0) seal.replyToSender(ctx, msg, '请附带图片'); + const img = images[0]; + img.imageToText(cmdArgs.getRestArgsFrom(3)) + .then(() => seal.replyToSender(ctx, msg, img.CQCode + `\n` + img.content)); + }); return ret; + } } } case 'find': { @@ -1487,7 +1483,7 @@ ${images.map(img => ImageManager.getImageCQCode(img)).join('\n')}`)); return ret; } const img = ai.context.findImage(ctx, id); - seal.replyToSender(ctx, msg, img ? ImageManager.getImageCQCode(img) : '未找到该图片'); + seal.replyToSender(ctx, msg, img ? img.CQCode : '未找到该图片'); return ret; } default: { diff --git a/src/service.ts b/src/service.ts index 0fbaeb6..1ed8e0e 100644 --- a/src/service.ts +++ b/src/service.ts @@ -1,21 +1,17 @@ -import { AI, AIManager } from "./AI/AI"; -import { ToolCall, ToolManager } from "./tool/tool"; +import { AIManager } from "./AI/AI"; +import { ToolCall, ToolInfo } from "./tool/tool"; import { ConfigManager } from "./config/configManager"; -import { handleMessages, parseBody, parseEmbeddingBody } from "./utils/utils_message"; -import { ImageManager } from "./AI/image"; +import { parseBody, parseEmbeddingBody } from "./utils/utils_message"; import { logger } from "./logger"; import { withTimeout } from "./utils/utils"; -import { transformTextToArray } from "./utils/utils_string"; -export async function sendChatRequest(ctx: seal.MsgContext, msg: seal.Message, ai: AI, messages: { +export async function sendChatRequest(messages: { role: string, content: string, tool_calls?: ToolCall[], tool_call_id?: string -}[], tool_choice: string): Promise { +}[], tools: ToolInfo[], tool_choice: string): Promise<{ content: string, tool_calls: ToolCall[] }> { const { url, apiKey, bodyTemplate, timeout } = ConfigManager.request; - const { isTool, usePromptEngineering } = ConfigManager.tool; - const tools = ai.tool.getToolsInfo(msg.messageType); try { const bodyObject = parseBody(bodyTemplate, messages, tools, tool_choice); @@ -33,54 +29,17 @@ export async function sendChatRequest(ctx: seal.MsgContext, msg: seal.Message, a logger.info(`思维链内容:`, message.reasoning_content); } - const reply = message.content || ''; - - logger.info(`响应内容:`, reply, '\nlatency:', Date.now() - time, 'ms', '\nfinish_reason:', finish_reason); - - if (isTool) { - if (usePromptEngineering) { - const match = reply.match(/<[\|│|]?function(?:_call)?>([\s\S]*)<\/function(?:_call)?>/); - if (match) { - const messageArray = transformTextToArray(match[0]); - await ai.context.addMessage(ctx, msg, ai, messageArray, [], "assistant", ''); - - try { - await ToolManager.handlePromptToolCall(ctx, msg, ai, match[1]); - } catch (e) { - logger.error(`在handlePromptToolCall中出错:`, e.message); - return ''; - } - - const messages = await handleMessages(ctx, ai); - return await sendChatRequest(ctx, msg, ai, messages, tool_choice); - } - } else { - if (message.hasOwnProperty('tool_calls') && Array.isArray(message.tool_calls) && message.tool_calls.length > 0) { - logger.info(`触发工具调用`); - - ai.context.addToolCallsMessage(message.tool_calls); - - let tool_choice = 'auto'; - try { - tool_choice = await ToolManager.handleToolCalls(ctx, msg, ai, message.tool_calls); - } catch (e) { - logger.error(`在handleToolCalls中出错:`, e.message); - return ''; - } - - const messages = await handleMessages(ctx, ai); - return await sendChatRequest(ctx, msg, ai, messages, tool_choice); - } - } - } + const content = message.content || ''; + + logger.info(`响应内容:`, content, '\nlatency:', Date.now() - time, 'ms', '\nfinish_reason:', finish_reason); - return reply; + return { content, tool_calls: message.tool_calls || [] }; } else { throw new Error(`服务器响应中没有choices或choices为空\n响应体:${JSON.stringify(data, null, 2)}`); } } catch (e) { logger.error("在sendChatRequest中出错:", e.message); - return ''; + return { content: '', tool_calls: [] }; } } @@ -91,9 +50,9 @@ export async function sendITTRequest(messages: { image_url?: { url: string } text?: string }[] -}[], useBase64: boolean): Promise { +}[]): Promise { const { timeout } = ConfigManager.request; - const { url, apiKey, bodyTemplate, urlToBase64 } = ConfigManager.image; + const { url, apiKey, bodyTemplate } = ConfigManager.image; try { const bodyObject = parseBody(bodyTemplate, messages, null, null); @@ -105,37 +64,16 @@ export async function sendITTRequest(messages: { AIManager.updateUsage(data.model, data.usage); const message = data.choices[0].message; - const reply = message.content || ''; + const content = message.content || ''; - logger.info(`响应内容:`, reply, '\nlatency', Date.now() - time, 'ms'); + logger.info(`响应内容:`, content, '\nlatency', Date.now() - time, 'ms'); - return reply; + return content; } else { throw new Error(`服务器响应中没有choices或choices为空\n响应体:${JSON.stringify(data, null, 2)}`); } } catch (e) { logger.error("在sendITTRequest中请求出错:", e.message); - if (urlToBase64 === '自动' && !useBase64) { - logger.info(`自动尝试使用转换为base64`); - - for (let i = 0; i < messages.length; i++) { - const message = messages[i]; - for (let j = 0; j < message.content.length; j++) { - const content = message.content[j]; - if (content.type === 'image_url') { - const { base64, format } = await ImageManager.imageUrlToBase64(content.image_url.url); - if (!base64 || !format) { - logger.warning(`转换为base64失败`); - return ''; - } - - message.content[j].image_url.url = `data:image/${format};base64,${base64}`; - } - } - } - - return await sendITTRequest(messages, true); - } return ''; } } diff --git a/src/tool/tool.ts b/src/tool/tool.ts index c3a932f..299f30b 100644 --- a/src/tool/tool.ts +++ b/src/tool/tool.ts @@ -272,15 +272,7 @@ export class ToolManager { * @param tool_calls * @returns tool_choice */ - static async handleToolCalls(ctx: seal.MsgContext, msg: seal.Message, ai: AI, tool_calls: { - index: number, - id: string, - type: "function", - function: { - name: string, - arguments: string - } - }[]): Promise { + static async handleToolCalls(ctx: seal.MsgContext, msg: seal.Message, ai: AI, tool_calls: ToolCall[]): Promise { const { maxCallCount } = ConfigManager.tool; if (tool_calls.length !== 0) { diff --git a/src/tool/tool_image.ts b/src/tool/tool_image.ts index 0f68db5..0bd7728 100644 --- a/src/tool/tool_image.ts +++ b/src/tool/tool_image.ts @@ -1,4 +1,4 @@ -import { ImageManager } from "../AI/image"; +import { Image } from "../AI/image"; import { logger } from "../logger"; import { ConfigManager } from "../config/configManager"; import { Tool } from "./tool"; @@ -31,17 +31,10 @@ export function registerImage() { const image = ai.context.findImage(ctx, id); if (!image) return { content: `未找到图片${id}`, images: [] }; const text = content ? `请帮我用简短的语言概括这张图片中出现的:${content}` : ``; - - if (image.isUrl) { - const reply = await ImageManager.imageToText(image.file, text); - if (reply) { - return { content: reply, images: [] }; - } else { - return { content: '图片识别失败', images: [] }; - } - } else { - return { content: '本地图片暂时无法识别', images: [] }; - } + + if (image.type === 'local') return { content: '本地图片暂时无法识别', images: [] }; + await image.imageToText(text); + return { content: image.content || '图片识别失败', images: [] }; } const toolAvatar = new Tool({ @@ -78,29 +71,20 @@ export function registerImage() { if (avatar_type === "private") { const uid = await ai.context.findUserId(ctx, name, true); - if (uid === null) { - return { content: `未找到<${name}>`, images: [] }; - } - + if (uid === null) return { content: `未找到<${name}>`, images: [] }; url = `https://q1.qlogo.cn/g?b=qq&nk=${uid.replace(/^.+:/, '')}&s=640`; } else if (avatar_type === "group") { const gid = await ai.context.findGroupId(ctx, name); - if (gid === null) { - return { content: `未找到<${name}>`, images: [] }; - } - + if (gid === null) return { content: `未找到<${name}>`, images: [] }; url = `https://p.qlogo.cn/gh/${gid.replace(/^.+:/, '')}/${gid.replace(/^.+:/, '')}/640`; } else { return { content: `未知的头像类型<${avatar_type}>`, images: [] }; } - - const reply = await ImageManager.imageToText(url, text); - if (reply) { - return { content: reply, images: [] }; - } else { - return { content: '头像识别失败', images: [] }; - } + const image = new Image(); + image.file = url; + await image.imageToText(text); + return { content: image.content || '头像识别失败', images: [] }; } const toolTTI = new Tool({ diff --git a/src/tool/tool_meme.ts b/src/tool/tool_meme.ts index e2dcee2..a00267c 100644 --- a/src/tool/tool_meme.ts +++ b/src/tool/tool_meme.ts @@ -190,6 +190,7 @@ export function registerMeme() { const img = new Image(); img.id = `${name}_${generateId()}`; img.base64 = base64; + img.format = 'unknown'; img.content = `表情包<|img:${img.id}|> ${textText ? `文字:${textText}` : ''} ${memberText ? `用户:${memberText}` : ''}`; diff --git a/src/tool/tool_render.ts b/src/tool/tool_render.ts index 5cf6570..2c8994f 100644 --- a/src/tool/tool_render.ts +++ b/src/tool/tool_render.ts @@ -98,6 +98,7 @@ export function registerRender() { const img = new Image(); img.id = `${name}_${generateId()}`; img.base64 = base64; + img.format = 'unknown'; img.content = `Markdown 渲染图片<|img:${img.id}|> 主题:${theme}`; @@ -161,6 +162,7 @@ export function registerRender() { const img = new Image(); img.id = `${name}_${generateId()}`; img.base64 = base64; + img.format = 'unknown'; img.content = `HTML 渲染图片<|img:${img.id}|>`; if (save) ai.memory.addMemory(ctx, ai, [], [], kws, [img], img.content); @@ -174,4 +176,9 @@ export function registerRender() { return { content: `渲染图片失败: ${err.message}`, images: [] }; } } -} \ No newline at end of file +} + +// TODO:嵌入图片…… +// 1. 嵌入本地图片 +// 2. 嵌入网络图片,包括聊天记录,用户头像,群头像,直接使用url +// 3. 嵌入base64图片 \ No newline at end of file diff --git a/src/update.ts b/src/update.ts index e94d0e5..81b2726 100644 --- a/src/update.ts +++ b/src/update.ts @@ -6,7 +6,9 @@ export const updateInfo = { - 新增了修改上下文里的名字相关功能 - 活跃时间添加上一条消息时间提示 - 新增向量记忆 -- 新增知识库`, +- 新增知识库 +- 抛弃保存图片,功能合并到记忆 +- 新增渲染md和html功能`, "4.11.2": `- 增加修复json解析错误的功能`, "4.11.1": `- 修复了戳戳、权限检查、权限设置、帮助文本等相关问题`, "4.11.0": `- 新增请求超时相关 diff --git a/src/utils/utils_string.ts b/src/utils/utils_string.ts index 755478f..1b727f5 100644 --- a/src/utils/utils_string.ts +++ b/src/utils/utils_string.ts @@ -1,5 +1,5 @@ import { Context } from "../AI/context"; -import { Image, ImageManager } from "../AI/image"; +import { Image } from "../AI/image"; import { logger } from "../logger"; import { ConfigManager } from "../config/configManager"; import { transformMsgId, transformMsgIdBack } from "./utils"; @@ -126,28 +126,17 @@ export function transformTextToArray(text: string): MessageSegment[] { if (match[2]) { match[2].trim().split(',').forEach(param => { const eqIndex = param.indexOf('='); - if (eqIndex === -1) { - return; - } + if (eqIndex === -1) return; const key = param.slice(0, eqIndex).trim(); const value = param.slice(eqIndex + 1).trim(); - // 这对吗?nc是这样的吗? - if (type === 'image' && key === 'file') { - params['url'] = value; - } - - if (key) { - params[key] = value; - } + if (type === 'image' && key === 'file') params['url'] = value; // 这对吗?nc是这样的吗? + if (key) params[key] = value; }); } - messageArray.push({ - type: type, - data: params - }); + messageArray.push({ type, data: params }); } else { logger.error(`无法解析CQ码:${segment}`); } @@ -155,7 +144,6 @@ export function transformTextToArray(text: string): MessageSegment[] { messageArray.push({ type: 'text', data: { text: segment } }); } } - return messageArray; } @@ -284,7 +272,7 @@ async function transformContentToText(ctx: seal.MsgContext, ai: AI, content: str if (image) { images.push(image); - text += ImageManager.getImageCQCode(image); + text += image.CQCode; } else { logger.warning(`无法找到图片:${id}`); }