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/config/config_backend.ts b/src/config/config_backend.ts index 4bd44ad..30cb262 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/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 8f521c2..299f30b 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(); } /** @@ -270,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 new file mode 100644 index 0000000..2c8994f --- /dev/null +++ b/src/tool/tool_render.ts @@ -0,0 +1,184 @@ +import { logger } from "../logger"; +import { Tool } from "./tool"; +import { ConfigManager } from "../config/configManager"; +import { AIManager } from "../AI/AI"; +import { Image } from "../AI/image"; +import { generateId } from "../utils/utils"; + +interface RenderResponse { + status: string; + imageId?: string; + url?: string; + fileName?: string; + contentType?: string; + base64?: string; + message?: string; +} + +async function postToRenderEndpoint(endpoint: string, bodyData: any): Promise { + try { + const { renderUrl } = ConfigManager.backend; + const res = await fetch(renderUrl + endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(bodyData) + }); + + 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); + } +} + +// 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 toolMd = new Tool({ + type: "function", + function: { + name: "render_markdown", + description: `渲染 Markdown 内容为图片`, + parameters: { + type: "object", + properties: { + content: { + type: "string", + description: "要渲染的 Markdown 内容。支持 LaTeX 数学公式,使用前后 $ 包裹行内公式,前后 $$ 包裹块级公式" + }, + name: { + type: "string", + description: "名称,对内容大致描述" + }, + theme: { + type: "string", + description: "主题样式,其中 gradient 为紫色渐变背景", + enum: ["light", "dark", "gradient"], + }, + save: { + type: "boolean", + description: "是否保存图片" + } + }, + required: ["content", "name", "save"] + } + } + }); + + 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: [] }; + if (!['light', 'dark', 'gradient'].includes(theme)) return { content: `无效的主题: ${theme}。支持: light, dark, gradient`, images: [] }; + + // 切换到当前会话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) { + const base64 = result.base64; + if (!base64) { + logger.error(`生成的base64为空`); + return { content: "生成的base64为空", images: [] }; + } + + const img = new Image(); + img.id = `${name}_${generateId()}`; + img.base64 = base64; + img.format = 'unknown'; + img.content = `Markdown 渲染图片<|img:${img.id}|> +主题:${theme}`; + + if (save) ai.memory.addMemory(ctx, ai, [], [], kws, [img], img.content); + + return { content: `渲染成功,请使用<|img:${img.id}|>发送`, images: [img] }; + } 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 数学公式,使用前后 $ 包裹行内公式,前后 $$ 包裹块级公式。" + }, + name: { + type: "string", + description: "名称,对内容大致描述" + }, + save: { + type: "boolean", + description: "是否保存图片" + } + }, + required: ["content", "name", "save"] + } + } + }); + + 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: [] }; + + // 切换到当前会话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) { + const base64 = result.base64; + if (!base64) { + logger.error(`生成的base64为空`); + return { content: "生成的base64为空", images: [] }; + } + + 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); + + return { content: `渲染成功,请使用<|img:${img.id}|>发送`, images: [img] }; + } else { + throw new Error(result.message || "渲染失败"); + } + } catch (err) { + logger.error(`HTML 渲染失败: ${err.message}`); + return { content: `渲染图片失败: ${err.message}`, images: [] }; + } + } +} + +// 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}`); } 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..6f43c76 --- /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,451 @@ +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'); +} + +// HTML模板 +function generateHTML(content, contentType, theme = 'light', style = 'github') { + 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; + + if (contentType === 'markdown') { + return ` + + + + + + + + + + +
+ ${bodyContent} +
+ + + `; + } else { + // 移除所有外层样式,让传入的 HTML 自行决定外观,但是不传外层样式的时候是不是太怪了 + return ` + + + + + + + + + + + ${bodyContent} + + + `; + } +} + +// 渲染内容为图片 +async function renderToImage(content, options = {}) { + const { + contentType, + 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: 3000, + deviceScaleFactor: 2 + }); + + const html = generateHTML(content, contentType, theme, style); + + await page.setContent(html, { + waitUntil: 'networkidle0', + timeout: 30000 + }); + + await new Promise(r => setTimeout(r, 1500)); + + const imageId = generateImageId(); + + 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; + } + + let base64; + if (!clip || clip.width === 0 || clip.height === 0) { + console.warn('Clipping failed, screenshotting full page as fallback.'); + base64 = await page.screenshot({ + type: 'png', + omitBackground: omitBackground, + fullPage: true, + encoding: 'base64' + }); + } else { + base64 = await page.screenshot({ + type: 'png', + omitBackground: omitBackground, + clip: { + x: clip.x, + y: clip.y, + width: Math.ceil(clip.width), + height: Math.ceil(clip.height) + }, + encoding: 'base64' + }); + } + + return { imageId, base64 }; + } finally { + await browser.close(); + } +} + +// 渲染 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' }); + } + + const result = await renderToImage(markdown, { + contentType: 'markdown', + theme, + width, + quality + }); + + res.json({ + status: 'success', + imageId: result.imageId, + base64: result.base64, + contentType: 'markdown', + theme + }); + } catch (error) { + console.error('Render markdown error:', error); + res.status(500).json({ status: 'error', message: error.message }); + } +}); + +// 渲染 HTML +app.post('/render/html', async (req, res) => { + try { + const { html, width = 1200, quality = 90 } = req.body; + if (!html) { + return res.status(400).json({ status: 'error', message: 'Field "html" is required' }); + } + + const result = await renderToImage(html, { + contentType: 'html', + width, + quality + }); + + res.json({ + status: 'success', + imageId: result.imageId, + base64: result.base64, + contentType: 'html' + }); + } catch (error) { + 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 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); + + res.json({ + status: 'success', + 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 + }); + } +}); + +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