diff --git a/package.json b/package.json index 13ad8b3..88914d8 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "better-sqlite3-multiple-ciphers": "^12.5.0", "fs-extra": "^11.3.2", "fzstd": "^0.1.1", - "html-to-image": "^1.11.13", "koffi": "^2.9.0", "openai": "^6.10.0", "silk-wasm": "^3.7.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f111d83..be04697 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,7 +26,6 @@ specifiers: eslint-plugin-react-refresh: ^0.4.24 fs-extra: ^11.3.2 fzstd: ^0.1.1 - html-to-image: ^1.11.13 koffi: ^2.9.0 openai: ^6.10.0 prettier: ^3.7.4 @@ -42,7 +41,6 @@ dependencies: better-sqlite3-multiple-ciphers: 12.5.0 fs-extra: 11.3.2 fzstd: 0.1.1 - html-to-image: 1.11.13 koffi: 2.16.2 openai: 6.10.0 silk-wasm: 3.7.1 @@ -2946,10 +2944,6 @@ packages: lru-cache: 6.0.0 dev: true - /html-to-image/1.11.13: - resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} - dev: false - /http-cache-semantics/4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} diff --git a/resources/mobile_daily_report.html b/resources/mobile_daily_report.html new file mode 100644 index 0000000..2d4c6cb --- /dev/null +++ b/resources/mobile_daily_report.html @@ -0,0 +1,479 @@ + + + + + + {{REPORT_TITLE}} + + + +
+
+
+
+

{{GROUP_NAME}}日报

+
{{DATE_RANGE}}
{{RECORD_NOTE}}
+
+
{{HERO_AVATARS}}
+
+
+
{{MESSAGE_COUNT}}消息数
+
{{ACTIVE_USERS}}活跃人数
+
{{TIME_SPAN}}时间跨度
+
{{TOPIC_COUNT}}主要话题
+
+
+ +
+
今日讨论热点
+ {{TOPIC_CARDS}} +
+ +
+
实用信息与资源
+ {{RESOURCE_ITEMS}} +
+ +
+
重要消息汇总
+ {{IMPORTANT_MESSAGES}} +
+ +
+
有趣对话或金句
+ {{QUOTE_BLOCKS}} +
+ +
+
问题与解答
+ {{QA_CARDS}} +
+ +
+
群内数据可视化
+ {{HEAT_BARS}} +
+
+ 话唠榜 TOP5(基于已读取记录估算) +
+ {{RANK_ITEMS}} +
+
+

活跃时间线:{{ACTIVITY_TIMELINE}}

