Compare commits

..

51 Commits

Author SHA1 Message Date
dependabot[bot]
242a489a32 chore(deps-dev): bump @electron/rebuild from 4.0.3 to 4.0.4
Bumps [@electron/rebuild](https://github.com/electron/rebuild) from 4.0.3 to 4.0.4.
- [Release notes](https://github.com/electron/rebuild/releases)
- [Commits](https://github.com/electron/rebuild/compare/v4.0.3...v4.0.4)

---
updated-dependencies:
- dependency-name: "@electron/rebuild"
  dependency-version: 4.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-05 23:17:26 +00:00
cc
a6d652eec9 同步资源文件 2026-05-06 00:06:50 +08:00
cc
abde85a900 #910 2026-05-06 00:05:11 +08:00
cc
3f908a4dd3 Merge pull request #809 from hicccc77/dependabot/npm_and_yarn/dev/react-virtuoso-4.18.5
chore(deps): bump react-virtuoso from 4.18.4 to 4.18.5
2026-05-05 22:43:32 +08:00
cc
961ae4dea8 Merge pull request #812 from hicccc77/dependabot/npm_and_yarn/dev/lucide-react-1.8.0
chore(deps): bump lucide-react from 1.7.0 to 1.8.0
2026-05-05 22:42:50 +08:00
cc
df0e638301 Merge pull request #810 from hicccc77/dependabot/npm_and_yarn/dev/vite-8.0.9
chore(deps-dev): bump vite from 7.3.2 to 8.0.10
2026-05-05 22:42:25 +08:00
cc
a0eee30f7d Merge pull request #909 from Jasonzhu1207/main
feat: add insight inbox
2026-05-05 14:33:03 +08:00
Jason
416b62fdf1 feat: add insight inbox 2026-05-05 13:54:50 +08:00
Jason
65247a01d3 fix: AI_Insight Icon 2026-05-05 12:35:15 +08:00
Jason
b4758d690b feat: add AI insight notification toggle 2026-05-05 12:08:32 +08:00
cc
98377beebe 同步数据服务 2026-05-05 10:15:02 +08:00
cc
c09128b83e 支持一键已读 2026-05-04 23:34:49 +08:00
cc
fd0db6e306 修复联系人页面导出异常 2026-05-04 18:33:02 +08:00
cc
7233f4249d #894 2026-05-04 09:27:57 +08:00
cc
4271d29f2b Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-05-04 09:25:21 +08:00
cc
86f966d469 #899 2026-05-04 09:25:15 +08:00
cc
99a3ccd228 Merge pull request #896 from CosmicHz/dev
fix: 修复嵌套引用消息显示为[链接]的问题
2026-05-03 10:17:16 +08:00
badboyyyyHmm
a001f3327c fix: 修复嵌套引用消息显示为[链接]的问题(#895) 2026-05-02 23:50:44 +08:00
cc
2d14ba9078 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-05-02 19:08:12 +08:00
cc
1e3a496021 #887 #875 2026-05-02 19:08:07 +08:00
xuncha
4cb799ca7f Merge pull request #889 from xunchahaha:dev
[Bug]: 群聊导出中的邀请记录无法正常查看
2026-05-02 08:45:51 +08:00
xuncha
e61930107a [Bug]: 群聊导出中的邀请记录无法正常查看
Fixes #877
2026-05-02 07:59:54 +08:00
cc
becec65ee3 Merge pull request #885 from hicccc77/dev
Dev
2026-05-01 19:43:31 +08:00
cc
318b553d0e 修复 #884 2026-05-01 19:41:01 +08:00
cc
8946559d94 #883 2026-05-01 16:52:42 +08:00
cc
4ca0d23a2d Merge pull request #882 from hicccc77/dev
Dev
2026-05-01 14:46:42 +08:00
cc
4a57a503f5 #881 2026-05-01 14:46:14 +08:00
cc
d53ddb0ba7 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-30 00:00:04 +08:00
cc
1fc710ccef 修复底层配置服务混乱的问题 2026-04-29 23:59:56 +08:00
H3CoF6
82200e5fd7 Merge pull request #872 from H3CoF6/feat/image_hook
自动下载大图功能
2026-04-29 08:34:39 +08:00
H3CoF6
bdf285062f 优化下载会话选择页面 2026-04-29 08:25:45 +08:00
H3CoF6
b1807b21e7 feat: 选择会话的前端界面 2026-04-29 08:07:16 +08:00
H3CoF6
32feac7d5e chore: update dll for impl whitelist 2026-04-29 07:26:43 +08:00
H3CoF6
d2e59db123 fix: 修复AUR下载安装包并尝试提交的bug 2026-04-29 04:56:01 +08:00
H3CoF6
d27cef6358 优化前端显示和错误提醒 2026-04-29 04:38:25 +08:00
H3CoF6
1f0b2613bf feat(image): 新增自动下载大图选项(win32 x64)
Co-authored-by: NineBird <CavanasD@users.noreply.github.com>
2026-04-29 04:05:48 +08:00
H3CoF6
9c7ed1729a chore: add win32 dll + readme 2026-04-29 02:44:01 +08:00
cc
52f58f6288 Merge pull request #868 from Jasonzhu1207/feature/insight-moments-context
feat(insight): add moments context gating and prompt integration & streamline insight prompts & and optimize UI animations in the Insights section
2026-04-28 22:26:35 +08:00
Jason
dfe0186267 Add files via upload 2026-04-28 14:00:26 +08:00
Jason
fd9b7c4546 Merge pull request #38 from Jasonzhu1207/fix/insight-prompt-animation-polish
fix(insight): trim prompt noise and smooth settings animation
2026-04-28 13:40:23 +08:00
Jason
9f9ad337ab fix(insight): trim prompt noise and smooth settings animation 2026-04-28 13:35:11 +08:00
Jason
c596d24083 Merge branch 'hicccc77:main' into main 2026-04-28 13:00:15 +08:00
Jason
6cfc38c33a Merge pull request #37 from Jasonzhu1207/fix/insight-settings-ui-polish
fix(settings): polish insight context controls
2026-04-28 12:59:57 +08:00
Jason
13cede13f9 fix(settings): polish insight context controls 2026-04-28 12:55:46 +08:00
Jason
106d19fc6c Merge pull request #36 from Jasonzhu1207/fix/release-upload-assets
fix(release): upload assets after packaging
2026-04-28 12:12:51 +08:00
Jason
60a4011539 fix(release): upload assets after packaging 2026-04-28 12:05:33 +08:00
Jason
fd97920fb2 Merge pull request #35 from Jasonzhu1207/feature/insight-moments-context
feat(insight): add moments context gating and prompt integration
2026-04-28 00:17:41 +08:00
Jason
55a7ce7b66 feat(insight): add moments context gating and prompt integration 2026-04-28 00:14:05 +08:00
dependabot[bot]
5d64efdddf chore(deps-dev): bump vite from 7.3.2 to 8.0.10
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.2 to 8.0.10.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.10/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.9
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-25 07:24:50 +00:00
dependabot[bot]
fe02ff0d84 chore(deps): bump lucide-react from 1.7.0 to 1.8.0
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 1.7.0 to 1.8.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.8.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 00:16:19 +00:00
dependabot[bot]
dfec3dba41 chore(deps): bump react-virtuoso from 4.18.4 to 4.18.5
Bumps [react-virtuoso](https://github.com/petyosi/react-virtuoso/tree/HEAD/packages/react-virtuoso) from 4.18.4 to 4.18.5.
- [Release notes](https://github.com/petyosi/react-virtuoso/releases)
- [Changelog](https://github.com/petyosi/react-virtuoso/blob/master/packages/react-virtuoso/CHANGELOG.md)
- [Commits](https://github.com/petyosi/react-virtuoso/commits/react-virtuoso@4.18.5/packages/react-virtuoso)

---
updated-dependencies:
- dependency-name: react-virtuoso
  dependency-version: 4.18.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 00:15:45 +00:00
55 changed files with 4308 additions and 2441 deletions

View File

@@ -350,6 +350,8 @@ jobs:
updpkgsums: true
assets: |
resources/installer/linux/weflow.desktop
resources/installer/linux/icon.png
resources/installer/linux/.gitignore
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_username: H3CoF6

View File

@@ -166,7 +166,15 @@ async function run() {
let result: any
if (config.mode === 'contacts') {
const { contactExportService } = await import('./services/contactExportService')
const [{ contactExportService }, { chatService }] = await Promise.all([
import('./services/contactExportService'),
import('./services/chatService')
])
chatService.setRuntimeConfig({
dbPath: config.dbPath,
decryptKey: config.decryptKey,
myWxid: config.myWxid
})
result = await contactExportService.exportContacts(
String(config.outputDir || ''),
config.options || {}

View File

@@ -31,9 +31,11 @@ import { destroyNotificationWindow, registerNotificationHandlers, showNotificati
import { httpService } from './services/httpService'
import { messagePushService } from './services/messagePushService'
import { insightService } from './services/insightService'
import { insightRecordService } from './services/insightRecordService'
import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService'
import { bizService } from './services/bizService'
import { backupService } from './services/backupService'
import { imageDownloadService } from './services/imageDownloadService'
// 配置自动更新
autoUpdater.autoDownload = false
@@ -733,14 +735,41 @@ const focusMainWindowAndNavigate = (sessionId: string): void => {
targetWindow.webContents.send('navigate-to-session', sessionId)
}
const focusMainWindowAndNavigateRoute = (route: string): void => {
const targetWindow = mainWindow
if (!targetWindow || targetWindow.isDestroyed()) return
if (targetWindow.isMinimized()) targetWindow.restore()
targetWindow.show()
targetWindow.focus()
targetWindow.webContents.send('navigate-to-route', route)
}
const handleNotificationClickNavigation = (payload: unknown): void => {
if (payload && typeof payload === 'object') {
const data = payload as { sessionId?: string; channel?: string; insightRecordId?: string; targetRoute?: string }
const targetRoute = String(data.targetRoute || '').trim()
if (targetRoute.startsWith('/')) {
focusMainWindowAndNavigateRoute(targetRoute)
return
}
if (data.channel === 'ai-insight' && data.insightRecordId) {
focusMainWindowAndNavigateRoute(`/insight-inbox?recordId=${encodeURIComponent(String(data.insightRecordId))}`)
return
}
focusMainWindowAndNavigate(String(data.sessionId || ''))
return
}
focusMainWindowAndNavigate(String(payload || ''))
}
const ensureNotificationNavigateHandlerRegistered = (): void => {
if (notificationNavigateHandlerRegistered) return
notificationNavigateHandlerRegistered = true
ipcMain.on('notification-clicked', (_event, sessionId) => {
focusMainWindowAndNavigate(String(sessionId || ''))
ipcMain.on('notification-clicked', (_event, payload) => {
handleNotificationClickNavigation(payload)
})
setNotificationNavigateHandler((sessionId: string) => {
focusMainWindowAndNavigate(String(sessionId || ''))
setNotificationNavigateHandler((payload: unknown) => {
handleNotificationClickNavigation(payload)
})
}
@@ -1733,6 +1762,33 @@ function registerIpcHandlers() {
return insightService.getTodayStats()
})
ipcMain.handle('insight:listRecords', async (_, filters?: {
keyword?: string
sessionId?: string
startTime?: number
endTime?: number
limit?: number
offset?: number
}) => {
return insightRecordService.listRecords(filters || {})
})
ipcMain.handle('insight:getRecord', async (_, id: string) => {
return insightRecordService.getRecord(id)
})
ipcMain.handle('insight:markRecordRead', async (_, id: string) => {
return insightRecordService.markRecordRead(id)
})
ipcMain.handle('insight:clearRecords', async (_, filters?: {
sessionId?: string
startTime?: number
endTime?: number
}) => {
return insightRecordService.clearRecords(filters || {})
})
ipcMain.handle('insight:triggerTest', async () => {
return insightService.triggerTest()
})
@@ -2207,11 +2263,21 @@ function registerIpcHandlers() {
// WCDB 数据库相关
ipcMain.handle('wcdb:testConnection', async (_, dbPath: string, hexKey: string, wxid: string) => {
return wcdbService.testConnection(dbPath, hexKey, wxid)
const cfg = configService || new ConfigService()
const accountDir = cfg.getAccountDir(dbPath, wxid)
if (!accountDir) {
return { success: false, error: '未找到账号目录' }
}
return wcdbService.testConnection(accountDir, hexKey)
})
ipcMain.handle('wcdb:open', async (_, dbPath: string, hexKey: string, wxid: string) => {
return wcdbService.open(dbPath, hexKey, wxid)
const cfg = configService || new ConfigService()
const accountDir = cfg.getAccountDir(dbPath, wxid)
if (!accountDir) {
return false
}
return wcdbService.open(accountDir, hexKey)
})
ipcMain.handle('wcdb:close', async () => {
@@ -2242,6 +2308,10 @@ function registerIpcHandlers() {
return chatService.getSessions()
})
ipcMain.handle('chat:markAllSessionsRead', async () => {
return chatService.markAllSessionsRead()
})
ipcMain.handle('chat:getSessionStatuses', async (_, usernames: string[]) => {
return chatService.getSessionStatuses(usernames)
})
@@ -3954,6 +4024,19 @@ function registerIpcHandlers() {
}
})
// 自动下载原图
ipcMain.handle('image:startAutoDownload', async (_, whitelist?: string[]) => {
return await imageDownloadService.startAutoDownload(whitelist || [])
})
ipcMain.handle('image:stopAutoDownload', async () => {
await imageDownloadService.stopAutoDownload()
return { success: true }
})
ipcMain.handle('image:getAutoDownloadStatus', async () => {
return await imageDownloadService.getStatus()
})
}
// 主窗口引用
@@ -4081,6 +4164,13 @@ app.whenReady().then(async () => {
// 注册 IPC 处理器
updateSplashProgress(28, '正在初始化...')
registerIpcHandlers()
if (configService.get('autoDownloadHighRes')) {
const whitelistArr = configService.get('autoDownloadWhitelist') || []
const whitelistStr = (Array.isArray(whitelistArr) && whitelistArr.length > 0)
? (whitelistArr.join('\0') + '\0\0')
: ''
imageDownloadService.startAutoDownload(whitelistStr)
}
chatService.addDbMonitorListener((type, json) => {
messagePushService.handleDbMonitorChange(type, json)
insightService.handleDbMonitorChange(type, json)
@@ -4252,6 +4342,8 @@ const shutdownAppServices = async (): Promise<void> => {
}, 5000)
forceExitTimer.unref()
try { await cloudControlService.stop() } catch {}
// 停止自动下载服务
try { await imageDownloadService.stopAutoDownload() } catch {}
// 停止 chatService内部会关闭 cursor 与 DB避免退出阶段仍触发监控回调
try { chatService.close() } catch {}
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出

View File

@@ -13,7 +13,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
notification: {
show: (data: any) => ipcRenderer.invoke('notification:show', data),
close: () => ipcRenderer.invoke('notification:close'),
click: (sessionId: string) => ipcRenderer.send('notification-clicked', sessionId),
click: (payload: any) => ipcRenderer.send('notification-clicked', payload),
ready: () => ipcRenderer.send('notification:ready'),
resize: (width: number, height: number) => ipcRenderer.send('notification:resize', { width, height }),
onShow: (callback: (event: any, data: any) => void) => {
@@ -24,6 +24,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
const listener = (_: any, sessionId: string) => callback(sessionId)
ipcRenderer.on('navigate-to-session', listener)
return () => ipcRenderer.removeListener('navigate-to-session', listener)
},
onNavigateToRoute: (callback: (route: string) => void) => {
const listener = (_: any, route: string) => callback(route)
ipcRenderer.on('navigate-to-route', listener)
return () => ipcRenderer.removeListener('navigate-to-route', listener)
}
},
@@ -185,6 +190,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
chat: {
connect: () => ipcRenderer.invoke('chat:connect'),
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
markAllSessionsRead: () => ipcRenderer.invoke('chat:markAllSessionsRead'),
getAntiRevokeSessions: () => ipcRenderer.invoke('chat:getAntiRevokeSessions'),
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
@@ -365,7 +371,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
}) => callback(payload)
ipcRenderer.on('image:decryptProgress', listener)
return () => ipcRenderer.removeListener('image:decryptProgress', listener)
}
},
startAutoDownload: (whitelist: string[] | string) => ipcRenderer.invoke('image:startAutoDownload', whitelist),
stopAutoDownload: () => ipcRenderer.invoke('image:stopAutoDownload'),
getAutoDownloadStatus: () => ipcRenderer.invoke('image:getAutoDownloadStatus')
},
// 视频
@@ -374,6 +383,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
},
process: {
platform: process.platform,
arch: process.arch
},
// 数据分析
analytics: {
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
@@ -564,6 +578,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
insight: {
testConnection: () => ipcRenderer.invoke('insight:testConnection'),
getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'),
listRecords: (filters?: any) => ipcRenderer.invoke('insight:listRecords', filters),
getRecord: (id: string) => ipcRenderer.invoke('insight:getRecord', id),
markRecordRead: (id: string) => ipcRenderer.invoke('insight:markRecordRead', id),
clearRecords: (filters?: any) => ipcRenderer.invoke('insight:clearRecords', filters),
triggerTest: () => ipcRenderer.invoke('insight:triggerTest'),
generateFootprintInsight: (payload: {
rangeLabel: string

View File

@@ -131,9 +131,13 @@ class AnalyticsService {
if (!dbPath) return { success: false, error: '未配置数据库路径' }
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
const cleanedWxid = this.cleanAccountDirName(wxid)
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
const accountDir = this.configService.getAccountDir(dbPath, wxid)
if (!accountDir) return { success: false, error: '未找到账号目录' }
const ok = await wcdbService.open(accountDir, decryptKey)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
const cleanedWxid = this.cleanAccountDirName(wxid)
return { success: true, cleanedWxid }
}

View File

@@ -1,5 +1,6 @@
import { parentPort } from 'worker_threads'
import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
export interface TopContact {
username: string
@@ -158,9 +159,14 @@ class AnnualReportService {
if (!dbPath) return { success: false, error: '未配置数据库路径' }
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
const cleanedWxid = this.cleanAccountDirName(wxid)
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
const configService = ConfigService.getInstance()
const accountDir = configService.getAccountDir(dbPath, wxid)
if (!accountDir) return { success: false, error: '未找到账号目录' }
const ok = await wcdbService.open(accountDir, decryptKey)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
const cleanedWxid = this.cleanAccountDirName(wxid)
return { success: true, cleanedWxid, rawWxid: wxid }
}

View File

@@ -454,14 +454,14 @@ export class BackupService {
if (!wxid || !dbPath) return { success: false, error: '请先配置数据库路径和微信账号' }
if (!decryptKey) return { success: false, error: '请先配置数据库解密密钥' }
const accountDir = this.resolveAccountDir(dbPath, wxid)
// 使用 ConfigService 统一解析账号目录
const accountDir = this.configService.getAccountDir(dbPath, wxid)
if (!accountDir) return { success: false, error: `未在配置的 dbPath 下找到账号目录:${wxid}` }
const dbStorage = join(accountDir, 'db_storage')
if (!existsSync(dbStorage)) return { success: false, error: '未找到 db_storage 目录' }
const accountDirName = basename(accountDir)
const opened = await withTimeout(
wcdbService.open(dbPath, decryptKey, accountDirName),
wcdbService.open(accountDir, decryptKey),
15000,
'连接目标账号数据库超时,请检查数据库路径、密钥是否正确'
)

View File

@@ -6,6 +6,7 @@ import * as https from 'https'
import * as http from 'http'
import * as fzstd from 'fzstd'
import * as crypto from 'crypto'
import { app, BrowserWindow, dialog } from 'electron'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { MessageCacheService } from './messageCacheService'
@@ -17,7 +18,6 @@ import { voiceTranscribeService } from './voiceTranscribeService'
import { ImageDecryptService } from './imageDecryptService'
import { CONTACT_REGION_LOOKUP_DATA } from './contactRegionLookupData'
import { LRUCache } from '../utils/LRUCache.js'
import { getAppPathFallback, getElectronBrowserWindow, getElectronDialog, getPathFallback, isElectronAppPackaged } from './electronRuntime'
export interface ChatSession {
username: string
@@ -344,6 +344,7 @@ const FRIEND_EXCLUDE_USERNAMES = new Set(['medianote', 'floatbottle', 'qmessage'
class ChatService {
private configService: ConfigService
private runtimeConfig?: { dbPath?: string; decryptKey?: string; myWxid?: string }
private connected = false
private readonly dbMonitorListeners = new Set<(type: string, json: string) => void>()
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number; startTime?: number; endTime?: number; ascending?: boolean; bufferedMessages?: any[] }> = new Map()
@@ -452,6 +453,10 @@ class ChatService {
this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录
}
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string }): void {
this.runtimeConfig = config
}
/**
* 清理账号目录名
*/
@@ -498,7 +503,7 @@ class ChatService {
}
private async maybeShowInitFailureDialog(errorMessage: string): Promise<void> {
if (!isElectronAppPackaged()) return
if (!app.isPackaged) return
if (this.initFailureDialogShown) return
const code = this.extractErrorCode(errorMessage)
@@ -519,8 +524,6 @@ class ChatService {
].join('\n')
try {
const dialog = getElectronDialog()
if (!dialog?.showMessageBox) return
await dialog.showMessageBox({
type: 'error',
title: 'WeFlow 启动失败',
@@ -539,12 +542,9 @@ class ChatService {
*/
async connect(): Promise<{ success: boolean; error?: string }> {
try {
if (this.connected && wcdbService.isReady()) {
return { success: true }
}
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
const decryptKey = this.configService.get('decryptKey')
const wxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
const dbPath = String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim()
if (!wxid) {
return { success: false, error: '请先在设置页面配置微信ID' }
}
@@ -555,8 +555,17 @@ class ChatService {
return { success: false, error: '请先在设置页面配置解密密钥' }
}
const cleanedWxid = this.cleanAccountDirName(wxid)
const openOk = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
if (this.connected && wcdbService.isReady()) {
return { success: true }
}
// 使用 ConfigService 统一解析账号目录
const accountDir = this.configService.getAccountDir(dbPath, wxid)
if (!accountDir) {
return { success: false, error: '未找到账号目录请检查数据库路径和微信ID配置' }
}
const openOk = await wcdbService.open(accountDir, decryptKey)
if (!openOk) {
const detailedError = this.toCodeOnlyMessage(await wcdbService.getLastInitError())
await this.maybeShowInitFailureDialog(detailedError)
@@ -602,7 +611,7 @@ class ChatService {
console.error('[ChatService] 数据库监听回调失败:', error)
}
}
const windows = getElectronBrowserWindow()?.getAllWindows?.() || []
const windows = BrowserWindow.getAllWindows()
// 广播给所有渲染进程窗口
windows.forEach((win) => {
if (!win.isDestroyed()) {
@@ -969,6 +978,23 @@ class ChatService {
}
}
async markAllSessionsRead(): Promise<{ success: boolean; error?: string }> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error }
}
const result = await wcdbService.markAllSessionsRead()
if (result.success) {
this.syntheticUnreadState.clear()
}
return result
} catch (e) {
console.error('ChatService: 一键已读失败:', e)
return { success: false, error: String(e) }
}
}
private getSessionUsername(row: Record<string, any>): string {
return String(
row.username ||
@@ -2504,7 +2530,7 @@ class ChatService {
const rawRows = result.messages as Record<string, any>[]
const hasMore = rawRows.length > pageLimit
const selectedRows = hasMore ? rawRows.slice(0, pageLimit) : rawRows
const mapped = this.mapRowsToMessages(selectedRows)
const mapped = this.mapRowsToMessages(selectedRows, sessionId)
const visible = mapped.filter((msg) => this.isMessageVisibleForSession(sessionId, msg))
const outputMessages = (visible.length === 0 && mapped.length > 0)
? mapped
@@ -2515,6 +2541,7 @@ class ChatService {
const normalized = this.normalizeMessageOrder(outputMessages)
if (normalized.length > 0) {
await this.repairEmojiMessages(normalized)
await this.resolveQuotedMessages(normalized, sessionId)
}
return {
@@ -2571,7 +2598,7 @@ class ChatService {
}
// 转换为 Message 对象
const messages = this.mapRowsToMessages(res.messages as Record<string, any>[])
const messages = this.mapRowsToMessages(res.messages as Record<string, any>[], sessionId)
const normalized = this.normalizeMessageOrder(messages)
// 并发检查并修复缺失 CDN URL 的表情包
@@ -2808,7 +2835,7 @@ class ChatService {
const rowsToProcess = queuedRows
queuedRows = []
const mappedMessages = this.mapRowsToMessages(rowsToProcess)
const mappedMessages = this.mapRowsToMessages(rowsToProcess, sessionId)
for (let index = 0; index < mappedMessages.length; index += 1) {
const msg = mappedMessages[index]
rawRowsConsumed += 1
@@ -4799,8 +4826,8 @@ class ChatService {
/**
* HTTP API 复用消息解析逻辑,确保和应用内展示一致。
*/
mapRowsToMessagesForApi(rows: Record<string, any>[]): Message[] {
return this.mapRowsToMessages(rows)
mapRowsToMessagesForApi(rows: Record<string, any>[], sessionId: string): Message[] {
return this.mapRowsToMessages(rows, sessionId)
}
mapRowsToMessagesLiteForApi(rows: Record<string, any>[]): Message[] {
@@ -4854,7 +4881,7 @@ class ChatService {
return messages
}
private mapRowsToMessages(rows: Record<string, any>[]): Message[] {
private mapRowsToMessages(rows: Record<string, any>[], sessionId: string): Message[] {
const myWxid = this.configService.get('myWxid')
const messages: Message[] = []
@@ -4974,11 +5001,23 @@ class ChatService {
encrypVer = imageInfo.encrypVer
cdnThumbUrl = imageInfo.cdnThumbUrl
imageDatName = this.parseImageDatNameFromRow(row)
// 解析图片消息中的引用信息
const quoteInfo = this.parseMediaQuoteMessage(content, sessionId)
if (quoteInfo.content) quotedContent = quoteInfo.content
if (quoteInfo.sender) quotedSender = quoteInfo.sender
} else if (localType === 43) {
// 视频消息:优先从 packed_info_data 提取真实文件名32位十六进制再回退 XML
videoMd5 = this.parseVideoFileNameFromRow(row, content)
// 解析视频消息中的引用信息
const quoteInfo = this.parseMediaQuoteMessage(content, sessionId)
if (quoteInfo.content) quotedContent = quoteInfo.content
if (quoteInfo.sender) quotedSender = quoteInfo.sender
} else if (localType === 34 && content) {
voiceDurationSeconds = this.parseVoiceDurationSeconds(content)
// 解析语音消息中的引用信息
const quoteInfo = this.parseMediaQuoteMessage(content, sessionId)
if (quoteInfo.content) quotedContent = quoteInfo.content
if (quoteInfo.sender) quotedSender = quoteInfo.sender
} else if (localType === 42 && content) {
// 名片消息
const cardInfo = this.parseCardInfo(content)
@@ -5699,9 +5738,18 @@ class ChatService {
case '47':
displayContent = '[动画表情]'
break
case '49':
displayContent = '[链接]'
case '49': {
// 链接类消息 (type=49):需区分真正的链接和嵌套引用
// 嵌套引用的 referContent 中 xmlType=57真正的链接 xmlType=49 或 5
const decodedReferContent = this.decodeHtmlEntities(referContent || '')
const innerInfo = this.parseType49Message(decodedReferContent)
if (innerInfo.xmlType === '57' && innerInfo.linkTitle) {
displayContent = innerInfo.linkTitle
} else {
displayContent = '[链接]'
}
break
}
case '42':
displayContent = '[名片]'
break
@@ -5725,6 +5773,116 @@ class ChatService {
}
}
/**
* 解析媒体消息(图片/视频/语音)中的引用信息
* 这些消息的引用信息在 <extcommoninfo><refermsg> 中
*/
private parseMediaQuoteMessage(content: string, sessionId: string): { content?: string; sender?: string } {
try {
const normalizedContent = this.decodeHtmlEntities(content || '')
const referMsgStart = normalizedContent.indexOf('<refermsg>')
const referMsgEnd = normalizedContent.indexOf('</refermsg>')
if (referMsgStart === -1 || referMsgEnd === -1) {
return {}
}
const referMsgXml = normalizedContent.substring(referMsgStart, referMsgEnd + 11)
const svrid = this.extractXmlValue(referMsgXml, 'svrid')
console.log('[DEBUG] parseMediaQuoteMessage - svrid:', svrid)
if (!svrid) {
return {}
}
// 简化方案:返回 svrid 标记
console.log('[DEBUG] parseMediaQuoteMessage - 返回标记:', `__SVRID__${svrid}__`)
return { content: `__SVRID__${svrid}__` }
} catch {
return {}
}
}
async resolveQuotedMessages(messages: Message[], sessionId: string): Promise<void> {
console.log('[DEBUG] resolveQuotedMessages - 开始解析,消息数量:', messages.length)
const svridsToResolve: Array<{ msg: Message; svrid: string }> = []
for (const msg of messages) {
if (msg.quotedContent && msg.quotedContent.startsWith('__SVRID__')) {
const match = msg.quotedContent.match(/__SVRID__(.+?)__/)
if (match) {
console.log('[DEBUG] resolveQuotedMessages - 找到需要解析的svrid:', match[1])
svridsToResolve.push({ msg, svrid: match[1] })
}
}
}
console.log('[DEBUG] resolveQuotedMessages - 需要解析的数量:', svridsToResolve.length)
if (svridsToResolve.length === 0) return
const results = await Promise.allSettled(
svridsToResolve.map(({ svrid }) => {
console.log('[DEBUG] resolveQuotedMessages - 查询svrid:', svrid, 'sessionId:', sessionId)
return wcdbService.getMessageByServerId(sessionId, svrid)
})
)
console.log('[DEBUG] resolveQuotedMessages - 查询结果数量:', results.length)
for (let i = 0; i < results.length; i++) {
const result = results[i]
const { msg, svrid } = svridsToResolve[i]
console.log('[DEBUG] resolveQuotedMessages - 处理结果', i, ':', {
status: result.status,
success: result.status === 'fulfilled' ? result.value.success : false,
hasRow: result.status === 'fulfilled' && result.value.row ? true : false,
error: result.status === 'fulfilled' ? result.value.error : undefined,
svrid
})
if (result.status === 'fulfilled' && result.value.success && result.value.row) {
const localType = parseInt(result.value.row.local_type || '0', 10)
const rawMessageContent = result.value.row.message_content
const rawCompressContent = result.value.row.compress_content
console.log('[DEBUG] resolveQuotedMessages - 原始数据:', {
hasMessageContent: !!rawMessageContent,
hasCompressContent: !!rawCompressContent,
messageContentType: typeof rawMessageContent,
messageContentLength: rawMessageContent ? rawMessageContent.length : 0
})
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
console.log('[DEBUG] resolveQuotedMessages - 解码后:', { localType, contentLength: content.length, contentPreview: content.substring(0, 50) })
if (localType === 1) {
msg.quotedContent = this.sanitizeQuotedContent(content)
} else if (localType === 3) {
msg.quotedContent = '[图片]'
} else if (localType === 34) {
msg.quotedContent = '[语音]'
} else if (localType === 43) {
msg.quotedContent = '[视频]'
} else if (localType === 47) {
msg.quotedContent = '[动画表情]'
} else if (localType === 49) {
msg.quotedContent = '[链接]'
} else {
msg.quotedContent = '[消息]'
}
console.log('[DEBUG] resolveQuotedMessages - 更新后的quotedContent:', msg.quotedContent)
} else {
msg.quotedContent = '[引用消息]'
console.log('[DEBUG] resolveQuotedMessages - 查询失败,使用占位符')
}
}
console.log('[DEBUG] resolveQuotedMessages - 完成')
}
private extractPreferredQuotedText(referMsgXml: string): string {
if (!referMsgXml) return ''
@@ -6654,17 +6812,35 @@ class ChatService {
}
private cleanSystemMessage(content: string): string {
if (!content) return '[系统消息]'
const normalized = this.cleanUtf16(this.decodeHtmlEntities(String(content)))
const readableSysmsg = this.extractReadableSystemMessageText(normalized)
if (readableSysmsg) {
return readableSysmsg
}
// 移除 XML 声明
let cleaned = content.replace(/<\?xml[^?]*\?>/gi, '')
let cleaned = normalized.replace(/<\?xml[^?]*\?>/gi, '')
// 移除所有 XML/HTML 标签
cleaned = cleaned.replace(/<[^>]+>/g, '')
// 移除尾部的数字(如撤回消息后的时间戳)
cleaned = cleaned.replace(/\d+\s*$/, '')
// 清理多余空白
cleaned = cleaned.replace(/\s+/g, ' ').trim()
cleaned = this.stripSenderPrefix(cleaned).replace(/\s+/g, ' ').trim()
return cleaned || '[系统消息]'
}
private extractReadableSystemMessageText(content: string): string {
const sysmsgMatch = /<sysmsg\b[^>]*>([\s\S]*?)<\/sysmsg>/i.exec(content)
const source = sysmsgMatch?.[1] || content
const text =
this.extractXmlValue(source, 'plain') ||
this.extractXmlValue(source, 'text') ||
''
return this.stripSenderPrefix(text).replace(/\s+/g, ' ').trim()
}
private stripSenderPrefix(content: string): string {
return content.replace(/^[\s]*([a-zA-Z0-9_@-]+):(?!\/\/)(?:\s*(?:\r?\n|<br\s*\/?>)\s*|\s*)/i, '')
}
@@ -7182,7 +7358,7 @@ class ChatService {
return join(cachePath, 'Voices')
}
// 回退到默认目录
const documentsPath = getPathFallback('documents')
const documentsPath = app.getPath('documents')
return join(documentsPath, 'WeFlow', 'Voices')
}
@@ -7192,7 +7368,7 @@ class ChatService {
return join(cachePath, 'Emojis')
}
// 回退到默认目录
const documentsPath = getPathFallback('documents')
const documentsPath = app.getPath('documents')
return join(documentsPath, 'WeFlow', 'Emojis')
}
@@ -8437,13 +8613,13 @@ class ChatService {
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
try {
let wasmPath: string
if (isElectronAppPackaged()) {
if (app.isPackaged) {
wasmPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
if (!existsSync(wasmPath)) {
wasmPath = join(process.resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
}
} else {
wasmPath = join(getAppPathFallback(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
wasmPath = join(app.getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
}
if (!existsSync(wasmPath)) {
@@ -8631,7 +8807,7 @@ class ChatService {
/** 获取持久化转写缓存文件路径 */
private getTranscriptCachePath(): string {
const cachePath = this.configService.get('cachePath')
const base = cachePath || join(getPathFallback('documents'), 'WeFlow')
const base = cachePath || join(app.getPath('documents'), 'WeFlow')
return join(base, 'Voices', 'transcripts.json')
}
@@ -8739,7 +8915,7 @@ class ChatService {
return { success: false, error: result.error || '查询语音消息失败' }
}
let allVoiceMessages: Message[] = this.mapRowsToMessages(result.rows as Record<string, any>[])
let allVoiceMessages: Message[] = this.mapRowsToMessages(result.rows as Record<string, any>[], sessionId)
// 按 createTime 降序排序
allVoiceMessages.sort((a, b) => b.createTime - a.createTime)
@@ -8782,7 +8958,7 @@ class ChatService {
return { success: false, error: result.error || '查询图片消息失败' }
}
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[])
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[], sessionId)
let allImages: Array<{ imageMd5?: string; imageDatName?: string; createTime?: number }> = mapped
.filter(msg => msg.localType === 3)
.map(msg => ({
@@ -8907,7 +9083,7 @@ class ChatService {
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) continue
if (result.rows.length >= perTypeFetch) maybeHasMore = true
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[])
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[], sessionId)
for (const message of mapped) {
const resourceType = this.resolveResourceType(message)
if (!resourceType || !typeSet.has(resourceType)) continue

View File

@@ -1,14 +1,14 @@
import { dirname, join } from 'path'
import { join } from 'path'
import { existsSync, readdirSync, statSync } from 'fs'
import { app, safeStorage } from 'electron'
import crypto from 'crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import Store from 'electron-store'
import { expandHomePath } from '../utils/pathUtils'
import { getElectronSafeStorage, getPathFallback, isWorkerRuntime } from './electronRuntime'
// 加密前缀标记
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
const isSafeStorageAvailable = (): boolean => {
try {
const safeStorage = getElectronSafeStorage()
return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable()
} catch {
return false
@@ -59,6 +59,7 @@ interface ConfigSchema {
// 通知
notificationEnabled: boolean
aiInsightNotificationEnabled: boolean
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
notificationFilterList: string[]
@@ -86,7 +87,13 @@ interface ConfigSchema {
aiInsightApiModel: string
aiInsightSilenceDays: number
aiInsightAllowContext: boolean
aiInsightAllowMomentsContext: boolean
aiInsightMomentsContextCount: number
aiInsightMomentsBindings: Record<string, { enabled: boolean; updatedAt: number }>
aiInsightAllowSocialContext: boolean
aiInsightSocialContextCount: number
aiInsightWeiboCookie: string
aiInsightWeiboBindings: Record<string, { uid: string; screenName?: string; updatedAt: number }>
aiInsightFilterMode: 'whitelist' | 'blacklist'
aiInsightFilterList: string[]
aiInsightWhitelistEnabled: boolean
@@ -111,68 +118,8 @@ interface ConfigSchema {
aiFootprintSystemPrompt: string
/** 是否将 AI 见解调试日志输出到桌面 */
aiInsightDebugLogEnabled: boolean
}
interface ConfigStoreLike<T extends Record<string, any>> {
get<K extends keyof T>(key: K): T[K]
set<K extends keyof T>(key: K, value: T[K]): void
clear(): void
store: T
}
function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value))
}
class JsonConfigStore<T extends Record<string, any>> implements ConfigStoreLike<T> {
private readonly filePath: string
private readonly defaults: T
private data: T
constructor(options: { name: string; defaults: T; cwd?: string }) {
const baseDir = options.cwd || getPathFallback('userData')
mkdirSync(baseDir, { recursive: true })
this.filePath = join(baseDir, `${options.name}.json`)
this.defaults = cloneJson(options.defaults)
this.data = cloneJson(options.defaults)
this.load()
}
get store(): T {
return this.data
}
private load(): void {
try {
if (!existsSync(this.filePath)) return
const raw = readFileSync(this.filePath, 'utf8')
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
this.data = { ...cloneJson(this.defaults), ...parsed }
}
} catch {
this.data = cloneJson(this.defaults)
}
}
private persist(): void {
mkdirSync(dirname(this.filePath), { recursive: true })
writeFileSync(this.filePath, JSON.stringify(this.data), 'utf8')
}
get<K extends keyof T>(key: K): T[K] {
return this.data[key]
}
set<K extends keyof T>(key: K, value: T[K]): void {
this.data[key] = value
this.persist()
}
clear(): void {
this.data = cloneJson(this.defaults)
this.persist()
}
autoDownloadHighRes: boolean
autoDownloadWhitelist: string[]
}
// 需要 safeStorage 加密的字段(普通模式)
@@ -194,12 +141,15 @@ const LOCKABLE_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
export class ConfigService {
private static instance: ConfigService
private store!: ConfigStoreLike<ConfigSchema>
private store!: Store<ConfigSchema>
// 锁定模式运行时状态
private unlockedKeys: Map<string, any> = new Map()
private unlockPassword: string | null = null
// 账号目录缓存
private accountDirCache: Map<string, string> = new Map()
static getInstance(): ConfigService {
if (!ConfigService.instance) {
ConfigService.instance = new ConfigService()
@@ -243,6 +193,7 @@ export class ConfigService {
ignoredUpdateVersion: '',
updateChannel: 'auto',
notificationEnabled: true,
aiInsightNotificationEnabled: true,
notificationPosition: 'top-right',
notificationFilterMode: 'all',
notificationFilterList: [],
@@ -268,6 +219,9 @@ export class ConfigService {
aiInsightApiModel: 'gpt-4o-mini',
aiInsightSilenceDays: 3,
aiInsightAllowContext: false,
aiInsightAllowMomentsContext: false,
aiInsightMomentsContextCount: 5,
aiInsightMomentsBindings: {},
aiInsightAllowSocialContext: false,
aiInsightFilterMode: 'whitelist',
aiInsightFilterList: [],
@@ -285,20 +239,41 @@ export class ConfigService {
aiInsightWeiboBindings: {},
aiFootprintEnabled: false,
aiFootprintSystemPrompt: '',
aiInsightDebugLogEnabled: false
aiInsightDebugLogEnabled: false,
autoDownloadHighRes: false,
autoDownloadWhitelist: []
}
const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim()
this.store = new JsonConfigStore<ConfigSchema>({
const storeOptions: any = {
name: 'WeFlow-config',
defaults,
cwd: cwd || undefined
})
if (!isWorkerRuntime()) {
this.migrateAuthFields()
this.migrateAiConfig()
projectName: String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow'
}
const runningInWorker = process.env.WEFLOW_WORKER === '1'
if (runningInWorker) {
const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim()
if (cwd) {
storeOptions.cwd = cwd
}
}
try {
this.store = new Store<ConfigSchema>(storeOptions)
} catch (error) {
const message = String((error as Error)?.message || error || '')
if (message.includes('projectName')) {
const fallbackOptions = {
...storeOptions,
projectName: 'WeFlow',
cwd: storeOptions.cwd || process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || process.cwd()
}
this.store = new Store<ConfigSchema>(fallbackOptions)
} else {
throw error
}
}
this.migrateAuthFields()
this.migrateAiConfig()
}
// === 状态查询 ===
@@ -400,8 +375,6 @@ export class ConfigService {
if (!plaintext) return ''
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
if (!isSafeStorageAvailable()) return plaintext
const safeStorage = getElectronSafeStorage()
if (!safeStorage) return plaintext
const encrypted = safeStorage.encryptString(plaintext)
return SAFE_PREFIX + encrypted.toString('base64')
}
@@ -410,8 +383,6 @@ export class ConfigService {
if (!stored) return ''
if (!stored.startsWith(SAFE_PREFIX)) return stored
if (!isSafeStorageAvailable()) return ''
const safeStorage = getElectronSafeStorage()
if (!safeStorage) return ''
try {
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
return safeStorage.decryptString(buf)
@@ -874,12 +845,105 @@ export class ConfigService {
}
}
/**
* 清理账号目录名称(移除后缀)
*/
private cleanAccountDirName(dirName: string): string {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
// wxid_ 开头的特殊处理
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
// 移除4位后缀
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
/**
* 检查是否是目录
*/
private isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory()
} catch {
return false
}
}
/**
* 获取账号目录路径
* 统一的账号目录解析方法,所有服务应该使用此方法而不是自己实现
*
* @param dbPath 数据库根目录(可选,默认从配置读取)
* @param wxid 微信ID可选默认从配置读取
* @returns 账号目录的完整路径,如果找不到返回 null
*/
getAccountDir(dbPath?: string, wxid?: string): string | null {
const actualDbPath = dbPath || this.get('dbPath')
const actualWxid = wxid || this.get('myWxid')
if (!actualDbPath || !actualWxid) return null
const cleanedWxid = this.cleanAccountDirName(actualWxid)
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)
for (const entry of entries) {
const entryPath = join(normalized, entry)
if (!this.isDirectory(entryPath)) continue
const lowerEntry = entry.toLowerCase()
const isExactMatch = lowerEntry === lowerWxid
const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`)
// wxid_ 开头只接受带后缀的目录;其他账号精确匹配或带后缀都可以
const shouldMatch = lowerWxid.startsWith('wxid_')
? isSuffixMatch
: (isExactMatch || isSuffixMatch)
if (shouldMatch) {
this.accountDirCache.set(cacheKey, entryPath)
return entryPath
}
}
} catch { }
return null
}
private getUserDataPath(): string {
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
if (workerUserDataPath) {
return workerUserDataPath
}
return getPathFallback('userData')
return app?.getPath?.('userData') || process.cwd()
}
getCacheBasePath(): string {

View File

@@ -1,5 +1,6 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { app } from 'electron'
import { ConfigService } from './config'
export interface ContactCacheEntry {

View File

@@ -160,6 +160,16 @@ 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)
}
}
@@ -232,6 +242,16 @@ export class DbPathService {
const lower = entry.toLowerCase()
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 })
}
}

View File

@@ -110,7 +110,9 @@ class DualReportService {
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
const cleanedWxid = this.cleanAccountDirName(wxid)
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
const accountDir = this.configService.getAccountDir(dbPath, wxid)
if (!accountDir) return { success: false, error: '无法找到账号目录' }
const ok = await wcdbService.open(accountDir, decryptKey)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
return { success: true, cleanedWxid, rawWxid: wxid }
}

View File

@@ -1,96 +0,0 @@
import { homedir, tmpdir } from 'os'
import { join } from 'path'
type RuntimeRequire = (id: string) => any
let cachedElectron: any | null | false = null
export function isWorkerRuntime(): boolean {
return process.env.WEFLOW_WORKER === '1'
}
export function getElectronModule(): any | null {
if (isWorkerRuntime()) return null
if (cachedElectron !== null) return cachedElectron || null
try {
const runtimeRequire = (0, eval)('require') as RuntimeRequire
cachedElectron = runtimeRequire('electron')
} catch {
cachedElectron = false
}
return cachedElectron || null
}
export function getElectronApp(): any | null {
return getElectronModule()?.app || null
}
export function getElectronBrowserWindow(): any | null {
return getElectronModule()?.BrowserWindow || null
}
export function getElectronDialog(): any | null {
return getElectronModule()?.dialog || null
}
export function getElectronSafeStorage(): any | null {
return getElectronModule()?.safeStorage || null
}
export function getElectronPath(name: string): string | null {
try {
const getter = getElectronApp()?.getPath
if (typeof getter === 'function') {
return getter(name)
}
} catch {
// fall through to caller fallback
}
return null
}
export function getAppPathFallback(): string {
try {
const getter = getElectronApp()?.getAppPath
if (typeof getter === 'function') {
return getter()
}
} catch {
// fall through
}
return process.cwd()
}
export function getPathFallback(name: string): string {
const fromElectron = getElectronPath(name)
if (fromElectron) return fromElectron
const home = homedir()
switch (name) {
case 'userData': {
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
if (workerUserDataPath) return workerUserDataPath
if (process.platform === 'win32' && process.env.APPDATA) return join(process.env.APPDATA, 'WeFlow')
if (process.platform === 'darwin') return join(home, 'Library', 'Application Support', 'WeFlow')
return join(process.env.XDG_CONFIG_HOME || join(home, '.config'), 'WeFlow')
}
case 'documents':
return join(home, 'Documents')
case 'desktop':
return join(home, 'Desktop')
case 'downloads':
return join(home, 'Downloads')
case 'temp':
return tmpdir()
case 'appData':
return process.platform === 'win32' && process.env.APPDATA ? process.env.APPDATA : join(home, '.config')
default:
return process.cwd()
}
}
export function isElectronAppPackaged(): boolean {
const app = getElectronApp()
if (typeof app?.isPackaged === 'boolean') return app.isPackaged
return Boolean((process as any).resourcesPath && process.env.NODE_ENV !== 'development')
}

View File

@@ -1,6 +1,6 @@
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
import { getPathFallback } from './electronRuntime'
export interface ExportRecord {
exportTime: number
@@ -20,7 +20,7 @@ class ExportRecordService {
private resolveFilePath(): string {
if (this.filePath) return this.filePath
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
const userDataPath = workerUserDataPath || getPathFallback('userData')
const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd()
fs.mkdirSync(userDataPath, { recursive: true })
this.filePath = path.join(userDataPath, 'weflow-export-records.json')
return this.filePath

View File

@@ -1889,7 +1889,9 @@ class ExportService {
if (!decryptKey) return { success: false, error: '请先在设置页面配置解密密钥' }
const cleanedWxid = this.cleanAccountDirName(wxid)
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
const accountDir = this.configService.getAccountDir(dbPath, wxid)
if (!accountDir) return { success: false, error: '无法找到账号目录' }
const ok = await wcdbService.open(accountDir, decryptKey)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
return { success: true, cleanedWxid }
}
@@ -2178,6 +2180,10 @@ class ExportService {
*/
private convertMessageType(localType: number, content: string): number {
const normalized = this.normalizeAppMessageContent(content || '')
if (this.isReadableSystemMessage(localType, normalized)) {
return 80
}
const xmlTypeRaw = this.extractAppMessageType(normalized)
const xmlType = xmlTypeRaw ? Number.parseInt(xmlTypeRaw, 10) : null
const looksLikeAppMessage = localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>')
@@ -2201,6 +2207,12 @@ class ExportService {
return MESSAGE_TYPE_MAP[localType] ?? 99 // 未知类型 -> OTHER
}
private isReadableSystemMessage(localType: number, content: string): boolean {
if (localType === 10000) return true
const normalized = this.normalizeAppMessageContent(content || '')
return /<sysmsg\b/i.test(this.stripSenderPrefix(normalized))
}
/**
* 解码消息内容
*/
@@ -2627,6 +2639,10 @@ class ExportService {
emojiCaption?: string
): string {
const safeContent = content || ''
const readableSystemText = this.extractReadableSystemMessageText(safeContent)
if (readableSystemText && this.isReadableSystemMessage(localType, safeContent)) {
return readableSystemText
}
if (localType === 3) return '[图片]'
if (localType === 1) return this.stripSenderPrefix(safeContent)
@@ -3075,6 +3091,18 @@ class ExportService {
.trim() || '[系统消息]'
}
private extractReadableSystemMessageText(content: string): string {
if (!content) return ''
const normalized = this.normalizeAppMessageContent(content)
const sysmsgMatch = /<sysmsg\b[^>]*>([\s\S]*?)<\/sysmsg>/i.exec(this.stripSenderPrefix(normalized))
const source = sysmsgMatch?.[1] || normalized
const text =
this.extractXmlValue(source, 'plain') ||
this.extractXmlValue(source, 'text') ||
''
return this.stripSenderPrefix(text).replace(/\s+/g, ' ').trim()
}
/**
* 解析通话消息
* 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg>
@@ -3139,6 +3167,9 @@ class ExportService {
// 检查 XML 中的 type 标签(支持大 localType 的情况)
if (content) {
const normalized = this.normalizeAppMessageContent(content)
if (this.isReadableSystemMessage(localType, normalized)) {
return '系统消息'
}
const xmlType = this.extractAppMessageType(normalized)
if (xmlType) {
@@ -3505,7 +3536,49 @@ class ExportService {
return result
}
private parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string } {
private async resolveQuotedMessagesForExport(messages: any[], sessionId: string): Promise<void> {
const svridsToResolve: Array<{ msg: any; svrid: string }> = []
for (const msg of messages) {
if (msg.replyToMessageId && msg.quotedContent === '[消息]') {
svridsToResolve.push({ msg, svrid: msg.replyToMessageId })
}
}
if (svridsToResolve.length === 0) return
const results = await Promise.allSettled(
svridsToResolve.map(({ svrid }) => wcdbService.getMessageByServerId(sessionId, svrid))
)
for (let i = 0; i < results.length; i++) {
const result = results[i]
const { msg } = svridsToResolve[i]
if (result.status === 'fulfilled' && result.value.success && result.value.row) {
const localType = parseInt(result.value.row.local_type || '0', 10)
const rawMessageContent = result.value.row.message_content
const rawCompressContent = result.value.row.compress_content
const content = chatService['decodeMessageContent'](rawMessageContent, rawCompressContent)
if (localType === 1) {
msg.quotedContent = chatService['sanitizeQuotedContent'](content)
} else if (localType === 3) {
msg.quotedContent = '[图片]'
} else if (localType === 34) {
msg.quotedContent = '[语音]'
} else if (localType === 43) {
msg.quotedContent = '[视频]'
} else if (localType === 47) {
msg.quotedContent = '[动画表情]'
} else if (localType === 49) {
msg.quotedContent = '[链接]'
}
}
}
}
private parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string; svrid?: string } {
try {
const normalized = this.normalizeAppMessageContent(content || '')
const referMsgStart = normalized.indexOf('<refermsg>')
@@ -3522,6 +3595,7 @@ class ExportService {
const referContent = this.extractXmlValue(referMsgXml, 'content')
const referType = this.extractXmlValue(referMsgXml, 'type')
const svrid = this.extractXmlValue(referMsgXml, 'svrid')
let displayContent = referContent
switch (referType) {
@@ -3744,6 +3818,7 @@ class ExportService {
if (quoteInfo.content) meta.quotedContent = quoteInfo.content
if (quoteInfo.sender) meta.quotedSender = quoteInfo.sender
if (quoteInfo.type) meta.quotedType = quoteInfo.type
if (quoteInfo.svrid) meta.quotedSvrid = quoteInfo.svrid
}
if (appMsgKind === 'link') {
@@ -3936,6 +4011,11 @@ class ExportService {
}
if (!content) return ''
const readableSystemText = this.extractReadableSystemMessageText(content)
if (readableSystemText && this.isReadableSystemMessage(localType, content)) {
return readableSystemText
}
if (localType === 1) {
return this.stripSenderPrefix(content)
}
@@ -6585,6 +6665,9 @@ class ExportService {
msg.emojiCaption
)
}
if (this.isReadableSystemMessage(msg.localType, msg.content)) {
content = this.extractReadableSystemMessageText(msg.content) || content
}
// 转账消息:追加 "谁转账给谁" 信息
if (content && this.isTransferExportContent(content) && msg.content) {
@@ -6896,6 +6979,9 @@ class ExportService {
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
// 解析引用消息
await this.resolveQuotedMessagesForExport(collected.rows, sessionId)
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
@@ -7086,6 +7172,9 @@ class ExportService {
msg.emojiCaption
)
}
if (this.isReadableSystemMessage(msg.localType, msg.content)) {
content = this.extractReadableSystemMessageText(msg.content) || content
}
const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({
content: msg.content,
@@ -7097,7 +7186,8 @@ class ExportService {
rawMyWxid,
myDisplayName: myInfo.displayName || cleanedMyWxid
})
if (quotedReplyDisplay) {
// 对于媒体消息,不要让引用信息覆盖媒体路径
if (quotedReplyDisplay && !mediaItem) {
content = this.buildQuotedReplyText(quotedReplyDisplay)
}
@@ -7141,7 +7231,7 @@ class ExportService {
localId: allMessages.length + 1,
createTime: msg.createTime,
formattedTime: this.formatTimestamp(msg.createTime),
type: this.getMessageTypeName(msg.localType),
type: this.getMessageTypeName(msg.localType, msg.content),
localType: msg.localType,
content,
isSend: msg.isSend ? 1 : 0,
@@ -7632,6 +7722,9 @@ class ExportService {
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
// 解析引用消息
await this.resolveQuotedMessagesForExport(collected.rows, sessionId)
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
@@ -8052,20 +8145,20 @@ class ExportService {
worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime)
if (useCompactColumns) {
worksheet.getCell(currentRow, 3).value = senderRole
worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType)
worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType, msg.content)
} else if (includeGroupNicknameColumn) {
worksheet.getCell(currentRow, 3).value = senderNickname
worksheet.getCell(currentRow, 4).value = senderWxid
worksheet.getCell(currentRow, 5).value = senderRemark
worksheet.getCell(currentRow, 6).value = senderGroupNickname
worksheet.getCell(currentRow, 7).value = senderRole
worksheet.getCell(currentRow, 8).value = this.getMessageTypeName(msg.localType)
worksheet.getCell(currentRow, 8).value = this.getMessageTypeName(msg.localType, msg.content)
} else {
worksheet.getCell(currentRow, 3).value = senderNickname
worksheet.getCell(currentRow, 4).value = senderWxid
worksheet.getCell(currentRow, 5).value = senderRemark
worksheet.getCell(currentRow, 6).value = senderRole
worksheet.getCell(currentRow, 7).value = this.getMessageTypeName(msg.localType)
worksheet.getCell(currentRow, 7).value = this.getMessageTypeName(msg.localType, msg.content)
}
contentCell.value = enrichedContentValue
if (!quotedReplyDisplay) {
@@ -8338,7 +8431,7 @@ class ExportService {
i + 1,
this.formatTimestamp(msg.createTime),
senderRole,
this.getMessageTypeName(msg.localType),
this.getMessageTypeName(msg.localType, msg.content),
enrichedContentValue
]
: includeGroupNicknameColumn
@@ -8350,7 +8443,7 @@ class ExportService {
senderRemark,
senderGroupNickname,
senderRole,
this.getMessageTypeName(msg.localType),
this.getMessageTypeName(msg.localType, msg.content),
enrichedContentValue
]
: [
@@ -8360,7 +8453,7 @@ class ExportService {
senderWxid,
senderRemark,
senderRole,
this.getMessageTypeName(msg.localType),
this.getMessageTypeName(msg.localType, msg.content),
enrichedContentValue
])
if (!quotedReplyDisplay) {
@@ -8510,6 +8603,9 @@ class ExportService {
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
// 解析引用消息
await this.resolveQuotedMessagesForExport(collected.rows, sessionId)
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
@@ -9635,7 +9731,7 @@ class ExportService {
const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, resolvedSenderName)
const timeText = this.formatTimestamp(msg.createTime)
const typeName = this.getMessageTypeName(msg.localType)
const typeName = this.getMessageTypeName(msg.localType, msg.content)
const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({
content: msg.content,
isGroup,

View File

@@ -259,7 +259,9 @@ class GroupAnalyticsService {
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
const cleanedWxid = this.cleanAccountDirName(wxid)
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
const accountDir = this.configService.getAccountDir(dbPath, wxid)
if (!accountDir) return { success: false, error: '无法找到账号目录' }
const ok = await wcdbService.open(accountDir, decryptKey)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
return { success: true }
}

View File

@@ -1,3 +1,4 @@
import { app, BrowserWindow } from 'electron'
import { basename, dirname, extname, join } from 'path'
import { pathToFileURL } from 'url'
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
@@ -7,7 +8,6 @@ import crypto from 'crypto'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { decryptDatViaNative, nativeAddonLocation } from './nativeImageDecrypt'
import { getElectronBrowserWindow, getPathFallback, isElectronAppPackaged } from './electronRuntime'
// 获取 ffmpeg-static 的路径
function getStaticFfmpegPath(): string | null {
@@ -35,7 +35,7 @@ function getStaticFfmpegPath(): string | null {
}
// 方法3: 打包后的路径
if (isElectronAppPackaged()) {
if (app?.isPackaged) {
const resourcesPath = process.resourcesPath
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
if (existsSync(packedPath)) {
@@ -514,50 +514,11 @@ export class ImageDecryptService {
}
private resolveAccountDir(dbPath: string, wxid: string): string | null {
const cleanedWxid = this.cleanAccountDirName(wxid)
const normalized = dbPath.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)
}
const direct = join(normalized, cleanedWxid)
if (existsSync(direct)) {
this.accountDirCache.set(cacheKey, direct)
return direct
}
if (this.isAccountDir(normalized)) {
this.accountDirCache.set(cacheKey, normalized)
return normalized
}
try {
const entries = readdirSync(normalized)
const lowerWxid = cleanedWxid.toLowerCase()
for (const entry of entries) {
const entryPath = join(normalized, entry)
if (!this.isDirectory(entryPath)) continue
const lowerEntry = entry.toLowerCase()
if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) {
if (this.isAccountDir(entryPath)) {
this.accountDirCache.set(cacheKey, entryPath)
return entryPath
}
}
}
} catch { }
return null
return this.configService.getAccountDir(dbPath, wxid)
}
private resolveCurrentAccountDir(): string | null {
const wxid = this.getConfiguredMyWxid()
const dbPath = this.getConfiguredDbPath()
if (!wxid || !dbPath) return null
return this.resolveAccountDir(dbPath, wxid)
return this.configService.getAccountDir()
}
/**
@@ -1260,8 +1221,9 @@ export class ImageDecryptService {
const decryptKey = this.configService.get('decryptKey')
const wxid = this.configService.get('myWxid')
if (!dbPath || !decryptKey || !wxid) return false
const cleanedWxid = this.cleanAccountDirName(wxid)
return await wcdbService.open(dbPath, decryptKey, cleanedWxid)
const accountDir = this.configService.getAccountDir(dbPath, wxid)
if (!accountDir) return false
return await wcdbService.open(accountDir, decryptKey)
}
private getRowValue(row: any, column: string): any {
@@ -1475,7 +1437,7 @@ export class ImageDecryptService {
private getActiveWindowsSafely(): Array<{ isDestroyed: () => boolean; webContents: { send: (channel: string, payload: unknown) => void } }> {
try {
const getter = (getElectronBrowserWindow() as { getAllWindows?: () => any[] } | undefined)?.getAllWindows
const getter = (BrowserWindow as unknown as { getAllWindows?: () => any[] } | undefined)?.getAllWindows
if (typeof getter !== 'function') return []
const windows = getter()
if (!Array.isArray(windows)) return []
@@ -2191,7 +2153,14 @@ export class ImageDecryptService {
}
private getElectronPath(name: 'userData' | 'documents' | 'temp'): string | null {
return getPathFallback(name)
try {
const getter = (app as unknown as { getPath?: (n: string) => string } | undefined)?.getPath
if (typeof getter !== 'function') return null
const value = getter(name)
return typeof value === 'string' && value.trim() ? value : null
} catch {
return null
}
}
private getUserDataPath(): string {

View File

@@ -0,0 +1,203 @@
import { app } from 'electron'
import { join } from 'path'
import { existsSync } from 'fs'
import { execFile } from 'child_process'
import { promisify } from 'util'
// import { ConfigService } from './config'
const execFileAsync = promisify(execFile)
export class ImageDownloadService {
private static instance: ImageDownloadService
private koffi: any = null
private lib: any = null
private initialized = false
private initImgHelper: any = null
private uninstallImgHelper: any = null
private getImgHelperError: any = null
private currentPid: number | null = null
private pollTimer: NodeJS.Timeout | null = null
private isHooked = false
private lastWhitelist: string[] = []
static getInstance(): ImageDownloadService {
if (!ImageDownloadService.instance) {
ImageDownloadService.instance = new ImageDownloadService()
}
return ImageDownloadService.instance
}
private constructor() {
}
private async ensureInitialized(): Promise<boolean> {
if (this.initialized) return true
if (process.platform !== 'win32' || process.arch !== 'x64') return false
try {
this.koffi = require('koffi')
const dllPath = this.getDllPath()
if (!existsSync(dllPath)) return false
this.lib = this.koffi.load(dllPath)
this.initImgHelper = this.lib.func('bool InitImgHelper(uint32, const char*)')
this.uninstallImgHelper = this.lib.func('void UninstallImgHelper()')
this.getImgHelperError = this.lib.func('const char* GetImgHelperError()')
this.initialized = true
return true
} catch (error) {
console.error('[ImageDownloadService] failed to initialize:', error)
return false
}
}
private getDllPath(): string {
const isPackaged = app.isPackaged
const candidates: string[] = []
if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'image', 'win32', 'x64', 'img_helper.dll'))
} else {
candidates.push(join(process.cwd(), 'resources', 'image', 'win32', 'x64', 'img_helper.dll'))
}
for (const path of candidates) {
if (existsSync(path)) return path
}
return candidates[0]
}
private async findMainWeChatPid(): Promise<number | null> {
try {
const script = `
Get-CimInstance Win32_Process -Filter "Name = 'Weixin.exe'" |
Select-Object ProcessId, CommandLine |
ConvertTo-Json -Compress
`;
const { stdout } = await execFileAsync('powershell', ['-NoProfile', '-Command', script])
if (!stdout || !stdout.trim()) return null
let processes = JSON.parse(stdout.trim())
if (!Array.isArray(processes)) processes = [processes]
const target = processes
.filter((p: any) => p.CommandLine && p.CommandLine.toLowerCase().includes('weixin.exe'))
.sort((a: any, b: any) => a.CommandLine.length - b.CommandLine.length)[0]
return target ? target.ProcessId : null;
} catch (e) {
return null
}
}
async startAutoDownload(whitelist: string[] | string = []): Promise<{ success: boolean; error?: string }> {
if (!await this.ensureInitialized()) {
return { success: false, error: '核心组件初始化失败' }
}
if (this.isHooked) {
await this.unhook()
}
this.lastWhitelist = whitelist
if (!this.pollTimer) {
this.pollTimer = setInterval(() => this.checkAndHook(this.lastWhitelist, false), 30000)
}
return await this.checkAndHook(whitelist, true)
}
async stopAutoDownload() {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
await this.unhook()
}
private async checkAndHook(whitelist: string[] | string = [], isManualStart = false): Promise<{ success: boolean; error?: string }> {
const pid = await this.findMainWeChatPid()
if (!pid) {
if (this.isHooked) {
console.log('[ImageDownloadService] WeChat exited, unhooking')
await this.unhook()
}
return { success: true, error: '等待微信启动' }
}
if (this.isHooked && this.currentPid === pid) {
return { success: true }
}
if (this.isHooked && this.currentPid !== pid) {
console.log('[ImageDownloadService] WeChat PID changed, re-hooking')
await this.unhook()
}
console.log(`[ImageDownloadService] attempting to hook PID: ${pid}`)
try {
let whitelistBuffer: Buffer | null = null;
if (typeof whitelist === 'string') {
if (whitelist.length > 0) {
whitelistBuffer = Buffer.from(whitelist, 'utf8');
}
} else if (Array.isArray(whitelist) && whitelist.length > 0) {
whitelistBuffer = Buffer.from(whitelist.join('\0') + '\0\0', 'utf8');
}
const success = this.initImgHelper(pid, whitelistBuffer)
if (success) {
this.isHooked = true
this.currentPid = pid
console.log('[ImageDownloadService] hook successful')
return { success: true }
} else {
const err = this.getImgHelperError()
console.error(`[ImageDownloadService] hook failed: ${err}`)
if (isManualStart && this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
return { success: false, error: err || 'Hook 失败' }
}
} catch (e: any) {
console.error('[ImageDownloadService] InitImgHelper call crashed:', e)
if (isManualStart && this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
return { success: false, error: `调用异常: ${e.message || String(e)}` }
}
}
private async unhook() {
if (this.isHooked && this.uninstallImgHelper) {
try {
this.uninstallImgHelper()
} catch (e) {
console.error('[ImageDownloadService] uninstall failed:', e)
}
}
this.isHooked = false
this.currentPid = null
}
async getStatus() {
return {
isHooked: this.isHooked,
pid: this.currentPid,
supported: process.platform === 'win32' && process.arch === 'x64'
}
}
}
export const imageDownloadService = ImageDownloadService.getInstance()

View File

@@ -0,0 +1,292 @@
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
import { createHash, randomUUID } from 'crypto'
import { ConfigService } from './config'
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test'
export interface InsightRecordLog {
endpoint: string
model: string
maxTokens: number
temperature: number
triggerReason: InsightRecordTriggerReason
allowContext: boolean
contextCount: number
systemPrompt: string
userPrompt: string
rawOutput: string
finalInsight: string
durationMs: number
createdAt: number
}
export interface InsightRecord {
id: string
accountScope: string
createdAt: number
sessionId: string
displayName: string
avatarUrl?: string
triggerReason: InsightRecordTriggerReason
insight: string
read: boolean
log: InsightRecordLog
}
export interface InsightRecordSummary {
id: string
createdAt: number
sessionId: string
displayName: string
avatarUrl?: string
triggerReason: InsightRecordTriggerReason
insight: string
read: boolean
}
export interface InsightRecordContactFacet {
sessionId: string
displayName: string
avatarUrl?: string
count: number
}
export interface InsightRecordFilters {
keyword?: string
sessionId?: string
startTime?: number
endTime?: number
limit?: number
offset?: number
}
export interface InsightRecordListResult {
success: boolean
records: InsightRecordSummary[]
total: number
todayCount: number
unreadCount: number
contacts: InsightRecordContactFacet[]
error?: string
}
class InsightRecordService {
private readonly maxRecordsPerScope = 1000
private filePath: string | null = null
private loaded = false
private records: InsightRecord[] = []
private resolveFilePath(): string {
if (this.filePath) return this.filePath
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 })
this.filePath = path.join(userDataPath, 'weflow-insight-records.json')
return this.filePath
}
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)
if (Array.isArray(parsed)) {
this.records = parsed.filter((item) => item && typeof item === 'object') as InsightRecord[]
} else if (Array.isArray(parsed?.records)) {
this.records = parsed.records.filter((item: unknown) => item && typeof item === 'object') as InsightRecord[]
}
} catch {
this.records = []
}
}
private persist(): void {
try {
const filePath = this.resolveFilePath()
fs.writeFileSync(filePath, JSON.stringify({ version: 1, records: this.records }, null, 2), 'utf-8')
} catch {
// Keep insight generation non-blocking even if local persistence fails.
}
}
private getCurrentAccountScope(): string {
const config = ConfigService.getInstance()
const myWxid = String(config.get('myWxid') || '').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 getStartOfToday(): number {
const date = new Date()
date.setHours(0, 0, 0, 0)
return date.getTime()
}
private toSummary(record: InsightRecord): InsightRecordSummary {
return {
id: record.id,
createdAt: record.createdAt,
sessionId: record.sessionId,
displayName: record.displayName,
avatarUrl: record.avatarUrl,
triggerReason: record.triggerReason,
insight: record.insight,
read: record.read
}
}
private getScopedRecords(): InsightRecord[] {
this.ensureLoaded()
const scope = this.getCurrentAccountScope()
return this.records.filter((record) => record.accountScope === scope)
}
addRecord(input: {
sessionId: string
displayName: string
avatarUrl?: string
triggerReason: InsightRecordTriggerReason
insight: string
log: InsightRecordLog
}): InsightRecord {
this.ensureLoaded()
const scope = this.getCurrentAccountScope()
const now = Date.now()
const record: InsightRecord = {
id: randomUUID(),
accountScope: scope,
createdAt: now,
sessionId: input.sessionId,
displayName: input.displayName,
avatarUrl: input.avatarUrl,
triggerReason: input.triggerReason,
insight: input.insight,
read: false,
log: input.log
}
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 record
}
listRecords(filters: InsightRecordFilters = {}): InsightRecordListResult {
try {
const allScoped = this.getScopedRecords()
const todayStart = this.getStartOfToday()
const contactsMap = new Map<string, InsightRecordContactFacet>()
for (const record of allScoped) {
const existing = contactsMap.get(record.sessionId)
if (existing) {
existing.count += 1
} else {
contactsMap.set(record.sessionId, {
sessionId: record.sessionId,
displayName: record.displayName,
avatarUrl: record.avatarUrl,
count: 1
})
}
}
const keyword = String(filters.keyword || '').trim().toLowerCase()
const sessionId = String(filters.sessionId || '').trim()
const startTime = Number(filters.startTime || 0)
const endTime = Number(filters.endTime || 0)
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 = allScoped
.filter((record) => {
if (sessionId && record.sessionId !== sessionId) return false
if (startTime > 0 && record.createdAt < startTime) return false
if (endTime > 0 && record.createdAt > endTime) return false
if (keyword) {
const haystack = `${record.displayName}\n${record.sessionId}\n${record.insight}`.toLowerCase()
if (!haystack.includes(keyword)) return false
}
return true
})
.sort((a, b) => b.createdAt - a.createdAt)
return {
success: true,
records: filtered.slice(offset, offset + limit).map((record) => this.toSummary(record)),
total: filtered.length,
todayCount: allScoped.filter((record) => record.createdAt >= todayStart).length,
unreadCount: allScoped.filter((record) => !record.read).length,
contacts: Array.from(contactsMap.values()).sort((a, b) => b.count - a.count)
}
} catch (error) {
return {
success: false,
records: [],
total: 0,
todayCount: 0,
unreadCount: 0,
contacts: [],
error: (error as Error).message
}
}
}
getRecord(id: string): { success: boolean; record?: InsightRecord; 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: '未找到该见解记录' }
return { success: true, record }
}
markRecordRead(id: string): { success: boolean; error?: string } {
this.ensureLoaded()
const normalizedId = String(id || '').trim()
const scope = this.getCurrentAccountScope()
const record = this.records.find((item) => item.id === normalizedId && item.accountScope === scope)
if (!record) return { success: false, error: '未找到该见解记录' }
if (!record.read) {
record.read = true
this.persist()
}
return { success: true }
}
clearRecords(filters: InsightRecordFilters = {}): { success: boolean; removed: number; error?: string } {
this.ensureLoaded()
const scope = this.getCurrentAccountScope()
const sessionId = String(filters.sessionId || '').trim()
const startTime = Number(filters.startTime || 0)
const endTime = Number(filters.endTime || 0)
let removed = 0
this.records = this.records.filter((record) => {
if (record.accountScope !== scope) return true
if (sessionId && record.sessionId !== sessionId) return true
if (startTime > 0 && record.createdAt < startTime) return true
if (endTime > 0 && record.createdAt > endTime) return true
removed += 1
return false
})
this.persist()
return { success: true, removed }
}
}
export const insightRecordService = new InsightRecordService()

View File

@@ -10,18 +10,18 @@
* 设计原则:
* - 不引入任何额外 npm 依赖,使用 Node 原生 https 模块调用 OpenAI 兼容 API
* - 所有失败静默处理,不影响主流程
* - 当日触发记录sessionId + 时间列表)随 prompt 一起发送,让模型自行判断是否克制
* - 触发频率、冷却与名单过滤均在本地完成,不把调度统计塞进模型 prompt
*/
import https from 'https'
import http from 'http'
import fs from 'fs'
import path from 'path'
import { URL } from 'url'
import { app, Notification } from 'electron'
import { ConfigService } from './config'
import { chatService, ChatSession, Message } from './chatService'
import { snsService } from './snsService'
import { weiboService } from './social/weiboService'
import { showNotification } from '../windows/notificationWindow'
import { insightRecordService, type InsightRecordLog, type InsightRecordTriggerReason } from './insightRecordService'
// ─── 常量 ────────────────────────────────────────────────────────────────────
@@ -40,6 +40,7 @@ const API_MAX_TOKENS_DEFAULT = 200
const API_MAX_TOKENS_MIN = 1
const API_MAX_TOKENS_MAX = 65_535
const API_TEMPERATURE = 0.7
const INSIGHT_NOTIFICATION_AVATAR_URL = './assets/insight/AI_Insight.png'
/** 沉默天数阈值默认值 */
const DEFAULT_SILENCE_DAYS = 3
@@ -52,6 +53,9 @@ const INSIGHT_CONFIG_KEYS = new Set([
'aiModelApiMaxTokens',
'aiInsightFilterMode',
'aiInsightFilterList',
'aiInsightAllowMomentsContext',
'aiInsightMomentsContextCount',
'aiInsightMomentsBindings',
'aiInsightAllowSocialContext',
'aiInsightSocialContextCount',
'aiInsightWeiboCookie',
@@ -81,60 +85,12 @@ type InsightFilterMode = 'whitelist' | 'blacklist'
type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR'
let debugLogWriteQueue: Promise<void> = Promise.resolve()
function formatDebugTimestamp(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')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
function insightDebugLine(_level: InsightLogLevel, _message: string): void {
// Desktop debug log export has been replaced by per-insight request logs.
}
function getInsightDebugLogFilePath(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')
return path.join(app.getPath('desktop'), `weflow-ai-insight-debug-${year}-${month}-${day}.log`)
}
function isInsightDebugLogEnabled(): boolean {
try {
return ConfigService.getInstance().get('aiInsightDebugLogEnabled') === true
} catch {
return false
}
}
function appendInsightDebugText(text: string): void {
if (!isInsightDebugLogEnabled()) return
let logFilePath = ''
try {
logFilePath = getInsightDebugLogFilePath()
} catch {
return
}
debugLogWriteQueue = debugLogWriteQueue
.then(() => fs.promises.appendFile(logFilePath, text, 'utf8'))
.catch(() => undefined)
}
function insightDebugLine(level: InsightLogLevel, message: string): void {
appendInsightDebugText(`[${formatDebugTimestamp()}] [${level}] ${message}\n`)
}
function insightDebugSection(level: InsightLogLevel, title: string, payload: unknown): void {
const content = typeof payload === 'string'
? payload
: JSON.stringify(payload, null, 2)
appendInsightDebugText(
`\n========== [${formatDebugTimestamp()}] [${level}] ${title} ==========\n${content}\n========== END ==========\n`
)
function insightDebugSection(_level: InsightLogLevel, _title: string, _payload: unknown): void {
// Desktop debug log export has been replaced by per-insight request logs.
}
/**
@@ -445,7 +401,7 @@ class InsightService {
try {
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
const requestMessages = [{ role: 'user', content: appendPromptCurrentTime('请回复"连接成功"四个字。') }]
const requestMessages = [{ role: 'user', content: '请回复"连接成功"四个字。' }]
insightDebugSection(
'INFO',
'AI 测试连接请求',
@@ -512,9 +468,15 @@ class InsightService {
await this.generateInsightForSession({
sessionId,
displayName,
triggerReason: 'activity'
triggerReason: 'test'
})
return { success: true, message: `已向「${displayName}」发送测试见解,请查看右下角弹窗` }
const notificationEnabled = this.config.get('aiInsightNotificationEnabled') !== false
return {
success: true,
message: notificationEnabled
? `已向「${displayName}」发送测试见解,请查看通知弹窗`
: `已生成「${displayName}」的测试见解AI 见解消息通知当前已关闭`
}
} catch (e) {
return { success: false, message: `测试失败:${(e as Error).message}` }
}
@@ -823,26 +785,13 @@ ${topMentionText}
}
/**
* 记录触发并返回该会话今日所有触发时间(用于组装 prompt
* 记录成功推送的见解,用于设置页展示今日触发统计
*/
private recordTrigger(sessionId: string): string[] {
private recordTrigger(sessionId: string): void {
this.resetIfNewDay()
const existing = this.todayTriggers.get(sessionId) ?? { timestamps: [] }
existing.timestamps.push(Date.now())
this.todayTriggers.set(sessionId, existing)
return existing.timestamps.map(formatTimestamp)
}
/**
* 获取今日全局已触发次数(所有会话合计),用于 prompt 中告知模型全局上下文。
*/
private getTodayTotalTriggerCount(): number {
this.resetIfNewDay()
let total = 0
for (const record of this.todayTriggers.values()) {
total += record.timestamps.length
}
return total
}
private formatWeiboTimestamp(raw: string): string {
@@ -853,12 +802,66 @@ ${topMentionText}
return new Date(parsed).toLocaleString('zh-CN')
}
private formatMomentsTimestamp(raw: unknown): string {
const numeric = Number(raw)
if (!Number.isFinite(numeric) || numeric <= 0) {
return ''
}
const ms = numeric > 1_000_000_000_000 ? numeric : numeric * 1000
return new Date(ms).toLocaleString('zh-CN')
}
private extractMomentReadableText(post: { contentDesc?: unknown; linkTitle?: unknown }): string {
const contentDesc = this.normalizeInsightText(String(post.contentDesc || '')).replace(/\s+/g, ' ').trim()
if (contentDesc) return contentDesc
const linkTitle = this.normalizeInsightText(String(post.linkTitle || '')).replace(/\s+/g, ' ').trim()
if (linkTitle) return `[链接] ${linkTitle}`
return ''
}
private async getMomentsContextSection(sessionId: string): Promise<string> {
const allowMomentsContext = this.config.get('aiInsightAllowMomentsContext') === true
if (!allowMomentsContext) return ''
const bindings =
(this.config.get('aiInsightMomentsBindings') as Record<string, { enabled?: boolean }> | undefined) || {}
const isEnabledForSession = bindings[sessionId]?.enabled === true
if (!isEnabledForSession) return ''
const countRaw = Number(this.config.get('aiInsightMomentsContextCount') || 5)
const momentsCount = Math.max(1, Math.min(20, Math.floor(countRaw) || 5))
try {
const result = await snsService.getTimeline(momentsCount, 0, [sessionId])
const posts = result.success && Array.isArray(result.timeline) ? result.timeline : []
if (posts.length === 0) return ''
const lines = posts
.map((post) => {
const text = this.extractMomentReadableText(post as { contentDesc?: unknown; linkTitle?: unknown })
if (!text) return ''
const shortText = text.length > 180 ? `${text.slice(0, 180)}...` : text
const time = this.formatMomentsTimestamp((post as { createTime?: unknown }).createTime)
return time ? `[朋友圈 ${time}] ${shortText}` : `[朋友圈] ${shortText}`
})
.filter(Boolean) as string[]
if (lines.length === 0) return ''
insightLog('INFO', `已加载 ${lines.length} 条朋友圈内容 (sessionId=${sessionId})`)
return `近期朋友圈内容(最近 ${lines.length} 条):\n${lines.join('\n')}`
} catch (error) {
insightLog('WARN', `拉取朋友圈内容失败 (sessionId=${sessionId}): ${(error as Error).message}`)
return ''
}
}
private async getSocialContextSection(sessionId: string): Promise<string> {
const allowSocialContext = this.config.get('aiInsightAllowSocialContext') === true
if (!allowSocialContext) return ''
const rawCookie = String(this.config.get('aiInsightWeiboCookie') || '').trim()
const hasCookie = rawCookie.length > 0
const bindings =
(this.config.get('aiInsightWeiboBindings') as Record<string, { uid?: string; screenName?: string }> | undefined) || {}
@@ -879,10 +882,7 @@ ${topMentionText}
return `[微博 ${time}] ${text}`
})
insightLog('INFO', `已加载 ${lines.length} 条微博公开内容 (uid=${uid})`)
const riskHint = hasCookie
? ''
: '\n提示未配置微博 Cookie使用移动端公开接口抓取可能因平台风控导致获取失败或内容较少。'
return `近期公开社交平台内容(来源:微博,最近 ${lines.length} 条):\n${lines.join('\n')}${riskHint}`
return `近期公开社交平台内容(来源:微博,最近 ${lines.length} 条):\n${lines.join('\n')}`
} catch (error) {
insightLog('WARN', `拉取微博公开内容失败 (uid=${uid}): ${(error as Error).message}`)
return ''
@@ -1097,7 +1097,7 @@ ${topMentionText}
private async generateInsightForSession(params: {
sessionId: string
displayName: string
triggerReason: 'activity' | 'silence'
triggerReason: InsightRecordTriggerReason
silentDays?: number
}): Promise<void> {
const { sessionId, displayName, triggerReason, silentDays } = params
@@ -1108,6 +1108,13 @@ ${topMentionText}
const allowContext = this.config.get('aiInsightAllowContext') as boolean
const contextCount = (this.config.get('aiInsightContextCount') as number) || 40
const resolvedDisplayName = await this.resolveInsightSessionDisplayName(sessionId, displayName)
let resolvedAvatarUrl: string | undefined
try {
const contact = await chatService.getContactAvatar(sessionId)
resolvedAvatarUrl = String(contact?.avatarUrl || '').trim() || undefined
} catch {
resolvedAvatarUrl = undefined
}
insightLog('INFO', `generateInsightForSession: sessionId=${sessionId}, reason=${triggerReason}, contextCount=${contextCount}, api=${apiBaseUrl ? '已配置' : '未配置'}`)
@@ -1118,10 +1125,6 @@ ${topMentionText}
// ── 构建 prompt ────────────────────────────────────────────────────────────
// 今日触发统计(让模型具备时间与克制感)
const sessionTriggerTimes = this.recordTrigger(sessionId)
const totalTodayTriggers = this.getTodayTotalTriggerCount()
let contextSection = ''
if (allowContext) {
try {
@@ -1136,6 +1139,7 @@ ${topMentionText}
}
}
const momentsContextSection = await this.getMomentsContextSection(sessionId)
const socialContextSection = await this.getSocialContextSection(sessionId)
// ── 默认 system prompt稳定内容有利于 provider 端 prompt cache 命中)────
@@ -1151,25 +1155,12 @@ ${topMentionText}
const customPrompt = (this.config.get('aiInsightSystemPrompt') as string) || ''
const systemPrompt = customPrompt.trim() || DEFAULT_SYSTEM_PROMPT
// 可变的上下文统计信息放在 user message 里,保持 system prompt 稳定不变
// 这样 provider 端Anthropic/OpenAI能最大化命中 prompt cache降低费用
const triggerDesc =
triggerReason === 'silence'
? `你已经 ${silentDays} 天没有和「${resolvedDisplayName}」聊天了。`
: `你最近和「${resolvedDisplayName}」有新的聊天动态。`
const todayStatsDesc =
sessionTriggerTimes.length > 1
? `今天你已经针对「${resolvedDisplayName}」收到过 ${sessionTriggerTimes.length - 1} 条见解(时间:${sessionTriggerTimes.slice(0, -1).join('、')}),请适当克制。`
: `今天你还没有针对「${resolvedDisplayName}」发出过见解。`
const globalStatsDesc = `今天全部联系人合计已触发 ${totalTodayTriggers} 条见解。`
const userPromptBase = [
`触发原因:${triggerDesc}`,
`时间统计:${todayStatsDesc}`,
`全局统计:${globalStatsDesc}`,
triggerReason === 'silence' && silentDays
? `${silentDays} 天未联系「${resolvedDisplayName}」。`
: '',
contextSection,
momentsContextSection,
socialContextSection,
'请给出你的见解≤80字'
].filter(Boolean).join('\n\n')
@@ -1189,7 +1180,7 @@ ${topMentionText}
`接口地址:${endpoint}`,
`模型:${model}`,
`Max Tokens${maxTokens}`,
`触发原因${triggerReason}`,
`触发类型${triggerReason}`,
`上下文开关:${allowContext ? '开启' : '关闭'}`,
`上下文条数:${contextCount}`,
'',
@@ -1202,6 +1193,7 @@ ${topMentionText}
)
try {
const apiStartedAt = Date.now()
const result = await callApi(
apiBaseUrl,
apiKey,
@@ -1210,6 +1202,7 @@ ${topMentionText}
API_TIMEOUT_MS,
maxTokens
)
const apiDurationMs = Date.now() - apiStartedAt
insightLog('INFO', `API 返回原文: ${result.slice(0, 150)}`)
insightDebugSection('INFO', `AI 输出原文 ${resolvedDisplayName} (${sessionId})`, result)
@@ -1223,15 +1216,45 @@ ${topMentionText}
const insight = result.slice(0, 120)
const notifTitle = `见解 · ${resolvedDisplayName}`
const recordLog: InsightRecordLog = {
endpoint,
model,
maxTokens,
temperature: API_TEMPERATURE,
triggerReason,
allowContext,
contextCount,
systemPrompt,
userPrompt,
rawOutput: result,
finalInsight: insight,
durationMs: apiDurationMs,
createdAt: Date.now()
}
const record = insightRecordService.addRecord({
sessionId,
displayName: resolvedDisplayName,
avatarUrl: resolvedAvatarUrl,
triggerReason,
insight,
log: recordLog
})
insightLog('INFO', `推送通知 → ${resolvedDisplayName}: ${insight}`)
const insightNotificationEnabled = this.config.get('aiInsightNotificationEnabled') !== false
if (insightNotificationEnabled) {
insightLog('INFO', `推送通知 → ${resolvedDisplayName}: ${insight}`)
// 渠道一:Electron 原生系统通知
if (Notification.isSupported()) {
const notif = new Notification({ title: notifTitle, body: insight, silent: false })
notif.show()
// 渠道一:应用内通知窗口。AI 见解使用独立通知开关,不受新消息通知开关和会话过滤影响。
await showNotification({
title: notifTitle,
content: insight,
avatarUrl: INSIGHT_NOTIFICATION_AVATAR_URL,
sessionId,
insightRecordId: record.id,
channel: 'ai-insight'
})
} else {
insightLog('WARN', '当前系统不支持原生通知')
insightLog('INFO', `AI 见解消息通知已关闭,跳过应用通知 → ${resolvedDisplayName}: ${insight}`)
}
// 渠道二Telegram Bot 推送(可选)
@@ -1252,7 +1275,8 @@ ${topMentionText}
}
}
insightLog('INFO', ` ${resolvedDisplayName} 推送见解`)
insightLog('INFO', `完成 ${resolvedDisplayName} 的见解处理`)
this.recordTrigger(sessionId)
} catch (e) {
insightDebugSection(
'ERROR',

View File

@@ -6,10 +6,13 @@ export interface LinuxNotificationData {
title: string;
content: string;
avatarUrl?: string;
channel?: string;
insightRecordId?: string;
targetRoute?: string;
expireTimeout?: number;
}
type NotificationCallback = (sessionId: string) => void;
type NotificationCallback = (payload: unknown) => void;
let notificationCallbacks: NotificationCallback[] = [];
let notificationCounter = 1;
@@ -31,10 +34,10 @@ function clearNotificationState(notificationId: number): void {
}
}
function triggerNotificationCallback(sessionId: string): void {
function triggerNotificationCallback(payload: unknown): void {
for (const callback of notificationCallbacks) {
try {
callback(sessionId);
callback(payload);
} catch (error) {
console.error("[LinuxNotification] Callback error:", error);
}
@@ -69,6 +72,15 @@ export async function showLinuxNotification(
activeNotifications.set(notificationId, notification);
notification.on("click", () => {
if (data.channel === "ai-insight" && data.insightRecordId) {
triggerNotificationCallback({
sessionId: data.sessionId,
channel: data.channel,
insightRecordId: data.insightRecordId,
targetRoute: data.targetRoute,
});
return;
}
if (data.sessionId) {
triggerNotificationCallback(data.sessionId);
}

View File

@@ -1,5 +1,6 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { app } from 'electron'
import { ConfigService } from './config'
export interface SessionMessageCacheEntry {

View File

@@ -1,9 +1,9 @@
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
import { pathToFileURL } from 'url'
import { app } from 'electron'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { getPathFallback } from './electronRuntime'
export interface VideoInfo {
videoUrl?: string // 视频文件路径(用于 readFile
@@ -45,7 +45,7 @@ class VideoService {
try {
const timestamp = new Date().toISOString()
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logDir = join(getPathFallback('userData'), 'logs')
const logDir = join(app.getPath('userData'), 'logs')
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
} catch { }
@@ -131,6 +131,14 @@ class VideoService {
if (dbPathContainsWxid) {
return join(dbPath, 'msg', 'video')
}
// 使用 ConfigService 的统一账号目录解析
const accountDir = this.configService.getAccountDir(dbPath, wxid)
if (accountDir) {
return join(accountDir, 'msg', 'video')
}
// 回退到原始逻辑
return join(dbPath, wxid, 'msg', 'video')
}
@@ -144,6 +152,13 @@ class VideoService {
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
}
// 使用 ConfigService 的统一账号目录解析
const accountDir = this.configService.getAccountDir(dbPath, wxid)
if (accountDir) {
return [join(accountDir, 'db_storage', 'hardlink', 'hardlink.db')]
}
// 回退到原始逻辑
return [
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')

View File

@@ -1,9 +1,9 @@
import { app } from 'electron'
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream, openSync, writeSync, closeSync } from 'fs'
import { join } from 'path'
import * as https from 'https'
import * as http from 'http'
import { ConfigService } from './config'
import { getPathFallback } from './electronRuntime'
// Sherpa-onnx 类型定义
type OfflineRecognizer = any
@@ -91,7 +91,7 @@ export class VoiceTranscribeService {
private resolveModelDir(): string {
const configured = this.configService.get('whisperModelDir') as string | undefined
if (configured) return configured
return join(getPathFallback('documents'), 'WeFlow', 'models', 'sensevoice')
return join(app.getPath('documents'), 'WeFlow', 'models', 'sensevoice')
}
private resolveModelPath(fileName: string): string {

View File

@@ -35,8 +35,10 @@ export class WcdbCore {
private wcdbUpdateMessage: any = null
private wcdbDeleteMessage: any = null
private wcdbGetSessions: any = null
private wcdbMarkAllSessionsRead: any = null
private wcdbGetMessages: any = null
private wcdbGetMessageCount: any = null
private wcdbGetMessageByServerId: any = null
private wcdbGetDisplayNames: any = null
private wcdbGetAvatarUrls: any = null
private wcdbGetGroupMemberCount: any = null
@@ -810,12 +812,22 @@ export class WcdbCore {
// wcdb_status wcdb_get_sessions(wcdb_handle handle, char** out_json)
this.wcdbGetSessions = this.lib.func('int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)')
// wcdb_status wcdb_mark_all_sessions_read(wcdb_handle handle, char** out_error)
try {
this.wcdbMarkAllSessionsRead = this.lib.func('int32 wcdb_mark_all_sessions_read(int64 handle, _Out_ void** outError)')
} catch {
this.wcdbMarkAllSessionsRead = null
}
// wcdb_status wcdb_get_messages(wcdb_handle handle, const char* username, int32_t limit, int32_t offset, char** out_json)
this.wcdbGetMessages = this.lib.func('int32 wcdb_get_messages(int64 handle, const char* username, int32 limit, int32 offset, _Out_ void** outJson)')
// wcdb_status wcdb_get_message_count(wcdb_handle handle, const char* username, int32_t* out_count)
this.wcdbGetMessageCount = this.lib.func('int32 wcdb_get_message_count(int64 handle, const char* username, _Out_ int32* outCount)')
// wcdb_status wcdb_get_message_by_svrid(wcdb_handle handle, const char* session_id, const char* svrid, char** out_json)
this.wcdbGetMessageByServerId = this.lib.func('int32 wcdb_get_message_by_svrid(int64 handle, const char* sessionId, const char* svrid, _Out_ void** outJson)')
// wcdb_status wcdb_get_display_names(wcdb_handle handle, const char* usernames_json, char** out_json)
this.wcdbGetDisplayNames = this.lib.func('int32 wcdb_get_display_names(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
@@ -1260,13 +1272,12 @@ export class WcdbCore {
/**
* 测试数据库连接
*/
async testConnection(dbPath: string, hexKey: string, wxid: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
async testConnection(accountDir: string, hexKey: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
try {
// 如果当前已经有相同参数的活动连接,直接返回成功
if (this.handle !== null &&
this.currentPath === dbPath &&
this.currentKey === hexKey &&
this.currentWxid === wxid) {
this.currentPath === accountDir &&
this.currentKey === hexKey) {
return { success: true, sessionCount: 0 }
}
@@ -1284,9 +1295,9 @@ export class WcdbCore {
}
}
// 构建 db_storage 目录路径
const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid)
this.writeLog(`testConnection dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`)
// 直接使用账号目录
const dbStoragePath = join(accountDir, 'db_storage')
this.writeLog(`testConnection accountDir=${accountDir} dbStorage=${dbStoragePath}`)
if (!dbStoragePath || !existsSync(dbStoragePath)) {
return { success: false, error: this.formatInitProtectionError(-3001) }
@@ -1329,9 +1340,9 @@ export class WcdbCore {
}
// 恢复测试前的连接(如果之前有活动连接)
if (hadActiveConnection && prevPath && prevKey && prevWxid) {
if (hadActiveConnection && prevPath && prevKey) {
try {
await this.open(prevPath, prevKey, prevWxid)
await this.open(prevPath, prevKey)
} catch {
// 恢复失败则保持断开,由调用方处理
}
@@ -1536,7 +1547,7 @@ export class WcdbCore {
/**
* 打开数据库
*/
async open(dbPath: string, hexKey: string, wxid: string): Promise<boolean> {
async open(accountDir: string, hexKey: string): Promise<boolean> {
try {
lastDllInitError = null
if (!this.initialized) {
@@ -1546,9 +1557,8 @@ export class WcdbCore {
// 检查是否已经是当前连接的参数,如果是则直接返回成功,实现"始终保持链接"
if (this.handle !== null &&
this.currentPath === dbPath &&
this.currentKey === hexKey &&
this.currentWxid === wxid) {
this.currentPath === accountDir &&
this.currentKey === hexKey) {
return true
}
@@ -1560,12 +1570,12 @@ export class WcdbCore {
if (!initOk) return false
}
const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid)
this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`, true)
const dbStoragePath = join(accountDir, 'db_storage')
this.writeLog(`open accountDir=${accountDir} dbStorage=${dbStoragePath}`, true)
if (!dbStoragePath || !existsSync(dbStoragePath)) {
console.error('数据库目录不存在:', dbPath)
this.writeLog(`open failed: dbStorage not found for ${dbPath}`)
console.error('数据库目录不存在:', accountDir)
this.writeLog(`open failed: dbStorage not found for ${accountDir}`)
lastDllInitError = this.formatInitProtectionError(-3001)
return false
}
@@ -1596,8 +1606,11 @@ export class WcdbCore {
return false
}
// 从账号目录路径中提取 wxid目录名
const wxid = basename(accountDir)
this.handle = handle
this.currentPath = dbPath
this.currentPath = accountDir
this.currentKey = hexKey
this.currentWxid = wxid
this.currentDbStoragePath = dbStoragePath
@@ -1615,7 +1628,7 @@ export class WcdbCore {
}
this.writeLog(`open ok handle=${handle}`, true)
await this.dumpDbStatus('open')
await this.runPostOpenDiagnostics(dbPath, dbStoragePath, sessionDbPath, wxid)
await this.runPostOpenDiagnostics(accountDir, dbStoragePath, sessionDbPath, wxid)
return true
} catch (e) {
console.error('打开数据库异常:', e)
@@ -1696,6 +1709,39 @@ export class WcdbCore {
}
}
async markAllSessionsRead(): Promise<{ success: boolean; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbMarkAllSessionsRead) {
return { success: false, error: '当前数据服务版本不支持一键已读' }
}
try {
await new Promise(resolve => setImmediate(resolve))
const outPtr = [null as any]
const result = this.wcdbMarkAllSessionsRead(this.handle, outPtr)
let message = ''
if (outPtr[0]) {
try { message = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
try { this.wcdbFreeString(outPtr[0]) } catch { }
}
await new Promise(resolve => setImmediate(resolve))
if (result !== 0) {
this.writeLog(`markAllSessionsRead failed: code=${result} error=${message}`)
return { success: false, error: message || `一键已读失败: ${result}` }
}
this.clearMediaStreamSessionCache()
this.writeLog('markAllSessionsRead ok')
return { success: true }
} catch (e) {
this.writeLog(`markAllSessionsRead exception: ${String(e)}`)
return { success: false, error: String(e) }
}
}
async getMessages(sessionId: string, limit: number, offset: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
@@ -1765,6 +1811,30 @@ export class WcdbCore {
}
}
async getMessageByServerId(sessionId: string, svrid: string): Promise<{ success: boolean; row?: any; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetMessageByServerId(this.handle, sessionId, svrid, outPtr)
if (result !== 0) {
return { success: false, error: `查询消息失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) {
return { success: true, row: null }
}
const parsed = JSON.parse(jsonStr)
if (!parsed || Object.keys(parsed).length === 0) {
return { success: true, row: null }
}
return { success: true, row: parsed }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }

View File

@@ -154,15 +154,17 @@ export class WcdbService {
/**
* 测试数据库连接
*/
async testConnection(dbPath: string, hexKey: string, wxid: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
return this.callWorker('testConnection', { dbPath, hexKey, wxid })
async testConnection(accountDir: string, hexKey: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
return this.callWorker('testConnection', { accountDir, hexKey })
}
/**
* 打开数据库
* @param accountDir 账号目录的完整路径
* @param hexKey 解密密钥
*/
async open(dbPath: string, hexKey: string, wxid: string): Promise<boolean> {
return this.callWorker('open', { dbPath, hexKey, wxid })
async open(accountDir: string, hexKey: string): Promise<boolean> {
return this.callWorker('open', { accountDir, hexKey })
}
async getLastInitError(): Promise<string | null> {
@@ -202,6 +204,10 @@ export class WcdbService {
return this.callWorker('getSessions')
}
async markAllSessionsRead(): Promise<{ success: boolean; error?: string }> {
return this.callWorker('markAllSessionsRead')
}
/**
* 获取消息列表
*/
@@ -223,6 +229,13 @@ export class WcdbService {
return this.callWorker('getMessageCount', { sessionId })
}
/**
* 根据 server_id 查询单条消息
*/
async getMessageByServerId(sessionId: string, svrid: string): Promise<{ success: boolean; row?: any; error?: string }> {
return this.callWorker('getMessageByServerId', { sessionId, svrid })
}
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
return this.callWorker('getMessageCounts', { sessionIds })
}

View File

@@ -32,10 +32,10 @@ if (parentPort) {
break
}
case 'testConnection':
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
result = await core.testConnection(payload.accountDir, payload.hexKey)
break
case 'open':
result = await core.open(payload.dbPath, payload.hexKey, payload.wxid)
result = await core.open(payload.accountDir, payload.hexKey)
break
case 'getLastInitError':
result = core.getLastInitError()
@@ -50,6 +50,9 @@ if (parentPort) {
case 'getSessions':
result = await core.getSessions()
break
case 'markAllSessionsRead':
result = await core.markAllSessionsRead()
break
case 'getMessages':
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
break
@@ -59,6 +62,9 @@ if (parentPort) {
case 'getMessageCount':
result = await core.getMessageCount(payload.sessionId)
break
case 'getMessageByServerId':
result = await core.getMessageByServerId(payload.sessionId, payload.svrid)
break
case 'getMessageCounts':
result = await core.getMessageCounts(payload.sessionIds)
break

View File

@@ -9,10 +9,10 @@ let linuxNotificationService:
| null = null;
// 用于处理通知点击的回调函数在Linux上用于导航到会话
let onNotificationNavigate: ((sessionId: string) => void) | null = null;
let onNotificationNavigate: ((payload: unknown) => void) | null = null;
export function setNotificationNavigateHandler(
callback: (sessionId: string) => void,
callback: (payload: unknown) => void,
) {
onNotificationNavigate = callback;
}
@@ -109,25 +109,33 @@ export function createNotificationWindow() {
export async function showNotification(data: any) {
// 先检查配置
const config = ConfigService.getInstance();
const enabled = await config.get("notificationEnabled");
if (enabled === false) return; // 默认为 true
// 检查会话过滤
const filterMode = config.get("notificationFilterMode") || "all";
const filterList = config.get("notificationFilterList") || [];
const sessionId = typeof data.sessionId === "string" ? data.sessionId : "";
// 系统通知(如 "WeFlow 准备就绪")不是聊天消息,不应受会话白/黑名单影响
const isSystemNotification = sessionId.startsWith("weflow-");
const channel = typeof data.channel === "string" ? data.channel : "";
const isAiInsightNotification = channel === "ai-insight";
if (!isSystemNotification && filterMode !== "all") {
const isInList = sessionId !== "" && filterList.includes(sessionId);
if (filterMode === "whitelist" && !isInList) {
// 白名单模式:不在列表中则不显示(空列表视为全部拦截)
return;
}
if (filterMode === "blacklist" && isInList) {
// 黑名单模式:在列表中则不显示
return;
if (isAiInsightNotification) {
const enabled = await config.get("aiInsightNotificationEnabled");
if (enabled === false) return; // 默认为 true
} else {
const enabled = await config.get("notificationEnabled");
if (enabled === false) return; // 默认为 true
// 检查会话过滤
const filterMode = config.get("notificationFilterMode") || "all";
const filterList = config.get("notificationFilterList") || [];
// 系统通知(如 "WeFlow 准备就绪")不是聊天消息,不应受会话白/黑名单影响
const isSystemNotification = sessionId.startsWith("weflow-");
if (!isSystemNotification && filterMode !== "all") {
const isInList = sessionId !== "" && filterList.includes(sessionId);
if (filterMode === "whitelist" && !isInList) {
// 白名单模式:不在列表中则不显示(空列表视为全部拦截)
return;
}
if (filterMode === "blacklist" && isInList) {
// 黑名单模式:在列表中则不显示
return;
}
}
}
@@ -176,6 +184,9 @@ async function showLinuxNotification(data: any) {
content: data.content,
avatarUrl: data.avatarUrl,
sessionId: data.sessionId,
channel: data.channel,
insightRecordId: data.insightRecordId,
targetRoute: data.targetRoute,
expireTimeout: 5000,
};
@@ -249,14 +260,14 @@ export async function registerNotificationHandlers() {
await linuxNotificationModule.initLinuxNotificationService();
// 在Linux上注册通知点击回调
linuxNotificationModule.onNotificationAction((sessionId: string) => {
linuxNotificationModule.onNotificationAction((payload: unknown) => {
console.log(
"[NotificationWindow] Linux notification clicked, sessionId:",
sessionId,
payload,
);
// 如果设置了导航处理程序则使用该处理程序否则回退到ipcMain方法。
if (onNotificationNavigate) {
onNotificationNavigate(sessionId);
onNotificationNavigate(payload);
} else {
// 如果尚未设置处理程序则通过ipcMain发出事件
// 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。

2554
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,7 @@
"electron:build": "npm run build"
},
"dependencies": {
"@vscode/sudo-prompt": "^9.3.2",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.2",
"electron-store": "^11.0.2",
@@ -34,30 +35,30 @@
"jieba-wasm": "^2.2.0",
"jszip": "^3.10.1",
"koffi": "^2.9.0",
"lucide-react": "^1.7.0",
"lucide-react": "^1.8.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.14.0",
"react-virtuoso": "^4.18.1",
"react-virtuoso": "^4.18.5",
"remark-gfm": "^4.0.1",
"sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1",
"@vscode/sudo-prompt": "^9.3.2",
"wechat-emojis": "^1.0.2",
"zustand": "^5.0.2"
},
"devDependencies": {
"@electron/rebuild": "^4.0.2",
"@electron/rebuild": "^4.0.4",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-react": "^6.0.1",
"electron": "^41.1.1",
"electron-builder": "^26.8.1",
"esbuild": "^0.28.0",
"sass": "^1.98.0",
"sharp": "^0.34.5",
"typescript": "^6.0.2",
"vite": "^7.0.0",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vite-plugin-electron": "^0.28.8",
"vite-plugin-electron-renderer": "^0.14.6"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

View File

@@ -0,0 +1 @@
> 目前只适配了x64 win32平台其它平台同样原理但是代码还没写

Binary file not shown.

6
resources/installer/linux/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
*.tar.gz
*.tar.xz
*.zip
src/
pkg/
weflow-*/

View File

@@ -28,6 +28,7 @@ import ChatHistoryPage from './pages/ChatHistoryPage'
import NotificationWindow from './pages/NotificationWindow'
import AccountManagementPage from './pages/AccountManagementPage'
import BackupPage from './pages/BackupPage'
import InsightInboxPage from './pages/InsightInboxPage'
import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
@@ -319,6 +320,19 @@ function App() {
}
}, [navigate, isNotificationWindow])
useEffect(() => {
if (isNotificationWindow) return
const removeListener = window.electronAPI?.notification?.onNavigateToRoute?.((route: string) => {
if (!route || !route.startsWith('/')) return
navigate(route, { replace: true })
})
return () => {
removeListener?.()
}
}, [navigate, isNotificationWindow])
// 解锁后显示暂存的更新弹窗
useEffect(() => {
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
@@ -703,6 +717,7 @@ function App() {
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
<Route path="/sns" element={<SnsPage />} />
<Route path="/insight-inbox" element={<InsightInboxPage />} />
<Route path="/biz" element={<BizPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/resources" element={<ResourcesPage />} />

View File

@@ -11,8 +11,7 @@
.export-date-range-dialog {
width: min(480px, calc(100vw - 32px));
max-height: calc(100vh - 64px);
overflow-y: auto;
max-height: calc(100vh - 80px);
border-radius: 16px;
border: 1px solid var(--border-color);
background: var(--bg-secondary-solid, var(--bg-primary));
@@ -21,12 +20,14 @@
flex-direction: column;
gap: 10px;
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.16);
overflow: hidden;
}
.export-date-range-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
h4 {
margin: 0;
@@ -35,6 +36,26 @@
}
}
.export-date-range-dialog-content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
gap: 10px;
padding-right: 2px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
}
.export-date-range-dialog-close-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
@@ -439,6 +460,7 @@
display: flex;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
}
.export-date-range-dialog-btn {

View File

@@ -565,6 +565,7 @@ export function ExportDateRangeDialog({
</button>
</div>
<div className="export-date-range-dialog-content">
<div className="export-date-range-preset-list">
{EXPORT_DATE_RANGE_PRESETS.map((preset) => {
const active = isPresetActive(preset.value)
@@ -728,6 +729,7 @@ export function ExportDateRangeDialog({
})}
</div>
</section>
</div>
<div className="export-date-range-dialog-actions">
<button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}>

View File

@@ -7,6 +7,9 @@ import './NotificationToast.scss'
export interface NotificationData {
id: string
sessionId: string
channel?: string
insightRecordId?: string
targetRoute?: string
avatarUrl?: string
title: string
content: string
@@ -16,7 +19,7 @@ export interface NotificationData {
interface NotificationToastProps {
data: NotificationData | null
onClose: () => void
onClick: (sessionId: string) => void
onClick: (data: NotificationData) => void
duration?: number
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
isStatic?: boolean
@@ -64,7 +67,7 @@ export function NotificationToast({
setIsVisible(false)
setTimeout(() => {
onClose()
onClick(currentData.sessionId)
onClick(currentData)
}, 300)
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, FolderClosed, Footprints, Users, ArchiveRestore } from 'lucide-react'
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, FolderClosed, Footprints, Users, ArchiveRestore, Sparkles } from 'lucide-react'
import { useAppStore } from '../stores/appStore'
import * as configService from '../services/config'
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
@@ -344,6 +344,15 @@ function Sidebar({ collapsed }: SidebarProps) {
<span className="nav-label"></span>
</NavLink>
<NavLink
to="/insight-inbox"
className={`nav-item ${isActive('/insight-inbox') ? 'active' : ''}`}
title={collapsed ? '灵感信箱' : undefined}
>
<span className="nav-icon"><Sparkles size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 通讯录 */}
<NavLink
to="/contacts"

View File

@@ -2245,11 +2245,28 @@
box-shadow: 0 0 0 2px var(--primary-light);
}
.image-unavailable.error {
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2);
svg {
color: rgba(239, 68, 68, 0.8);
}
}
.image-unavailable:disabled {
cursor: default;
opacity: 0.7;
}
.image-error-reason {
font-size: 11px;
color: rgba(239, 68, 68, 0.9);
max-width: 140px;
word-break: break-word;
line-height: 1.3;
}
.image-action {
font-size: 11px;
color: var(--text-quaternary);

View File

@@ -1453,6 +1453,7 @@ function ChatPage(props: ChatPageProps) {
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [sidebarWidth, setSidebarWidth] = useState(260)
const [isResizing, setIsResizing] = useState(false)
const [isMarkingAllSessionsRead, setIsMarkingAllSessionsRead] = useState(false)
const [showDetailPanel, setShowDetailPanel] = useState(false)
const [showGroupMembersPanel, setShowGroupMembersPanel] = useState(false)
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
@@ -3130,6 +3131,35 @@ function ChatPage(props: ChatPageProps) {
}
}
const handleMarkAllSessionsRead = async () => {
if (isMarkingAllSessionsRead || isLoadingSessions || isRefreshingSessions) return
setIsMarkingAllSessionsRead(true)
setConnectionError(null)
try {
const result = await window.electronAPI.chat.markAllSessionsRead()
if (!result.success) {
setConnectionError(result.error || '一键已读失败')
return
}
const latestSessions = useChatStore.getState().sessions || []
const nextSessions = latestSessions.map((session) => (
session.unreadCount > 0 ? { ...session, unreadCount: 0 } : session
))
setSessions(nextSessions)
sessionsRef.current = nextSessions
const scope = await resolveChatCacheScope()
persistSessionListCache(scope, nextSessions)
await loadSessions({ silent: true })
} catch (e) {
console.error('一键已读失败:', e)
setConnectionError(`一键已读失败: ${String(e)}`)
} finally {
setIsMarkingAllSessionsRead(false)
}
}
// 分批异步加载联系人信息(优化:缓存优先 + 可持续队列 + 首屏优先批次)
const enrichSessionsContactInfo = async (sessions: ChatSession[]) => {
if (Array.isArray(sessions) && sessions.length > 0) {
@@ -6775,6 +6805,15 @@ function ChatPage(props: ChatPageProps) {
<button className="icon-btn refresh-btn" onClick={handleRefresh} disabled={isLoadingSessions || isRefreshingSessions}>
<RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} />
</button>
<button
className="icon-btn refresh-btn mark-read-btn"
onClick={handleMarkAllSessionsRead}
disabled={isMarkingAllSessionsRead || isLoadingSessions || isRefreshingSessions}
title="一键已读"
aria-label="一键已读"
>
{isMarkingAllSessionsRead ? <Loader2 size={16} className="spin" /> : <CheckSquare size={16} />}
</button>
</div>
</div>
{/* 折叠群 header */}
@@ -8370,6 +8409,8 @@ function MessageBubble({
// State variables...
const [imageError, setImageError] = useState(false)
const [imageErrorReason, setImageErrorReason] = useState<string | undefined>(undefined)
const [imageFailureKind, setImageFailureKind] = useState<'not_found' | 'decrypt_failed' | undefined>(undefined)
const [imageLoading, setImageLoading] = useState(false)
const [imageLoaded, setImageLoaded] = useState(false)
const [imageStageLockHeight, setImageStageLockHeight] = useState<number | null>(null)
@@ -8757,7 +8798,11 @@ function MessageBubble({
if (result.success && result.localPath) {
const renderPath = toRenderableImageSrc(result.localPath)
if (!renderPath) {
if (!silent) setImageError(true)
if (!silent) {
setImageError(true)
setImageErrorReason('路径无效')
setImageFailureKind('decrypt_failed')
}
return { success: false }
}
imageDataUrlCache.set(imageCacheKey, renderPath)
@@ -8769,6 +8814,10 @@ function MessageBubble({
setImageHasUpdate(false)
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
return { ...result, localPath: renderPath }
} else if (!silent && result.error) {
setImageError(true)
setImageErrorReason(result.error)
setImageFailureKind(result.failureKind)
}
}
@@ -8785,9 +8834,17 @@ function MessageBubble({
setImageHasUpdate(false)
return { success: true, localPath: dataUrl }
}
if (!silent) setImageError(true)
} catch {
if (!silent) setImageError(true)
if (!silent) {
setImageError(true)
setImageErrorReason('图片数据获取失败')
setImageFailureKind('not_found')
}
} catch (e) {
if (!silent) {
setImageError(true)
setImageErrorReason(e instanceof Error ? e.message : '解密异常')
setImageFailureKind('decrypt_failed')
}
} finally {
if (!silent) setImageLoading(false)
imageDecryptPendingRef.current = false
@@ -9409,8 +9466,14 @@ function MessageBubble({
appMsgTextCache.set(selector, value)
return value
}, [appMsgDoc, appMsgTextCache])
const decodeHtmlEntities = useCallback((text: string): string => {
const textarea = document.createElement('textarea')
textarea.innerHTML = text
return textarea.value
}, [])
const queryPreferredQuotedContent = useCallback((): string => {
if (message.quotedContent) return message.quotedContent
if (message.quotedContent) return decodeHtmlEntities(message.quotedContent)
const candidates = [
'refermsg > selectedcontent',
'refermsg > selectedtext',
@@ -9427,10 +9490,10 @@ function MessageBubble({
]
for (const selector of candidates) {
const value = queryAppMsgText(selector)
if (value) return value
if (value) return decodeHtmlEntities(value)
}
return ''
}, [message.quotedContent, queryAppMsgText])
}, [message.quotedContent, queryAppMsgText, decodeHtmlEntities])
const appMsgThumbRawCandidate = useMemo(() => (
message.linkThumb ||
message.appMsgThumbUrl ||
@@ -9624,7 +9687,7 @@ function MessageBubble({
// 渲染消息内容
const renderContent = () => {
if (isImage) {
return (
const imageContent = (
<div
ref={imageContainerRef}
className={`image-stage ${imageStageLockHeight ? 'locked' : ''}`}
@@ -9636,14 +9699,15 @@ function MessageBubble({
</div>
) : imageError || !imageLocalPath ? (
<button
className={`image-unavailable ${imageClicked ? 'clicked' : ''}`}
className={`image-unavailable ${imageClicked ? 'clicked' : ''} ${imageError ? 'error' : ''}`}
onClick={handleImageClick}
disabled={imageLoading}
type="button"
>
<ImageIcon size={24} />
<span></span>
<span className="image-action">{imageClicked ? '已点击…' : '点击解密'}</span>
<span>{imageError ? '解密失败' : '图片未解密'}</span>
{imageErrorReason && <span className="image-error-reason">{imageErrorReason}</span>}
<span className="image-action">{imageClicked ? '已点击…' : '点击重试'}</span>
</button>
) : (
<>
@@ -9659,6 +9723,8 @@ function MessageBubble({
onLoad={() => {
setImageLoaded(true)
setImageError(false)
setImageErrorReason(undefined)
setImageFailureKind(undefined)
stabilizeImageScrollAfterResize()
releaseImageStageLock()
}}
@@ -9679,13 +9745,24 @@ function MessageBubble({
)}
</div>
)
if (hasQuote) {
return renderBubbleWithQuote(
renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(quotedContent))),
imageContent
)
}
return <div className="bubble-content">{imageContent}</div>
}
// 视频消息
if (isVideo) {
let videoContent: React.ReactNode
// 未进入可视区域时显示占位符
if (!isVideoVisible) {
return (
videoContent = (
<div className="video-placeholder" ref={videoContainerRef as React.RefObject<HTMLDivElement>}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="23 7 16 12 23 17 23 7"></polygon>
@@ -9693,20 +9770,16 @@ function MessageBubble({
</svg>
</div>
)
}
// 加载中
if (videoLoading) {
return (
} else if (videoLoading) {
// 加载中
videoContent = (
<div className="video-loading" ref={videoContainerRef as React.RefObject<HTMLDivElement>}>
<Loader2 size={20} className="spin" />
</div>
)
}
// 视频不存在 - 添加点击重试功能
if (!videoInfo?.exists || !videoInfo.videoUrl) {
return (
} else if (!videoInfo?.exists || !videoInfo.videoUrl) {
// 视频不存在 - 添加点击重试功能
videoContent = (
<button
className={`video-unavailable ${videoClicked ? 'clicked' : ''}`}
ref={videoContainerRef as React.RefObject<HTMLButtonElement>}
@@ -9726,27 +9799,36 @@ function MessageBubble({
<span className="video-action">{videoClicked ? '已点击…' : '点击重试'}</span>
</button>
)
} else {
// 默认显示缩略图,点击打开独立播放窗口
const thumbSrc = videoInfo.thumbUrl || videoInfo.coverUrl
videoContent = (
<div className="video-thumb-wrapper" ref={videoContainerRef as React.RefObject<HTMLDivElement>} onClick={handlePlayVideo}>
{thumbSrc ? (
<img src={thumbSrc} alt="视频缩略图" className="video-thumb" loading="lazy" decoding="async" />
) : (
<div className="video-thumb-placeholder">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="23 7 16 12 23 17 23 7"></polygon>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
</svg>
</div>
)}
<div className="video-play-button">
<Play size={32} fill="white" />
</div>
</div>
)
}
// 默认显示缩略图,点击打开独立播放窗口
const thumbSrc = videoInfo.thumbUrl || videoInfo.coverUrl
return (
<div className="video-thumb-wrapper" ref={videoContainerRef as React.RefObject<HTMLDivElement>} onClick={handlePlayVideo}>
{thumbSrc ? (
<img src={thumbSrc} alt="视频缩略图" className="video-thumb" loading="lazy" decoding="async" />
) : (
<div className="video-thumb-placeholder">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="23 7 16 12 23 17 23 7"></polygon>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
</svg>
</div>
)}
<div className="video-play-button">
<Play size={32} fill="white" />
</div>
</div>
)
if (hasQuote) {
return renderBubbleWithQuote(
renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(quotedContent))),
videoContent
)
}
return <div className="bubble-content">{videoContent}</div>
}
if (isVoice) {
@@ -9834,7 +9916,7 @@ function MessageBubble({
void requestVoiceTranscript()
}
return (
const voiceContent = (
<div className="voice-stack">
<div className={`voice-message ${isVoicePlaying ? 'playing' : ''}`} onClick={handleToggle}>
<button
@@ -9917,6 +9999,15 @@ function MessageBubble({
)}
</div>
)
if (hasQuote) {
return renderBubbleWithQuote(
renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(quotedContent))),
voiceContent
)
}
return <div className="bubble-content">{voiceContent}</div>
}
// 名片消息
@@ -10020,10 +10111,30 @@ function MessageBubble({
return <span className="quoted-type-label">[]</span>
}
// 链接类消息:需区分真正的链接和嵌套引用
// 当一个引用了别的消息的消息被引用B引用AC又引用B那么 B 在 C 的 refermsg 里 type=49
// 与此同时,一个链接的 type 也是 49这可能意味着 49 是一个更高级别的分类
// 因此,不能将 type=49 的引用信息一律视为链接,它也可能是嵌套引用。那么怎么区分呢?
// 答:嵌套引用的 referContent 中 xmlType=57真正的链接 xmlType=49 或 5
// 对于更多层的嵌套引用,微信不会保存所有层的信息,因此和两层的情况差不多
// 注意:需从原始 XML 获取 refermsg > content而非后端处理过的 quotedContent
if (referType === '49') {
try {
const rawReferContent = q('refermsg > content') || ''
const innerDoc = new DOMParser().parseFromString(rawReferContent, 'text/xml')
const innerXmlType = innerDoc.querySelector('appmsg > type')?.textContent?.trim()
if (innerXmlType === '57') {
const innerTitle = innerDoc.querySelector('title')?.textContent?.trim() || ''
if (innerTitle) return <>{renderTextWithEmoji(cleanMessageContent(innerTitle))}</>
}
} catch { /* 解析失败降级 */ }
return <span className="quoted-type-label">[]</span>
}
// 各类型名称映射
const typeLabels: Record<string, string> = {
'3': '图片', '34': '语音', '43': '视频',
'49': '链接', '50': '通话', '10000': '系统消息', '10002': '撤回消息',
'50': '通话', '10000': '系统消息', '10002': '撤回消息',
}
if (referType && typeLabels[referType]) {
return <span className="quoted-type-label">[{typeLabels[referType]}]</span>
@@ -10396,9 +10507,29 @@ function MessageBubble({
} catch { /* 解析失败降级 */ }
return <span className="quoted-type-label">[]</span>
}
// 链接类消息:需区分真正的链接和嵌套引用
// 当一个引用了别的消息的消息被引用B引用AC又引用B那么 B 在 C 的 refermsg 里 type=49
// 与此同时,一个链接的 type 也是 49这可能意味着 49 是一个更高级别的分类
// 因此,不能将 type=49 的引用信息一律视为链接,它也可能是嵌套引用。那么怎么区分呢?
// 答:嵌套引用的 referContent 中 xmlType=57真正的链接 xmlType=49 或 5
// 对于更多层的嵌套引用,微信不会保存所有层的信息,因此和两层的情况差不多
// 注意:需从原始 XML 获取 refermsg > content而非后端处理过的 quotedContent
if (referType === '49') {
try {
const rawReferContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || ''
const innerDoc = new DOMParser().parseFromString(rawReferContent, 'text/xml')
const innerXmlType = innerDoc.querySelector('appmsg > type')?.textContent?.trim()
if (innerXmlType === '57') {
const innerTitle = innerDoc.querySelector('title')?.textContent?.trim() || ''
if (innerTitle) return <>{renderTextWithEmoji(cleanMessageContent(innerTitle))}</>
}
} catch { /* 解析失败降级 */ }
return <span className="quoted-type-label">[]</span>
}
// 各类型名称映射
const typeLabels: Record<string, string> = {
'3': '图片', '34': '语音', '43': '视频',
'49': '链接', '50': '通话', '10000': '系统消息', '10002': '撤回消息',
'50': '通话', '10000': '系统消息', '10002': '撤回消息',
}
if (referType && typeLabels[referType]) {
return <span className="quoted-type-label">[{typeLabels[referType]}]</span>

View File

@@ -0,0 +1,612 @@
.insight-inbox-page {
--insight-panel-width: 360px;
--insight-card-bg: var(--bg-secondary);
display: flex;
height: calc(100% + 48px);
margin: -24px;
overflow: hidden;
background: var(--bg-primary);
color: var(--text-primary);
}
.insight-inbox-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
padding: 18px 24px 14px;
}
.insight-inbox-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 4px 12px;
border-bottom: 1px solid var(--border-color);
}
.insight-inbox-title-block {
min-width: 0;
display: flex;
flex-direction: column;
gap: 7px;
}
.insight-inbox-title-line {
display: flex;
align-items: center;
gap: 10px;
h2 {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
}
}
.insight-inbox-logo {
width: 30px;
height: 30px;
border-radius: 8px;
object-fit: cover;
}
.insight-inbox-stats {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 13px;
color: var(--text-secondary);
span + span::before {
content: '';
display: inline-block;
width: 3px;
height: 3px;
margin-right: 10px;
border-radius: 50%;
background: var(--text-tertiary);
vertical-align: middle;
}
}
.insight-icon-btn,
.insight-action-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease;
&:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
border-color: var(--text-secondary);
}
}
.insight-icon-btn {
width: 40px;
height: 40px;
border-radius: 10px;
}
.insight-action-btn {
width: 30px;
height: 30px;
border-radius: 8px;
&.code {
color: var(--primary);
}
}
.spinning {
animation: insight-spin 0.9s linear infinite;
}
@keyframes insight-spin {
to {
transform: rotate(360deg);
}
}
.insight-focus-bar {
margin: 12px 4px 0;
padding: 9px 12px;
border: 1px solid rgba(91, 147, 144, 0.22);
border-radius: 10px;
background: rgba(91, 147, 144, 0.08);
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
button {
margin-left: auto;
border: none;
background: transparent;
color: var(--primary);
cursor: pointer;
font-size: 13px;
}
}
.insight-record-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px 4px 22px;
}
.insight-date-group {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 18px;
}
.insight-date-label {
position: sticky;
top: 0;
z-index: 1;
width: fit-content;
padding: 5px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--bg-primary) 86%, transparent);
color: var(--text-tertiary);
font-size: 12px;
backdrop-filter: blur(10px);
}
.insight-card {
display: flex;
gap: 14px;
padding: 18px;
border: 1px solid var(--border-color);
border-radius: 14px;
background: var(--insight-card-bg);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
&:hover {
border-color: rgba(91, 147, 144, 0.28);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.07);
}
&.unread {
border-left: 4px solid var(--primary);
}
&.focused {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(91, 147, 144, 0.14), 0 12px 32px rgba(0, 0, 0, 0.08);
}
}
.insight-card-avatar {
flex: 0 0 auto;
}
.insight-card-content {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
.insight-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.insight-recipient {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
}
.insight-recipient-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.insight-recipient-name {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.insight-session-id {
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--text-tertiary);
}
.insight-card-actions {
display: flex;
align-items: center;
gap: 7px;
flex-shrink: 0;
}
.insight-trigger-pill {
padding: 5px 8px;
border-radius: 999px;
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 12px;
white-space: nowrap;
&.silence {
color: #8a5a00;
background: rgba(245, 158, 11, 0.14);
}
&.test {
color: #5b55a0;
background: rgba(99, 102, 241, 0.12);
}
}
.insight-time {
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
}
.insight-body {
margin: 0;
color: var(--text-primary);
font-size: 15px;
line-height: 1.72;
white-space: pre-wrap;
word-break: break-word;
}
.insight-filter-panel {
width: var(--insight-panel-width);
flex-shrink: 0;
padding: 24px 24px 18px;
border-left: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-secondary) 70%, var(--bg-primary));
overflow-y: auto;
}
.insight-filter-header {
margin-bottom: 18px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
}
}
.insight-filter-widget {
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-secondary);
padding: 14px;
margin-bottom: 14px;
}
.insight-widget-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 13px;
font-weight: 700;
margin-bottom: 10px;
}
.insight-widget-count {
margin-left: auto;
color: var(--text-tertiary);
font-weight: 500;
}
.insight-input-wrap {
display: flex;
align-items: center;
gap: 6px;
border-radius: 9px;
background: var(--bg-tertiary);
padding: 0 9px;
input {
min-width: 0;
flex: 1;
height: 38px;
border: none;
outline: none;
background: transparent;
color: var(--text-primary);
font-size: 13px;
}
button {
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
display: inline-flex;
padding: 3px;
}
}
.insight-date-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
button {
height: 34px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-secondary);
cursor: pointer;
font-size: 13px;
&.active {
border-color: var(--primary);
color: var(--primary);
background: rgba(91, 147, 144, 0.08);
}
}
}
.insight-custom-dates {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
margin-top: 10px;
input {
height: 34px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-primary);
padding: 0 10px;
}
}
.contact-filter {
display: flex;
flex-direction: column;
min-height: 260px;
}
.insight-contact-list {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
max-height: 420px;
overflow-y: auto;
}
.insight-contact-row {
width: 100%;
min-height: 42px;
display: flex;
align-items: center;
gap: 9px;
border: none;
border-radius: 9px;
background: transparent;
color: var(--text-secondary);
padding: 7px 8px;
cursor: pointer;
text-align: left;
span {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
strong {
font-size: 12px;
color: var(--text-tertiary);
font-weight: 600;
}
&:hover,
&.active {
background: var(--bg-tertiary);
color: var(--text-primary);
}
&.all {
margin-top: 10px;
}
}
.insight-empty-state {
min-height: 240px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-tertiary);
text-align: center;
strong {
color: var(--text-primary);
font-size: 16px;
}
button {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 8px;
padding: 7px 12px;
cursor: pointer;
}
}
.insight-modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
}
.insight-log-dialog {
width: min(860px, 92vw);
height: min(780px, 84vh);
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: 14px;
background: var(--bg-secondary);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.18);
overflow: hidden;
}
.insight-log-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 16px 18px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
h3 {
margin: 0 0 4px;
font-size: 16px;
}
span {
color: var(--text-secondary);
font-size: 12px;
}
}
.insight-log-actions {
display: flex;
align-items: center;
gap: 8px;
button {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-secondary);
min-height: 32px;
padding: 0 10px;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
&:hover {
color: var(--text-primary);
background: var(--bg-primary);
}
&.close {
width: 32px;
padding: 0;
justify-content: center;
}
}
}
.insight-log-body {
flex: 1;
overflow-y: auto;
padding: 18px;
background: var(--bg-primary);
section {
margin-bottom: 18px;
}
h4 {
margin: 0 0 8px;
color: var(--text-primary);
font-size: 13px;
font-weight: 700;
}
pre {
margin: 0;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
color: var(--text-primary);
font-family: Consolas, Monaco, 'Courier New', monospace;
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
user-select: text;
}
}
.insight-copy-toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%);
z-index: 1100;
padding: 9px 14px;
border-radius: 999px;
background: rgba(30, 30, 30, 0.88);
color: #fff;
font-size: 13px;
}
@media (max-width: 980px) {
.insight-inbox-page {
flex-direction: column;
}
.insight-filter-panel {
width: auto;
border-left: none;
border-top: 1px solid var(--border-color);
max-height: 42%;
}
.insight-card-header,
.insight-card-actions {
align-items: flex-start;
flex-wrap: wrap;
}
}

View File

@@ -0,0 +1,470 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { CalendarDays, Code, Copy, MessageSquare, RefreshCw, Search, Sparkles, X } from 'lucide-react'
import { Avatar } from '../components/Avatar'
import type {
InsightRecord,
InsightRecordContactFacet,
InsightRecordFilters,
InsightRecordListResult,
InsightRecordSummary,
InsightRecordTriggerReason
} from '../types/electron'
import './InsightInboxPage.scss'
const INSIGHT_AVATAR_URL = './assets/insight/AI_Insight.png'
type DateFilterMode = 'all' | 'today' | 'week' | 'custom'
function getStartOfDay(date: Date): number {
const next = new Date(date)
next.setHours(0, 0, 0, 0)
return next.getTime()
}
function getEndOfDay(date: Date): number {
const next = new Date(date)
next.setHours(23, 59, 59, 999)
return next.getTime()
}
function formatDateInput(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function parseDateInput(value: string, endOfDay = false): number | undefined {
if (!value) return undefined
const date = new Date(`${value}T00:00:00`)
if (Number.isNaN(date.getTime())) return undefined
return endOfDay ? getEndOfDay(date) : getStartOfDay(date)
}
function formatRecordTime(timestamp: number): string {
return new Date(timestamp).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
function formatGroupDate(timestamp: number): string {
const date = new Date(timestamp)
const today = new Date()
const yesterday = new Date()
yesterday.setDate(today.getDate() - 1)
if (getStartOfDay(date) === getStartOfDay(today)) return '今天'
if (getStartOfDay(date) === getStartOfDay(yesterday)) return '昨天'
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
}
function getTriggerLabel(reason: InsightRecordTriggerReason): string {
if (reason === 'silence') return '沉默提醒'
if (reason === 'test') return '测试见解'
return '活跃分析'
}
function buildLogText(record: InsightRecord): string {
const log = record.log
return [
`时间:${new Date(record.createdAt).toLocaleString('zh-CN')}`,
`联系人:${record.displayName} (${record.sessionId})`,
`触发类型:${getTriggerLabel(record.triggerReason)}`,
`接口地址:${log.endpoint}`,
`模型:${log.model}`,
`Max Tokens${log.maxTokens}`,
`Temperature${log.temperature}`,
`耗时:${log.durationMs}ms`,
'',
'系统提示词:',
log.systemPrompt,
'',
'用户提示词:',
log.userPrompt,
'',
'模型输出原文:',
log.rawOutput,
'',
'最终见解:',
log.finalInsight
].join('\n')
}
export default function InsightInboxPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const [records, setRecords] = useState<InsightRecordSummary[]>([])
const [contacts, setContacts] = useState<InsightRecordContactFacet[]>([])
const [keyword, setKeyword] = useState('')
const [contactSearch, setContactSearch] = useState('')
const [selectedSessionId, setSelectedSessionId] = useState('')
const [dateMode, setDateMode] = useState<DateFilterMode>('all')
const [customStart, setCustomStart] = useState(formatDateInput(new Date()))
const [customEnd, setCustomEnd] = useState(formatDateInput(new Date()))
const [stats, setStats] = useState({ total: 0, todayCount: 0, unreadCount: 0 })
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [focusedRecordId, setFocusedRecordId] = useState(searchParams.get('recordId') || '')
const [logRecord, setLogRecord] = useState<InsightRecord | null>(null)
const [message, setMessage] = useState('')
const dateRange = useMemo(() => {
const now = new Date()
if (dateMode === 'today') {
return { startTime: getStartOfDay(now), endTime: getEndOfDay(now) }
}
if (dateMode === 'week') {
const start = new Date(now)
start.setDate(now.getDate() - 6)
return { startTime: getStartOfDay(start), endTime: getEndOfDay(now) }
}
if (dateMode === 'custom') {
return {
startTime: parseDateInput(customStart),
endTime: parseDateInput(customEnd, true)
}
}
return {}
}, [customEnd, customStart, dateMode])
const filters = useMemo<InsightRecordFilters>(() => ({
keyword: keyword.trim() || undefined,
sessionId: selectedSessionId || undefined,
startTime: dateRange.startTime,
endTime: dateRange.endTime,
limit: 200,
offset: 0
}), [dateRange.endTime, dateRange.startTime, keyword, selectedSessionId])
const loadRecords = useCallback(async () => {
setLoading(true)
setError('')
try {
const result: InsightRecordListResult = await window.electronAPI.insight.listRecords(filters)
if (!result.success) {
setError(result.error || '加载灵感信箱失败')
return
}
setRecords(result.records)
setContacts(result.contacts)
setStats({
total: result.total,
todayCount: result.todayCount,
unreadCount: result.unreadCount
})
} catch (err) {
setError((err as Error).message || '加载灵感信箱失败')
} finally {
setLoading(false)
}
}, [filters])
useEffect(() => {
void loadRecords()
}, [loadRecords])
useEffect(() => {
const recordId = searchParams.get('recordId') || ''
if (!recordId) return
setFocusedRecordId(recordId)
window.setTimeout(() => {
document.getElementById(`insight-record-${recordId}`)?.scrollIntoView({ block: 'center', behavior: 'smooth' })
}, 120)
void window.electronAPI.insight.markRecordRead(recordId)
}, [searchParams])
const groupedRecords = useMemo(() => {
const groups: Array<{ label: string; records: InsightRecordSummary[] }> = []
for (const record of records) {
const label = formatGroupDate(record.createdAt)
const last = groups[groups.length - 1]
if (last?.label === label) {
last.records.push(record)
} else {
groups.push({ label, records: [record] })
}
}
return groups
}, [records])
const filteredContacts = useMemo(() => {
const normalized = contactSearch.trim().toLowerCase()
if (!normalized) return contacts
return contacts.filter((contact) => {
const text = `${contact.displayName}\n${contact.sessionId}`.toLowerCase()
return text.includes(normalized)
})
}, [contactSearch, contacts])
const openChat = (record: InsightRecordSummary) => {
navigate(`/chat?sessionId=${encodeURIComponent(record.sessionId)}`)
}
const copyText = async (text: string, successText: string) => {
try {
await navigator.clipboard.writeText(text)
setMessage(successText)
window.setTimeout(() => setMessage(''), 1800)
} catch {
setMessage('复制失败')
window.setTimeout(() => setMessage(''), 1800)
}
}
const openLog = async (recordId: string) => {
const result = await window.electronAPI.insight.getRecord(recordId)
if (!result.success || !result.record) {
setMessage(result.error || '读取请求日志失败')
window.setTimeout(() => setMessage(''), 1800)
return
}
setLogRecord(result.record)
void window.electronAPI.insight.markRecordRead(recordId)
setRecords((prev) => prev.map((record) => record.id === recordId ? { ...record, read: true } : record))
}
const clearFocusedRecord = () => {
setFocusedRecordId('')
searchParams.delete('recordId')
setSearchParams(searchParams, { replace: true })
}
return (
<div className="insight-inbox-page">
<section className="insight-inbox-main">
<header className="insight-inbox-header">
<div className="insight-inbox-title-block">
<div className="insight-inbox-title-line">
<img src={INSIGHT_AVATAR_URL} alt="" className="insight-inbox-logo" />
<h2></h2>
</div>
<div className="insight-inbox-stats">
<span> {stats.total} </span>
<span> {stats.todayCount} </span>
<span> {stats.unreadCount} </span>
</div>
</div>
<button className="insight-icon-btn" onClick={() => { void loadRecords() }} title="刷新">
<RefreshCw size={18} className={loading ? 'spinning' : ''} />
</button>
</header>
{focusedRecordId && (
<div className="insight-focus-bar">
<Sparkles size={15} />
<span></span>
<button type="button" onClick={clearFocusedRecord}></button>
</div>
)}
<div className="insight-record-scroll">
{error && (
<div className="insight-empty-state">
<span>{error}</span>
<button onClick={() => { void loadRecords() }}></button>
</div>
)}
{!error && loading && records.length === 0 && (
<div className="insight-empty-state">
<RefreshCw size={18} className="spinning" />
<span>...</span>
</div>
)}
{!error && !loading && records.length === 0 && (
<div className="insight-empty-state">
<Sparkles size={36} />
<strong></strong>
<span>AI </span>
</div>
)}
{groupedRecords.map((group) => (
<div className="insight-date-group" key={group.label}>
<div className="insight-date-label">{group.label}</div>
{group.records.map((record) => (
<article
id={`insight-record-${record.id}`}
key={record.id}
className={`insight-card ${record.read ? '' : 'unread'} ${focusedRecordId === record.id ? 'focused' : ''}`}
>
<div className="insight-card-avatar">
<Avatar src={INSIGHT_AVATAR_URL} name="见解" size={44} shape="rounded" lazy={false} />
</div>
<div className="insight-card-content">
<div className="insight-card-header">
<div className="insight-recipient">
<Avatar src={record.avatarUrl} name={record.displayName} size={28} shape="rounded" />
<div className="insight-recipient-text">
<span className="insight-recipient-name"> {record.displayName}</span>
<span className="insight-session-id">{record.sessionId}</span>
</div>
</div>
<div className="insight-card-actions">
<span className={`insight-trigger-pill ${record.triggerReason}`}>{getTriggerLabel(record.triggerReason)}</span>
<span className="insight-time">{formatRecordTime(record.createdAt)}</span>
<button className="insight-action-btn" onClick={() => openChat(record)} title="打开聊天">
<MessageSquare size={14} />
</button>
<button className="insight-action-btn" onClick={() => { void copyText(record.insight, '见解已复制') }} title="复制见解">
<Copy size={14} />
</button>
<button className="insight-action-btn code" onClick={() => { void openLog(record.id) }} title="查看请求日志">
<Code size={14} />
</button>
</div>
</div>
<p className="insight-body">{record.insight}</p>
</div>
</article>
))}
</div>
))}
</div>
</section>
<aside className="insight-filter-panel">
<div className="insight-filter-header">
<h3></h3>
</div>
<div className="insight-filter-widget">
<div className="insight-widget-title">
<Search size={14} />
<span></span>
</div>
<div className="insight-input-wrap">
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索见解或联系人..."
/>
{keyword && <button onClick={() => setKeyword('')}><X size={14} /></button>}
</div>
</div>
<div className="insight-filter-widget">
<div className="insight-widget-title">
<CalendarDays size={14} />
<span></span>
</div>
<div className="insight-date-tabs">
{[
{ value: 'all', label: '全部' },
{ value: 'today', label: '今天' },
{ value: 'week', label: '近 7 天' },
{ value: 'custom', label: '自定义' }
].map((option) => (
<button
key={option.value}
className={dateMode === option.value ? 'active' : ''}
onClick={() => setDateMode(option.value as DateFilterMode)}
>
{option.label}
</button>
))}
</div>
{dateMode === 'custom' && (
<div className="insight-custom-dates">
<input type="date" value={customStart} onChange={(event) => setCustomStart(event.target.value)} />
<input type="date" value={customEnd} onChange={(event) => setCustomEnd(event.target.value)} />
</div>
)}
</div>
<div className="insight-filter-widget contact-filter">
<div className="insight-widget-title">
<MessageSquare size={14} />
<span></span>
<span className="insight-widget-count">{contacts.length}</span>
</div>
<div className="insight-input-wrap">
<input
value={contactSearch}
onChange={(event) => setContactSearch(event.target.value)}
placeholder="查找联系人..."
/>
{contactSearch && <button onClick={() => setContactSearch('')}><X size={14} /></button>}
</div>
<button
className={`insight-contact-row all ${selectedSessionId ? '' : 'active'}`}
onClick={() => setSelectedSessionId('')}
>
<span></span>
<strong>{contacts.reduce((sum, contact) => sum + contact.count, 0)}</strong>
</button>
<div className="insight-contact-list">
{filteredContacts.map((contact) => (
<button
key={contact.sessionId}
className={`insight-contact-row ${selectedSessionId === contact.sessionId ? 'active' : ''}`}
onClick={() => setSelectedSessionId(contact.sessionId)}
>
<Avatar src={contact.avatarUrl} name={contact.displayName} size={32} shape="rounded" />
<span>{contact.displayName}</span>
<strong>{contact.count}</strong>
</button>
))}
</div>
</div>
</aside>
{logRecord && (
<div className="insight-modal-overlay" onClick={() => setLogRecord(null)}>
<div className="insight-log-dialog" onClick={(event) => event.stopPropagation()}>
<div className="insight-log-header">
<div>
<h3></h3>
<span>{logRecord.displayName} · {formatRecordTime(logRecord.createdAt)}</span>
</div>
<div className="insight-log-actions">
<button onClick={() => { void copyText(buildLogText(logRecord), '请求日志已复制') }}>
<Copy size={15} />
</button>
<button className="close" onClick={() => setLogRecord(null)}>
<X size={18} />
</button>
</div>
</div>
<div className="insight-log-body">
<section>
<h4></h4>
<pre>{[
`Endpoint: ${logRecord.log.endpoint}`,
`Model: ${logRecord.log.model}`,
`Max Tokens: ${logRecord.log.maxTokens}`,
`Temperature: ${logRecord.log.temperature}`,
`Duration: ${logRecord.log.durationMs}ms`,
`Trigger: ${getTriggerLabel(logRecord.triggerReason)}`
].join('\n')}</pre>
</section>
<section>
<h4>System Prompt</h4>
<pre>{logRecord.log.systemPrompt}</pre>
</section>
<section>
<h4>User Prompt</h4>
<pre>{logRecord.log.userPrompt}</pre>
</section>
<section>
<h4></h4>
<pre>{logRecord.log.rawOutput}</pre>
</section>
<section>
<h4></h4>
<pre>{logRecord.log.finalInsight}</pre>
</section>
</div>
</div>
</div>
)}
{message && <div className="insight-copy-toast">{message}</div>}
</div>
)
}

View File

@@ -29,6 +29,9 @@ export default function NotificationWindow() {
const newNoti: NotificationData = {
id: `noti_${timestamp}_${Math.random().toString(36).substr(2, 9)}`,
sessionId: data.sessionId,
channel: data.channel,
insightRecordId: data.insightRecordId,
targetRoute: data.targetRoute,
title: data.title,
content: data.content,
timestamp: timestamp,
@@ -70,8 +73,17 @@ export default function NotificationWindow() {
window.electronAPI.notification?.close()
}
const handleClick = (sessionId: string) => {
window.electronAPI.notification?.click(sessionId)
const handleClick = (data: NotificationData) => {
if (data.channel === 'ai-insight') {
window.electronAPI.notification?.click({
sessionId: data.sessionId,
channel: data.channel,
insightRecordId: data.insightRecordId,
targetRoute: data.targetRoute
})
} else {
window.electronAPI.notification?.click(data.sessionId)
}
setNotification(null)
setPrevNotification(null)
// Main process handles window hide/close

View File

@@ -915,6 +915,31 @@
color: var(--text-secondary);
}
.insight-collapsible-setting {
max-height: 0;
opacity: 0;
overflow: hidden;
transform: translate3d(0, -4px, 0);
contain: layout paint;
will-change: max-height, opacity, transform;
transition: max-height 0.2s ease, opacity 0.18s ease, transform 0.2s ease;
&.expanded {
max-height: 128px;
opacity: 1;
transform: translate3d(0, 0, 0);
}
&.collapsed {
pointer-events: none;
}
}
.insight-collapsible-setting-inner {
padding-top: 2px;
backface-visibility: hidden;
}
/* Premium Switch Style */
.switch {
position: relative;
@@ -3616,17 +3641,35 @@
}
&.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);
.anti-revoke-list-header {
grid-template-columns: minmax(0, 1fr) minmax(300px, 420px) auto;
grid-template-columns: var(--insight-social-list-grid);
gap: 14px;
.insight-moments-column-title {
display: flex;
justify-content: center;
color: var(--text-tertiary);
}
.insight-social-column-title {
min-width: 0;
color: var(--text-tertiary);
}
.anti-revoke-status-column-title {
justify-self: end;
color: var(--text-tertiary);
}
}
.anti-revoke-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(300px, 420px) auto;
grid-template-columns: var(--insight-social-list-grid);
align-items: center;
gap: 14px;
}
@@ -3635,6 +3678,67 @@
min-width: 0;
}
.insight-moments-cell {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 30px;
}
.insight-moments-toggle {
position: relative;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
input[type='checkbox'] {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.check-indicator {
width: 100%;
height: 100%;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--border-color) 78%, var(--primary) 22%);
background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary) 14%);
color: var(--on-primary, #fff);
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.16s ease;
svg {
opacity: 0;
transform: scale(0.75);
transition: opacity 0.16s ease, transform 0.16s ease;
}
}
input[type='checkbox']:checked + .check-indicator {
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
svg {
opacity: 1;
transform: scale(1);
}
}
input[type='checkbox']:focus-visible + .check-indicator {
outline: 2px solid color-mix(in srgb, var(--primary) 42%, transparent);
outline-offset: 1px;
}
}
.insight-social-binding-cell {
min-width: 0;
display: grid;
@@ -3653,7 +3757,7 @@
.binding-platform-chip {
flex-shrink: 0;
border-radius: 999px;
padding: 2px 8px;
padding: 2px 7px;
font-size: 11px;
color: var(--text-secondary);
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
@@ -3663,7 +3767,7 @@
.insight-social-binding-input {
width: 100%;
min-width: 0;
height: 30px;
height: 28px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-primary) 92%, var(--bg-secondary) 8%);
@@ -3706,9 +3810,10 @@
}
.anti-revoke-row-status {
justify-self: flex-end;
justify-self: end;
align-items: flex-end;
max-width: none;
min-width: 0;
}
}
@@ -3752,6 +3857,7 @@
.anti-revoke-list-header {
grid-template-columns: minmax(0, 1fr) auto;
.insight-moments-column-title,
.insight-social-column-title {
display: none;
}
@@ -3763,11 +3869,16 @@
flex-direction: column;
}
.insight-moments-cell,
.insight-social-binding-cell,
.anti-revoke-row-status {
width: 100%;
}
.insight-moments-cell {
justify-content: flex-start;
}
.insight-social-binding-cell {
grid-template-columns: 1fr;
}

View File

@@ -32,6 +32,7 @@ type SettingsTab =
| 'aiCommon'
| 'insight'
| 'aiFootprint'
| 'autoDownload'
const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint'>; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette },
@@ -39,6 +40,7 @@ const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint'>; label: string
{ id: 'antiRevoke', label: '防撤回', icon: RotateCcw },
{ id: 'database', label: '数据库连接', icon: Database },
{ id: 'models', label: '模型管理', icon: Mic },
{ id: 'autoDownload', label: '自动下载', icon: Download },
{ id: 'cache', label: '缓存', icon: HardDrive },
{ id: 'api', label: 'API 服务', icon: Globe },
{ id: 'analytics', label: '分析', icon: BarChart2 },
@@ -47,6 +49,13 @@ const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint'>; label: string
{ id: 'about', label: '关于', icon: Info }
]
const filteredTabs = tabs.filter(tab => {
if (tab.id === 'autoDownload') {
return (window as any).electronAPI.process.platform === 'win32' && (window as any).electronAPI.process.arch === 'x64'
}
return true
})
const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootprint'>; label: string }> = [
{ id: 'aiCommon', label: '基础配置' },
{ id: 'insight', label: 'AI 见解' },
@@ -149,6 +158,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
const [logEnabled, setLogEnabled] = useState(false)
const [autoDownloadHighRes, setAutoDownloadHighRes] = useState(false)
const [whisperModelName, setWhisperModelName] = useState('base')
const [whisperModelDir, setWhisperModelDir] = useState('')
const [isWhisperDownloading, setIsWhisperDownloading] = useState(false)
@@ -189,6 +199,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
const [notificationEnabled, setNotificationEnabled] = useState(true)
const [aiInsightNotificationEnabled, setAiInsightNotificationEnabled] = useState(true)
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'>('top-right')
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
@@ -284,6 +295,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [aiModelApiMaxTokens, setAiModelApiMaxTokens] = useState(200)
const [aiInsightSilenceDays, setAiInsightSilenceDays] = useState(3)
const [aiInsightAllowContext, setAiInsightAllowContext] = useState(false)
const [aiInsightAllowMomentsContext, setAiInsightAllowMomentsContext] = useState(false)
const [aiInsightMomentsContextCount, setAiInsightMomentsContextCount] = useState(5)
const [aiInsightMomentsBindings, setAiInsightMomentsBindings] = useState<Record<string, configService.AiInsightMomentsBinding>>({})
const [isTestingInsight, setIsTestingInsight] = useState(false)
const [insightTestResult, setInsightTestResult] = useState<{ success: boolean; message: string } | null>(null)
const [showInsightApiKey, setShowInsightApiKey] = useState(false)
@@ -313,7 +327,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [weiboBindingLoadingSessionId, setWeiboBindingLoadingSessionId] = useState<string | null>(null)
const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false)
const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('')
const [aiInsightDebugLogEnabled, setAiInsightDebugLogEnabled] = useState(false)
// 自动下载图片
const [autoDownloadStatus, setAutoDownloadStatus] = useState<{ isHooked: boolean; pid: number | null; supported: boolean } | null>(null)
const [autoDownloadSelectedIds, setAutoDownloadSelectedIds] = useState<Set<string>>(new Set())
const [autoDownloadSearchKeyword, setAutoDownloadSearchKeyword] = useState('')
// 检查 Hello 可用性
useEffect(() => {
@@ -440,6 +458,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
const savedTranscribeLanguages = await configService.getTranscribeLanguages()
const savedNotificationEnabled = await configService.getNotificationEnabled()
const savedAiInsightNotificationEnabled = await configService.getAiInsightNotificationEnabled()
const savedNotificationPosition = await configService.getNotificationPosition()
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
const savedNotificationFilterList = await configService.getNotificationFilterList()
@@ -494,6 +513,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setTranscribeLanguages(savedTranscribeLanguages)
setNotificationEnabled(savedNotificationEnabled)
setAiInsightNotificationEnabled(savedAiInsightNotificationEnabled)
setNotificationPosition(savedNotificationPosition)
setNotificationFilterMode(savedNotificationFilterMode)
setNotificationFilterList(savedNotificationFilterList)
@@ -526,9 +546,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setWordCloudExcludeWords(savedExcludeWords)
setExcludeWordsInput(savedExcludeWords.join('\n'))
const savedAutoDownloadHighRes = await configService.getAutoDownloadHighRes()
const savedAutoDownloadWhitelist = await configService.getAutoDownloadWhitelist()
const savedAnalyticsConsent = await configService.getAnalyticsConsent()
setAnalyticsConsent(savedAnalyticsConsent ?? false)
setAutoDownloadHighRes(savedAutoDownloadHighRes)
setAutoDownloadSelectedIds(new Set(savedAutoDownloadWhitelist))
// 如果语言列表为空,保存默认值
@@ -549,6 +572,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedAiModelApiMaxTokens = await configService.getAiModelApiMaxTokens()
const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays()
const savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
const savedAiInsightAllowMomentsContext = await configService.getAiInsightAllowMomentsContext()
const savedAiInsightMomentsContextCount = await configService.getAiInsightMomentsContextCount()
const savedAiInsightMomentsBindings = await configService.getAiInsightMomentsBindings()
const savedAiInsightFilterMode = await configService.getAiInsightFilterMode()
const savedAiInsightFilterList = await configService.getAiInsightFilterList()
const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes()
@@ -564,7 +590,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedAiInsightWeiboBindings = await configService.getAiInsightWeiboBindings()
const savedAiFootprintEnabled = await configService.getAiFootprintEnabled()
const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt()
const savedAiInsightDebugLogEnabled = await configService.getAiInsightDebugLogEnabled()
setAiInsightEnabled(savedAiInsightEnabled)
setAiModelApiBaseUrl(savedAiModelApiBaseUrl)
@@ -573,6 +598,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setAiModelApiMaxTokens(savedAiModelApiMaxTokens)
setAiInsightSilenceDays(savedAiInsightSilenceDays)
setAiInsightAllowContext(savedAiInsightAllowContext)
setAiInsightAllowMomentsContext(savedAiInsightAllowMomentsContext)
setAiInsightMomentsContextCount(savedAiInsightMomentsContextCount)
setAiInsightMomentsBindings(savedAiInsightMomentsBindings)
setAiInsightFilterMode(savedAiInsightFilterMode)
setAiInsightFilterList(new Set(savedAiInsightFilterList))
setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes)
@@ -588,7 +616,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setAiInsightWeiboBindings(savedAiInsightWeiboBindings)
setAiFootprintEnabled(savedAiFootprintEnabled)
setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt)
setAiInsightDebugLogEnabled(savedAiInsightDebugLogEnabled)
} catch (e: any) {
console.error('加载配置失败:', e)
@@ -685,6 +712,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
void refreshWhisperStatus(whisperModelDir)
}, [whisperModelDir])
useEffect(() => {
if (activeTab === 'autoDownload') {
fetchAutoDownloadStatus()
let interval: ReturnType<typeof setInterval> | undefined
if (autoDownloadHighRes) {
interval = setInterval(fetchAutoDownloadStatus, 2000)
}
return () => {
if (interval) clearInterval(interval)
}
}
}, [activeTab, autoDownloadHighRes])
const getErrorMessage = (error: any): string => {
const raw = typeof error?.message === 'string' ? error.message : String(error ?? '')
const normalized = raw.replace(/^Error:\s*/i, '').trim()
@@ -1013,11 +1055,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
}
useEffect(() => {
if (activeTab !== 'antiRevoke' && activeTab !== 'insight') return
if (activeTab !== 'antiRevoke' && activeTab !== 'insight' && activeTab !== 'autoDownload') return
let canceled = false
;(async () => {
try {
if (activeTab === 'antiRevoke') {
if (activeTab === 'antiRevoke' || activeTab === 'autoDownload') {
await ensureAntiRevokeSessionsLoaded()
} else {
await ensureChatSessionsLoaded()
@@ -1579,6 +1621,15 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
}
}
const fetchAutoDownloadStatus = async () => {
try {
const status = await (window as any).electronAPI.image.getAutoDownloadStatus()
setAutoDownloadStatus(status)
} catch (error) {
console.error('获取自动下载状态失败:', error)
}
}
const renderAppearanceTab = () => (
<div className="tab-content">
<div className="theme-mode-toggle">
@@ -1852,6 +1903,29 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div>
</div>
<div className="form-group">
<label>AI </label>
<span className="form-hint"> AI Telegram </span>
<div className="log-toggle-line">
<span className="log-status">{aiInsightNotificationEnabled ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="ai-insight-notification-enabled-toggle">
<input
id="ai-insight-notification-enabled-toggle"
className="switch-input"
type="checkbox"
checked={aiInsightNotificationEnabled}
onChange={async (e) => {
const val = e.target.checked
setAiInsightNotificationEnabled(val)
await configService.setAiInsightNotificationEnabled(val)
showMessage(val ? '已开启 AI 见解消息通知' : '已关闭 AI 见解消息通知', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
@@ -3081,6 +3155,24 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
})
}
const isMomentsEnabledForSession = (sessionId: string): boolean => {
return aiInsightMomentsBindings[sessionId]?.enabled === true
}
const handleToggleMomentsBinding = async (sessionId: string, enabled: boolean) => {
const nextBindings = { ...aiInsightMomentsBindings }
if (enabled) {
nextBindings[sessionId] = {
enabled: true,
updatedAt: Date.now()
}
} else {
delete nextBindings[sessionId]
}
setAiInsightMomentsBindings(nextBindings)
await configService.setAiInsightMomentsBindings(nextBindings)
}
const handleSaveWeiboBinding = async (sessionId: string, displayName: string) => {
const draftUid = getWeiboBindingDraftValue(sessionId)
setWeiboBindingLoadingSessionId(sessionId)
@@ -3140,7 +3232,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="form-group">
<label>AI </label>
<span className="form-hint">
AI
AI
</span>
<div className="log-toggle-line">
<span className="log-status">{aiInsightEnabled ? '已开启' : '已关闭'}</span>
@@ -3274,7 +3366,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span className="form-hint">
N AI
<br />
<strong></strong>AI
<strong></strong>
<br />
<strong></strong> API
</span>
@@ -3295,27 +3387,79 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div>
</div>
{aiInsightAllowContext && (
<div className="form-group">
<label></label>
<span className="form-hint">
AI token
</span>
<input
type="number"
className="field-input"
value={aiInsightContextCount}
min={1}
max={200}
onChange={(e) => {
const val = Math.max(1, Math.min(200, parseInt(e.target.value, 10) || 40))
setAiInsightContextCount(val)
scheduleConfigSave('aiInsightContextCount', () => configService.setAiInsightContextCount(val))
}}
style={{ width: 100 }}
/>
<div className={`insight-collapsible-setting ${aiInsightAllowContext ? 'expanded' : 'collapsed'}`} aria-hidden={!aiInsightAllowContext}>
<div className="insight-collapsible-setting-inner">
<div className="form-group">
<label></label>
<span className="form-hint">
AI token
</span>
<input
type="number"
className="field-input"
value={aiInsightContextCount}
min={1}
max={200}
disabled={!aiInsightAllowContext}
onChange={(e) => {
const val = Math.max(1, Math.min(200, parseInt(e.target.value, 10) || 40))
setAiInsightContextCount(val)
scheduleConfigSave('aiInsightContextCount', () => configService.setAiInsightContextCount(val))
}}
style={{ width: 100 }}
/>
</div>
</div>
)}
</div>
<div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint">
</span>
<div className="log-toggle-line">
<span className="log-status">{aiInsightAllowMomentsContext ? '已开启' : '已关闭'}</span>
<label className="switch">
<input
type="checkbox"
checked={aiInsightAllowMomentsContext}
onChange={async (e) => {
const val = e.target.checked
setAiInsightAllowMomentsContext(val)
await configService.setAiInsightAllowMomentsContext(val)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className={`insight-collapsible-setting ${aiInsightAllowMomentsContext ? 'expanded' : 'collapsed'}`} aria-hidden={!aiInsightAllowMomentsContext}>
<div className="insight-collapsible-setting-inner">
<div className="form-group">
<label></label>
<span className="form-hint">
AI token
</span>
<input
type="number"
className="field-input"
value={aiInsightMomentsContextCount}
min={1}
max={20}
disabled={!aiInsightAllowMomentsContext}
onChange={(e) => {
const val = Math.max(1, Math.min(20, parseInt(e.target.value, 10) || 5))
setAiInsightMomentsContextCount(val)
scheduleConfigSave('aiInsightMomentsContextCount', () => configService.setAiInsightMomentsContextCount(val))
}}
style={{ width: 100 }}
/>
</div>
</div>
</div>
<div className="divider" />
@@ -3354,29 +3498,32 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
)}
</div>
{aiInsightAllowSocialContext && (
<div className="form-group">
<label></label>
<span className="form-hint">
<br />
<strong> 5</strong>
</span>
<input
type="number"
className="field-input"
value={aiInsightSocialContextCount}
min={1}
max={5}
onChange={(e) => {
const val = Math.max(1, Math.min(5, parseInt(e.target.value, 10) || 3))
setAiInsightSocialContextCount(val)
scheduleConfigSave('aiInsightSocialContextCount', () => configService.setAiInsightSocialContextCount(val))
}}
style={{ width: 100 }}
/>
<div className={`insight-collapsible-setting ${aiInsightAllowSocialContext ? 'expanded' : 'collapsed'}`} aria-hidden={!aiInsightAllowSocialContext}>
<div className="insight-collapsible-setting-inner">
<div className="form-group">
<label></label>
<span className="form-hint">
<br />
<strong> 5</strong>
</span>
<input
type="number"
className="field-input"
value={aiInsightSocialContextCount}
min={1}
max={5}
disabled={!aiInsightAllowSocialContext}
onChange={(e) => {
const val = Math.max(1, Math.min(5, parseInt(e.target.value, 10) || 3))
setAiInsightSocialContextCount(val)
scheduleConfigSave('aiInsightSocialContextCount', () => configService.setAiInsightSocialContextCount(val))
}}
style={{ width: 100 }}
/>
</div>
</div>
)}
</div>
<div className="divider" />
{/* 自定义 System Prompt */}
@@ -3652,11 +3799,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<>
<div className="anti-revoke-list-header">
<span>{filteredSessions.length}</span>
<span className="insight-moments-column-title"></span>
<span className="insight-social-column-title"></span>
<span></span>
<span className="anti-revoke-status-column-title"></span>
</div>
{filteredSessions.map((session) => {
const isSelected = aiInsightFilterList.has(session.username)
const isPrivateSession = session.type === 'private'
const isMomentsEnabled = isMomentsEnabledForSession(session.username)
const weiboBinding = aiInsightWeiboBindings[session.username]
const weiboDraftValue = getWeiboBindingDraftValue(session.username)
const isBindingLoading = weiboBindingLoadingSessionId === session.username
@@ -3695,8 +3845,24 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span className="desc">{getSessionFilterTypeLabel(session.type)}</span>
</div>
</label>
<div className="insight-moments-cell">
{isPrivateSession ? (
<label className="insight-moments-toggle">
<input
type="checkbox"
checked={isMomentsEnabled}
onChange={(e) => { void handleToggleMomentsBinding(session.username, e.target.checked) }}
/>
<span className="check-indicator" aria-hidden="true">
<Check size={12} />
</span>
</label>
) : (
<span className="binding-feedback muted">-</span>
)}
</div>
<div className="insight-social-binding-cell">
{session.type === 'private' ? (
{isPrivateSession ? (
<>
<div className="insight-social-binding-input-wrap">
<span className="binding-platform-chip"></span>
@@ -3771,41 +3937,15 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="api-docs">
<div className="api-item">
<p className="api-desc" style={{ lineHeight: 1.7 }}>
<strong></strong> 500ms <br />
<strong></strong> 2 <br />
<strong></strong> 4 <br />
<strong></strong> AI AI <br />
<strong></strong> <br />
<strong></strong> API WeFlow
</p>
</div>
</div>
</div>
<div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint">
AI <code>weflow-ai-insight-debug-YYYY-MM-DD.log</code>
AI API Key
</span>
<div className="log-toggle-line">
<span className="log-status">{aiInsightDebugLogEnabled ? '已开启' : '已关闭'}</span>
<label className="switch">
<input
type="checkbox"
checked={aiInsightDebugLogEnabled}
onChange={async (e) => {
const val = e.target.checked
setAiInsightDebugLogEnabled(val)
await configService.setAiInsightDebugLogEnabled(val)
showMessage(val ? '已开启 AI 见解调试日志,后续日志将写入桌面' : '已关闭 AI 见解调试日志', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
</div>
)
@@ -4557,6 +4697,203 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div>
)
const renderAutoDownloadTab = () => {
const sortedSessions = [...antiRevokeSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
const keyword = autoDownloadSearchKeyword.trim().toLowerCase()
const filteredSessions = sortedSessions.filter((session) => {
if (!keyword) return true
const displayName = String(session.displayName || '').toLowerCase()
const username = String(session.username || '').toLowerCase()
return displayName.includes(keyword) || username.includes(keyword)
})
const filteredSessionIds = filteredSessions.map((session) => session.username)
const selectedCount = autoDownloadSelectedIds.size
const selectedInFilteredCount = filteredSessionIds.filter((id) => autoDownloadSelectedIds.has(id)).length
const allFilteredSelected = filteredSessionIds.length > 0 && selectedInFilteredCount === filteredSessionIds.length
const isHooked = autoDownloadStatus?.isHooked
const persistWhitelist = (ids: Set<string>) => {
const whitelistArr = Array.from(ids)
configService.setAutoDownloadWhitelist(whitelistArr)
if (autoDownloadHighRes) {
const whitelistStr = whitelistArr.length > 0 ? (whitelistArr.join('\0') + '\0\0') : '';
(window as any).electronAPI.image.startAutoDownload(whitelistStr)
}
}
const toggleSelection = (id: string) => {
const next = new Set(autoDownloadSelectedIds)
if (next.has(id)) next.delete(id)
else next.add(id)
setAutoDownloadSelectedIds(next)
persistWhitelist(next)
}
const selectAllFiltered = () => {
const next = new Set(autoDownloadSelectedIds)
filteredSessionIds.forEach(id => next.add(id))
setAutoDownloadSelectedIds(next)
persistWhitelist(next)
}
const clearSelection = () => {
const next = new Set<string>()
setAutoDownloadSelectedIds(next)
persistWhitelist(next)
}
return (
<div className="tab-content anti-revoke-tab">
{/* 顶部 Hero 区域保持不变 */}
<div className="anti-revoke-hero" style={{ background: 'linear-gradient(110deg, var(--bg-primary) 0%, rgba(245, 158, 11, 0.1) 100%)', borderColor: 'rgba(245, 158, 11, 0.3)' }}>
<div className="anti-revoke-hero-main">
<span className="updates-chip" style={{ color: '#f59e0b', background: 'rgba(245, 158, 11, 0.15)', width: 'fit-content' }}> (Test)</span>
<h2 style={{ marginTop: '8px' }}></h2>
<p></p>
</div>
<div className="anti-revoke-metrics">
<div className={`anti-revoke-metric ${isHooked ? 'is-installed' : 'is-pending'}`}>
<span className="label"></span>
<span className="value" style={{ fontSize: '14px' }}>
{isHooked ? '正在监控' : autoDownloadHighRes ? '等待连接' : '未启用'}
</span>
</div>
<div className="anti-revoke-metric">
<span className="label"></span>
<span className="value">{selectedCount}</span>
</div>
</div>
</div>
<div className="anti-revoke-control-card">
<div className="anti-revoke-toolbar">
<div className="filter-search-box anti-revoke-search">
<Search size={14} />
<input
type="text"
placeholder="搜索联系人或群聊..."
value={autoDownloadSearchKeyword}
onChange={(e) => setAutoDownloadSearchKeyword(e.target.value)}
/>
</div>
<div className="anti-revoke-toolbar-actions">
<div className="anti-revoke-btn-group">
<button className="btn btn-secondary btn-sm" onClick={selectAllFiltered} disabled={filteredSessionIds.length === 0 || allFilteredSelected}>
</button>
<button className="btn btn-secondary btn-sm" onClick={clearSelection} disabled={selectedCount === 0}>
</button>
</div>
<div className="anti-revoke-btn-group" style={{ marginLeft: '12px', paddingLeft: '12px', borderLeft: '1px solid var(--border-color)' }}>
<label className="switch switch-md">
<input
type="checkbox"
checked={autoDownloadHighRes}
onChange={() => handleToggleAutoDownload(Array.from(autoDownloadSelectedIds))}
/>
<span className="switch-slider" />
</label>
<span style={{ fontSize: '12px', color: 'var(--text-secondary)', marginLeft: '8px' }}>
{autoDownloadHighRes ? '服务已开启' : '服务已关闭'}
</span>
</div>
</div>
</div>
<div className="anti-revoke-batch-actions">
<div className="anti-revoke-selected-count">
<span> <strong>{selectedCount}</strong> </span>
<span style={{ opacity: 0.6 }}></span>
</div>
</div>
</div>
<div className="anti-revoke-list">
<div className="anti-revoke-list-header">
<span>{filteredSessions.length}</span>
<span></span>
</div>
{filteredSessions.length === 0 ? (
<div className="anti-revoke-empty">{autoDownloadSearchKeyword ? '没有匹配的会话' : '暂无会话'}</div>
) : (
filteredSessions.map((session) => {
const isSelected = autoDownloadSelectedIds.has(session.username)
return (
<div key={session.username} className={`anti-revoke-row ${isSelected ? 'selected' : ''}`}>
<label className="anti-revoke-row-main">
<span className="anti-revoke-check">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelection(session.username)}
/>
<span className="check-indicator" aria-hidden="true">
<Check size={12} />
</span>
</span>
<Avatar src={session.avatarUrl} name={session.displayName} size={30} />
<div className="anti-revoke-row-text">
<span className="name">{session.displayName || session.username}</span>
</div>
</label>
<div className="anti-revoke-row-status">
<span className={`status-badge ${isSelected ? 'installed' : 'not-installed'}`}>
<i className="status-dot" aria-hidden="true" />
{isSelected ? '已监控' : '未开启'}
</span>
</div>
</div>
)
})
)}
</div>
{/* 风险提示部分保持不变 */}
<div className="api-warning-modal" style={{ width: '100%', border: '1px solid rgba(239, 68, 68, 0.2)', marginTop: '16px', background: 'rgba(239, 68, 68, 0.02)', animation: 'none', boxShadow: 'none', position: 'static' }}>
<div className="modal-header" style={{ border: 'none', padding: '12px 20px 0' }}>
<Lock size={16} color="#ef4444" />
<h3 style={{ fontSize: '13px', color: '#ef4444' }}></h3>
</div>
<div className="modal-body" style={{ fontSize: '12px', color: 'var(--text-secondary)', padding: '8px 20px 12px' }}>
Hook
</div>
</div>
</div>
)
}
const handleToggleAutoDownload = async (whitelist?: string[] | string) => {
const newVal = !autoDownloadHighRes
setAutoDownloadHighRes(newVal)
try {
if (newVal) {
let currentWhitelist: string[] | string = whitelist || Array.from(autoDownloadSelectedIds)
if (Array.isArray(currentWhitelist)) {
currentWhitelist = currentWhitelist.length > 0 ? (currentWhitelist.join('\0') + '\0\0') : ''
}
const result = await (window as any).electronAPI.image.startAutoDownload(currentWhitelist)
if (result && !result.success) {
// 如果底层明确返回了失败
throw new Error(result.error || '启动自动下载服务失败')
}
showMessage('自动下载已开启,正在尝试连接微信', true)
await fetchAutoDownloadStatus()
} else {
await (window as any).electronAPI.image.stopAutoDownload()
showMessage('自动下载已关闭', true)
setAutoDownloadStatus(null)
}
await configService.setAutoDownloadHighRes(newVal)
} catch (e: any) {
// 发生错误时,将开关拨回去
setAutoDownloadHighRes(!newVal)
showMessage(`操作失败: ${e.message || String(e)}`, false)
}
}
const renderUpdatesTab = () => {
const downloadPercent = Math.max(0, Math.min(100, Number(downloadProgress?.percent || 0)))
const channelCards: { id: configService.UpdateChannel; title: string; desc: string }[] = [
@@ -4691,7 +5028,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="settings-layout">
<div className="settings-tabs" role="tablist" aria-label="设置项">
{tabs.flatMap((tab) => {
{filteredTabs.flatMap((tab) => {
const row: React.ReactNode[] = [
<button
key={tab.id}
@@ -4749,6 +5086,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{activeTab === 'aiCommon' && renderAiCommonTab()}
{activeTab === 'insight' && renderInsightTab()}
{activeTab === 'aiFootprint' && renderAiFootprintTab()}
{activeTab === 'autoDownload' && renderAutoDownloadTab()}
{activeTab === 'updates' && renderUpdatesTab()}
{activeTab === 'analytics' && renderAnalyticsTab()}
{activeTab === 'security' && renderSecurityTab()}

View File

@@ -2015,6 +2015,7 @@
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
width: 480px;
max-width: 92vw;
max-height: calc(100vh - 80px);
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
@@ -2062,6 +2063,9 @@
display: flex;
flex-direction: column;
gap: 18px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
}

View File

@@ -1038,7 +1038,13 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
className="field-input"
placeholder="64 位十六进制密钥"
value={decryptKey}
onChange={(e) => setDecryptKey(e.target.value.trim())}
onChange={(e) => {
const value = e.target.value.trim()
setDecryptKey(value)
if (value.length === 64) {
setHasReacquiredDbKey(true)
}
}}
/>
<button type="button" className="toggle-btn" onClick={() => setShowDecryptKey(!showDecryptKey)}>
{showDecryptKey ? <EyeOff size={16} /> : <Eye size={16} />}
@@ -1171,7 +1177,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
)}
<div className="field-hint" style={{ marginTop: '8px' }}>
+ 使
"缓存计算 + 本地校验通过"使
</div>
{isImageKeyVerified && (
<div className="status-message is-success" style={{ marginTop: '8px' }}>

View File

@@ -66,6 +66,7 @@ export const CONFIG_KEYS = {
// 通知
NOTIFICATION_ENABLED: 'notificationEnabled',
AI_INSIGHT_NOTIFICATION_ENABLED: 'aiInsightNotificationEnabled',
NOTIFICATION_POSITION: 'notificationPosition',
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
@@ -97,6 +98,9 @@ export const CONFIG_KEYS = {
AI_INSIGHT_API_MODEL: 'aiInsightApiModel',
AI_INSIGHT_SILENCE_DAYS: 'aiInsightSilenceDays',
AI_INSIGHT_ALLOW_CONTEXT: 'aiInsightAllowContext',
AI_INSIGHT_ALLOW_MOMENTS_CONTEXT: 'aiInsightAllowMomentsContext',
AI_INSIGHT_MOMENTS_CONTEXT_COUNT: 'aiInsightMomentsContextCount',
AI_INSIGHT_MOMENTS_BINDINGS: 'aiInsightMomentsBindings',
AI_INSIGHT_ALLOW_SOCIAL_CONTEXT: 'aiInsightAllowSocialContext',
AI_INSIGHT_FILTER_MODE: 'aiInsightFilterMode',
AI_INSIGHT_FILTER_LIST: 'aiInsightFilterList',
@@ -116,7 +120,9 @@ export const CONFIG_KEYS = {
// AI 足迹
AI_FOOTPRINT_ENABLED: 'aiFootprintEnabled',
AI_FOOTPRINT_SYSTEM_PROMPT: 'aiFootprintSystemPrompt',
AI_INSIGHT_DEBUG_LOG_ENABLED: 'aiInsightDebugLogEnabled'
AI_INSIGHT_DEBUG_LOG_ENABLED: 'aiInsightDebugLogEnabled',
AUTO_DOWNLOAD_HIGH_RES: 'autoDownloadHighRes',
AUTO_DOWNLOAD_WHITELIST: 'autoDownloadWhitelist'
} as const
export interface WxidConfig {
@@ -132,6 +138,11 @@ export interface AiInsightWeiboBinding {
updatedAt: number
}
export interface AiInsightMomentsBinding {
enabled: boolean
updatedAt: number
}
export interface ExportDefaultMediaConfig {
images: boolean
videos: boolean
@@ -1667,6 +1678,15 @@ export async function setNotificationEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.NOTIFICATION_ENABLED, enabled)
}
export async function getAiInsightNotificationEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_NOTIFICATION_ENABLED)
return value !== false
}
export async function setAiInsightNotificationEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_NOTIFICATION_ENABLED, enabled)
}
// 获取通知位置
export async function getNotificationPosition(): Promise<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'> {
const value = await config.get(CONFIG_KEYS.NOTIFICATION_POSITION)
@@ -1922,6 +1942,24 @@ export async function setAiInsightAllowContext(allow: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_CONTEXT, allow)
}
export async function getAiInsightAllowMomentsContext(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ALLOW_MOMENTS_CONTEXT)
return value === true
}
export async function setAiInsightAllowMomentsContext(allow: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_MOMENTS_CONTEXT, allow)
}
export async function getAiInsightMomentsContextCount(): Promise<number> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_MOMENTS_CONTEXT_COUNT)
return typeof value === 'number' && value > 0 ? value : 5
}
export async function setAiInsightMomentsContextCount(count: number): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_MOMENTS_CONTEXT_COUNT, count)
}
export async function getAiInsightAllowSocialContext(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ALLOW_SOCIAL_CONTEXT)
return value === true
@@ -2067,6 +2105,33 @@ export async function setAiInsightWeiboBindings(bindings: Record<string, AiInsig
await config.set(CONFIG_KEYS.AI_INSIGHT_WEIBO_BINDINGS, bindings)
}
const normalizeAiInsightMomentsBindings = (value: unknown): Record<string, AiInsightMomentsBinding> => {
if (!value || typeof value !== 'object') return {}
const result: Record<string, AiInsightMomentsBinding> = {}
for (const [sessionIdRaw, bindingRaw] of Object.entries(value as Record<string, unknown>)) {
const sessionId = String(sessionIdRaw || '').trim()
if (!sessionId) continue
if (!bindingRaw || typeof bindingRaw !== 'object') continue
const bindingObj = bindingRaw as { enabled?: unknown; updatedAt?: unknown }
if (bindingObj.enabled !== true) continue
const updatedAtRaw = Number(bindingObj.updatedAt)
result[sessionId] = {
enabled: true,
updatedAt: Number.isFinite(updatedAtRaw) && updatedAtRaw > 0 ? Math.floor(updatedAtRaw) : Date.now()
}
}
return result
}
export async function getAiInsightMomentsBindings(): Promise<Record<string, AiInsightMomentsBinding>> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_MOMENTS_BINDINGS)
return normalizeAiInsightMomentsBindings(value)
}
export async function setAiInsightMomentsBindings(bindings: Record<string, AiInsightMomentsBinding>): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_MOMENTS_BINDINGS, normalizeAiInsightMomentsBindings(bindings))
}
export async function getAiFootprintEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_FOOTPRINT_ENABLED)
return value === true
@@ -2094,3 +2159,22 @@ export async function setAiInsightDebugLogEnabled(enabled: boolean): Promise<voi
await config.set(CONFIG_KEYS.AI_INSIGHT_DEBUG_LOG_ENABLED, enabled)
}
export async function getAutoDownloadHighRes(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AUTO_DOWNLOAD_HIGH_RES)
return value === true
}
export async function setAutoDownloadHighRes(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AUTO_DOWNLOAD_HIGH_RES, enabled)
}
export async function getAutoDownloadWhitelist(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.AUTO_DOWNLOAD_WHITELIST)
return Array.isArray(value) ? value : []
}
export async function setAutoDownloadWhitelist(list: string[]): Promise<void> {
const normalized = Array.from(new Set((list || []).map(item => String(item || '').trim()).filter(Boolean)))
await config.set(CONFIG_KEYS.AUTO_DOWNLOAD_WHITELIST, normalized)
}

View File

@@ -21,6 +21,72 @@ export interface SocialSaveWeiboCookieResult {
error?: string
}
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test'
export interface InsightRecordLog {
endpoint: string
model: string
maxTokens: number
temperature: number
triggerReason: InsightRecordTriggerReason
allowContext: boolean
contextCount: number
systemPrompt: string
userPrompt: string
rawOutput: string
finalInsight: string
durationMs: number
createdAt: number
}
export interface InsightRecordSummary {
id: string
createdAt: number
sessionId: string
displayName: string
avatarUrl?: string
triggerReason: InsightRecordTriggerReason
insight: string
read: boolean
}
export interface InsightRecord extends InsightRecordSummary {
accountScope: string
log: InsightRecordLog
}
export interface InsightRecordContactFacet {
sessionId: string
displayName: string
avatarUrl?: string
count: number
}
export interface InsightRecordFilters {
keyword?: string
sessionId?: string
startTime?: number
endTime?: number
limit?: number
offset?: number
}
export interface InsightRecordListResult {
success: boolean
records: InsightRecordSummary[]
total: number
todayCount: number
unreadCount: number
contacts: InsightRecordContactFacet[]
error?: string
}
export interface InsightRecordResult {
success: boolean
record?: InsightRecord
error?: string
}
export interface BackupProgress {
phase: 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed'
message: string
@@ -167,13 +233,14 @@ export interface ElectronAPI {
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
}
notification: {
show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string }) => Promise<{ success?: boolean; error?: string } | void>
show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string; channel?: string; insightRecordId?: string; targetRoute?: string }) => Promise<{ success?: boolean; error?: string } | void>
close: () => Promise<void>
click: (sessionId: string) => void
click: (payload: string | { sessionId?: string; channel?: string; insightRecordId?: string; targetRoute?: string }) => void
ready: () => void
resize: (width: number, height: number) => void
onShow: (callback: (event: any, data: any) => void) => () => void
onNavigateToSession: (callback: (sessionId: string) => void) => () => void
onNavigateToRoute: (callback: (route: string) => void) => () => void
}
log: {
getPath: () => Promise<string>
@@ -271,6 +338,7 @@ export interface ElectronAPI {
chat: {
connect: () => Promise<{ success: boolean; error?: string }>
getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
markAllSessionsRead: () => Promise<{ success: boolean; error?: string }>
getAntiRevokeSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
getSessionStatuses: (usernames: string[]) => Promise<{
success: boolean
@@ -1234,6 +1302,10 @@ export interface ElectronAPI {
insight: {
testConnection: () => Promise<{ success: boolean; message: string }>
getTodayStats: () => Promise<Array<{ sessionId: string; count: number; times: string[] }>>
listRecords: (filters?: InsightRecordFilters) => Promise<InsightRecordListResult>
getRecord: (id: string) => Promise<InsightRecordResult>
markRecordRead: (id: string) => Promise<{ success: boolean; error?: string }>
clearRecords: (filters?: InsightRecordFilters) => Promise<{ success: boolean; removed: number; error?: string }>
triggerTest: () => Promise<{ success: boolean; message: string }>
generateFootprintInsight: (payload: {
rangeLabel: string

View File

@@ -8,6 +8,77 @@ const handleElectronOnStart = (options: { reload: () => void }) => {
options.reload()
}
const exportWorkerElectronShimPlugin = () => {
const virtualId = 'virtual:weflow-export-worker-electron'
const resolvedVirtualId = `\0${virtualId}`
return {
name: 'weflow-export-worker-electron-shim',
enforce: 'pre' as const,
resolveId(id: string) {
if (id === virtualId) return resolvedVirtualId
return null
},
load(id: string) {
if (id !== resolvedVirtualId) return null
return `
import { homedir, tmpdir } from 'os'
import { join } from 'path'
const workerUserDataPath = () => String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
const appDataPath = () => {
if (process.platform === 'win32' && process.env.APPDATA) return process.env.APPDATA
if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support')
return process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
}
const getPath = (name) => {
if (name === 'userData') return workerUserDataPath() || join(appDataPath(), 'WeFlow')
if (name === 'documents') return join(homedir(), 'Documents')
if (name === 'desktop') return join(homedir(), 'Desktop')
if (name === 'downloads') return join(homedir(), 'Downloads')
if (name === 'temp') return tmpdir()
if (name === 'appData') return appDataPath()
return process.cwd()
}
export const app = {
isPackaged: Boolean(process.resourcesPath && process.env.NODE_ENV !== 'development'),
getPath,
getAppPath: () => process.cwd(),
getName: () => 'WeFlow',
getVersion: () => process.env.npm_package_version || '0.0.0'
}
export const BrowserWindow = { getAllWindows: () => [] }
export const dialog = { showMessageBox: async () => ({ response: 0, checkboxChecked: false }) }
export const shell = { openExternal: async () => false, showItemInFolder: () => {} }
export const ipcMain = { on: () => {}, handle: () => {}, removeHandler: () => {} }
export const ipcRenderer = { sendSync: () => ({}) }
export const safeStorage = {
isEncryptionAvailable: () => false,
encryptString: (value) => Buffer.from(String(value || ''), 'utf8'),
decryptString: (value) => Buffer.isBuffer(value) ? value.toString('utf8') : Buffer.from(value).toString('utf8')
}
export const Notification = class {
static isSupported() { return false }
on() { return this }
show() {}
close() {}
}
export default { app, BrowserWindow, dialog, shell, ipcMain, ipcRenderer, safeStorage, Notification }
`
},
transform(code: string, id: string) {
if (!/\.[cm]?[jt]s$/.test(id)) return null
if (!code.includes("'electron'") && !code.includes('"electron"')) return null
const next = code
.replace(/from\s+(['"])electron\1/g, `from '${virtualId}'`)
.replace(/import\s*\(\s*(['"])electron\1\s*\)/g, `import('${virtualId}')`)
.replace(/require\s*\(\s*(['"])electron\1\s*\)/g, `require('${virtualId}')`)
return next === code ? null : { code: next, map: null }
}
}
}
export default defineConfig({
base: './',
server: {
@@ -142,6 +213,7 @@ export default defineConfig({
entry: 'electron/exportWorker.ts',
onstart: handleElectronOnStart,
vite: {
plugins: [exportWorkerElectronShimPlugin()],
build: {
outDir: 'dist-electron',
rollupOptions: {