diff --git a/electron/exportWorker.ts b/electron/exportWorker.ts index 60f896e..dd55157 100644 --- a/electron/exportWorker.ts +++ b/electron/exportWorker.ts @@ -16,6 +16,7 @@ interface ExportWorkerConfig { resourcesPath?: string userDataPath?: string logEnabled?: boolean + isPackaged?: boolean } const config = workerData as ExportWorkerConfig @@ -150,7 +151,10 @@ async function run() { decryptKey: config.decryptKey, myWxid: config.myWxid, imageXorKey: config.imageXorKey, - imageAesKey: config.imageAesKey + imageAesKey: config.imageAesKey, + resourcesPath: config.resourcesPath, + appPath: config.resourcesPath ? require('path').dirname(config.resourcesPath) : __dirname, + isPackaged: config.isPackaged }) const onProgress = (progress: any) => queueProgress(progress) @@ -173,7 +177,10 @@ async function run() { chatService.setRuntimeConfig({ dbPath: config.dbPath, decryptKey: config.decryptKey, - myWxid: config.myWxid + myWxid: config.myWxid, + resourcesPath: config.resourcesPath, + appPath: config.resourcesPath ? require('path').dirname(config.resourcesPath) : __dirname, + isPackaged: config.isPackaged }) result = await contactExportService.exportContacts( String(config.outputDir || ''), diff --git a/electron/main.ts b/electron/main.ts index cf80daf..7faf0ee 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -397,13 +397,7 @@ let keyService: any if (process.platform === 'darwin') { keyService = new KeyServiceMac() } else if (process.platform === 'linux') { - // const { KeyServiceLinux } = require('./services/keyServiceLinux') - // keyService = new KeyServiceLinux() - - import('./services/keyServiceLinux').then(({ KeyServiceLinux }) => { - keyService = new KeyServiceLinux(); - }); - + keyService = new KeyServiceLinux() } else { keyService = new KeyService() } @@ -1792,6 +1786,7 @@ function registerIpcHandlers() { sessionId?: string startTime?: number endTime?: number + sourceType?: 'insight' | 'message_analysis' | 'all' limit?: number offset?: number }) => { @@ -1818,6 +1813,14 @@ function registerIpcHandlers() { return insightService.triggerTest() }) + ipcMain.handle('insight:triggerSessionInsight', async (_, payload: { + sessionId: string + displayName?: string + avatarUrl?: string + }) => { + return insightService.triggerSessionInsight(payload) + }) + ipcMain.handle('insight:generateFootprintInsight', async (_, payload: { rangeLabel: string summary: { @@ -1834,6 +1837,21 @@ function registerIpcHandlers() { return insightService.generateFootprintInsight(payload) }) + ipcMain.handle('insight:generateMessageInsight', async (_, payload: { + sessionId: string + displayName?: string + avatarUrl?: string + targetLocalId?: number + targetCreateTime?: number + targetMessageKey?: string + targetText: string + targetSenderName?: string + contextCount?: number + forceRefresh?: boolean + }) => { + return insightService.generateMessageInsight(payload) + }) + ipcMain.handle('social:saveWeiboCookie', async (_, rawInput: string) => { try { if (!configService) { @@ -2349,8 +2367,8 @@ function registerIpcHandlers() { return chatService.getContactTypeCounts() }) - ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[]) => { - return chatService.getSessionMessageCounts(sessionIds) + ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[], options?: { preferHintCache?: boolean; bypassSessionCache?: boolean }) => { + return chatService.getSessionMessageCounts(sessionIds, options) }) ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[], options?: { @@ -3213,7 +3231,8 @@ function registerIpcHandlers() { imageAesKey: imageKeys.aesKey, resourcesPath, userDataPath, - logEnabled + logEnabled, + isPackaged: app.isPackaged } }) @@ -3344,7 +3363,8 @@ function registerIpcHandlers() { imageAesKey: imageKeys.aesKey, resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'), userDataPath: app.getPath('userData'), - logEnabled: cfg.get('logEnabled') + logEnabled: cfg.get('logEnabled'), + isPackaged: app.isPackaged } }) @@ -3411,7 +3431,8 @@ function registerIpcHandlers() { myWxid: String(cfg.getMyWxidCleaned() || '').trim(), resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'), userDataPath: app.getPath('userData'), - logEnabled: cfg.get('logEnabled') + logEnabled: cfg.get('logEnabled'), + isPackaged: app.isPackaged } }) diff --git a/electron/preload.ts b/electron/preload.ts index bb175c0..ced455d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -195,7 +195,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames), getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'), - getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds), + getSessionMessageCounts: (sessionIds: string[], options?: { preferHintCache?: boolean; bypassSessionCache?: boolean }) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds, options), enrichSessionsContactInfo: ( usernames: string[], options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean } @@ -583,6 +583,11 @@ contextBridge.exposeInMainWorld('electronAPI', { markRecordRead: (id: string) => ipcRenderer.invoke('insight:markRecordRead', id), clearRecords: (filters?: any) => ipcRenderer.invoke('insight:clearRecords', filters), triggerTest: () => ipcRenderer.invoke('insight:triggerTest'), + triggerSessionInsight: (payload: { + sessionId: string + displayName?: string + avatarUrl?: string + }) => ipcRenderer.invoke('insight:triggerSessionInsight', payload), generateFootprintInsight: (payload: { rangeLabel: string summary: { @@ -595,7 +600,19 @@ contextBridge.exposeInMainWorld('electronAPI', { } privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }> mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }> - }) => ipcRenderer.invoke('insight:generateFootprintInsight', payload) + }) => ipcRenderer.invoke('insight:generateFootprintInsight', payload), + generateMessageInsight: (payload: { + sessionId: string + displayName?: string + avatarUrl?: string + targetLocalId?: number + targetCreateTime?: number + targetMessageKey?: string + targetText: string + targetSenderName?: string + contextCount?: number + forceRefresh?: boolean + }) => ipcRenderer.invoke('insight:generateMessageInsight', payload) }, social: { diff --git a/electron/services/backupService.ts b/electron/services/backupService.ts index d3d0072..feac28e 100644 --- a/electron/services/backupService.ts +++ b/electron/services/backupService.ts @@ -460,6 +460,7 @@ export class BackupService { const dbStorage = join(accountDir, 'db_storage') if (!existsSync(dbStorage)) return { success: false, error: '未找到 db_storage 目录' } + const accountDirName = basename(accountDir) const opened = await withTimeout( wcdbService.open(accountDir, decryptKey), 15000, diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index b827d41..6c393a0 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -1,5 +1,6 @@ import { join, dirname, basename, extname } from 'path' import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch, promises as fsPromises } from 'fs' +import { createRequire } from 'module' import * as path from 'path' import * as fs from 'fs' import * as https from 'https' @@ -453,7 +454,7 @@ class ChatService { this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录 } - setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string }): void { + setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; resourcesPath?: string; appPath?: string; isPackaged?: boolean }): void { this.runtimeConfig = config } @@ -2585,6 +2586,93 @@ class ChatService { } } + async getMessagesAround( + sessionId: string, + target: { localId?: number; createTime: number; messageKey?: string }, + totalContextCount: number = 50 + ): Promise<{ + success: boolean + before: Message[] + after: Message[] + requested: number + error?: string + }> { + const requested = Math.max(1, Math.min(200, Math.floor(Number(totalContextCount) || 50))) + const targetCreateTime = Math.floor(Number(target?.createTime || 0)) + if (!sessionId || targetCreateTime <= 0) { + return { success: false, before: [], after: [], requested, error: '无效的目标消息' } + } + + const collect = async (ascending: boolean): Promise => { + let cursor: number | undefined + try { + const cursorResult = await wcdbService.openMessageCursorLite( + sessionId, + Math.min(240, Math.max(60, requested + 20)), + ascending, + ascending ? targetCreateTime : 0, + ascending ? 0 : targetCreateTime + 1 + ) + if (!cursorResult.success || !cursorResult.cursor) { + throw new Error(cursorResult.error || '打开消息游标失败') + } + cursor = cursorResult.cursor + const collected = await this.collectVisibleMessagesFromCursor(sessionId, cursor, requested + 1) + if (!collected.success) { + throw new Error(collected.error || '读取上下文消息失败') + } + const targetLocalId = Math.floor(Number(target?.localId || 0)) + const targetMessageKey = String(target?.messageKey || '').trim() + return (collected.messages || []).filter((message) => { + const sameLocalId = targetLocalId > 0 && Number(message.localId || 0) === targetLocalId + const sameCreateTime = Number(message.createTime || 0) === targetCreateTime + const sameKey = Boolean(targetMessageKey && message.messageKey === targetMessageKey) + return !(sameKey || (sameLocalId && sameCreateTime)) + }) + } finally { + if (cursor) { + await wcdbService.closeMessageCursor(cursor).catch(() => {}) + } + } + } + + try { + const [beforeCandidatesRaw, afterCandidatesRaw] = await Promise.all([ + collect(false), + collect(true) + ]) + const beforeCandidates = beforeCandidatesRaw + .filter((message) => Number(message.createTime || 0) <= targetCreateTime) + .sort((a, b) => (a.createTime - b.createTime) || (a.sortSeq - b.sortSeq)) + const afterCandidates = afterCandidatesRaw + .filter((message) => Number(message.createTime || 0) >= targetCreateTime) + .sort((a, b) => (a.createTime - b.createTime) || (a.sortSeq - b.sortSeq)) + + const baseBefore = Math.floor(requested / 2) + const baseAfter = requested - baseBefore + const takeAfter = Math.min(baseAfter, afterCandidates.length) + const takeBefore = Math.min(requested - takeAfter, beforeCandidates.length) + const remainingAfter = Math.max(0, requested - takeBefore - takeAfter) + const finalAfter = Math.min(afterCandidates.length, takeAfter + remainingAfter) + const finalBefore = Math.min(beforeCandidates.length, requested - finalAfter) + + return { + success: true, + before: beforeCandidates.slice(Math.max(0, beforeCandidates.length - finalBefore)), + after: afterCandidates.slice(0, finalAfter), + requested + } + } catch (error) { + return { + success: false, + before: [], + after: [], + requested, + error: (error as Error).message || String(error) + } + } + } + async getNewMessages(sessionId: string, minTime: number, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; error?: string }> { try { const connectResult = await this.ensureConnected() @@ -8613,13 +8701,17 @@ class ChatService { private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise { try { let wasmPath: string - if (app.isPackaged) { - wasmPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') + const isPackaged = this.runtimeConfig?.isPackaged ?? app.isPackaged + const resourcesPath = this.runtimeConfig?.resourcesPath ?? process.resourcesPath + const appPath = this.runtimeConfig?.appPath ?? app.getAppPath() + + if (isPackaged) { + wasmPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') if (!existsSync(wasmPath)) { - wasmPath = join(process.resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') + wasmPath = join(resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') } } else { - wasmPath = join(app.getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') + wasmPath = join(appPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') } if (!existsSync(wasmPath)) { @@ -8627,7 +8719,9 @@ class ChatService { return null } - const silkWasm = require('silk-wasm') + // 在 worker 环境中使用 createRequire 来正确加载模块 + const requireFromApp = createRequire(join(appPath, 'package.json')) + const silkWasm = requireFromApp('silk-wasm') if (!silkWasm || !silkWasm.decode) { console.error('[ChatService][Voice] silk-wasm module invalid') return null @@ -9456,12 +9550,13 @@ class ChatService { data = this.filterMyFootprintMentionsBySource(nativeRaw, myWxid, mentionLimit) - if (privateSessionIds.length > 0 && data.private_segments.length === 0) { + if (data.private_sessions.length > 0) { + const sessionsWithMessages = data.private_sessions.map(s => s.session_id) const privateSegments = await this.rebuildMyFootprintPrivateSegments({ begin, end: normalizedEnd, myWxid, - privateSessionIds + privateSessionIds: sessionsWithMessages }) if (privateSegments.length > 0) { data = { @@ -9561,7 +9656,7 @@ class ChatService { myWxid: string privateSessionIds: string[] }): Promise { - const sessionGapSeconds = 10 * 60 + const sessionGapSeconds = 5 * 60 const segments: MyFootprintPrivateSegment[] = [] type WorkingSegment = { @@ -9579,14 +9674,17 @@ class ChatService { } for (const sessionId of params.privateSessionIds) { - const cursorResult = await wcdbService.openMessageCursorLite( + const cursorResult = await wcdbService.openMessageCursor( sessionId, 360, true, - params.begin, - params.end + 0, + 0 ) - if (!cursorResult.success || !cursorResult.cursor) continue + if (!cursorResult.success || !cursorResult.cursor) { + console.log(`[足迹分段] 打开游标失败: ${sessionId}, 原因: ${cursorResult.error || '未知'}`) + continue + } let segmentCursor = 0 let active: WorkingSegment | null = null @@ -9620,19 +9718,30 @@ class ChatService { } let hasMore = true + let batchCount = 0 + let totalMessages = 0 try { while (hasMore) { const batchResult = await wcdbService.fetchMessageBatch(cursorResult.cursor) + batchCount++ if (!batchResult.success || !Array.isArray(batchResult.rows)) break hasMore = Boolean(batchResult.hasMore) + totalMessages += batchResult.rows.length for (const row of batchResult.rows as Array>) { const createTime = this.toSafeInt(row.create_time, 0) const localId = this.toSafeInt(row.local_id, 0) const isSend = this.resolveFootprintRowIsSend(row, params.myWxid) + // 过滤时间范围外的消息 + if (createTime > 0 && (createTime < params.begin || createTime > params.end)) { + continue + } + if (createTime > 0) { - const needNew = !active || (lastMessageTs > 0 && createTime - lastMessageTs > sessionGapSeconds) + const referenceTs = lastMessageTs > 0 ? lastMessageTs : (active ? active.end_ts : 0) + const timeDiff = referenceTs > 0 ? createTime - referenceTs : 0 + const needNew = !active || (referenceTs > 0 && timeDiff > sessionGapSeconds) if (needNew) { commit() segmentCursor += 1 diff --git a/electron/services/config.ts b/electron/services/config.ts index 618d908..c2148bc 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -129,6 +129,9 @@ interface ConfigSchema { // AI 足迹 aiFootprintEnabled: boolean aiFootprintSystemPrompt: string + aiMessageInsightEnabled: boolean + aiMessageInsightContextCount: number + aiMessageInsightSystemPrompt: string /** 是否将 AI 见解调试日志输出到桌面 */ aiInsightDebugLogEnabled: boolean autoDownloadHighRes: boolean @@ -252,6 +255,9 @@ export class ConfigService { aiInsightWeiboBindings: {}, aiFootprintEnabled: false, aiFootprintSystemPrompt: '', + aiMessageInsightEnabled: false, + aiMessageInsightContextCount: 50, + aiMessageInsightSystemPrompt: '', aiInsightDebugLogEnabled: false, autoDownloadHighRes: false, autoDownloadWhitelist: [] diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 72198df..cd88bb0 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -323,7 +323,7 @@ class ExportService { return error } - setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void { + setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string; resourcesPath?: string; appPath?: string; isPackaged?: boolean } | null): void { this.runtimeConfig = config imageDecryptService.setRuntimeConfig({ dbPath: config?.dbPath, @@ -331,6 +331,14 @@ class ExportService { imageXorKey: config?.imageXorKey, imageAesKey: config?.imageAesKey }) + chatService.setRuntimeConfig({ + dbPath: config?.dbPath, + decryptKey: config?.decryptKey, + myWxid: config?.myWxid, + resourcesPath: config?.resourcesPath, + appPath: config?.appPath, + isPackaged: config?.isPackaged + }) } private getConfiguredDbPath(): string { @@ -6651,7 +6659,7 @@ class ExportService { if (msg.localType === 34 && options.exportVoiceAsText) { // 使用预先转写的文字 content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' - } else if (mediaItem && msg.localType === 3) { + } else if (mediaItem && msg.localType !== 47) { content = mediaItem.relativePath } else { content = this.parseMessageContent( diff --git a/electron/services/insightRecordService.ts b/electron/services/insightRecordService.ts index 762b372..b36b203 100644 --- a/electron/services/insightRecordService.ts +++ b/electron/services/insightRecordService.ts @@ -4,7 +4,24 @@ import path from 'path' import { createHash, randomUUID } from 'crypto' import { ConfigService } from './config' -export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test' +export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test' | 'manual' | 'message_analysis' +export type InsightRecordSourceType = 'insight' | 'message_analysis' + +export interface MessageInsightAnalysis { + explicitText: string + emotion: string + intent: string + topic: string +} + +export interface MessageInsightTarget { + targetLocalId: number + targetCreateTime: number + targetMessageKey: string + targetSenderName: string + targetTextPreview: string + analysis: MessageInsightAnalysis +} export interface InsightRecordLog { endpoint: string @@ -20,11 +37,29 @@ export interface InsightRecordLog { finalInsight: string durationMs: number createdAt: number + responseFormatJson?: boolean + responseFormatFallback?: boolean + responseFormatFallbackReason?: string + targetMessage?: { + localId: number + createTime: number + messageKey: string + senderName: string + textPreview: string + } + contextStats?: { + requested: number + beforeTarget: number + afterTarget: number + readError?: string + } + parsedAnalysis?: MessageInsightAnalysis } export interface InsightRecord { id: string accountScope: string + sourceType: InsightRecordSourceType createdAt: number sessionId: string displayName: string @@ -32,11 +67,13 @@ export interface InsightRecord { triggerReason: InsightRecordTriggerReason insight: string read: boolean + messageInsight?: MessageInsightTarget log: InsightRecordLog } export interface InsightRecordSummary { id: string + sourceType: InsightRecordSourceType createdAt: number sessionId: string displayName: string @@ -44,6 +81,7 @@ export interface InsightRecordSummary { triggerReason: InsightRecordTriggerReason insight: string read: boolean + messageInsight?: MessageInsightTarget } export interface InsightRecordContactFacet { @@ -58,6 +96,7 @@ export interface InsightRecordFilters { sessionId?: string startTime?: number endTime?: number + sourceType?: InsightRecordSourceType | 'all' limit?: number offset?: number } @@ -136,13 +175,15 @@ class InsightRecordService { private toSummary(record: InsightRecord): InsightRecordSummary { return { id: record.id, + sourceType: record.sourceType || 'insight', createdAt: record.createdAt, sessionId: record.sessionId, displayName: record.displayName, avatarUrl: record.avatarUrl, triggerReason: record.triggerReason, insight: record.insight, - read: record.read + read: record.read, + messageInsight: record.messageInsight } } @@ -156,8 +197,10 @@ class InsightRecordService { sessionId: string displayName: string avatarUrl?: string + sourceType?: InsightRecordSourceType triggerReason: InsightRecordTriggerReason insight: string + messageInsight?: MessageInsightTarget log: InsightRecordLog }): InsightRecord { this.ensureLoaded() @@ -166,6 +209,7 @@ class InsightRecordService { const record: InsightRecord = { id: randomUUID(), accountScope: scope, + sourceType: input.sourceType || 'insight', createdAt: now, sessionId: input.sessionId, displayName: input.displayName, @@ -173,6 +217,7 @@ class InsightRecordService { triggerReason: input.triggerReason, insight: input.insight, read: false, + messageInsight: input.messageInsight, log: input.log } @@ -207,6 +252,7 @@ class InsightRecordService { const keyword = String(filters.keyword || '').trim().toLowerCase() const sessionId = String(filters.sessionId || '').trim() + const sourceType = String(filters.sourceType || 'all').trim() const startTime = Number(filters.startTime || 0) const endTime = Number(filters.endTime || 0) const offset = Math.max(0, Math.floor(Number(filters.offset || 0))) @@ -215,10 +261,22 @@ class InsightRecordService { const filtered = allScoped .filter((record) => { if (sessionId && record.sessionId !== sessionId) return false + const recordSourceType = record.sourceType || 'insight' + if (sourceType !== 'all' && sourceType && recordSourceType !== sourceType) return false if (startTime > 0 && record.createdAt < startTime) return false if (endTime > 0 && record.createdAt > endTime) return false if (keyword) { - const haystack = `${record.displayName}\n${record.sessionId}\n${record.insight}`.toLowerCase() + const haystack = [ + record.displayName, + record.sessionId, + record.insight, + record.messageInsight?.targetSenderName, + record.messageInsight?.targetTextPreview, + record.messageInsight?.analysis?.explicitText, + record.messageInsight?.analysis?.emotion, + record.messageInsight?.analysis?.intent, + record.messageInsight?.analysis?.topic + ].join('\n').toLowerCase() if (!haystack.includes(keyword)) return false } return true @@ -256,6 +314,36 @@ class InsightRecordService { return { success: true, record } } + findLatestMessageAnalysis(input: { + sessionId: string + targetLocalId?: number + targetCreateTime?: number + targetMessageKey?: string + }): InsightRecord | null { + this.ensureLoaded() + const scope = this.getCurrentAccountScope() + const sessionId = String(input.sessionId || '').trim() + if (!sessionId) return null + const targetLocalId = Math.floor(Number(input.targetLocalId || 0)) + const targetCreateTime = Math.floor(Number(input.targetCreateTime || 0)) + const targetMessageKey = String(input.targetMessageKey || '').trim() + const matches = this.records + .filter((record) => { + if (record.accountScope !== scope) return false + if ((record.sourceType || 'insight') !== 'message_analysis') return false + if (record.sessionId !== sessionId) return false + const target = record.messageInsight + if (!target) return false + if (targetLocalId > 0 && Number(target.targetLocalId || 0) === targetLocalId) { + if (targetCreateTime <= 0 || Number(target.targetCreateTime || 0) === targetCreateTime) return true + } + if (targetMessageKey && target.targetMessageKey === targetMessageKey) return true + return false + }) + .sort((a, b) => b.createdAt - a.createdAt) + return matches[0] || null + } + markRecordRead(id: string): { success: boolean; error?: string } { this.ensureLoaded() const normalizedId = String(id || '').trim() diff --git a/electron/services/insightService.ts b/electron/services/insightService.ts index bb0ea57..ed48173 100644 --- a/electron/services/insightService.ts +++ b/electron/services/insightService.ts @@ -21,7 +21,12 @@ import { chatService, ChatSession, Message } from './chatService' import { snsService } from './snsService' import { weiboService } from './social/weiboService' import { showNotification } from '../windows/notificationWindow' -import { insightRecordService, type InsightRecordLog, type InsightRecordTriggerReason } from './insightRecordService' +import { + insightRecordService, + type InsightRecordLog, + type InsightRecordTriggerReason, + type MessageInsightAnalysis +} from './insightRecordService' // ─── 常量 ──────────────────────────────────────────────────────────────────── @@ -79,8 +84,29 @@ interface SharedAiModelConfig { maxTokens: number } +interface SessionInsightTriggerResult { + success: boolean + message: string + recordId?: string + insight?: string + skipped?: boolean + notificationEnabled?: boolean +} + type InsightFilterMode = 'whitelist' | 'blacklist' +class ApiRequestError extends Error { + statusCode?: number + responseBody?: string + + constructor(message: string, statusCode?: number, responseBody?: string) { + super(message) + this.name = 'ApiRequestError' + this.statusCode = statusCode + this.responseBody = responseBody + } +} + // ─── 日志 ───────────────────────────────────────────────────────────────────── type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR' @@ -161,6 +187,52 @@ function normalizeSessionIdList(value: unknown): string[] { return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean))) } +function clampText(value: unknown, maxLength: number): string { + const text = String(value || '').replace(/\s+/g, ' ').trim() + if (text.length <= maxLength) return text + return `${text.slice(0, Math.max(0, maxLength - 1))}…` +} + +function stripJsonFence(value: string): string { + const text = String(value || '').trim() + const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i) + if (fenced) return fenced[1].trim() + const firstBrace = text.indexOf('{') + const lastBrace = text.lastIndexOf('}') + if (firstBrace >= 0 && lastBrace > firstBrace) { + return text.slice(firstBrace, lastBrace + 1).trim() + } + return text +} + +function parseMessageInsightAnalysis(rawOutput: string): MessageInsightAnalysis { + let parsed: unknown + try { + parsed = JSON.parse(stripJsonFence(rawOutput)) + } catch { + throw new Error('模型输出格式异常:不是合法 JSON') + } + if (!parsed || typeof parsed !== 'object') { + throw new Error('模型输出格式异常:JSON 根节点不是对象') + } + const source = parsed as Record + const explicitText = clampText(source.explicit_text ?? source.explicitText, 120) + const emotion = clampText(source.emotion, 16) + const intent = clampText(source.intent, 20) + const topic = clampText(source.topic, 20) + if (!explicitText || !emotion || !intent || !topic) { + throw new Error('模型输出格式异常:缺少必要字段') + } + return { explicitText, emotion, intent, topic } +} + +function shouldFallbackJsonMode(error: unknown): boolean { + const statusCode = Number((error as ApiRequestError)?.statusCode || 0) + if (statusCode === 400 || statusCode === 404 || statusCode === 422) return true + const text = `${(error as Error)?.message || ''}\n${(error as ApiRequestError)?.responseBody || ''}`.toLowerCase() + return text.includes('response_format') || text.includes('json_object') || text.includes('json mode') +} + /** * 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。 * 使用 Node 原生 https/http 模块,无需任何第三方 SDK。 @@ -171,7 +243,8 @@ function callApi( model: string, messages: Array<{ role: string; content: string }>, timeoutMs: number = API_TIMEOUT_MS, - maxTokens: number = API_MAX_TOKENS_DEFAULT + maxTokens: number = API_MAX_TOKENS_DEFAULT, + options?: { responseFormatJson?: boolean } ): Promise { return new Promise((resolve, reject) => { const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') @@ -183,15 +256,19 @@ function callApi( return } - const body = JSON.stringify({ + const payload: Record = { model, messages, max_tokens: normalizeApiMaxTokens(maxTokens), temperature: API_TEMPERATURE, stream: false - }) + } + if (options?.responseFormatJson) { + payload.response_format = { type: 'json_object' } + } + const body = JSON.stringify(payload) - const options = { + const requestOptions = { hostname: urlObj.hostname, port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), path: urlObj.pathname + urlObj.search, @@ -205,11 +282,15 @@ function callApi( const isHttps = urlObj.protocol === 'https:' const requestFn = isHttps ? https.request : http.request - const req = requestFn(options, (res) => { + const req = requestFn(requestOptions, (res) => { let data = '' res.on('data', (chunk) => { data += chunk }) res.on('end', () => { try { + if (res.statusCode && res.statusCode >= 400) { + reject(new ApiRequestError(`API 请求失败 (${res.statusCode}): ${data.slice(0, 200)}`, res.statusCode, data)) + return + } const parsed = JSON.parse(data) const content = parsed?.choices?.[0]?.message?.content if (typeof content === 'string' && content.trim()) { @@ -465,11 +546,14 @@ class InsightService { const sessionId = session.username?.trim() || '' const displayName = session.displayName || sessionId insightLog('INFO', `测试目标会话:${displayName} (${sessionId})`) - await this.generateInsightForSession({ + const result = await this.generateInsightForSession({ sessionId, displayName, triggerReason: 'test' }) + if (!result.success) { + return { success: false, message: result.message } + } const notificationEnabled = this.config.get('aiInsightNotificationEnabled') !== false return { success: true, @@ -482,6 +566,47 @@ class InsightService { } } + /** + * 手动对指定会话立即触发一次 AI 见解。 + * 只新增触发入口;实际上下文、朋友圈/微博拼接、prompt 和入库仍走 generateInsightForSession。 + */ + async triggerSessionInsight(params: { + sessionId: string + displayName?: string + avatarUrl?: string + }): Promise { + const sessionId = String(params?.sessionId || '').trim() + if (!sessionId) { + return { success: false, message: '当前会话无效,无法触发 AI 见解' } + } + if (!this.isEnabled()) { + return { success: false, message: '请先在设置中开启「AI 见解」' } + } + + const { apiBaseUrl, apiKey } = this.getSharedAiModelConfig() + if (!apiBaseUrl || !apiKey) { + return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } + } + + try { + const connectResult = await chatService.connect() + if (!connectResult.success) { + return { success: false, message: '数据库连接失败,请先在"数据库连接"页完成配置' } + } + this.dbConnected = true + + const displayName = String(params?.displayName || sessionId).trim() || sessionId + insightLog('INFO', `手动触发当前会话见解:${displayName} (${sessionId})`) + return await this.generateInsightForSession({ + sessionId, + displayName, + triggerReason: 'manual' + }) + } catch (error) { + return { success: false, message: `触发失败:${(error as Error).message}` } + } + } + /** 获取今日触发统计(供设置页展示) */ getTodayStats(): { sessionId: string; count: number; times: string[] }[] { this.resetIfNewDay() @@ -590,6 +715,207 @@ ${topMentionText} } } + async generateMessageInsight(params: { + sessionId: string + displayName?: string + avatarUrl?: string + targetLocalId?: number + targetCreateTime?: number + targetMessageKey?: string + targetText: string + targetSenderName?: string + contextCount?: number + forceRefresh?: boolean + }): Promise<{ success: boolean; message: string; cached?: boolean; recordId?: string; data?: MessageInsightAnalysis }> { + const enabled = this.config.get('aiMessageInsightEnabled') === true + if (!enabled) { + return { success: false, message: '请先在设置中开启「消息解析」' } + } + + const sessionId = String(params?.sessionId || '').trim() + const targetText = clampText(params?.targetText || '', 500) + const targetCreateTime = Math.floor(Number(params?.targetCreateTime || 0)) + const targetLocalId = Math.floor(Number(params?.targetLocalId || 0)) + const targetMessageKey = String(params?.targetMessageKey || '').trim() + if (!sessionId || !targetText || targetCreateTime <= 0) { + return { success: false, message: '目标消息无效,无法解析' } + } + + if (params?.forceRefresh !== true) { + const cached = insightRecordService.findLatestMessageAnalysis({ + sessionId, + targetLocalId, + targetCreateTime, + targetMessageKey + }) + if (cached?.messageInsight?.analysis) { + return { + success: true, + message: '已读取缓存解析', + cached: true, + recordId: cached.id, + data: cached.messageInsight.analysis + } + } + } + + const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig() + if (!apiBaseUrl || !apiKey) { + return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } + } + + const configuredContextCount = Number(this.config.get('aiMessageInsightContextCount') || 50) + const contextCount = Math.max(1, Math.min(200, Math.floor(Number(params?.contextCount || configuredContextCount) || 50))) + const displayName = await this.resolveInsightSessionDisplayName(sessionId, String(params?.displayName || sessionId)) + const targetSenderName = clampText(params?.targetSenderName || displayName, 40) || displayName + const targetTextPreview = clampText(targetText, 120) + let avatarUrl = String(params?.avatarUrl || '').trim() || undefined + if (!avatarUrl) { + try { + const contact = await chatService.getContactAvatar(sessionId) + avatarUrl = String(contact?.avatarUrl || '').trim() || undefined + } catch { + avatarUrl = undefined + } + } + + let beforeMessages: Message[] = [] + let afterMessages: Message[] = [] + let contextReadError = '' + try { + const aroundResult = await chatService.getMessagesAround( + sessionId, + { localId: targetLocalId, createTime: targetCreateTime, messageKey: targetMessageKey }, + contextCount + ) + if (aroundResult.success) { + beforeMessages = aroundResult.before || [] + afterMessages = aroundResult.after || [] + } else { + contextReadError = aroundResult.error || '读取上下文失败' + } + } catch (error) { + contextReadError = (error as Error).message || String(error) + } + + const formatLine = (message: Message) => { + const senderName = message.isSend === 1 ? '我' : (message.senderDisplayName || targetSenderName || displayName) + return `${this.formatInsightMessageTimestamp(message.createTime)} ${senderName}:${this.formatInsightMessageContent(message)}` + } + const beforeText = beforeMessages.length > 0 ? beforeMessages.map(formatLine).join('\n') : '无' + const afterText = afterMessages.length > 0 ? afterMessages.map(formatLine).join('\n') : '无' + + const DEFAULT_MESSAGE_INSIGHT_PROMPT = `你是一个克制、准确的聊天语义分析助手。你的任务是把用户选中的一句聊天消息做深度解析,帮助用户理解对方未明说的含义。 + +严格要求: +1. 必须且只能输出合法的纯 JSON。 +2. 禁止输出解释说明、前言后语,禁止使用 Markdown 或代码块。 +3. 不要编造上下文没有支持的信息;不确定时用谨慎表述。 +4. explicit_text 用自然中文说明这句话可能想表达的真实含义,80字以内。 +5. emotion、intent、topic 必须是短标签。 + +JSON 输出格式: +{ + "explicit_text": "暗示转明示,80字以内", + "emotion": "2-6字情绪标签", + "intent": "2-8字意图标签", + "topic": "2-8字话题标签" +}` + const customPrompt = String(this.config.get('aiMessageInsightSystemPrompt') || '').trim() + const systemPrompt = customPrompt || DEFAULT_MESSAGE_INSIGHT_PROMPT + const userPromptBase = `会话:${displayName} +目标发送者:${targetSenderName} +目标消息时间:${this.formatInsightMessageTimestamp(targetCreateTime)} + +目标消息: +${targetText} + +目标消息之前的上下文(${beforeMessages.length} 条): +${beforeText} + +目标消息之后的上下文(${afterMessages.length} 条): +${afterText} + +请分析目标消息,只输出指定 JSON。` + const userPrompt = appendPromptCurrentTime(userPromptBase) + const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') + const requestMessages = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ] + + let rawOutput = '' + let responseFormatJson = true + let responseFormatFallback = false + let responseFormatFallbackReason = '' + const startedAt = Date.now() + try { + try { + rawOutput = await callApi(apiBaseUrl, apiKey, model, requestMessages, API_TIMEOUT_MS, maxTokens, { responseFormatJson: true }) + } catch (error) { + if (!shouldFallbackJsonMode(error)) throw error + responseFormatJson = false + responseFormatFallback = true + responseFormatFallbackReason = (error as Error).message || 'response_format 不受支持' + rawOutput = await callApi(apiBaseUrl, apiKey, model, requestMessages, API_TIMEOUT_MS, maxTokens) + } + const analysis = parseMessageInsightAnalysis(rawOutput) + const finalInsight = analysis.explicitText + const log: InsightRecordLog = { + endpoint, + model, + maxTokens, + temperature: API_TEMPERATURE, + triggerReason: 'message_analysis', + allowContext: true, + contextCount, + systemPrompt, + userPrompt, + rawOutput, + finalInsight, + durationMs: Date.now() - startedAt, + createdAt: Date.now(), + responseFormatJson, + responseFormatFallback, + responseFormatFallbackReason, + targetMessage: { + localId: targetLocalId, + createTime: targetCreateTime, + messageKey: targetMessageKey, + senderName: targetSenderName, + textPreview: targetTextPreview + }, + contextStats: { + requested: contextCount, + beforeTarget: beforeMessages.length, + afterTarget: afterMessages.length, + readError: contextReadError || undefined + }, + parsedAnalysis: analysis + } + const record = insightRecordService.addRecord({ + sessionId, + displayName, + avatarUrl, + sourceType: 'message_analysis', + triggerReason: 'message_analysis', + insight: finalInsight, + messageInsight: { + targetLocalId, + targetCreateTime, + targetMessageKey, + targetSenderName, + targetTextPreview, + analysis + }, + log + }) + return { success: true, message: '解析完成', cached: false, recordId: record.id, data: analysis } + } catch (error) { + return { success: false, message: `解析失败:${(error as Error).message}` } + } + } + // ── 私有方法 ──────────────────────────────────────────────────────────────── private isEnabled(): boolean { @@ -1099,10 +1425,10 @@ ${topMentionText} displayName: string triggerReason: InsightRecordTriggerReason silentDays?: number - }): Promise { + }): Promise { const { sessionId, displayName, triggerReason, silentDays } = params - if (!sessionId) return - if (!this.isEnabled()) return + if (!sessionId) return { success: false, message: '会话无效,无法生成见解' } + if (!this.isEnabled()) return { success: false, message: '请先在设置中开启「AI 见解」' } const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig() const allowContext = this.config.get('aiInsightAllowContext') as boolean @@ -1120,7 +1446,7 @@ ${topMentionText} if (!apiBaseUrl || !apiKey) { insightLog('WARN', 'API 地址或 Key 未配置,跳过见解生成') - return + return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } } // ── 构建 prompt ──────────────────────────────────────────────────────────── @@ -1210,9 +1536,9 @@ ${topMentionText} // 模型主动选择跳过 if (result.trim().toUpperCase() === 'SKIP' || result.trim().startsWith('SKIP')) { insightLog('INFO', `模型选择跳过 ${resolvedDisplayName}`) - return + return { success: true, message: `模型判断「${resolvedDisplayName}」暂无可生成的见解`, skipped: true } } - if (!this.isEnabled()) return + if (!this.isEnabled()) return { success: false, message: 'AI 见解已关闭,生成结果未保存' } const insight = result.trim() const notifTitle = `见解 · ${resolvedDisplayName}` @@ -1277,6 +1603,15 @@ ${topMentionText} insightLog('INFO', `已完成 ${resolvedDisplayName} 的见解处理`) this.recordTrigger(sessionId) + return { + success: true, + message: insightNotificationEnabled + ? `已生成「${resolvedDisplayName}」的 AI 见解,请查看通知弹窗` + : `已生成「${resolvedDisplayName}」的 AI 见解,AI 见解消息通知当前已关闭`, + recordId: record.id, + insight, + notificationEnabled: insightNotificationEnabled + } } catch (e) { insightDebugSection( 'ERROR', @@ -1284,6 +1619,7 @@ ${topMentionText} `错误信息:${(e as Error).message}\n\n堆栈:\n${(e as Error).stack || '[无堆栈]'}` ) insightLog('ERROR', `API 调用失败 (${resolvedDisplayName}): ${(e as Error).message}`) + return { success: false, message: `生成失败:${(e as Error).message}` } } } diff --git a/electron/services/keyServiceLinux.ts b/electron/services/keyServiceLinux.ts index b67a73b..6cc46a5 100644 --- a/electron/services/keyServiceLinux.ts +++ b/electron/services/keyServiceLinux.ts @@ -5,7 +5,7 @@ import { execFile, exec, spawn } from 'child_process' import { promisify } from 'util' import crypto from 'crypto' import { createRequire } from 'module'; -const require = createRequire(import.meta.url); +const require = createRequire(__filename); const execFileAsync = promisify(execFile) const execAsync = promisify(exec) diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 9cde3e1..e340121 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -14,6 +14,7 @@ export interface SnsLivePhoto { thumb: string md5?: string token?: string + thumbToken?: string key?: string encIdx?: string } @@ -23,6 +24,7 @@ export interface SnsMedia { thumb: string md5?: string token?: string + thumbToken?: string key?: string encIdx?: string livePhoto?: SnsLivePhoto @@ -126,13 +128,22 @@ const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => { let fixedUrl = url.replace('http://', 'https://') - // 只有非视频(即图片)才需要处理缩略图路径变 /0(获取原图) - // 支持 /150、/200、/480 等常见的缩略图尺寸 + // 只有非视频(即图片)才需要处理路径末尾的尺寸标识(/150、/200等)变为 /0 if (!isVideo) { - fixedUrl = fixedUrl.replace(/\/(150|200|480)($|\?)/, '/0$2') + const [pathPart, queryPart] = fixedUrl.split('?') + const fixedPath = pathPart.replace(/\/\d+$/, '/0') + fixedUrl = queryPart ? `${fixedPath}?${queryPart}` : fixedPath } - if (!token || fixedUrl.includes('token=')) return fixedUrl + // 如果没有提供新token,直接返回 + if (!token) return fixedUrl + + // 移除已有的token和idx参数 + const [pathPart, queryPart] = fixedUrl.split('?') + if (queryPart) { + const params = queryPart.split('&').filter(p => !p.startsWith('token=') && !p.startsWith('idx=')) + fixedUrl = params.length > 0 ? `${pathPart}?${params.join('&')}` : pathPart + } // 根据用户要求,视频链接组合方式为: BASE_URL + "?" + "token=" + token + "&idx=1" + 原有参数 if (isVideo) { @@ -705,6 +716,7 @@ class SnsService { url: urlMatch ? urlMatch[1].trim() : '', thumb: thumbMatch ? thumbMatch[1].trim() : '', token: urlToken || thumbToken, + thumbToken: thumbToken, key: urlKey || thumbKey, md5: urlMd5, encIdx: urlEncIdx || thumbEncIdx @@ -717,19 +729,24 @@ class SnsService { const lpUrlTag = lx.match(/]*)>/i) const lpThumb = lx.match(/]*>([^<]+)<\/thumb>/i) const lpThumbTag = lx.match(/]*)>/i) - let lpToken: string | undefined, lpKey: string | undefined, lpEncIdx: string | undefined + let lpUrlToken: string | undefined, lpThumbToken: string | undefined + let lpKey: string | undefined, lpEncIdx: string | undefined if (lpUrlTag?.[1]) { const a = lpUrlTag[1] - lpToken = a.match(/token="([^"]+)"/i)?.[1] + lpUrlToken = a.match(/token="([^"]+)"/i)?.[1] lpKey = a.match(/key="([^"]+)"/i)?.[1] lpEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1] } - if (!lpToken && lpThumbTag?.[1]) lpToken = lpThumbTag[1].match(/token="([^"]+)"/i)?.[1] - if (!lpKey && lpThumbTag?.[1]) lpKey = lpThumbTag[1].match(/key="([^"]+)"/i)?.[1] + if (lpThumbTag?.[1]) { + const a = lpThumbTag[1] + lpThumbToken = a.match(/token="([^"]+)"/i)?.[1] + if (!lpKey) lpKey = a.match(/key="([^"]+)"/i)?.[1] + } item.livePhoto = { url: lpUrl ? lpUrl[1].trim() : '', thumb: lpThumb ? lpThumb[1].trim() : '', - token: lpToken, + token: lpUrlToken || lpThumbToken, + thumbToken: lpThumbToken, key: lpKey, encIdx: lpEncIdx } @@ -1182,16 +1199,18 @@ class SnsService { const fixedMedia = (post.media || []).map((m: any) => ({ url: fixSnsUrl(m.url, m.token, isVideoPost), - thumb: fixSnsUrl(m.thumb, m.token, false), + thumb: fixSnsUrl(m.thumb, m.thumbToken || m.token, false), md5: m.md5, token: m.token, + thumbToken: m.thumbToken, key: isVideoPost ? (videoKey || m.key) : m.key, encIdx: m.encIdx || m.enc_idx, livePhoto: m.livePhoto ? { ...m.livePhoto, url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true), - thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false), + thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.thumbToken || m.livePhoto.token, false), token: m.livePhoto.token, + thumbToken: m.livePhoto.thumbToken, key: videoKey || m.livePhoto.key || m.key, encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx } : undefined diff --git a/src/components/Export/ExportDateRangeDialog.tsx b/src/components/Export/ExportDateRangeDialog.tsx index c858472..0bde562 100644 --- a/src/components/Export/ExportDateRangeDialog.tsx +++ b/src/components/Export/ExportDateRangeDialog.tsx @@ -551,8 +551,13 @@ export function ExportDateRangeDialog({ if (!open) return null return createPortal( -
-
event.stopPropagation()}> +
{ + event.stopPropagation() + onClose() + }} + >
event.stopPropagation()}>

{title}

+ ))} +
, + document.body + ) + : null + + const excelColumnsDropdown = showExportExcelColumnsSelect && exportExcelColumnsPlacement + ? createPortal( +
event.stopPropagation()} + > + {exportExcelColumnOptions.map((option) => ( + + ))} +
, + document.body + ) + : null + return (
@@ -273,6 +422,8 @@ export function ExportDefaultsSettingsForm({ - {showExportFileNamingModeSelect && ( -
- {exportFileNamingModeOptions.map((option) => ( - - ))} -
- )} + {fileNamingModeDropdown}
@@ -317,6 +448,8 @@ export function ExportDefaultsSettingsForm({ - {showExportExcelColumnsSelect && ( -
- {exportExcelColumnOptions.map((option) => ( - - ))} -
- )} + {excelColumnsDropdown}
@@ -371,7 +483,8 @@ export function ExportDefaultsSettingsForm({ notify(`已${e.target.checked ? '开启' : '关闭'}默认导出图片`, true) }} /> - 图片 +