merge: 同步 dev 并解决 AI 见解冲突

This commit is contained in:
clearyss
2026-05-29 14:58:55 +08:00
41 changed files with 6741 additions and 403 deletions

View File

@@ -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 || ''),

View File

@@ -32,6 +32,8 @@ import { httpService } from './services/httpService'
import { messagePushService } from './services/messagePushService'
import { insightService } from './services/insightService'
import { insightRecordService } from './services/insightRecordService'
import { insightProfileService } from './services/insightProfileService'
import { groupSummaryService } from './services/groupSummaryService'
import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService'
import { bizService } from './services/bizService'
import { backupService } from './services/backupService'
@@ -397,13 +399,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()
}
@@ -444,6 +440,7 @@ const pruneChatHistoryPayloadStore = (): void => {
}
type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
type CloseRestoreMethod = 'tray' | 'dock'
// 更新下载状态管理Issue #294 修复)
let isDownloadInProgress = false
@@ -817,29 +814,47 @@ const isSilentStartupEnabled = (): boolean => {
return configService?.get('silentStartup') === true
}
const getCloseRestoreMethod = (): CloseRestoreMethod | null => {
if (tray) return 'tray'
if (process.platform === 'darwin') return 'dock'
return null
}
const canKeepMainWindowInBackground = (): boolean => {
return getCloseRestoreMethod() !== null
}
const getPlatformIconName = (): string => {
if (process.platform === 'linux') return 'icon.png'
if (process.platform === 'darwin') return 'icon.icns'
return 'icon.ico'
}
const resolveAppIconPath = (): string => {
const iconName = getPlatformIconName()
if (!process.env.VITE_DEV_SERVER_URL) {
return join(process.resourcesPath, iconName)
}
if (process.platform === 'darwin') {
return join(__dirname, '../resources/icons/macos/icon.icns')
}
return join(__dirname, `../public/${iconName}`)
}
const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => {
if (isClosePromptVisible) return
isClosePromptVisible = true
const restoreMethod = getCloseRestoreMethod()
win.webContents.send('window:confirmCloseRequested', {
canMinimizeToTray: Boolean(tray)
canMinimizeToTray: restoreMethod !== null,
restoreMethod: restoreMethod ?? undefined
})
}
function createWindow(options: { autoShow?: boolean } = {}) {
// 获取图标路径 - 打包后在 resources 目录
const { autoShow = true } = options
let iconName = 'icon.ico';
if (process.platform === 'linux') {
iconName = 'icon.png';
} else if (process.platform === 'darwin') {
iconName = 'icon.icns';
}
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, `../public/${iconName}`)
: join(process.resourcesPath, iconName);
const iconPath = resolveAppIconPath()
const win = new BrowserWindow({
width: 1400,
@@ -912,7 +927,7 @@ function createWindow(options: { autoShow?: boolean } = {}) {
return
}
if (closeBehavior === 'tray' && tray) {
if (closeBehavior === 'tray' && canKeepMainWindowInBackground()) {
win.hide()
return
}
@@ -1775,6 +1790,7 @@ function registerIpcHandlers() {
}
void messagePushService.handleConfigChanged(key)
void insightService.handleConfigChanged(key)
void groupSummaryService.handleConfigChanged(key)
return result
})
@@ -1792,6 +1808,7 @@ function registerIpcHandlers() {
sessionId?: string
startTime?: number
endTime?: number
sourceType?: 'insight' | 'message_analysis' | 'all'
limit?: number
offset?: number
}) => {
@@ -1818,6 +1835,30 @@ function registerIpcHandlers() {
return insightService.triggerTest()
})
ipcMain.handle('insight:triggerSessionInsight', async (_, payload: {
sessionId: string
displayName?: string
avatarUrl?: string
}) => {
return insightService.triggerSessionInsight(payload)
})
ipcMain.handle('insight:listProfileStatuses', async (_, sessionIds: string[]) => {
return insightProfileService.listProfileStatuses(Array.isArray(sessionIds) ? sessionIds : [])
})
ipcMain.handle('insight:generateProfile', async (_, payload: {
sessionId: string
displayName?: string
avatarUrl?: string
}) => {
return insightProfileService.generateProfile(payload)
})
ipcMain.handle('insight:cancelProfile', async (_, sessionId?: string) => {
return insightProfileService.cancelProfile(sessionId)
})
ipcMain.handle('insight:generateFootprintInsight', async (_, payload: {
rangeLabel: string
summary: {
@@ -1834,6 +1875,54 @@ 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('groupSummary:listRecords', async (_, filters?: {
sessionId?: string
startTime?: number
endTime?: number
limit?: number
offset?: number
}) => {
return groupSummaryService.listRecords(filters || {})
})
ipcMain.handle('groupSummary:getRecord', async (_, id: string) => {
return groupSummaryService.getRecord(id)
})
ipcMain.handle('groupSummary:triggerManual', async (_, payload: {
sessionId: string
displayName?: string
avatarUrl?: string
startTime: number
endTime: number
}) => {
return groupSummaryService.triggerManual(payload)
})
ipcMain.handle('groupSummary:triggerDay', async (_, payload: {
sessionId: string
displayName?: string
avatarUrl?: string
date: string
}) => {
return groupSummaryService.triggerDay(payload)
})
ipcMain.handle('social:saveWeiboCookie', async (_, rawInput: string) => {
try {
if (!configService) {
@@ -1870,6 +1959,7 @@ function registerIpcHandlers() {
configService?.clear()
messagePushService.handleConfigCleared()
insightService.handleConfigCleared()
groupSummaryService.handleConfigCleared()
return true
})
@@ -2113,7 +2203,7 @@ function registerIpcHandlers() {
try {
if (action === 'tray') {
if (tray) {
if (canKeepMainWindowInBackground()) {
mainWindow.hide()
return true
}
@@ -2349,8 +2439,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 +3303,8 @@ function registerIpcHandlers() {
imageAesKey: imageKeys.aesKey,
resourcesPath,
userDataPath,
logEnabled
logEnabled,
isPackaged: app.isPackaged
}
})
@@ -3344,7 +3435,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 +3503,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
}
})
@@ -4206,6 +4299,7 @@ app.whenReady().then(async () => {
})
messagePushService.start()
insightService.start()
groupSummaryService.start()
await delay(200)
// 已完成引导时,在 Splash 阶段预热核心数据(联系人、消息库索引等)
@@ -4259,18 +4353,7 @@ app.whenReady().then(async () => {
ensureWeChatRequestHeaderInterceptor()
mainWindow = createWindow({ autoShow: false })
let iconName = 'icon.ico';
if (process.platform === 'linux') {
iconName = 'icon.png';
} else if (process.platform === 'darwin') {
iconName = 'icon.icns';
}
const isDev = !!process.env.VITE_DEV_SERVER_URL
const resolvedTrayIcon = isDev
? join(__dirname, `../public/${iconName}`)
: join(process.resourcesPath, iconName);
const resolvedTrayIcon = resolveAppIconPath()
try {
@@ -4348,6 +4431,14 @@ app.whenReady().then(async () => {
await httpService.autoStart()
app.on('activate', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (!mainWindow.isVisible()) {
mainWindow.show()
}
mainWindow.focus()
return
}
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow()
}
@@ -4364,6 +4455,7 @@ const shutdownAppServices = async (): Promise<void> => {
destroyNotificationWindow()
messagePushService.stop()
insightService.stop()
groupSummaryService.stop()
// 兜底5秒后强制退出防止某个异步任务卡住导致进程残留
const forceExitTimer = setTimeout(() => {
console.warn('[App] Force exit after timeout')

View File

@@ -1,5 +1,10 @@
import { contextBridge, ipcRenderer } from 'electron'
type CloseConfirmPayload = {
canMinimizeToTray: boolean
restoreMethod?: 'tray' | 'dock'
}
// 暴露给渲染进程的 API
contextBridge.exposeInMainWorld('electronAPI', {
// 配置
@@ -106,8 +111,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener)
},
close: () => ipcRenderer.send('window:close'),
onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => {
const listener = (_: unknown, payload: { canMinimizeToTray: boolean }) => callback(payload)
onCloseConfirmRequested: (callback: (payload: CloseConfirmPayload) => void) => {
const listener = (_: unknown, payload: CloseConfirmPayload) => callback(payload)
ipcRenderer.on('window:confirmCloseRequested', listener)
return () => ipcRenderer.removeListener('window:confirmCloseRequested', listener)
},
@@ -195,7 +200,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 }
@@ -585,6 +590,18 @@ 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),
listProfileStatuses: (sessionIds: string[]) => ipcRenderer.invoke('insight:listProfileStatuses', sessionIds),
generateProfile: (payload: {
sessionId: string
displayName?: string
avatarUrl?: string
}) => ipcRenderer.invoke('insight:generateProfile', payload),
cancelProfile: (sessionId?: string) => ipcRenderer.invoke('insight:cancelProfile', sessionId),
generateFootprintInsight: (payload: {
rangeLabel: string
summary: {
@@ -597,7 +614,37 @@ 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)
},
groupSummary: {
listRecords: (filters?: any) => ipcRenderer.invoke('groupSummary:listRecords', filters),
getRecord: (id: string) => ipcRenderer.invoke('groupSummary:getRecord', id),
triggerManual: (payload: {
sessionId: string
displayName?: string
avatarUrl?: string
startTime: number
endTime: number
}) => ipcRenderer.invoke('groupSummary:triggerManual', payload),
triggerDay: (payload: {
sessionId: string
displayName?: string
avatarUrl?: string
date: string
}) => ipcRenderer.invoke('groupSummary:triggerDay', payload)
},
social: {

View File

@@ -1,8 +1,30 @@
/**
* 账号目录解析器Worker 线程 / 主进程通用)
*
* 职责:在 dbPath 根目录下,根据传入的 wxid找出微信"实际写入数据"
* 的那个账号子目录,例如:
* dbPath = <微信数据根目录>
* wxid = customwxid_abcd 或 customwxid
* 期望返回 <微信数据根目录>/customwxid_abcd带后缀、有 session.db 的那个)
*
* 与 ConfigService.getAccountDir 行为保持一致;二者实现独立是因为本文件
* 也会在 Worker 线程中被加载,无法依赖 electron-store。
*/
import { existsSync, readdirSync, statSync } from 'fs'
import { join } from 'path'
// 解析结果缓存(进程内,避免重复 IO。key = `${dbPath}|${cleanedWxid}`
const accountDirCache = new Map<string, string>()
/**
* 把 wxid 字符串"标准化"为目录前缀。
* - wxid_xxx_yyyy → wxid_xxx wxid_ 后只取第一段)
* - 自定义微信号_后缀(4 位) → 自定义微信号 (例如 customwxid_abcd → customwxid
* - 其他形式 → 原样返回
*
* 注意:清洗只是为了得到"前缀"用于扫描匹配,并不代表清洗结果就是真实目录名。
* 真实目录名仍需在 dbPath 下按"前缀 + 任意后缀"扫描得出。
*/
const cleanAccountDirName = (dirName: string): string => {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
@@ -27,6 +49,39 @@ const isDirectory = (path: string): boolean => {
}
}
/**
* 解析账号目录的真实绝对路径。
*
* ## 修复 #996错误码 -3001未找到数据库目录
*
* ### 旧实现存在的两处严重缺陷
* 1. **对 wxid_ 开头的目录强制要求"带后缀"**
* 未自定义微信号的普通用户,目录就叫 `wxid_X`(无任何后缀),
* 旧逻辑会因为段数不足而把它过滤掉,导致这类用户根本匹配不到。
*
* 2. **对非 wxid_ 开头(自定义微信号)走短路返回,且不校验目录有效性**
* 旧实现写法是
* ```
* if (!lowerWxid.startsWith('wxid_')) {
* const direct = join(root, cleanedWxid)
* if (existsSync(direct)) return direct // ← 直接返回,没校验里面有没有 db_storage
* }
* ```
* 叠加 `cleanAccountDirName` 会把 `<自定义号>_<4位后缀>` 清洗成
* `<自定义号>`,于是无论用户存的是哪个 wxid都会命中旧的、无后缀的
* 空目录(它真实存在但里面没有 db_storage最终触发 -3001。
*
* ### 修复后的统一匹配流程
* 1. 扫描 dbPath 下所有子目录;
* 2. 同时接受**精确匹配**(`entry == cleanedWxid`) 与
* **后缀匹配**(`entry.startsWith(cleanedWxid + '_')`) 两种命中方式;
* 3. 用 {@link accountDirLooksValid} 过滤掉"看起来根本不像账号目录"的项
* (没有 db_storage 也没有 FileStorage/Image[2]
* 4. 在剩余候选中按以下优先级排序,取最优:
* - **有 session.db** > 没有:区分"真正写入数据"与"残留空目录"
* - **后缀匹配** > 精确匹配:与微信 4.x 实际写入目录的命名习惯一致;
* - **修改时间更新** > 更旧:兜底。
*/
export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null => {
if (!dbPath || !wxid) return null
@@ -34,6 +89,7 @@ export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null
const normalized = dbPath.replace(/[\\/]+$/, '')
const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}`
// 命中缓存且目标仍存在则直接返回;目标已被删除的过期缓存项会被剔除
const cached = accountDirCache.get(cacheKey)
if (cached && existsSync(cached)) return cached
if (cached && !existsSync(cached)) {
@@ -41,16 +97,12 @@ export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null
}
const lowerWxid = cleanedWxid.toLowerCase()
if (!lowerWxid.startsWith('wxid_')) {
const direct = join(normalized, cleanedWxid)
if (existsSync(direct) && isDirectory(direct)) {
accountDirCache.set(cacheKey, direct)
return direct
}
}
try {
const entries = readdirSync(normalized)
type Candidate = { entryPath: string; isExact: boolean; hasSession: boolean; mtime: number }
const candidates: Candidate[] = []
for (const entry of entries) {
const entryPath = join(normalized, entry)
if (!isDirectory(entryPath)) continue
@@ -58,16 +110,72 @@ export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null
const lowerEntry = entry.toLowerCase()
const isExactMatch = lowerEntry === lowerWxid
const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`)
const shouldMatch = lowerWxid.startsWith('wxid_')
? isSuffixMatch
: (isExactMatch || isSuffixMatch)
// 既不是精确命中、也不是前缀命中 → 与本 wxid 无关,跳过
if (!isExactMatch && !isSuffixMatch) continue
if (shouldMatch) {
accountDirCache.set(cacheKey, entryPath)
return entryPath
}
// 看起来不像账号目录(连 db_storage 与 FileStorage/Image 都没有)→ 跳过
// 这一步是修复 #996 的关键:自定义微信号场景下旧的、无后缀空目录
// 会在这里被过滤掉,避免后续 wcdbCore.open 误判为真实账号目录。
if (!accountDirLooksValid(entryPath)) continue
let mtime = 0
try { mtime = statSync(entryPath).mtimeMs } catch { /* 忽略 stat 异常 */ }
candidates.push({
entryPath,
isExact: isExactMatch,
hasSession: accountDirHasSessionDb(entryPath),
mtime,
})
}
} catch { }
if (candidates.length > 0) {
candidates.sort((a, b) => {
// 1) 优先选有 session.db 的(真实写入数据的目录)
if (a.hasSession !== b.hasSession) return a.hasSession ? -1 : 1
// 2) 其次优先选"带后缀"的(更接近微信 4.x 实际写入目录)
if (a.isExact !== b.isExact) return a.isExact ? 1 : -1
// 3) 最后按修改时间倒序(最新的优先)
return b.mtime - a.mtime
})
const best = candidates[0].entryPath
accountDirCache.set(cacheKey, best)
return best
}
} catch { /* 扫描目录失败时直接 fallthrough 返回 null */ }
return null
}
/**
* 浅层判定一个目录"看起来像不像账号目录"
* 存在 db_storage 子目录,或存在 FileStorage/Image[2] 子目录之一即认为是。
*
* 用于在候选阶段剔除"同名但实际无数据"的残留空目录
*(例如自定义微信号后遗留下来的旧 wxid 主目录)。
*/
const accountDirLooksValid = (entryPath: string): boolean => {
return (
existsSync(join(entryPath, 'db_storage')) ||
existsSync(join(entryPath, 'FileStorage', 'Image')) ||
existsSync(join(entryPath, 'FileStorage', 'Image2'))
)
}
/**
* 检测账号目录下是否存在 session.db。
*
* 是排序优先级里"区分真实写入数据 vs 仅有空 db_storage 骨架"的关键判据,
* 同时兼容微信 4.x 两种已知布局:
* - db_storage/session/session.db (新版本嵌套布局)
* - db_storage/session.db (部分版本扁平布局)
*/
const accountDirHasSessionDb = (entryPath: string): boolean => {
const candidates = [
join(entryPath, 'db_storage', 'session', 'session.db'),
join(entryPath, 'db_storage', 'session.db'),
]
for (const candidate of candidates) {
if (existsSync(candidate)) return true
}
return false
}

View File

@@ -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,
@@ -467,10 +468,10 @@ export class BackupService {
)
if (!opened) {
const detail = await wcdbService.getLastInitError().catch(() => null)
return { success: false, error: detail || `目标账号 ${accountDirName} 数据库连接失败` }
return { success: false, error: detail || `目标账号 ${accountDir} 数据库连接失败` }
}
return { success: true, wxid: accountDirName, dbPath, dbStorage }
return { success: true, wxid: accountDir, dbPath, dbStorage }
}
private buildDbId(kind: BackupDbKind, index: number, dbPath: string): string {
@@ -857,10 +858,13 @@ export class BackupService {
if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' }
const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest
if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) {
emitBackupProgress({ phase: 'failed', message: '不支持的备份包格式' })
return { success: false, error: '不支持的备份包格式' }
}
emitBackupProgress({ phase: 'done', message: '备份包已读取' })
return { success: true, manifest }
} catch (e) {
emitBackupProgress({ phase: 'failed', message: e instanceof Error ? e.message : String(e) })
return { success: false, error: e instanceof Error ? e.message : String(e) }
} finally {
if (extractDir) {

View File

@@ -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<Message[]> => {
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<Buffer | null> {
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<MyFootprintPrivateSegment[]> {
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<Record<string, any>>) {
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

View File

@@ -129,6 +129,14 @@ interface ConfigSchema {
// AI 足迹
aiFootprintEnabled: boolean
aiFootprintSystemPrompt: string
aiGroupSummaryEnabled: boolean
aiGroupSummaryIntervalHours: number
aiGroupSummarySystemPrompt: string
aiGroupSummaryFilterMode: 'whitelist' | 'blacklist'
aiGroupSummaryFilterList: string[]
aiMessageInsightEnabled: boolean
aiMessageInsightContextCount: number
aiMessageInsightSystemPrompt: string
/** 是否将 AI 见解调试日志输出到桌面 */
aiInsightDebugLogEnabled: boolean
autoDownloadHighRes: boolean
@@ -252,6 +260,14 @@ export class ConfigService {
aiInsightWeiboBindings: {},
aiFootprintEnabled: false,
aiFootprintSystemPrompt: '',
aiGroupSummaryEnabled: false,
aiGroupSummaryIntervalHours: 4,
aiGroupSummarySystemPrompt: '',
aiGroupSummaryFilterMode: 'whitelist',
aiGroupSummaryFilterList: [],
aiMessageInsightEnabled: false,
aiMessageInsightContextCount: 50,
aiMessageInsightSystemPrompt: '',
aiInsightDebugLogEnabled: false,
autoDownloadHighRes: false,
autoDownloadWhitelist: []
@@ -817,6 +833,12 @@ export class ConfigService {
if (!sharedModel && legacyModel) {
this.set('aiModelApiModel', legacyModel)
}
const groupSummaryFilterMode = String(this.store.get('aiGroupSummaryFilterMode' as any) || '').trim()
if (groupSummaryFilterMode === 'blacklist') {
this.store.set('aiGroupSummaryFilterList' as any, [] as any)
this.store.set('aiGroupSummaryFilterMode' as any, 'whitelist' as any)
}
}
// === 验证 ===
@@ -899,12 +921,78 @@ export class ConfigService {
}
/**
* 获取账号目录路径
* 统一的账号目录解析方法,所有服务应该使用此方法而不是自己实现
* 浅层判定一个目录"看起来像不像账号目录"
* 存在 db_storage 子目录,或存在 FileStorage/Image[2] 子目录之一即认为是。
*
* @param dbPath 数据库根目录(可选,默认从配置读取)
* @param wxid 微信ID可选默认从配置读取
* @returns 账号目录的完整路径,如果找不到返回 null
* 用于在 {@link getAccountDir} 候选阶段剔除"同名但实际无数据"的残留空目录
* (例如自定义微信号后微信遗留下来的旧 wxid 主目录)。
*/
private accountDirLooksValid(entryPath: string): boolean {
return (
existsSync(join(entryPath, 'db_storage')) ||
existsSync(join(entryPath, 'FileStorage', 'Image')) ||
existsSync(join(entryPath, 'FileStorage', 'Image2'))
)
}
/**
* 检测账号目录下是否存在 session.db。
*
* 是排序优先级里"区分真实写入数据 vs 仅有空 db_storage 骨架"的关键判据,
* 同时兼容微信 4.x 两种已知布局:
* - db_storage/session/session.db (新版本嵌套布局)
* - db_storage/session.db (部分版本扁平布局)
*/
private accountDirHasSessionDb(entryPath: string): boolean {
const candidates = [
join(entryPath, 'db_storage', 'session', 'session.db'),
join(entryPath, 'db_storage', 'session.db'),
]
for (const candidate of candidates) {
if (existsSync(candidate)) return true
}
return false
}
/**
* 获取账号目录的真实绝对路径。
*
* 这是 WeFlow 统一的账号目录解析入口,所有服务都应通过本方法获取
* 账号目录,而不要自行拼接 `join(dbPath, wxid)`。
*
* ## 修复 #996错误码 -3001未找到数据库目录
*
* ### 旧实现存在的两处严重缺陷
* 1. **对 wxid_ 开头强制要求"带后缀"**
* 未自定义微信号的普通用户,目录就叫 `wxid_X`(无任何后缀),
* 旧逻辑把它过滤掉,导致这类用户根本匹配不到自己的账号目录。
*
* 2. **对非 wxid_ 开头(自定义微信号)走短路返回,不校验目录有效性**
* 旧实现写法是
* ```ts
* if (!lowerWxid.startsWith('wxid_')) {
* const direct = join(root, cleanedWxid)
* if (existsSync(direct)) return direct // ← 直接返回,没校验里面有没有 db_storage
* }
* ```
* 叠加 {@link cleanAccountDirName} 会把 `<自定义号>_<4位后缀>` 清洗成
* `<自定义号>`,于是无论用户保存的是哪个 wxid都会命中旧的、
* 无后缀的空目录(它真实存在但里面没有 db_storage最终在
* wcdbCore.open 阶段触发 -3001。
*
* ### 修复后的统一匹配流程
* 1. 扫描 dbPath 下所有子目录;
* 2. 同时接受**精确匹配**(`entry == cleanedWxid`) 与
* **后缀匹配**(`entry.startsWith(cleanedWxid + '_')`) 两种命中方式;
* 3. 用 {@link accountDirLooksValid} 过滤掉"看起来根本不像账号目录"的项;
* 4. 在剩余候选中按以下优先级排序,取最优:
* - **有 session.db** > 没有:区分"真正写入数据"与"残留空目录"
* - **后缀匹配** > 精确匹配:与微信 4.x 实际写入目录的命名习惯一致;
* - **修改时间更新** > 更旧:兜底。
*
* @param dbPath 数据库根目录(可选,默认从配置读取 `dbPath`
* @param wxid 微信 ID可选默认从配置读取 `myWxid`
* @returns 账号目录的完整绝对路径;找不到返回 null
*/
getAccountDir(dbPath?: string, wxid?: string): string | null {
const actualDbPath = dbPath || this.get('dbPath')
@@ -916,26 +1004,20 @@ export class ConfigService {
const normalized = actualDbPath.replace(/[\\/]+$/, '')
const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}`
// 检查缓存
// 命中缓存且目标仍存在则直接返回;目标已被删除的过期缓存项会被剔除
const cached = this.accountDirCache.get(cacheKey)
if (cached && existsSync(cached)) return cached
if (cached && !existsSync(cached)) {
this.accountDirCache.delete(cacheKey)
}
// 尝试直接路径(非 wxid_ 开头的账号)
const lowerWxid = cleanedWxid.toLowerCase()
if (!lowerWxid.startsWith('wxid_')) {
const direct = join(normalized, cleanedWxid)
if (existsSync(direct) && this.isDirectory(direct)) {
this.accountDirCache.set(cacheKey, direct)
return direct
}
}
// 扫描目录查找匹配的账号目录
try {
const entries = readdirSync(normalized)
type Candidate = { entryPath: string; isExact: boolean; hasSession: boolean; mtime: number }
const candidates: Candidate[] = []
for (const entry of entries) {
const entryPath = join(normalized, entry)
if (!this.isDirectory(entryPath)) continue
@@ -943,16 +1025,36 @@ export class ConfigService {
const lowerEntry = entry.toLowerCase()
const isExactMatch = lowerEntry === lowerWxid
const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`)
// 既不是精确命中、也不是前缀命中 → 与本 wxid 无关,跳过
if (!isExactMatch && !isSuffixMatch) continue
// wxid_ 开头只接受带后缀的目录;其他账号精确匹配或带后缀都可以
const shouldMatch = lowerWxid.startsWith('wxid_')
? isSuffixMatch
: (isExactMatch || isSuffixMatch)
// 看起来不像账号目录(连 db_storage 与 FileStorage/Image 都没有)→ 跳过
// 这一步是修复 #996 的关键:自定义微信号场景下旧的、无后缀空目录
// 会在这里被过滤掉,避免后续 wcdbCore.open 误判为真实账号目录。
if (!this.accountDirLooksValid(entryPath)) continue
if (shouldMatch) {
this.accountDirCache.set(cacheKey, entryPath)
return entryPath
}
let mtime = 0
try { mtime = statSync(entryPath).mtimeMs } catch { /* 忽略 stat 异常 */ }
candidates.push({
entryPath,
isExact: isExactMatch,
hasSession: this.accountDirHasSessionDb(entryPath),
mtime,
})
}
if (candidates.length > 0) {
candidates.sort((a, b) => {
// 1) 优先选有 session.db 的(真实写入数据的目录)
if (a.hasSession !== b.hasSession) return a.hasSession ? -1 : 1
// 2) 其次优先选"带后缀"的(更接近微信 4.x 实际写入目录)
if (a.isExact !== b.isExact) return a.isExact ? 1 : -1
// 3) 最后按修改时间倒序(最新的优先)
return b.mtime - a.mtime
})
const best = candidates[0].entryPath
this.accountDirCache.set(cacheKey, best)
return best
}
} catch { }

View File

@@ -137,7 +137,25 @@ export class DbPathService {
}
/**
* 查找账号目录(包含 db_storage 或图片目录)
* 查找 dbPath 根目录下所有"看起来像账号目录"的子目录名。
*
* ## 修复 #996错误码 -3001未找到数据库目录
*
* ### 旧实现的过滤逻辑及缺陷
* 旧实现对名字以 `wxid_` 开头的目录额外加了一道判断:
* "段数(按下划线切分)必须 ≥ 3否则跳过"
* 也就是 `wxid_X_<suffix>` 才算合法、`wxid_X` 一律忽略。
*
* 这种粗暴过滤会**误伤未自定义微信号的普通用户**——他们的真实账号目录
* 就叫 `wxid_X`(没有任何数字后缀),结果在欢迎页扫描时压根看不到自己。
*
* ### 修复策略
* 1. **不再依据"段数"过滤**:先按是否真的是账号目录(含 db_storage 或
* FileStorage/Image[2])一视同仁地收集所有候选;
* 2. **用 {@link dedupeAccountDirs} 做更精准的去重**:仅当 `wxid_X` 和
* `wxid_X_<suffix>` 同时存在时(这是自定义微信号后微信遗留旧空目录
* 的典型场景),才二选一保留"更像微信实际在用"的那个,避免下拉框里
* 出现两个看起来一样但只有一个能用的混乱选项。
*/
findAccountDirs(rootPath: string): string[] {
const resolvedRootPath = expandHomePath(rootPath)
@@ -160,23 +178,93 @@ export class DbPathService {
// 检查是否有有效账号目录结构
if (this.isAccountDir(entryPath)) {
// 过滤掉不带后缀的 wxid_ 目录
const lowerEntry = entry.toLowerCase()
if (lowerEntry.startsWith('wxid_')) {
// wxid_ 开头的目录必须带后缀wxid_xxx_yyyy 格式)
const parts = entry.split('_')
if (parts.length <= 2) {
// wxid_xxx 格式,跳过
continue
}
}
accounts.push(entry)
}
}
}
} catch { }
return accounts
return this.dedupeAccountDirs(resolvedRootPath, accounts)
}
/**
* 账号目录去重:仅当存在"前缀-后缀变体对"时(即同时出现 `wxid_X` 与
* `wxid_X_<suffix>`),才二选一保留"微信实际在用"的那个目录。
*
* - 仅有一个候选目录时,原样返回,不做任何处理;
* - 没有匹配到变体对的目录也都保留(互不相关的多账号场景);
* - 真正二选一时由 {@link shouldPreferSuffixedDir} 决定胜负。
*/
private dedupeAccountDirs(rootPath: string, names: string[]): string[] {
if (names.length <= 1) return names.slice()
const lowered = names.map(n => n.toLowerCase())
const toSkip = new Set<string>()
// O(n^2) 双层循环找出所有"前缀-后缀变体对"。账号数极少,性能可忽略。
for (let i = 0; i < names.length; i++) {
for (let j = 0; j < names.length; j++) {
if (i === j) continue
// 判定 names[j] 是 names[i] 的"带后缀变体":以 `<i>_` 开头
if (lowered[j].startsWith(lowered[i] + '_')) {
const baseName = names[i]
const suffixedName = names[j]
if (this.shouldPreferSuffixedDir(rootPath, baseName, suffixedName)) {
toSkip.add(baseName) // 留 suffixedName去掉无后缀的旧目录
} else {
toSkip.add(suffixedName) // 反之亦然
}
}
}
}
return names.filter(n => !toSkip.has(n))
}
/**
* 在"无后缀目录"与"带后缀目录"之间二选一时,判定后者是否应该胜出。
*
* 优先级(从高到低):
* 1) 谁含有 session.db 谁优先 —— 这是"数据真实写入"最强的信号;
* 2) 都含或都不含 session.db 时,比较修改时间,更新的优先;
* 3) 兜底返回 true即默认保留带后缀的目录与微信 4.x 自定义微信号
* 后真实目录命名一致)。
*/
private shouldPreferSuffixedDir(rootPath: string, baseName: string, suffixedName: string): boolean {
const basePath = join(rootPath, baseName)
const suffixedPath = join(rootPath, suffixedName)
const baseHasSession = this.hasSessionDb(basePath)
const suffixedHasSession = this.hasSessionDb(suffixedPath)
if (baseHasSession !== suffixedHasSession) {
return suffixedHasSession
}
const baseTime = this.getAccountModifiedTime(basePath)
const suffixedTime = this.getAccountModifiedTime(suffixedPath)
if (baseTime !== suffixedTime) {
return suffixedTime >= baseTime
}
return true
}
/**
* 浅层检测账号目录下是否存在 session.db"数据是否真实写入"的判据)。
*
* 仅检测两条已知路径,不做深度递归,避免在大目录上拖慢扫描:
* - db_storage/session/session.db (新版本嵌套布局)
* - db_storage/session.db (部分版本扁平布局)
*/
private hasSessionDb(accountDir: string): boolean {
const candidates = [
join(accountDir, 'db_storage', 'session', 'session.db'),
join(accountDir, 'db_storage', 'session.db'),
]
for (const candidate of candidates) {
if (existsSync(candidate)) return true
}
return false
}
private isAccountDir(entryPath: string): boolean {
@@ -225,7 +313,20 @@ export class DbPathService {
}
/**
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users
* 扫描 dbPath 下"目录名包含下划线"的文件夹作为 wxid 候选。
* 与 {@link findAccountDirs} 的区别:本方法不要求目录里真的有 db_storage/
* FileStorage仅按命名特征判断结果会暴露给"手动选择 wxid"的弹窗使用。
*
* ## 修复 #996错误码 -3001未找到数据库目录
*
* 旧实现对 `wxid_` 开头的目录额外要求"段数 ≥ 3"才放行,会误伤未自定义
* 微信号的普通用户(他们的真实目录就叫 `wxid_X`)。现在改为不再依据段数
* 过滤,并在末尾通过 {@link dedupeAccountDirs} 处理 `wxid_X` 与
* `wxid_X_<suffix>` 同时存在的去重场景。
*
* 排除规则保留:
* - 微信本身的非账号目录(如 `all_users`
* - 不含下划线的文件夹(不可能是 wxid
*/
scanWxidCandidates(rootPath: string): WxidInfo[] {
const resolvedRootPath = expandHomePath(rootPath)
@@ -243,15 +344,6 @@ export class DbPathService {
if (lower === 'all_users') continue
if (!entry.includes('_')) continue
// 过滤掉不带后缀的 wxid_ 目录
if (lower.startsWith('wxid_')) {
const parts = entry.split('_')
if (parts.length <= 2) {
// wxid_xxx 格式,跳过
continue
}
}
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
}
}
@@ -266,7 +358,13 @@ export class DbPathService {
}
} catch { }
const sorted = wxids.sort((a, b) => {
// 修复 #996对扫描到的 wxid 候选做去重,避免同时显示 wxid_X 与 wxid_X_<suffix>。
const dedupedNames = new Set(
this.dedupeAccountDirs(resolvedRootPath, wxids.map(w => w.wxid))
)
const deduped = wxids.filter(w => dedupedNames.has(w.wxid))
const sorted = deduped.sort((a, b) => {
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
return a.wxid.localeCompare(b.wxid)
});

View File

@@ -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(

View File

@@ -0,0 +1,384 @@
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
import { createHash, randomUUID } from 'crypto'
import { ConfigService } from './config'
export type GroupSummaryTriggerType = 'auto' | 'manual'
export interface GroupSummaryTopic {
title: string
participants: string[]
keyPoints: string[]
conclusion: string
}
export interface GroupSummaryLog {
endpoint: string
model: string
temperature: number
triggerType: GroupSummaryTriggerType
periodStart: number
periodEnd: number
messageCount: number
readableMessageCount: number
systemPrompt: string
userPrompt: string
rawOutput: string
finalSummary: string
durationMs: number
createdAt: number
responseFormatJson?: boolean
responseFormatFallback?: boolean
responseFormatFallbackReason?: string
parsedTopics?: GroupSummaryTopic[]
}
export interface GroupSummaryRecord {
id: string
accountScope: string
createdAt: number
sessionId: string
displayName: string
avatarUrl?: string
triggerType: GroupSummaryTriggerType
periodStart: number
periodEnd: number
messageCount: number
readableMessageCount: number
topics: GroupSummaryTopic[]
summaryText: string
rawOutput: string
log: GroupSummaryLog
}
export interface GroupSummaryRecordSummary {
id: string
createdAt: number
sessionId: string
displayName: string
avatarUrl?: string
triggerType: GroupSummaryTriggerType
periodStart: number
periodEnd: number
messageCount: number
readableMessageCount: number
topics: GroupSummaryTopic[]
summaryText: string
}
export interface GroupSummaryRecordFilters {
sessionId?: string
startTime?: number
endTime?: number
limit?: number
offset?: number
}
export interface GroupSummaryRecordListResult {
success: boolean
records: GroupSummaryRecordSummary[]
total: number
error?: string
}
interface GroupSummaryIndexRecord extends GroupSummaryRecordSummary {
accountScope: string
logFile?: string
}
interface LegacyGroupSummaryRecord extends GroupSummaryIndexRecord {
rawOutput?: string
log?: GroupSummaryLog
}
class GroupSummaryRecordService {
private readonly maxRecordsPerScope = 2000
private filePath: string | null = null
private logDir: string | null = null
private loaded = false
private records: GroupSummaryIndexRecord[] = []
private resolveUserDataPath(): string {
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd()
fs.mkdirSync(userDataPath, { recursive: true })
return userDataPath
}
private resolveFilePath(): string {
if (this.filePath) return this.filePath
this.filePath = path.join(this.resolveUserDataPath(), 'weflow-group-summary-records.json')
return this.filePath
}
private resolveLogDir(): string {
if (this.logDir) return this.logDir
this.logDir = path.join(this.resolveUserDataPath(), 'weflow-group-summary-logs')
fs.mkdirSync(this.logDir, { recursive: true })
return this.logDir
}
private normalizeTimestampSeconds(value: unknown): number {
const numeric = Number(value || 0)
if (!Number.isFinite(numeric) || numeric <= 0) return 0
let normalized = Math.floor(numeric)
while (normalized > 10000000000) {
normalized = Math.floor(normalized / 1000)
}
return normalized
}
private safeLogFileName(id: string): string {
const normalized = String(id || '').replace(/[^a-zA-Z0-9_-]/g, '')
return `${normalized || randomUUID()}.json`
}
private writeLogFile(recordId: string, log: GroupSummaryLog, rawOutput: string): string | undefined {
try {
const fileName = this.safeLogFileName(recordId)
const logPath = path.join(this.resolveLogDir(), fileName)
fs.writeFileSync(logPath, JSON.stringify({ version: 1, rawOutput, log }, null, 2), 'utf-8')
return fileName
} catch {
return undefined
}
}
private readLogFile(fileName?: string): { rawOutput: string; log: GroupSummaryLog } | null {
if (!fileName) return null
try {
const logPath = path.join(this.resolveLogDir(), this.safeLogFileName(fileName.replace(/\.json$/i, '')))
if (!fs.existsSync(logPath)) return null
const parsed = JSON.parse(fs.readFileSync(logPath, 'utf-8'))
const log = parsed?.log
if (!log || typeof log !== 'object') return null
return {
rawOutput: typeof parsed?.rawOutput === 'string' ? parsed.rawOutput : String(log.rawOutput || ''),
log: log as GroupSummaryLog
}
} catch {
return null
}
}
private ensureLoaded(): void {
if (this.loaded) return
this.loaded = true
const filePath = this.resolveFilePath()
try {
if (!fs.existsSync(filePath)) return
const raw = fs.readFileSync(filePath, 'utf-8')
const parsed = JSON.parse(raw)
const records = Array.isArray(parsed) ? parsed : parsed?.records
if (!Array.isArray(records)) return
const legacyRecords = records.filter((item) => item && typeof item === 'object') as LegacyGroupSummaryRecord[]
const needsMigration = legacyRecords.some((record) => Boolean(record.log || record.rawOutput))
if (needsMigration) {
this.backupLegacyFile(filePath)
}
this.records = legacyRecords.map((record) => {
const id = String(record.id || randomUUID())
const logFile = record.log
? this.writeLogFile(id, record.log, String(record.rawOutput || record.log.rawOutput || ''))
: record.logFile
return {
id,
accountScope: String(record.accountScope || 'default'),
createdAt: Number(record.createdAt || Date.now()),
sessionId: String(record.sessionId || ''),
displayName: String(record.displayName || record.sessionId || ''),
avatarUrl: record.avatarUrl,
triggerType: record.triggerType === 'auto' ? 'auto' : 'manual',
periodStart: this.normalizeTimestampSeconds(record.periodStart),
periodEnd: this.normalizeTimestampSeconds(record.periodEnd),
messageCount: Math.max(0, Math.floor(Number(record.messageCount || 0))),
readableMessageCount: Math.max(0, Math.floor(Number(record.readableMessageCount || 0))),
topics: Array.isArray(record.topics) ? record.topics : [],
summaryText: String(record.summaryText || ''),
logFile
}
}).filter((record) => record.sessionId && record.periodStart > 0 && record.periodEnd > record.periodStart)
if (needsMigration) {
this.persist()
}
} catch {
this.records = []
}
}
private backupLegacyFile(filePath: string): void {
try {
const backupPath = `${filePath}.legacy-${Date.now()}.bak`
if (!fs.existsSync(backupPath)) {
fs.copyFileSync(filePath, backupPath)
}
} catch {
// Backup failure should not block reading existing records.
}
}
private persist(): void {
try {
const filePath = this.resolveFilePath()
fs.writeFileSync(filePath, JSON.stringify({ version: 2, records: this.records }, null, 2), 'utf-8')
} catch {
// Summary generation should not fail because local record persistence failed.
}
}
private getCurrentAccountScope(): string {
const config = ConfigService.getInstance()
const myWxid = String(config.getMyWxidCleaned() || '').trim()
if (myWxid) return `wxid:${myWxid}`
const dbPath = String(config.get('dbPath') || '').trim()
if (dbPath) {
const hash = createHash('sha1').update(dbPath).digest('hex').slice(0, 16)
return `db:${hash}`
}
return 'default'
}
private toSummary(record: GroupSummaryIndexRecord): GroupSummaryRecordSummary {
return {
id: record.id,
createdAt: record.createdAt,
sessionId: record.sessionId,
displayName: record.displayName,
avatarUrl: record.avatarUrl,
triggerType: record.triggerType,
periodStart: record.periodStart,
periodEnd: record.periodEnd,
messageCount: record.messageCount,
readableMessageCount: record.readableMessageCount,
topics: Array.isArray(record.topics) ? record.topics : [],
summaryText: record.summaryText || ''
}
}
private getScopedRecords(): GroupSummaryIndexRecord[] {
this.ensureLoaded()
const scope = this.getCurrentAccountScope()
return this.records.filter((record) => record.accountScope === scope)
}
addRecord(input: {
sessionId: string
displayName: string
avatarUrl?: string
triggerType: GroupSummaryTriggerType
periodStart: number
periodEnd: number
messageCount: number
readableMessageCount: number
topics: GroupSummaryTopic[]
summaryText: string
rawOutput: string
log: GroupSummaryLog
}): GroupSummaryRecordSummary {
this.ensureLoaded()
const scope = this.getCurrentAccountScope()
const id = randomUUID()
const logFile = this.writeLogFile(id, input.log, input.rawOutput)
const record: GroupSummaryIndexRecord = {
id,
accountScope: scope,
createdAt: Date.now(),
sessionId: input.sessionId,
displayName: input.displayName,
avatarUrl: input.avatarUrl,
triggerType: input.triggerType,
periodStart: input.periodStart,
periodEnd: input.periodEnd,
messageCount: input.messageCount,
readableMessageCount: input.readableMessageCount,
topics: input.topics,
summaryText: input.summaryText,
logFile
}
this.records.push(record)
const scopedRecords = this.records
.filter((item) => item.accountScope === scope)
.sort((a, b) => b.createdAt - a.createdAt)
const keepIds = new Set(scopedRecords.slice(0, this.maxRecordsPerScope).map((item) => item.id))
this.records = this.records.filter((item) => item.accountScope !== scope || keepIds.has(item.id))
this.persist()
return this.toSummary(record)
}
hasAutoRecord(sessionId: string, periodStart: number, periodEnd: number): boolean {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return false
return this.getScopedRecords().some((record) =>
record.triggerType === 'auto' &&
record.sessionId === normalizedSessionId &&
Number(record.periodStart || 0) === periodStart &&
Number(record.periodEnd || 0) === periodEnd
)
}
listRecords(filters: GroupSummaryRecordFilters = {}): GroupSummaryRecordListResult {
try {
const sessionId = String(filters.sessionId || '').trim()
const startTime = this.normalizeTimestampSeconds(filters.startTime)
const endTime = this.normalizeTimestampSeconds(filters.endTime)
const offset = Math.max(0, Math.floor(Number(filters.offset || 0)))
const limit = Math.min(200, Math.max(1, Math.floor(Number(filters.limit || 100))))
const filtered = this.getScopedRecords()
.filter((record) => {
if (sessionId && record.sessionId !== sessionId) return false
const periodStart = Number(record.periodStart || 0)
const periodEnd = Number(record.periodEnd || 0)
if (startTime > 0 && periodEnd < startTime) return false
if (endTime > 0 && periodStart > endTime) return false
return true
})
.sort((a, b) => Number(b.periodStart || b.createdAt) - Number(a.periodStart || a.createdAt))
return {
success: true,
records: filtered.slice(offset, offset + limit).map((record) => this.toSummary(record)),
total: filtered.length
}
} catch (error) {
return { success: false, records: [], total: 0, error: (error as Error).message || String(error) }
}
}
getRecord(id: string): { success: boolean; record?: GroupSummaryRecord; error?: string } {
this.ensureLoaded()
const normalizedId = String(id || '').trim()
if (!normalizedId) return { success: false, error: '记录 ID 为空' }
const scope = this.getCurrentAccountScope()
const record = this.records.find((item) => item.id === normalizedId && item.accountScope === scope)
if (!record) return { success: false, error: '未找到该群聊总结记录' }
const logData = this.readLogFile(record.logFile)
if (!logData) return { success: false, error: '未找到该群聊总结日志' }
return {
success: true,
record: {
...this.toSummary(record),
accountScope: record.accountScope,
rawOutput: logData.rawOutput,
log: logData.log
}
}
}
clearRuntimeCache(): void {
this.loaded = false
this.records = []
this.filePath = null
this.logDir = null
}
}
export const groupSummaryRecordService = new GroupSummaryRecordService()

View File

@@ -0,0 +1,801 @@
import https from 'https'
import http from 'http'
import { URL } from 'url'
import groupSummaryPrompt from '../../shared/groupSummaryPrompt.json'
import { ConfigService } from './config'
import { chatService, type Message } from './chatService'
import { wcdbService } from './wcdbService'
import {
groupSummaryRecordService,
type GroupSummaryLog,
type GroupSummaryRecord,
type GroupSummaryRecordFilters,
type GroupSummaryRecordListResult,
type GroupSummaryRecordSummary,
type GroupSummaryTopic,
type GroupSummaryTriggerType
} from './groupSummaryRecordService'
const API_TIMEOUT_MS = 90_000
const API_TEMPERATURE = 0.4
const MIN_SUMMARY_MESSAGES = 5
const MAX_MANUAL_RANGE_SECONDS = 48 * 60 * 60
const MAX_MESSAGES_PER_SUMMARY = 3000
const SUMMARY_CURSOR_BATCH_SIZE = 360
const DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT = String(groupSummaryPrompt.defaultSystemPrompt || '').trim()
const SUMMARY_CONFIG_KEYS = new Set([
'aiGroupSummaryEnabled',
'aiGroupSummaryIntervalHours',
'aiGroupSummarySystemPrompt',
'aiGroupSummaryFilterMode',
'aiGroupSummaryFilterList',
'aiModelApiBaseUrl',
'aiModelApiKey',
'aiModelApiModel',
'aiInsightApiBaseUrl',
'aiInsightApiKey',
'aiInsightApiModel',
'dbPath',
'decryptKey',
'myWxid'
])
interface SharedAiModelConfig {
apiBaseUrl: string
apiKey: string
model: string
}
interface GroupSummaryTriggerResult {
success: boolean
message: string
recordId?: string
record?: GroupSummaryRecordSummary
skipped?: boolean
skippedReason?: string
}
interface GroupSummaryDayTriggerResult {
success: boolean
message: string
generated: number
skipped: number
records: GroupSummaryRecordSummary[]
}
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
}
}
function buildApiUrl(baseUrl: string, path: string): string {
const base = baseUrl.replace(/\/+$/, '')
const suffix = path.startsWith('/') ? path : `/${path}`
return `${base}${suffix}`
}
function normalizeSessionIdList(value: unknown): string[] {
if (!Array.isArray(value)) return []
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
}
function normalizeIntervalHours(value: unknown): number {
const allowed = new Set([1, 2, 4, 8, 12, 24])
const numeric = Math.floor(Number(value) || 4)
return allowed.has(numeric) ? numeric : 4
}
function getStartOfDaySeconds(date: Date = new Date()): number {
const next = new Date(date)
next.setHours(0, 0, 0, 0)
return Math.floor(next.getTime() / 1000)
}
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 shouldFallbackJsonMode(error: unknown): boolean {
const statusCode = (error as ApiRequestError)?.statusCode
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')
}
function formatTimestamp(createTime: number): string {
const ms = createTime > 1_000_000_000_000 ? createTime : createTime * 1000
const date = new Date(ms)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function callChatCompletions(
apiBaseUrl: string,
apiKey: string,
model: string,
messages: Array<{ role: string; content: string }>,
options?: { responseFormatJson?: boolean }
): Promise<string> {
return new Promise((resolve, reject) => {
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
let urlObj: URL
try {
urlObj = new URL(endpoint)
} catch {
reject(new Error(`无效的 API URL: ${endpoint}`))
return
}
const payload: Record<string, unknown> = {
model,
messages,
temperature: API_TEMPERATURE,
stream: false
}
if (options?.responseFormatJson) {
payload.response_format = { type: 'json_object' }
}
const body = JSON.stringify(payload)
const requestOptions = {
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: 'POST' as const,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body).toString(),
Authorization: `Bearer ${apiKey}`
}
}
const requestFn = urlObj.protocol === 'https:' ? https.request : http.request
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()) {
resolve(content.trim())
} else {
reject(new Error(`API 返回格式异常: ${data.slice(0, 200)}`))
}
} catch {
reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`))
}
})
})
req.setTimeout(API_TIMEOUT_MS, () => {
req.destroy()
reject(new Error('API 请求超时'))
})
req.on('error', reject)
req.write(body)
req.end()
})
}
function parseTopics(rawOutput: string): GroupSummaryTopic[] {
const parsed = JSON.parse(stripJsonFence(rawOutput)) as unknown
if (!parsed || typeof parsed !== 'object') {
throw new Error('模型输出格式异常JSON 根节点不是对象')
}
const source = parsed as Record<string, unknown>
const rawTopics = Array.isArray(source.topics) ? source.topics : []
const topics = rawTopics.map((item, index) => {
const topic = item && typeof item === 'object' ? item as Record<string, unknown> : {}
const participantsRaw = Array.isArray(topic.participants) ? topic.participants : []
const keyPointsRaw = Array.isArray(topic.key_points)
? topic.key_points
: (Array.isArray(topic.keyPoints) ? topic.keyPoints : [])
return {
title: clampText(topic.title || `话题 ${index + 1}`, 48) || `话题 ${index + 1}`,
participants: participantsRaw.map((value) => clampText(value, 24)).filter(Boolean).slice(0, 12),
keyPoints: keyPointsRaw.map((value) => clampText(value, 120)).filter(Boolean).slice(0, 8),
conclusion: clampText(topic.conclusion, 180) || '无明确结论'
}
}).filter((topic) => topic.title || topic.keyPoints.length > 0 || topic.conclusion)
if (topics.length === 0) {
throw new Error('模型输出格式异常topics 为空')
}
return topics
}
function buildSummaryText(topics: GroupSummaryTopic[]): string {
return topics.map((topic) => {
const participants = topic.participants.length > 0 ? topic.participants.join('、') : '未明确'
const keyPoints = topic.keyPoints.length > 0 ? topic.keyPoints.join('') : '无'
return `${topic.title}】参与者:${participants}。关键/矛盾点:${keyPoints}。结论:${topic.conclusion}`
}).join('\n')
}
function fallbackTopicFromRaw(rawOutput: string): GroupSummaryTopic {
return {
title: '未归类总结',
participants: [],
keyPoints: [clampText(rawOutput, 500)],
conclusion: '模型未按固定 JSON 格式返回,请查看完整日志。'
}
}
class GroupSummaryService {
private config: ConfigService
private started = false
private scanTimer: NodeJS.Timeout | null = null
private processing = false
private pendingAutoRun = false
private dbConnected = false
constructor() {
this.config = ConfigService.getInstance()
}
start(): void {
if (this.started) return
this.started = true
void this.refreshConfiguration('startup')
}
stop(): void {
this.started = false
this.clearTimers()
this.processing = false
this.pendingAutoRun = false
this.dbConnected = false
}
async handleConfigChanged(key: string): Promise<void> {
const normalizedKey = String(key || '').trim()
if (!SUMMARY_CONFIG_KEYS.has(normalizedKey)) return
if (normalizedKey === 'aiGroupSummarySystemPrompt') return
if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') {
this.dbConnected = false
groupSummaryRecordService.clearRuntimeCache()
}
await this.refreshConfiguration(`config:${normalizedKey}`)
}
handleConfigCleared(): void {
this.clearTimers()
this.processing = false
this.pendingAutoRun = false
this.dbConnected = false
groupSummaryRecordService.clearRuntimeCache()
}
listRecords(filters?: GroupSummaryRecordFilters): GroupSummaryRecordListResult {
return groupSummaryRecordService.listRecords(filters || {})
}
getRecord(id: string): { success: boolean; record?: GroupSummaryRecord; error?: string } {
return groupSummaryRecordService.getRecord(id)
}
async triggerManual(params: {
sessionId: string
displayName?: string
avatarUrl?: string
startTime: number
endTime: number
}): Promise<GroupSummaryTriggerResult> {
if (!this.isEnabled()) {
return { success: false, message: '请先在设置中开启「AI 群聊总结」' }
}
const sessionId = String(params?.sessionId || '').trim()
if (!sessionId.endsWith('@chatroom')) {
return { success: false, message: 'AI 群聊总结仅支持群聊' }
}
const startTime = this.normalizeTimestampSeconds(params?.startTime)
const endTime = this.normalizeTimestampSeconds(params?.endTime)
if (startTime <= 0 || endTime <= startTime) {
return { success: false, message: '请选择有效的总结时段' }
}
if (endTime - startTime > MAX_MANUAL_RANGE_SECONDS) {
return { success: false, message: '手动总结时段不能超过 48 小时' }
}
const displayName = String(params?.displayName || sessionId).trim() || sessionId
const avatarUrl = String(params?.avatarUrl || '').trim() || undefined
return this.generateSummaryForPeriod({
sessionId,
displayName,
avatarUrl,
periodStart: startTime,
periodEnd: endTime,
triggerType: 'manual'
})
}
async triggerDay(params: {
sessionId: string
displayName?: string
avatarUrl?: string
date: string
}): Promise<GroupSummaryDayTriggerResult> {
if (!this.isEnabled()) {
return { success: false, message: '请先在设置中开启「AI 群聊总结」', generated: 0, skipped: 0, records: [] }
}
const sessionId = String(params?.sessionId || '').trim()
if (!sessionId.endsWith('@chatroom')) {
return { success: false, message: 'AI 群聊总结仅支持群聊', generated: 0, skipped: 0, records: [] }
}
const dayRange = this.parseLocalDateDayRange(params?.date)
if (!dayRange) {
return { success: false, message: '请选择有效日期', generated: 0, skipped: 0, records: [] }
}
const todayStart = getStartOfDaySeconds(new Date())
if (dayRange.start > todayStart) {
return { success: false, message: '不能总结未来日期', generated: 0, skipped: 0, records: [] }
}
const now = Math.floor(Date.now() / 1000)
const effectiveEnd = dayRange.start === todayStart ? Math.min(dayRange.end, now) : dayRange.end
const periods = this.getIntervalPeriods(dayRange.start, effectiveEnd, false)
if (periods.length === 0) {
return { success: true, message: '当前日期暂无已完成的总结时段', generated: 0, skipped: 0, records: [] }
}
const displayName = String(params?.displayName || sessionId).trim() || sessionId
const avatarUrl = String(params?.avatarUrl || '').trim() || undefined
return this.generateSummariesForPeriods({
sessionId,
displayName,
avatarUrl,
periods,
triggerType: 'manual'
})
}
private async refreshConfiguration(_reason: string): Promise<void> {
if (!this.started) return
this.clearTimers()
if (!this.isEnabled()) return
await this.queueDueAutoSummaries()
this.scheduleNextAutoRun()
}
private isEnabled(): boolean {
return this.config.get('aiGroupSummaryEnabled') === true
}
private clearTimers(): void {
if (this.scanTimer !== null) {
clearTimeout(this.scanTimer)
this.scanTimer = null
}
}
private scheduleNextAutoRun(): void {
if (!this.started || !this.isEnabled()) return
const intervalHours = normalizeIntervalHours(this.config.get('aiGroupSummaryIntervalHours'))
const now = Math.floor(Date.now() / 1000)
const dayStart = getStartOfDaySeconds(new Date())
const intervalSeconds = intervalHours * 60 * 60
const elapsed = Math.max(0, now - dayStart)
const nextBoundary = dayStart + (Math.floor(elapsed / intervalSeconds) + 1) * intervalSeconds
const delayMs = Math.max(1_000, (nextBoundary - now) * 1000 + 1_000)
this.scanTimer = setTimeout(async () => {
this.scanTimer = null
await this.queueDueAutoSummaries()
this.scheduleNextAutoRun()
}, delayMs)
}
private async ensureConnected(): Promise<boolean> {
if (this.dbConnected) return true
const result = await chatService.connect()
this.dbConnected = result.success === true
return this.dbConnected
}
private getSharedAiModelConfig(): SharedAiModelConfig {
const apiBaseUrl = String(
this.config.get('aiModelApiBaseUrl')
|| this.config.get('aiInsightApiBaseUrl')
|| ''
).trim()
const apiKey = String(
this.config.get('aiModelApiKey')
|| this.config.get('aiInsightApiKey')
|| ''
).trim()
const model = String(
this.config.get('aiModelApiModel')
|| this.config.get('aiInsightApiModel')
|| 'gpt-4o-mini'
).trim() || 'gpt-4o-mini'
return { apiBaseUrl, apiKey, model }
}
private getAutoScopeSessionIds(): string[] {
return normalizeSessionIdList(this.config.get('aiGroupSummaryFilterList'))
.filter((sessionId) => sessionId.endsWith('@chatroom'))
}
private normalizeTimestampSeconds(value: unknown): number {
const numeric = Number(value || 0)
if (!Number.isFinite(numeric) || numeric <= 0) return 0
let normalized = Math.floor(numeric)
while (normalized > 10000000000) {
normalized = Math.floor(normalized / 1000)
}
return normalized
}
private parseLocalDateDayRange(value: unknown): { start: number; end: number } | null {
const text = String(value || '').trim()
const match = text.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (!match) return null
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const start = new Date(year, month - 1, day, 0, 0, 0, 0)
if (
!Number.isFinite(start.getTime()) ||
start.getFullYear() !== year ||
start.getMonth() !== month - 1 ||
start.getDate() !== day
) {
return null
}
const end = new Date(start)
end.setDate(end.getDate() + 1)
return {
start: Math.floor(start.getTime() / 1000),
end: Math.floor(end.getTime() / 1000)
}
}
private getIntervalPeriods(startTime: number, endTime: number, includePartial: boolean): Array<{ start: number; end: number }> {
const intervalHours = normalizeIntervalHours(this.config.get('aiGroupSummaryIntervalHours'))
const intervalSeconds = intervalHours * 60 * 60
const periods: Array<{ start: number; end: number }> = []
for (let start = startTime; start < endTime; start += intervalSeconds) {
const end = Math.min(start + intervalSeconds, endTime)
if (!includePartial && end - start < intervalSeconds) continue
if (end > start) periods.push({ start, end })
}
return periods
}
private getCompletedPeriodsToday(): Array<{ start: number; end: number }> {
const dayStart = getStartOfDaySeconds(new Date())
const now = Math.floor(Date.now() / 1000)
return this.getIntervalPeriods(dayStart, now, false)
}
private async queueDueAutoSummaries(): Promise<void> {
if (!this.started || !this.isEnabled()) return
if (this.processing) {
this.pendingAutoRun = true
return
}
this.processing = true
try {
do {
this.pendingAutoRun = false
await this.runDueAutoSummariesOnce()
} while (this.pendingAutoRun && this.started && this.isEnabled())
} finally {
this.processing = false
}
}
private async runDueAutoSummariesOnce(): Promise<void> {
if (!this.started || !this.isEnabled()) return
try {
const { apiBaseUrl, apiKey } = this.getSharedAiModelConfig()
if (!apiBaseUrl || !apiKey) return
const scopeSessionIds = this.getAutoScopeSessionIds()
if (scopeSessionIds.length === 0) return
if (!await this.ensureConnected()) return
const contacts = (await chatService.enrichSessionsContactInfo(scopeSessionIds).catch(() => null))?.contacts || {}
const periods = this.getCompletedPeriodsToday()
for (const period of periods) {
for (const sessionId of scopeSessionIds) {
if (!this.started || !this.isEnabled()) return
if (!sessionId) continue
if (groupSummaryRecordService.hasAutoRecord(sessionId, period.start, period.end)) continue
await this.generateSummaryForPeriod({
sessionId,
displayName: contacts[sessionId]?.displayName || sessionId,
avatarUrl: contacts[sessionId]?.avatarUrl,
periodStart: period.start,
periodEnd: period.end,
triggerType: 'auto'
})
}
}
} catch (error) {
console.warn('[GroupSummaryService] 自动总结失败:', error)
}
}
private async readMessagesInPeriod(sessionId: string, startTime: number, endTime: number): Promise<Message[]> {
if (!await this.ensureConnected()) {
throw new Error('数据库连接失败,请先在“数据库连接”页完成配置')
}
const cursorResult = await wcdbService.openMessageCursorLite(
sessionId,
SUMMARY_CURSOR_BATCH_SIZE,
true,
startTime,
endTime
)
if (!cursorResult.success || !cursorResult.cursor) {
throw new Error(cursorResult.error || '打开消息游标失败')
}
const cursor = cursorResult.cursor
const messages: Message[] = []
try {
let hasMore = true
while (hasMore && messages.length < MAX_MESSAGES_PER_SUMMARY) {
const batch = await wcdbService.fetchMessageBatch(cursor)
if (!batch.success) {
throw new Error(batch.error || '读取消息失败')
}
hasMore = batch.hasMore === true
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
if (rows.length === 0) {
if (!hasMore) break
continue
}
const mapped = chatService.mapRowsToMessagesForApi(rows, sessionId)
for (const message of mapped) {
const createTime = Number(message.createTime || 0)
if (createTime < startTime || createTime > endTime) continue
messages.push(message)
if (messages.length >= MAX_MESSAGES_PER_SUMMARY) break
}
}
} finally {
await wcdbService.closeMessageCursor(cursor).catch(() => {})
}
return messages.sort((a, b) => {
if (a.createTime !== b.createTime) return a.createTime - b.createTime
if (a.sortSeq !== b.sortSeq) return a.sortSeq - b.sortSeq
return a.localId - b.localId
})
}
private normalizeMessageText(message: Message): string {
const parsedContent = String(message.parsedContent || '').replace(/\s+/g, ' ').trim()
const quotedContent = String(message.quotedContent || '').replace(/\s+/g, ' ').trim()
const quotedSender = String(message.quotedSender || '').replace(/\s+/g, ' ').trim()
let text = parsedContent
if (quotedContent) {
const quote = quotedSender ? `${quotedSender}${quotedContent}` : quotedContent
text = text && text !== '[引用消息]' ? `${text} [引用 ${quote}]` : `[引用 ${quote}]`
}
if (!text) {
text = String(message.linkTitle || message.fileName || message.appMsgDesc || '').replace(/\s+/g, ' ').trim()
}
if (!text) return ''
if (/^<\?xml|^<msg\b|^<appmsg\b|^<img\b|^<emoji\b/i.test(text)) return ''
return text
}
private async buildTranscript(sessionId: string, messages: Message[]): Promise<{ transcript: string; readableMessages: Message[] }> {
const readableMessages = messages.filter((message) => this.normalizeMessageText(message))
const senderIds = Array.from(new Set(
readableMessages
.map((message) => String(message.senderUsername || '').trim())
.filter(Boolean)
))
const contacts = senderIds.length > 0
? (await chatService.enrichSessionsContactInfo(senderIds).catch(() => null))?.contacts || {}
: {}
const myWxid = String(this.config.getMyWxidCleaned() || '').trim()
const lines = readableMessages.map((message) => {
const senderUsername = String(message.senderUsername || '').trim()
const senderName = message.isSend === 1 || (senderUsername && myWxid && senderUsername === myWxid)
? '我'
: (contacts[senderUsername]?.displayName || senderUsername || '未知成员')
return `${formatTimestamp(message.createTime)} ${senderName}${this.normalizeMessageText(message)}`
})
return {
transcript: lines.join('\n'),
readableMessages
}
}
private async generateSummaryForPeriod(params: {
sessionId: string
displayName: string
avatarUrl?: string
periodStart: number
periodEnd: number
triggerType: GroupSummaryTriggerType
}): Promise<GroupSummaryTriggerResult> {
const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig()
if (!apiBaseUrl || !apiKey) {
return { success: false, message: '请先填写通用 AI 模型配置API 地址和 Key' }
}
try {
const messages = await this.readMessagesInPeriod(params.sessionId, params.periodStart, params.periodEnd)
const { transcript, readableMessages } = await this.buildTranscript(params.sessionId, messages)
if (readableMessages.length < MIN_SUMMARY_MESSAGES) {
return {
success: true,
skipped: true,
skippedReason: 'message_count_too_low',
message: `该时段可总结消息少于 ${MIN_SUMMARY_MESSAGES} 条,已跳过`
}
}
const customPrompt = String(this.config.get('aiGroupSummarySystemPrompt') || '').trim()
const systemPrompt = customPrompt || DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT
const userPrompt = `群聊:${params.displayName}
总结时段:${formatTimestamp(params.periodStart)}${formatTimestamp(params.periodEnd)}
消息数量:${readableMessages.length}
群聊记录:
${transcript}
请只输出指定 JSON。`
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 {
rawOutput = await callChatCompletions(apiBaseUrl, apiKey, model, requestMessages, { responseFormatJson: true })
} catch (error) {
if (!shouldFallbackJsonMode(error)) throw error
responseFormatJson = false
responseFormatFallback = true
responseFormatFallbackReason = (error as Error).message || 'response_format 不受支持'
rawOutput = await callChatCompletions(apiBaseUrl, apiKey, model, requestMessages)
}
let topics: GroupSummaryTopic[]
let finalSummary: string
try {
topics = parseTopics(rawOutput)
finalSummary = buildSummaryText(topics)
} catch {
topics = [fallbackTopicFromRaw(rawOutput)]
finalSummary = buildSummaryText(topics)
}
const log: GroupSummaryLog = {
endpoint,
model,
temperature: API_TEMPERATURE,
triggerType: params.triggerType,
periodStart: params.periodStart,
periodEnd: params.periodEnd,
messageCount: messages.length,
readableMessageCount: readableMessages.length,
systemPrompt,
userPrompt,
rawOutput,
finalSummary,
durationMs: Date.now() - startedAt,
createdAt: Date.now(),
responseFormatJson,
responseFormatFallback,
responseFormatFallbackReason,
parsedTopics: topics
}
const record = groupSummaryRecordService.addRecord({
sessionId: params.sessionId,
displayName: params.displayName,
avatarUrl: params.avatarUrl,
triggerType: params.triggerType,
periodStart: params.periodStart,
periodEnd: params.periodEnd,
messageCount: messages.length,
readableMessageCount: readableMessages.length,
topics,
summaryText: finalSummary,
rawOutput,
log
})
return { success: true, message: '群聊总结已生成', recordId: record.id, record }
} catch (error) {
return { success: false, message: `生成失败:${(error as Error).message || String(error)}` }
}
}
private async generateSummariesForPeriods(params: {
sessionId: string
displayName: string
avatarUrl?: string
periods: Array<{ start: number; end: number }>
triggerType: GroupSummaryTriggerType
}): Promise<GroupSummaryDayTriggerResult> {
const records: GroupSummaryRecordSummary[] = []
let skipped = 0
let failed = 0
let firstError = ''
for (const period of params.periods) {
const result = await this.generateSummaryForPeriod({
sessionId: params.sessionId,
displayName: params.displayName,
avatarUrl: params.avatarUrl,
periodStart: period.start,
periodEnd: period.end,
triggerType: params.triggerType
})
if (result.success && result.record) {
records.push(result.record)
continue
}
if (result.success && result.skipped) {
skipped += 1
continue
}
failed += 1
if (!firstError) firstError = result.message
}
const generated = records.length
const parts = [`生成 ${generated}`, `跳过 ${skipped}`]
if (failed > 0) parts.push(`失败 ${failed}`)
const message = failed > 0 && generated === 0 && skipped === 0
? (firstError || '群聊总结生成失败')
: `群聊总结完成:${parts.join('')}`
return {
success: generated > 0 || skipped > 0 || failed === 0,
message,
generated,
skipped,
records
}
}
}
export const groupSummaryService = new GroupSummaryService()

View File

@@ -1431,6 +1431,20 @@ class HttpService {
return '.jpg'
}
private writeFileIfLarger(fullPath: string, data: Buffer): void {
if (fs.existsSync(fullPath)) {
try {
const stat = fs.statSync(fullPath)
if (!stat.isFile()) return
if (data.length <= stat.size) return
} catch {
// If the existing export cannot be inspected, overwrite it below.
}
}
fs.writeFileSync(fullPath, data)
}
private async exportMediaForMessages(
messages: Message[],
talker: string,
@@ -1530,9 +1544,7 @@ class HttpService {
const targetDir = path.join(sessionDir, 'images')
const fullPath = path.join(targetDir, fileName)
this.ensureDir(targetDir)
if (!fs.existsSync(fullPath)) {
fs.writeFileSync(fullPath, imageBuffer)
}
this.writeFileIfLarger(fullPath, imageBuffer)
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
return { kind: 'image', fileName, fullPath, relativePath }
}
@@ -1545,9 +1557,7 @@ class HttpService {
const targetDir = path.join(sessionDir, 'images')
const fullPath = path.join(targetDir, fileName)
this.ensureDir(targetDir)
if (!fs.existsSync(fullPath)) {
fs.copyFileSync(imagePath, fullPath)
}
this.writeFileIfLarger(fullPath, imageBuffer)
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
return { kind: 'image', fileName, fullPath, relativePath }
}

File diff suppressed because it is too large Load Diff

View File

@@ -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()

View File

@@ -1,4 +1,4 @@
/**
/**
* insightService.ts
*
* AI 见解后台服务:
@@ -21,7 +21,13 @@ 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 { insightProfileService } from './insightProfileService'
import {
insightRecordService,
type InsightRecordLog,
type InsightRecordTriggerReason,
type MessageInsightAnalysis
} from './insightRecordService'
// ─── 常量 ────────────────────────────────────────────────────────────────────
@@ -91,12 +97,34 @@ interface SharedAiModelConfig {
maxTokens: number
}
interface SessionInsightTriggerResult {
success: boolean
message: string
recordId?: string
insight?: string
skipped?: boolean
notificationEnabled?: boolean
}
type InsightFilterMode = 'whitelist' | 'blacklist'
interface CallApiOptions {
temperature?: number
disableThinking?: boolean
useMaxCompletionTokens?: boolean
responseFormatJson?: boolean
}
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
}
}
// ─── 日志 ─────────────────────────────────────────────────────────────────────
@@ -227,6 +255,52 @@ function normalizeFootprintInsight(text: string): string {
return normalized
}
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<string, unknown>
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。
@@ -251,23 +325,25 @@ function callApi(
}
const normalizedMaxTokens = normalizeApiMaxTokens(maxTokens)
const requestBody: Record<string, unknown> = {
const payload: Record<string, unknown> = {
model,
messages,
temperature: options.temperature ?? API_TEMPERATURE,
stream: false
}
if (options.useMaxCompletionTokens) {
requestBody.max_completion_tokens = normalizedMaxTokens
payload.max_completion_tokens = normalizedMaxTokens
} else {
requestBody.max_tokens = normalizedMaxTokens
payload.max_tokens = normalizedMaxTokens
}
if (options.disableThinking) {
requestBody.thinking = { type: 'disabled' }
requestBody.enable_thinking = false
payload.thinking = { type: 'disabled' }
payload.enable_thinking = false
}
const body = JSON.stringify(requestBody)
if (options?.responseFormatJson) {
payload.response_format = { type: 'json_object' }
}
const body = JSON.stringify(payload)
const requestOptions = {
hostname: urlObj.hostname,
@@ -288,6 +364,10 @@ function callApi(
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()) {
@@ -388,6 +468,7 @@ class InsightService {
this.clearTimers()
this.clearRuntimeCache()
this.processing = false
insightProfileService.cancelActiveTask('AI 见解服务已停止,画像任务已取消')
if (hadActiveFlow) {
insightLog('INFO', '已停止')
}
@@ -403,6 +484,7 @@ class InsightService {
}
if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') {
insightProfileService.cancelActiveTask('数据库或账号配置已变化,画像任务已取消')
this.clearRuntimeCache()
}
@@ -412,6 +494,7 @@ class InsightService {
handleConfigCleared(): void {
this.clearTimers()
this.clearRuntimeCache()
insightProfileService.cancelActiveTask('配置已清除,画像任务已取消')
this.processing = false
}
@@ -549,11 +632,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,
@@ -566,6 +652,47 @@ class InsightService {
}
}
/**
* 手动对指定会话立即触发一次 AI 见解。
* 只新增触发入口;实际上下文、朋友圈/微博拼接、prompt 和入库仍走 generateInsightForSession。
*/
async triggerSessionInsight(params: {
sessionId: string
displayName?: string
avatarUrl?: string
}): Promise<SessionInsightTriggerResult> {
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()
@@ -691,6 +818,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 {
@@ -1200,10 +1528,10 @@ ${topMentionText}
displayName: string
triggerReason: InsightRecordTriggerReason
silentDays?: number
}): Promise<void> {
}): Promise<SessionInsightTriggerResult> {
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
@@ -1221,7 +1549,7 @@ ${topMentionText}
if (!apiBaseUrl || !apiKey) {
insightLog('WARN', 'API 地址或 Key 未配置,跳过见解生成')
return
return { success: false, message: '请先填写通用 AI 模型配置API 地址和 Key' }
}
// ── 构建 prompt ────────────────────────────────────────────────────────────
@@ -1242,6 +1570,7 @@ ${topMentionText}
const momentsContextSection = await this.getMomentsContextSection(sessionId)
const socialContextSection = await this.getSocialContextSection(sessionId)
const profileContextSection = insightProfileService.getProfileContextSection(sessionId)
// ── 默认 system prompt稳定内容有利于 provider 端 prompt cache 命中)────
const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。
@@ -1261,6 +1590,7 @@ ${topMentionText}
? `${silentDays} 天未联系「${resolvedDisplayName}」。`
: '',
contextSection,
profileContextSection,
momentsContextSection,
socialContextSection,
'请给出你的见解≤80字'
@@ -1311,9 +1641,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}`
@@ -1378,6 +1708,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',
@@ -1385,6 +1724,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}` }
}
}

View File

@@ -10,6 +10,10 @@ const execFileAsync = promisify(execFile)
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
type DbKeyPollResult =
| { status: 'success'; key: string; loginRequiredDetected: boolean }
| { status: 'process-ended'; loginRequiredDetected: boolean }
| { status: 'timeout'; loginRequiredDetected: boolean }
export class KeyService {
private readonly isMac = process.platform === 'darwin'
@@ -58,6 +62,7 @@ export class KeyService {
private readonly HKEY_CURRENT_USER = 0x80000001
private readonly ERROR_SUCCESS = 0
private readonly WM_CLOSE = 0x0010
private readonly DB_KEY_PROCESS_CHECK_INTERVAL_MS = 1000
private getDllPath(): string {
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
@@ -342,30 +347,169 @@ export class KeyService {
return null
}
private async findPidByImageName(imageName: string): Promise<number | null> {
private async findPidsByImageName(imageName: string): Promise<number[]> {
try {
const { stdout } = await execFileAsync('tasklist', ['/FI', `IMAGENAME eq ${imageName}`, '/FO', 'CSV', '/NH'])
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
const pids: number[] = []
for (const line of lines) {
if (line.startsWith('INFO:')) continue
const parts = line.split('","').map((p) => p.replace(/^"|"$/g, ''))
if (parts[0]?.toLowerCase() === imageName.toLowerCase()) {
const pid = Number(parts[1])
if (!Number.isNaN(pid)) return pid
if (!Number.isNaN(pid)) pids.push(pid)
}
}
return null
return pids
} catch (e) {
return null
return []
}
}
private async findWeChatPid(): Promise<number | null> {
const names = ['Weixin.exe', 'WeChat.exe']
for (const name of names) {
const pid = await this.findPidByImageName(name)
private async findWeChatPids(): Promise<number[]> {
const pids: number[] = []
const pushUnique = (pid: number | null | undefined) => {
if (!pid || pids.includes(pid)) return
pids.push(pid)
}
for (const name of ['Weixin.exe', 'WeChat.exe']) {
const found = await this.findPidsByImageName(name)
found.forEach(pushUnique)
}
return pids
}
private async isWeChatPidActive(pid: number): Promise<boolean> {
const pids = await this.findWeChatPids()
if (pids.includes(pid)) return true
const fallbackPid = await this.waitForWeChatWindow(250)
return fallbackPid === pid
}
private async waitForWeChatPid(timeoutMs: number): Promise<number | null> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
const pids = await this.findWeChatPids()
if (pids.length > 0) return pids[0]
const fallbackPid = await this.waitForWeChatWindow(250)
if (fallbackPid) return fallbackPid
await new Promise(r => setTimeout(r, 500))
}
return null
}
private getRemainingMs(deadline: number): number {
return Math.max(0, deadline - Date.now())
}
private async pollDbKeyFromHook(
pid: number,
deadline: number,
logs: string[],
onStatus?: (message: string, level: number) => void
): Promise<DbKeyPollResult> {
const keyBuffer = Buffer.alloc(128)
let loginRequiredDetected = false
let nextProcessCheckAt = 0
while (Date.now() < deadline) {
const now = Date.now()
if (now >= nextProcessCheckAt) {
nextProcessCheckAt = now + this.DB_KEY_PROCESS_CHECK_INTERVAL_MS
if (!await this.isWeChatPidActive(pid)) {
return { status: 'process-ended', loginRequiredDetected }
}
}
if (this.pollKeyData(keyBuffer, keyBuffer.length)) {
const key = this.decodeUtf8(keyBuffer)
if (key.length === 64) {
onStatus?.('密钥获取成功', 1)
return { status: 'success', key, loginRequiredDetected }
}
}
for (let i = 0; i < 5; i++) {
const statusBuffer = Buffer.alloc(256)
const levelOut = [0]
if (!this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)) break
const msg = this.decodeUtf8(statusBuffer)
const level = levelOut[0] ?? 0
if (msg) {
logs.push(msg)
if (this.isLoginRelatedText(msg)) {
loginRequiredDetected = true
}
onStatus?.(msg, level)
}
}
await new Promise((resolve) => setTimeout(resolve, 120))
}
return { status: 'timeout', loginRequiredDetected }
}
private cleanupDbKeyHook(): void {
try {
this.cleanupHook()
} catch { }
}
private buildInitHookError(): string {
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
if (error) {
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) {
return '权限不足:无法访问微信进程。\n\n解决方法\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件如360、火绒等\n3. 确保微信没有以管理员权限运行'
}
return error
}
const statusBuffer = Buffer.alloc(256)
const levelOut = [0]
const status = this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)
? this.decodeUtf8(statusBuffer)
: ''
return status || '初始化失败'
}
private async waitForNextDbKeyPid(deadline: number, onStatus?: (message: string, level: number) => void): Promise<number | null> {
while (this.getRemainingMs(deadline) > 0) {
onStatus?.('正在查找微信进程...', 0)
const pid = await this.waitForWeChatPid(Math.min(this.getRemainingMs(deadline), 30_000))
if (pid) return pid
}
return null
}
private shouldRetryAfterProcessLost(deadline: number): boolean {
return this.getRemainingMs(deadline) > 1000
}
private async delayBeforeRetry(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 500))
}
private async waitForProcessRestart(deadline: number, onStatus?: (message: string, level: number) => void): Promise<number | null> {
if (!this.shouldRetryAfterProcessLost(deadline)) return null
onStatus?.('检测到微信已退出,已清理 Hook等待重新打开微信...', 0)
await this.delayBeforeRetry()
return this.waitForNextDbKeyPid(deadline, onStatus)
}
private async detectLoginRequiredForLastPid(pid: number | null, loginRequiredDetected: boolean): Promise<boolean> {
if (loginRequiredDetected) return true
if (!pid) return false
if (!await this.isWeChatPidActive(pid)) return false
return await this.detectWeChatLoginRequired(pid)
}
private async findWeChatPid(): Promise<number | null> {
const pids = await this.findWeChatPids()
if (pids.length > 0) return pids[0]
const fallbackPid = await this.waitForWeChatWindow(5000)
return fallbackPid ?? null
}
@@ -373,9 +517,8 @@ export class KeyService {
private async waitForWeChatExit(timeoutMs = 8000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
const weixinPid = await this.findPidByImageName('Weixin.exe')
const wechatPid = await this.findPidByImageName('WeChat.exe')
if (!weixinPid && !wechatPid) return true
const runningPids = await this.findWeChatPids()
if (runningPids.length === 0) return true
await new Promise(r => setTimeout(r, 400))
}
return false
@@ -604,7 +747,7 @@ export class KeyService {
return true
}
// --- DB Key Logic (Unchanged core flow) ---
// --- DB Key Logic (core hook/poll flow unchanged) ---
async autoGetDbKey(
timeoutMs = 60_000,
@@ -615,74 +758,56 @@ export class KeyService {
if (!this.ensureKernel32()) return { success: false, error: 'Kernel32 Init Failed' }
const logs: string[] = []
const deadline = Date.now() + timeoutMs
onStatus?.('正在查找微信进程...', 0)
const pid = await this.findWeChatPid()
let pid = await this.findWeChatPid()
if (!pid) {
const err = '未找到微信进程,请先启动微信'
onStatus?.(err, 2)
return { success: false, error: err }
}
let lastAttemptLoginRequiredDetected = false
onStatus?.(`检测到微信窗口 (PID: ${pid}),正在获取...`, 0)
onStatus?.('正在检测微信界面组件...', 0)
await this.waitForWeChatWindowComponents(pid, 15000)
while (pid && this.getRemainingMs(deadline) > 0) {
onStatus?.(`检测微信窗口 (PID: ${pid}),正在获取...`, 0)
onStatus?.('正在检测微信界面组件...', 0)
await this.waitForWeChatWindowComponents(pid, Math.min(15000, this.getRemainingMs(deadline)))
const ok = this.initHook(pid)
if (!ok) {
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
if (error) {
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) {
const friendlyError = '权限不足:无法访问微信进程。\n\n解决方法\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件如360、火绒等\n3. 确保微信没有以管理员权限运行'
return { success: false, error: friendlyError }
}
return { success: false, error }
if (!await this.isWeChatPidActive(pid)) {
pid = await this.waitForProcessRestart(deadline, onStatus)
continue
}
const statusBuffer = Buffer.alloc(256)
const levelOut = [0]
const status = this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)
? this.decodeUtf8(statusBuffer)
: ''
return { success: false, error: status || '初始化失败' }
}
const keyBuffer = Buffer.alloc(128)
const start = Date.now()
let loginRequiredDetected = false
try {
while (Date.now() - start < timeoutMs) {
if (this.pollKeyData(keyBuffer, keyBuffer.length)) {
const key = this.decodeUtf8(keyBuffer)
if (key.length === 64) {
onStatus?.('密钥获取成功', 1)
return { success: true, key, logs }
}
const ok = this.initHook(pid)
if (!ok) {
if (!await this.isWeChatPidActive(pid)) {
this.cleanupDbKeyHook()
pid = await this.waitForProcessRestart(deadline, onStatus)
continue
}
for (let i = 0; i < 5; i++) {
const statusBuffer = Buffer.alloc(256)
const levelOut = [0]
if (!this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)) break
const msg = this.decodeUtf8(statusBuffer)
const level = levelOut[0] ?? 0
if (msg) {
logs.push(msg)
if (this.isLoginRelatedText(msg)) {
loginRequiredDetected = true
}
onStatus?.(msg, level)
}
}
await new Promise((resolve) => setTimeout(resolve, 120))
return { success: false, error: this.buildInitHookError(), logs }
}
} finally {
let pollResult: DbKeyPollResult
try {
this.cleanupHook()
} catch { }
pollResult = await this.pollDbKeyFromHook(pid, deadline, logs, onStatus)
} finally {
this.cleanupDbKeyHook()
}
lastAttemptLoginRequiredDetected = pollResult.loginRequiredDetected
if (pollResult.status === 'success') {
return { success: true, key: pollResult.key, logs }
}
if (pollResult.status === 'process-ended') {
lastAttemptLoginRequiredDetected = false
pid = await this.waitForProcessRestart(deadline, onStatus)
continue
}
break
}
const loginRequired = loginRequiredDetected || await this.detectWeChatLoginRequired(pid)
const loginRequired = await this.detectLoginRequiredForLastPid(pid, lastAttemptLoginRequiredDetected)
if (loginRequired) {
return {
success: false,

View File

@@ -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)

View File

@@ -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,12 +128,22 @@ const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => {
let fixedUrl = url.replace('http://', 'https://')
// 只有非视频(即图片)才需要处理 /150 变 /0
// 只有非视频(即图片)才需要处理路径末尾的尺寸标识(/150、/200等变为 /0
if (!isVideo) {
fixedUrl = fixedUrl.replace(/\/150($|\?)/, '/0$1')
const [pathPart, queryPart] = fixedUrl.split('?')
const fixedPath = pathPart.replace(/\/(150|200|480)($|\?)/, '/0$2')
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) {
@@ -704,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
@@ -716,19 +729,24 @@ class SnsService {
const lpUrlTag = lx.match(/<url([^>]*)>/i)
const lpThumb = lx.match(/<thumb[^>]*>([^<]+)<\/thumb>/i)
const lpThumbTag = lx.match(/<thumb([^>]*)>/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
}
@@ -1181,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
@@ -2060,6 +2080,8 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
const zlib = require('zlib')
const urlObj = new URL(url)
console.log(`[SnsService] 开始下载图片: url=${url.substring(0, 100)}..., key=${key || 'undefined'}`)
const options = {
hostname: urlObj.hostname,
path: urlObj.pathname + urlObj.search,
@@ -2074,7 +2096,9 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
}
const req = https.request(options, (res: any) => {
console.log(`[SnsService] CDN 响应: statusCode=${res.statusCode}, x-enc=${res.headers['x-enc']}, content-type=${res.headers['content-type']}`)
if (res.statusCode !== 200 && res.statusCode !== 206) {
console.error(`[SnsService] CDN 请求失败: HTTP ${res.statusCode}`)
resolve({ success: false, error: `HTTP ${res.statusCode}` })
return
}
@@ -2094,9 +2118,11 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
let decoded = raw
const rawMagicMime = detectImageMime(raw, '')
console.log(`[SnsService] 原始数据: size=${raw.length}, mime=${rawMagicMime}, xEnc=${xEnc}`)
// 图片逻辑
const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0
console.log(`[SnsService] 解密判断: shouldDecrypt=${shouldDecrypt}, key=${key || 'undefined'}`)
if (shouldDecrypt) {
try {
const keyStr = String(key).trim()
@@ -2112,6 +2138,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
}
const decryptedMagicMime = detectImageMime(decrypted, '')
console.log(`[SnsService] 解密后: mime=${decryptedMagicMime}`)
if (decryptedMagicMime.startsWith('image/')) {
decoded = decrypted
} else if (!rawMagicMime.startsWith('image/')) {
@@ -2124,7 +2151,9 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
}
const decodedMagicMime = detectImageMime(decoded, '')
console.log(`[SnsService] 最终结果: mime=${decodedMagicMime}, isImage=${decodedMagicMime.startsWith('image/')}`)
if (!decodedMagicMime.startsWith('image/')) {
console.error(`[SnsService] 图片解密失败: 原始mime=${rawMagicMime}, 解密后mime=${decodedMagicMime}, key=${key}`)
resolve({ success: false, error: '图片解密失败:无法识别图片格式' })
return
}