+
+
+ +
+
词云/关键词
+
{{CLOUD_TAGS}}
+
+ + +
+ + diff --git a/src/main/group-report-service.ts b/src/main/group-report-service.ts new file mode 100644 index 0000000..22ed183 --- /dev/null +++ b/src/main/group-report-service.ts @@ -0,0 +1,296 @@ +import { app, BrowserWindow } from 'electron' +import fs from 'fs-extra' +import os from 'os' +import path from 'path' +import { + GroupReportExportRequest, + GroupReportExportResult, + ReportHeat +} from '../shared/group-report' + +const TEMPLATE_NAME = 'mobile_daily_report.html' + +const escapeHtml = (value: unknown): string => + String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + +const sanitizeFileName = (value: string): string => + value + .replace(/[\\/:*?"<>|]/g, '_') + .replace(/\s+/g, ' ') + .trim() || '未命名群聊' + +const hashName = (name: string): number => { + let hash = 0 + for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0 + return hash +} + +const fallbackAvatar = (name: string): string => { + const hue = hashName(name) % 360 + const initial = escapeHtml(Array.from(name.trim())[0] || '?') + const svg = `${initial}` + return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` +} + +const imageMimeType = (contentType: string | null, source: string): string => { + if (contentType?.startsWith('image/')) return contentType.split(';')[0] + const extension = path.extname(source).toLowerCase() + if (extension === '.png') return 'image/png' + if (extension === '.webp') return 'image/webp' + if (extension === '.gif') return 'image/gif' + return 'image/jpeg' +} + +const embedAvatar = async (source: string | undefined, name: string): Promise => { + if (!source) return fallbackAvatar(name) + if (/^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source + + try { + if (/^https?:\/\//i.test(source)) { + const response = await fetch(source, { + headers: { + 'User-Agent': 'Mozilla/5.0 WechatExplorer', + Referer: 'https://weixin.qq.com/' + }, + signal: AbortSignal.timeout(8000) + }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + const mime = imageMimeType(response.headers.get('content-type'), source) + return `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}` + } + + const localPath = source.startsWith('file://') ? new URL(source) : source + const buffer = await fs.readFile(localPath) + return `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}` + } catch (error) { + console.warn(`[GroupReport] avatar fallback for ${name}:`, error) + return fallbackAvatar(name) + } +} + +const templatePath = (): string => { + const candidates = [ + path.join(process.resourcesPath, 'resources', TEMPLATE_NAME), + path.join(app.getAppPath(), 'resources', TEMPLATE_NAME), + path.join(process.cwd(), 'resources', TEMPLATE_NAME) + ] + const found = candidates.find((candidate) => fs.existsSync(candidate)) + if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`) + return found +} + +const heatClass = (heat: ReportHeat): string => { + if (heat === '高') return 'hot' + if (heat === '低') return 'blue' + return '' +} + +const replacePlaceholder = (html: string, key: string, value: string): string => + html.replaceAll(`{{${key}}}`, value) + +const renderReportHtml = async (request: GroupReportExportRequest): Promise => { + const { report, metadata } = request + const avatarNames = new Set(metadata.heroParticipants) + report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name))) + report.importantMessages.forEach((message) => avatarNames.add(message.sender)) + report.quotes.forEach((quote) => + quote.messages.forEach((message) => avatarNames.add(message.sender)) + ) + report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name)) + + const avatars = new Map() + await Promise.all( + Array.from(avatarNames).map(async (name) => { + avatars.set(name, await embedAvatar(metadata.avatars[name], name)) + }) + ) + const avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name) + + const heroNames = metadata.heroParticipants.slice(0, 4) + while (heroNames.length < 4) heroNames.push(metadata.groupName) + const heroAvatars = heroNames + .map((name) => `${escapeHtml(name)}`) + .join('') + + const topicCards = report.topics + .map( + (topic) => `
+

${escapeHtml(topic.title)}

${escapeHtml(topic.heat)}热
+
${escapeHtml(topic.timeRange)}
+

${escapeHtml(topic.summary)}

+ ${topic.conclusion ? `

${escapeHtml(topic.conclusion)}

` : ''} +
${topic.participants + .slice(0, 5) + .map( + (name) => + `${escapeHtml(name)}` + ) + .join('')}
+
${topic.keywords.map((word) => `${escapeHtml(word)}`).join('')}
+
` + ) + .join('') + + const resourceItems = report.resources + .map( + (resource) => + `
${escapeHtml(resource.title)}${resource.sender ? ` · ${escapeHtml(resource.sender)}` : ''}
${escapeHtml(resource.description)}
` + ) + .join('') + + const importantMessages = report.importantMessages + .map( + (message) => `
+ +
${escapeHtml(message.sender)}${escapeHtml(message.time)}
+
${escapeHtml(message.content)}
${escapeHtml(message.note)}
+
` + ) + .join('') + + const quoteBlocks = report.quotes + .map( + (quote) => + `
${quote.messages + .map( + ( + message + ) => `
+
${escapeHtml(message.sender)}
${escapeHtml(message.content)}
+
` + ) + .join('')}
${escapeHtml(quote.note)}
` + ) + .join('') + + const qaCards = report.qa + .map( + (item) => + `
Q:${escapeHtml(item.question)}
A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}
` + ) + .join('') + + const maxHeat = Math.max(1, ...report.analytics.topicHeat.map((item) => item.score)) + const heatBars = report.analytics.topicHeat + .map( + (item) => + `
${escapeHtml(item.topic)}
` + ) + .join('') + + const rankItems = report.analytics.topSpeakers + .slice(0, 5) + .map( + (speaker, index) => + `
${index + 1}. ${escapeHtml(speaker.name)}${Math.max(0, speaker.count)} 条
` + ) + .join('') + + const cloudTags = report.keywords + .slice(0, 15) + .map( + (word, index) => + `${escapeHtml(word)}` + ) + .join('') + + let html = await fs.readFile(templatePath(), 'utf8') + const values: Record = { + REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`), + GROUP_NAME: escapeHtml(metadata.groupName), + DATE_RANGE: escapeHtml(metadata.dateRange), + RECORD_NOTE: escapeHtml(`${metadata.recordNote} ${report.overview}`.trim()), + HERO_AVATARS: heroAvatars, + MESSAGE_COUNT: String(metadata.messageCount), + ACTIVE_USERS: String(metadata.activeUsers), + TIME_SPAN: escapeHtml(metadata.timeSpan), + TOPIC_COUNT: String(report.topics.length), + TOPIC_CARDS: topicCards, + RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section', + RESOURCE_ITEMS: resourceItems, + MESSAGES_EMPTY_CLASS: report.importantMessages.length ? '' : 'empty-section', + IMPORTANT_MESSAGES: importantMessages, + QUOTES_EMPTY_CLASS: report.quotes.length ? '' : 'empty-section', + QUOTE_BLOCKS: quoteBlocks, + QA_EMPTY_CLASS: report.qa.length ? '' : 'empty-section', + QA_CARDS: qaCards, + HEAT_BARS: heatBars, + RANK_ITEMS: rankItems, + ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline), + CLOUD_TAGS: cloudTags, + GENERATED_AT: escapeHtml(metadata.generatedAt), + FOOTER_NOTE: escapeHtml(metadata.footerNote) + } + for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value) + return html +} + +const captureFullPage = async (htmlPath: string, pngPath: string): Promise => { + const reportWindow = new BrowserWindow({ + show: false, + width: 430, + height: 800, + frame: false, + backgroundColor: '#f3f5f7', + webPreferences: { sandbox: true } + }) + + try { + await reportWindow.loadFile(htmlPath) + await reportWindow.webContents.executeJavaScript(`Promise.all([ + document.fonts.ready, + ...Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => { + img.addEventListener('load', resolve, { once: true }); + img.addEventListener('error', resolve, { once: true }); + })) + ])`) + reportWindow.webContents.debugger.attach('1.3') + const metrics = (await reportWindow.webContents.debugger.sendCommand( + 'Page.getLayoutMetrics' + )) as { cssContentSize: { width: number; height: number } } + const width = Math.max(430, Math.ceil(metrics.cssContentSize.width)) + const height = Math.ceil(metrics.cssContentSize.height) + const screenshot = (await reportWindow.webContents.debugger.sendCommand( + 'Page.captureScreenshot', + { + format: 'png', + captureBeyondViewport: true, + fromSurface: true, + clip: { x: 0, y: 0, width, height, scale: 1 } + } + )) as { data: string } + const png = Buffer.from(screenshot.data, 'base64') + if (png.length < 1000) throw new Error('生成的日报图片为空') + await fs.writeFile(pngPath, png) + return `data:image/png;base64,${screenshot.data}` + } finally { + if (reportWindow.webContents.debugger.isAttached()) { + reportWindow.webContents.debugger.detach() + } + reportWindow.destroy() + } +} + +export const exportGroupReport = async ( + request: GroupReportExportRequest +): Promise => { + try { + const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录') + await fs.ensureDir(outputDir) + const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图` + const htmlPath = path.join(outputDir, `${baseName}.html`) + const pngPath = path.join(outputDir, `${baseName}.png`) + const html = await renderReportHtml(request) + await fs.writeFile(htmlPath, html, 'utf8') + const imageDataUrl = await captureFullPage(htmlPath, pngPath) + return { success: true, htmlPath, pngPath, imageDataUrl } + } catch (error) { + console.error('[GroupReport] export failed:', error) + return { success: false, error: error instanceof Error ? error.message : String(error) } + } +} diff --git a/src/main/image-decrypt-service.ts b/src/main/image-decrypt-service.ts index 55674b1..4582218 100644 --- a/src/main/image-decrypt-service.ts +++ b/src/main/image-decrypt-service.ts @@ -236,8 +236,6 @@ export class ImageDecryptService { version, 'file:', datPath, - 'xorKey:', - this.xorKey, 'aesKey present:', !!this.aesKey ) @@ -258,7 +256,6 @@ export class ImageDecryptService { return null } const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16) - console.log('[ImageDecrypt] AES key bytes:', key.toString('hex'), 'length:', key.length) decrypted = this.decryptDatV4(datPath, key) } diff --git a/src/main/index.ts b/src/main/index.ts index b603575..d67b8bc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,6 +11,8 @@ import { parseStickerMessageFromRow } from './message-parser' import { ImageDecryptService } from './image-decrypt-service' +import { exportGroupReport } from './group-report-service' +import { GroupReportExportRequest } from '../shared/group-report' let wechatDb: WechatDb | null = null let voiceService: VoiceService | null = null @@ -53,8 +55,8 @@ function normalizeMsgType(value: string | number | undefined): number { function createWindow(): void { // 创建浏览器窗口 const mainWindow = new BrowserWindow({ - width: 1200, - height: 670, + width: 1400, + height: 800, show: false, autoHideMenuBar: true, ...(process.platform === 'linux' ? { icon } : {}), @@ -102,9 +104,7 @@ app.whenReady().then(() => { ipcMain.handle('db:init', (_, key: string) => { try { const trimmedKey = String(key || '').trim() - console.log( - `db:init build=${BUILD_MARK} keyLength=${trimmedKey.length} keyPreview=${trimmedKey.slice(0, 6)}...${trimmedKey.slice(-6)}` - ) + console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`) wechatDb = new WechatDb(key) const wcdb4Client = wechatDb.getWcdb4Client() if (wcdb4Client) { @@ -175,6 +175,10 @@ app.whenReady().then(() => { const rawMessages = wechatDb.getUserMessages(userMd5, startTime, endTime) const groupMembers = wechatDb.getGroupMembersForChat(userMd5) const myAvatar = wechatDb.getMyAvatarUrl() + const myGroupNickname = + username?.endsWith('@chatroom') && wcdb4Client + ? wcdb4Client.getMyGroupNickname(username) + : undefined return rawMessages.map((msg: WechatMessage) => { const rawMsgType = parseInt(msg.messageType) @@ -187,13 +191,12 @@ app.whenReady().then(() => { let content = msg.msgContent let img = '' let name = '' - if (isMine && myAvatar) { - img = myAvatar - } else if (typeof msg.senderAvatar === 'string') { - img = msg.senderAvatar - } - if (typeof msg.senderNickname === 'string') { - name = msg.senderNickname + if (isMine) { + if (myAvatar) img = myAvatar + name = myGroupNickname || (typeof msg.senderNickname === 'string' ? msg.senderNickname : '') + } else { + if (typeof msg.senderAvatar === 'string') img = msg.senderAvatar + if (typeof msg.senderNickname === 'string') name = msg.senderNickname } // 检查内容是否以 wxid 开头并包含冒号 // 示例: wxid_xxxx:\nContent 或 wxid_xxxx:Content @@ -343,6 +346,19 @@ app.whenReady().then(() => { } }) + ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => { + return exportGroupReport(request) + }) + + ipcMain.handle('report:reveal', async (_, filePath: string) => { + try { + shell.showItemInFolder(filePath) + return { success: true } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }) + ipcMain.handle( 'db:getVoiceData', async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => { diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index 71b2365..e021b79 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -65,6 +65,7 @@ export class Wcdb4Client { private initialized = false private displayNameCache = new Map() private avatarCache = new Map() + private groupNicknameCache = new Map>() private cachedSessions: Wcdb4Session[] | null = null private wcdbInit: (() => number) | null = null @@ -96,6 +97,9 @@ export class Wcdb4Client { private wcdbGetGroupMembers: | ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number) | null = null + private wcdbGetGroupNicknames: + | ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number) + | null = null private wcdbOpenMessageCursor: | (( handle: number, @@ -234,6 +238,7 @@ export class Wcdb4Client { this.cachedSessions = null this.displayNameCache.clear() this.avatarCache.clear() + this.groupNicknameCache.clear() } getSessions(): Wcdb4Session[] { @@ -291,12 +296,7 @@ export class Wcdb4Client { } getMyAvatarUrl(): string | undefined { - const rawAccountName = path.basename(this.accountRoot) - const candidates = this.uniq([ - this.wxid, - rawAccountName, - Wcdb4Client.cleanAccountDirName(rawAccountName) - ]) + const candidates = this.getMyUsernameCandidates() this.hydrateAvatarUrls(candidates) for (const candidate of candidates) { @@ -307,6 +307,15 @@ export class Wcdb4Client { return undefined } + getMyGroupNickname(chatroomId: string): string | undefined { + const groupNicknames = this.getGroupNicknames(chatroomId) + for (const candidate of this.getMyUsernameCandidates()) { + const nickname = groupNicknames.get(candidate) + if (nickname) return nickname + } + return undefined + } + private getMessagesByCursor( username: string, startTime?: number, @@ -413,11 +422,12 @@ export class Wcdb4Client { if (!this.wcdbGetGroupMembers || !chatroomId) return [] try { + const groupNicknames = this.getGroupNicknames(chatroomId) const rows = this.callJson[]>((handle, outJson) => this.wcdbGetGroupMembers!(handle, chatroomId, outJson) ) - return (Array.isArray(rows) ? rows : []).map((row) => { + const members = (Array.isArray(rows) ? rows : []).map((row) => { const username = this.pickString(row, [ 'username', 'userName', @@ -425,10 +435,15 @@ export class Wcdb4Client { 'member_username', 'm_nsUsrName' ]) - const nickname = this.pickString(row, [ + const memberNickname = this.pickString(row, [ 'nickname', + 'nickName', 'displayName', 'display_name', + 'groupNickname', + 'group_nickname', + 'roomNickname', + 'room_nickname', 'remark', 'm_nsNickName' ]) @@ -440,21 +455,60 @@ export class Wcdb4Client { ]) if (username) { - if (nickname) this.displayNameCache.set(username, nickname) if (avatar) this.avatarCache.set(username, avatar) } return { m_nsUsrName: username, - nickname: nickname || username, + nickname: groupNicknames.get(username) || memberNickname, m_nsHeadImgUrl: avatar } }) + + const missingDisplayNames = members + .filter((member) => !member.nickname) + .map((member) => member.m_nsUsrName) + .filter(Boolean) + this.hydrateDisplayNames(missingDisplayNames) + return members.map((member) => ({ + ...member, + nickname: + member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName, + m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || '' + })) } catch { return [] } } + getGroupNicknames(chatroomId: string): Map { + const cached = this.groupNicknameCache.get(chatroomId) + if (cached) return cached + + const nicknames = new Map() + if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames + + try { + const rows = this.callJson | Record[]>( + (handle, outJson) => this.wcdbGetGroupNicknames!(handle, chatroomId, outJson) + ) + this.readStringMap(rows, [ + 'nickname', + 'nickName', + 'displayName', + 'display_name', + 'groupNickname', + 'group_nickname', + 'name' + ]).forEach((nickname, username) => nicknames.set(username, nickname)) + this.groupNicknameCache.set(chatroomId, nicknames) + } catch (error) { + console.warn(`[WCDB4] failed to get group nicknames for ${chatroomId}:`, error) + } + + return nicknames + } + async getVoiceData( sessionId: string, createTime: number, @@ -650,6 +704,14 @@ export class Wcdb4Client { this.wcdbGetGroupMembers = null } + try { + this.wcdbGetGroupNicknames = lib.func( + 'int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)' + ) as (handle: number, chatroomId: string, outJson: WcdbVoidOut) => number + } catch { + this.wcdbGetGroupNicknames = null + } + try { this.wcdbOpenMessageCursor = lib.func( 'int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)' @@ -1168,6 +1230,11 @@ export class Wcdb4Client { return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))) } + private getMyUsernameCandidates(): string[] { + const rawAccountName = path.basename(this.accountRoot) + return this.uniq([this.wxid, rawAccountName, Wcdb4Client.cleanAccountDirName(rawAccountName)]) + } + private normalizeTimestamp(input: number): number { if (!input || input <= 0) return 0 const normalized = input > 1e12 ? Math.floor(input / 1000) : Math.floor(input) diff --git a/src/main/wechat-db.ts b/src/main/wechat-db.ts index febfa1d..9e5ad7c 100644 --- a/src/main/wechat-db.ts +++ b/src/main/wechat-db.ts @@ -53,7 +53,7 @@ export class WechatDb { constructor(rawKey: string) { this.rawKey = rawKey - console.log(`Initializing WechatDb with key: ${rawKey}`) + console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`) if (this.tryOpenWechat4()) { this.chatDb = this.getChatDbNumber() diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 6efb173..d5f875c 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,5 +1,6 @@ import { ElectronAPI } from '@electron-toolkit/preload' import { Contact, Message } from '../shared/types' +import { GroupReportExportRequest, GroupReportExportResult } from '../shared/group-report' export type ParsedContent = | { type: 'text'; content: string } @@ -58,6 +59,8 @@ declare global { cdnUrl?: string, md5?: string ) => Promise<{ success: boolean; data?: string; error?: string }> + exportGroupReport: (request: GroupReportExportRequest) => Promise + revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }> } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index e625aad..a452a75 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,6 @@ import { contextBridge, ipcRenderer } from 'electron' import { electronAPI } from '@electron-toolkit/preload' +import { GroupReportExportRequest } from '../shared/group-report' // 渲染器的自定义 API const api = { @@ -19,7 +20,10 @@ const api = { ipcRenderer.invoke('db:parseMessage', content, messageType), getImage: (imageMd5?: string, imageDatNameOrThumb?: string | boolean, sessionId?: string) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId), - getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5) + getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5), + exportGroupReport: (request: GroupReportExportRequest) => + ipcRenderer.invoke('report:export', request), + revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath) } if (process.contextIsolated) { diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 6b45be3..fd5ab80 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -703,6 +703,79 @@ body { overflow: auto; } +.ai-settings-modal { + width: min(460px, 90vw); +} + +.ai-settings-modal h3 { + margin: 0 0 18px; +} + +.ai-filter-section { + margin-bottom: 16px; +} + +.ai-filter-label { + margin-bottom: 7px; + color: #333; + font-size: 14px; + font-weight: 600; +} + +.ai-date-options { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} + +.ai-date-options label { + display: flex; + min-width: 0; + align-items: center; + justify-content: center; + gap: 5px; + padding: 8px 6px; + border: 1px solid #d8dcdf; + border-radius: 6px; + background: #fff; + color: #4a5257; + cursor: pointer; + font-size: 13px; +} + +.ai-date-options label.selected { + border-color: #07c160; + background: #eefaf3; + color: #078f49; +} + +.ai-date-options input, +.ai-type-options input { + margin: 0; + accent-color: #07c160; +} + +.ai-type-options { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px 10px; + padding: 11px; + border: 1px solid #e1e4e6; + border-radius: 6px; + background: #f8f9fa; +} + +.ai-type-options label { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + color: #3f474c; + cursor: pointer; + font-size: 13px; + white-space: nowrap; +} + .image-preview-modal { background: transparent; box-shadow: none; diff --git a/src/renderer/src/components/ChatWindow.tsx b/src/renderer/src/components/ChatWindow.tsx index 1efd0c4..16330bd 100644 --- a/src/renderer/src/components/ChatWindow.tsx +++ b/src/renderer/src/components/ChatWindow.tsx @@ -1,9 +1,13 @@ import React, { useEffect, useRef, useState } from 'react' -import { toPng } from 'html-to-image' import { Message, Contact } from '../../../shared/types' import { VoicePlayer } from './VoicePlayer' import { RichMessageBubble } from './RichMessageBubble' import { ImageBubble } from './ImageBubble' +import { + buildGroupReportInput, + GROUP_REPORT_SYSTEM_PROMPT, + parseGroupDailyReport +} from '../utils/group-report' interface ChatWindowProps { contact: Contact | null @@ -13,27 +17,41 @@ interface ChatWindowProps { onRefreshData?: () => void } -const systemPrompt = `你是一个中文的群聊总结的助手,你可以为一个微信的群聊记录,提取并总结每个时间段大家在重点讨论的话题内容。 -请注意 不要回复总结除外的内容, 并且不要输出 群友的wxid 微信id 只需要显示群名称 -请帮我将给出的群聊内容总结成一个群聊报告,需要你生成7个最重要 最火爆的话题的总结(如果还有更多话题,可以在后面简单补充)。每个话题包含以下内容: -- 整体评价 - - 话题名(50字以内,带序号1️⃣2️⃣3️⃣,同时附带热度,以🔥数量表示) - - 参与者(不超过5个人,将重复的人名去重) - - 注意按时间排序,时间段(从日期几点到几点) - - 过程(50到200字左右) - - 评价(50字以下) - - 生成这7天内热度最高的话题,27日到2日一共7天 -需要生成27, 28, 29, 30, 31, 1, 2日的话题总结 - - 分割线: ------------ +type SummaryDateRange = 'today' | 'yesterday' | '7days' +type SummaryMessageType = 'text' | 'image' | 'sticker' | 'video' | 'voice' | 'share' | 'system' - 另外有以下要求: - 1. 每个话题结束使用------------分割 -2. 使用中文冒号 -3. 无需大标题 -4. 开始给出本群讨论风格的整体评价,例如活跃、太水、太黄、太暴力、话题不集中、无聊诸如此类 -5. 每个话题详细写出参与者 +const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [ + { value: 'today', label: '今天' }, + { value: 'yesterday', label: '昨日' }, + { value: '7days', label: '最近 7 天' } +] -最后总结下今日最活跃的前五个发言者` +const SUMMARY_TYPE_OPTIONS: { + value: SummaryMessageType + label: string + messageTypes: string[] +}[] = [ + { value: 'text', label: '文本', messageTypes: ['普通文本'] }, + { value: 'image', label: '图片', messageTypes: ['图片'] }, + { value: 'sticker', label: '表情包', messageTypes: ['表情包'] }, + { value: 'video', label: '视频', messageTypes: ['视频'] }, + { value: 'voice', label: '语音', messageTypes: ['语音'] }, + { value: 'share', label: '分享/引用', messageTypes: ['分享消息', '名片', '位置', '通话'] }, + { value: 'system', label: '系统消息', messageTypes: ['系统消息'] } +] + +const getSummaryDateRange = (range: SummaryDateRange): { startTime: number; endTime: number } => { + const now = new Date() + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000 + const endTime = Math.floor(Date.now() / 1000) + if (range === 'yesterday') { + return { startTime: startOfToday - 86400, endTime: startOfToday - 1 } + } + if (range === '7days') { + return { startTime: startOfToday - 6 * 86400, endTime } + } + return { startTime: startOfToday, endTime } +} const ChatWindow: React.FC = ({ contact, @@ -42,10 +60,12 @@ const ChatWindow: React.FC = ({ onRefresh, onRefreshData }) => { - const isGroupChat = contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom') + const isGroupChat = Boolean( + contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom') + ) const messagesEndRef = useRef(null) - const imageContainerRef = useRef(null) const [generatedImage, setGeneratedImage] = useState(null) + const [reportPaths, setReportPaths] = useState<{ htmlPath: string; pngPath: string } | null>(null) const [previewImage, setPreviewImage] = useState(null) const [imageScale, setImageScale] = useState(0.75) const [imageRotation, setImageRotation] = useState(0) @@ -62,8 +82,14 @@ const ChatWindow: React.FC = ({ () => localStorage.getItem('ai_base_url') || 'https://api.deepseek.com' ) const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-chat') + const [summaryDateRange, setSummaryDateRange] = useState('today') + const [summaryMessageTypes, setSummaryMessageTypes] = useState(['text']) const handleSaveSettings = (): void => { + if (!summaryMessageTypes.length) { + alert('请至少选择一种消息类型') + return + } localStorage.setItem('ai_api_key', apiKey) localStorage.setItem('ai_base_url', baseURL) localStorage.setItem('ai_model', model) @@ -71,6 +97,12 @@ const ChatWindow: React.FC = ({ AIChat() } + const toggleSummaryMessageType = (type: SummaryMessageType): void => { + setSummaryMessageTypes((current) => + current.includes(type) ? current.filter((item) => item !== type) : [...current, type] + ) + } + const scrollToBottom = (): void => { messagesEndRef.current?.scrollIntoView({ behavior: 'auto' }) } @@ -188,86 +220,52 @@ const ChatWindow: React.FC = ({ document.body.removeChild(link) } - const [summaryContent, setSummaryContent] = useState('') const [isLoading, setIsLoading] = useState(false) const AIChat = async (): Promise => { - if (!messages || messages.length === 0) { - alert('当前没有消息可供总结') + if (!contact) return + if (!summaryMessageTypes.length) { + alert('请至少选择一种消息类型') return } - const filteredMessages = messages - .filter((msg) => !'分享消息,图片,表情包,视频'.split(',').includes(msg.type)) - .map((msg) => { - return { - from: msg.from, - type: msg.type, - datetime: msg.datetime, - content: msg.content, - name: msg.name - } - }) - const recentMessages = filteredMessages - .map((msg) => { - return `${msg.datetime} ${msg.from}: ${msg.content}` - }) - .join('\n') - - const prompt = `请总结以下微信聊天记录的核心内容:\n\n${recentMessages}` - setIsLoading(true) try { - console.log('正在请求AI...') + const { startTime, endTime } = getSummaryDateRange(summaryDateRange) + const rangeMessages = await window.api.getMessages(contact.md5, startTime, endTime) + const allowedTypes = new Set( + SUMMARY_TYPE_OPTIONS.filter((option) => summaryMessageTypes.includes(option.value)).flatMap( + (option) => option.messageTypes + ) + ) + const reportMessages = rangeMessages.filter((message) => allowedTypes.has(message.type)) + if (!reportMessages.length) throw new Error('当前条件下没有可总结的消息') + + const input = buildGroupReportInput(reportMessages, contact, isGroupChat) + console.log('🚀 ~ AIChat ~ input:', input) + console.log('🚀 ~ AIChat ~ input.prompt:', input.prompt) const result = await window.api.aiChat( [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: prompt } + { role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT }, + { role: 'user', content: input.prompt } ], { apiKey, model, baseURL } ) - if (result.success && result.data) { - console.log('AI Summary:', result.data) - setSummaryContent(result.data) - - // 等待状态更新和渲染 - setTimeout(() => { - textToImage() - setIsLoading(false) // 图片生成开始后停止加载 - }, 500) - } else { - console.error('AI Error:', result.error) - alert(`AI 请求失败: ${result.error}`) - setIsLoading(false) + if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败') + const report = parseGroupDailyReport(result.data, input.topSpeakers, input.activeTimeline) + const exported = await window.api.exportGroupReport({ report, metadata: input.metadata }) + if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) { + throw new Error(exported.error || '日报文件生成失败') } + setGeneratedImage(exported.imageDataUrl) + setReportPaths({ htmlPath: exported.htmlPath, pngPath: exported.pngPath }) } catch (error) { console.error('AI Call Failed:', error) - alert('AI 请求发生错误') + alert(`AI 日报生成失败:${error instanceof Error ? error.message : String(error)}`) + } finally { setIsLoading(false) } } - - const textToImage = async (): Promise => { - if (imageContainerRef.current) { - try { - const dataUrl = await toPng(imageContainerRef.current, { - cacheBust: true, - backgroundColor: '#ffffff', - style: { - transform: 'scale(1)' - } - }) - if (dataUrl && dataUrl.length > 100) { - setGeneratedImage(dataUrl) - } else { - alert('生成图片为空') - } - } catch (err) { - console.error('Failed to generate image', err) - alert('生成图片失败: ' + (err instanceof Error ? err.message : String(err))) - } - } - } const handleCopyImage = async (): Promise => { if (!generatedImage) return const result = await window.api.copyImage(generatedImage) @@ -276,8 +274,6 @@ const ChatWindow: React.FC = ({ } } - const [displayLimit, setDisplayLimit] = useState(100) - const filteredMessages = React.useMemo(() => { return messages.filter((msg) => { const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '') @@ -290,8 +286,6 @@ const ChatWindow: React.FC = ({ }) }, [messages, contentFilter]) - const visibleMessages = filteredMessages.slice(0, displayLimit) - if (!contact) { return (
@@ -308,7 +302,7 @@ const ChatWindow: React.FC = ({
- {visibleMessages.map((msg) => { + {filteredMessages.map((msg) => { const isMine = msg.from === 'assistant' const displayName = isMine ? '我' @@ -368,22 +362,6 @@ const ChatWindow: React.FC = ({
) })} - {filteredMessages.length > displayLimit && ( -
- -
- )}
@@ -425,42 +403,14 @@ const ChatWindow: React.FC = ({ -
-
- {summaryContent} -
-
- {/* 加载模态框 */} {isLoading && (
🤖
-
正在生成 AI 总结...
+
正在生成群聊日报...
- 请稍候,生成后将自动转换为图片 + 正在分析记录、处理头像并生成 HTML 和长图
@@ -496,6 +446,14 @@ const ChatWindow: React.FC = ({ > 📋 复制图片 + {reportPaths && ( + + )}