diff --git a/package.json b/package.json index bdf821e..410cf3c 100644 --- a/package.json +++ b/package.json @@ -44,9 +44,10 @@ "build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux" }, "dependencies": { - "@koromix/koffi-win32-x64": "3.1.0", "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", + "@koromix/koffi-win32-x64": "3.1.0", + "@tanstack/react-virtual": "^3.14.6", "fs-extra": "^11.3.2", "fzstd": "^0.1.1", "koffi": "^3.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 595d1a3..636a825 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,7 @@ specifiers: '@electron-toolkit/utils': ^4.0.0 '@koromix/koffi-win32-x64': 3.1.0 '@rollup/rollup-darwin-arm64': ^4.62.2 + '@tanstack/react-virtual': ^3.14.6 '@types/fs-extra': ^11.0.4 '@types/node': ^22.19.1 '@types/react': ^19.2.7 @@ -40,6 +41,7 @@ dependencies: '@electron-toolkit/preload': 3.0.2_electron@43.1.0 '@electron-toolkit/utils': 4.0.0_electron@43.1.0 '@koromix/koffi-win32-x64': 3.1.0 + '@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq fs-extra: 11.3.2 fzstd: 0.1.1 koffi: 3.1.0 @@ -1124,6 +1126,21 @@ packages: defer-to-connect: 2.0.1 dev: true + /@tanstack/react-virtual/3.14.6_bokjwhiew3ov3ffvbmafuwoalq: + resolution: {integrity: sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + dependencies: + '@tanstack/virtual-core': 3.17.4 + react: 19.2.1 + react-dom: 19.2.1_react@19.2.1 + dev: false + + /@tanstack/virtual-core/3.17.4: + resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==} + dev: false + /@tootallnate/once/2.0.0: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} @@ -4037,7 +4054,6 @@ packages: dependencies: react: 19.2.1 scheduler: 0.27.0 - dev: true /react-is/16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -4051,7 +4067,6 @@ packages: /react/19.2.1: resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} engines: {node: '>=0.10.0'} - dev: true /read-binary-file-arch/1.0.6: resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} @@ -4234,7 +4249,6 @@ packages: /scheduler/0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - dev: true /semver/5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} diff --git a/src/main/image-decrypt-service.ts b/src/main/image-decrypt-service.ts index 44d51a1..8ed1aac 100644 --- a/src/main/image-decrypt-service.ts +++ b/src/main/image-decrypt-service.ts @@ -4,6 +4,11 @@ import crypto from 'crypto' import os from 'os' import { Wcdb4Client } from './wcdb4-client' +const imageDecryptDebugEnabled = process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1' +const imageDecryptLog = (...args: unknown[]): void => { + if (imageDecryptDebugEnabled) console.log(...args) +} + export class ImageDecryptService { private readonly defaultV1AesKey = 'cfcd208495d565ef' @@ -41,7 +46,7 @@ export class ImageDecryptService { ) if (!existsSync(accountRoot)) { - console.log('[ImageDecrypt] account root not found:', accountRoot) + imageDecryptLog('[ImageDecrypt] account root not found:', accountRoot) return null } @@ -61,7 +66,7 @@ export class ImageDecryptService { .sort((a, b) => b.mtime - a.mtime) if (accounts.length === 0) { - console.log('[ImageDecrypt] no accounts found') + imageDecryptLog('[ImageDecrypt] no accounts found') return null } @@ -83,7 +88,7 @@ export class ImageDecryptService { const normalizedMd5 = this.normalizeDatBase(md5 || '') const normalizedDatName = this.normalizeDatBase(imageDatName || '') - console.log('[ImageDecrypt] findImageFile:', { + imageDecryptLog('[ImageDecrypt] findImageFile:', { md5: normalizedMd5, imageDatName: normalizedDatName, accountDir, @@ -96,7 +101,7 @@ export class ImageDecryptService { if (fullPath && existsSync(fullPath)) { const selected = this.getPreferredDatVariantPath(fullPath, allowThumbnail) if (allowThumbnail || !this.isThumbnailName(basename(selected))) { - console.log('[ImageDecrypt] hardlink hit:', selected) + imageDecryptLog('[ImageDecrypt] hardlink hit:', selected) return selected } } @@ -105,7 +110,7 @@ export class ImageDecryptService { // 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/ const attachDir = join(accountDir, 'msg', 'attach') if (!existsSync(attachDir)) { - console.log('[ImageDecrypt] attach dir not found:', attachDir) + imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir) return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName) } @@ -120,7 +125,7 @@ export class ImageDecryptService { const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail) if (legacyHit) return legacyHit - console.log('[ImageDecrypt] findImageFile miss for:', searchKeys) + imageDecryptLog('[ImageDecrypt] findImageFile miss for:', searchKeys) return null } @@ -146,7 +151,7 @@ export class ImageDecryptService { ] const found = this.getLargestExistingPath(candidates, allowThumbnail) if (found) { - console.log('[ImageDecrypt] prefix path hit:', found) + imageDecryptLog('[ImageDecrypt] prefix path hit:', found) return found } } @@ -175,14 +180,14 @@ export class ImageDecryptService { allowThumbnail ) if (found) { - console.log('[ImageDecrypt] found at:', found) + imageDecryptLog('[ImageDecrypt] found at:', found) return found } } } } } catch (e) { - console.log('[ImageDecrypt]遍历目录失败:', e) + imageDecryptLog('[ImageDecrypt]遍历目录失败:', e) } return null @@ -229,7 +234,7 @@ export class ImageDecryptService { const fullPath = join(dir, entry) const stat = statSync(fullPath) if (stat.isFile() && variants.has(entry.toLowerCase())) { - console.log('[ImageDecrypt] legacy path hit:', fullPath) + imageDecryptLog('[ImageDecrypt] legacy path hit:', fullPath) return fullPath } } @@ -252,13 +257,13 @@ export class ImageDecryptService { */ decryptImage(datPath: string): Buffer | null { if (!existsSync(datPath)) { - console.log('[ImageDecrypt] file not found:', datPath) + imageDecryptLog('[ImageDecrypt] file not found:', datPath) return null } try { const version = this.getDatVersion(datPath) - console.log( + imageDecryptLog( '[ImageDecrypt] dat version:', version, 'file:', @@ -269,25 +274,25 @@ export class ImageDecryptService { let decrypted: Buffer if (version === 1) { - console.log('[ImageDecrypt] using V1 (default AES key)') + imageDecryptLog('[ImageDecrypt] using V1 (default AES key)') const key = Buffer.from(this.defaultV1AesKey, 'ascii') decrypted = this.decryptDatV4(datPath, key) } else if (version === 2) { - console.log('[ImageDecrypt] using V2 (user AES key)') + imageDecryptLog('[ImageDecrypt] using V2 (user AES key)') if (!this.aesKey) { - console.log('[ImageDecrypt] no AES key configured') + imageDecryptLog('[ImageDecrypt] no AES key configured') return null } const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16) decrypted = this.decryptDatV4(datPath, key) } else { - console.log('[ImageDecrypt] unsupported dat version:', version) + imageDecryptLog('[ImageDecrypt] unsupported dat version:', version) return null } return decrypted } catch (error) { - console.error('[ImageDecrypt] decrypt error:', error) + imageDecryptLog('[ImageDecrypt] decrypt error:', error) return null } } @@ -309,7 +314,7 @@ export class ImageDecryptService { const unwrapped = this.unwrapWxgf(decrypted) const ext = this.detectImageExtension(unwrapped) if (!ext) { - console.log('[ImageDecrypt] unknown image format') + imageDecryptLog('[ImageDecrypt] unknown image format') return null } @@ -346,7 +351,7 @@ export class ImageDecryptService { const data = this.decryptImageToBase64(candidate) if (data) return { data, filePath: candidate } } - console.warn('[ImageDecrypt] all variants failed:', this.uniq(candidates)) + imageDecryptLog('[ImageDecrypt] all variants failed:', this.uniq(candidates)) return null } diff --git a/src/main/services/agent-hub-service.ts b/src/main/services/agent-hub-service.ts index b044365..99bc48d 100644 --- a/src/main/services/agent-hub-service.ts +++ b/src/main/services/agent-hub-service.ts @@ -14,7 +14,15 @@ import type { } from '../../shared/agent-hub' import type { AppSettings } from './settings-store' import { generateAgentGroupReport } from './agent-group-report-service' -import { isReady, listMessages, listRecentChat, resolveMd5 } from './chat-service' +import { AIProviderService } from './ai-provider-service' +import { + getGroupSnapshot, + isReady, + listContacts, + listMessages, + listRecentChat, + resolveMd5 +} from './chat-service' const execFileAsync = promisify(execFile) const HEALTH_INTERVAL_MS = 5_000 @@ -39,8 +47,24 @@ interface GroupReportIntent { interface ContactChatIntent { contact: string limit: number + summarize: boolean } +interface GroupMemberChatIntent { + group: string + member: string + range: 'today' | 'yesterday' | '7days' + days: number + goal: string +} + +interface NaturalLanguageResult { + command?: string + reply?: string +} + +const agentAIProvider = new AIProviderService() + function resolveBundledBinary( resourceSegments: string[], executable: string, @@ -314,6 +338,7 @@ class AgentHubService { this.addLog('agent-hub', 'info', `收到微信消息 message_id=${messageId || 'unknown'}`) const reportIntent = this.matchGroupReportIntent(text) + if (reportIntent) { if (messageId) this.processedMessages.set(messageId, Date.now()) this.addLog( @@ -328,20 +353,39 @@ class AgentHubService { return this.sendHubJson(response, 202, { status: 'generating' }) } + const groupMemberIntent = this.matchGroupMemberChatIntent(text) + if (groupMemberIntent) { + if (messageId) this.processedMessages.set(messageId, Date.now()) + void this.summarizeGroupMemberChat(inbound, groupMemberIntent) + return this.sendHubJson(response, 202, { status: 'generating', mode: 'group-member-summary' }) + } + const contactChatIntent = this.matchContactChatIntent(text) if (contactChatIntent) { if (messageId) this.processedMessages.set(messageId, Date.now()) + if (contactChatIntent.summarize) { + void this.summarizeContactChat(inbound, contactChatIntent) + return this.sendHubJson(response, 202, { status: 'generating', mode: 'contact-summary' }) + } await this.replyContactChat(inbound, contactChatIntent) return this.sendHubJson(response, 200, { status: 'ok' }) } - const limit = this.matchRecentChatIntent(text) - if (limit === null) { + const recentChatLimit = this.matchRecentChatIntent(text) + if (recentChatLimit === null && text.trim()) { + if (messageId) this.processedMessages.set(messageId, Date.now()) + void this.handleNaturalLanguage(inbound, text) + return this.sendHubJson(response, 202, { + status: 'processing', + mode: 'natural-language' + }) + } + if (recentChatLimit === null) { this.addLog('agent-hub', 'info', '消息已忽略:没有匹配到支持的意图') return this.sendHubJson(response, 202, { status: 'ignored', reason: 'no matching intent' }) } if (!isReady()) return this.sendHubJson(response, 502, { error: 'upstream query failed' }) - const items = listRecentChat(limit) + const items = listRecentChat(recentChatLimit) const lines = items.map((item, index) => { const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim() return `${index + 1}. ${name}(${item.type === 'group' ? '群聊' : '联系人'})` @@ -360,6 +404,140 @@ class AgentHubService { this.sendHubJson(response, 200, { status: 'ok' }) } + private async handleNaturalLanguage(inbound: InboundMessage, text: string): Promise { + try { + const result = await this.resolveNaturalLanguage(text) + if (result.reply) { + await this.sendConnector(inbound, this.formatAIReply(result.reply)) + this.addLog('agent-hub', 'info', '自然语言回复已发送') + return + } + if (!result.command) { + await this.sendConnector(inbound, '暂时没有理解你的意思,可以换一种说法再试。') + return + } + + this.addLog('agent-hub', 'info', `自然语言已理解为:${result.command}`) + const reportIntent = this.matchGroupReportIntent(result.command) + if (reportIntent) { + await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch(() => undefined) + await this.generateAndSendReport(inbound, reportIntent) + return + } + + const groupMemberIntent = this.matchGroupMemberChatIntent(result.command) + if (groupMemberIntent) { + await this.summarizeGroupMemberChat(inbound, groupMemberIntent) + return + } + + const contactIntent = this.matchContactChatIntent(result.command) + if (contactIntent) { + if (contactIntent.summarize) await this.summarizeContactChat(inbound, contactIntent) + else await this.replyContactChat(inbound, contactIntent) + return + } + + const recentLimit = this.matchRecentChatIntent(result.command) + if (recentLimit !== null) { + await this.replyRecentChats(inbound, recentLimit) + return + } + await this.sendConnector(inbound, '暂时没有理解你的意思,可以换一种说法再试。') + } catch (error) { + this.addLog('agent-hub', 'error', `自然语言处理失败:${this.errorMessage(error)}`) + await this.sendConnector(inbound, `处理失败:${this.errorMessage(error)}`).catch( + () => undefined + ) + } + } + + private async replyRecentChats(inbound: InboundMessage, limit: number): Promise { + if (!isReady()) { + await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。') + return + } + const items = listRecentChat(limit) + const lines = items.map((item, index) => { + const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim() + return `${index + 1}. ${name}(${item.type === 'group' ? '群聊' : '联系人'})` + }) + await this.sendConnector( + inbound, + lines.length ? `最近 ${items.length} 个会话:\n${lines.join('\n')}` : '暂时没有找到最近会话。' + ) + this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`) + } + + private async resolveNaturalLanguage(text: string): Promise { + const result = await agentAIProvider.chat([ + { + role: 'system', + content: `你是 WechatExplorer 微信机器人的意图理解器。只能输出一行 JSON,不要 Markdown。 +支持的工具: +1. recent:查看最近会话,参数 limit 为 1-20。 +2. contact:查看我与某个联系人的最近聊天,参数 contact 和 limit。 +3. report:生成某个群的群聊总结图片,参数 group 和 range(today、yesterday、7days)。 +4. group_member:分析某个群里某位成员的发言,参数 group、member、range(today、yesterday、7days)、days(1-30)和 goal(保留用户希望总结、研究人物、提取观点等完整目标)。只要用户同时提到群聊和群成员,应优先使用 group_member,不能识别成 contact。 +5. chat:不需要工具的普通对话,reply 用简洁中文直接回答。 +输出格式:{"type":"recent|contact|report|group_member|chat","limit":5,"contact":"","group":"","member":"","range":"today","days":3,"goal":"","reply":""} +不要声称已经读取未调用的聊天记录,不要执行电脑控制、文件操作、付款或发送给其他联系人。` + }, + { role: 'user', content: text.slice(0, 1000) } + ]) + if (!result.success || !result.data) { + this.addLog('agent-hub', 'warn', `自然语言理解不可用:${result.error || 'AI 未返回内容'}`) + return {} + } + + try { + const json = result.data.match(/\{[\s\S]*\}/)?.[0] + if (!json) return {} + const parsed = JSON.parse(json) as Record + const limit = Math.max(1, Math.min(20, Number(parsed['limit']) || 5)) + if (parsed['type'] === 'recent') return { command: `最近${limit}条消息` } + if (parsed['type'] === 'contact' && String(parsed['contact'] || '').trim()) { + return { command: `我和${String(parsed['contact']).trim()}最近${limit}条聊了什么` } + } + if (parsed['type'] === 'report' && String(parsed['group'] || '').trim()) { + const range = + parsed['range'] === '7days' + ? '最近7天' + : parsed['range'] === 'yesterday' + ? '昨天' + : '今天' + return { command: `生成${String(parsed['group']).trim()}${range}的群聊总结图片` } + } + if ( + parsed['type'] === 'group_member' && + String(parsed['group'] || '').trim() && + String(parsed['member'] || '').trim() + ) { + const range = + parsed['range'] === 'yesterday' + ? '昨天' + : parsed['range'] === 'today' + ? '今天' + : '最近7天' + const group = String(parsed['group'] || '') + .trim() + .replace(/(?:群聊|群)+$/g, '') + const days = Math.max(1, Math.min(30, Number(parsed['days']) || 7)) + const goal = String(parsed['goal'] || '总结发言').trim() + return { + command: `看看${group}群里${String(parsed['member']).trim()}${range === '最近7天' ? `最近${days}天` : range}说了什么,${goal}` + } + } + if (parsed['type'] === 'chat') { + const reply = String(parsed['reply'] || '').trim() + return reply ? { reply: reply.slice(0, 1500) } : {} + } + } catch (error) { + this.addLog('agent-hub', 'warn', `自然语言结果解析失败:${this.errorMessage(error)}`) + } + return {} + } + private async replyContactChat( inbound: InboundMessage, intent: ContactChatIntent @@ -395,6 +573,180 @@ class AgentHubService { this.addLog('agent-hub', 'info', `联系人聊天回复已发送(${recent.length} 条)`) } + private async summarizeContactChat( + inbound: InboundMessage, + intent: ContactChatIntent + ): Promise { + try { + if (!isReady()) { + await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。') + return + } + const contact = resolveMd5(intent.contact) + if (!contact || contact.type !== 'user') { + await this.sendConnector(inbound, `没有找到联系人“${intent.contact}”。`) + return + } + + await this.sendConnector( + inbound, + `收到!正在整理和${contact.m_nsNickName}的近期聊天,请等待…` + ) + const endTime = Math.floor(Date.now() / 1000) + const startTime = endTime - 7 * 24 * 60 * 60 + const messages = listMessages(contact.md5, startTime, endTime, { limit: 300 }).slice(-300) + if (!messages.length) { + await this.sendConnector(inbound, `最近 7 天没有找到和${contact.m_nsNickName}的聊天记录。`) + return + } + + const transcript = messages + .map((message) => { + const speaker = message.isSender ? '我' : contact.m_nsNickName + return `[${message.datetime}] ${speaker}:${this.describeChatMessage(message.content, message.type)}` + }) + .join('\n') + const summary = await agentAIProvider.chat([ + { + role: 'system', + content: + '你是私人聊天记录总结助手。仅根据提供的记录总结,不编造。按日期或主题整理关键进展、双方观点、决定、待办和未解决问题;忽略无意义表情,保留重要数字与事实。使用适合微信阅读的简洁中文。' + }, + { + role: 'user', + content: `请总结我和“${contact.m_nsNickName}”最近 7 天聊了什么。\n\n聊天记录:\n${transcript}` + } + ]) + if (!summary.success || !summary.data?.trim()) { + throw new Error(summary.error || 'AI 未返回总结') + } + await this.sendConnector( + inbound, + this.formatAIReply( + `和${contact.m_nsNickName}最近聊天总结(近 7 天,共 ${messages.length} 条):\n\n${summary.data.trim().slice(0, 3500)}` + ) + ) + this.addLog('agent-hub', 'info', `联系人聊天总结已发送(${messages.length} 条)`) + } catch (error) { + this.addLog('agent-hub', 'error', `联系人聊天总结失败:${this.errorMessage(error)}`) + await this.sendConnector(inbound, `聊天总结失败:${this.errorMessage(error)}`).catch( + () => undefined + ) + } + } + + private async summarizeGroupMemberChat( + inbound: InboundMessage, + intent: GroupMemberChatIntent + ): Promise { + try { + if (!isReady()) { + await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。') + return + } + const group = this.resolveGroup(intent.group) + if (!group) { + await this.sendConnector(inbound, `没有找到群聊“${intent.group}”。`) + return + } + const snapshot = getGroupSnapshot(group.md5) + const memberQuery = intent.member.trim().toLowerCase() + const member = snapshot?.members.find((item) => + [item.groupNickname, item.wechatNickname, item.remark, item.nickname, item.wxid].some( + (name) => + String(name || '') + .trim() + .toLowerCase() === memberQuery + ) + ) + if (!member) { + await this.sendConnector( + inbound, + `没有在“${group.m_nsNickName}”找到成员“${intent.member}”。` + ) + return + } + + const displayName = + member.groupNickname || member.wechatNickname || member.remark || member.nickname + await this.sendConnector( + inbound, + `收到!正在整理${displayName}在“${group.m_nsNickName}”的近期发言,请等待…` + ) + const now = new Date() + const todayStart = Math.floor( + new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000 + ) + const startTime = + intent.range === 'today' + ? todayStart + : intent.range === 'yesterday' + ? todayStart - 24 * 60 * 60 + : Math.floor(Date.now() / 1000) - intent.days * 24 * 60 * 60 + const endTime = intent.range === 'yesterday' ? todayStart - 1 : Math.floor(Date.now() / 1000) + const aliases = new Set( + [member.groupNickname, member.wechatNickname, member.remark, member.nickname] + .map((name) => + String(name || '') + .trim() + .toLowerCase() + ) + .filter(Boolean) + ) + const messages = listMessages(group.md5, startTime, endTime, { limit: 10_000 }) + .filter( + (message) => + String(message.senderId || '').trim() === member.wxid || + aliases.has( + String(message.name || '') + .trim() + .toLowerCase() + ) + ) + .slice(-1000) + if (!messages.length) { + await this.sendConnector( + inbound, + `所选时间范围没有找到${displayName}在“${group.m_nsNickName}”的发言。` + ) + return + } + + const transcript = messages + .map( + (message) => + `[${message.datetime}] ${this.describeChatMessage(message.content, message.type)}` + ) + .join('\n') + const summary = await agentAIProvider.chat([ + { + role: 'system', + content: + '你是擅长分析微信群聊的助手。严格依据提供的发言完成用户的原始要求,输出结构和侧重点由内容决定,不套固定模板。可以归纳人物特征、兴趣、表达习惯和群内角色,但必须区分事实与推测,为推测说明依据和不确定性,不得编造。使用适合微信阅读的中文。' + }, + { + role: 'user', + content: `用户原始要求:${intent.goal}\n分析对象:“${displayName}”在群聊“${group.m_nsNickName}”中的发言。\n时间范围:${intent.range === 'today' ? '今天' : intent.range === 'yesterday' ? '昨天' : `最近 ${intent.days} 天`}。\n共提供 ${messages.length} 条发言。\n\n发言记录:\n${transcript}` + } + ]) + if (!summary.success || !summary.data?.trim()) { + throw new Error(summary.error || 'AI 未返回总结') + } + await this.sendConnector( + inbound, + this.formatAIReply( + `${displayName}在“${group.m_nsNickName}”的发言总结(共 ${messages.length} 条):\n\n${summary.data.trim().slice(0, 3500)}` + ) + ) + this.addLog('agent-hub', 'info', `群成员发言总结已发送(${messages.length} 条)`) + } catch (error) { + this.addLog('agent-hub', 'error', `群成员发言总结失败:${this.errorMessage(error)}`) + await this.sendConnector(inbound, `群成员发言总结失败:${this.errorMessage(error)}`).catch( + () => undefined + ) + } + } + private describeChatMessage(content: string, type: string): string { const normalized = String(content || '') .replace(/\s+/g, ' ') @@ -404,6 +756,15 @@ class AgentHubService { return `[${label}]` } + private formatAIReply(content: string): string { + return content + .replace(/\r\n?/g, '\n') + .replace(/[ \t]*•[ \t]*/g, '\n• ') + .replace(/[ \t]+(?=\d+[.、][ \t])/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() + } + private async generateAndSendReport( inbound: InboundMessage, intent: GroupReportIntent @@ -465,7 +826,62 @@ class AgentHubService { if (!contact) return null const limit = Number(normalized.match(/最近(\d{1,2})条/)?.[1] || 10) - return { contact, limit: Math.max(1, Math.min(20, limit)) } + const summarize = /(聊了什么|聊什么|说了什么|谈了什么|总结)/.test(normalized) + return { contact, limit: Math.max(1, Math.min(20, limit)), summarize } + } + + private matchGroupMemberChatIntent(text: string): GroupMemberChatIntent | null { + const normalized = text.trim().replace(/[,。!??::]/g, '') + const timePattern = '(今天|今日|昨天|昨日|最近\\d{1,2}天|近\\d{1,2}天|最近|近来|这几天)' + const actionPattern = '(?:说了什么|聊了什么|发言|说过什么|都聊什么|都说什么|干了什么)' + const patterns = [ + new RegExp( + `(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)[\\s,,]+(.+?)${timePattern}${actionPattern}` + ), + new RegExp( + `(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)(?:里|中的)(.+?)${timePattern}${actionPattern}` + ) + ] + for (const pattern of patterns) { + const match = normalized.match(pattern) + if (match?.[1]?.trim() && match[2]?.trim()) { + const range = /昨天|昨日/.test(match[3] || '') + ? 'yesterday' + : /今天|今日/.test(match[3] || '') + ? 'today' + : '7days' + const days = Math.max(1, Math.min(30, Number((match[3] || '').match(/\d{1,2}/)?.[0]) || 7)) + return { + group: match[1].trim(), + member: match[2].trim(), + range, + days, + goal: normalized + } + } + } + return null + } + + private resolveGroup(query: string): ReturnType { + const normalize = (value: string): string => + value + .trim() + .toLowerCase() + .replace(/[\s,,。!??::、“”'‘’]/g, '') + .replace(/(?:群聊|群)+$/g, '') + const target = normalize(query) + if (!target) return null + + const groups = listContacts().filter((contact) => contact.type === 'group') + return ( + groups.find((contact) => normalize(contact.m_nsNickName) === target) || + groups.find((contact) => { + const name = normalize(contact.m_nsNickName) + return name.includes(target) || target.includes(name) + }) || + null + ) } private matchGroupReportIntent(text: string): GroupReportIntent | null { diff --git a/src/main/services/bootstrap-cache.ts b/src/main/services/bootstrap-cache.ts index 5375379..c1f76c7 100644 --- a/src/main/services/bootstrap-cache.ts +++ b/src/main/services/bootstrap-cache.ts @@ -31,6 +31,10 @@ interface BootstrapCacheFile { const CACHE_VERSION = 1 const MAX_MESSAGE_BUCKETS = 24 const MAX_MESSAGES_PER_BUCKET = 1200 +const WRITE_DEBOUNCE_MS = 300 +const memoryCache = new Map() +const writeTimers = new Map() +const writeQueues = new Map>() function normalizeRoot(accountRoot?: string): string { return String(accountRoot || '').trim() @@ -43,19 +47,26 @@ function getCacheFile(accountRoot?: string): string { .update(`${process.platform}:${normalizedRoot}`) .digest('hex') .slice(0, 16) - return path.join(app.getPath('userData'), 'cache', 'bootstrap', `${process.platform}-${hash}.json`) + return path.join( + app.getPath('userData'), + 'cache', + 'bootstrap', + `${process.platform}-${hash}.json` + ) } function readCacheFile(accountRoot?: string): BootstrapCacheFile | null { const normalizedRoot = normalizeRoot(accountRoot) if (!normalizedRoot) return null const file = getCacheFile(normalizedRoot) + const cached = memoryCache.get(file) + if (cached) return cached try { if (!fs.existsSync(file)) return null const raw = fs.readJsonSync(file) as Partial if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null - return { + const result: BootstrapCacheFile = { version: CACHE_VERSION, platform: process.platform, accountRoot: normalizedRoot, @@ -64,6 +75,8 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null { contacts: Array.isArray(raw.contacts) ? raw.contacts : [], messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {} } + memoryCache.set(file, result) + return result } catch (error) { console.warn('[BootstrapCache] read failed:', error) return null @@ -71,28 +84,48 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null { } function writeCacheFile(cache: BootstrapCacheFile): void { - try { - const file = getCacheFile(cache.accountRoot) - fs.ensureDirSync(path.dirname(file)) - fs.writeJsonSync(file, cache, { spaces: 2 }) - } catch (error) { - console.warn('[BootstrapCache] write failed:', error) - } + const file = getCacheFile(cache.accountRoot) + memoryCache.set(file, cache) + const existingTimer = writeTimers.get(file) + if (existingTimer) clearTimeout(existingTimer) + writeTimers.set( + file, + setTimeout(() => { + writeTimers.delete(file) + const serialized = JSON.stringify(memoryCache.get(file) || cache) + const previous = writeQueues.get(file) || Promise.resolve() + const next = previous + .catch(() => undefined) + .then(async () => { + await fs.ensureDir(path.dirname(file)) + await fs.writeFile(file, serialized, 'utf8') + }) + .catch((error) => { + console.warn('[BootstrapCache] write failed:', error) + }) + .finally(() => { + if (writeQueues.get(file) === next) writeQueues.delete(file) + }) + writeQueues.set(file, next) + }, WRITE_DEBOUNCE_MS) + ) } function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null { const normalizedRoot = normalizeRoot(accountRoot) if (!normalizedRoot) return null - return ( - readCacheFile(normalizedRoot) || { - version: CACHE_VERSION, - platform: process.platform, - accountRoot: normalizedRoot, - updatedAt: Date.now(), - contacts: [], - messages: {} - } - ) + const existing = readCacheFile(normalizedRoot) + if (existing) return existing + const created: BootstrapCacheFile = { + version: CACHE_VERSION, + platform: process.platform, + accountRoot: normalizedRoot, + updatedAt: Date.now(), + contacts: [], + messages: {} + } + memoryCache.set(getCacheFile(normalizedRoot), created) + return created } function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string { @@ -144,7 +177,9 @@ export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact ) const nameByUsername = new Map( cache.contacts - .filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)) + .filter( + (contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact) + ) .map((contact) => [contact.m_nsUsrName, contact.m_nsNickName]) ) if (avatarByUsername.size === 0 && nameByUsername.size === 0) return contacts @@ -179,7 +214,9 @@ export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]): ) const nameByUsername = new Map( (cache.contacts || []) - .filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)) + .filter( + (contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact) + ) .map((contact) => [contact.m_nsUsrName, contact.m_nsNickName]) ) cache.contacts = contacts.map((contact) => ({ diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 25882c1..0cea872 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -330,7 +330,7 @@ function App(): React.ReactElement { usernames.length ? 55 : 90 ) let loadedCount = 0 - const chunkSize = 8 + const chunkSize = 32 for (let index = 0; index < usernames.length; index += chunkSize) { if (runId !== contactAvatarHydrationRunRef.current) return const chunk = usernames.slice(index, index + chunkSize) @@ -361,7 +361,7 @@ function App(): React.ReactElement { } catch (error) { console.warn('[Contacts] avatar hydrate failed:', error) } - await new Promise((resolve) => window.setTimeout(resolve, 150)) + await new Promise((resolve) => window.setTimeout(resolve, 50)) } if (usernames.length) onProgress?.('头像加载完成', 90) } diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 9957653..78be08d 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1143,6 +1143,20 @@ body { background: #f0f2ef; } +.virtual-message-list { + position: relative; + flex: 0 0 auto; + width: 100%; +} + +.virtual-message-group { + position: absolute; + top: 0; + left: 0; + display: flow-root; + width: 100%; +} + .wechat-message-row { margin-bottom: 14px; } @@ -7061,7 +7075,9 @@ body { border: 1.5px solid var(--wxex-border); border-radius: 12px; background: #fff; - transition: border-color 0.2s, background 0.2s; + transition: + border-color 0.2s, + background 0.2s; } .report-template-item.active { @@ -7114,7 +7130,10 @@ body { background: #fff; color: var(--wxex-text-primary, #1f2933); cursor: pointer; - transition: background 0.2s, color 0.2s, border-color 0.2s; + transition: + background 0.2s, + color 0.2s, + border-color 0.2s; } .report-template-preview-btn:hover { diff --git a/src/renderer/src/components/ChatWindow.tsx b/src/renderer/src/components/ChatWindow.tsx index 024728f..15af505 100644 --- a/src/renderer/src/components/ChatWindow.tsx +++ b/src/renderer/src/components/ChatWindow.tsx @@ -20,7 +20,6 @@ interface ChatWindowProps { isAiLoading?: boolean } -const MAX_RENDERED_MESSAGES = 600 const DATE_RANGE_LABELS: Record = { today: '今天', yesterday: '昨日', @@ -242,12 +241,6 @@ const ChatWindow: React.FC = ({ return typeMatch && contentMatch }) }, [messages, contentFilter]) - const hiddenMessageCount = Math.max(0, filteredMessages.length - MAX_RENDERED_MESSAGES) - const renderedMessages = React.useMemo( - () => filteredMessages.slice(-MAX_RENDERED_MESSAGES), - [filteredMessages] - ) - if (!contact) return const dateRangeLabel = getChatHeaderRangeLabel(dateRange) @@ -272,8 +265,8 @@ const ChatWindow: React.FC = ({ = ({ onImageClick={openImagePreview} /> buildMessageGroups(messages), [messages]) + // TanStack Virtual intentionally exposes mutable measurement methods. + // eslint-disable-next-line react-hooks/incompatible-library + const virtualizer = useVirtualizer({ + count: groups.length, + getScrollElement: () => listRef.current, + estimateSize: () => 96, + getItemKey: (index) => groups[index]?.id || index, + overscan: 8 + }) + const virtualItems = virtualizer.getVirtualItems() return (
@@ -40,16 +51,29 @@ export function MessageList({
)} - {groups.map((group) => ( - - ))} +
+ {virtualItems.map((virtualItem) => { + const group = groups[virtualItem.index] + if (!group) return null + return ( +
+ +
+ ) + })} +
) diff --git a/src/renderer/src/features/agent-hub/AgentHubWorkspace.tsx b/src/renderer/src/features/agent-hub/AgentHubWorkspace.tsx index 0e44312..21fa6a6 100644 --- a/src/renderer/src/features/agent-hub/AgentHubWorkspace.tsx +++ b/src/renderer/src/features/agent-hub/AgentHubWorkspace.tsx @@ -195,8 +195,9 @@ export function AgentHubWorkspace(): React.ReactElement {

微信数据助手

机器人通过本机 Agent Hub 调用 WechatExplorer,不向公网暴露数据库。

- 可以这样问 + 支持自然语言,可以这样问 “最近 5 条消息是谁?” + “帮我看看最近跟xx聊了些什么” “生成产品交流群今天的群聊总结图片”
    @@ -212,6 +213,10 @@ export function AgentHubWorkspace(): React.ReactElement { 消息重复保护 +
  • + + 使用已配置 AI 理解自然语言 +
  • 本地数据 API:{status.dataApi === 'online' ? '已连接' : '未连接'}