diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a257720..f0e88a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,7 @@ jobs: run: | export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/" echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR" - npx electron-builder --mac dmg zip --arm64 --publish always + npx electron-builder --mac dmg zip --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' - name: Inject minimumVersion into latest yml env: @@ -114,7 +114,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npx electron-builder --linux --publish always + npx electron-builder --linux --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' - name: Inject minimumVersion into latest yml env: @@ -167,7 +167,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npx electron-builder --win nsis --x64 --publish always '--config.artifactName=${productName}-${version}-x64-Setup.${ext}' + npx electron-builder --win nsis --x64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.artifactName=${productName}-${version}-x64-Setup.${ext}' - name: Inject minimumVersion into latest yml env: @@ -220,7 +220,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npx electron-builder --win nsis --arm64 --publish always '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}' + npx electron-builder --win nsis --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}' - name: Inject minimumVersion into latest yml env: diff --git a/electron/services/config.ts b/electron/services/config.ts index a1066f6..35a382d 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -85,6 +85,8 @@ interface ConfigSchema { aiInsightSilenceDays: number aiInsightAllowContext: boolean aiInsightAllowSocialContext: boolean + aiInsightFilterMode: 'whitelist' | 'blacklist' + aiInsightFilterList: string[] aiInsightWhitelistEnabled: boolean aiInsightWhitelist: string[] /** 活跃分析冷却时间(分钟),0 表示无冷却 */ @@ -202,6 +204,8 @@ export class ConfigService { aiInsightSilenceDays: 3, aiInsightAllowContext: false, aiInsightAllowSocialContext: false, + aiInsightFilterMode: 'whitelist', + aiInsightFilterList: [], aiInsightWhitelistEnabled: false, aiInsightWhitelist: [], aiInsightCooldownMinutes: 120, diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 3688afd..3f660d1 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -79,6 +79,9 @@ const MESSAGE_TYPE_MAP: Record = { 34: 2, // 语音 -> VOICE 43: 3, // 视频 -> VIDEO 49: 7, // 链接/文件 -> LINK (需要进一步判断) + 34359738417: 7, // 文件消息变体 -> LINK + 103079215153: 7, // 文件消息变体 -> LINK + 25769803825: 7, // 文件消息变体 -> LINK 47: 5, // 表情包 -> EMOJI 48: 8, // 位置 -> LOCATION 42: 27, // 名片 -> CONTACT @@ -86,9 +89,13 @@ const MESSAGE_TYPE_MAP: Record = { 10000: 80, // 系统消息 -> SYSTEM } +// 与 chatService 的资源消息识别保持一致,覆盖桌面微信里的多种文件消息 localType。 +const FILE_APP_LOCAL_TYPES = [49, 34359738417, 103079215153, 25769803825] as const +const FILE_APP_LOCAL_TYPE_SET = new Set(FILE_APP_LOCAL_TYPES) + export interface ExportOptions { format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' - contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' + contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file' dateRange?: { start: number; end: number } | null senderUsername?: string fileNameSuffix?: string @@ -137,11 +144,19 @@ interface ExportDisplayProfile { } type MessageCollectMode = 'full' | 'text-fast' | 'media-fast' -type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' +type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' | 'file' interface FileExportCandidate { sourcePath: string matchedBy: 'md5' | 'name' yearMonth?: string + preferredMonth?: boolean + mtimeMs: number + searchOrder: number +} +interface FileAttachmentSearchRoot { + accountDir: string + msgFileRoot?: string + fileStorageRoot?: string } export interface ExportProgress { @@ -501,6 +516,13 @@ class ExportService { .trim() } + private resolveFileAttachmentExtensionDir(msg: any, fileName: string): string { + const rawExt = String(msg?.fileExt || '').trim() || path.extname(String(fileName || '')) + const normalizedExt = rawExt.replace(/^\.+/, '').trim().toLowerCase() + const safeExt = this.sanitizeExportFileNamePart(normalizedExt).replace(/\s+/g, '_') + return safeExt || 'no-extension' + } + private normalizeFileNamingMode(value: unknown): 'classic' | 'date-range' { return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic' } @@ -947,7 +969,7 @@ class ExportService { private getMediaContentType(options: ExportOptions): MediaContentType | null { const value = options.contentType - if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji') { + if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji' || value === 'file') { return value } return null @@ -963,15 +985,117 @@ class ExportService { if (mediaContentType === 'image') return new Set([3]) if (mediaContentType === 'video') return new Set([43]) if (mediaContentType === 'emoji') return new Set([47]) + if (mediaContentType === 'file') return new Set(FILE_APP_LOCAL_TYPES) const selected = new Set() if (options.exportImages) selected.add(3) if (options.exportVoices) selected.add(34) if (options.exportVideos) selected.add(43) - if (options.exportFiles) selected.add(49) + if (options.exportFiles) { + for (const fileType of FILE_APP_LOCAL_TYPES) { + selected.add(fileType) + } + } return selected } + private isFileAppLocalType(localType: number): boolean { + return FILE_APP_LOCAL_TYPE_SET.has(localType) + } + + private isFileOnlyMediaFilter(targetMediaTypes: Set | null): boolean { + return Boolean( + targetMediaTypes && + targetMediaTypes.size === FILE_APP_LOCAL_TYPES.length && + FILE_APP_LOCAL_TYPES.every((fileType) => targetMediaTypes.has(fileType)) + ) + } + + private getFileAppMessageHints(message: Record | null | undefined): { + xmlType?: string + fileName?: string + fileSize?: number + fileExt?: string + fileMd5?: string + } { + const xmlType = String(message?.xmlType ?? message?.xml_type ?? '').trim() || undefined + const fileName = String(message?.fileName ?? message?.file_name ?? '').trim() || undefined + const fileExt = String(message?.fileExt ?? message?.file_ext ?? '').trim() || undefined + const fileSizeRaw = Number(message?.fileSize ?? message?.file_size ?? message?.total_len ?? message?.totalLen ?? message?.totallen ?? 0) + const fileSize = Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : undefined + const fileMd5Raw = String(message?.fileMd5 ?? message?.file_md5 ?? '').trim() + const fileMd5 = /^[a-f0-9]{32}$/i.test(fileMd5Raw) ? fileMd5Raw.toLowerCase() : undefined + return { xmlType, fileName, fileSize, fileExt, fileMd5 } + } + + private hasFileAppMessageHints(message: Record | null | undefined): boolean { + const hints = this.getFileAppMessageHints(message) + if (hints.xmlType) return hints.xmlType === '6' + return Boolean(hints.fileName || hints.fileExt || hints.fileMd5 || hints.fileSize) + } + + private isFileAppMessage(msg: { + localType?: unknown + xmlType?: unknown + xml_type?: unknown + content?: unknown + fileName?: unknown + file_name?: unknown + fileSize?: unknown + file_size?: unknown + fileExt?: unknown + file_ext?: unknown + fileMd5?: unknown + file_md5?: unknown + }): boolean { + const { xmlType, fileName, fileExt, fileMd5, fileSize } = this.getFileAppMessageHints(msg as Record) + if (xmlType) return xmlType === '6' + if (fileName || fileExt || fileMd5 || fileSize) return true + + const normalized = this.normalizeAppMessageContent(String(msg?.content || '')) + if (!normalized || (!normalized.includes(''))) { + return false + } + return this.extractAppMessageType(normalized) === '6' + } + + private extractFileAppMessageMeta(content: string): { + xmlType?: string + fileName?: string + fileSize?: number + fileExt?: string + fileMd5?: string + } | null { + const normalized = this.normalizeAppMessageContent(content || '') + if (!normalized || (!normalized.includes(''))) { + return null + } + + const xmlType = this.extractAppMessageType(normalized) + if (!xmlType) return null + + const rawFileName = this.extractXmlValue(normalized, 'filename') || this.extractXmlValue(normalized, 'title') + const rawFileExt = this.extractXmlValue(normalized, 'fileext') + const rawFileSize = + this.extractXmlValue(normalized, 'totallen') || + this.extractXmlValue(normalized, 'datasize') || + this.extractXmlValue(normalized, 'filesize') + const rawFileMd5 = + this.extractXmlValue(normalized, 'md5') || + this.extractXmlAttribute(normalized, 'appattach', 'md5') || + this.extractLooseHexMd5(normalized) + const fileSize = Number.parseInt(rawFileSize, 10) + const fileMd5 = String(rawFileMd5 || '').trim() + + return { + xmlType, + fileName: this.decodeHtmlEntities(rawFileName).trim() || undefined, + fileSize: Number.isFinite(fileSize) && fileSize > 0 ? fileSize : undefined, + fileExt: this.decodeHtmlEntities(rawFileExt).trim() || undefined, + fileMd5: /^[a-f0-9]{32}$/i.test(fileMd5) ? fileMd5.toLowerCase() : undefined + } + } + private resolveCollectMode(options: ExportOptions): MessageCollectMode { if (this.isMediaContentBatchExport(options)) { return 'media-fast' @@ -1020,12 +1144,17 @@ class ExportService { return true } - private shouldDecodeMessageContentInMediaMode(localType: number, targetMediaTypes: Set | null): boolean { - if (!targetMediaTypes || !targetMediaTypes.has(localType)) return false + private shouldDecodeMessageContentInMediaMode( + localType: number, + targetMediaTypes: Set | null, + options?: { allowFileProbe?: boolean } + ): boolean { + const allowFileProbe = options?.allowFileProbe === true + if (!targetMediaTypes || (!targetMediaTypes.has(localType) && !allowFileProbe)) return false // 语音导出仅需要 localId 读取音频数据,不依赖 XML 内容 if (localType === 34) return false - // 图片/视频/表情可能需要从 XML 提取 md5/datName/cdnUrl - if (localType === 3 || localType === 43 || localType === 47) return true + // 图片/视频/表情/文件可能需要从 XML 提取 md5/datName/附件信息 + if (localType === 3 || localType === 43 || localType === 47 || this.isFileAppLocalType(localType) || allowFileProbe) return true return false } @@ -3628,7 +3757,7 @@ class ExportService { ) } - if ((localType === 49 || localType === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') { + if (options.exportFiles && this.isFileAppMessage(msg)) { return this.exportFileAttachment( msg, mediaRootDir, @@ -4183,33 +4312,104 @@ class ExportService { return this.normalizeVideoFileToken(this.extractVideoMd5(content || '')) } - private resolveFileAttachmentRoots(): string[] { + private isFileAttachmentAccountDir(dirPath: string): boolean { + if (!dirPath) return false + return fs.existsSync(path.join(dirPath, 'db_storage')) || + fs.existsSync(path.join(dirPath, 'msg', 'file')) || + fs.existsSync(path.join(dirPath, 'FileStorage', 'File')) || + fs.existsSync(path.join(dirPath, 'FileStorage', 'Image')) || + fs.existsSync(path.join(dirPath, 'FileStorage', 'Image2')) + } + + private resolveAccountDirForFileExport(basePath: string, wxid: string): string | null { + const cleanedWxid = this.cleanAccountDirName(wxid) + if (!basePath || !cleanedWxid) return null + + const normalized = path.resolve(basePath.replace(/[\\/]+$/, '')) + const parentDir = path.dirname(normalized) + const dbStorageParent = path.basename(normalized).toLowerCase() === 'db_storage' + ? path.dirname(normalized) + : '' + const fileInsideDbStorageParent = path.basename(parentDir).toLowerCase() === 'db_storage' + ? path.dirname(parentDir) + : '' + const candidateBases = Array.from(new Set([ + normalized, + parentDir, + path.join(normalized, 'WeChat Files'), + path.join(parentDir, 'WeChat Files'), + dbStorageParent, + fileInsideDbStorageParent + ].filter(Boolean))) + + const lowerWxid = cleanedWxid.toLowerCase() + const tryResolveBase = (candidateBase: string): string | null => { + if (!candidateBase || !fs.existsSync(candidateBase)) return null + if (this.isFileAttachmentAccountDir(candidateBase)) return candidateBase + + const direct = path.join(candidateBase, cleanedWxid) + if (this.isFileAttachmentAccountDir(direct)) return direct + + try { + const entries = fs.readdirSync(candidateBase, { withFileTypes: true }) + for (const entry of entries) { + if (!entry.isDirectory()) continue + const lowerEntry = entry.name.toLowerCase() + if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) { + const entryPath = path.join(candidateBase, entry.name) + if (this.isFileAttachmentAccountDir(entryPath)) { + return entryPath + } + } + } + } catch { + return null + } + + return null + } + + for (const candidateBase of candidateBases) { + const resolved = tryResolveBase(candidateBase) + if (resolved) return resolved + } + + return null + } + + private resolveFileAttachmentSearchRoots(): FileAttachmentSearchRoot[] { const dbPath = String(this.configService.get('dbPath') || '').trim() const rawWxid = String(this.configService.get('myWxid') || '').trim() const cleanedWxid = this.cleanAccountDirName(rawWxid) if (!dbPath) return [] - const normalized = dbPath.replace(/[\\/]+$/, '') - const roots = new Set() - const tryAddRoot = (candidate: string) => { - const fileRoot = path.join(candidate, 'msg', 'file') - if (fs.existsSync(fileRoot)) { - roots.add(fileRoot) + const normalized = path.resolve(dbPath.replace(/[\\/]+$/, '')) + const accountDirs = new Set() + const maybeAddAccountDir = (candidate: string | null | undefined) => { + if (!candidate) return + const resolved = path.resolve(candidate) + if (this.isFileAttachmentAccountDir(resolved)) { + accountDirs.add(resolved) } } - tryAddRoot(normalized) - if (rawWxid) tryAddRoot(path.join(normalized, rawWxid)) - if (cleanedWxid && cleanedWxid !== rawWxid) tryAddRoot(path.join(normalized, cleanedWxid)) + maybeAddAccountDir(normalized) + maybeAddAccountDir(path.dirname(normalized)) - const dbStoragePath = - this.resolveDbStoragePathForExport(normalized, cleanedWxid) || - this.resolveDbStoragePathForExport(normalized, rawWxid) - if (dbStoragePath) { - tryAddRoot(path.dirname(dbStoragePath)) + const wxidCandidates = Array.from(new Set([cleanedWxid, rawWxid].filter(Boolean))) + for (const wxid of wxidCandidates) { + maybeAddAccountDir(this.resolveAccountDirForFileExport(normalized, wxid)) } - return Array.from(roots) + return Array.from(accountDirs).map((accountDir) => { + const msgFileRoot = path.join(accountDir, 'msg', 'file') + const fileStorageRoot = path.join(accountDir, 'FileStorage', 'File') + return { + accountDir, + msgFileRoot: fs.existsSync(msgFileRoot) ? msgFileRoot : undefined, + fileStorageRoot: fs.existsSync(fileStorageRoot) ? fileStorageRoot : undefined + } + }).filter((root) => Boolean(root.msgFileRoot || root.fileStorageRoot)) } private buildPreferredFileYearMonths(createTime?: unknown): string[] { @@ -4241,52 +4441,147 @@ class ExportService { } } - private async resolveFileAttachmentCandidates(msg: any): Promise { - const fileName = String(msg?.fileName || '').trim() - if (!fileName) return [] + private collectFileStorageCandidatesByName(rootDir: string, fileName: string, maxDepth = 3): string[] { + const normalizedName = String(fileName || '').trim().toLowerCase() + if (!rootDir || !normalizedName) return [] - const roots = this.resolveFileAttachmentRoots() - if (roots.length === 0) return [] + const matches: string[] = [] + const stack: Array<{ dir: string; depth: number }> = [{ dir: rootDir, depth: 0 }] - const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase() - const preferredMonths = this.buildPreferredFileYearMonths(msg?.createTime) - const candidates: FileExportCandidate[] = [] - const seen = new Set() - - for (const root of roots) { - let monthDirs: string[] = [] + while (stack.length > 0) { + const current = stack.pop()! + let entries: fs.Dirent[] try { - monthDirs = fs.readdirSync(root) - .filter(entry => /^\d{4}-\d{2}$/.test(entry) && fs.existsSync(path.join(root, entry))) - .sort() + entries = fs.readdirSync(current.dir, { withFileTypes: true }) } catch { continue } - const orderedMonths = Array.from(new Set([ - ...preferredMonths, - ...monthDirs.slice().reverse() - ])) - - for (const month of orderedMonths) { - const sourcePath = path.join(root, month, fileName) - if (!fs.existsSync(sourcePath)) continue - const resolvedPath = path.resolve(sourcePath) - if (seen.has(resolvedPath)) continue - seen.add(resolvedPath) - - if (normalizedMd5) { - const ok = await this.verifyFileHash(resolvedPath, normalizedMd5) - if (ok) { - candidates.unshift({ sourcePath: resolvedPath, matchedBy: 'md5', yearMonth: month }) - continue - } + for (const entry of entries) { + const entryPath = path.join(current.dir, entry.name) + if (entry.isFile() && entry.name.toLowerCase() === normalizedName) { + matches.push(entryPath) + continue + } + if (entry.isDirectory() && current.depth < maxDepth) { + stack.push({ dir: entryPath, depth: current.depth + 1 }) } - - candidates.push({ sourcePath: resolvedPath, matchedBy: 'name', yearMonth: month }) } } + return matches + } + + private getFileAttachmentLogContext(msg: any): Record { + return { + localId: msg?.localId, + createTime: msg?.createTime, + localType: msg?.localType, + xmlType: msg?.xmlType, + fileName: msg?.fileName, + fileMd5: msg?.fileMd5 + } + } + + private logFileAttachmentEvent( + level: 'warn' | 'error', + action: string, + msg: any, + extra: Record = {} + ): void { + const logger = level === 'error' ? console.error : console.warn + logger(`[Export][File] ${action}`, { + ...this.getFileAttachmentLogContext(msg), + ...extra + }) + } + + private recordFileAttachmentMiss(msg: any, action: string, extra: Record = {}): void { + this.logFileAttachmentEvent('warn', action, msg, extra) + this.noteMediaTelemetry({ cacheMissFiles: 1 }) + } + + private async resolveFileAttachmentCandidates(msg: any): Promise { + const fileName = String(msg?.fileName || '').trim() + if (!fileName) return [] + + const roots = this.resolveFileAttachmentSearchRoots() + if (roots.length === 0) return [] + + const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase() + const preferredMonths = new Set(this.buildPreferredFileYearMonths(msg?.createTime)) + const candidates: FileExportCandidate[] = [] + const seen = new Set() + let searchOrder = 0 + + const appendCandidate = async (sourcePath: string, yearMonth?: string) => { + if (!sourcePath || !fs.existsSync(sourcePath)) return + + const resolvedPath = path.resolve(sourcePath) + if (seen.has(resolvedPath)) return + + let stat: fs.Stats + try { + stat = await fs.promises.stat(resolvedPath) + } catch { + return + } + if (!stat.isFile()) return + + seen.add(resolvedPath) + const matchedBy = normalizedMd5 && await this.verifyFileHash(resolvedPath, normalizedMd5) ? 'md5' : 'name' + candidates.push({ + sourcePath: resolvedPath, + matchedBy, + yearMonth, + preferredMonth: Boolean(yearMonth && preferredMonths.has(yearMonth)), + mtimeMs: Number.isFinite(stat.mtimeMs) ? stat.mtimeMs : 0, + searchOrder: searchOrder++ + }) + } + + for (const root of roots) { + if (root.msgFileRoot) { + for (const month of preferredMonths) { + await appendCandidate(path.join(root.msgFileRoot, month, fileName), month) + } + + let monthDirs: string[] = [] + try { + monthDirs = fs.readdirSync(root.msgFileRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && /^\d{4}-\d{2}$/.test(entry.name) && !preferredMonths.has(entry.name)) + .map(entry => entry.name) + .sort() + } catch { + monthDirs = [] + } + + for (const month of monthDirs) { + await appendCandidate(path.join(root.msgFileRoot, month, fileName), month) + } + await appendCandidate(path.join(root.msgFileRoot, fileName)) + } + + if (root.fileStorageRoot) { + for (const candidatePath of this.collectFileStorageCandidatesByName(root.fileStorageRoot, fileName, 3)) { + await appendCandidate(candidatePath) + } + } + } + + candidates.sort((left, right) => { + if (left.matchedBy !== right.matchedBy) { + return left.matchedBy === 'md5' ? -1 : 1 + } + if (left.preferredMonth !== right.preferredMonth) { + return left.preferredMonth ? -1 : 1 + } + if (left.mtimeMs !== right.mtimeMs) { + return right.mtimeMs - left.mtimeMs + } + return left.searchOrder - right.searchOrder + }) + return candidates } @@ -4301,14 +4596,20 @@ class ExportService { const fileNameRaw = String(msg?.fileName || '').trim() if (!fileNameRaw) return null - const filesDir = path.join(mediaRootDir, mediaRelativePrefix, 'files') - if (!dirCache?.has(filesDir)) { - await fs.promises.mkdir(filesDir, { recursive: true }) - dirCache?.add(filesDir) + const fileExtDir = this.resolveFileAttachmentExtensionDir(msg, fileNameRaw) + const fileDir = path.join(mediaRootDir, mediaRelativePrefix, 'file', fileExtDir) + if (!dirCache?.has(fileDir)) { + await fs.promises.mkdir(fileDir, { recursive: true }) + dirCache?.add(fileDir) } const candidates = await this.resolveFileAttachmentCandidates(msg) - if (candidates.length === 0) return null + if (candidates.length === 0) { + this.recordFileAttachmentMiss(msg, '附件候选未命中', { + searchRoots: this.resolveFileAttachmentSearchRoots().map(root => root.accountDir) + }) + return null + } const maxBytes = Number.isFinite(maxFileSizeMb) ? Math.max(0, Math.floor(Number(maxFileSizeMb) * 1024 * 1024)) @@ -4316,28 +4617,54 @@ class ExportService { const selected = candidates[0] const stat = await fs.promises.stat(selected.sourcePath) - if (!stat.isFile()) return null - if (maxBytes > 0 && stat.size > maxBytes) return null + if (!stat.isFile()) { + this.recordFileAttachmentMiss(msg, '附件候选不是普通文件', { + sourcePath: selected.sourcePath + }) + return null + } + if (maxBytes > 0 && stat.size > maxBytes) { + this.recordFileAttachmentMiss(msg, '附件超过大小限制', { + sourcePath: selected.sourcePath, + size: stat.size, + maxBytes + }) + return null + } const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase() if (normalizedMd5 && selected.matchedBy !== 'md5') { - const verified = await this.verifyFileHash(selected.sourcePath, normalizedMd5) - if (!verified) return null + this.recordFileAttachmentMiss(msg, '附件哈希校验失败', { + sourcePath: selected.sourcePath, + expectedMd5: normalizedMd5 + }) + return null } const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file' const messageId = String(msg?.localId || Date.now()) const destFileName = `${messageId}_${safeBaseName}` - const destPath = path.join(filesDir, destFileName) + const destPath = path.join(fileDir, destFileName) const copied = await this.copyFileOptimized(selected.sourcePath, destPath) - if (!copied.success) return null + if (!copied.success) { + this.recordFileAttachmentMiss(msg, '附件复制失败', { + sourcePath: selected.sourcePath, + destPath, + code: copied.code + }) + return null + } this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size }) return { - relativePath: path.posix.join(mediaRelativePrefix, 'files', destFileName), + relativePath: path.posix.join(mediaRelativePrefix, 'file', fileExtDir, destFileName), kind: 'file' } - } catch { + } catch (error) { + this.logFileAttachmentEvent('error', '附件导出异常', msg, { + error: error instanceof Error ? error.message : String(error || 'unknown') + }) + this.noteMediaTelemetry({ cacheMissFiles: 1 }) return null } } @@ -4420,6 +4747,38 @@ class ExportService { return { exportMediaEnabled, mediaRootDir: outputDir, mediaRelativePrefix } } + private collectMediaMessagesForExport(messages: any[], options: ExportOptions): any[] { + if (!this.isMediaExportEnabled(options)) return [] + + return messages.filter((msg) => { + const localType = Number(msg?.localType || 0) + return (localType === 3 && options.exportImages) || + (localType === 47 && options.exportEmojis) || + (localType === 43 && options.exportVideos) || + (localType === 34 && options.exportVoices) || + (options.exportFiles === true && this.isFileAppMessage(msg)) + }) + } + + private getMediaDoneFilesCount(): number { + return this.mediaExportTelemetry?.doneFiles ?? 0 + } + + private buildFileOnlyExportFailure( + options: ExportOptions, + mediaMessages: any[], + beforeDoneFiles: number + ): { success: boolean; error?: string } | null { + if (options.contentType !== 'file') return null + if (!mediaMessages.some(msg => this.isFileAppMessage(msg))) return null + if (this.getMediaDoneFilesCount() > beforeDoneFiles) return null + + return { + success: false, + error: '检测到文件消息,但未找到可导出的源文件,请检查数据库路径或文件存储目录配置' + } + } + /** * 下载文件 */ @@ -4485,6 +4844,7 @@ class ExportService { const mediaTypeFilter = collectMode === 'media-fast' && targetMediaTypes && targetMediaTypes.size > 0 ? targetMediaTypes : null + const fileOnlyMediaFilter = this.isFileOnlyMediaFilter(mediaTypeFilter) // 修复时间范围:0 表示不限制,而不是时间戳 0 const beginTime = dateRange?.start || 0 @@ -4545,12 +4905,14 @@ class ExportService { const localType = this.getIntFromRow(row, [ 'local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type' ], 1) - if (mediaTypeFilter && !mediaTypeFilter.has(localType)) { + const rowFileHints = this.getFileAppMessageHints(row) + const allowFileProbe = fileOnlyMediaFilter && this.hasFileAppMessageHints(row) + if (mediaTypeFilter && !mediaTypeFilter.has(localType) && !allowFileProbe) { continue } const shouldDecodeContent = collectMode === 'full' || (collectMode === 'text-fast' && this.shouldDecodeMessageContentInFastMode(localType)) - || (collectMode === 'media-fast' && this.shouldDecodeMessageContentInMediaMode(localType, mediaTypeFilter)) + || (collectMode === 'media-fast' && this.shouldDecodeMessageContentInMediaMode(localType, mediaTypeFilter, { allowFileProbe })) const content = shouldDecodeContent ? this.decodeMessageContent(row.message_content, row.compress_content) : '' @@ -4619,6 +4981,11 @@ class ExportService { let locationLabel: string | undefined let chatRecordList: any[] | undefined let emojiCaption: string | undefined + let xmlType: string | undefined + let fileName: string | undefined + let fileSize: number | undefined + let fileExt: string | undefined + let fileMd5: string | undefined if (localType === 48 && content) { const locationMeta = this.extractLocationMeta(content, localType) @@ -4649,6 +5016,22 @@ class ExportService { imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined videoMd5 = this.extractVideoFileNameFromRow(row, content) + xmlType = rowFileHints.xmlType + fileName = rowFileHints.fileName + fileExt = rowFileHints.fileExt + fileSize = rowFileHints.fileSize + fileMd5 = rowFileHints.fileMd5 + + if (content && (this.isFileAppLocalType(localType) || allowFileProbe || this.hasFileAppMessageHints({ xmlType, fileName, fileSize, fileExt, fileMd5 }))) { + const fileMeta = this.extractFileAppMessageMeta(content) + if (fileMeta) { + xmlType = fileMeta.xmlType || xmlType + fileName = fileMeta.fileName || fileName + fileSize = fileMeta.fileSize || fileSize + fileExt = fileMeta.fileExt || fileExt + fileMd5 = fileMeta.fileMd5 || fileMd5 + } + } if (localType === 3 && content) { // 图片消息 @@ -4667,6 +5050,10 @@ class ExportService { } } + if (fileOnlyMediaFilter && !this.isFileAppMessage({ localType, xmlType, content, fileName, fileExt, fileMd5, fileSize })) { + continue + } + rows.push({ localId, serverId, @@ -4682,6 +5069,11 @@ class ExportService { emojiMd5, emojiCaption, videoMd5, + xmlType, + fileName, + fileSize, + fileExt, + fileMd5, locationLat, locationLng, locationPoiname, @@ -4746,7 +5138,12 @@ class ExportService { targetMediaTypes: Set, control?: ExportTaskControl ): Promise { + const fileOnlyMediaFilter = this.isFileOnlyMediaFilter(targetMediaTypes) const needsBackfill = rows.filter((msg) => { + const isFileCandidate = this.isFileAppLocalType(Number(msg.localType || 0)) || (fileOnlyMediaFilter && this.hasFileAppMessageHints(msg)) + if (isFileCandidate) { + return !msg.xmlType || !msg.fileName || !msg.fileMd5 || !msg.fileSize || !msg.fileExt + } if (!targetMediaTypes.has(msg.localType)) return false if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName if (msg.localType === 47) return !msg.emojiMd5 @@ -4803,6 +5200,24 @@ class ExportService { if (msg.localType === 43) { const videoMd5 = this.extractVideoFileNameFromRow(row, content) if (videoMd5) msg.videoMd5 = videoMd5 + return + } + + if (this.isFileAppLocalType(Number(msg.localType || 0)) || this.hasFileAppMessageHints(msg)) { + const rowFileHints = this.getFileAppMessageHints(row) + const fileMeta = this.extractFileAppMessageMeta(content) + const mergedFileMeta = { + xmlType: fileMeta?.xmlType || rowFileHints.xmlType, + fileName: fileMeta?.fileName || rowFileHints.fileName, + fileSize: fileMeta?.fileSize || rowFileHints.fileSize, + fileExt: fileMeta?.fileExt || rowFileHints.fileExt, + fileMd5: fileMeta?.fileMd5 || rowFileHints.fileMd5 + } + if (mergedFileMeta.xmlType) msg.xmlType = mergedFileMeta.xmlType + if (mergedFileMeta.fileName) msg.fileName = mergedFileMeta.fileName + if (mergedFileMeta.fileSize) msg.fileSize = mergedFileMeta.fileSize + if (mergedFileMeta.fileExt) msg.fileExt = mergedFileMeta.fileExt + if (mergedFileMeta.fileMd5) msg.fileMd5 = mergedFileMeta.fileMd5 } } catch (error) { // 详情补取失败时保持降级导出(占位符),避免中断整批任务。 @@ -5329,19 +5744,11 @@ class ExportService { const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) // ========== 阶段1:并行导出媒体文件 ========== - const mediaMessages = exportMediaEnabled - ? allMessages.filter(msg => { - const t = msg.localType - return (t === 3 && options.exportImages) || // 图片 - (t === 47 && options.exportEmojis) || // 表情 - (t === 43 && options.exportVideos) || // 视频 - (t === 34 && options.exportVoices) || // 语音文件 - ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') - }) - : [] + const mediaMessages = this.collectMediaMessagesForExport(allMessages, options) const mediaCache = new Map() const mediaDirCache = new Set() + const beforeMediaDoneFiles = this.getMediaDoneFilesCount() if (mediaMessages.length > 0) { await this.preloadMediaLookupCaches(sessionId, mediaMessages, { @@ -5400,6 +5807,8 @@ class ExportService { } }) } + const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles) + if (fileOnlyExportFailure) return fileOnlyExportFailure // ========== 阶段2:并行语音转文字 ========== const voiceTranscriptMap = new Map() @@ -5840,19 +6249,11 @@ class ExportService { const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) // ========== 阶段1:并行导出媒体文件 ========== - const mediaMessages = exportMediaEnabled - ? collected.rows.filter(msg => { - const t = msg.localType - return (t === 3 && options.exportImages) || - (t === 47 && options.exportEmojis) || - (t === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) || - ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') - }) - : [] + const mediaMessages = this.collectMediaMessagesForExport(collected.rows, options) const mediaCache = new Map() const mediaDirCache = new Set() + const beforeMediaDoneFiles = this.getMediaDoneFilesCount() if (mediaMessages.length > 0) { await this.preloadMediaLookupCaches(sessionId, mediaMessages, { @@ -5910,6 +6311,8 @@ class ExportService { } }) } + const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles) + if (fileOnlyExportFailure) return fileOnlyExportFailure // ========== 阶段2:并行语音转文字 ========== const voiceTranscriptMap = new Map() @@ -6711,19 +7114,11 @@ class ExportService { const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) // ========== 并行预处理:媒体文件 ========== - const mediaMessages = exportMediaEnabled - ? sortedMessages.filter(msg => { - const t = msg.localType - return (t === 3 && options.exportImages) || - (t === 47 && options.exportEmojis) || - (t === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) || - ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') - }) - : [] + const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options) const mediaCache = new Map() const mediaDirCache = new Set() + const beforeMediaDoneFiles = this.getMediaDoneFilesCount() if (mediaMessages.length > 0) { await this.preloadMediaLookupCaches(sessionId, mediaMessages, { @@ -6781,6 +7176,8 @@ class ExportService { } }) } + const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles) + if (fileOnlyExportFailure) return fileOnlyExportFailure // ========== 并行预处理:语音转文字 ========== const voiceTranscriptMap = new Map() @@ -7461,19 +7858,11 @@ class ExportService { const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) - const mediaMessages = exportMediaEnabled - ? sortedMessages.filter(msg => { - const t = msg.localType - return (t === 3 && options.exportImages) || - (t === 47 && options.exportEmojis) || - (t === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) || - ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') - }) - : [] + const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options) const mediaCache = new Map() const mediaDirCache = new Set() + const beforeMediaDoneFiles = this.getMediaDoneFilesCount() if (mediaMessages.length > 0) { await this.preloadMediaLookupCaches(sessionId, mediaMessages, { @@ -7531,6 +7920,8 @@ class ExportService { } }) } + const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles) + if (fileOnlyExportFailure) return fileOnlyExportFailure const voiceTranscriptMap = new Map() @@ -7840,19 +8231,11 @@ class ExportService { } const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) - const mediaMessages = exportMediaEnabled - ? sortedMessages.filter(msg => { - const t = msg.localType - return (t === 3 && options.exportImages) || - (t === 47 && options.exportEmojis) || - (t === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) || - ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') - }) - : [] + const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options) const mediaCache = new Map() const mediaDirCache = new Set() + const beforeMediaDoneFiles = this.getMediaDoneFilesCount() if (mediaMessages.length > 0) { await this.preloadMediaLookupCaches(sessionId, mediaMessages, { @@ -7910,6 +8293,8 @@ class ExportService { } }) } + const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles) + if (fileOnlyExportFailure) return fileOnlyExportFailure const voiceTranscriptMap = new Map() @@ -8263,18 +8648,11 @@ class ExportService { const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) - const mediaMessages = exportMediaEnabled - ? sortedMessages.filter(msg => { - const t = msg.localType - return (t === 3 && options.exportImages) || - (t === 47 && options.exportEmojis) || - (t === 34 && options.exportVoices) || - (t === 43 && options.exportVideos) - }) - : [] + const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options) const mediaCache = new Map() const mediaDirCache = new Set() + const beforeMediaDoneFiles = this.getMediaDoneFilesCount() if (mediaMessages.length > 0) { await this.preloadMediaLookupCaches(sessionId, mediaMessages, { @@ -8333,6 +8711,8 @@ class ExportService { } }) } + const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles) + if (fileOnlyExportFailure) return fileOnlyExportFailure const useVoiceTranscript = options.exportVoiceAsText === true const voiceMessages = useVoiceTranscript @@ -9051,7 +9431,7 @@ class ExportService { : options const exportMediaEnabled = effectiveOptions.exportMedia === true && - Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis) + Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles) attachMediaTelemetry = exportMediaEnabled if (exportMediaEnabled) { this.triggerMediaFileCacheCleanup() diff --git a/electron/services/insightService.ts b/electron/services/insightService.ts index f1ee5b4..0566571 100644 --- a/electron/services/insightService.ts +++ b/electron/services/insightService.ts @@ -50,6 +50,8 @@ const INSIGHT_CONFIG_KEYS = new Set([ 'aiModelApiKey', 'aiModelApiModel', 'aiModelApiMaxTokens', + 'aiInsightFilterMode', + 'aiInsightFilterList', 'aiInsightAllowSocialContext', 'aiInsightSocialContextCount', 'aiInsightWeiboCookie', @@ -73,6 +75,8 @@ interface SharedAiModelConfig { maxTokens: number } +type InsightFilterMode = 'whitelist' | 'blacklist' + // ─── 日志 ───────────────────────────────────────────────────────────────────── type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR' @@ -196,6 +200,11 @@ function normalizeApiMaxTokens(value: unknown): number { return Math.min(API_MAX_TOKENS_MAX, Math.max(API_MAX_TOKENS_MIN, Math.floor(numeric))) } +function normalizeSessionIdList(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean))) +} + /** * 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。 * 使用 Node 原生 https/http 模块,无需任何第三方 SDK。 @@ -495,7 +504,7 @@ class InsightService { return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') && this.isSessionAllowed(id) }) if (!session) { - return { success: false, message: '未找到任何私聊会话(若已启用白名单,请检查是否有勾选的私聊)' } + return { success: false, message: '未找到任何可触发的私聊会话(请检查黑白名单模式与选择列表)' } } const sessionId = session.username?.trim() || '' const displayName = session.displayName || sessionId @@ -747,14 +756,23 @@ ${topMentionText} /** * 判断某个会话是否允许触发见解。 - * 若白名单未启用,则所有私聊会话均允许; - * 若白名单已启用,则只有在白名单中的会话才允许。 + * white/black 模式二选一: + * - whitelist:仅名单内允许 + * - blacklist:名单内屏蔽,其他允许 */ + private getInsightFilterConfig(): { mode: InsightFilterMode; list: string[] } { + const modeRaw = String(this.config.get('aiInsightFilterMode') || '').trim().toLowerCase() + const mode: InsightFilterMode = modeRaw === 'blacklist' ? 'blacklist' : 'whitelist' + const list = normalizeSessionIdList(this.config.get('aiInsightFilterList')) + return { mode, list } + } + private isSessionAllowed(sessionId: string): boolean { - const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean - if (!whitelistEnabled) return true - const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || [] - return whitelist.includes(sessionId) + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return false + const { mode, list } = this.getInsightFilterConfig() + if (mode === 'whitelist') return list.includes(normalizedSessionId) + return !list.includes(normalizedSessionId) } /** @@ -966,8 +984,8 @@ ${topMentionText} * 1. 会话有真正的新消息(lastTimestamp 比上次见到的更新) * 2. 该会话距上次活跃分析已超过冷却期 * - * 白名单启用时:直接使用白名单里的 sessionId,完全跳过 getSessions()。 - * 白名单未启用时:从缓存拉取全量会话后过滤私聊。 + * whitelist 模式:直接使用名单里的 sessionId,完全跳过 getSessions()。 + * blacklist 模式:从缓存拉取会话后过滤名单。 */ private async analyzeRecentActivity(): Promise { if (!this.isEnabled()) return @@ -978,12 +996,11 @@ ${topMentionText} const now = Date.now() const cooldownMinutes = (this.config.get('aiInsightCooldownMinutes') as number) ?? 120 const cooldownMs = cooldownMinutes * 60 * 1000 - const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean - const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || [] + const { mode: filterMode, list: filterList } = this.getInsightFilterConfig() - // 白名单启用且有勾选项时,直接用白名单 sessionId,无需查数据库全量会话列表。 + // whitelist 模式且有勾选项时,直接用名单 sessionId,无需查数据库全量会话列表。 // 通过拉取该会话最新 1 条消息时间戳判断是否真正有新消息,开销极低。 - if (whitelistEnabled && whitelist.length > 0) { + if (filterMode === 'whitelist' && filterList.length > 0) { // 确保数据库已连接(首次时连接,之后复用) if (!this.dbConnected) { const connectResult = await chatService.connect() @@ -991,8 +1008,8 @@ ${topMentionText} this.dbConnected = true } - for (const sessionId of whitelist) { - if (!sessionId || sessionId.endsWith('@chatroom')) continue + for (const sessionId of filterList) { + if (!sessionId || sessionId.toLowerCase().includes('placeholder')) continue // 冷却期检查(先过滤,减少不必要的 DB 查询) if (cooldownMs > 0) { @@ -1029,16 +1046,22 @@ ${topMentionText} return } - // 白名单未启用:需要拉取全量会话列表,从中过滤私聊 + if (filterMode === 'whitelist' && filterList.length === 0) { + insightLog('INFO', '白名单模式且名单为空,跳过活跃分析') + return + } + + // blacklist 模式:拉取会话缓存后按过滤规则筛选 const sessions = await this.getSessionsCached() if (sessions.length === 0) return - const privateSessions = sessions.filter((s) => { + const candidateSessions = sessions.filter((s) => { const id = s.username?.trim() || '' - return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') + if (!id || id.toLowerCase().includes('placeholder')) return false + return this.isSessionAllowed(id) }) - for (const session of privateSessions.slice(0, 10)) { + for (const session of candidateSessions.slice(0, 10)) { const sessionId = session.username?.trim() || '' if (!sessionId) continue diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index d4c77ef..2f1957f 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -25,9 +25,7 @@ export class WcdbService { private logEnabled = false private monitorListener: ((type: string, json: string) => void) | null = null - constructor() { - this.initWorker() - } + constructor() {} /** * 初始化 Worker 线程 diff --git a/package.json b/package.json index 05ef287..7c6f375 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,13 @@ }, "//": "二改不应改变此处的作者与应用信息", "scripts": { - "postinstall": "electron-builder install-app-deps", + "postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs", "rebuild": "electron-rebuild", - "dev": "vite", + "dev": "node scripts/prepare-electron-runtime.cjs && vite", "typecheck": "tsc --noEmit", "build": "tsc && vite build && electron-builder", "preview": "vite preview", - "electron:dev": "vite --mode electron", + "electron:dev": "node scripts/prepare-electron-runtime.cjs && vite --mode electron", "electron:build": "npm run build" }, "dependencies": { diff --git a/scripts/prepare-electron-runtime.cjs b/scripts/prepare-electron-runtime.cjs new file mode 100644 index 0000000..1230732 --- /dev/null +++ b/scripts/prepare-electron-runtime.cjs @@ -0,0 +1,57 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const runtimeNames = [ + 'msvcp140.dll', + 'msvcp140_1.dll', + 'vcruntime140.dll', + 'vcruntime140_1.dll', +]; + +function copyIfDifferent(sourcePath, targetPath) { + const source = fs.statSync(sourcePath); + const targetExists = fs.existsSync(targetPath); + + if (targetExists) { + const target = fs.statSync(targetPath); + if (target.size === source.size && target.mtimeMs >= source.mtimeMs) { + return false; + } + } + + fs.copyFileSync(sourcePath, targetPath); + return true; +} + +function main() { + if (process.platform !== 'win32') { + return; + } + + const projectRoot = path.resolve(__dirname, '..'); + const sourceDir = path.join(projectRoot, 'resources', 'runtime', 'win32'); + const targetDir = path.join(projectRoot, 'node_modules', 'electron', 'dist'); + + if (!fs.existsSync(sourceDir) || !fs.existsSync(targetDir)) { + return; + } + + let copiedCount = 0; + + for (const name of runtimeNames) { + const sourcePath = path.join(sourceDir, name); + const targetPath = path.join(targetDir, name); + if (!fs.existsSync(sourcePath)) { + continue; + } + if (copyIfDifferent(sourcePath, targetPath)) { + copiedCount += 1; + } + } + + if (copiedCount > 0) { + console.log(`[prepare-electron-runtime] synced ${copiedCount} runtime DLL(s) to ${targetDir}`); + } +} + +main(); diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 362960a..3554fcb 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1898,6 +1898,9 @@ const TaskCenterModal = memo(function TaskCenterModal({ const mediaCacheMetricLabel = mediaCacheTotal > 0 ? `缓存命中 ${mediaCacheHitFiles}/${mediaCacheTotal}` : '' + const mediaMissMetricLabel = mediaCacheMissFiles > 0 + ? `未导出 ${mediaCacheMissFiles} 个文件/媒体` + : '' const mediaDedupMetricLabel = mediaDedupReuseFiles > 0 ? `复用 ${mediaDedupReuseFiles}` : '' @@ -1958,6 +1961,7 @@ const TaskCenterModal = memo(function TaskCenterModal({ {phaseMetricLabel ? ` · ${phaseMetricLabel}` : ''} {mediaLiveMetricLabel ? ` · ${mediaLiveMetricLabel}` : ''} {mediaCacheMetricLabel ? ` · ${mediaCacheMetricLabel}` : ''} + {mediaMissMetricLabel ? ` · ${mediaMissMetricLabel}` : ''} {mediaDedupMetricLabel ? ` · ${mediaDedupMetricLabel}` : ''} {task.status === 'running' && currentSessionRatio !== null ? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)` diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 851d8d1..a5f2202 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -75,6 +75,7 @@ interface WxidOption { type SessionFilterType = configService.MessagePushSessionType type SessionFilterTypeValue = 'all' | SessionFilterType type SessionFilterMode = 'all' | 'whitelist' | 'blacklist' +type InsightSessionFilterTypeValue = 'all' | 'private' | 'group' | 'official' interface SessionFilterOption { username: string @@ -91,6 +92,13 @@ const sessionFilterTypeOptions: Array<{ value: SessionFilterTypeValue; label: st { value: 'other', label: '其他/非好友' } ] +const insightFilterTypeOptions: Array<{ value: InsightSessionFilterTypeValue; label: string }> = [ + { value: 'all', label: '全部' }, + { value: 'private', label: '私聊' }, + { value: 'group', label: '群聊' }, + { value: 'official', label: '订阅号/服务号' } +] + interface SettingsPageProps { onClose?: () => void } @@ -194,6 +202,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false) + const [insightFilterModeDropdownOpen, setInsightFilterModeDropdownOpen] = useState(false) const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState([]) const [excludeWordsInput, setExcludeWordsInput] = useState('') @@ -275,8 +284,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [showInsightApiKey, setShowInsightApiKey] = useState(false) const [isTriggeringInsightTest, setIsTriggeringInsightTest] = useState(false) const [insightTriggerResult, setInsightTriggerResult] = useState<{ success: boolean; message: string } | null>(null) - const [aiInsightWhitelistEnabled, setAiInsightWhitelistEnabled] = useState(false) - const [aiInsightWhitelist, setAiInsightWhitelist] = useState>(new Set()) + const [aiInsightFilterMode, setAiInsightFilterMode] = useState('whitelist') + const [aiInsightFilterList, setAiInsightFilterList] = useState>(new Set()) + const [insightFilterType, setInsightFilterType] = useState('all') const [insightWhitelistSearch, setInsightWhitelistSearch] = useState('') const [aiInsightCooldownMinutes, setAiInsightCooldownMinutes] = useState(120) const [aiInsightScanIntervalHours, setAiInsightScanIntervalHours] = useState(4) @@ -397,15 +407,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setPositionDropdownOpen(false) setCloseBehaviorDropdownOpen(false) setMessagePushFilterDropdownOpen(false) + setInsightFilterModeDropdownOpen(false) } } - if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen) { + if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen || insightFilterModeDropdownOpen) { document.addEventListener('click', handleClickOutside) } return () => { document.removeEventListener('click', handleClickOutside) } - }, [closeBehaviorDropdownOpen, filterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen]) + }, [closeBehaviorDropdownOpen, filterModeDropdownOpen, insightFilterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen]) const loadConfig = async () => { @@ -531,8 +542,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedAiModelApiMaxTokens = await configService.getAiModelApiMaxTokens() const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays() const savedAiInsightAllowContext = await configService.getAiInsightAllowContext() - const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled() - const savedAiInsightWhitelist = await configService.getAiInsightWhitelist() + const savedAiInsightFilterMode = await configService.getAiInsightFilterMode() + const savedAiInsightFilterList = await configService.getAiInsightFilterList() const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes() const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours() const savedAiInsightContextCount = await configService.getAiInsightContextCount() @@ -555,8 +566,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setAiModelApiMaxTokens(savedAiModelApiMaxTokens) setAiInsightSilenceDays(savedAiInsightSilenceDays) setAiInsightAllowContext(savedAiInsightAllowContext) - setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled) - setAiInsightWhitelist(new Set(savedAiInsightWhitelist)) + setAiInsightFilterMode(savedAiInsightFilterMode) + setAiInsightFilterList(new Set(savedAiInsightFilterList)) setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes) setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours) setAiInsightContextCount(savedAiInsightContextCount) @@ -3390,98 +3401,129 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
- {/* 对话白名单 */} + {/* 对话过滤名单 */} {(() => { - const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0)) + const selectableSessions = sessionFilterOptions.filter((session) => + session.type === 'private' || session.type === 'group' || session.type === 'official' + ) const keyword = insightWhitelistSearch.trim().toLowerCase() - const filteredSessions = sortedSessions.filter((s) => { - const id = s.username?.trim() || '' - if (!id || id.endsWith('@chatroom') || id.toLowerCase().includes('placeholder')) return false + const filteredSessions = selectableSessions.filter((session) => { + if (insightFilterType !== 'all' && session.type !== insightFilterType) return false + const id = session.username?.trim() || '' + if (!id || id.toLowerCase().includes('placeholder')) return false if (!keyword) return true return ( - String(s.displayName || '').toLowerCase().includes(keyword) || + String(session.displayName || '').toLowerCase().includes(keyword) || id.toLowerCase().includes(keyword) ) }) - const filteredIds = filteredSessions.map((s) => s.username) - const selectedCount = aiInsightWhitelist.size - const selectedInFilteredCount = filteredIds.filter((id) => aiInsightWhitelist.has(id)).length + const filteredIds = filteredSessions.map((session) => session.username) + const selectedCount = aiInsightFilterList.size + const selectedInFilteredCount = filteredIds.filter((id) => aiInsightFilterList.has(id)).length const allFilteredSelected = filteredIds.length > 0 && selectedInFilteredCount === filteredIds.length - const toggleSession = (id: string) => { - setAiInsightWhitelist((prev) => { - const next = new Set(prev) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) + const saveFilterList = async (next: Set) => { + await configService.setAiInsightFilterList(Array.from(next)) } - const saveWhitelist = async (next: Set) => { - await configService.setAiInsightWhitelist(Array.from(next)) + const saveFilterMode = async (mode: configService.AiInsightFilterMode) => { + setAiInsightFilterMode(mode) + setInsightFilterModeDropdownOpen(false) + await configService.setAiInsightFilterMode(mode) + showMessage(mode === 'whitelist' ? '已切换为白名单模式' : '已切换为黑名单模式', true) } const selectAllFiltered = () => { - setAiInsightWhitelist((prev) => { + setAiInsightFilterList((prev) => { const next = new Set(prev) for (const id of filteredIds) next.add(id) - void saveWhitelist(next) + void saveFilterList(next) return next }) } const clearSelection = () => { const next = new Set() - setAiInsightWhitelist(next) - void saveWhitelist(next) + setAiInsightFilterList(next) + void saveFilterList(next) } return (
-

对话白名单

+

对话黑白名单

- 开启后,AI 见解仅对勾选的私聊对话生效,未勾选的对话将被完全忽略。关闭时对所有私聊均生效。中间可填写微博 UID。 + 白名单模式下仅对已选会话触发见解;黑名单模式下会跳过已选会话。默认白名单且不选择任何会话。支持私聊、群聊、订阅号/服务号分类筛选后批量选择。

- 私聊总数 - {filteredIds.length + (keyword ? 0 : 0)} + 可选会话总数 + {selectableSessions.length}
- 已选中 + 已加入名单 {selectedCount}
-
- - {aiInsightWhitelistEnabled ? '白名单已启用(仅对勾选对话生效)' : '白名单未启用(对所有私聊生效)'} - - +
+
+ + {aiInsightFilterMode === 'whitelist' + ? '白名单模式(仅对名单内会话生效)' + : '黑名单模式(名单内会话将被忽略)'} + +
+
setInsightFilterModeDropdownOpen(!insightFilterModeDropdownOpen)} + > + + {aiInsightFilterMode === 'whitelist' ? '白名单模式' : '黑名单模式'} + + +
+
+ {[ + { value: 'whitelist', label: '白名单模式' }, + { value: 'blacklist', label: '黑名单模式' } + ].map(option => ( +
{ void saveFilterMode(option.value as configService.AiInsightFilterMode) }} + > + {option.label} + {aiInsightFilterMode === option.value && } +
+ ))} +
+
+
+
+ {insightFilterTypeOptions.map(option => ( + + ))} +
setInsightWhitelistSearch(e.target.value)} /> @@ -3517,7 +3559,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{filteredSessions.length === 0 ? (
- {insightWhitelistSearch ? '没有匹配的对话' : '暂无私聊对话'} + {insightWhitelistSearch || insightFilterType !== 'all' ? '没有匹配的对话' : '暂无可选对话'}
) : ( <> @@ -3527,7 +3569,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { 状态
{filteredSessions.map((session) => { - const isSelected = aiInsightWhitelist.has(session.username) + const isSelected = aiInsightFilterList.has(session.username) const weiboBinding = aiInsightWeiboBindings[session.username] const weiboDraftValue = getWeiboBindingDraftValue(session.username) const isBindingLoading = weiboBindingLoadingSessionId === session.username @@ -3543,11 +3585,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { type="checkbox" checked={isSelected} onChange={async () => { - setAiInsightWhitelist((prev) => { + setAiInsightFilterList((prev) => { const next = new Set(prev) if (next.has(session.username)) next.delete(session.username) else next.add(session.username) - void configService.setAiInsightWhitelist(Array.from(next)) + void configService.setAiInsightFilterList(Array.from(next)) return next }) }} @@ -3563,54 +3605,65 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { />
{session.displayName || session.username} + {getSessionFilterTypeLabel(session.type)}
-
- 微博 - updateWeiboBindingDraft(session.username, e.target.value)} - /> -
-
- - {weiboBinding && ( - - )} -
-
- {weiboBindingError ? ( - {weiboBindingError} - ) : weiboBinding?.screenName ? ( - @{weiboBinding.screenName} - ) : weiboBinding?.uid ? ( - 已绑定 UID:{weiboBinding.uid} - ) : ( - 仅支持手动填写数字 UID - )} -
+ {session.type === 'private' ? ( + <> +
+ 微博 + updateWeiboBindingDraft(session.username, e.target.value)} + /> +
+
+ + {weiboBinding && ( + + )} +
+
+ {weiboBindingError ? ( + {weiboBindingError} + ) : weiboBinding?.screenName ? ( + @{weiboBinding.screenName} + ) : weiboBinding?.uid ? ( + 已绑定 UID:{weiboBinding.uid} + ) : ( + 仅支持手动填写数字 UID + )} +
+ + ) : ( +
+ 仅私聊支持微博绑定 +
+ )}