This commit is contained in:
cc committed 2026-05-29 19:44:43 +08:00
commit b75313bc29
27 files changed
+4661 -263

No files matched your search

+98 -28
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'
@@ -438,6 +440,7 @@ const pruneChatHistoryPayloadStore = (): void => {
}
type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
type CloseRestoreMethod = 'tray' | 'dock'
// 更新下载状态管理(Issue #294 修复)
let isDownloadInProgress = false
@@ -811,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,
@@ -906,7 +927,7 @@ function createWindow(options: { autoShow?: boolean } = {}) {
return
}
if (closeBehavior === 'tray' && tray) {
if (closeBehavior === 'tray' && canKeepMainWindowInBackground()) {
win.hide()
return
}
@@ -1769,6 +1790,7 @@ function registerIpcHandlers() {
}
void messagePushService.handleConfigChanged(key)
void insightService.handleConfigChanged(key)
void groupSummaryService.handleConfigChanged(key)
return result
})
@@ -1821,6 +1843,22 @@ function registerIpcHandlers() {
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: {
@@ -1852,6 +1890,39 @@ function registerIpcHandlers() {
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) {
@@ -1888,6 +1959,7 @@ function registerIpcHandlers() {
configService?.clear()
messagePushService.handleConfigCleared()
insightService.handleConfigCleared()
groupSummaryService.handleConfigCleared()
return true
})
@@ -2131,7 +2203,7 @@ function registerIpcHandlers() {
try {
if (action === 'tray') {
if (tray) {
if (canKeepMainWindowInBackground()) {
mainWindow.hide()
return true
}
@@ -4223,6 +4295,7 @@ app.whenReady().then(async () => {
})
messagePushService.start()
insightService.start()
groupSummaryService.start()
await delay(200)
// 已完成引导时,在 Splash 阶段预热核心数据(联系人、消息库索引等)
@@ -4276,18 +4349,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 {
@@ -4365,6 +4427,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()
}
@@ -4381,6 +4451,7 @@ const shutdownAppServices = async (): Promise<void> => {
destroyNotificationWindow()
messagePushService.stop()
insightService.stop()
groupSummaryService.stop()
// 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留
const forceExitTimer = setTimeout(() => {
console.warn('[App] Force exit after timeout')
@@ -4409,4 +4480,3 @@ app.on('window-all-closed', () => {
app.quit()
}
})
+32 -2
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)
},
@@ -588,6 +593,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
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: {
@@ -615,6 +627,24 @@ contextBridge.exposeInMainWorld('electronAPI', {
}) => 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: {
saveWeiboCookie: (rawInput: string) => ipcRenderer.invoke('social:saveWeiboCookie', rawInput),
validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid)
+123 -15
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
}
+119 -23
View File
@@ -129,6 +129,11 @@ interface ConfigSchema {
// AI 足迹
aiFootprintEnabled: boolean
aiFootprintSystemPrompt: string
aiGroupSummaryEnabled: boolean
aiGroupSummaryIntervalHours: number
aiGroupSummarySystemPrompt: string
aiGroupSummaryFilterMode: 'whitelist' | 'blacklist'
aiGroupSummaryFilterList: string[]
aiMessageInsightEnabled: boolean
aiMessageInsightContextCount: number
aiMessageInsightSystemPrompt: string
@@ -255,6 +260,11 @@ export class ConfigService {
aiInsightWeiboBindings: {},
aiFootprintEnabled: false,
aiFootprintSystemPrompt: '',
aiGroupSummaryEnabled: false,
aiGroupSummaryIntervalHours: 4,
aiGroupSummarySystemPrompt: '',
aiGroupSummaryFilterMode: 'whitelist',
aiGroupSummaryFilterList: [],
aiMessageInsightEnabled: false,
aiMessageInsightContextCount: 50,
aiMessageInsightSystemPrompt: '',
@@ -823,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)
}
}
// === 验证 ===
@@ -905,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')
@@ -922,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
@@ -949,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 { }
+121 -23
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)
});
@@ -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()
+801
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()
+16 -6
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 }
}
+1001
View File
@@ -0,0 +1,1001 @@
import fs from 'fs'
import path from 'path'
import https from 'https'
import http from 'http'
import { URL } from 'url'
import { app } from 'electron'
import { randomUUID, createHash } from 'crypto'
import { ConfigService } from './config'
import { chatService, type Message } from './chatService'
import { wcdbService } from './wcdbService'
const API_TIMEOUT_MS = 45_000
const API_TEMPERATURE = 0.7
const MONTH_MATERIAL_CHAR_LIMIT = 45_000
const DIRECT_MONTH_MESSAGE_LIMIT = 1000
const MONTH_CURSOR_BATCH_SIZE = 800
const MAX_RETRY_ATTEMPTS = 5
const MONTHLY_OUTPUT_MIN_TOKENS = 1600
const FINAL_OUTPUT_MIN_TOKENS = 2400
type ProfileStatusValue = 'none' | 'ready' | 'running' | 'failed'
interface SharedAiModelConfig {
apiBaseUrl: string
apiKey: string
model: string
maxTokens: number
}
interface ActiveProfileTask {
taskId: string
sessionId: string
displayName: string
controller: AbortController
phase: string
startedAt: number
cursor?: number
}
interface MonthWindow {
key: string
label: string
startSec: number
endSec: number
}
interface MonthStats {
total: number
mine: number
peer: number
activeDays: number
longestActiveDayStreak: number
longestSilenceDays: number
topHours: string[]
firstTime?: number
lastTime?: number
}
interface PreparedMonthMaterial {
text: string
compressed: boolean
stats: MonthStats
scannedMessages: number
sampledMessages: number
}
interface MonthSummary {
month: string
messageCount: number
compressed: boolean
sampledMessages: number
summary: string
}
export interface InsightProfileRecord {
id: string
accountScope: string
sessionId: string
displayName: string
avatarUrl?: string
createdAt: number
updatedAt: number
rangeStart: number
rangeEnd: number
months: string[]
emptyMonths: string[]
monthlySummaries: MonthSummary[]
finalProfile: string
stats: {
scannedMessages: number
summarizedMonths: number
emptyMonths: number
compressedMonths: number
}
model: string
}
export interface InsightProfileStatus {
sessionId: string
status: ProfileStatusValue
updatedAt?: number
error?: string
phase?: string
busy?: boolean
}
export interface InsightProfileStatusListResult {
success: boolean
statuses: Record<string, InsightProfileStatus>
activeTask?: {
sessionId: string
displayName: string
phase: string
startedAt: number
}
error?: string
}
export interface InsightProfileGenerateResult {
success: boolean
message: string
cancelled?: boolean
profile?: InsightProfileRecord
error?: string
}
class AbortRequestError extends Error {
constructor(message = '画像任务已取消') {
super(message)
this.name = 'AbortError'
}
}
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 isAbortError(error: unknown): boolean {
return (error as Error)?.name === 'AbortError' || String((error as Error)?.message || '').includes('取消')
}
function abortIfNeeded(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new AbortRequestError()
}
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
abortIfNeeded(signal)
let settled = false
const timer = setTimeout(() => {
if (settled) return
settled = true
cleanup()
resolve()
}, ms)
const onAbort = () => {
if (settled) return
settled = true
clearTimeout(timer)
cleanup()
reject(new AbortRequestError())
}
const cleanup = () => {
signal?.removeEventListener('abort', onAbort)
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}
function normalizeApiMaxTokens(value: unknown): number {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return 1024
return Math.min(2_000_000, Math.max(1, Math.floor(numeric)))
}
function buildApiUrl(baseUrl: string, apiPath: string): string {
const base = baseUrl.replace(/\/+$/, '')
const suffix = apiPath.startsWith('/') ? apiPath : `/${apiPath}`
return `${base}${suffix}`
}
function formatPromptCurrentTime(date: Date = new Date()): string {
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')
return `当前系统时间:${year}年${month}月${day}日 ${hours}:${minutes}`
}
function appendPromptCurrentTime(prompt: string): string {
const base = String(prompt || '').trimEnd()
return base ? `${base}\n\n${formatPromptCurrentTime()}` : formatPromptCurrentTime()
}
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 truncateStructuredText(value: unknown, maxLength: number): string {
const text = String(value || '').replace(/\u0000/g, '').trim()
if (text.length <= maxLength) return text
return `${text.slice(0, Math.max(0, maxLength - 3))}...`
}
function formatDateTime(timestampSeconds: number): string {
if (!Number.isFinite(timestampSeconds) || timestampSeconds <= 0) return ''
const date = new Date(timestampSeconds * 1000)
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')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
function formatMonthKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
}
function getMonthStart(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0)
}
function toSeconds(date: Date): number {
return Math.floor(date.getTime() / 1000)
}
function buildRecentTwelveMonthWindows(now: Date = new Date()): MonthWindow[] {
const currentMonthStart = getMonthStart(now)
const windows: MonthWindow[] = []
for (let index = 11; index >= 0; index -= 1) {
const start = new Date(currentMonthStart)
start.setMonth(currentMonthStart.getMonth() - index)
const next = new Date(start)
next.setMonth(start.getMonth() + 1)
const isCurrentMonth = index === 0
const end = isCurrentMonth ? now : new Date(next.getTime() - 1000)
const key = formatMonthKey(start)
windows.push({
key,
label: key,
startSec: toSeconds(start),
endSec: Math.max(toSeconds(start), toSeconds(end))
})
}
return windows
}
function callProfileApi(
config: SharedAiModelConfig,
messages: Array<{ role: string; content: string }>,
maxTokens: number,
signal?: AbortSignal
): Promise<string> {
return new Promise((resolve, reject) => {
try {
abortIfNeeded(signal)
const endpoint = buildApiUrl(config.apiBaseUrl, '/chat/completions')
const urlObj = new URL(endpoint)
const payload = JSON.stringify({
model: config.model,
messages,
max_tokens: normalizeApiMaxTokens(maxTokens),
temperature: API_TEMPERATURE,
stream: false
})
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(payload).toString(),
Authorization: `Bearer ${config.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)}`))
}
})
})
const onAbort = () => {
req.destroy(new AbortRequestError())
}
signal?.addEventListener('abort', onAbort, { once: true })
req.setTimeout(API_TIMEOUT_MS, () => {
req.destroy()
reject(new Error('API 请求超时'))
})
req.on('error', (error) => {
signal?.removeEventListener('abort', onAbort)
reject(isAbortError(error) || signal?.aborted ? new AbortRequestError() : error)
})
req.on('close', () => {
signal?.removeEventListener('abort', onAbort)
})
req.write(payload)
req.end()
} catch (error) {
reject(error)
}
})
}
async function callProfileApiWithRetry(
config: SharedAiModelConfig,
messages: Array<{ role: string; content: string }>,
maxTokens: number,
signal?: AbortSignal
): Promise<string> {
let lastError: unknown
for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt += 1) {
abortIfNeeded(signal)
try {
return await callProfileApi(config, messages, maxTokens, signal)
} catch (error) {
if (isAbortError(error) || signal?.aborted) throw new AbortRequestError()
lastError = error
if (attempt >= MAX_RETRY_ATTEMPTS) break
await sleep(Math.min(10_000, 800 * Math.pow(2, attempt - 1)), signal)
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError || 'API 请求失败'))
}
class InsightProfileService {
private readonly config = ConfigService.getInstance()
private filePath: string | null = null
private loaded = false
private records: InsightProfileRecord[] = []
private activeTask: ActiveProfileTask | null = null
private failedStatus = new Map<string, { error: string; updatedAt: number }>()
private resolveFilePath(): string {
if (this.filePath) return this.filePath
const userDataPath = app?.getPath?.('userData') || process.cwd()
fs.mkdirSync(userDataPath, { recursive: true })
this.filePath = path.join(userDataPath, 'weflow-insight-profiles.json')
return this.filePath
}
private ensureLoaded(): void {
if (this.loaded) return
this.loaded = true
try {
const filePath = this.resolveFilePath()
if (!fs.existsSync(filePath)) return
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
const records = Array.isArray(parsed) ? parsed : parsed?.records
if (Array.isArray(records)) {
this.records = records.filter((item) => item && typeof item === 'object') as InsightProfileRecord[]
}
} catch {
this.records = []
}
}
private persist(): void {
try {
fs.writeFileSync(this.resolveFilePath(), JSON.stringify({ version: 1, records: this.records }, null, 2), 'utf-8')
} catch {
// Profile generation should not crash when local persistence fails.
}
}
private getCurrentAccountScope(): string {
const myWxid = String(this.config.getMyWxidCleaned() || '').trim()
if (myWxid) return `wxid:${myWxid}`
const dbPath = String(this.config.get('dbPath') || '').trim()
if (dbPath) {
const hash = createHash('sha1').update(dbPath).digest('hex').slice(0, 16)
return `db:${hash}`
}
return 'default'
}
private getSharedAiModelConfig(): SharedAiModelConfig {
const apiBaseUrl = String(
this.config.get('aiModelApiBaseUrl')
|| this.config.get('aiInsightApiBaseUrl')
|| ''
).trim().replace(/\/+$/, '')
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'
const maxTokens = normalizeApiMaxTokens(this.config.get('aiModelApiMaxTokens'))
return { apiBaseUrl, apiKey, model, maxTokens }
}
private findLatestRecord(sessionId: string): InsightProfileRecord | null {
this.ensureLoaded()
const scope = this.getCurrentAccountScope()
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return null
const matches = this.records
.filter((record) => record.accountScope === scope && record.sessionId === normalizedSessionId)
.sort((a, b) => b.updatedAt - a.updatedAt)
return matches[0] || null
}
listProfileStatuses(sessionIds: string[]): InsightProfileStatusListResult {
this.ensureLoaded()
const scope = this.getCurrentAccountScope()
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
const latestBySession = new Map<string, InsightProfileRecord>()
for (const record of this.records.filter((item) => item.accountScope === scope)) {
const existing = latestBySession.get(record.sessionId)
if (!existing || record.updatedAt > existing.updatedAt) {
latestBySession.set(record.sessionId, record)
}
}
const statuses: Record<string, InsightProfileStatus> = {}
for (const sessionId of normalizedIds) {
const activeForSession = this.activeTask?.sessionId === sessionId
if (activeForSession && this.activeTask) {
statuses[sessionId] = {
sessionId,
status: 'running',
phase: this.activeTask.phase,
updatedAt: this.activeTask.startedAt,
busy: false
}
continue
}
const record = latestBySession.get(sessionId)
if (record) {
statuses[sessionId] = {
sessionId,
status: 'ready',
updatedAt: record.updatedAt,
busy: Boolean(this.activeTask)
}
continue
}
const failed = this.failedStatus.get(sessionId)
if (failed) {
statuses[sessionId] = {
sessionId,
status: 'failed',
updatedAt: failed.updatedAt,
error: failed.error,
busy: Boolean(this.activeTask)
}
continue
}
statuses[sessionId] = {
sessionId,
status: 'none',
busy: Boolean(this.activeTask)
}
}
return {
success: true,
statuses,
activeTask: this.activeTask
? {
sessionId: this.activeTask.sessionId,
displayName: this.activeTask.displayName,
phase: this.activeTask.phase,
startedAt: this.activeTask.startedAt
}
: undefined
}
}
getProfileContextSection(sessionId: string): string {
const record = this.findLatestRecord(sessionId)
if (!record?.finalProfile) return ''
const rangeStart = formatDateTime(record.rangeStart)
const rangeEnd = formatDateTime(record.rangeEnd)
return [
`联系人长期 AI 画像(覆盖 ${rangeStart} 至 ${rangeEnd},生成于 ${new Date(record.updatedAt).toLocaleString('zh-CN')}):`,
clampText(record.finalProfile, 3000)
].join('\n')
}
cancelProfile(sessionId?: string): { success: boolean; message: string } {
const normalizedSessionId = String(sessionId || '').trim()
if (!this.activeTask) return { success: true, message: '当前没有画像任务' }
if (normalizedSessionId && normalizedSessionId !== this.activeTask.sessionId) {
return { success: false, message: '当前运行中的画像任务不属于该联系人' }
}
this.activeTask.phase = '正在取消画像...'
this.activeTask.controller.abort()
return { success: true, message: '已请求取消画像任务' }
}
cancelActiveTask(reason = '画像任务已取消'): void {
if (!this.activeTask) return
this.activeTask.phase = reason
this.activeTask.controller.abort()
}
async generateProfile(params: {
sessionId: string
displayName?: string
avatarUrl?: string
}): Promise<InsightProfileGenerateResult> {
const sessionId = String(params?.sessionId || '').trim()
if (!sessionId || sessionId.endsWith('@chatroom')) {
return { success: false, message: 'AI 画像仅支持私聊联系人' }
}
if (this.activeTask) {
return {
success: false,
message: `「${this.activeTask.displayName}」的画像正在生成,请等待完成或取消后再试`
}
}
const aiConfig = this.getSharedAiModelConfig()
if (!aiConfig.apiBaseUrl || !aiConfig.apiKey) {
return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' }
}
const existing = this.findLatestRecord(sessionId)
const displayName = clampText(params?.displayName || existing?.displayName || sessionId, 80) || sessionId
const controller = new AbortController()
const task: ActiveProfileTask = {
taskId: randomUUID(),
sessionId,
displayName,
controller,
phase: '正在初始化画像...',
startedAt: Date.now()
}
this.activeTask = task
try {
const connectResult = await chatService.connect()
abortIfNeeded(controller.signal)
if (!connectResult.success) {
throw new Error('数据库连接失败,请先在“数据库连接”页完成配置')
}
const windows = buildRecentTwelveMonthWindows()
const monthlySummaries: MonthSummary[] = []
const emptyMonths: string[] = []
let scannedMessages = 0
let compressedMonths = 0
for (let index = 0; index < windows.length; index += 1) {
abortIfNeeded(controller.signal)
const month = windows[index]
task.phase = `正在读取 ${month.label} 聊天记录 (${index + 1}/12)...`
const messages = await this.readMonthMessages(sessionId, month, task)
scannedMessages += messages.length
if (messages.length === 0) {
emptyMonths.push(month.label)
continue
}
const material = this.prepareMonthMaterial(messages, displayName)
if (material.compressed) compressedMonths += 1
task.phase = `正在生成 ${month.label} 月度画像 (${monthlySummaries.length + 1})...`
const summary = await this.generateMonthlySummary(aiConfig, displayName, month.label, material, controller.signal)
monthlySummaries.push({
month: month.label,
messageCount: material.scannedMessages,
compressed: material.compressed,
sampledMessages: material.sampledMessages,
summary
})
}
if (monthlySummaries.length === 0) {
throw new Error('最近 12 个自然月没有可用于画像的聊天记录')
}
task.phase = '正在汇总完整 AI 画像...'
const finalProfile = await this.generateFinalProfile(aiConfig, displayName, windows, emptyMonths, monthlySummaries, controller.signal)
abortIfNeeded(controller.signal)
const now = Date.now()
const record: InsightProfileRecord = {
id: randomUUID(),
accountScope: this.getCurrentAccountScope(),
sessionId,
displayName,
avatarUrl: String(params?.avatarUrl || existing?.avatarUrl || '').trim() || undefined,
createdAt: existing?.createdAt || now,
updatedAt: now,
rangeStart: windows[0].startSec,
rangeEnd: windows[windows.length - 1].endSec,
months: windows.map((month) => month.label),
emptyMonths,
monthlySummaries,
finalProfile,
stats: {
scannedMessages,
summarizedMonths: monthlySummaries.length,
emptyMonths: emptyMonths.length,
compressedMonths
},
model: aiConfig.model
}
this.upsertRecord(record)
this.failedStatus.delete(sessionId)
return {
success: true,
message: `已完成「${displayName}」的 AI 画像`,
profile: record
}
} catch (error) {
if (isAbortError(error) || controller.signal.aborted) {
return { success: false, cancelled: true, message: '画像已取消' }
}
const message = (error as Error).message || String(error)
if (!existing) {
this.failedStatus.set(sessionId, { error: message, updatedAt: Date.now() })
}
return { success: false, message: `画像失败:${message}`, error: message }
} finally {
if (task.cursor) {
await wcdbService.closeMessageCursor(task.cursor).catch(() => {})
}
if (this.activeTask?.taskId === task.taskId) {
this.activeTask = null
}
}
}
private upsertRecord(record: InsightProfileRecord): void {
this.ensureLoaded()
this.records = this.records.filter((item) => !(item.accountScope === record.accountScope && item.sessionId === record.sessionId))
this.records.push(record)
this.persist()
}
private async readMonthMessages(sessionId: string, month: MonthWindow, task: ActiveProfileTask): Promise<Message[]> {
const cursorResult = await wcdbService.openMessageCursorLite(
sessionId,
MONTH_CURSOR_BATCH_SIZE,
true,
month.startSec,
month.endSec
)
if (!cursorResult.success || !cursorResult.cursor) {
throw new Error(cursorResult.error || `读取 ${month.label} 聊天记录失败`)
}
task.cursor = cursorResult.cursor
const messages: Message[] = []
try {
while (true) {
abortIfNeeded(task.controller.signal)
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success) {
throw new Error(batch.error || `读取 ${month.label} 聊天记录失败`)
}
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
if (rows.length > 0) {
const mapped = chatService.mapRowsToMessagesLiteForApi(rows)
for (const message of mapped) {
const createTime = Number(message.createTime || 0)
if (createTime < month.startSec || createTime > month.endSec) continue
messages.push({
...message,
rawContent: clampText(message.rawContent || message.content || '', 1200),
content: undefined
})
}
}
if (!batch.hasMore) break
}
messages.sort((a, b) => (a.createTime - b.createTime) || (a.sortSeq - b.sortSeq) || (a.localId - b.localId))
return messages
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor).catch(() => {})
if (task.cursor === cursorResult.cursor) task.cursor = undefined
}
}
private prepareMonthMaterial(messages: Message[], peerDisplayName: string): PreparedMonthMaterial {
const stats = this.computeMonthStats(messages)
const lines = messages.map((message) => this.formatMessageLine(message, peerDisplayName))
const fullText = lines.join('\n')
if (messages.length <= DIRECT_MONTH_MESSAGE_LIMIT && fullText.length <= MONTH_MATERIAL_CHAR_LIMIT) {
return {
text: fullText,
compressed: false,
stats,
scannedMessages: messages.length,
sampledMessages: messages.length
}
}
const statsText = this.formatMonthStats(stats)
const selectedIndices = this.selectRepresentativeIndices(messages)
const sampledLines = Array.from(selectedIndices)
.sort((a, b) => a - b)
.map((index) => lines[index])
const sampledText = this.fitLinesToBudget(sampledLines, Math.max(10_000, MONTH_MATERIAL_CHAR_LIMIT - statsText.length - 800))
const text = [
'本月聊天记录已完整扫描。由于原文过长,以下为本地统计摘要、时间均匀抽样与高信息密度片段;请基于这些证据谨慎概括,不要把抽样片段视为全部事实。',
'',
statsText,
'',
'代表性聊天片段(按时间顺序):',
sampledText || '无可读文本片段'
].join('\n')
return {
text: truncateStructuredText(text, MONTH_MATERIAL_CHAR_LIMIT),
compressed: true,
stats,
scannedMessages: messages.length,
sampledMessages: sampledLines.length
}
}
private computeMonthStats(messages: Message[]): MonthStats {
const daySet = new Set<string>()
const hourCounts = new Map<number, number>()
let mine = 0
let peer = 0
let firstTime = 0
let lastTime = 0
for (const message of messages) {
const ts = Math.max(0, Math.floor(Number(message.createTime || 0)))
if (ts > 0) {
if (!firstTime || ts < firstTime) firstTime = ts
if (!lastTime || ts > lastTime) lastTime = ts
const date = new Date(ts * 1000)
const day = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
daySet.add(day)
const hour = date.getHours()
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1)
}
if (message.isSend === 1) mine += 1
else peer += 1
}
const sortedDays = Array.from(daySet).sort()
let longestActiveDayStreak = 0
let currentStreak = 0
let longestSilenceDays = 0
let prevDayTime = 0
for (const day of sortedDays) {
const dayTime = new Date(`${day}T00:00:00`).getTime()
if (!prevDayTime || dayTime - prevDayTime === 86_400_000) {
currentStreak += 1
} else {
currentStreak = 1
longestSilenceDays = Math.max(longestSilenceDays, Math.floor((dayTime - prevDayTime) / 86_400_000) - 1)
}
prevDayTime = dayTime
longestActiveDayStreak = Math.max(longestActiveDayStreak, currentStreak)
}
const topHours = Array.from(hourCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([hour, count]) => `${String(hour).padStart(2, '0')}:00(${count}条)`)
return {
total: messages.length,
mine,
peer,
activeDays: daySet.size,
longestActiveDayStreak,
longestSilenceDays,
topHours,
firstTime: firstTime || undefined,
lastTime: lastTime || undefined
}
}
private formatMonthStats(stats: MonthStats): string {
return [
'本月统计摘要:',
`消息总数:${stats.total}`,
`我发送:${stats.mine};对方发送:${stats.peer}`,
`活跃天数:${stats.activeDays}`,
`最长连续活跃:${stats.longestActiveDayStreak} 天`,
`最长无互动间隔:${stats.longestSilenceDays} 天`,
`主要互动时段:${stats.topHours.length > 0 ? stats.topHours.join('、') : '无'}`,
`首条消息时间:${stats.firstTime ? formatDateTime(stats.firstTime) : '无'}`,
`末条消息时间:${stats.lastTime ? formatDateTime(stats.lastTime) : '无'}`
].join('\n')
}
private selectRepresentativeIndices(messages: Message[]): Set<number> {
const selected = new Set<number>()
const addWindow = (center: number, radius = 2) => {
for (let index = Math.max(0, center - radius); index <= Math.min(messages.length - 1, center + radius); index += 1) {
selected.add(index)
}
}
if (messages.length === 0) return selected
const bucketCount = Math.min(24, Math.max(6, Math.ceil(messages.length / 250)))
for (let bucket = 0; bucket < bucketCount; bucket += 1) {
const start = Math.floor((messages.length * bucket) / bucketCount)
const end = Math.max(start, Math.floor((messages.length * (bucket + 1)) / bucketCount) - 1)
addWindow(start, 1)
addWindow(Math.floor((start + end) / 2), 1)
addWindow(end, 1)
}
const scored = messages.map((message, index) => ({
index,
score: this.scoreMessageForProfile(message)
}))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 120)
for (const item of scored) {
addWindow(item.index, 2)
}
addWindow(0, 3)
addWindow(Math.floor(messages.length / 2), 3)
addWindow(messages.length - 1, 3)
return selected
}
private scoreMessageForProfile(message: Message): number {
const content = this.extractReadableContent(message)
if (!content || content.startsWith('[')) return 0
const emotionWords = [
'谢谢', '感谢', '抱歉', '对不起', '开心', '高兴', '难过', '委屈', '生气', '焦虑', '压力', '累',
'想你', '喜欢', '爱', '在乎', '担心', '害怕', '烦', '崩溃', '见面', '一起', '约', '陪', '帮'
]
let score = Math.min(80, content.length)
if (/[??]/.test(content)) score += 18
if (/[!!]{1,}/.test(content)) score += 8
for (const word of emotionWords) {
if (content.includes(word)) score += 24
}
if (message.quotedContent) score += 12
if (content.length >= 80) score += 16
return score
}
private fitLinesToBudget(lines: string[], budget: number): string {
const output: string[] = []
let used = 0
for (const line of lines) {
const normalized = clampText(line, 700)
const nextUsed = used + normalized.length + 1
if (nextUsed > budget) break
output.push(normalized)
used = nextUsed
}
return output.join('\n')
}
private extractReadableContent(message: Message): string {
const parsed = String(message.parsedContent || '').trim()
if (parsed) return clampText(parsed, 600)
const raw = String(message.rawContent || message.content || '').trim()
if (!raw) return '[其他消息]'
if (/^(<\?xml|<msg\b|<appmsg\b|<img\b|<emoji\b|<voip\b|<sysmsg\b|&lt;)/i.test(raw)) {
return '[其他消息]'
}
return clampText(raw, 600)
}
private formatMessageLine(message: Message, peerDisplayName: string): string {
const sender = message.isSend === 1 ? '我' : peerDisplayName
const content = this.extractReadableContent(message)
const quoted = message.quotedContent ? ` [引用:${clampText(message.quotedContent, 120)}]` : ''
return `${formatDateTime(message.createTime)} ${sender}:${content}${quoted}`
}
private async generateMonthlySummary(
config: SharedAiModelConfig,
displayName: string,
monthLabel: string,
material: PreparedMonthMaterial,
signal?: AbortSignal
): Promise<string> {
const systemPrompt = `你是一个克制、细致的长期关系画像分析助手。你只根据给定聊天材料分析,不做诊断,不给道德评判,不编造事实。你的目标是从一个自然月的聊天中提炼这个人的沟通风格、情绪模式、关系需求、关注主题、互动节奏,以及与“我”的关系变化线索。
要求:
1. 输出中文纯文本,不使用 Markdown。
2. 控制在 400-600 字。
3. 必须区分“有证据支持的观察”和“不确定但可留意的倾向”。
4. 不要逐条复述聊天记录,要提炼稳定模式和本月变化。
5. 对敏感内容使用概括,不输出隐私细节。
6. 如果材料经过压缩或抽样,明确保持谨慎,不把局部片段当成全部事实。`
const userPrompt = appendPromptCurrentTime(`对象:${displayName}
月份:${monthLabel}
材料状态:${material.compressed ? '本月原始记录过长,已完整扫描后进行本地结构化压缩与代表性抽样' : '本月记录未超过预算,按时间顺序提供'}
扫描消息数:${material.scannedMessages}
用于输入的代表消息数:${material.sampledMessages}
本月聊天材料:
${material.text}
请输出本月画像总结,覆盖:沟通风格、情绪与压力线索、关注主题、与我的互动模式、本月关系变化、后续相处建议。`)
return callProfileApiWithRetry(
config,
[
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
Math.max(config.maxTokens, MONTHLY_OUTPUT_MIN_TOKENS),
signal
)
}
private async generateFinalProfile(
config: SharedAiModelConfig,
displayName: string,
months: MonthWindow[],
emptyMonths: string[],
monthlySummaries: MonthSummary[],
signal?: AbortSignal
): Promise<string> {
const systemPrompt = `你是用户的私人关系画像整理助手。你需要把最近 12 个自然月的月度画像总结合成为一份长期 AI 画像。你只能基于月度总结和空月信息判断,不编造缺失月份内容。
要求:
1. 输出中文纯文本,不使用 Markdown。
2. 控制在 900-1400 字。
3. 画像要稳定、克制、可用于后续 AI 见解上下文。
4. 优先总结长期模式,其次指出近三个月变化。
5. 给出与这个人互动时最值得注意的 3-5 条原则。
6. 不做医学、法律、心理诊断;避免贴标签式结论。`
const summaryText = monthlySummaries
.map((item) => `【${item.month}】消息数:${item.messageCount};材料${item.compressed ? '已压缩抽样' : '未压缩'}\n${item.summary}`)
.join('\n\n')
const userPrompt = appendPromptCurrentTime(`对象:${displayName}
时间范围:${months[0].label} 至 ${months[months.length - 1].label}
空月:${emptyMonths.length > 0 ? emptyMonths.join('、') : '无'}
月度总结:
${summaryText}
请生成完整 AI 画像,结构包含:整体印象、沟通风格、情绪/压力模式、核心关注、关系互动模式、最近变化、相处建议、后续 AI 见解使用注意事项。`)
return callProfileApiWithRetry(
config,
[
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
Math.max(config.maxTokens, FINAL_OUTPUT_MIN_TOKENS),
signal
)
}
}
export const insightProfileService = new InsightProfileService()
+6
View File
@@ -21,6 +21,7 @@ import { chatService, ChatSession, Message } from './chatService'
import { snsService } from './snsService'
import { weiboService } from './social/weiboService'
import { showNotification } from '../windows/notificationWindow'
import { insightProfileService } from './insightProfileService'
import {
insightRecordService,
type InsightRecordLog,
@@ -385,6 +386,7 @@ class InsightService {
this.clearTimers()
this.clearRuntimeCache()
this.processing = false
insightProfileService.cancelActiveTask('AI 见解服务已停止,画像任务已取消')
if (hadActiveFlow) {
insightLog('INFO', '已停止')
}
@@ -400,6 +402,7 @@ class InsightService {
}
if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') {
insightProfileService.cancelActiveTask('数据库或账号配置已变化,画像任务已取消')
this.clearRuntimeCache()
}
@@ -409,6 +412,7 @@ class InsightService {
handleConfigCleared(): void {
this.clearTimers()
this.clearRuntimeCache()
insightProfileService.cancelActiveTask('配置已清除,画像任务已取消')
this.processing = false
}
@@ -1467,6 +1471,7 @@ ${afterText}
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 = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。
@@ -1486,6 +1491,7 @@ ${afterText}
? `已 ${silentDays} 天未联系「${resolvedDisplayName}」。`
: '',
contextSection,
profileContextSection,
momentsContextSection,
socialContextSection,
'请给出你的见解(≤80字):'
+190 -65
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,
+3
View File
@@ -0,0 +1,3 @@
{
"defaultSystemPrompt": "你是一个群聊会议纪要式总结助手。你只根据用户提供的群聊记录总结,不编造记录中没有的信息。\n\n严格要求:\n1. 必须且只能输出合法纯 JSON,禁止 Markdown 和解释说明。\n2. 按话题分类总结,每个话题包含参与者、关键/矛盾点、结论。\n3. 参与者写群成员显示名;不确定参与者时写已有发言人。\n4. 关键/矛盾点必须来自聊天记录,避免泛泛而谈。\n5. 结论要短、具体;没有结论时写“暂无明确结论”。\n\nJSON 输出格式:\n{\n \"topics\": [\n {\n \"title\": \"话题名称\",\n \"participants\": [\"参与者A\", \"参与者B\"],\n \"key_points\": [\"关键点或矛盾点\"],\n \"conclusion\": \"结论\"\n }\n ]\n}"
}
+3
View File
@@ -94,6 +94,7 @@ function App() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [showCloseDialog, setShowCloseDialog] = useState(false)
const [canMinimizeToTray, setCanMinimizeToTray] = useState(false)
const [closeRestoreMethod, setCloseRestoreMethod] = useState<'tray' | 'dock'>('tray')
// 锁定状态
// const [isLocked, setIsLocked] = useState(false) // Moved to store
@@ -120,6 +121,7 @@ function App() {
useEffect(() => {
const removeCloseConfirmListener = window.electronAPI.window.onCloseConfirmRequested((payload) => {
setCanMinimizeToTray(Boolean(payload.canMinimizeToTray))
setCloseRestoreMethod(payload.restoreMethod === 'dock' ? 'dock' : 'tray')
setShowCloseDialog(true)
})
@@ -685,6 +687,7 @@ function App() {
<WindowCloseDialog
open={showCloseDialog}
canMinimizeToTray={canMinimizeToTray}
restoreMethod={closeRestoreMethod}
onSelect={(action, rememberChoice) => handleWindowCloseAction(action, rememberChoice)}
onCancel={() => handleWindowCloseAction('cancel')}
/>
+15 -3
View File
@@ -15,6 +15,7 @@ interface JumpToDatePopoverProps {
messageDateCounts?: Record<string, number>
loadingDates?: boolean
loadingDateCounts?: boolean
maxDate?: Date
}
const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
@@ -29,7 +30,8 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
hasLoadedMessageDates = false,
messageDateCounts,
loadingDates = false,
loadingDateCounts = false
loadingDateCounts = false,
maxDate
}) => {
type CalendarViewMode = 'day' | 'month' | 'year'
const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12
@@ -73,6 +75,14 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
return messageDates.has(toDateKey(day))
}
const isAfterMaxDate = (day: number): boolean => {
if (!maxDate) return false
const max = new Date(maxDate)
max.setHours(23, 59, 59, 999)
const candidate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day, 0, 0, 0, 0)
return candidate.getTime() > max.getTime()
}
const isToday = (day: number): boolean => {
const today = new Date()
return day === today.getDate()
@@ -102,6 +112,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
const handleDateClick = (day: number) => {
if (hasLoadedMessageDates && !hasMessage(day)) return
if (isAfterMaxDate(day)) return
const targetDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
setSelectedDate(targetDate)
onSelect(targetDate)
@@ -113,7 +124,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
const classes = ['day-cell']
if (isToday(day)) classes.push('today')
if (isSelected(day)) classes.push('selected')
if (hasLoadedMessageDates && !hasMessage(day)) classes.push('no-message')
if ((hasLoadedMessageDates && !hasMessage(day)) || isAfterMaxDate(day)) classes.push('no-message')
return classes.join(' ')
}
@@ -225,6 +236,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
if (day === null) return <div key={index} className="day-cell empty" />
const dateKey = toDateKey(day)
const hasMessageOnDay = hasMessage(day)
const isDisabled = (hasLoadedMessageDates && !hasMessageOnDay) || isAfterMaxDate(day)
const count = Number(messageDateCounts?.[dateKey] || 0)
const showCount = count > 0
const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount
@@ -233,7 +245,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
key={index}
className={getDayClassName(day)}
onClick={() => handleDateClick(day)}
disabled={hasLoadedMessageDates && !hasMessageOnDay}
disabled={isDisabled}
type="button"
>
<span className="day-number">{day}</span>
+12 -3
View File
@@ -5,6 +5,7 @@ import './WindowCloseDialog.scss'
interface WindowCloseDialogProps {
open: boolean
canMinimizeToTray: boolean
restoreMethod?: 'tray' | 'dock'
onSelect: (action: 'tray' | 'quit', rememberChoice: boolean) => void
onCancel: () => void
}
@@ -12,10 +13,12 @@ interface WindowCloseDialogProps {
export default function WindowCloseDialog({
open,
canMinimizeToTray,
restoreMethod = 'tray',
onSelect,
onCancel
}: WindowCloseDialogProps) {
const [rememberChoice, setRememberChoice] = useState(false)
const isDockRestore = restoreMethod === 'dock'
useEffect(() => {
if (!open) return
@@ -57,7 +60,9 @@ export default function WindowCloseDialog({
<h2 id="window-close-dialog-title">关闭 WeFlow</h2>
<p>
{canMinimizeToTray
? '你可以保留后台进程与本地 API,或者直接完全退出应用。'
? isDockRestore
? '你可以隐藏主窗口并保留后台进程与本地 API,稍后可从 Dock 或重新打开应用恢复。'
: '你可以保留后台进程与本地 API,或者直接完全退出应用。'
: '当前系统托盘不可用,本次只能完全退出应用。'}
</p>
</div>
@@ -73,8 +78,12 @@ export default function WindowCloseDialog({
<Minimize2 size={18} />
</span>
<span className="window-close-dialog-option-text">
<strong>最小化到系统托盘</strong>
<span>继续保留后台进程和本地 API,稍后可从托盘恢复。</span>
<strong>{isDockRestore ? '隐藏主窗口' : '最小化到系统托盘'}</strong>
<span>
{isDockRestore
? '继续保留后台进程和本地 API,稍后可从 Dock 或重新打开应用恢复。'
: '继续保留后台进程和本地 API,稍后可从托盘恢复。'}
</span>
</span>
</button>
)}
+21
View File
@@ -8,6 +8,7 @@ import {
Info,
Loader2,
Mic,
Newspaper,
RefreshCw,
Search,
Sparkles,
@@ -22,9 +23,11 @@ export interface ChatHeaderProps {
isGroupChat: boolean
standaloneSessionWindow: boolean
showGroupMembersPanel: boolean
showGroupSummaryPanel: boolean
showJumpPopover: boolean
showInSessionSearch: boolean
showDetailPanel: boolean
aiGroupSummaryEnabled: boolean
shouldHideStandaloneDetailButton: boolean
isPrivateSnsSupported: boolean
isExportActionBusy: boolean
@@ -39,6 +42,7 @@ export interface ChatHeaderProps {
currentSessionId?: string | null
jumpCalendarWrapRef: React.RefObject<HTMLDivElement | null>
onTriggerSessionInsight: () => void
onToggleGroupSummaryPanel: () => void
onGroupAnalytics: () => void
onToggleGroupMembersPanel: () => void
onExportCurrentSession: () => void
@@ -56,9 +60,11 @@ function ChatHeader({
isGroupChat,
standaloneSessionWindow,
showGroupMembersPanel,
showGroupSummaryPanel,
showJumpPopover,
showInSessionSearch,
showDetailPanel,
aiGroupSummaryEnabled,
shouldHideStandaloneDetailButton,
isPrivateSnsSupported,
isExportActionBusy,
@@ -73,6 +79,7 @@ function ChatHeader({
currentSessionId,
jumpCalendarWrapRef,
onTriggerSessionInsight,
onToggleGroupSummaryPanel,
onGroupAnalytics,
onToggleGroupMembersPanel,
onExportCurrentSession,
@@ -116,6 +123,17 @@ function ChatHeader({
>
{isTriggeringSessionInsight ? <Loader2 size={18} className="spin" /> : <Sparkles size={18} />}
</button>
{isGroupChat && aiGroupSummaryEnabled && (
<button
className={`icon-btn group-summary-btn ${showGroupSummaryPanel ? 'active' : ''}`}
onClick={onToggleGroupSummaryPanel}
disabled={!currentSessionId}
title="AI 群聊总结"
aria-label="AI 群聊总结"
>
<Newspaper size={18} />
</button>
)}
{!standaloneSessionWindow && isGroupChat && (
<button className="icon-btn group-analytics-btn" onClick={onGroupAnalytics} title="群聊分析">
<BarChart3 size={18} />
@@ -217,9 +235,11 @@ function areEqual(prev: ChatHeaderProps, next: ChatHeaderProps) {
prev.isGroupChat === next.isGroupChat &&
prev.standaloneSessionWindow === next.standaloneSessionWindow &&
prev.showGroupMembersPanel === next.showGroupMembersPanel &&
prev.showGroupSummaryPanel === next.showGroupSummaryPanel &&
prev.showJumpPopover === next.showJumpPopover &&
prev.showInSessionSearch === next.showInSessionSearch &&
prev.showDetailPanel === next.showDetailPanel &&
prev.aiGroupSummaryEnabled === next.aiGroupSummaryEnabled &&
prev.shouldHideStandaloneDetailButton === next.shouldHideStandaloneDetailButton &&
prev.isPrivateSnsSupported === next.isPrivateSnsSupported &&
prev.isExportActionBusy === next.isExportActionBusy &&
@@ -234,6 +254,7 @@ function areEqual(prev: ChatHeaderProps, next: ChatHeaderProps) {
prev.currentSessionId === next.currentSessionId &&
prev.jumpCalendarWrapRef === next.jumpCalendarWrapRef &&
prev.onTriggerSessionInsight === next.onTriggerSessionInsight &&
prev.onToggleGroupSummaryPanel === next.onToggleGroupSummaryPanel &&
prev.onGroupAnalytics === next.onGroupAnalytics &&
prev.onToggleGroupMembersPanel === next.onToggleGroupMembersPanel &&
prev.onExportCurrentSession === next.onExportCurrentSession &&
+265
View File
@@ -3569,6 +3569,271 @@
}
}
.group-summary-panel {
width: clamp(320px, 30vw, 420px);
min-width: 320px;
max-width: 420px;
.group-summary-controls {
padding: 12px;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-primary);
}
.group-summary-date-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
}
input,
.group-summary-date-trigger {
height: 32px;
min-width: 0;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--card-bg);
color: var(--text-primary);
padding: 0 9px;
font-size: 12px;
}
}
.group-summary-date-picker {
position: relative;
min-width: 0;
}
.group-summary-date-trigger {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
transition: all 0.16s ease;
svg {
color: var(--text-secondary);
flex-shrink: 0;
}
&:hover,
&.open {
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
background: var(--bg-hover);
}
}
.group-summary-calendar-popover {
right: auto;
left: 0;
top: calc(100% + 8px);
width: min(312px, calc(100vw - 32px));
border-radius: 10px;
}
.group-summary-icon-btn,
.group-summary-code-btn {
width: 30px;
height: 30px;
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
border-radius: 8px;
background: var(--card-bg);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.16s ease;
&:hover {
color: var(--primary);
background: var(--bg-hover);
border-color: color-mix(in srgb, var(--primary) 34%, var(--border-color));
}
.spin {
animation: spin 1s linear infinite;
}
}
.group-summary-range-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
button {
height: 30px;
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
border-radius: 8px;
background: var(--card-bg);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.16s ease;
&.active {
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
background: color-mix(in srgb, var(--primary) 10%, var(--card-bg));
font-weight: 600;
}
}
}
.group-summary-generate-btn {
height: 34px;
border: none;
border-radius: 8px;
background: var(--primary);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: transform 0.16s ease, opacity 0.16s ease;
&:disabled {
opacity: 0.7;
cursor: not-allowed;
}
&:hover:not(:disabled) {
transform: translateY(-1px);
}
.spin {
animation: spin 1s linear infinite;
}
}
.group-summary-rule-hint,
.group-summary-count {
font-size: 12px;
color: var(--text-tertiary);
}
.group-summary-list {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.group-summary-record {
border: 1px solid color-mix(in srgb, var(--border-color) 74%, transparent);
background: var(--bg-secondary);
border-radius: 10px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.group-summary-record-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.group-summary-period {
display: block;
color: var(--text-primary);
font-size: 12px;
font-weight: 600;
line-height: 1.35;
}
.group-summary-meta {
display: block;
margin-top: 3px;
color: var(--text-tertiary);
font-size: 12px;
}
.group-summary-topic-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.group-summary-topic {
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
padding: 9px;
h5 {
margin: 0 0 7px;
font-size: 13px;
color: var(--text-primary);
line-height: 1.35;
}
}
.group-summary-topic-row {
display: grid;
grid-template-columns: 72px 1fr;
gap: 8px;
padding: 5px 0;
border-top: 1px dashed color-mix(in srgb, var(--border-color) 72%, transparent);
span {
color: var(--text-tertiary);
font-size: 12px;
line-height: 1.45;
}
p {
margin: 0;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
word-break: break-word;
}
}
}
.group-summary-log-modal {
width: min(860px, calc(100vw - 32px));
max-height: min(760px, calc(100vh - 32px));
.detail-content {
max-height: calc(100vh - 120px);
}
}
.group-summary-log-pre {
margin: 0;
max-height: 260px;
overflow: auto;
padding: 10px;
border-radius: 8px;
background: color-mix(in srgb, var(--card-bg) 86%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
@keyframes detailCardEnter {
from {
opacity: 0;
+432 -1
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper, Star, Sparkles } from 'lucide-react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper, Star, Sparkles, Code2 } from 'lucide-react'
import { useNavigate, useLocation } from 'react-router-dom'
import { createPortal } from 'react-dom'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
@@ -8,6 +8,7 @@ import { useChatStore } from '../stores/chatStore'
import { useBatchTranscribeStore, type BatchVoiceTaskType } from '../stores/batchTranscribeStore'
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
import type { ChatRecordItem, ChatSession, Message } from '../types/models'
import type { GroupSummaryRecord, GroupSummaryRecordSummary } from '../types/electron'
import { getEmojiPath } from 'wechat-emojis'
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
import { LivePhotoIcon } from '../components/LivePhotoIcon'
@@ -68,6 +69,7 @@ interface QuotedMessageJumpTarget {
type GlobalMsgSearchPhase = 'idle' | 'seed' | 'backfill' | 'done'
type GlobalMsgSearchResult = Message & { sessionId: string }
type GroupSummaryRangeMode = 1 | 2 | 4 | 8 | 12 | 24
interface GlobalMsgPrefixCacheEntry {
keyword: string
@@ -762,6 +764,17 @@ function formatYmdHmDateTime(timestamp?: number): string {
return `${y}-${m}-${day} ${h}:${min}`
}
function formatDateInputLocal(date: Date): string {
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${y}-${m}-${day}`
}
function formatSummaryPeriod(start: number, end: number): string {
return `${formatYmdHmDateTime(start * 1000)} - ${formatYmdHmDateTime(end * 1000)}`
}
interface ChatPageProps {
standaloneSessionWindow?: boolean
initialSessionId?: string | null
@@ -1476,6 +1489,7 @@ function ChatPage(props: ChatPageProps) {
const sessionListRef = useRef<HTMLDivElement>(null)
const jumpCalendarWrapRef = useRef<HTMLDivElement>(null)
const jumpPopoverPortalRef = useRef<HTMLDivElement>(null)
const groupSummaryDateWrapRef = useRef<HTMLDivElement>(null)
const [currentOffset, setCurrentOffset] = useState(0)
const [jumpStartTime, setJumpStartTime] = useState(0)
const [jumpEndTime, setJumpEndTime] = useState(0)
@@ -1537,6 +1551,18 @@ function ChatPage(props: ChatPageProps) {
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, message: Message } | null>(null)
const [showMessageInfo, setShowMessageInfo] = useState<Message | null>(null)
const [editingMessage, setEditingMessage] = useState<{ message: Message, content: string } | null>(null)
const [aiGroupSummaryEnabled, setAiGroupSummaryEnabled] = useState(false)
const [showGroupSummaryPanel, setShowGroupSummaryPanel] = useState(false)
const [groupSummaryRecords, setGroupSummaryRecords] = useState<GroupSummaryRecordSummary[]>([])
const [groupSummaryTotal, setGroupSummaryTotal] = useState(0)
const [groupSummaryLoading, setGroupSummaryLoading] = useState(false)
const [groupSummaryError, setGroupSummaryError] = useState<string | null>(null)
const [groupSummaryDateFilter, setGroupSummaryDateFilter] = useState(() => formatDateInputLocal(new Date()))
const [groupSummaryRangeMode, setGroupSummaryRangeMode] = useState<GroupSummaryRangeMode>(4)
const [showGroupSummaryDatePopover, setShowGroupSummaryDatePopover] = useState(false)
const [isTriggeringGroupSummary, setIsTriggeringGroupSummary] = useState(false)
const [groupSummaryHint, setGroupSummaryHint] = useState<{ success: boolean; message: string } | null>(null)
const [groupSummaryLogRecord, setGroupSummaryLogRecord] = useState<GroupSummaryRecord | null>(null)
// 多选模式
const [isSelectionMode, setIsSelectionMode] = useState(false)
@@ -1861,6 +1887,130 @@ function ChatPage(props: ChatPageProps) {
})
}, [])
const getGroupSummaryDateRangeSeconds = useCallback((dateValue = groupSummaryDateFilter) => {
const date = dateValue || formatDateInputLocal(new Date())
const start = new Date(`${date}T00:00:00`)
if (!Number.isFinite(start.getTime())) {
const fallback = new Date()
fallback.setHours(0, 0, 0, 0)
const fallbackEnd = new Date(fallback)
fallbackEnd.setHours(23, 59, 59, 999)
return { startTime: Math.floor(fallback.getTime() / 1000), endTime: Math.floor(fallbackEnd.getTime() / 1000) }
}
const end = new Date(start)
end.setHours(23, 59, 59, 999)
return { startTime: Math.floor(start.getTime() / 1000), endTime: Math.floor(end.getTime() / 1000) }
}, [groupSummaryDateFilter])
const isGroupSummaryToday = useMemo(() => {
return (groupSummaryDateFilter || formatDateInputLocal(new Date())) === formatDateInputLocal(new Date())
}, [groupSummaryDateFilter])
const loadGroupSummaryRecords = useCallback(async (sessionId?: string) => {
const targetSessionId = String(sessionId || currentSessionRef.current || '').trim()
if (!targetSessionId || !targetSessionId.endsWith('@chatroom')) return
const { startTime, endTime } = getGroupSummaryDateRangeSeconds()
setGroupSummaryLoading(true)
setGroupSummaryError(null)
try {
const result = await window.electronAPI.groupSummary.listRecords({
sessionId: targetSessionId,
startTime,
endTime,
limit: 100
})
if (currentSessionRef.current !== targetSessionId) return
if (!result.success) {
setGroupSummaryRecords([])
setGroupSummaryTotal(0)
setGroupSummaryError(result.error || '读取群聊总结失败')
return
}
setGroupSummaryRecords(result.records || [])
setGroupSummaryTotal(result.total || 0)
} catch (error) {
if (currentSessionRef.current !== targetSessionId) return
setGroupSummaryRecords([])
setGroupSummaryTotal(0)
setGroupSummaryError((error as Error).message || String(error))
} finally {
if (currentSessionRef.current === targetSessionId) {
setGroupSummaryLoading(false)
}
}
}, [getGroupSummaryDateRangeSeconds])
const resolveTodayGroupSummaryManualRange = useCallback(() => {
const nowSeconds = Math.floor(Date.now() / 1000)
const hours = Number(groupSummaryRangeMode)
return { startTime: nowSeconds - hours * 60 * 60, endTime: nowSeconds }
}, [groupSummaryRangeMode])
const triggerManualGroupSummary = useCallback(async () => {
const sessionId = String(currentSessionId || '').trim()
if (!sessionId || !sessionId.endsWith('@chatroom')) return
const sessionInfo = sessionMapRef.current.get(sessionId)
const selectedDate = groupSummaryDateFilter || formatDateInputLocal(new Date())
const today = formatDateInputLocal(new Date())
setIsTriggeringGroupSummary(true)
setGroupSummaryHint({ success: true, message: '正在生成群聊总结...' })
try {
if (selectedDate === today) {
const { startTime, endTime } = resolveTodayGroupSummaryManualRange()
if (startTime <= 0 || endTime <= startTime) {
setGroupSummaryHint({ success: false, message: '请选择有效的总结时段' })
return
}
const result = await window.electronAPI.groupSummary.triggerManual({
sessionId,
displayName: sessionInfo?.displayName || sessionId,
avatarUrl: sessionInfo?.avatarUrl,
startTime,
endTime
})
if (result.success) {
setGroupSummaryHint({ success: true, message: result.message || '群聊总结已生成' })
if (!result.skipped) {
await loadGroupSummaryRecords(sessionId)
}
} else {
setGroupSummaryHint({ success: false, message: result.message || '群聊总结生成失败' })
}
} else {
const result = await window.electronAPI.groupSummary.triggerDay({
sessionId,
displayName: sessionInfo?.displayName || sessionId,
avatarUrl: sessionInfo?.avatarUrl,
date: selectedDate
})
if (result.success) {
setGroupSummaryHint({ success: true, message: result.message || '群聊总结已生成' })
await loadGroupSummaryRecords(sessionId)
} else {
setGroupSummaryHint({ success: false, message: result.message || '群聊总结生成失败' })
}
}
} catch (error) {
setGroupSummaryHint({ success: false, message: (error as Error).message || String(error) })
} finally {
setIsTriggeringGroupSummary(false)
}
}, [currentSessionId, groupSummaryDateFilter, loadGroupSummaryRecords, resolveTodayGroupSummaryManualRange])
const openGroupSummaryLog = useCallback(async (recordId: string) => {
try {
const result = await window.electronAPI.groupSummary.getRecord(recordId)
if (!result.success || !result.record) {
setGroupSummaryHint({ success: false, message: result.error || '读取总结日志失败' })
return
}
setGroupSummaryLogRecord(result.record)
} catch (error) {
setGroupSummaryHint({ success: false, message: (error as Error).message || String(error) })
}
}, [])
const handleToggleJumpPopover = useCallback(() => {
if (!currentSessionId) return
if (showJumpPopover) {
@@ -2903,9 +3053,21 @@ function ChatPage(props: ChatPageProps) {
return
}
setShowDetailPanel(false)
setShowGroupSummaryPanel(false)
setShowGroupMembersPanel(true)
}, [currentSessionId, showGroupMembersPanel, isGroupChatSession])
const toggleGroupSummaryPanel = useCallback(() => {
if (!currentSessionId || !isGroupChatSession(currentSessionId) || !aiGroupSummaryEnabled) return
if (showGroupSummaryPanel) {
setShowGroupSummaryPanel(false)
return
}
setShowDetailPanel(false)
setShowGroupMembersPanel(false)
setShowGroupSummaryPanel(true)
}, [aiGroupSummaryEnabled, currentSessionId, showGroupSummaryPanel, isGroupChatSession])
// 切换详情面板
const toggleDetailPanel = useCallback(() => {
if (showDetailPanel) {
@@ -2913,6 +3075,7 @@ function ChatPage(props: ChatPageProps) {
return
}
setShowGroupMembersPanel(false)
setShowGroupSummaryPanel(false)
setShowDetailPanel(true)
if (currentSessionId) {
void loadSessionDetail(currentSessionId)
@@ -2929,6 +3092,15 @@ function ChatPage(props: ChatPageProps) {
void loadGroupMembersPanel(currentSessionId)
}, [showGroupMembersPanel, currentSessionId, loadGroupMembersPanel, isGroupChatSession])
useEffect(() => {
if (!showGroupSummaryPanel) return
if (!currentSessionId || !isGroupChatSession(currentSessionId) || !aiGroupSummaryEnabled) {
setShowGroupSummaryPanel(false)
return
}
void loadGroupSummaryRecords(currentSessionId)
}, [aiGroupSummaryEnabled, currentSessionId, groupSummaryDateFilter, loadGroupSummaryRecords, showGroupSummaryPanel, isGroupChatSession])
useEffect(() => {
const chatroomId = String(sessionDetail?.wxid || '').trim()
if (!chatroomId || !chatroomId.includes('@chatroom')) return
@@ -3010,6 +3182,10 @@ function ChatPage(props: ChatPageProps) {
setIsLoadingRelationStats(false)
setShowDetailPanel(false)
setShowGroupMembersPanel(false)
setShowGroupSummaryPanel(false)
setGroupSummaryRecords([])
setGroupSummaryError(null)
setGroupSummaryHint(null)
setGroupPanelMembers([])
setGroupMembersError(null)
setGroupMembersLoadingHint('')
@@ -4405,6 +4581,9 @@ function ChatPage(props: ChatPageProps) {
setShowJumpPopover(false)
setShowDetailPanel(false)
setShowGroupMembersPanel(false)
setShowGroupSummaryPanel(false)
setGroupSummaryError(null)
setGroupSummaryHint(null)
setGroupMemberSearchKeyword('')
setGroupMembersError(null)
setGroupMembersLoadingHint('')
@@ -5512,6 +5691,20 @@ function ChatPage(props: ChatPageProps) {
}
}, [showJumpPopover])
useEffect(() => {
if (!showGroupSummaryDatePopover) return
const handleGlobalPointerDown = (event: MouseEvent) => {
const target = event.target as Node | null
if (!target) return
if (groupSummaryDateWrapRef.current?.contains(target)) return
setShowGroupSummaryDatePopover(false)
}
document.addEventListener('mousedown', handleGlobalPointerDown)
return () => {
document.removeEventListener('mousedown', handleGlobalPointerDown)
}
}, [showGroupSummaryDatePopover])
useEffect(() => {
if (!showJumpPopover) return
const syncPosition = () => {
@@ -5870,6 +6063,29 @@ function ChatPage(props: ChatPageProps) {
}
}, [])
useEffect(() => {
let canceled = false
const loadGroupSummaryConfig = () => {
void configService.getAiGroupSummaryEnabled()
.then((enabled) => {
if (!canceled) setAiGroupSummaryEnabled(enabled)
})
.catch((error) => {
console.warn('加载群聊总结配置失败:', error)
if (!canceled) setAiGroupSummaryEnabled(false)
})
}
loadGroupSummaryConfig()
const handleFocus = () => loadGroupSummaryConfig()
window.addEventListener('focus', handleFocus)
return () => {
canceled = true
window.removeEventListener('focus', handleFocus)
}
}, [])
useEffect(() => {
if (!standaloneSessionWindow) return
setStandaloneInitialLoadRequested(false)
@@ -7375,9 +7591,11 @@ function ChatPage(props: ChatPageProps) {
isGroupChat={isCurrentSessionGroup}
standaloneSessionWindow={standaloneSessionWindow}
showGroupMembersPanel={showGroupMembersPanel}
showGroupSummaryPanel={showGroupSummaryPanel}
showJumpPopover={showJumpPopover}
showInSessionSearch={showInSessionSearch}
showDetailPanel={showDetailPanel}
aiGroupSummaryEnabled={aiGroupSummaryEnabled}
shouldHideStandaloneDetailButton={shouldHideStandaloneDetailButton}
isPrivateSnsSupported={isCurrentSessionPrivateSnsSupported}
isExportActionBusy={isExportActionBusy}
@@ -7392,6 +7610,7 @@ function ChatPage(props: ChatPageProps) {
currentSessionId={currentSessionId}
jumpCalendarWrapRef={jumpCalendarWrapRef}
onTriggerSessionInsight={handleTriggerSessionInsight}
onToggleGroupSummaryPanel={toggleGroupSummaryPanel}
onGroupAnalytics={handleGroupAnalytics}
onToggleGroupMembersPanel={toggleGroupMembersPanel}
onExportCurrentSession={handleExportCurrentSession}
@@ -7443,6 +7662,13 @@ function ChatPage(props: ChatPageProps) {
</div>
)}
{groupSummaryHint && (
<div className={`session-insight-hint ${groupSummaryHint.success ? 'success' : 'error'}`} role="status" aria-live="polite">
{isTriggeringGroupSummary ? <Loader2 size={14} className="spin" /> : <Newspaper size={14} />}
<span>{groupSummaryHint.message}</span>
</div>
)}
<ContactSnsTimelineDialog
target={chatSnsTimelineTarget}
onClose={() => setChatSnsTimelineTarget(null)}
@@ -7683,6 +7909,138 @@ function ChatPage(props: ChatPageProps) {
</div>
)}
{showGroupSummaryPanel && isCurrentSessionGroup && (
<div className="detail-panel group-summary-panel">
<div className="detail-header">
<div className="detail-title-wrap">
<h4>AI 群聊总结</h4>
<span className="detail-title-sub">{currentSession?.displayName || currentSessionId}</span>
</div>
<button className="close-btn" onClick={() => setShowGroupSummaryPanel(false)}>
<X size={16} />
</button>
</div>
<div className="group-summary-controls">
<div className="group-summary-date-row">
<label>日期</label>
<div className="group-summary-date-picker" ref={groupSummaryDateWrapRef}>
<button
type="button"
className={`group-summary-date-trigger ${showGroupSummaryDatePopover ? 'open' : ''}`}
onClick={() => setShowGroupSummaryDatePopover((open) => !open)}
>
<span>{groupSummaryDateFilter}</span>
<Calendar size={14} />
</button>
<JumpToDatePopover
isOpen={showGroupSummaryDatePopover}
onClose={() => setShowGroupSummaryDatePopover(false)}
currentDate={new Date(`${groupSummaryDateFilter || formatDateInputLocal(new Date())}T00:00:00`)}
onSelect={(date) => setGroupSummaryDateFilter(formatDateInputLocal(date))}
className="group-summary-calendar-popover"
maxDate={new Date()}
/>
</div>
<button
type="button"
className="group-summary-icon-btn"
onClick={() => void loadGroupSummaryRecords(currentSessionId || undefined)}
title="刷新总结"
>
<RefreshCw size={14} className={groupSummaryLoading ? 'spin' : ''} />
</button>
</div>
{isGroupSummaryToday ? (
<div className="group-summary-range-tabs">
{([1, 2, 4, 8, 12, 24] as const).map((hours) => (
<button
key={hours}
type="button"
className={groupSummaryRangeMode === hours ? 'active' : ''}
onClick={() => setGroupSummaryRangeMode(hours)}
>
{hours}h
</button>
))}
</div>
) : (
<div className="group-summary-rule-hint">将按设置里的自动总结间隔切分选中日期的完整聊天记录。</div>
)}
<button
type="button"
className="group-summary-generate-btn"
onClick={() => void triggerManualGroupSummary()}
disabled={isTriggeringGroupSummary}
>
{isTriggeringGroupSummary ? <Loader2 size={14} className="spin" /> : <Sparkles size={14} />}
<span>生成总结</span>
</button>
<div className="group-summary-rule-hint">
{isGroupSummaryToday ? '少于 5 条可总结消息会自动跳过。' : '每个切片少于 5 条可总结消息会自动跳过。'}
</div>
</div>
<div className="group-summary-list">
{groupSummaryLoading ? (
<div className="detail-loading">
<Loader2 size={20} className="spin" />
<span>加载总结中...</span>
</div>
) : groupSummaryError ? (
<div className="detail-empty">{groupSummaryError}</div>
) : groupSummaryRecords.length === 0 ? (
<div className="detail-empty">当前日期暂无群聊总结</div>
) : (
<>
<div className="group-summary-count">共 {groupSummaryTotal} 条总结</div>
{groupSummaryRecords.map((record) => (
<div key={record.id} className="group-summary-record">
<div className="group-summary-record-head">
<div>
<span className="group-summary-period">{formatSummaryPeriod(record.periodStart, record.periodEnd)}</span>
<span className="group-summary-meta">
{record.triggerType === 'manual' ? '手动' : '自动'} · {record.readableMessageCount} 条消息
</span>
</div>
<button
type="button"
className="group-summary-code-btn"
onClick={() => void openGroupSummaryLog(record.id)}
title="查看完整日志"
>
<Code2 size={14} />
</button>
</div>
<div className="group-summary-topic-list">
{record.topics.map((topic, topicIndex) => (
<div key={`${record.id}-${topicIndex}`} className="group-summary-topic">
<h5>{topic.title}</h5>
<div className="group-summary-topic-row">
<span>参与者</span>
<p>{topic.participants.length > 0 ? topic.participants.join('、') : '未明确'}</p>
</div>
<div className="group-summary-topic-row">
<span>关键/矛盾点</span>
<p>{topic.keyPoints.length > 0 ? topic.keyPoints.join(';') : '无'}</p>
</div>
<div className="group-summary-topic-row">
<span>结论</span>
<p>{topic.conclusion || '暂无明确结论'}</p>
</div>
</div>
))}
</div>
</div>
))}
</>
)}
</div>
</div>
)}
{/* 会话详情面板 */}
{showDetailPanel && (
<div className="detail-panel session-detail-panel">
@@ -8339,6 +8697,79 @@ function ChatPage(props: ChatPageProps) {
document.body
)}
{groupSummaryLogRecord && createPortal(
<div className="message-info-overlay" onClick={() => setGroupSummaryLogRecord(null)}>
<div className="message-info-modal group-summary-log-modal" onClick={(e) => e.stopPropagation()}>
<div className="detail-header">
<h4>群聊总结日志</h4>
<button className="close-btn" onClick={() => setGroupSummaryLogRecord(null)}>
<X size={16} />
</button>
</div>
<div className="detail-content">
<div className="detail-section">
<div className="detail-item">
<span className="label">群聊</span>
<span className="value">{groupSummaryLogRecord.displayName}</span>
</div>
<div className="detail-item">
<span className="label">时段</span>
<span className="value">{formatSummaryPeriod(groupSummaryLogRecord.periodStart, groupSummaryLogRecord.periodEnd)}</span>
</div>
<div className="detail-item">
<span className="label">触发</span>
<span className="value">{groupSummaryLogRecord.triggerType === 'manual' ? '手动' : '自动'}</span>
</div>
<div className="detail-item">
<span className="label">模型</span>
<span className="value">{groupSummaryLogRecord.log.model}</span>
</div>
<div className="detail-item">
<span className="label">消息数</span>
<span className="value">{groupSummaryLogRecord.log.readableMessageCount} / {groupSummaryLogRecord.log.messageCount}</span>
</div>
<div className="detail-item">
<span className="label">JSON Mode</span>
<span className="value">
{groupSummaryLogRecord.log.responseFormatJson ? '启用' : '未启用'}
{groupSummaryLogRecord.log.responseFormatFallback ? `,已降级:${groupSummaryLogRecord.log.responseFormatFallbackReason || '未知原因'}` : ''}
</span>
</div>
</div>
<div className="detail-section">
<div className="section-title">
<Code2 size={14} />
<span>系统提示词</span>
</div>
<pre className="group-summary-log-pre">{groupSummaryLogRecord.log.systemPrompt}</pre>
</div>
<div className="detail-section">
<div className="section-title">
<Code2 size={14} />
<span>用户提示词与完整记录</span>
</div>
<pre className="group-summary-log-pre">{groupSummaryLogRecord.log.userPrompt}</pre>
</div>
<div className="detail-section">
<div className="section-title">
<Code2 size={14} />
<span>模型输出原文</span>
</div>
<pre className="group-summary-log-pre">{groupSummaryLogRecord.log.rawOutput}</pre>
</div>
<div className="detail-section">
<div className="section-title">
<Newspaper size={14} />
<span>最终总结</span>
</div>
<pre className="group-summary-log-pre">{groupSummaryLogRecord.log.finalSummary}</pre>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 修改消息弹窗 */}
{editingMessage && createPortal(
<div className="modal-overlay">
+90 -1
View File
@@ -1940,10 +1940,11 @@
--contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width));
--contacts-left-sticky-width: calc(var(--contacts-select-col-width) + var(--contacts-main-col-width) + var(--contacts-column-gap));
--contacts-message-col-width: 94px;
--contacts-latest-time-col-width: 128px;
--contacts-media-col-width: 58px;
--contacts-action-col-width: 126px;
--contacts-actions-sticky-width: 160px;
--contacts-table-min-width: 1120px;
--contacts-table-min-width: 1248px;
overflow: hidden;
border: none;
border-radius: 8px;
@@ -2192,6 +2193,58 @@
box-sizing: border-box;
}
.contacts-list-header-latest-time {
width: var(--contacts-latest-time-col-width);
min-width: var(--contacts-latest-time-col-width);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
text-align: center;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-sizing: border-box;
}
.contacts-list-header-sortable {
background: transparent;
border: none;
padding: 4px 6px;
margin: 0;
color: inherit;
font: inherit;
cursor: pointer;
border-radius: 6px;
gap: 4px;
transition: background-color 0.12s ease, color 0.12s ease;
&:hover {
background: color-mix(in srgb, var(--primary) 10%, transparent);
color: var(--primary);
}
&:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
&.is-active {
color: var(--primary);
}
}
.contacts-list-header-sort-icon {
color: inherit;
flex-shrink: 0;
&.muted {
color: var(--text-tertiary);
opacity: 0.6;
}
}
.contacts-list-header-media {
width: var(--contacts-media-col-width);
min-width: var(--contacts-media-col-width);
@@ -2509,6 +2562,37 @@
box-sizing: border-box;
}
.row-latest-time {
width: var(--contacts-latest-time-col-width);
min-width: var(--contacts-latest-time-col-width);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
text-align: center;
box-sizing: border-box;
}
.row-latest-time-value {
margin: 0;
font-size: 12px;
line-height: 1.2;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
&.muted {
color: var(--text-tertiary);
}
}
.row-media-metric {
width: var(--contacts-media-col-width);
min-width: var(--contacts-media-col-width);
@@ -5035,6 +5119,7 @@
--contacts-name-text-width: 10em;
--contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width));
--contacts-message-col-width: 94px;
--contacts-latest-time-col-width: 120px;
--contacts-media-col-width: 56px;
--contacts-action-col-width: 126px;
}
@@ -5062,6 +5147,10 @@
min-width: var(--contacts-message-col-width);
}
.table-wrap .row-latest-time {
min-width: var(--contacts-latest-time-col-width);
}
.table-wrap .row-media-metric {
min-width: var(--contacts-media-col-width);
}
+145 -22
View File
@@ -4,6 +4,9 @@ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { createPortal } from 'react-dom'
import {
Aperture,
ArrowDown,
ArrowUp,
ArrowUpDown,
Calendar,
Check,
CheckSquare,
@@ -656,6 +659,41 @@ const formatYmdHmDateTime = (timestamp?: number): string => {
return `${y}-${m}-${day} ${h}:${min}`
}
const formatLatestMessageTimeFromSeconds = (
timestamp?: number,
now: number = Date.now()
): { text: string; title: string } => {
if (!timestamp || !Number.isFinite(timestamp) || timestamp <= 0) {
return { text: '--', title: '' }
}
const ms = timestamp * 1000
const absolute = formatYmdHmDateTime(ms)
const diff = Math.max(0, now - ms)
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < minute) {
return { text: '刚刚', title: absolute }
}
if (diff < hour) {
const minutes = Math.max(1, Math.floor(diff / minute))
return { text: `${minutes} 分钟前`, title: absolute }
}
if (diff < day) {
const hours = Math.max(1, Math.floor(diff / hour))
return { text: `${hours} 小时前`, title: absolute }
}
return { text: absolute, title: absolute }
}
type ContactsSortKey = 'messageCount' | 'latestMessageTime'
type ContactsSortOrder = 'desc' | 'asc'
interface ContactsSortConfig {
key: ContactsSortKey | null
order: ContactsSortOrder | null
}
const DEFAULT_CONTACTS_SORT_CONFIG: ContactsSortConfig = { key: null, order: null }
const isSingleContactSession = (sessionId: string): boolean => {
const normalized = String(sessionId || '').trim()
if (!normalized) return false
@@ -2269,6 +2307,18 @@ function ExportPage() {
const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState<SessionSnsTimelineTarget | null>(null)
const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('')
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTaskRecord[]>([])
const [contactsSortConfig, setContactsSortConfig] = useState<ContactsSortConfig>(DEFAULT_CONTACTS_SORT_CONFIG)
const toggleContactsSort = useCallback((key: ContactsSortKey) => {
setContactsSortConfig(prev => {
if (prev.key !== key) {
return { key, order: 'desc' }
}
if (prev.order === 'desc') return { key, order: 'asc' }
if (prev.order === 'asc') return DEFAULT_CONTACTS_SORT_CONFIG
return { key, order: 'desc' }
})
}, [])
const [exportFolder, setExportFolder] = useState('')
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('B')
@@ -6661,34 +6711,47 @@ function ExportPage() {
)
})
const indexedContacts = contacts.map((contact, index) => ({
contact,
index,
count: (() => {
const counted = normalizeMessageCount(sessionMessageCounts[contact.username])
if (typeof counted === 'number') return counted
const hinted = normalizeMessageCount(sessionRowByUsername.get(contact.username)?.messageCountHint)
return hinted
})()
}))
const indexedContacts = contacts.map((contact, index) => {
const sessionRow = sessionRowByUsername.get(contact.username)
const counted = normalizeMessageCount(sessionMessageCounts[contact.username])
const hinted = normalizeMessageCount(sessionRow?.messageCountHint)
const count = typeof counted === 'number' ? counted : hinted
const rowTs = sessionRow?.lastTimestamp || sessionRow?.sortTimestamp
const latestTime = typeof rowTs === 'number' && rowTs > 0 ? rowTs : undefined
return { contact, index, count, latestTime }
})
const compareNullable = (a: number | undefined, b: number | undefined, order: ContactsSortOrder): number => {
const aHas = typeof a === 'number' && Number.isFinite(a)
const bHas = typeof b === 'number' && Number.isFinite(b)
if (aHas && bHas) {
const diff = (a as number) - (b as number)
return order === 'desc' ? -diff : diff
}
if (aHas) return -1
if (bHas) return 1
return 0
}
const sortKey = contactsSortConfig.key
const sortOrder = contactsSortConfig.order ?? 'desc'
indexedContacts.sort((a, b) => {
const aHasCount = typeof a.count === 'number'
const bHasCount = typeof b.count === 'number'
if (aHasCount && bHasCount) {
const diff = (b.count as number) - (a.count as number)
if (sortKey === 'latestMessageTime') {
const diff = compareNullable(a.latestTime, b.latestTime, sortOrder)
if (diff !== 0) return diff
} else if (sortKey === 'messageCount') {
const diff = compareNullable(a.count, b.count, sortOrder)
if (diff !== 0) return diff
} else {
const diff = compareNullable(a.count, b.count, 'desc')
if (diff !== 0) return diff
} else if (aHasCount) {
return -1
} else if (bHasCount) {
return 1
}
// 无统计值或同分时保持原顺序,避免列表频繁跳动。
return a.index - b.index
})
return indexedContacts.map(item => item.contact)
}, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername])
}, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername, contactsSortConfig])
const keywordMatchedContactUsernameSet = useMemo(() => {
const keyword = searchKeyword.trim().toLowerCase()
@@ -6897,7 +6960,7 @@ function ExportPage() {
useEffect(() => {
contactsVirtuosoRef.current?.scrollToIndex({ index: 0, align: 'start' })
setIsContactsListAtTop(true)
}, [activeTab, searchKeyword])
}, [activeTab, searchKeyword, contactsSortConfig])
const collectVisibleSessionMetricTargets = useCallback((sourceContacts: ContactInfo[]): string[] => {
if (sourceContacts.length === 0) return []
@@ -8408,6 +8471,15 @@ function ExportPage() {
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
const displayedMessageCount = countedMessages ?? hintedMessages
const mediaMetric = sessionContentMetrics[contact.username]
const rowLatestTs = matchedSession?.lastTimestamp || matchedSession?.sortTimestamp
const resolvedLatestTs = typeof rowLatestTs === 'number' && rowLatestTs > 0 ? rowLatestTs : undefined
const latestTimeInfo = formatLatestMessageTimeFromSeconds(resolvedLatestTs, nowTick)
const latestTimeState: { state: 'value'; text: string; title: string } | { state: 'loading' } | { state: 'na'; text: '--' } =
!canExport
? (isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' })
: (typeof resolvedLatestTs === 'number' && resolvedLatestTs > 0
? { state: 'value', text: latestTimeInfo.text, title: latestTimeInfo.title }
: { state: 'na', text: '--' })
const messageCountState: { state: 'value'; text: string } | { state: 'loading' } | { state: 'na'; text: '--' } =
!canExport
? (isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' })
@@ -8523,6 +8595,18 @@ function ExportPage() {
</button>
)}
</div>
<div className="row-latest-time">
{latestTimeState.state === 'loading'
? <Loader2 size={12} className="spin row-media-metric-icon" aria-label="最新消息时间加载中" />
: (
<span
className={`row-latest-time-value ${latestTimeState.state === 'value' ? '' : 'muted'}`}
title={latestTimeState.state === 'value' ? latestTimeState.title : undefined}
>
{latestTimeState.text}
</span>
)}
</div>
<div className="row-media-metric">
<strong className="row-media-metric-value">
{emojiMetric.state === 'loading'
@@ -9471,7 +9555,46 @@ function ExportPage() {
<span className="contacts-list-header-main-label">{contactsHeaderMainLabel}</span>
</span>
</span>
<span className="contacts-list-header-count">总消息数</span>
<button
type="button"
className={`contacts-list-header-count contacts-list-header-sortable ${contactsSortConfig.key === 'messageCount' ? 'is-active' : ''}`}
onClick={() => toggleContactsSort('messageCount')}
title={
contactsSortConfig.key !== 'messageCount'
? '按总消息数降序排列'
: contactsSortConfig.order === 'desc'
? '切换为按总消息数升序'
: '取消排序(恢复默认)'
}
>
<span>总消息数</span>
{contactsSortConfig.key === 'messageCount'
? (contactsSortConfig.order === 'asc'
? <ArrowUp size={12} className="contacts-list-header-sort-icon" />
: <ArrowDown size={12} className="contacts-list-header-sort-icon" />)
: <ArrowUpDown size={12} className="contacts-list-header-sort-icon muted" />
}
</button>
<button
type="button"
className={`contacts-list-header-latest-time contacts-list-header-sortable ${contactsSortConfig.key === 'latestMessageTime' ? 'is-active' : ''}`}
onClick={() => toggleContactsSort('latestMessageTime')}
title={
contactsSortConfig.key !== 'latestMessageTime'
? '按最新消息时间降序排列'
: contactsSortConfig.order === 'desc'
? '切换为按最新消息时间升序'
: '取消排序(恢复默认)'
}
>
<span>最新消息时间</span>
{contactsSortConfig.key === 'latestMessageTime'
? (contactsSortConfig.order === 'asc'
? <ArrowUp size={12} className="contacts-list-header-sort-icon" />
: <ArrowDown size={12} className="contacts-list-header-sort-icon" />)
: <ArrowUpDown size={12} className="contacts-list-header-sort-icon muted" />
}
</button>
<span className="contacts-list-header-media">表情包</span>
<span className="contacts-list-header-media">语音</span>
<span className="contacts-list-header-media">图片</span>
+139 -13
View File
@@ -3390,14 +3390,17 @@
}
&.insight-social-tab {
--insight-moments-column-width: 76px;
--insight-social-column-width: minmax(220px, 300px);
--insight-status-column-width: 82px;
--insight-social-list-grid: minmax(0, 1fr) var(--insight-moments-column-width) var(--insight-social-column-width) var(--insight-status-column-width);
--insight-moments-column-width: 44px;
--insight-profile-column-width: 78px;
--insight-social-column-width: minmax(184px, 230px);
--insight-status-column-width: 70px;
--insight-social-list-grid: minmax(0, 1fr) var(--insight-moments-column-width) var(--insight-profile-column-width) var(--insight-social-column-width) var(--insight-status-column-width);
.anti-revoke-list-header {
grid-template-columns: var(--insight-social-list-grid);
gap: 14px;
gap: 8px;
padding-left: 14px;
padding-right: 10px;
.insight-moments-column-title {
display: flex;
@@ -3405,6 +3408,12 @@
color: var(--text-tertiary);
}
.insight-profile-column-title {
display: flex;
justify-content: center;
color: var(--text-tertiary);
}
.insight-social-column-title {
min-width: 0;
color: var(--text-tertiary);
@@ -3420,11 +3429,19 @@
display: grid;
grid-template-columns: var(--insight-social-list-grid);
align-items: center;
gap: 14px;
gap: 8px;
padding-left: 14px;
padding-right: 10px;
}
.anti-revoke-row-main {
min-width: 0;
gap: 8px;
.anti-revoke-check {
width: 16px;
height: 16px;
}
}
.insight-moments-cell {
@@ -3435,6 +3452,92 @@
min-height: 30px;
}
.insight-profile-cell {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 30px;
}
.insight-profile-status-btn {
position: relative;
width: 74px;
height: 28px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary) 10%);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
font-size: 12px;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, opacity 0.16s ease;
&:disabled {
cursor: not-allowed;
opacity: 0.48;
}
&:not(:disabled)::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
bottom: calc(100% + 8px);
transform: translateX(-50%) translateY(2px);
z-index: 8;
max-width: 180px;
width: max-content;
padding: 6px 8px;
border-radius: 6px;
background: color-mix(in srgb, var(--text-primary) 92%, #000 8%);
color: var(--bg-primary);
font-size: 12px;
line-height: 1.2;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s ease;
}
&:not(:disabled):hover::after,
&:not(:disabled):focus-visible::after {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
.profile-status-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--text-tertiary) 86%, transparent);
flex-shrink: 0;
}
&.ready {
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
background: color-mix(in srgb, var(--primary) 10%, var(--bg-secondary));
}
&.running {
color: color-mix(in srgb, var(--primary) 70%, var(--text-primary) 30%);
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
background: color-mix(in srgb, var(--primary) 9%, var(--bg-secondary));
}
&.failed {
color: color-mix(in srgb, var(--danger) 72%, var(--text-primary) 28%);
border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color));
background: color-mix(in srgb, var(--danger) 8%, var(--bg-secondary));
}
}
.insight-moments-toggle {
position: relative;
width: 18px;
@@ -3489,24 +3592,33 @@
}
.insight-social-binding-cell {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 6px;
flex-wrap: wrap;
}
.insight-social-binding-controls {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 10px;
grid-template-columns: minmax(92px, 1fr) auto;
gap: 6px;
align-items: center;
flex: 1 1 100%;
}
.insight-social-binding-input-wrap {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
gap: 5px;
}
.binding-platform-chip {
flex-shrink: 0;
border-radius: 999px;
padding: 2px 7px;
padding: 2px 6px;
font-size: 11px;
color: var(--text-secondary);
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
@@ -3536,12 +3648,18 @@
display: inline-flex;
align-items: center;
justify-self: flex-end;
gap: 8px;
gap: 4px;
.btn {
min-width: 0;
padding-left: 8px;
padding-right: 8px;
}
}
.insight-social-binding-feedback {
grid-column: 1 / span 2;
min-height: 18px;
flex: 1 1 100%;
}
.binding-feedback {
@@ -3563,6 +3681,11 @@
align-items: flex-end;
max-width: none;
min-width: 0;
.status-badge {
padding-left: 8px;
padding-right: 8px;
}
}
}
@@ -3605,6 +3728,7 @@
grid-template-columns: minmax(0, 1fr) auto;
.insight-moments-column-title,
.insight-profile-column-title,
.insight-social-column-title {
display: none;
}
@@ -3617,12 +3741,14 @@
}
.insight-moments-cell,
.insight-profile-cell,
.insight-social-binding-cell,
.anti-revoke-row-status {
width: 100%;
}
.insight-moments-cell {
.insight-moments-cell,
.insight-profile-cell {
justify-content: flex-start;
}
+437 -54
View File
@@ -1,12 +1,15 @@
import { useState, useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { useMemo } from 'react'
import { useAppStore } from '../stores/appStore'
import { useChatStore } from '../stores/chatStore'
import { useThemeStore, themes } from '../stores/themeStore'
import { useAnalyticsStore } from '../stores/analyticsStore'
import { dialog } from '../services/ipc'
import * as configService from '../services/config'
import groupSummaryPrompt from '../../shared/groupSummaryPrompt.json'
import type { ChatSession, ContactInfo } from '../types/models'
import type { InsightProfileStatus } from '../types/electron'
import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
@@ -32,6 +35,7 @@ type SettingsTab =
| 'aiCommon'
| 'insight'
| 'aiFootprint'
| 'aiGroupSummary'
| 'aiMessageInsight'
| 'autoDownload'
@@ -57,10 +61,11 @@ const filteredTabs = tabs.filter(tab => {
return true
})
const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootprint' | 'aiMessageInsight'>; label: string }> = [
const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootprint' | 'aiGroupSummary' | 'aiMessageInsight'>; label: string }> = [
{ id: 'aiCommon', label: '基础配置' },
{ id: 'insight', label: 'AI 见解' },
{ id: 'aiFootprint', label: 'AI 足迹' },
{ id: 'aiGroupSummary', label: '群聊总结' },
{ id: 'aiMessageInsight', label: '消息解析' }
]
@@ -68,6 +73,7 @@ const isMac = navigator.userAgent.toLowerCase().includes('mac')
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
const isWindows = !isMac && !isLinux
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
const DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT = String(groupSummaryPrompt.defaultSystemPrompt || '').trim()
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
const dbPathPlaceholder = isMac
@@ -327,8 +333,15 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [weiboBindingDrafts, setWeiboBindingDrafts] = useState<Record<string, string>>({})
const [weiboBindingErrors, setWeiboBindingErrors] = useState<Record<string, string>>({})
const [weiboBindingLoadingSessionId, setWeiboBindingLoadingSessionId] = useState<string | null>(null)
const [aiInsightProfileStatuses, setAiInsightProfileStatuses] = useState<Record<string, InsightProfileStatus>>({})
const [aiInsightProfileActiveSessionId, setAiInsightProfileActiveSessionId] = useState<string | null>(null)
const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false)
const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('')
const [aiGroupSummaryEnabled, setAiGroupSummaryEnabled] = useState(false)
const [aiGroupSummaryIntervalHours, setAiGroupSummaryIntervalHours] = useState(4)
const [aiGroupSummarySystemPrompt, setAiGroupSummarySystemPrompt] = useState('')
const [aiGroupSummaryFilterList, setAiGroupSummaryFilterList] = useState<string[]>([])
const [aiGroupSummaryFilterSearchKeyword, setAiGroupSummaryFilterSearchKeyword] = useState('')
const [aiMessageInsightEnabled, setAiMessageInsightEnabled] = useState(false)
const [aiMessageInsightContextCount, setAiMessageInsightContextCount] = useState(50)
const [aiMessageInsightSystemPrompt, setAiMessageInsightSystemPrompt] = useState('')
@@ -377,7 +390,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
}, [location.state])
useEffect(() => {
if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint' || activeTab === 'aiMessageInsight') {
if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint' || activeTab === 'aiGroupSummary' || activeTab === 'aiMessageInsight') {
setAiGroupExpanded(true)
}
}, [activeTab])
@@ -595,6 +608,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedAiInsightWeiboBindings = await configService.getAiInsightWeiboBindings()
const savedAiFootprintEnabled = await configService.getAiFootprintEnabled()
const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt()
const savedAiGroupSummaryEnabled = await configService.getAiGroupSummaryEnabled()
const savedAiGroupSummaryIntervalHours = await configService.getAiGroupSummaryIntervalHours()
const savedAiGroupSummarySystemPrompt = await configService.getAiGroupSummarySystemPrompt()
const savedAiGroupSummaryFilterList = await configService.getAiGroupSummaryFilterList()
const savedAiMessageInsightEnabled = await configService.getAiMessageInsightEnabled()
const savedAiMessageInsightContextCount = await configService.getAiMessageInsightContextCount()
const savedAiMessageInsightSystemPrompt = await configService.getAiMessageInsightSystemPrompt()
@@ -624,6 +641,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setAiInsightWeiboBindings(savedAiInsightWeiboBindings)
setAiFootprintEnabled(savedAiFootprintEnabled)
setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt)
setAiGroupSummaryEnabled(savedAiGroupSummaryEnabled)
setAiGroupSummaryIntervalHours(savedAiGroupSummaryIntervalHours)
setAiGroupSummarySystemPrompt(savedAiGroupSummarySystemPrompt)
setAiGroupSummaryFilterList(savedAiGroupSummaryFilterList)
setAiMessageInsightEnabled(savedAiMessageInsightEnabled)
setAiMessageInsightContextCount(savedAiMessageInsightContextCount)
setAiMessageInsightSystemPrompt(savedAiMessageInsightSystemPrompt)
@@ -2844,37 +2865,41 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
showMessage('已清空主动推送过滤列表', true)
}
const sessionFilterOptionMap = new Map<string, SessionFilterOption>()
const { sessionFilterOptionMap, sessionFilterOptions } = useMemo(() => {
const optionMap = new Map<string, SessionFilterOption>()
for (const session of chatSessions) {
if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue
sessionFilterOptionMap.set(session.username, {
username: session.username,
displayName: session.displayName || session.username,
avatarUrl: session.avatarUrl,
type: getSessionFilterType(session)
})
}
for (const session of chatSessions) {
if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue
optionMap.set(session.username, {
username: session.username,
displayName: session.displayName || session.username,
avatarUrl: session.avatarUrl,
type: getSessionFilterType(session)
})
}
for (const contact of messagePushContactOptions) {
if (!contact.username) continue
if (contact.type !== 'friend' && contact.type !== 'group' && contact.type !== 'official' && contact.type !== 'former_friend') continue
const existing = sessionFilterOptionMap.get(contact.username)
sessionFilterOptionMap.set(contact.username, {
username: contact.username,
displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username,
avatarUrl: existing?.avatarUrl || contact.avatarUrl,
type: getSessionFilterType(contact)
})
}
for (const contact of messagePushContactOptions) {
if (!contact.username) continue
if (contact.type !== 'friend' && contact.type !== 'group' && contact.type !== 'official' && contact.type !== 'former_friend') continue
const existing = optionMap.get(contact.username)
optionMap.set(contact.username, {
username: contact.username,
displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username,
avatarUrl: existing?.avatarUrl || contact.avatarUrl,
type: getSessionFilterType(contact)
})
}
const sessionFilterOptions = Array.from(sessionFilterOptionMap.values())
.sort((a, b) => {
const aSession = chatSessions.find(session => session.username === a.username)
const bSession = chatSessions.find(session => session.username === b.username)
return Number(bSession?.sortTimestamp || bSession?.lastTimestamp || 0) -
Number(aSession?.sortTimestamp || aSession?.lastTimestamp || 0)
})
const options = Array.from(optionMap.values())
.sort((a, b) => {
const aSession = chatSessions.find(session => session.username === a.username)
const bSession = chatSessions.find(session => session.username === b.username)
return Number(bSession?.sortTimestamp || bSession?.lastTimestamp || 0) -
Number(aSession?.sortTimestamp || aSession?.lastTimestamp || 0)
})
return { sessionFilterOptionMap: optionMap, sessionFilterOptions: options }
}, [chatSessions, messagePushContactOptions])
const getSessionFilterOptionInfo = (username: string) => {
return sessionFilterOptionMap.get(username) || {
@@ -2914,6 +2939,15 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
messagePushFilterSearchKeyword
)
const groupSummaryFilterOptions = sessionFilterOptions.filter((session) => session.type === 'group')
const groupSummaryAvailableSessions = groupSummaryFilterOptions.filter((session) => {
const keyword = aiGroupSummaryFilterSearchKeyword.trim().toLowerCase()
if (aiGroupSummaryFilterList.includes(session.username)) return false
if (!keyword) return true
return String(session.displayName || '').toLowerCase().includes(keyword) ||
session.username.toLowerCase().includes(keyword)
})
const handleAddAllNotificationFilterSessions = async () => {
const usernames = notificationAvailableSessions.map(session => session.username)
if (usernames.length === 0) return
@@ -3242,6 +3276,120 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
await configService.setAiInsightWeiboBindings(nextBindings)
if (!silent) showMessage('已清除微博绑定', true)
}
const refreshAiInsightProfileStatuses = async (sessionIds?: string[]) => {
const ids = normalizeSessionIds(
sessionIds || sessionFilterOptions
.filter((session) => session.type === 'private')
.map((session) => session.username)
)
if (ids.length === 0) {
setAiInsightProfileStatuses({})
setAiInsightProfileActiveSessionId(null)
return
}
try {
const result = await window.electronAPI.insight.listProfileStatuses(ids)
if (!result.success) return
setAiInsightProfileStatuses(result.statuses || {})
setAiInsightProfileActiveSessionId(result.activeTask?.sessionId || null)
} catch (error) {
console.warn('刷新 AI 画像状态失败:', error)
}
}
const handleGenerateInsightProfile = async (session: SessionFilterOption) => {
const sessionId = session.username
const currentStatus = aiInsightProfileStatuses[sessionId]
if (currentStatus?.status === 'running') {
try {
const result = await window.electronAPI.insight.cancelProfile(sessionId)
showMessage(result.message || '已请求取消画像任务', result.success)
} catch (e: any) {
showMessage(`取消画像失败:${e?.message || String(e)}`, false)
} finally {
setTimeout(() => { void refreshAiInsightProfileStatuses() }, 500)
}
return
}
if (aiInsightProfileActiveSessionId && aiInsightProfileActiveSessionId !== sessionId) return
setAiInsightProfileStatuses((prev) => ({
...prev,
[sessionId]: {
sessionId,
status: 'running',
phase: '正在初始化画像...',
updatedAt: Date.now()
}
}))
setAiInsightProfileActiveSessionId(sessionId)
try {
const result = await window.electronAPI.insight.generateProfile({
sessionId,
displayName: session.displayName || session.username,
avatarUrl: session.avatarUrl
})
showMessage(result.message || (result.success ? '画像完成' : '画像失败'), result.success)
} catch (e: any) {
showMessage(`画像失败:${e?.message || String(e)}`, false)
} finally {
await refreshAiInsightProfileStatuses()
}
}
useEffect(() => {
if (activeTab !== 'insight') return
const ids = sessionFilterOptions
.filter((session) => session.type === 'private')
.map((session) => session.username)
if (ids.length === 0) return
void refreshAiInsightProfileStatuses(ids)
const timer = window.setInterval(() => {
void refreshAiInsightProfileStatuses(ids)
}, 2500)
return () => window.clearInterval(timer)
}, [activeTab, sessionFilterOptions])
const getInsightProfileButtonMeta = (sessionId: string) => {
const status = aiInsightProfileStatuses[sessionId]
const activeOther = Boolean(aiInsightProfileActiveSessionId && aiInsightProfileActiveSessionId !== sessionId)
if (status?.status === 'running') {
return {
className: 'running',
label: '取消',
title: status.phase || '画像生成中,点击取消',
disabled: false,
icon: <Loader2 size={13} style={{ animation: 'spin 1s linear infinite' }} />
}
}
if (status?.status === 'ready') {
return {
className: 'ready',
label: '已画像',
title: activeOther ? '其他联系人正在画像中' : '点击以重新画像',
disabled: activeOther,
icon: <Check size={13} />
}
}
if (status?.status === 'failed') {
return {
className: 'failed',
label: '失败',
title: activeOther ? '其他联系人正在画像中' : (status.error || '画像失败,点击重试'),
disabled: activeOther,
icon: <XCircle size={13} />
}
}
return {
className: 'none',
label: '未画像',
title: activeOther ? '其他联系人正在画像中' : '点击进行画像',
disabled: activeOther,
icon: <i className="profile-status-dot" aria-hidden="true" />
}
}
const renderInsightTab = () => (
<div className="tab-content">
{/* 总开关 */}
@@ -3816,6 +3964,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="anti-revoke-list-header">
<span>对话({filteredSessions.length})</span>
<span className="insight-moments-column-title">朋友圈</span>
<span className="insight-profile-column-title">画像</span>
<span className="insight-social-column-title">社交平台(微博)</span>
<span className="anti-revoke-status-column-title">状态</span>
</div>
@@ -3827,6 +3976,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const weiboDraftValue = getWeiboBindingDraftValue(session.username)
const isBindingLoading = weiboBindingLoadingSessionId === session.username
const weiboBindingError = weiboBindingErrors[session.username]
const profileButtonMeta = getInsightProfileButtonMeta(session.username)
return (
<div
key={session.username}
@@ -3877,37 +4027,56 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span className="binding-feedback muted">-</span>
)}
</div>
<div className="insight-profile-cell">
{isPrivateSession ? (
<button
type="button"
className={`insight-profile-status-btn ${profileButtonMeta.className}`}
title={profileButtonMeta.title}
data-tooltip={profileButtonMeta.title}
disabled={profileButtonMeta.disabled}
onClick={() => void handleGenerateInsightProfile(session)}
>
{profileButtonMeta.icon}
<span>{profileButtonMeta.label}</span>
</button>
) : (
<span className="binding-feedback muted">-</span>
)}
</div>
<div className="insight-social-binding-cell">
{isPrivateSession ? (
<>
<div className="insight-social-binding-input-wrap">
<span className="binding-platform-chip">微博</span>
<input
type="text"
className="insight-social-binding-input"
value={weiboDraftValue}
placeholder="填写数字 UID"
onChange={(e) => updateWeiboBindingDraft(session.username, e.target.value)}
/>
</div>
<div className="insight-social-binding-actions">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => void handleSaveWeiboBinding(session.username, session.displayName || session.username)}
disabled={isBindingLoading || !weiboDraftValue.trim()}
>
{isBindingLoading ? '绑定中...' : (weiboBinding ? '更新' : '绑定')}
</button>
{weiboBinding && (
<div className="insight-social-binding-controls">
<div className="insight-social-binding-input-wrap">
<span className="binding-platform-chip">微博</span>
<input
type="text"
className="insight-social-binding-input"
value={weiboDraftValue}
placeholder="填写数字 UID"
onChange={(e) => updateWeiboBindingDraft(session.username, e.target.value)}
/>
</div>
<div className="insight-social-binding-actions">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => void handleClearWeiboBinding(session.username)}
onClick={() => void handleSaveWeiboBinding(session.username, session.displayName || session.username)}
disabled={isBindingLoading || !weiboDraftValue.trim()}
>
清除
{isBindingLoading ? '绑定中...' : (weiboBinding ? '更新' : '绑定')}
</button>
)}
{weiboBinding && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => void handleClearWeiboBinding(session.username)}
>
清除
</button>
)}
</div>
</div>
<div className="insight-social-binding-feedback">
{weiboBindingError ? (
@@ -4032,6 +4201,219 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div>
)
const renderAiGroupSummaryTab = () => {
const groupSummaryPromptDisplayValue = aiGroupSummarySystemPrompt || DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT
const addToFilterList = async (username: string) => {
if (!username.endsWith('@chatroom') || aiGroupSummaryFilterList.includes(username)) return
const next = [...aiGroupSummaryFilterList, username]
setAiGroupSummaryFilterList(next)
await configService.setAiGroupSummaryFilterList(next)
showMessage('已添加到群聊总结作用域', true)
}
const removeFromFilterList = async (username: string) => {
const next = aiGroupSummaryFilterList.filter((item) => item !== username)
setAiGroupSummaryFilterList(next)
await configService.setAiGroupSummaryFilterList(next)
showMessage('已从群聊总结作用域移除', true)
}
const addAllFiltered = async () => {
const usernames = groupSummaryAvailableSessions.map((session) => session.username)
if (usernames.length === 0) return
const next = Array.from(new Set([...aiGroupSummaryFilterList, ...usernames]))
setAiGroupSummaryFilterList(next)
await configService.setAiGroupSummaryFilterList(next)
showMessage(`已添加 ${usernames.length} 个群聊`, true)
}
const clearFilterList = async () => {
if (aiGroupSummaryFilterList.length === 0) return
setAiGroupSummaryFilterList([])
await configService.setAiGroupSummaryFilterList([])
showMessage('已清空群聊总结作用域', true)
}
return (
<div className="tab-content">
<div className="form-group">
<label>AI 群聊总结</label>
<span className="form-hint">
开启后,群聊页顶部会显示 AI 总结按钮;自动总结只对下面作用域内的群聊生效。未选择任何群聊时不会自动消耗 token。
</span>
<div className="log-toggle-line">
<span className="log-status">{aiGroupSummaryEnabled ? '已开启' : '已关闭'}</span>
<label className="switch">
<input
type="checkbox"
checked={aiGroupSummaryEnabled}
onChange={async (e) => {
const val = e.target.checked
setAiGroupSummaryEnabled(val)
await configService.setAiGroupSummaryEnabled(val)
showMessage(val ? 'AI 群聊总结已开启' : 'AI 群聊总结已关闭', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="divider" />
<div className="form-group">
<label>自动总结间隔</label>
<span className="form-hint">
按本地系统时间从当天 00:00 开始切分完整时间段,到点总结上一段。时段内可总结消息少于 5 条时会跳过。
</span>
<div className="push-filter-type-tabs" style={{ marginTop: 10 }}>
{[1, 2, 4, 8, 12, 24].map((hours) => (
<button
key={hours}
type="button"
className={`push-filter-type-tab ${aiGroupSummaryIntervalHours === hours ? 'active' : ''}`}
onClick={() => {
setAiGroupSummaryIntervalHours(hours)
scheduleConfigSave('aiGroupSummaryIntervalHours', () => configService.setAiGroupSummaryIntervalHours(hours))
}}
>
{hours} 小时
</button>
))}
</div>
</div>
<div className="form-group">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<label style={{ marginBottom: 0 }}>群聊总结提示词</label>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={async () => {
setAiGroupSummarySystemPrompt('')
await configService.setAiGroupSummarySystemPrompt('')
}}
>
恢复默认
</button>
</div>
<span className="form-hint">
群聊总结专用提示词。留空时使用内置默认提示词。
</span>
<textarea
className="field-input ai-prompt-textarea"
rows={10}
style={{ width: '100%', resize: 'vertical', marginTop: 8 }}
value={groupSummaryPromptDisplayValue}
onChange={(e) => {
const val = e.target.value
setAiGroupSummarySystemPrompt(val)
scheduleConfigSave('aiGroupSummarySystemPrompt', () => configService.setAiGroupSummarySystemPrompt(val))
}}
/>
<span className="form-hint" style={{ color: 'var(--danger, #ef4444)', marginTop: 8, display: 'block' }}>
该提示词控制 JSON 输出结构和总结解析路径,不建议随意修改,否则可能导致总结失败或内容错位。
</span>
</div>
<div className="divider" />
<div className="form-group">
<label>自动总结作用域群聊</label>
<span className="form-hint">
仅控制自动总结范围。手动点击群聊页 AI 总结按钮不受作用域限制;未选择任何群聊时自动总结不会运行。
</span>
{aiGroupSummaryFilterList.length === 0 && (
<div className="api-docs" style={{ marginTop: 12 }}>
<div className="api-item">
<p className="api-desc">当前未选择作用域群聊,自动群聊总结不会触发。</p>
</div>
</div>
)}
<div className="notification-filter-container" style={{ marginTop: 12 }}>
<div className="filter-panel">
<div className="filter-panel-header">
<span>可选群聊</span>
{groupSummaryAvailableSessions.length > 0 && (
<button type="button" className="filter-panel-action" onClick={() => { void addAllFiltered() }}>
全选当前
</button>
)}
<div className="filter-search-box">
<Search size={14} />
<input
type="text"
placeholder="搜索群聊..."
value={aiGroupSummaryFilterSearchKeyword}
onChange={(e) => setAiGroupSummaryFilterSearchKeyword(e.target.value)}
/>
</div>
</div>
<div className="filter-panel-list">
{groupSummaryAvailableSessions.length > 0 ? (
groupSummaryAvailableSessions.map(session => (
<div
key={session.username}
className="filter-panel-item"
onClick={() => { void addToFilterList(session.username) }}
>
<Avatar src={session.avatarUrl} name={session.displayName || session.username} size={28} />
<span className="filter-item-name">{session.displayName || session.username}</span>
<span className="filter-item-type">群聊</span>
<span className="filter-item-action">+</span>
</div>
))
) : (
<div className="filter-panel-empty">
{aiGroupSummaryFilterSearchKeyword ? '没有匹配的群聊' : '暂无可添加的群聊'}
</div>
)}
</div>
</div>
<div className="filter-panel">
<div className="filter-panel-header">
<span>作用域群聊</span>
{aiGroupSummaryFilterList.length > 0 && (
<span className="filter-panel-count">{aiGroupSummaryFilterList.length}</span>
)}
{aiGroupSummaryFilterList.length > 0 && (
<button type="button" className="filter-panel-action" onClick={() => { void clearFilterList() }}>
全不选
</button>
)}
</div>
<div className="filter-panel-list">
{aiGroupSummaryFilterList.length > 0 ? (
aiGroupSummaryFilterList.map(username => {
const info = getSessionFilterOptionInfo(username)
return (
<div
key={username}
className="filter-panel-item selected"
onClick={() => { void removeFromFilterList(username) }}
>
<Avatar src={info.avatarUrl} name={info.displayName} size={28} />
<span className="filter-item-name">{info.displayName}</span>
<span className="filter-item-type">群聊</span>
<span className="filter-item-action">×</span>
</div>
)
})
) : (
<div className="filter-panel-empty">尚未添加任何群聊</div>
)}
</div>
</div>
</div>
</div>
</div>
)
}
const renderAiMessageInsightTab = () => (
<div className="tab-content">
{(() => {
@@ -5161,7 +5543,7 @@ JSON 输出格式:
row.push(
<div key="ai-settings-group" className={`tab-group ${aiGroupExpanded ? 'expanded' : ''}`}>
<button
className={`tab-btn tab-group-trigger ${(activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint' || activeTab === 'aiMessageInsight') ? 'active' : ''}`}
className={`tab-btn tab-group-trigger ${(activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint' || activeTab === 'aiGroupSummary' || activeTab === 'aiMessageInsight') ? 'active' : ''}`}
onClick={() => setAiGroupExpanded((prev) => !prev)}
aria-expanded={aiGroupExpanded}
>
@@ -5203,6 +5585,7 @@ JSON 输出格式:
{activeTab === 'aiCommon' && renderAiCommonTab()}
{activeTab === 'insight' && renderInsightTab()}
{activeTab === 'aiFootprint' && renderAiFootprintTab()}
{activeTab === 'aiGroupSummary' && renderAiGroupSummaryTab()}
{activeTab === 'aiMessageInsight' && renderAiMessageInsightTab()}
{activeTab === 'autoDownload' && renderAutoDownloadTab()}
{activeTab === 'updates' && renderUpdatesTab()}
+6 -1
View File
@@ -201,6 +201,7 @@
padding: 12px 16px;
margin-left: -4px;
margin-right: -4px;
overflow: hidden;
// 遮挡流程线
&::before {
@@ -212,7 +213,7 @@
width: 20px;
background: var(--primary);
border-radius: 12px 0 0 12px;
z-index: -1;
z-index: 0;
}
}
@@ -234,6 +235,8 @@
flex-shrink: 0;
margin-top: 2px;
transition: all 0.3s ease;
position: relative;
z-index: 1;
[data-mode="dark"] .welcome-sidebar & {
border-color: rgba(255, 255, 255, 0.2); // 稍微调亮边框
@@ -273,6 +276,8 @@
display: flex;
flex-direction: column;
transition: transform 0.3s ease;
position: relative;
z-index: 1;
}
.nav-title {
+66
View File
@@ -120,6 +120,11 @@ export const CONFIG_KEYS = {
// AI 足迹
AI_FOOTPRINT_ENABLED: 'aiFootprintEnabled',
AI_FOOTPRINT_SYSTEM_PROMPT: 'aiFootprintSystemPrompt',
AI_GROUP_SUMMARY_ENABLED: 'aiGroupSummaryEnabled',
AI_GROUP_SUMMARY_INTERVAL_HOURS: 'aiGroupSummaryIntervalHours',
AI_GROUP_SUMMARY_SYSTEM_PROMPT: 'aiGroupSummarySystemPrompt',
AI_GROUP_SUMMARY_FILTER_MODE: 'aiGroupSummaryFilterMode',
AI_GROUP_SUMMARY_FILTER_LIST: 'aiGroupSummaryFilterList',
AI_MESSAGE_INSIGHT_ENABLED: 'aiMessageInsightEnabled',
AI_MESSAGE_INSIGHT_CONTEXT_COUNT: 'aiMessageInsightContextCount',
AI_MESSAGE_INSIGHT_SYSTEM_PROMPT: 'aiMessageInsightSystemPrompt',
@@ -2178,6 +2183,67 @@ export async function setAiFootprintSystemPrompt(prompt: string): Promise<void>
await config.set(CONFIG_KEYS.AI_FOOTPRINT_SYSTEM_PROMPT, prompt)
}
// Legacy only: 群聊总结现在只使用 aiGroupSummaryFilterList 作为作用域白名单。
export type AiGroupSummaryFilterMode = 'whitelist' | 'blacklist'
const AI_GROUP_SUMMARY_INTERVALS = new Set([1, 2, 4, 8, 12, 24])
const normalizeAiGroupSummaryFilterList = (value: unknown): string[] => {
if (!Array.isArray(value)) return []
return Array.from(new Set(
value
.map((item) => String(item || '').trim())
.filter((item) => item.endsWith('@chatroom'))
))
}
export async function getAiGroupSummaryEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_ENABLED)
return value === true
}
export async function setAiGroupSummaryEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_ENABLED, enabled)
}
export async function getAiGroupSummaryIntervalHours(): Promise<number> {
const value = Number(await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_INTERVAL_HOURS))
const normalized = Number.isFinite(value) ? Math.floor(value) : 4
return AI_GROUP_SUMMARY_INTERVALS.has(normalized) ? normalized : 4
}
export async function setAiGroupSummaryIntervalHours(hours: number): Promise<void> {
const normalized = Math.floor(Number(hours) || 4)
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_INTERVAL_HOURS, AI_GROUP_SUMMARY_INTERVALS.has(normalized) ? normalized : 4)
}
export async function getAiGroupSummarySystemPrompt(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_SYSTEM_PROMPT)
return typeof value === 'string' ? value : ''
}
export async function setAiGroupSummarySystemPrompt(prompt: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_SYSTEM_PROMPT, prompt)
}
export async function getAiGroupSummaryFilterMode(): Promise<AiGroupSummaryFilterMode> {
const value = await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_FILTER_MODE)
return value === 'blacklist' ? 'blacklist' : 'whitelist'
}
export async function setAiGroupSummaryFilterMode(mode: AiGroupSummaryFilterMode): Promise<void> {
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_FILTER_MODE, mode === 'blacklist' ? 'blacklist' : 'whitelist')
}
export async function getAiGroupSummaryFilterList(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_FILTER_LIST)
return normalizeAiGroupSummaryFilterList(value)
}
export async function setAiGroupSummaryFilterList(list: string[]): Promise<void> {
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_FILTER_LIST, normalizeAiGroupSummaryFilterList(list))
}
export async function getAiMessageInsightEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_MESSAGE_INSIGHT_ENABLED)
return value === true
+132 -1
View File
@@ -124,6 +124,108 @@ export interface InsightRecordResult {
error?: string
}
export type InsightProfileStatusValue = 'none' | 'ready' | 'running' | 'failed'
export interface InsightProfileStatus {
sessionId: string
status: InsightProfileStatusValue
updatedAt?: number
error?: string
phase?: string
busy?: boolean
}
export interface InsightProfileStatusListResult {
success: boolean
statuses: Record<string, InsightProfileStatus>
activeTask?: {
sessionId: string
displayName: string
phase: string
startedAt: number
}
error?: string
}
export interface InsightProfileGenerateResult {
success: boolean
message: string
cancelled?: boolean
error?: string
}
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 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 GroupSummaryRecord extends GroupSummaryRecordSummary {
accountScope: string
rawOutput: string
log: GroupSummaryLog
}
export interface GroupSummaryRecordFilters {
sessionId?: string
startTime?: number
endTime?: number
limit?: number
offset?: number
}
export interface GroupSummaryRecordListResult {
success: boolean
records: GroupSummaryRecordSummary[]
total: number
error?: string
}
export interface GroupSummaryRecordResult {
success: boolean
record?: GroupSummaryRecord
error?: string
}
export interface BackupProgress {
phase: 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed'
message: string
@@ -206,6 +308,11 @@ export interface BackupManifest {
}
}
export type CloseConfirmPayload = {
canMinimizeToTray: boolean
restoreMethod?: 'tray' | 'dock'
}
export interface ElectronAPI {
window: {
minimize: () => void
@@ -213,7 +320,7 @@ export interface ElectronAPI {
isMaximized: () => Promise<boolean>
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => () => void
close: () => void
onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => () => void
onCloseConfirmRequested: (callback: (payload: CloseConfirmPayload) => void) => () => void
respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') => Promise<boolean>
openAgreementWindow: () => Promise<boolean>
completeOnboarding: () => Promise<boolean>
@@ -1349,6 +1456,13 @@ export interface ElectronAPI {
displayName?: string
avatarUrl?: string
}) => Promise<{ success: boolean; message: string; recordId?: string; insight?: string; skipped?: boolean; notificationEnabled?: boolean }>
listProfileStatuses: (sessionIds: string[]) => Promise<InsightProfileStatusListResult>
generateProfile: (payload: {
sessionId: string
displayName?: string
avatarUrl?: string
}) => Promise<InsightProfileGenerateResult>
cancelProfile: (sessionId?: string) => Promise<{ success: boolean; message: string }>
generateFootprintInsight: (payload: {
rangeLabel: string
summary: {
@@ -1375,6 +1489,23 @@ export interface ElectronAPI {
forceRefresh?: boolean
}) => Promise<{ success: boolean; message: string; cached?: boolean; recordId?: string; data?: MessageInsightAnalysis }>
}
groupSummary: {
listRecords: (filters?: GroupSummaryRecordFilters) => Promise<GroupSummaryRecordListResult>
getRecord: (id: string) => Promise<GroupSummaryRecordResult>
triggerManual: (payload: {
sessionId: string
displayName?: string
avatarUrl?: string
startTime: number
endTime: number
}) => Promise<{ success: boolean; message: string; recordId?: string; record?: GroupSummaryRecordSummary; skipped?: boolean; skippedReason?: string }>
triggerDay: (payload: {
sessionId: string
displayName?: string
avatarUrl?: string
date: string
}) => Promise<{ success: boolean; message: string; generated: number; skipped: number; records: GroupSummaryRecordSummary[] }>
}
}
export interface ExportOptions {
+1 -1
View File
@@ -19,6 +19,6 @@
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "shared/**/*.json"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+3 -1
View File
@@ -4,6 +4,7 @@
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"target": "ES2022",
@@ -14,6 +15,7 @@
"include": [
"vite.config.ts",
"electron/**/*.ts",
"electron/**/*.d.ts"
"electron/**/*.d.ts",
"shared/**/*.json"
]
}