diff --git a/src/core/apis/msg.ts b/src/core/apis/msg.ts index a8f8c294..cc59dc39 100644 --- a/src/core/apis/msg.ts +++ b/src/core/apis/msg.ts @@ -166,6 +166,18 @@ export class NTQQMsgApi { pageLimit: 20000, }); } + async queryFirstMsgBySenderTime(peer: Peer, SendersUid: string[], filterMsgFromTime: string, filterMsgToTime: string) { + return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', { + chatInfo: peer, + filterMsgType: [], + filterSendersUid: SendersUid, + filterMsgToTime: filterMsgToTime, + filterMsgFromTime: filterMsgFromTime, + isReverseOrder: true, + isIncludeCurrent: true, + pageLimit: 20000, + }); + } async queryFirstMsgByTime(peer: Peer, filterMsgFromTime: string, filterMsgToTime: string) { return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', { chatInfo: peer, diff --git a/src/plugin/drawTime.ts b/src/plugin/drawTime.ts new file mode 100644 index 00000000..c25e6ea8 --- /dev/null +++ b/src/plugin/drawTime.ts @@ -0,0 +1,317 @@ +import { createCanvas, loadImage } from "@napi-rs/canvas"; + +/** + * 绘制时间模式匹配的可视化图表 + * @param data 需要绘制的数据和配置 + * @returns Base64编码的图片 + */ +export async function drawTimePattern(data: { + targetUser: string, + matchedUsers: Array<{ + username: string, + similarity: number, + pattern: Map + }>, + targetPattern: Map, + timeRange: string +}) { + // 计算需要的画布高度,根据匹配用户数量可能需要更多空间 + const legendRowHeight = 30; // 每行图例的高度,增加一点空间 + const legendRows = Math.ceil(data.matchedUsers.length / 2) + 1; // 目标用户一行,其他匹配用户每两个一行 + + // 画布基础配置 + const padding = 50; + const titleHeight = 80; + const hourChartHeight = 250; + const weekdayChartHeight = 180; + const legendTitleHeight = 40; + + // 计算图例总高度,确保足够空间 + const legendHeight = legendRows * legendRowHeight + legendTitleHeight; + + // 计算所需的总高度 + const requiredHeight = titleHeight + hourChartHeight + 60 + weekdayChartHeight + 40 + legendHeight + padding; + + // 设置画布尺寸,确保足够显示所有内容 + const width = 1000; + const height = requiredHeight + padding; // 确保底部有足够的padding + + // 创建画布 + const canvas = createCanvas(width, height); + const ctx = canvas.getContext('2d'); + + // 加载背景图 + const backgroundImage = await loadImage('C:\\fonts\\post.jpg'); + const pattern = ctx.createPattern(backgroundImage, 'repeat'); + ctx.fillStyle = pattern; + ctx.fillRect(0, 0, width, height); + + // 应用模糊效果 + ctx.filter = 'blur(5px)'; + ctx.drawImage(canvas, 0, 0); + ctx.filter = 'none'; + + // 绘制半透明白色背景卡片 + ctx.fillStyle = 'rgba(255, 255, 255, 0.85)'; + const radius = 20; + const cardWidth = width - padding * 2; + const cardHeight = height - padding * 2; + const cardX = padding; + const cardY = padding; + + // 绘制圆角矩形 + ctx.beginPath(); + ctx.moveTo(cardX + radius, cardY); + ctx.lineTo(cardX + cardWidth - radius, cardY); + ctx.quadraticCurveTo(cardX + cardWidth, cardY, cardX + cardWidth, cardY + radius); + ctx.lineTo(cardX + cardWidth, cardY + cardHeight - radius); + ctx.quadraticCurveTo(cardX + cardWidth, cardY + cardHeight, cardX + cardWidth - radius, cardY + cardHeight); + ctx.lineTo(cardX + radius, cardY + cardHeight); + ctx.quadraticCurveTo(cardX, cardY + cardHeight, cardX, cardY + cardHeight - radius); + ctx.lineTo(cardX, cardY + radius); + ctx.quadraticCurveTo(cardX, cardY, cardX + radius, cardY); + ctx.closePath(); + ctx.fill(); + + // 设置标题 + ctx.fillStyle = '#333'; + ctx.font = 'bold 24px "Aa偷吃可爱长大的"'; + ctx.textAlign = 'center'; + ctx.fillText(`${data.targetUser} ${data.timeRange}聊天时间匹配分析`, width / 2, cardY + 35); + + // 绘制小时分布图表 + const hourChartTop = cardY + titleHeight; + const hourChartBottom = hourChartTop + hourChartHeight; + const hourChartLeft = cardX + 40; + const hourChartRight = cardX + cardWidth - 40; + const hourChartWidth = hourChartRight - hourChartLeft; + + // 绘制小时图表标题 + ctx.font = 'bold 18px "Aa偷吃可爱长大的"'; + ctx.fillText('每日小时活跃度分布', width / 2, hourChartTop - 10); + + // 绘制小时图表横坐标 + ctx.fillStyle = '#666'; + ctx.font = '14px "JetBrains Mono"'; + ctx.textAlign = 'center'; + for (let hour = 0; hour < 24; hour += 2) { + const x = hourChartLeft + (hour / 24) * hourChartWidth; + ctx.fillText(`${hour}`, x, hourChartBottom + 20); + } + ctx.textAlign = 'left'; + ctx.font = '14px "Aa偷吃可爱长大的"'; + ctx.fillText('时间(小时)', hourChartLeft, hourChartBottom + 40); + ctx.font = '14px "JetBrains Mono"'; + + // 绘制小时图表网格线 + ctx.strokeStyle = '#ddd'; + ctx.lineWidth = 0.5; + for (let hour = 0; hour < 24; hour += 2) { + const x = hourChartLeft + (hour / 24) * hourChartWidth; + ctx.beginPath(); + ctx.moveTo(x, hourChartTop); + ctx.lineTo(x, hourChartBottom); + ctx.stroke(); + } + + // 确定最大活跃度值,用于缩放 + let maxHourValue = 0; + for (let hour = 0; hour < 24; hour++) { + const targetValue = data.targetPattern.get(`hour_${hour}`) || 0; + if (targetValue > maxHourValue) maxHourValue = targetValue; + + for (const match of data.matchedUsers) { + const matchValue = match.pattern.get(`hour_${hour}`) || 0; + if (matchValue > maxHourValue) maxHourValue = matchValue; + } + } + // 为了美观,确保最大值不会让图表太扁 + maxHourValue = Math.max(maxHourValue, 0.15); + + // 绘制目标用户小时分布曲线 + ctx.strokeStyle = '#e74c3c'; + ctx.lineWidth = 3; + ctx.beginPath(); + for (let hour = 0; hour < 24; hour++) { + const x = hourChartLeft + (hour / 24) * hourChartWidth; + const value = data.targetPattern.get(`hour_${hour}`) || 0; + const y = hourChartBottom - (value / maxHourValue) * (hourChartHeight - 30); + if (hour === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + + // 绘制匹配用户小时分布曲线 + const colors = ['#3498db', '#2ecc71', '#9b59b6', '#f1c40f', '#1abc9c']; + data.matchedUsers.forEach((match, index) => { + const colorIndex = index % colors.length; + ctx.strokeStyle = colors[colorIndex]!; + ctx.lineWidth = 2; + ctx.beginPath(); + for (let hour = 0; hour < 24; hour++) { + const x = hourChartLeft + (hour / 24) * hourChartWidth; + const value = match.pattern.get(`hour_${hour}`) || 0; + const y = hourChartBottom - (value / maxHourValue) * (hourChartHeight - 30); + if (hour === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + }); + + // 绘制星期分布图表 + const weekChartTop = hourChartBottom + 60; + const weekChartBottom = weekChartTop + weekdayChartHeight; + const weekChartLeft = hourChartLeft; + const weekChartRight = hourChartRight; + const weekChartWidth = weekChartRight - weekChartLeft; + + // 绘制星期图表标题 + ctx.fillStyle = '#333'; + ctx.font = 'bold 18px "Aa偷吃可爱长大的"'; + ctx.textAlign = 'center'; + ctx.fillText('星期活跃度分布', width / 2, weekChartTop - 10); + + // 绘制星期图表横坐标 + ctx.fillStyle = '#666'; + ctx.font = '14px "Aa偷吃可爱长大的"'; + const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + for (let day = 0; day < 7; day++) { + const x = weekChartLeft + (day / 7) * weekChartWidth + (weekChartWidth / 14); + ctx.fillText(weekdays[day]!, x, weekChartBottom + 20); + } + + // 绘制星期图表网格线 + ctx.strokeStyle = '#ddd'; + ctx.lineWidth = 0.5; + for (let day = 0; day <= 7; day++) { + const x = weekChartLeft + (day / 7) * weekChartWidth; + ctx.beginPath(); + ctx.moveTo(x, weekChartTop); + ctx.lineTo(x, weekChartBottom); + ctx.stroke(); + } + + // 确定最大活跃度值,用于缩放 + let maxDayValue = 0; + for (let day = 0; day < 7; day++) { + const targetValue = data.targetPattern.get(`day_${day}`) || 0; + if (targetValue > maxDayValue) maxDayValue = targetValue; + + for (const match of data.matchedUsers) { + const matchValue = match.pattern.get(`day_${day}`) || 0; + if (matchValue > maxDayValue) maxDayValue = matchValue; + } + } + // 为了美观,确保最大值不会让图表太扁 + maxDayValue = Math.max(maxDayValue, 0.3); + + // 改进柱状图绘制逻辑,避免重叠 + const totalUsers = data.matchedUsers.length + 1; // 包括目标用户 + const dayWidth = weekChartWidth / 7; // 每天的总宽度 + + // 计算单个柱状图宽度,确保有足够间距 + const barWidth = dayWidth * 0.7 / totalUsers; // 每个柱子的宽度 + const groupPadding = dayWidth * 0.15; // 不同天之间的组间距 + const barPadding = dayWidth * 0.15 / (totalUsers + 1); // 同一天内柱子之间的间距 + + // 绘制所有用户的星期分布(包括目标用户和匹配用户) + const allUsers = [ + { username: data.targetUser, pattern: data.targetPattern, color: '#e74c3c', isTarget: true } + ]; + + data.matchedUsers.forEach((match, idx) => { + allUsers.push({ + username: match.username, + pattern: match.pattern, + color: colors[idx % colors.length] || '#3498db', + isTarget: false + }); + }); + + // 统一绘制所有用户的柱状图 + allUsers.forEach((user, userIndex) => { + ctx.fillStyle = user.color; + + for (let day = 0; day < 7; day++) { + const value = user.pattern.get(`day_${day}`) || 0; + const barHeight = (value / maxDayValue) * (weekdayChartHeight - 30); + + // 计算柱子的位置,确保均匀分布 + const startX = weekChartLeft + day * dayWidth + groupPadding / 2; + const x = startX + barPadding * (userIndex + 1) + barWidth * userIndex; + const y = weekChartBottom - barHeight; + + // 绘制柱子 + ctx.fillRect(x, y, barWidth, barHeight); + } + }); + + + // 绘制图例 + let legendTop = weekChartBottom + 50; // 增加与上方图表的间距 + ctx.textAlign = 'left'; + ctx.font = '14px "Aa偷吃可爱长大的"'; + + // 绘制图例标题 + ctx.fillStyle = '#333'; + ctx.font = 'bold 18px "Aa偷吃可爱长大的"'; + ctx.textAlign = 'center'; + ctx.fillText('图例说明', width / 2, legendTop); + ctx.font = '14px "Aa偷吃可爱长大的"'; + ctx.textAlign = 'left'; + + // 计算图例开始位置和每列宽度 + const legendStartX = hourChartLeft; + const legendColumnWidth = Math.min(450, (cardWidth - 80) / 2); // 确保在宽度有限时也能正常显示 + const legendsPerRow = 2; // 每行最多2个图例 + + // 目标用户图例 - 单独一行 + legendTop += 25; // 图例标题与第一个图例的间距 + ctx.fillStyle = '#e74c3c'; + ctx.fillRect(legendStartX, legendTop, 20, 10); + ctx.fillStyle = '#333'; + ctx.fillText(data.targetUser + " (目标用户)", legendStartX + 30, legendTop + 10); + + // 匹配用户图例 - 每行最多2个用户 + legendTop += legendRowHeight; // 进入下一行 + + data.matchedUsers.forEach((match, index) => { + const colorIndex = index % colors.length; + const row = Math.floor(index / legendsPerRow); + const col = index % legendsPerRow; + + const x = legendStartX + col * legendColumnWidth; + const y = legendTop + row * legendRowHeight; + + // 确保没有超出画布范围 + if (y + 20 <= cardY + cardHeight - padding / 2) { + ctx.fillStyle = colors[colorIndex]!; + ctx.fillRect(x, y, 20, 10); + ctx.fillStyle = '#333'; + const similarity = (match.similarity * 100).toFixed(1); + + // 测量文本长度,确保不超出列宽 + const text = `${match.username} (${similarity}% 匹配)`; + const metrics = ctx.measureText(text); + if (metrics.width > legendColumnWidth - 40) { + // 如果文本过长,缩短显示 + const shortUsername = match.username.length > 10 ? + match.username.substring(0, 10) + "..." : + match.username; + ctx.fillText(`${shortUsername} (${similarity}% 匹配)`, x + 30, y + 10); + } else { + ctx.fillText(text, x + 30, y + 10); + } + } + }); + + // 保存图像 + const buffer = canvas.toBuffer('image/png'); + return "base64://" + buffer.toString('base64'); +} \ No newline at end of file diff --git a/src/plugin/index.ts b/src/plugin/index.ts index a9a68f03..4e3b172c 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -1,5 +1,5 @@ import { NapCatOneBot11Adapter, OB11Message, OB11MessageData, OB11MessageDataType, OB11MessageNode } from '@/onebot'; -import { ChatType, NapCatCore, NTMsgAtType } from '@/core'; +import { ChatType, NapCatCore, NTMsgAtType, RawMessage } from '@/core'; import { ActionMap } from '@/onebot/action'; import { OB11PluginAdapter } from '@/onebot/network/plugin'; import { MsgData } from '@/core/packet/client/nativeClient'; @@ -10,11 +10,89 @@ import { Jieba } from '@node-rs/jieba'; import { dict } from '@node-rs/jieba/dict.js'; import { generateWordCloud } from './wordcloud'; import { drawJsonContent } from '@/shell/drawJson'; +import { drawWordNetwork } from './network'; +import { drawTimePattern } from './drawTime'; const jieba = Jieba.withDict(dict); function timestampToDateText(timestamp: string): string { const date = new Date(+(timestamp + '000')); return date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }); } +// 定义计算时间模式的函数 (如果不存在) +function calculateTimePattern(messages: any[]): Map { + // 统计每个时间段的消息数量 + const hourCount = new Map(); // 0-23 小时 + const weekdayCount = new Map(); // 0-6 对应周日到周六 + + // 处理所有消息 + for (const msg of messages) { + if (!msg.msgTime) continue; + + // 将消息时间转换为日期对象 + const timestamp = parseInt(msg.msgTime) * 1000; + const date = new Date(timestamp); + + // 获取小时 (0-23) + const hour = date.getHours(); + hourCount.set(hour, (hourCount.get(hour) || 0) + 1); + + // 获取星期几 (0-6, 0代表周日) + const weekday = date.getDay(); + weekdayCount.set(weekday, (weekdayCount.get(weekday) || 0) + 1); + } + + // 规范化时间模式 + const pattern = new Map(); + + // 处理小时分布 + const totalHours = Array.from(hourCount.values()).reduce((a, b) => a + b, 0) || 1; + for (let hour = 0; hour < 24; hour++) { + const count = hourCount.get(hour) || 0; + pattern.set(`hour_${hour}`, count / totalHours); + } + + // 处理星期分布 + const totalWeekdays = Array.from(weekdayCount.values()).reduce((a, b) => a + b, 0) || 1; + for (let day = 0; day < 7; day++) { + const count = weekdayCount.get(day) || 0; + pattern.set(`day_${day}`, count / totalWeekdays); + } + + return pattern; +} + +// 定义计算相似度的函数 (如果不存在) +function calculateSimilarity(pattern1: Map, pattern2: Map): number { + let dotProduct = 0; + let norm1 = 0; + let norm2 = 0; + + // 计算小时分布的余弦相似度 + for (let hour = 0; hour < 24; hour++) { + const key = `hour_${hour}`; + const val1 = pattern1.get(key) || 0; + const val2 = pattern2.get(key) || 0; + + dotProduct += val1 * val2; + norm1 += val1 * val1; + norm2 += val2 * val2; + } + + // 添加星期分布的余弦相似度权重 + for (let day = 0; day < 7; day++) { + const key = `day_${day}`; + const val1 = pattern1.get(key) || 0; + const val2 = pattern2.get(key) || 0; + + dotProduct += val1 * val2 * 0.5; // 星期分布权重为小时分布的一半 + norm1 += val1 * val1 * 0.5; + norm2 += val2 * val2 * 0.5; + } + + // 计算余弦相似度 + const magnitude = Math.sqrt(norm1) * Math.sqrt(norm2); + return magnitude > 0 ? dotProduct / magnitude : 0; +} +let ai_character = ''; export const plugin_onmessage = async (adapter: string, _core: NapCatCore, _obCtx: NapCatOneBot11Adapter, message: OB11Message, action: ActionMap, instance: OB11PluginAdapter) => { if (typeof message.message === 'string' || !message.raw) return; if (message.message.find(e => e.type == 'text' && e.data.text == '#千千的菜单')) { @@ -29,7 +107,26 @@ export const plugin_onmessage = async (adapter: string, _core: NapCatCore, _obCt '#Ta经常和谁一起聊天 <@reply> 返回这个人聊天的对象\n' + '#群友今日最爱表情包 <@reply> 返回这今天表情包\n' + '#群友本周最爱表情包 <@reply> 返回这本周表情包\n' + - '#Ta最爱的表情包 <@reply> 返回这个人最爱的表情包' + '#群友本月最爱表情包 <@reply> 返回这个人最爱的表情包\n' + + '#Ta最爱的表情包 <@reply> 返回这个人最爱的表情包\n' + + '#Ta今天最爱的表情包 <@reply> 返回这个人今天最爱的表情包\n' + + '#Ta本周最爱的表情包 <@reply> 返回这个人本周最爱的表情包\n' + + '#Ta本月最爱的表情包 <@reply> 返回这个人本月最爱的表情包\n' + + '#今日词分析 返回今日词分析\n' + + '#本周词分析 返回本周词分析\n' + + '#本月词分析 返回本月词分析\n' + + '#Ta的今日词分析 <@reply> 返回这个人说过的关键词\n' + + '#Ta的本周词分析 <@reply> 返回这个人说过的关键词\n' + + '#Ta的本月词分析 <@reply> 返回这个人说过的关键词\n' + + '#寻找同时间水群群友 返回同时间水群群友\n' + + '#寻找今日同时间水群群友 返回同时间水群群友\n' + + '#寻找本周同时间水群群友 返回同时间水群群友\n' + + '#寻找本月同时间水群群友 返回同时间水群群友\n' + + '#文本转图片 将文本转换为图片\n' + + '#Ai语音文本 返回Ai语音文本\n' + + '#Ai语音角色列表 返回Ai语音角色\n' + + '#Ai语音设置角色 设置Ai语音角色\n' + + `#关于千千 返回千千的介绍`; await action.get('send_group_msg')?.handle({ group_id: String(message.group_id), message: [{ @@ -241,7 +338,7 @@ export const plugin_onmessage = async (adapter: string, _core: NapCatCore, _obCt let text_msg_list = msgs.map(e => e.elements.filter(e => e.textElement)).flat().map(e => e.textElement!.content); let cutMap = new Map(); for (const text_msg_list_item of text_msg_list) { - let msg = jieba.cut(text_msg_list_item, false); + let msg = jieba.cut(text_msg_list_item, true); for (const msg_item of msg) { if (msg_item.length > 1) { cutMap.set(msg_item, (cutMap.get(msg_item) ?? 0) + 1); @@ -495,12 +592,14 @@ export const plugin_onmessage = async (adapter: string, _core: NapCatCore, _obCt }] }, adapter, instance.config); } - else if (message.message.find(e => e.type == 'text' && (e.data.text.startsWith('#群友今日最爱表情包') || e.data.text.startsWith('#群友本周最爱表情包')))) { + else if (message.message.find(e => e.type == 'text' && (e.data.text.startsWith('#群友今日最爱表情包') || e.data.text.startsWith('#群友本周最爱表情包') || e.data.text.startsWith('#群友本月最爱表情包')))) { let time = 0; if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#群友本周最爱表情包'))) { time = 7 * 24 * 60 * 60; // 一周的秒数 } else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#群友今日最爱表情包'))) { time = 24 * 60 * 60; // 一天的秒数 + } else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#群友本月最爱表情包'))) { + time = 30 * 24 * 60 * 60; // 一月的秒数 } let timebefore = (Math.floor(Date.now() / 1000) - time).toString(); let timeafter = Math.floor(Date.now() / 1000).toString(); @@ -777,4 +876,923 @@ export const plugin_onmessage = async (adapter: string, _core: NapCatCore, _obCt }, adapter, instance.config); } } + else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#关于千千'))) { + let msg = `千千是完全基于NapCat集成开发的测试Bot,开源在NapCat Branch上`; + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.text, + data: { + text: msg + } + }] + }, adapter, instance.config); + } + else if (message.message.find(e => e.type == 'text' && (e.data.text.startsWith('#今日词分析') || e.data.text.startsWith('#本周词分析') || e.data.text.startsWith('#本月词分析')))) { + let time = 0; + if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#本周词分析'))) { + time = 7 * 24 * 60 * 60; // 一周的秒数 + } else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#今日词分析'))) { + time = 24 * 60 * 60; // 一天的秒数 + } else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#本月词分析'))) { + time = 30 * 24 * 60 * 60; // 一月的秒数 + } + let timebefore = (Math.floor(Date.now() / 1000) - time).toString(); + let timeafter = Math.floor(Date.now() / 1000).toString(); + let peer = { peerUid: message.group_id?.toString() ?? "", chatType: ChatType.KCHATTYPEGROUP }; + let msgs = (await _core.apis.MsgApi.queryFirstMsgByTime(peer, timebefore, timeafter)).msgList; + + // 词频统计 + let cutMap = new Map(); + // 建立词共现关系映射 + let cooccurrenceMap = new Map>(); + + // 遍历所有消息 + for (const msg of msgs) { + let msg_list = msg.elements.filter(e => e.textElement).map(e => e.textElement!.content); + + for (const msg_list_item of msg_list) { + // 对每条消息进行分词 + let words = jieba.cut(msg_list_item, true) + .filter(word => word.length > 1); // 过滤掉单字词 + + // 词频统计 + for (const word of words) { + cutMap.set(word, (cutMap.get(word) ?? 0) + 1); + } + + // 构建共现关系 + for (let i = 0; i < words.length; i++) { + for (let j = i + 1; j < words.length; j++) { + // 对每对词建立共现关系 + const wordA = words[i]; + const wordB = words[j]; + + if (!wordA || !wordB) continue; // 防御性检查 + // 为第一个词添加共现 + if (!cooccurrenceMap.has(wordA)) { + cooccurrenceMap.set(wordA!, new Map()); + } + cooccurrenceMap.get(wordA)!.set(wordB, (cooccurrenceMap.get(wordA)!.get(wordB) ?? 0) + 1); + + // 为第二个词添加共现(双向关系) + if (!cooccurrenceMap.has(wordB)) { + cooccurrenceMap.set(wordB, new Map()); + } + cooccurrenceMap.get(wordB)!.set(wordA, (cooccurrenceMap.get(wordB)!.get(wordA) ?? 0) + 1); + } + } + } + } + + // 限制节点数量,只保留词频最高的50个 + let topWords = Array.from(cutMap.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 50) + .map(entry => entry[0]); + + // 构建图形数据 + let nodes: { id: string, label: string, value: number }[] = []; + let edges: { from: string, to: string, value: number }[] = []; + + // 添加节点 + for (const word of topWords) { + const frequency = cutMap.get(word) ?? 0; + nodes.push({ + id: word, + label: word, + value: frequency + }); + } + + // 最小共现阈值 - 忽略共现次数太少的连接 + const minCooccurrenceThreshold = 2; + // 每个节点最多保留的连接数 + const maxEdgesPerNode = 3; + + // 为每个节点添加最重要的几个连接 + for (const wordA of topWords) { + if (!wordA) continue; + + const cooccurrences = cooccurrenceMap.get(wordA); + if (!cooccurrences) continue; + + // 获取当前词与其他topWords中词的共现数据 + const relevantCooccurrences = Array.from(cooccurrences.entries()) + .filter(([wordB]) => topWords.includes(wordB) && wordB !== wordA) + .filter(([_, count]) => count >= minCooccurrenceThreshold) // 忽略共现次数太少的 + .sort((a, b) => b[1] - a[1]) // 按共现次数降序排序 + .slice(0, maxEdgesPerNode); // 只保留最重要的几个连接 + + // 添加边(只从一个方向添加,避免重复) + for (const [wordB, weight] of relevantCooccurrences) { + // 确保无重复边(只添加wordA < wordB的情况) + if (wordA < wordB) { + edges.push({ + from: wordA, + to: wordB, + value: weight + }); + } + } + } + + // 构建完整的网络图数据 + const networkData = { + nodes: nodes, + edges: edges, + title: `${message.group_id ?? "未知群聊"} 群词语关联分析` + }; + + // 使用新的网络图绘制函数 + const networkImageUrl = await drawWordNetwork(networkData); + + // 输出词频统计 + const topWordsText = topWords.slice(0, 20).map((word, index) => + `${index + 1}. ${word}: ${cutMap.get(word)}次` + ).join('\n'); + + // 发送结果 + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [ + { + type: OB11MessageDataType.node, + data: { + content: [ + { + type: OB11MessageDataType.text, + data: { + text: `词频分析 (总消息数: ${msgs.length})` + } + } + ] + } + }, + { + type: OB11MessageDataType.node, + data: { + content: [ + { + type: OB11MessageDataType.text, + data: { + text: `热词TOP20:\n${topWordsText}` + } + } + ] + } + }, + { + type: OB11MessageDataType.node, + data: { + content: [ + { + type: OB11MessageDataType.text, + data: { + text: `词语关联网络图:` + } + }, + { + type: OB11MessageDataType.image, + data: { + file: networkImageUrl + } + } + ] + } + } + ] + }, adapter, instance.config); + } + else if (message.message.find(e => e.type == 'text' && (e.data.text.startsWith('#Ta的今日词分析') || e.data.text.startsWith('#Ta的本周词分析') || e.data.text.startsWith('#Ta的本月词分析')))) { + let time = 0; + if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#Ta的本周词分析'))) { + time = 7 * 24 * 60 * 60; // 一周的秒数 + } else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#Ta的今日词分析'))) { + time = 24 * 60 * 60; // 一天的秒数 + } else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#Ta的本月词分析'))) { + time = 30 * 24 * 60 * 60; // 一月的秒数 + } + + // 获取目标用户 + let text_msg = message.message.find(e => e.type == 'text')?.data.text; + let at_msg = message.message.find(e => e.type == 'at')?.data.qq; + if (!at_msg) { + at_msg = message.user_id.toString(); + } + if (!text_msg || !at_msg) return; + + // 获取时间范围 + let timebefore = (Math.floor(Date.now() / 1000) - time).toString(); + let timeafter = Math.floor(Date.now() / 1000).toString(); + + // 获取群组信息和用户信息 + let peer = { peerUid: message.group_id?.toString() ?? "", chatType: ChatType.KCHATTYPEGROUP }; + let sender_uid = await _core.apis.UserApi.getUidByUinV2(at_msg); + + let userMsgs = (await _core.apis.MsgApi.queryFirstMsgBySenderTime(peer, [sender_uid], timebefore, timeafter)).msgList; + + // 词频统计 + let cutMap = new Map(); + // 建立词共现关系映射 + let cooccurrenceMap = new Map>(); + + // 遍历用户的所有消息 + for (const msg of userMsgs) { + let msg_list = msg.elements.filter(e => e.textElement).map(e => e.textElement!.content); + + for (const msg_list_item of msg_list) { + // 对每条消息进行分词 + let words = jieba.cut(msg_list_item, true) + .filter(word => word.length > 1); // 过滤掉单字词 + + // 词频统计 + for (const word of words) { + cutMap.set(word, (cutMap.get(word) ?? 0) + 1); + } + + // 构建共现关系 + for (let i = 0; i < words.length; i++) { + for (let j = i + 1; j < words.length; j++) { + // 对每对词建立共现关系 + const wordA = words[i]; + const wordB = words[j]; + + if (!wordA || !wordB) continue; // 防御性检查 + // 为第一个词添加共现 + if (!cooccurrenceMap.has(wordA)) { + cooccurrenceMap.set(wordA, new Map()); + } + cooccurrenceMap.get(wordA)!.set(wordB, (cooccurrenceMap.get(wordA)!.get(wordB) ?? 0) + 1); + + // 为第二个词添加共现(双向关系) + if (!cooccurrenceMap.has(wordB)) { + cooccurrenceMap.set(wordB, new Map()); + } + cooccurrenceMap.get(wordB)!.set(wordA, (cooccurrenceMap.get(wordB)!.get(wordA) ?? 0) + 1); + } + } + } + } + + // 构建结果数据 + let timeRangeText = "今日"; + if (time === 7 * 24 * 60 * 60) { + timeRangeText = "本周"; + } else if (time === 30 * 24 * 60 * 60) { + timeRangeText = "本月"; + } + + // 获取用户信息 + let info = await _core.apis.GroupApi.getGroupMember(message.group_id?.toString() ?? "", at_msg.toString()); + let username = info?.nick || at_msg; + + // 如果用户在该时间段内没有发言,发送提示 + if (userMsgs.length === 0) { + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.at, + data: { + qq: at_msg, + } + }, { + type: OB11MessageDataType.text, + data: { + text: ` ${timeRangeText}没有发言记录哦` + } + }] + }, adapter, instance.config); + return; + } + + // 如果词频数据为空,也发送提示 + if (cutMap.size === 0) { + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.at, + data: { + qq: at_msg, + } + }, { + type: OB11MessageDataType.text, + data: { + text: ` ${timeRangeText}没有有效的文本消息哦` + } + }] + }, adapter, instance.config); + return; + } + + // 限制节点数量,只保留词频最高的词 + let maxNodes = Math.min(50, cutMap.size); + let topWords = Array.from(cutMap.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, maxNodes) + .map(entry => entry[0]); + + // 构建网络图数据 + let nodes: { id: string, label: string, value: number }[] = []; + let edges: { from: string, to: string, value: number }[] = []; + + // 添加节点 + for (const word of topWords) { + const frequency = cutMap.get(word) ?? 0; + nodes.push({ + id: word, + label: word, + value: frequency + }); + } + + // 最小共现阈值和每个节点最多保留的连接数 + const minCooccurrenceThreshold = 1; // 对个人分析降低阈值 + const maxEdgesPerNode = 3; + + // 为每个节点添加最重要的几个连接 + for (const wordA of topWords) { + if (!wordA) continue; + + const cooccurrences = cooccurrenceMap.get(wordA); + if (!cooccurrences) continue; + + // 获取当前词与其他topWords中词的共现数据 + const relevantCooccurrences = Array.from(cooccurrences.entries()) + .filter(([wordB]) => topWords.includes(wordB) && wordB !== wordA) + .filter(([_, count]) => count >= minCooccurrenceThreshold) + .sort((a, b) => b[1] - a[1]) + .slice(0, maxEdgesPerNode); + + // 添加边(只从一个方向添加,避免重复) + for (const [wordB, weight] of relevantCooccurrences) { + if (wordA < wordB) { + edges.push({ + from: wordA, + to: wordB, + value: weight + }); + } + } + } + + // 构建网络图数据 + const networkData = { + nodes: nodes, + edges: edges, + title: `${username} ${timeRangeText}词语关联分析` + }; + + // 绘制网络图 + const networkImageUrl = await drawWordNetwork(networkData); + + // 输出词频统计(只取TOP15,因为是个人而非整个群) + const topWordsText = topWords.slice(0, 15).map((word, index) => + `${index + 1}. ${word}: ${cutMap.get(word)}次` + ).join('\n'); + + // 发送结果 + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [ + { + type: OB11MessageDataType.node, + data: { + content: [ + { + type: OB11MessageDataType.text, + data: { + text: `${username} ${timeRangeText}词频分析 (总消息数: ${userMsgs.length})` + } + } + ] + } + }, + { + type: OB11MessageDataType.node, + data: { + content: [ + { + type: OB11MessageDataType.text, + data: { + text: `热词TOP15:\n${topWordsText}` + } + } + ] + } + }, + { + type: OB11MessageDataType.node, + data: { + content: [ + { + type: OB11MessageDataType.text, + data: { + text: `词语关联网络图:` + } + }, + { + type: OB11MessageDataType.image, + data: { + file: networkImageUrl + } + } + ] + } + } + ] + }, adapter, instance.config); + } + else if (message.message.find(e => e.type == 'text' && (e.data.text.startsWith('#Ta今天最爱的表情包') || e.data.text.startsWith('#Ta本周最爱的表情包') || e.data.text.startsWith('#Ta本月最爱的表情包')))) { + // 确定时间范围 + let time = 0; + let timeRangeText = "今天"; + + if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#Ta本周最爱的表情包'))) { + time = 7 * 24 * 60 * 60; // 一周的秒数 + timeRangeText = "本周"; + } else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#Ta今天最爱的表情包'))) { + time = 24 * 60 * 60; // 一天的秒数 + timeRangeText = "今天"; + } else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#Ta本月最爱的表情包'))) { + time = 30 * 24 * 60 * 60; // 一月的秒数 + timeRangeText = "本月"; + } + + // 获取目标用户 + let text_msg = message.message.find(e => e.type == 'text')?.data.text; + let at_msg = message.message.find(e => e.type == 'at')?.data.qq; + if (!at_msg) { + at_msg = message.user_id.toString(); + } + if (!text_msg || !at_msg) return; + + // 获取时间范围 + let timebefore = (Math.floor(Date.now() / 1000) - time).toString(); + let timeafter = Math.floor(Date.now() / 1000).toString(); + + // 获取用户信息 + let peer = { peerUid: message.group_id?.toString() ?? "", chatType: ChatType.KCHATTYPEGROUP }; + let sender_uid = await _core.apis.UserApi.getUidByUinV2(at_msg); + + // 查询特定时间范围内的用户消息 + let msgs = (await _core.apis.MsgApi.queryFirstMsgBySenderTime(peer, [sender_uid], timebefore, timeafter)).msgList; + + // 记录表情包使用频率 + let countMap = new Map(); + + // 处理所有消息中的表情 + for (const msg of msgs) { + // 提取消息中的表情包元素 + const mediaElements = msg.elements.filter(e => e.marketFaceElement || e.picElement); + + for (const elem of mediaElements) { + let mediaPart = elem.marketFaceElement || elem.picElement; + if (!mediaPart) continue; + + if ('emojiId' in mediaPart) { + // 处理表情包 + const { emojiId } = mediaPart; + const dir = emojiId.substring(0, 2); + const url = `https://gxh.vip.qq.com/club/item/parcel/item/${dir}/${emojiId}/raw300.gif`; + + const existing = countMap.get(emojiId) || { count: 0, url, lastUsed: 0 }; + existing.count += 1; + existing.lastUsed = Math.max(existing.lastUsed, parseInt(msg.msgTime || '0')); + countMap.set(emojiId, existing); + } else { + // 处理图片 + let unique = mediaPart.fileName || ""; + let existing = countMap.get(unique) || { count: 0, url: '', lastUsed: 0 }; + + if (!existing.url) { + existing.url = await _core.apis.FileApi.getImageUrl(mediaPart); + } + + existing.count += 1; + existing.lastUsed = Math.max(existing.lastUsed, parseInt(msg.msgTime || '0')); + countMap.set(unique, existing); + } + } + } + + // 对表情包进行排名,取前10名 + let rank = Array.from(countMap.entries()) + .sort((a, b) => b[1].count - a[1].count) + .slice(0, 10); + + // 获取用户信息 + let info = await _core.apis.GroupApi.getGroupMember(message.group_id?.toString() ?? "", at_msg.toString()); + + // 准备消息内容 + let msgContent: OB11MessageNode[] = []; + + // 为每个表情包生成一个节点 + for (let i = 0; i < rank.length; i++) { + const item = rank[i]; + if (!item) continue; + + const [_unique, data] = item; + const { count, url } = data; + + // 添加表情图片节点 + msgContent.push({ + type: OB11MessageDataType.node, + data: { + content: [ + { + type: OB11MessageDataType.text, + data: { + text: `${i + 1}. 使用了${count}次\n` + } + }, + { + type: OB11MessageDataType.image, + data: { + file: url + } + } + ] as OB11MessageData[] + } + }); + } + + if (msgContent.length > 0) { + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.node, + data: { + content: [ + { + type: OB11MessageDataType.text, + data: { + text: `${info?.nick || at_msg} ${timeRangeText}最爱表情包Top${Math.min(10, rank.length)}` + } + } + ] + } + }, ...msgContent] + }, adapter, instance.config); + } else { + // 没有找到表情包时发送提示 + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.at, + data: { + qq: at_msg, + } + }, { + type: OB11MessageDataType.text, + data: { + text: ` ${timeRangeText}似乎没有发送过表情包呢` + } + }] + }, adapter, instance.config); + } + } + else if (message.message.find(e => e.type == 'text' && ( + e.data.text.startsWith('#寻找同时间水群群友') || + e.data.text.startsWith('#寻找今日同时间水群群友') || + e.data.text.startsWith('#寻找本周同时间水群群友') || + e.data.text.startsWith('#寻找本月同时间水群群友') + ))) { + try { + // 获取目标用户 + let at_msg = message.message.find(e => e.type == 'at')?.data.qq; + if (!at_msg) { + at_msg = message.user_id.toString(); + } + + // 确定查询时间范围 + let lookbackDays = 30; // 默认查询过去30天的数据 + let timeRangeText = "本月"; + + const text = message.message.find(e => e.type == 'text')?.data.text || ''; + if (text.includes('今日')) { + lookbackDays = 1; + timeRangeText = "今日"; + } else if (text.includes('本周')) { + lookbackDays = 7; + timeRangeText = "本周"; + } + + const groupId = message.group_id?.toString() ?? ""; + + // 计算时间范围 + const timeBefore = (Math.floor(Date.now() / 1000) - (lookbackDays * 24 * 60 * 60)).toString(); + const timeAfter = Math.floor(Date.now() / 1000).toString(); + + // 获取目标用户信息 + const targetInfo = await _core.apis.GroupApi.getGroupMember(groupId, at_msg); + if (!targetInfo) { + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.text, + data: { + text: "获取目标用户信息失败" + } + }] + }, adapter, instance.config); + return; + } + + // 使用 queryFirstMsgByTime 一次性获取所有消息 + const peer = { peerUid: groupId, chatType: ChatType.KCHATTYPEGROUP }; + const allMsgs = (await _core.apis.MsgApi.queryFirstMsgByTime(peer, timeBefore, timeAfter)).msgList; + + if (allMsgs.length === 0) { + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.text, + data: { + text: `${timeRangeText}内没有聊天记录` + } + }] + }, adapter, instance.config); + return; + } + + // 优化: 使用Map按用户UID分组消息,避免循环中重复判断 + const userMsgsMap = new Map(); + + // 第一轮: 收集所有用户的消息并按UID分组 + for (const msg of allMsgs) { + if (!msg.senderUid || !msg.msgTime) continue; + + // 使用消息中的UIN或通过UID获取UIN + let uin = msg.senderUin || ''; + if (uin === '0' || uin === '') { + uin = await _core.apis.UserApi.getUinByUidV2(msg.senderUid); + if (!uin) continue; + } + + // 获取或创建用户数据条目 + let userData = userMsgsMap.get(msg.senderUid); + if (!userData) { + userData = { + uin, + nick: msg.sendNickName || msg.sendMemberName || uin, + msgs: [] + }; + userMsgsMap.set(msg.senderUid, userData); + } + userData.msgs.push(msg); + } + + // 优化: 获取目标用户的消息 + const targetUid = await _core.apis.UserApi.getUidByUinV2(at_msg); + if (!targetUid || !userMsgsMap.has(targetUid)) { + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.at, + data: { + qq: at_msg, + } + }, { + type: OB11MessageDataType.text, + data: { + text: ` ${timeRangeText}内没有发言记录` + } + }] + }, adapter, instance.config); + return; + } + + const targetUserData = userMsgsMap.get(targetUid)!; + const targetMessages = targetUserData.msgs; + + // 确保目标用户有足够的消息用于分析 + const minMsgCount = lookbackDays === 1 ? 5 : 10; // 如果是今日数据,降低阈值 + + if (targetMessages.length < minMsgCount) { + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.at, + data: { + qq: at_msg, + } + }, { + type: OB11MessageDataType.text, + data: { + text: ` ${timeRangeText}的消息太少(${targetMessages.length}条),无法进行有效分析` + } + }] + }, adapter, instance.config); + return; + } + + // 计算目标用户的时间模式 + const targetPattern = calculateTimePattern(targetMessages); + + // 分析其他用户的时间模式并计算相似度 + const similarityResults: { + uin: string, + nickname: string, + similarity: number, + msgCount: number, + pattern: Map + }[] = []; + + // 优化: 一次性计算所有用户的相似度,避免多次循环 + for (const [uid, userData] of userMsgsMap.entries()) { + // 排除目标用户自己和消息数量太少的用户 + if (uid === targetUid || userData.msgs.length < minMsgCount) continue; + + const pattern = calculateTimePattern(userData.msgs); + const similarity = calculateSimilarity(targetPattern, pattern); + + similarityResults.push({ + uin: userData.uin, + nickname: userData.nick, + similarity, + msgCount: userData.msgs.length, + pattern + }); + } + + // 按相似度排序取前5 + const topMatches = similarityResults + .sort((a, b) => b.similarity - a.similarity) + .slice(0, 5); + + if (topMatches.length === 0) { + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.at, + data: { + qq: at_msg, + } + }, { + type: OB11MessageDataType.text, + data: { + text: ` ${timeRangeText}内没有找到与你聊天时间模式相似的群友` + } + }] + }, adapter, instance.config); + return; + } + + // 准备可视化数据 + const visualizationData = { + targetUser: targetInfo.nick || at_msg, + matchedUsers: topMatches.map(match => ({ + username: match.nickname, + similarity: match.similarity, + pattern: match.pattern + })), + targetPattern, + timeRange: timeRangeText + }; + + // 生成可视化图表 - 保持原有调用不变 + const visualizationImage = await drawTimePattern(visualizationData); + + // 准备文本结果 + let resultText = `${targetInfo.nick || at_msg} ${timeRangeText}聊天模式匹配结果\n`; + resultText += `分析消息: ${targetMessages.length}条\n\n`; + resultText += `与你聊天模式最相似的群友:\n`; + + for (let i = 0; i < topMatches.length; i++) { + const match = topMatches[i]; + if (!match) continue; + const similarityPercent = (match.similarity * 100).toFixed(1); + resultText += `${i + 1}. ${match.nickname}: ${similarityPercent}% 匹配 (${match.msgCount}条消息)\n`; + } + + // 发送结果 + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [ + { + type: OB11MessageDataType.node, + data: { + content: [ + { + type: OB11MessageDataType.text, + data: { + text: resultText + } + } + ] + } + }, + { + type: OB11MessageDataType.node, + data: { + content: [ + { + type: OB11MessageDataType.text, + data: { + text: `聊天时间模式对比图:` + } + }, + { + type: OB11MessageDataType.image, + data: { + file: visualizationImage + } + } + ] + } + } + ] + }, adapter, instance.config); + } catch (error) { + console.error("Error in 寻找同时间水群群友:", error); + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.text, + data: { + text: `处理请求时出错: ${error instanceof Error ? error.message : String(error)}` + } + }] + }, adapter, instance.config); + } + } + else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#文本转图片'))) { + let content = message.message.filter(e => e.type == 'text').map(e => e.data.text).join(' ').replace('#文本转图片', '').trim(); + if (!content) { + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.text, + data: { + text: '请输入要转换的文本' + } + }] + }, adapter, instance.config); + return; + } + let imageUrl = await drawJsonContent(content); + + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.image, + data: { + file: imageUrl + } + }] + }, adapter, instance.config); + + } + else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#Ai语音文本'))) { + let content = message.message.filter(e => e.type == 'text').map(e => e.data.text).join(' ').replace('#Ai语音文本', '').trim(); + + await action.get('send_group_ai_record')?._handle({ + group_id: String(message.group_id), + character: ai_character, + text: content + }) + + } + else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#Ai语音设置角色'))) { + let content = message.message.filter(e => e.type == 'text').map(e => e.data.text).join(' ').replace('#Ai语音设置角色', '').trim(); + ai_character = content; + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.image, + data: { + file: await drawJsonContent(`已设置角色为: ${ai_character}`) + } + }] + }, adapter, instance.config); + } + else if (message.message.find(e => e.type == 'text' && e.data.text.startsWith('#Ai语音角色列表'))) { + let ret = await action.get('get_ai_characters')?._handle({ + group_id: String(message.group_id), + chat_type: 1 + }) + if (!ret) return; + let msgJson = `可用角色列表:\n`; + for (const ai of ret) { + msgJson += `角色类型: ${ai.type}\n`; + for (const character of ai.characters) { + msgJson += ` 角色识别: ${character.character_id}\n`; + msgJson += ` 角色名称: ${character.character_name}\n`; + } + } + await action.get('send_group_msg')?.handle({ + group_id: String(message.group_id), + message: [{ + type: OB11MessageDataType.image, + data: { + file: await drawJsonContent(msgJson) + } + }] + }, adapter, instance.config); + } }; \ No newline at end of file diff --git a/src/plugin/network.ts b/src/plugin/network.ts new file mode 100644 index 00000000..39096a7f --- /dev/null +++ b/src/plugin/network.ts @@ -0,0 +1,430 @@ +import { createCanvas, loadImage } from "@napi-rs/canvas"; + +interface NetworkNode { + id: string; + label: string; + value: number; +} + +interface NetworkEdge { + from: string; + to: string; + value: number; +} + +interface NetworkData { + nodes: NetworkNode[]; + edges: NetworkEdge[]; + title: string; +} + +export async function drawWordNetwork(data: NetworkData): Promise { + // 根据节点数量动态调整画布尺寸 + const nodeCount = data.nodes.length; + const baseWidth = 1000; + const baseHeight = 800; + + // 根据节点数量计算合适的尺寸 + const width = Math.max(baseWidth, Math.min(2000, baseWidth + (nodeCount - 10) * 30)); + const height = Math.max(baseHeight, Math.min(1500, baseHeight + (nodeCount - 10) * 25)); + + // 根据画布大小调整边距 + const padding = Math.max(60, Math.min(100, 60 + nodeCount / 20)); + const centerX = width / 2; + const centerY = height / 2; + + // 创建画布 + const canvas = createCanvas(width, height); + const ctx = canvas.getContext('2d'); + + // 绘制背景 + try { + const backgroundImage = await loadImage('C:\\fonts\\post.jpg'); + const pattern = ctx.createPattern(backgroundImage, 'repeat'); + if (pattern) { + ctx.fillStyle = pattern; + ctx.fillRect(0, 0, width, height); + + // 添加模糊效果 + ctx.filter = 'blur(5px)'; + ctx.drawImage(canvas, 0, 0); + ctx.filter = 'none'; + } + } catch (e) { + // 如果背景图加载失败,使用纯色背景 + ctx.fillStyle = '#f0f0f0'; + ctx.fillRect(0, 0, width, height); + } + + // 绘制半透明卡片背景 + const cardWidth = width - padding * 2; + const cardHeight = height - padding * 2; + const cardX = padding; + const cardY = padding; + const radius = 20; + + ctx.fillStyle = 'rgba(255, 255, 255, 0.85)'; + ctx.beginPath(); + ctx.moveTo(cardX + radius, cardY); + ctx.lineTo(cardX + cardWidth - radius, cardY); + ctx.quadraticCurveTo(cardX + cardWidth, cardY, cardX + cardWidth, cardY + radius); + ctx.lineTo(cardX + cardWidth, cardY + cardHeight - radius); + ctx.quadraticCurveTo(cardX + cardWidth, cardY + cardHeight, cardX + cardWidth - radius, cardY + cardHeight); + ctx.lineTo(cardX + radius, cardY + cardHeight); + ctx.quadraticCurveTo(cardX, cardY + cardHeight, cardX, cardY + cardHeight - radius); + ctx.lineTo(cardX, cardY + radius); + ctx.quadraticCurveTo(cardX, cardY, cardX + radius, cardY); + ctx.closePath(); + ctx.fill(); + + // 绘制标题 + ctx.fillStyle = '#333'; + ctx.font = '28px "Aa偷吃可爱长大的"'; + ctx.textAlign = 'center'; + ctx.fillText(data.title, centerX, cardY + 40); + + // 计算节点位置 (使用简化版力导向算法) + const nodePositions = new Map(); + const radiusScale = 15; // 基础节点半径 + const maxRadius = 40; // 最大节点半径 + + // 找出最大频率值用于缩放 + const maxValue = Math.max(...data.nodes.map(n => n.value)); + + // 计算每个节点的实际半径 + const nodeRadiusMap = new Map(); + for (const node of data.nodes) { + const radius = Math.min(maxRadius, radiusScale + (node.value / maxValue) * 25); + nodeRadiusMap.set(node.id, radius); + } + + // 节点重叠标记系统 - 跟踪哪些节点存在重叠 + const overlapTracker = new Map>(); + for (const node of data.nodes) { + overlapTracker.set(node.id, new Set()); + } + + // 根据画布尺寸调整初始分布范围 - 增加分布范围 + const initialRadius = Math.min(cardWidth, cardHeight) * 0.4; + + // 对节点按大小排序,确保大节点先放置 + const sortedNodes = [...data.nodes].sort((a, b) => b.value - a.value); + + // 初始化随机位置 - 改进的空间分配策略 + for (let i = 0; i < sortedNodes.length; i++) { + const node = sortedNodes[i]; + if (!node) continue; // 防止空节点 + const nodeRadius = nodeRadiusMap.get(node.id)!; + + // 使用黄金角度法进行更均匀的分布 + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); // 黄金角 + const angle = i * goldenAngle; + + // 根据节点大小调整距离 + const sizeFactor = 1 + (nodeRadius / maxRadius) * 0.5; // 大节点获得更远的初始距离 + const distance = initialRadius * (0.4 + 0.6 * Math.random()) * sizeFactor; + + nodePositions.set(node.id, { + x: centerX + Math.cos(angle) * distance, + y: centerY + Math.sin(angle) * distance + }); + } + + // 根据节点数量调整迭代次数 - 增加迭代次数确保充分布局 + const iterations = Math.max(40, Math.min(80, 40 + nodeCount)); + + // 模拟物理力学 + for (let iteration = 0; iteration < iterations; iteration++) { + // 冷却因子 - 调整冷却曲线以减缓冷却速度 + const temperatureFactor = 1 - Math.pow(iteration / iterations, 1.5) * 0.8; + + // 清除重叠标记 + for (const nodeId of overlapTracker.keys()) { + overlapTracker.get(nodeId)!.clear(); + } + + // 斥力 (所有节点相互排斥) + for (let i = 0; i < data.nodes.length; i++) { + const node1 = data.nodes[i]; + if (!node1) continue; // 防止空节点 + const pos1 = nodePositions.get(node1.id)!; + const radius1 = nodeRadiusMap.get(node1.id)!; + + for (let j = i + 1; j < data.nodes.length; j++) { + const node2 = data.nodes[j]; + if (!node2) continue; // 防止空节点 + const pos2 = nodePositions.get(node2.id)!; + const radius2 = nodeRadiusMap.get(node2.id)!; + + const dx = pos2.x - pos1.x; + const dy = pos2.y - pos1.y; + const distance = Math.sqrt(dx * dx + dy * dy) || 1; + + // 根据节点实际大小计算最小距离 + const minDistance = radius1 + radius2 + 40; // 增加最小间隙 + + // 检测并标记重叠 + if (distance < minDistance) { + overlapTracker.get(node1.id)!.add(node2.id); + overlapTracker.get(node2.id)!.add(node1.id); + } + + // 对所有节点应用基础斥力 + const repulsionStrength = 1200 * temperatureFactor; // 增强基础斥力 + + if (distance > 0) { + // 使用反比平方斥力,但对近距离节点增加额外斥力 + const proximityFactor = Math.pow(Math.max(0, 1 - distance / (minDistance * 2)), 2) * 3 + 1; + const force = Math.min(8, repulsionStrength / (distance * distance)) * proximityFactor; + + // 根据节点大小调整斥力 + const sizeFactor = (radius1 + radius2) / (radiusScale * 2); + const adjustedForce = force * Math.sqrt(sizeFactor); + + const moveX = (dx / distance) * adjustedForce; + const moveY = (dy / distance) * adjustedForce; + + pos1.x -= moveX; + pos1.y -= moveY; + pos2.x += moveX; + pos2.y += moveY; + } + + // 如果距离小于最小距离,增加强制分离力 + if (distance < minDistance) { + // 计算重叠度 + const overlapRatio = (minDistance - distance) / minDistance; + + // 计算分离力 - 重叠程度越高,力越大 + // 在迭代后期增加分离力 + const lateStageFactor = 1 + Math.max(0, (iteration - iterations * 0.6) / (iterations * 0.4)) * 2; + const separationForce = overlapRatio * 0.8 * temperatureFactor * lateStageFactor; + + pos1.x -= dx * separationForce; + pos1.y -= dy * separationForce; + pos2.x += dx * separationForce; + pos2.y += dy * separationForce; + + // 额外的扭矩力,帮助节点绕过彼此 + if (overlapRatio > 0.5 && iteration > iterations * 0.3) { + // 计算垂直于连线的方向 + const perpX = -dy / distance; + const perpY = dx / distance; + + // 随机选择扭矩方向 + const sign = Math.random() > 0.5 ? 1 : -1; + const torqueFactor = 0.2 * overlapRatio * temperatureFactor; + + pos1.x += perpX * torqueFactor * sign; + pos1.y += perpY * torqueFactor * sign; + pos2.x -= perpX * torqueFactor * sign; + pos2.y -= perpY * torqueFactor * sign; + } + } + } + } + + // 引力 (有连接的节点相互吸引) - 优化以避免过度聚集 + for (const edge of data.edges) { + const pos1 = nodePositions.get(edge.from)!; + const pos2 = nodePositions.get(edge.to)!; + const radius1 = nodeRadiusMap.get(edge.from)!; + const radius2 = nodeRadiusMap.get(edge.to)!; + + const dx = pos2.x - pos1.x; + const dy = pos2.y - pos1.y; + const distance = Math.sqrt(dx * dx + dy * dy) || 1; + + // 根据边权值和节点大小调整引力 + const baseStrength = Math.min(0.015, edge.value / 200); + const strength = baseStrength * temperatureFactor; + + // 根据节点大小动态调整最佳距离 + const minNodeDistance = radius1 + radius2 + 40; + const optimalDistance = minNodeDistance + 60 + edge.value * 0.5; + + if (distance > optimalDistance) { + // 如果节点距离过远,应用引力 + const attractionForce = strength * Math.min(1, (distance - optimalDistance) / optimalDistance); + pos1.x += dx * attractionForce; + pos1.y += dy * attractionForce; + pos2.x -= dx * attractionForce; + pos2.y -= dy * attractionForce; + } else if (distance < minNodeDistance) { + // 如果节点距离过近,应用斥力 + const repulsionForce = 0.05 * temperatureFactor * (minNodeDistance - distance) / minNodeDistance; + pos1.x -= dx * repulsionForce; + pos1.y -= dy * repulsionForce; + pos2.x += dx * repulsionForce; + pos2.y += dy * repulsionForce; + } + } + + // 中心引力 - 防止节点飞得太远 + const availableArea = Math.min(cardWidth, cardHeight) * 0.45; // 增加有效区域 + + for (const node of data.nodes) { + const pos = nodePositions.get(node.id)!; + const dx = centerX - pos.x; + const dy = centerY - pos.y; + const distanceFromCenter = Math.sqrt(dx * dx + dy * dy); + + // 根据与中心距离施加引力 + if (distanceFromCenter > availableArea) { + const centerForce = 0.01 * temperatureFactor * + Math.pow((distanceFromCenter - availableArea) / availableArea, 1.2); + pos.x += dx * centerForce; + pos.y += dy * centerForce; + } + } + + // 确保节点不会超出边界 + for (const node of data.nodes) { + const pos = nodePositions.get(node.id)!; + const radius = nodeRadiusMap.get(node.id)!; + const margin = radius + 20; // 考虑节点实际大小的边距 + + pos.x = Math.max(cardX + margin, Math.min(cardX + cardWidth - margin, pos.x)); + pos.y = Math.max(cardY + margin, Math.min(cardY + cardHeight - margin, pos.y)); + } + + // 重叠度计算 - 统计当前总重叠数量 + let totalOverlaps = 0; + for (const overlaps of overlapTracker.values()) { + totalOverlaps += overlaps.size; + } + + // 如果迭代已进行了3/4以上且没有重叠,可以提前结束 + if (iteration > iterations * 0.75 && totalOverlaps === 0) { + break; + } + } + + // 最终重叠消除阶段 - 专门解决残余重叠问题 + for (let i = 0; i < 15; i++) { + let overlapsFixed = 0; + + for (let j = 0; j < data.nodes.length; j++) { + const node1 = data.nodes[j]; + if (!node1) continue; // 防止空节点 + const pos1 = nodePositions.get(node1.id)!; + const radius1 = nodeRadiusMap.get(node1.id)!; + + for (let k = j + 1; k < data.nodes.length; k++) { + const node2 = data.nodes[k]; + if (!node2) continue; // 防止空节点 + const pos2 = nodePositions.get(node2.id)!; + const radius2 = nodeRadiusMap.get(node2.id)!; + + const dx = pos2.x - pos1.x; + const dy = pos2.y - pos1.y; + const distance = Math.sqrt(dx * dx + dy * dy) || 1; + const minDistance = radius1 + radius2 + 40; + + if (distance < minDistance) { + // 计算需要移动的距离 + const moveDistance = (minDistance - distance) / 2 + 1; + const moveX = (dx / distance) * moveDistance; + const moveY = (dy / distance) * moveDistance; + + // 应用移动 + pos1.x -= moveX; + pos1.y -= moveY; + pos2.x += moveX; + pos2.y += moveY; + + // 施加小的随机扰动以打破对称性 + const jitter = 1; + pos1.x += (Math.random() - 0.5) * jitter; + pos1.y += (Math.random() - 0.5) * jitter; + pos2.x += (Math.random() - 0.5) * jitter; + pos2.y += (Math.random() - 0.5) * jitter; + + overlapsFixed++; + } + } + + // 确保节点不会超出边界 + const radius = nodeRadiusMap.get(node1.id)!; + const margin = radius + 20; + pos1.x = Math.max(cardX + margin, Math.min(cardX + cardWidth - margin, pos1.x)); + pos1.y = Math.max(cardY + margin, Math.min(cardY + cardHeight - margin, pos1.y)); + } + + // 如果没有重叠了,提前退出 + if (overlapsFixed === 0) break; + } + + // 绘制边 - 改进边的视觉效果 + for (const edge of data.edges) { + const pos1 = nodePositions.get(edge.from)!; + const pos2 = nodePositions.get(edge.to)!; + const radius1 = nodeRadiusMap.get(edge.from)!; + const radius2 = nodeRadiusMap.get(edge.to)!; + + // 计算边的实际起止点,从节点边缘开始而不是中心 + const dx = pos2.x - pos1.x; + const dy = pos2.y - pos1.y; + const distance = Math.sqrt(dx * dx + dy * dy) || 1; + + // 计算实际的起点和终点,从节点边缘开始 + const startX = pos1.x + (dx / distance) * radius1; + const startY = pos1.y + (dy / distance) * radius1; + const endX = pos2.x - (dx / distance) * radius2; + const endY = pos2.y - (dy / distance) * radius2; + + // 根据权重确定线宽 + const lineWidth = Math.max(1, Math.min(5, edge.value / 10)); + + // 计算透明度 (权重越高透明度越低) + const alpha = Math.min(0.7, 0.2 + edge.value / 20); + + ctx.beginPath(); + ctx.moveTo(startX, startY); + ctx.lineTo(endX, endY); + ctx.strokeStyle = `rgba(100, 100, 255, ${alpha})`; + ctx.lineWidth = lineWidth; + ctx.stroke(); + } + + // 绘制节点 + for (const node of data.nodes) { + const pos = nodePositions.get(node.id)!; + + // 使用预计算的节点半径 + const radius = nodeRadiusMap.get(node.id)!; + + // 绘制节点圆形 + ctx.beginPath(); + ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2); + ctx.fillStyle = `rgba(255, 100, 100, 0.8)`; + ctx.fill(); + + // 绘制边框 + ctx.strokeStyle = '#800000'; + ctx.lineWidth = 2; + ctx.stroke(); + + // 根据节点大小调整字体大小 + const fontSize = Math.max(14, Math.min(18, 14 + (node.value / maxValue) * 6)); + + // 绘制文本 + ctx.fillStyle = '#000'; + ctx.font = `${fontSize}px "Aa偷吃可爱长大的"`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(node.label, pos.x, pos.y); + } + + // 绘制图例 + ctx.fillStyle = '#333'; + ctx.font = '18px "Aa偷吃可爱长大的"'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ctx.fillText('词频越高,节点越大', cardX + 20, cardY + 20); + ctx.fillText('关联越强,连线越粗', cardX + 20, cardY + 50); + + // 保存图像 + const buffer = canvas.toBuffer('image/png'); + return "base64://" + buffer.toString('base64'); +} \ No newline at end of file diff --git a/src/shell/drawJson.ts b/src/shell/drawJson.ts index 174cf409..3dcd9f73 100644 --- a/src/shell/drawJson.ts +++ b/src/shell/drawJson.ts @@ -7,12 +7,12 @@ export async function drawJsonContent(jsonContent: string) { const lineHeight = 30; const canvas = createCanvas(1, 1); const ctx = canvas.getContext('2d'); - + const chineseRegex = /[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef]/; let maxLineWidth = 0; for (const line of lines) { let lineWidth = 0; for (const char of line) { - const isChinese = /[\u4e00-\u9fa5]/.test(char); + const isChinese = chineseRegex.test(char); ctx.font = isChinese ? '20px "Aa偷吃可爱长大的"' : '20px "JetBrains Mono"'; lineWidth += ctx.measureText(char).width; }