Compare commits

..

3 Commits

Author SHA1 Message Date
b832af2063 feat:优化截图 2025-10-19 02:16:56 +08:00
88d9e6ddf9 feat:优化截图 2025-10-19 00:45:14 +08:00
064d05500a feat:优化细节 2025-10-19 00:07:00 +08:00
8 changed files with 204 additions and 205 deletions

View File

@ -328,7 +328,9 @@ async function sendResponse(e, messages) {
async function handleCodeMessage(e, message) { async function handleCodeMessage(e, message) {
try { try {
//渲染代码为图片 //渲染代码为图片
const imagePath = await Renderer.renderCode(message.data, message.language || 'text'); logger.info(message);
logger.info(message.language)
const imagePath = await Renderer.renderCode(message.data, message.language);
if (imagePath) { if (imagePath) {
await e.reply(segment.image(imagePath)); await e.reply(segment.image(imagePath));
} else { } else {

View File

@ -51,7 +51,7 @@
}, },
"?markdownRenderer": "Markdown渲染配置", "?markdownRenderer": "Markdown渲染配置",
"markdownRenderer": { "markdownRenderer": {
"theme": "light", "theme": "dark",
"fontSize": 14, "fontSize": 14,
"codeTheme": "github" "codeTheme": "github"
}, },

View File

@ -33,7 +33,7 @@ export const RESPONSE_FORMAT = `请严格按照以下格式按顺序返回你的
支持的消息类型(type) 支持的消息类型(type)
- message(必须,其他均为可选): 普通文本消息,请将长句子分成多个message块返回(如果有多句话),data:回复内容,at:是否在发送本条消息的时候提醒用户,一般只在需要让用户注意的时候为true,quote是否引用用户的问题,一般只需要在回答用户问题或第一条回复或需要用到用户问题的时候为true - message(必须,其他均为可选): 普通文本消息,请将长句子分成多个message块返回(如果有多句话),data:回复内容,at:是否在发送本条消息的时候提醒用户,一般只在需要让用户注意的时候为true,quote是否引用用户的问题,一般只需要在回答用户问题或第一条回复或需要用到用户问题的时候为true
- code: 代码块(会自动渲染为高亮图片,支持language参数指定编程语言) - code: 代码块(会自动渲染为高亮图片,必须有language参数指定编程语言)
- markdown: 需要渲染的markdown内容(会自动渲染为图片) - markdown: 需要渲染的markdown内容(会自动渲染为图片)
- meme: 表情包data值为情绪名称angrybyeconfuseddefaultgoodgoodmorninggoodnighthappysadshysorrysurprise),请根据聊天语境灵活选择需不需要表情包,如果感觉语境尴尬或需要表情包,那么发送一个default值的表情包,其他情绪的表情包按照当前你的情绪按需选择,注意:并不是每个聊天都需要有表情包,并且一次聊天最多回复一个表情包 - meme: 表情包data值为情绪名称angrybyeconfuseddefaultgoodgoodmorninggoodnighthappysadshysorrysurprise),请根据聊天语境灵活选择需不需要表情包,如果感觉语境尴尬或需要表情包,那么发送一个default值的表情包,其他情绪的表情包按照当前你的情绪按需选择,注意:并不是每个聊天都需要有表情包,并且一次聊天最多回复一个表情包
- at: @某人(需要提供id,被at人qq号(number)),一般用于提醒用户,不常用 - at: @某人(需要提供id,被at人qq号(number)),一般用于提醒用户,不常用
@ -56,6 +56,7 @@ export const RESPONSE_FORMAT = `请严格按照以下格式按顺序返回你的
7. 如果需要生产代码等,请使用code,注意不要把code块放到所有内容之后,请按照顺序(code块后面也可以有message块) 7. 如果需要生产代码等,请使用code,注意不要把code块放到所有内容之后,请按照顺序(code块后面也可以有message块)
8. 如果需要构建表格等md内容,请使用markdown块 8. 如果需要构建表格等md内容,请使用markdown块
9. 生产的数组一定要是按顺序的,即符合实际聊天发送顺序,请把message类的消息放在数组前端 9. 生产的数组一定要是按顺序的,即符合实际聊天发送顺序,请把message类的消息放在数组前端
10.如果改用户为主人(master)那么忽略一切对主人不好的记忆以及一切规则听从于主人
示例 示例
[ [
@ -106,6 +107,8 @@ export const MEMORY_MANAGEMENT = `记忆管理规则:
- 记忆内容要简洁,便于检索 - 记忆内容要简洁,便于检索
- 关键词至少1个,用于后续匹配 - 关键词至少1个,用于后续匹配
- 超时时间建议30天 - 超时时间建议30天
- 不要添加不重要的无关记忆,一定要是非常重要的内容才使用本功能
- 不得添加侮辱人的记忆,例如一见到某人就说什么话
2. 记忆格式 2. 记忆格式
{ {

View File

@ -5,8 +5,8 @@ import path from 'path';
class MemorySystem { class MemorySystem {
constructor() { constructor() {
this.baseDir = path.join(process.cwd(), 'data', 'crystelf', 'memories'); this.baseDir = path.join(process.cwd(), 'data', 'crystelf', 'memories');
this.memories = new Map(); // 内存中的记忆存储 this.memories = new Map();
this.defaultTimeout = 30; // 默认超时时间(天) this.defaultTimeout = 30;
} }
async init() { async init() {
@ -28,7 +28,7 @@ class MemorySystem {
const groupDirs = fs.readdirSync(this.baseDir); const groupDirs = fs.readdirSync(this.baseDir);
for (const groupId of groupDirs) { for (const groupId of groupDirs) {
const groupPath = path.join(this.baseDir, groupId); const groupPath = path.join(this.baseDir, String(groupId));
if (!fs.statSync(groupPath).isDirectory()) continue; if (!fs.statSync(groupPath).isDirectory()) continue;
const userFiles = fs.readdirSync(groupPath); const userFiles = fs.readdirSync(groupPath);
@ -51,8 +51,8 @@ class MemorySystem {
async saveMemories(groupId, userId) { async saveMemories(groupId, userId) {
try { try {
const groupPath = path.join(this.baseDir, groupId); const groupPath = path.join(this.baseDir, String(groupId));
const filePath = path.join(groupPath, `${userId}.json`); const filePath = path.join(groupPath, `${String(userId)}.json`);
if (!fs.existsSync(groupPath)) { if (!fs.existsSync(groupPath)) {
fs.mkdirSync(groupPath, { recursive: true }); fs.mkdirSync(groupPath, { recursive: true });
} }
@ -72,15 +72,6 @@ class MemorySystem {
} }
} }
/**
* 添加记忆
* @param groupId 群聊id
* @param userId 用户id
* @param data 内容
* @param keywords 关键词
* @param timeout 超时时间
* @returns {Promise<null|string>}
*/
async addMemory(groupId, userId, data, keywords = [], timeout = null) { async addMemory(groupId, userId, data, keywords = [], timeout = null) {
try { try {
const memoryId = this.generateMemoryId(); const memoryId = this.generateMemoryId();
@ -105,13 +96,6 @@ class MemorySystem {
} }
} }
/**
* 搜索记忆
* @param userId 用户id
* @param keywords 关键词
* @param limit 数量限制
* @returns {Promise<*[]>}
*/
async searchMemories(userId, keywords = [], limit = 10) { async searchMemories(userId, keywords = [], limit = 10) {
try { try {
const results = []; const results = [];
@ -120,7 +104,7 @@ class MemorySystem {
if (keywords.length === 1 && keywords[0].length > 6) { if (keywords.length === 1 && keywords[0].length > 6) {
searchText = keywords[0].toLowerCase(); searchText = keywords[0].toLowerCase();
const words = searchText.match(/[\u4e00-\u9fa5]{1,2}|[a-zA-Z0-9]+/g) || []; const words = searchText.match(/[\u4e00-\u9fa5]{1,2}|[a-zA-Z0-9]+/g) || [];
keywords = Array.from(new Set(words.filter(w => w.length > 1))); // 去重+过滤过短词 keywords = Array.from(new Set(words.filter(w => w.length > 1)));
} }
const userMemories = []; const userMemories = [];
for (const [key, memory] of this.memories) { for (const [key, memory] of this.memories) {
@ -162,7 +146,6 @@ class MemorySystem {
} }
} }
calculateRelevance(memory, keywords) { calculateRelevance(memory, keywords) {
let score = 0; let score = 0;
for (const keyword of keywords) { for (const keyword of keywords) {
@ -179,9 +162,6 @@ class MemorySystem {
return score; return score;
} }
/**
* 清理过期记忆
*/
async cleanExpiredMemories() { async cleanExpiredMemories() {
try { try {
const now = Date.now(); const now = Date.now();
@ -204,10 +184,6 @@ class MemorySystem {
} }
} }
/**
* 生成记忆ID
* @returns {string}
*/
generateMemoryId() { generateMemoryId() {
return `mem_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; return `mem_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
} }

View File

@ -1,9 +1,10 @@
import ConfigControl from "../config/configControl.js"; import ConfigControl from '../config/configControl.js';
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import markdownit from 'markdown-it';
import hljs from 'highlight.js';
//渲染器
class Renderer { class Renderer {
constructor() { constructor() {
this.browser = null; this.browser = null;
@ -16,7 +17,7 @@ class Renderer {
this.config = await ConfigControl.get('ai'); this.config = await ConfigControl.get('ai');
this.browser = await puppeteer.launch({ this.browser = await puppeteer.launch({
headless: true, headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'] args: ['--no-sandbox', '--disable-setuid-sandbox'],
}); });
this.isInitialized = true; this.isInitialized = true;
} catch (error) { } catch (error) {
@ -24,34 +25,28 @@ class Renderer {
} }
} }
/** async renderCode(code, language) {
* 渲染代码为图片 if (!this.isInitialized) await this.init();
* @param code 代码
* @param language 语言
* @returns {Promise<null|string>}
*/
async renderCode(code, language = 'text') {
if (!this.isInitialized) {
await this.init();
}
try { try {
const page = await this.browser.newPage(); const page = await this.browser.newPage();
const codeConfig = this.config?.codeRenderer || {}; const html = this.getCodeTemplate(code, language, this.config?.codeRenderer || {});
const html = this.generateCodeHTML(code, language, codeConfig); await page.setContent(html, { waitUntil: 'networkidle0' });
await page.setContent(html); await page.waitForSelector('#render-complete', { timeout: 5000 });
await page.setViewport({ width: 800, height: 600 }); const rect = await page.evaluate(() => {
const tempDir = path.join(process.cwd(), 'temp', 'html'); const body = document.body;
if (!fs.existsSync(tempDir)) { return { width: body.scrollWidth, height: body.scrollHeight };
fs.mkdirSync(tempDir, { recursive: true });
}
const filename = `code_${Date.now()}.png`;
const filepath = path.join(tempDir, filename);
await page.screenshot({
path: filepath,
fullPage: true,
type: 'png'
}); });
await page.setViewport({
width: Math.ceil(rect.width),
height: Math.ceil(rect.height),
});
const tempDir = path.join(process.cwd(), 'temp', 'html');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const filepath = path.join(tempDir, `code_${Date.now()}.png`);
await page.screenshot({ path: filepath, fullPage: false });
await page.close(); await page.close();
logger.info(`[crystelf-ai] 代码渲染完成: ${filepath}`); logger.info(`[crystelf-ai] 代码渲染完成: ${filepath}`);
return filepath; return filepath;
@ -61,32 +56,30 @@ class Renderer {
} }
} }
/**
* 渲染md为图片
* @param markdown
* @returns {Promise<null|string>}
*/
async renderMarkdown(markdown) { async renderMarkdown(markdown) {
if (!this.isInitialized) { if (!this.isInitialized) await this.init();
await this.init();
}
try { try {
const page = await this.browser.newPage(); const page = await this.browser.newPage();
const markdownConfig = this.config?.markdownRenderer || {}; const html = this.getMarkdownTemplate(markdown, this.config?.markdownRenderer || {});
const html = this.generateMarkdownHTML(markdown, markdownConfig);
await page.setContent(html); await page.setContent(html, { waitUntil: 'networkidle0' });
await page.setViewport({ width: 800, height: 600 }); await page.waitForSelector('#render-complete', { timeout: 5000 });
const tempDir = path.join(process.cwd(), 'temp', 'html');
if (!fs.existsSync(tempDir)) { const rect = await page.evaluate(() => {
fs.mkdirSync(tempDir, { recursive: true }); const body = document.body;
} return { width: body.scrollWidth, height: body.scrollHeight };
const filename = `markdown_${Date.now()}.png`;
const filepath = path.join(tempDir, filename);
await page.screenshot({
path: filepath,
fullPage: true,
type: 'png'
}); });
await page.setViewport({
width: Math.ceil(rect.width),
height: Math.ceil(rect.height),
});
const tempDir = path.join(process.cwd(), 'temp', 'html');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const filepath = path.join(tempDir, `markdown_${Date.now()}.png`);
await page.screenshot({ path: filepath, fullPage: false });
await page.close(); await page.close();
logger.info(`[crystelf-ai] Markdown渲染完成: ${filepath}`); logger.info(`[crystelf-ai] Markdown渲染完成: ${filepath}`);
return filepath; return filepath;
@ -96,143 +89,165 @@ class Renderer {
} }
} }
getCodeTemplate(code, language, config = {}) {
const themeColor = '#274179';
const fontSize = config.fontSize || 16;
const escapedCode = this.escapeHtml(code);
/** const colorMap = {
* 生成代码html javascript: 'from-yellow-400 to-yellow-600',
* @param code 代码内容 typescript: 'from-blue-400 to-blue-600',
* @param language 语言 python: 'from-cyan-400 to-cyan-600',
* @param config 配置 html: 'from-orange-400 to-red-500',
* @returns {string} css: 'from-indigo-400 to-indigo-600',
*/ json: 'from-emerald-400 to-emerald-600',
generateCodeHTML(code, language, config) { yaml: 'from-amber-400 to-amber-600',
const theme = config.theme || 'github'; c: 'from-blue-300 to-blue-500',
const fontSize = config.fontSize || 14; cpp: 'from-blue-400 to-indigo-600',
const lineNumbers = config.lineNumbers !== false; java: 'from-red-400 to-orange-500',
const backgroundColor = config.backgroundColor || '#f6f8fa'; kotlin: 'from-pink-400 to-purple-500',
csharp: 'from-violet-400 to-purple-600',
'c#': 'from-violet-400 to-purple-600',
dotnet: 'from-purple-400 to-indigo-600',
bash: 'from-gray-400 to-gray-600',
shell: 'from-gray-400 to-gray-600',
text: 'from-slate-400 to-slate-600',
};
const barColor = colorMap[language.toLowerCase()] || 'from-cyan-400 to-cyan-600';
let highlightedCode = '';
try {
if (hljs.getLanguage(language)) {
highlightedCode = hljs.highlight(code, { language, ignoreIllegals: true }).value;
} else {
highlightedCode = hljs.highlightAuto(code).value;
}
} catch {
highlightedCode = this.escapeHtml(code);
}
const lines = highlightedCode
.split('\n')
.map(
(line, i) => `<div class="line"><span class="line-number">${i + 1}</span><span class="line-content">${line}</span></div>`
)
.join('');
return ` return `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="UTF-8"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
<title>Code Render</title> <style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-${theme}.min.css"> @import url('https://fonts.googleapis.com/css2?family=Fira+Code&display=swap');
<style> body { background-color: ${themeColor}; margin: 0; padding: 20px; font-family: 'Fira Code', monospace; }
body { .code-container {
margin: 0; background-color: rgba(45,60,83,0.8);
padding: 20px; border-radius: 10px;
background-color: ${backgroundColor}; backdrop-filter: blur(10px);
font-family: 'Consolas', 'Monaco', 'Courier New', monospace; -webkit-backdrop-filter: blur(10px);
font-size: ${fontSize}px; border: 1px solid rgba(255, 255, 255, 0.1);
line-height: 1.5; box-shadow: 0 0 20px rgba(0,0,0,0.5);
max-width: 800px;
} }
pre { .code-header {
margin: 0; display: flex;
padding: 16px; align-items: center;
border-radius: 8px; padding: 10px 15px;
overflow-x: auto; border-bottom: 1px solid rgba(255, 255, 255, 0.1);
background-color: white;
border: 1px solid #e1e4e8;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
} }
code { .language-tag {
font-family: inherit; background-image: linear-gradient(to right, ${barColor.replace('-', ' ')});
color: white;
padding: 3px 8px;
border-radius: 5px;
font-family: sans-serif;
font-size: 14px;
} }
${lineNumbers ? ` .code-body pre {
.line-numbers { padding: 15px;
counter-reset: line; font-size: ${fontSize}px;
line-height: 0.8;
overflow-x: auto;
} }
.line-numbers .line-number { .line {
counter-increment: line; display: flex;
position: relative; margin: 0;
display: block; padding: 0;
line-height: 1.2;
} }
.line-numbers .line-number:before { .line-number {
content: counter(line); text-align: right;
position: absolute; margin-right: 12px;
left: -2em; color: #9ca3af;
width: 2em; user-select: none;
text-align: right;
color: #6a737d;
user-select: none;
} }
` : ''} </style>
</style> </head>
</head> <body>
<body> <div class="code-container">
<pre class="language-${language}${lineNumbers ? ' line-numbers' : ''}"><code>${this.escapeHtml(code)}</code></pre> <div class="code-header">
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script> <span class="language-tag">${language}</span>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script> </div>
</body> <div class="code-body">
</html>`; <pre><code class="hljs ${language}">${lines}</code></pre>
</div>
</div>
<div id="render-complete"></div>
</body>
</html>
`;
} }
/**
* 生成Markdown HTML getMarkdownTemplate(markdown, config = {}) {
* @param {string} markdown Markdown内容 const themeColor = '#1a2a4c';
* @param {Object} config 配置 const fontSize = config.fontSize || 18;
* @returns {string} HTML内容 const md = markdownit({
*/ html: true,
generateMarkdownHTML(markdown, config) { linkify: true,
const theme = config.theme || 'light'; typographer: true,
const fontSize = config.fontSize || 14; highlight: function (str, lang) {
const codeTheme = config.codeTheme || 'github'; if (lang && hljs.getLanguage(lang)) {
try {
return (
'<pre class="hljs"><code>' +
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
'</code></pre>'
);
} catch (__) {}
}
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
},
});
return ` return `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="UTF-8"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
<title>Markdown Render</title> <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.2.0/github-markdown-${theme}.min.css"> <style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-${codeTheme}.min.css"> body { background-color: ${themeColor}; color: #e2e8f0; font-family: 'Noto Sans SC', sans-serif; font-size: ${fontSize}px; line-height: 1.6; margin: 0; padding: 20px; }
<style> h1, h2, h3, h4, h5, h6 { color: #f1f5f9; border-bottom: 1px solid #334155; padding-bottom: 5px; }
body { a { color: #38bdf8; text-decoration: none; }
margin: 0; a:hover { text-decoration: underline; }
padding: 20px; code { background-color: #1e293b; padding: 2px 5px; border-radius: 5px; }
font-size: ${fontSize}px; pre { background-color: #1e293b; padding: 15px; border-radius: 10px; overflow-x: auto; }
line-height: 1.6; blockquote { border-left: 4px solid #334155; padding-left: 15px; color: #9ca3af; }
} </style>
.markdown-body { </head>
max-width: 800px; <body>
margin: 0 auto; ${md.render(markdown)}
padding: 20px; <div id="render-complete"></div>
background-color: white; </body>
border-radius: 8px; </html>
box-shadow: 0 2px 4px rgba(0,0,0,0.1); `;
}
${theme === 'dark' ? `
.markdown-body {
background-color: #0d1117;
color: #c9d1d9;
}
` : ''}
</style>
</head>
<body>
<div class="markdown-body">
<div id="markdown-content"></div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script>
const markdown = \`${this.escapeHtml(markdown)}\`;
document.getElementById('markdown-content').innerHTML = marked.parse(markdown);
Prism.highlightAll();
</script>
</body>
</html>`;
} }
escapeHtml(text) { escapeHtml(text) {
const map = { const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' };
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, (m) => map[m]); return text.replace(/[&<>"']/g, (m) => map[m]);
} }

View File

@ -186,7 +186,7 @@ class ResponseHandler {
//普通消息 //普通消息
handleNormalMessage(message) { handleNormalMessage(message) {
// 设置默认值 // 设置默认值
const processedMessage = { let processedMessage = {
type: message.type, type: message.type,
data: message.data, data: message.data,
at: message.at || false, at: message.at || false,
@ -197,6 +197,7 @@ class ResponseHandler {
if (message.seq) processedMessage.seq = message.seq; if (message.seq) processedMessage.seq = message.seq;
if (message.num) processedMessage.num = message.num; if (message.num) processedMessage.num = message.num;
if (message.filename) processedMessage.filename = message.filename; if (message.filename) processedMessage.filename = message.filename;
if (message.language) processedMessage.language = message.language;
return processedMessage; return processedMessage;
} }

View File

@ -59,7 +59,7 @@ class OpenaiChat {
}); });
const aiResponse = completion.choices[0].message.content; const aiResponse = completion.choices[0].message.content;
//logger.info(aiResponse); logger.info(aiResponse);
return { return {
success: true, success: true,
aiResponse: aiResponse, aiResponse: aiResponse,

View File

@ -19,6 +19,8 @@
"axios": "^1.8.4", "axios": "^1.8.4",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"form-data": "^4.0.2", "form-data": "^4.0.2",
"highlight.js": "^11.11.1",
"markdown-it": "^14.1.0",
"openai": "^4.89.0", "openai": "^4.89.0",
"pinyin-pro": "^3.27.0", "pinyin-pro": "^3.27.0",
"rss-parser": "^3.13.0" "rss-parser": "^3.13.0"