mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
794880d389 | ||
|
|
d0ceeceb68 | ||
|
|
62f729d281 | ||
|
|
0d544275c0 | ||
|
|
a91b4d7a56 | ||
|
|
f9b567fba2 | ||
|
|
1f693aa3d7 | ||
|
|
8e1e166856 | ||
|
|
1275c512c4 |
@@ -9,7 +9,7 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
||||
|
||||
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
|
||||
|
||||
> 当前版本:`v2.1.0`。macOS 支持相对稳定;Windows 已初步支持微信 4.0 数据库连接、自动获取密钥和聊天记录查看,仍在持续兼容不同微信版本与本地目录结构。
|
||||
> 当前版本:`v2.1.2`。macOS 支持相对稳定;Windows 已初步支持微信 4.0 数据库连接、自动获取密钥和聊天记录查看,仍在持续兼容不同微信版本与本地目录结构。
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
@@ -197,3 +197,9 @@ curl -G "http://127.0.0.1:6131/api/v1/resolve" \
|
||||
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
|
||||
- [WeFlow](https://github.com/hicccc77/WeFlow)
|
||||
- [chatlog](https://github.com/sjzar/chatlog)
|
||||
|
||||
## 📱 交流与反馈
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/二维码.jpg" alt="WechatExplorer 交流二维码" width="280" />
|
||||
</p>
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wechatexplorer",
|
||||
"version": "2.1.2",
|
||||
"version": "2.1.4",
|
||||
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
|
||||
"keywords": [
|
||||
"wechat",
|
||||
@@ -44,11 +44,13 @@
|
||||
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"@koromix/koffi-win32-x64": "3.1.0",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@koromix/koffi-win32-x64": "3.1.0",
|
||||
"@tanstack/react-virtual": "^3.14.6",
|
||||
"fs-extra": "^11.3.2",
|
||||
"fzstd": "^0.1.1",
|
||||
"jsonrepair": "^3.15.0",
|
||||
"koffi": "^3.1.0",
|
||||
"openai": "^6.10.0",
|
||||
"silk-wasm": "^3.7.1",
|
||||
|
||||
Generated
+24
-3
@@ -12,6 +12,7 @@ specifiers:
|
||||
'@electron-toolkit/utils': ^4.0.0
|
||||
'@koromix/koffi-win32-x64': 3.1.0
|
||||
'@rollup/rollup-darwin-arm64': ^4.62.2
|
||||
'@tanstack/react-virtual': ^3.14.6
|
||||
'@types/fs-extra': ^11.0.4
|
||||
'@types/node': ^22.19.1
|
||||
'@types/react': ^19.2.7
|
||||
@@ -26,6 +27,7 @@ specifiers:
|
||||
eslint-plugin-react-refresh: ^0.4.24
|
||||
fs-extra: ^11.3.2
|
||||
fzstd: ^0.1.1
|
||||
jsonrepair: ^3.15.0
|
||||
koffi: ^3.1.0
|
||||
openai: ^6.10.0
|
||||
prettier: ^3.7.4
|
||||
@@ -40,8 +42,10 @@ dependencies:
|
||||
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
|
||||
'@electron-toolkit/utils': 4.0.0_electron@43.1.0
|
||||
'@koromix/koffi-win32-x64': 3.1.0
|
||||
'@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq
|
||||
fs-extra: 11.3.2
|
||||
fzstd: 0.1.1
|
||||
jsonrepair: 3.15.0
|
||||
koffi: 3.1.0
|
||||
openai: 6.10.0
|
||||
silk-wasm: 3.7.1
|
||||
@@ -1124,6 +1128,21 @@ packages:
|
||||
defer-to-connect: 2.0.1
|
||||
dev: true
|
||||
|
||||
/@tanstack/react-virtual/3.14.6_bokjwhiew3ov3ffvbmafuwoalq:
|
||||
resolution: {integrity: sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.17.4
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1_react@19.2.1
|
||||
dev: false
|
||||
|
||||
/@tanstack/virtual-core/3.17.4:
|
||||
resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==}
|
||||
dev: false
|
||||
|
||||
/@tootallnate/once/2.0.0:
|
||||
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -3414,6 +3433,11 @@ packages:
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
/jsonrepair/3.15.0:
|
||||
resolution: {integrity: sha512-wy8OTjwsJwQRnQJkKnMJJ9vcytRdBPAgIF/Hy6+s1dAj42BHMKiyL8JzEieIl3JY7idt8eyHwBWTO8mh/+mtwA==}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/jsx-ast-utils/3.3.5:
|
||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
@@ -4037,7 +4061,6 @@ packages:
|
||||
dependencies:
|
||||
react: 19.2.1
|
||||
scheduler: 0.27.0
|
||||
dev: true
|
||||
|
||||
/react-is/16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
@@ -4051,7 +4074,6 @@ packages:
|
||||
/react/19.2.1:
|
||||
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/read-binary-file-arch/1.0.6:
|
||||
resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==}
|
||||
@@ -4234,7 +4256,6 @@ packages:
|
||||
|
||||
/scheduler/0.27.0:
|
||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||
dev: true
|
||||
|
||||
/semver/5.7.2:
|
||||
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 583 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 157 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
@@ -0,0 +1,83 @@
|
||||
import { app, shell } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
|
||||
const MAX_LOG_BYTES = 5 * 1024 * 1024
|
||||
const REDACTED_KEY = /(?:api[-_]?key|authorization|token|secret|password|database[-_]?key)/i
|
||||
|
||||
const sanitize = (value: unknown, depth = 0): unknown => {
|
||||
if (depth > 4) return '[depth-limited]'
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***')
|
||||
.replace(/\bBearer\s+[a-z0-9._~-]{8,}\b/gi, 'Bearer ***')
|
||||
.slice(0, 2000)
|
||||
}
|
||||
if (Array.isArray(value)) return value.slice(0, 30).map((item) => sanitize(item, depth + 1))
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
|
||||
key,
|
||||
REDACTED_KEY.test(key) ? '***' : sanitize(item, depth + 1)
|
||||
])
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export class AppLogger {
|
||||
private get logDir(): string {
|
||||
return app.getPath('logs')
|
||||
}
|
||||
|
||||
get logPath(): string {
|
||||
return path.join(this.logDir, 'wechatexplorer.log')
|
||||
}
|
||||
|
||||
private rotateIfNeeded(): void {
|
||||
try {
|
||||
if (!fs.existsSync(this.logPath) || fs.statSync(this.logPath).size < MAX_LOG_BYTES) return
|
||||
const previous = `${this.logPath}.1`
|
||||
if (fs.existsSync(previous)) fs.removeSync(previous)
|
||||
fs.moveSync(this.logPath, previous)
|
||||
} catch {
|
||||
// Logging must never interrupt the application.
|
||||
}
|
||||
}
|
||||
|
||||
write(entry: AppLogEntry): void {
|
||||
try {
|
||||
fs.ensureDirSync(this.logDir)
|
||||
this.rotateIfNeeded()
|
||||
const record = {
|
||||
timestamp: new Date().toISOString(),
|
||||
mode: app.isPackaged ? 'packaged' : 'development',
|
||||
level: entry.level,
|
||||
scope: String(entry.scope || 'app').slice(0, 80),
|
||||
message: String(entry.message || '').slice(0, 500),
|
||||
details: sanitize(entry.details || {})
|
||||
}
|
||||
fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' })
|
||||
if (!app.isPackaged) {
|
||||
const method =
|
||||
entry.level === 'error'
|
||||
? console.error
|
||||
: entry.level === 'warn'
|
||||
? console.warn
|
||||
: console.log
|
||||
method(`[${record.scope}] ${record.message}`, record.details)
|
||||
}
|
||||
} catch {
|
||||
// Logging must never interrupt the application.
|
||||
}
|
||||
}
|
||||
|
||||
reveal(): void {
|
||||
fs.ensureDirSync(this.logDir)
|
||||
if (!fs.existsSync(this.logPath)) fs.writeFileSync(this.logPath, '', 'utf8')
|
||||
shell.showItemInFolder(this.logPath)
|
||||
}
|
||||
}
|
||||
|
||||
export const appLogger = new AppLogger()
|
||||
@@ -4,6 +4,11 @@ import crypto from 'crypto'
|
||||
import os from 'os'
|
||||
import { Wcdb4Client } from './wcdb4-client'
|
||||
|
||||
const imageDecryptDebugEnabled = process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1'
|
||||
const imageDecryptLog = (...args: unknown[]): void => {
|
||||
if (imageDecryptDebugEnabled) console.log(...args)
|
||||
}
|
||||
|
||||
export class ImageDecryptService {
|
||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
||||
|
||||
@@ -41,7 +46,7 @@ export class ImageDecryptService {
|
||||
)
|
||||
|
||||
if (!existsSync(accountRoot)) {
|
||||
console.log('[ImageDecrypt] account root not found:', accountRoot)
|
||||
imageDecryptLog('[ImageDecrypt] account root not found:', accountRoot)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -61,7 +66,7 @@ export class ImageDecryptService {
|
||||
.sort((a, b) => b.mtime - a.mtime)
|
||||
|
||||
if (accounts.length === 0) {
|
||||
console.log('[ImageDecrypt] no accounts found')
|
||||
imageDecryptLog('[ImageDecrypt] no accounts found')
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -83,7 +88,7 @@ export class ImageDecryptService {
|
||||
|
||||
const normalizedMd5 = this.normalizeDatBase(md5 || '')
|
||||
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
|
||||
console.log('[ImageDecrypt] findImageFile:', {
|
||||
imageDecryptLog('[ImageDecrypt] findImageFile:', {
|
||||
md5: normalizedMd5,
|
||||
imageDatName: normalizedDatName,
|
||||
accountDir,
|
||||
@@ -96,7 +101,7 @@ export class ImageDecryptService {
|
||||
if (fullPath && existsSync(fullPath)) {
|
||||
const selected = this.getPreferredDatVariantPath(fullPath, allowThumbnail)
|
||||
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
|
||||
console.log('[ImageDecrypt] hardlink hit:', selected)
|
||||
imageDecryptLog('[ImageDecrypt] hardlink hit:', selected)
|
||||
return selected
|
||||
}
|
||||
}
|
||||
@@ -105,7 +110,7 @@ export class ImageDecryptService {
|
||||
// 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/
|
||||
const attachDir = join(accountDir, 'msg', 'attach')
|
||||
if (!existsSync(attachDir)) {
|
||||
console.log('[ImageDecrypt] attach dir not found:', attachDir)
|
||||
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
|
||||
return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName)
|
||||
}
|
||||
|
||||
@@ -120,7 +125,7 @@ export class ImageDecryptService {
|
||||
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail)
|
||||
if (legacyHit) return legacyHit
|
||||
|
||||
console.log('[ImageDecrypt] findImageFile miss for:', searchKeys)
|
||||
imageDecryptLog('[ImageDecrypt] findImageFile miss for:', searchKeys)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -146,7 +151,7 @@ export class ImageDecryptService {
|
||||
]
|
||||
const found = this.getLargestExistingPath(candidates, allowThumbnail)
|
||||
if (found) {
|
||||
console.log('[ImageDecrypt] prefix path hit:', found)
|
||||
imageDecryptLog('[ImageDecrypt] prefix path hit:', found)
|
||||
return found
|
||||
}
|
||||
}
|
||||
@@ -175,14 +180,14 @@ export class ImageDecryptService {
|
||||
allowThumbnail
|
||||
)
|
||||
if (found) {
|
||||
console.log('[ImageDecrypt] found at:', found)
|
||||
imageDecryptLog('[ImageDecrypt] found at:', found)
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[ImageDecrypt]遍历目录失败:', e)
|
||||
imageDecryptLog('[ImageDecrypt]遍历目录失败:', e)
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -229,7 +234,7 @@ export class ImageDecryptService {
|
||||
const fullPath = join(dir, entry)
|
||||
const stat = statSync(fullPath)
|
||||
if (stat.isFile() && variants.has(entry.toLowerCase())) {
|
||||
console.log('[ImageDecrypt] legacy path hit:', fullPath)
|
||||
imageDecryptLog('[ImageDecrypt] legacy path hit:', fullPath)
|
||||
return fullPath
|
||||
}
|
||||
}
|
||||
@@ -252,13 +257,13 @@ export class ImageDecryptService {
|
||||
*/
|
||||
decryptImage(datPath: string): Buffer | null {
|
||||
if (!existsSync(datPath)) {
|
||||
console.log('[ImageDecrypt] file not found:', datPath)
|
||||
imageDecryptLog('[ImageDecrypt] file not found:', datPath)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const version = this.getDatVersion(datPath)
|
||||
console.log(
|
||||
imageDecryptLog(
|
||||
'[ImageDecrypt] dat version:',
|
||||
version,
|
||||
'file:',
|
||||
@@ -269,25 +274,25 @@ export class ImageDecryptService {
|
||||
|
||||
let decrypted: Buffer
|
||||
if (version === 1) {
|
||||
console.log('[ImageDecrypt] using V1 (default AES key)')
|
||||
imageDecryptLog('[ImageDecrypt] using V1 (default AES key)')
|
||||
const key = Buffer.from(this.defaultV1AesKey, 'ascii')
|
||||
decrypted = this.decryptDatV4(datPath, key)
|
||||
} else if (version === 2) {
|
||||
console.log('[ImageDecrypt] using V2 (user AES key)')
|
||||
imageDecryptLog('[ImageDecrypt] using V2 (user AES key)')
|
||||
if (!this.aesKey) {
|
||||
console.log('[ImageDecrypt] no AES key configured')
|
||||
imageDecryptLog('[ImageDecrypt] no AES key configured')
|
||||
return null
|
||||
}
|
||||
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
|
||||
decrypted = this.decryptDatV4(datPath, key)
|
||||
} else {
|
||||
console.log('[ImageDecrypt] unsupported dat version:', version)
|
||||
imageDecryptLog('[ImageDecrypt] unsupported dat version:', version)
|
||||
return null
|
||||
}
|
||||
|
||||
return decrypted
|
||||
} catch (error) {
|
||||
console.error('[ImageDecrypt] decrypt error:', error)
|
||||
imageDecryptLog('[ImageDecrypt] decrypt error:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -309,7 +314,7 @@ export class ImageDecryptService {
|
||||
const unwrapped = this.unwrapWxgf(decrypted)
|
||||
const ext = this.detectImageExtension(unwrapped)
|
||||
if (!ext) {
|
||||
console.log('[ImageDecrypt] unknown image format')
|
||||
imageDecryptLog('[ImageDecrypt] unknown image format')
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -346,7 +351,7 @@ export class ImageDecryptService {
|
||||
const data = this.decryptImageToBase64(candidate)
|
||||
if (data) return { data, filePath: candidate }
|
||||
}
|
||||
console.warn('[ImageDecrypt] all variants failed:', this.uniq(candidates))
|
||||
imageDecryptLog('[ImageDecrypt] all variants failed:', this.uniq(candidates))
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
+37
-6
@@ -69,6 +69,8 @@ import {
|
||||
} from './services/bootstrap-cache'
|
||||
import { installSafeConsole } from './safe-log'
|
||||
import { agentHubService } from './services/agent-hub-service'
|
||||
import { appLogger } from './app-logger'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
|
||||
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
||||
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
||||
@@ -141,6 +143,31 @@ function createWindow(): void {
|
||||
// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢?
|
||||
app.whenReady().then(async () => {
|
||||
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
||||
appLogger.write({
|
||||
level: 'info',
|
||||
scope: 'lifecycle',
|
||||
message: 'WechatExplorer 启动',
|
||||
details: { build: BUILD_MARK, platform: process.platform, version: app.getVersion() }
|
||||
})
|
||||
process.on('uncaughtException', (error) => {
|
||||
appLogger.write({
|
||||
level: 'error',
|
||||
scope: 'main-process',
|
||||
message: error.message,
|
||||
details: { stack: error.stack }
|
||||
})
|
||||
})
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
appLogger.write({
|
||||
level: 'error',
|
||||
scope: 'main-process',
|
||||
message: reason instanceof Error ? reason.message : 'Promise 未处理拒绝',
|
||||
details: {
|
||||
stack: reason instanceof Error ? reason.stack : undefined,
|
||||
reason: reason instanceof Error ? undefined : String(reason)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// WCDB's Windows runtime returns -1006 if wcdb_init is called more than once
|
||||
// per process. Bootstrap native once here so any later Wcdb4Client instance
|
||||
@@ -166,6 +193,9 @@ app.whenReady().then(async () => {
|
||||
|
||||
// IPC test
|
||||
ipcMain.on('ping', () => console.log('pong'))
|
||||
ipcMain.handle('app-log:write', (_, entry: AppLogEntry) => appLogger.write(entry))
|
||||
ipcMain.handle('app-log:getPath', () => appLogger.logPath)
|
||||
ipcMain.handle('app-log:reveal', () => appLogger.reveal())
|
||||
|
||||
ipcMain.handle('db:init', async (_, key: string) => {
|
||||
if (dbInitInFlight) return dbInitInFlight
|
||||
@@ -556,9 +586,9 @@ app.whenReady().then(async () => {
|
||||
if (!provider || !model) {
|
||||
return { success: false, error: '当前 AI 模型不存在' }
|
||||
}
|
||||
if (!model.capabilities.vision) {
|
||||
return { success: false, error: '当前模型不支持图片理解' }
|
||||
}
|
||||
// Capability metadata is stored per machine. A model verified on macOS
|
||||
// may still be unmarked on Windows, so do not reject before making the
|
||||
// real multimodal request. The provider response remains authoritative.
|
||||
// request 来自 renderer,imageHash 是 md5(优先)或 sha256(...),dataUrl 在内部算出
|
||||
// 这里直接调 service,dataUrl 由 renderer 通过 window.api.getImage 拿到再传进来
|
||||
return imageInsightService.analyze(request)
|
||||
@@ -655,9 +685,10 @@ app.whenReady().then(async () => {
|
||||
return error ? { success: false, error } : { success: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('db:disconnect', () => {
|
||||
if (!chat.isReady()) return { success: false, error: '数据库当前未连接' }
|
||||
chat.setChatDb(null)
|
||||
ipcMain.handle('db:disconnect', (_, options?: { closeNative?: boolean }) => {
|
||||
// 断开操作保持幂等:渲染进程可能已标记断开,或主进程连接已先行失效。
|
||||
// 即使当前未就绪,也应让用户正常返回登录页。
|
||||
if (options?.closeNative !== false && chat.isReady()) chat.setChatDb(null)
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AIProviderService } from './ai-provider-service'
|
||||
import {
|
||||
buildGroupReportInput,
|
||||
getSummaryDateRange,
|
||||
GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT,
|
||||
GROUP_REPORT_SYSTEM_PROMPT,
|
||||
isInternalName,
|
||||
parseGroupDailyReport,
|
||||
@@ -68,14 +69,39 @@ export async function generateAgentGroupReport(
|
||||
{ role: 'user', content: input.prompt }
|
||||
])
|
||||
if (!ai.success || !ai.data) return { success: false, error: ai.error || 'AI 总结失败' }
|
||||
const report = parseGroupDailyReport(
|
||||
ai.data,
|
||||
input.topSpeakers,
|
||||
input.activeTimeline,
|
||||
input.voiceLeaderboard,
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
const parseReport = (raw: string): ReturnType<typeof parseGroupDailyReport> =>
|
||||
parseGroupDailyReport(
|
||||
raw,
|
||||
input.topSpeakers,
|
||||
input.activeTimeline,
|
||||
input.voiceLeaderboard,
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
let report: ReturnType<typeof parseGroupDailyReport>
|
||||
try {
|
||||
report = parseReport(ai.data)
|
||||
} catch (parseError) {
|
||||
const repaired = await aiProvider.chat([
|
||||
{ role: 'system', content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: ai.data }
|
||||
])
|
||||
if (!repaired.success || !repaired.data) {
|
||||
const cause = parseError instanceof Error ? parseError.message : String(parseError)
|
||||
return {
|
||||
success: false,
|
||||
error: `${repaired.error || 'AI 修复日报 JSON 失败'}(原始错误:${cause})`
|
||||
}
|
||||
}
|
||||
try {
|
||||
report = parseReport(repaired.data)
|
||||
} catch (repairError) {
|
||||
return {
|
||||
success: false,
|
||||
error: repairError instanceof Error ? repairError.message : String(repairError)
|
||||
}
|
||||
}
|
||||
}
|
||||
const exported = await exportGroupReport({ report, metadata: input.metadata })
|
||||
if (!exported.success || !exported.pngPath) {
|
||||
return { success: false, error: exported.error || '总结图片生成失败' }
|
||||
|
||||
@@ -14,7 +14,15 @@ import type {
|
||||
} from '../../shared/agent-hub'
|
||||
import type { AppSettings } from './settings-store'
|
||||
import { generateAgentGroupReport } from './agent-group-report-service'
|
||||
import { isReady, listMessages, listRecentChat, resolveMd5 } from './chat-service'
|
||||
import { AIProviderService } from './ai-provider-service'
|
||||
import {
|
||||
getGroupSnapshot,
|
||||
isReady,
|
||||
listContacts,
|
||||
listMessages,
|
||||
listRecentChat,
|
||||
resolveMd5
|
||||
} from './chat-service'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const HEALTH_INTERVAL_MS = 5_000
|
||||
@@ -39,8 +47,24 @@ interface GroupReportIntent {
|
||||
interface ContactChatIntent {
|
||||
contact: string
|
||||
limit: number
|
||||
summarize: boolean
|
||||
}
|
||||
|
||||
interface GroupMemberChatIntent {
|
||||
group: string
|
||||
member: string
|
||||
range: 'today' | 'yesterday' | '7days'
|
||||
days: number
|
||||
goal: string
|
||||
}
|
||||
|
||||
interface NaturalLanguageResult {
|
||||
command?: string
|
||||
reply?: string
|
||||
}
|
||||
|
||||
const agentAIProvider = new AIProviderService()
|
||||
|
||||
function resolveBundledBinary(
|
||||
resourceSegments: string[],
|
||||
executable: string,
|
||||
@@ -314,6 +338,7 @@ class AgentHubService {
|
||||
this.addLog('agent-hub', 'info', `收到微信消息 message_id=${messageId || 'unknown'}`)
|
||||
|
||||
const reportIntent = this.matchGroupReportIntent(text)
|
||||
|
||||
if (reportIntent) {
|
||||
if (messageId) this.processedMessages.set(messageId, Date.now())
|
||||
this.addLog(
|
||||
@@ -328,20 +353,39 @@ class AgentHubService {
|
||||
return this.sendHubJson(response, 202, { status: 'generating' })
|
||||
}
|
||||
|
||||
const groupMemberIntent = this.matchGroupMemberChatIntent(text)
|
||||
if (groupMemberIntent) {
|
||||
if (messageId) this.processedMessages.set(messageId, Date.now())
|
||||
void this.summarizeGroupMemberChat(inbound, groupMemberIntent)
|
||||
return this.sendHubJson(response, 202, { status: 'generating', mode: 'group-member-summary' })
|
||||
}
|
||||
|
||||
const contactChatIntent = this.matchContactChatIntent(text)
|
||||
if (contactChatIntent) {
|
||||
if (messageId) this.processedMessages.set(messageId, Date.now())
|
||||
if (contactChatIntent.summarize) {
|
||||
void this.summarizeContactChat(inbound, contactChatIntent)
|
||||
return this.sendHubJson(response, 202, { status: 'generating', mode: 'contact-summary' })
|
||||
}
|
||||
await this.replyContactChat(inbound, contactChatIntent)
|
||||
return this.sendHubJson(response, 200, { status: 'ok' })
|
||||
}
|
||||
|
||||
const limit = this.matchRecentChatIntent(text)
|
||||
if (limit === null) {
|
||||
const recentChatLimit = this.matchRecentChatIntent(text)
|
||||
if (recentChatLimit === null && text.trim()) {
|
||||
if (messageId) this.processedMessages.set(messageId, Date.now())
|
||||
void this.handleNaturalLanguage(inbound, text)
|
||||
return this.sendHubJson(response, 202, {
|
||||
status: 'processing',
|
||||
mode: 'natural-language'
|
||||
})
|
||||
}
|
||||
if (recentChatLimit === null) {
|
||||
this.addLog('agent-hub', 'info', '消息已忽略:没有匹配到支持的意图')
|
||||
return this.sendHubJson(response, 202, { status: 'ignored', reason: 'no matching intent' })
|
||||
}
|
||||
if (!isReady()) return this.sendHubJson(response, 502, { error: 'upstream query failed' })
|
||||
const items = listRecentChat(limit)
|
||||
const items = listRecentChat(recentChatLimit)
|
||||
const lines = items.map((item, index) => {
|
||||
const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim()
|
||||
return `${index + 1}. ${name}(${item.type === 'group' ? '群聊' : '联系人'})`
|
||||
@@ -360,6 +404,140 @@ class AgentHubService {
|
||||
this.sendHubJson(response, 200, { status: 'ok' })
|
||||
}
|
||||
|
||||
private async handleNaturalLanguage(inbound: InboundMessage, text: string): Promise<void> {
|
||||
try {
|
||||
const result = await this.resolveNaturalLanguage(text)
|
||||
if (result.reply) {
|
||||
await this.sendConnector(inbound, this.formatAIReply(result.reply))
|
||||
this.addLog('agent-hub', 'info', '自然语言回复已发送')
|
||||
return
|
||||
}
|
||||
if (!result.command) {
|
||||
await this.sendConnector(inbound, '暂时没有理解你的意思,可以换一种说法再试。')
|
||||
return
|
||||
}
|
||||
|
||||
this.addLog('agent-hub', 'info', `自然语言已理解为:${result.command}`)
|
||||
const reportIntent = this.matchGroupReportIntent(result.command)
|
||||
if (reportIntent) {
|
||||
await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch(() => undefined)
|
||||
await this.generateAndSendReport(inbound, reportIntent)
|
||||
return
|
||||
}
|
||||
|
||||
const groupMemberIntent = this.matchGroupMemberChatIntent(result.command)
|
||||
if (groupMemberIntent) {
|
||||
await this.summarizeGroupMemberChat(inbound, groupMemberIntent)
|
||||
return
|
||||
}
|
||||
|
||||
const contactIntent = this.matchContactChatIntent(result.command)
|
||||
if (contactIntent) {
|
||||
if (contactIntent.summarize) await this.summarizeContactChat(inbound, contactIntent)
|
||||
else await this.replyContactChat(inbound, contactIntent)
|
||||
return
|
||||
}
|
||||
|
||||
const recentLimit = this.matchRecentChatIntent(result.command)
|
||||
if (recentLimit !== null) {
|
||||
await this.replyRecentChats(inbound, recentLimit)
|
||||
return
|
||||
}
|
||||
await this.sendConnector(inbound, '暂时没有理解你的意思,可以换一种说法再试。')
|
||||
} catch (error) {
|
||||
this.addLog('agent-hub', 'error', `自然语言处理失败:${this.errorMessage(error)}`)
|
||||
await this.sendConnector(inbound, `处理失败:${this.errorMessage(error)}`).catch(
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async replyRecentChats(inbound: InboundMessage, limit: number): Promise<void> {
|
||||
if (!isReady()) {
|
||||
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
|
||||
return
|
||||
}
|
||||
const items = listRecentChat(limit)
|
||||
const lines = items.map((item, index) => {
|
||||
const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim()
|
||||
return `${index + 1}. ${name}(${item.type === 'group' ? '群聊' : '联系人'})`
|
||||
})
|
||||
await this.sendConnector(
|
||||
inbound,
|
||||
lines.length ? `最近 ${items.length} 个会话:\n${lines.join('\n')}` : '暂时没有找到最近会话。'
|
||||
)
|
||||
this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`)
|
||||
}
|
||||
|
||||
private async resolveNaturalLanguage(text: string): Promise<NaturalLanguageResult> {
|
||||
const result = await agentAIProvider.chat([
|
||||
{
|
||||
role: 'system',
|
||||
content: `你是 WechatExplorer 微信机器人的意图理解器。只能输出一行 JSON,不要 Markdown。
|
||||
支持的工具:
|
||||
1. recent:查看最近会话,参数 limit 为 1-20。
|
||||
2. contact:查看我与某个联系人的最近聊天,参数 contact 和 limit。
|
||||
3. report:生成某个群的群聊总结图片,参数 group 和 range(today、yesterday、7days)。
|
||||
4. group_member:分析某个群里某位成员的发言,参数 group、member、range(today、yesterday、7days)、days(1-30)和 goal(保留用户希望总结、研究人物、提取观点等完整目标)。只要用户同时提到群聊和群成员,应优先使用 group_member,不能识别成 contact。
|
||||
5. chat:不需要工具的普通对话,reply 用简洁中文直接回答。
|
||||
输出格式:{"type":"recent|contact|report|group_member|chat","limit":5,"contact":"","group":"","member":"","range":"today","days":3,"goal":"","reply":""}
|
||||
不要声称已经读取未调用的聊天记录,不要执行电脑控制、文件操作、付款或发送给其他联系人。`
|
||||
},
|
||||
{ role: 'user', content: text.slice(0, 1000) }
|
||||
])
|
||||
if (!result.success || !result.data) {
|
||||
this.addLog('agent-hub', 'warn', `自然语言理解不可用:${result.error || 'AI 未返回内容'}`)
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const json = result.data.match(/\{[\s\S]*\}/)?.[0]
|
||||
if (!json) return {}
|
||||
const parsed = JSON.parse(json) as Record<string, unknown>
|
||||
const limit = Math.max(1, Math.min(20, Number(parsed['limit']) || 5))
|
||||
if (parsed['type'] === 'recent') return { command: `最近${limit}条消息` }
|
||||
if (parsed['type'] === 'contact' && String(parsed['contact'] || '').trim()) {
|
||||
return { command: `我和${String(parsed['contact']).trim()}最近${limit}条聊了什么` }
|
||||
}
|
||||
if (parsed['type'] === 'report' && String(parsed['group'] || '').trim()) {
|
||||
const range =
|
||||
parsed['range'] === '7days'
|
||||
? '最近7天'
|
||||
: parsed['range'] === 'yesterday'
|
||||
? '昨天'
|
||||
: '今天'
|
||||
return { command: `生成${String(parsed['group']).trim()}${range}的群聊总结图片` }
|
||||
}
|
||||
if (
|
||||
parsed['type'] === 'group_member' &&
|
||||
String(parsed['group'] || '').trim() &&
|
||||
String(parsed['member'] || '').trim()
|
||||
) {
|
||||
const range =
|
||||
parsed['range'] === 'yesterday'
|
||||
? '昨天'
|
||||
: parsed['range'] === 'today'
|
||||
? '今天'
|
||||
: '最近7天'
|
||||
const group = String(parsed['group'] || '')
|
||||
.trim()
|
||||
.replace(/(?:群聊|群)+$/g, '')
|
||||
const days = Math.max(1, Math.min(30, Number(parsed['days']) || 7))
|
||||
const goal = String(parsed['goal'] || '总结发言').trim()
|
||||
return {
|
||||
command: `看看${group}群里${String(parsed['member']).trim()}${range === '最近7天' ? `最近${days}天` : range}说了什么,${goal}`
|
||||
}
|
||||
}
|
||||
if (parsed['type'] === 'chat') {
|
||||
const reply = String(parsed['reply'] || '').trim()
|
||||
return reply ? { reply: reply.slice(0, 1500) } : {}
|
||||
}
|
||||
} catch (error) {
|
||||
this.addLog('agent-hub', 'warn', `自然语言结果解析失败:${this.errorMessage(error)}`)
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
private async replyContactChat(
|
||||
inbound: InboundMessage,
|
||||
intent: ContactChatIntent
|
||||
@@ -395,6 +573,180 @@ class AgentHubService {
|
||||
this.addLog('agent-hub', 'info', `联系人聊天回复已发送(${recent.length} 条)`)
|
||||
}
|
||||
|
||||
private async summarizeContactChat(
|
||||
inbound: InboundMessage,
|
||||
intent: ContactChatIntent
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!isReady()) {
|
||||
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
|
||||
return
|
||||
}
|
||||
const contact = resolveMd5(intent.contact)
|
||||
if (!contact || contact.type !== 'user') {
|
||||
await this.sendConnector(inbound, `没有找到联系人“${intent.contact}”。`)
|
||||
return
|
||||
}
|
||||
|
||||
await this.sendConnector(
|
||||
inbound,
|
||||
`收到!正在整理和${contact.m_nsNickName}的近期聊天,请等待…`
|
||||
)
|
||||
const endTime = Math.floor(Date.now() / 1000)
|
||||
const startTime = endTime - 7 * 24 * 60 * 60
|
||||
const messages = listMessages(contact.md5, startTime, endTime, { limit: 300 }).slice(-300)
|
||||
if (!messages.length) {
|
||||
await this.sendConnector(inbound, `最近 7 天没有找到和${contact.m_nsNickName}的聊天记录。`)
|
||||
return
|
||||
}
|
||||
|
||||
const transcript = messages
|
||||
.map((message) => {
|
||||
const speaker = message.isSender ? '我' : contact.m_nsNickName
|
||||
return `[${message.datetime}] ${speaker}:${this.describeChatMessage(message.content, message.type)}`
|
||||
})
|
||||
.join('\n')
|
||||
const summary = await agentAIProvider.chat([
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'你是私人聊天记录总结助手。仅根据提供的记录总结,不编造。按日期或主题整理关键进展、双方观点、决定、待办和未解决问题;忽略无意义表情,保留重要数字与事实。使用适合微信阅读的简洁中文。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `请总结我和“${contact.m_nsNickName}”最近 7 天聊了什么。\n\n聊天记录:\n${transcript}`
|
||||
}
|
||||
])
|
||||
if (!summary.success || !summary.data?.trim()) {
|
||||
throw new Error(summary.error || 'AI 未返回总结')
|
||||
}
|
||||
await this.sendConnector(
|
||||
inbound,
|
||||
this.formatAIReply(
|
||||
`和${contact.m_nsNickName}最近聊天总结(近 7 天,共 ${messages.length} 条):\n\n${summary.data.trim().slice(0, 3500)}`
|
||||
)
|
||||
)
|
||||
this.addLog('agent-hub', 'info', `联系人聊天总结已发送(${messages.length} 条)`)
|
||||
} catch (error) {
|
||||
this.addLog('agent-hub', 'error', `联系人聊天总结失败:${this.errorMessage(error)}`)
|
||||
await this.sendConnector(inbound, `聊天总结失败:${this.errorMessage(error)}`).catch(
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async summarizeGroupMemberChat(
|
||||
inbound: InboundMessage,
|
||||
intent: GroupMemberChatIntent
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!isReady()) {
|
||||
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
|
||||
return
|
||||
}
|
||||
const group = this.resolveGroup(intent.group)
|
||||
if (!group) {
|
||||
await this.sendConnector(inbound, `没有找到群聊“${intent.group}”。`)
|
||||
return
|
||||
}
|
||||
const snapshot = getGroupSnapshot(group.md5)
|
||||
const memberQuery = intent.member.trim().toLowerCase()
|
||||
const member = snapshot?.members.find((item) =>
|
||||
[item.groupNickname, item.wechatNickname, item.remark, item.nickname, item.wxid].some(
|
||||
(name) =>
|
||||
String(name || '')
|
||||
.trim()
|
||||
.toLowerCase() === memberQuery
|
||||
)
|
||||
)
|
||||
if (!member) {
|
||||
await this.sendConnector(
|
||||
inbound,
|
||||
`没有在“${group.m_nsNickName}”找到成员“${intent.member}”。`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const displayName =
|
||||
member.groupNickname || member.wechatNickname || member.remark || member.nickname
|
||||
await this.sendConnector(
|
||||
inbound,
|
||||
`收到!正在整理${displayName}在“${group.m_nsNickName}”的近期发言,请等待…`
|
||||
)
|
||||
const now = new Date()
|
||||
const todayStart = Math.floor(
|
||||
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
|
||||
)
|
||||
const startTime =
|
||||
intent.range === 'today'
|
||||
? todayStart
|
||||
: intent.range === 'yesterday'
|
||||
? todayStart - 24 * 60 * 60
|
||||
: Math.floor(Date.now() / 1000) - intent.days * 24 * 60 * 60
|
||||
const endTime = intent.range === 'yesterday' ? todayStart - 1 : Math.floor(Date.now() / 1000)
|
||||
const aliases = new Set(
|
||||
[member.groupNickname, member.wechatNickname, member.remark, member.nickname]
|
||||
.map((name) =>
|
||||
String(name || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
)
|
||||
.filter(Boolean)
|
||||
)
|
||||
const messages = listMessages(group.md5, startTime, endTime, { limit: 10_000 })
|
||||
.filter(
|
||||
(message) =>
|
||||
String(message.senderId || '').trim() === member.wxid ||
|
||||
aliases.has(
|
||||
String(message.name || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
)
|
||||
)
|
||||
.slice(-1000)
|
||||
if (!messages.length) {
|
||||
await this.sendConnector(
|
||||
inbound,
|
||||
`所选时间范围没有找到${displayName}在“${group.m_nsNickName}”的发言。`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const transcript = messages
|
||||
.map(
|
||||
(message) =>
|
||||
`[${message.datetime}] ${this.describeChatMessage(message.content, message.type)}`
|
||||
)
|
||||
.join('\n')
|
||||
const summary = await agentAIProvider.chat([
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'你是擅长分析微信群聊的助手。严格依据提供的发言完成用户的原始要求,输出结构和侧重点由内容决定,不套固定模板。可以归纳人物特征、兴趣、表达习惯和群内角色,但必须区分事实与推测,为推测说明依据和不确定性,不得编造。使用适合微信阅读的中文。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `用户原始要求:${intent.goal}\n分析对象:“${displayName}”在群聊“${group.m_nsNickName}”中的发言。\n时间范围:${intent.range === 'today' ? '今天' : intent.range === 'yesterday' ? '昨天' : `最近 ${intent.days} 天`}。\n共提供 ${messages.length} 条发言。\n\n发言记录:\n${transcript}`
|
||||
}
|
||||
])
|
||||
if (!summary.success || !summary.data?.trim()) {
|
||||
throw new Error(summary.error || 'AI 未返回总结')
|
||||
}
|
||||
await this.sendConnector(
|
||||
inbound,
|
||||
this.formatAIReply(
|
||||
`${displayName}在“${group.m_nsNickName}”的发言总结(共 ${messages.length} 条):\n\n${summary.data.trim().slice(0, 3500)}`
|
||||
)
|
||||
)
|
||||
this.addLog('agent-hub', 'info', `群成员发言总结已发送(${messages.length} 条)`)
|
||||
} catch (error) {
|
||||
this.addLog('agent-hub', 'error', `群成员发言总结失败:${this.errorMessage(error)}`)
|
||||
await this.sendConnector(inbound, `群成员发言总结失败:${this.errorMessage(error)}`).catch(
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private describeChatMessage(content: string, type: string): string {
|
||||
const normalized = String(content || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
@@ -404,6 +756,15 @@ class AgentHubService {
|
||||
return `[${label}]`
|
||||
}
|
||||
|
||||
private formatAIReply(content: string): string {
|
||||
return content
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(/[ \t]*•[ \t]*/g, '\n• ')
|
||||
.replace(/[ \t]+(?=\d+[.、][ \t])/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
private async generateAndSendReport(
|
||||
inbound: InboundMessage,
|
||||
intent: GroupReportIntent
|
||||
@@ -465,7 +826,62 @@ class AgentHubService {
|
||||
if (!contact) return null
|
||||
|
||||
const limit = Number(normalized.match(/最近(\d{1,2})条/)?.[1] || 10)
|
||||
return { contact, limit: Math.max(1, Math.min(20, limit)) }
|
||||
const summarize = /(聊了什么|聊什么|说了什么|谈了什么|总结)/.test(normalized)
|
||||
return { contact, limit: Math.max(1, Math.min(20, limit)), summarize }
|
||||
}
|
||||
|
||||
private matchGroupMemberChatIntent(text: string): GroupMemberChatIntent | null {
|
||||
const normalized = text.trim().replace(/[,。!??::]/g, '')
|
||||
const timePattern = '(今天|今日|昨天|昨日|最近\\d{1,2}天|近\\d{1,2}天|最近|近来|这几天)'
|
||||
const actionPattern = '(?:说了什么|聊了什么|发言|说过什么|都聊什么|都说什么|干了什么)'
|
||||
const patterns = [
|
||||
new RegExp(
|
||||
`(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)[\\s,,]+(.+?)${timePattern}${actionPattern}`
|
||||
),
|
||||
new RegExp(
|
||||
`(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)(?:里|中的)(.+?)${timePattern}${actionPattern}`
|
||||
)
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
const match = normalized.match(pattern)
|
||||
if (match?.[1]?.trim() && match[2]?.trim()) {
|
||||
const range = /昨天|昨日/.test(match[3] || '')
|
||||
? 'yesterday'
|
||||
: /今天|今日/.test(match[3] || '')
|
||||
? 'today'
|
||||
: '7days'
|
||||
const days = Math.max(1, Math.min(30, Number((match[3] || '').match(/\d{1,2}/)?.[0]) || 7))
|
||||
return {
|
||||
group: match[1].trim(),
|
||||
member: match[2].trim(),
|
||||
range,
|
||||
days,
|
||||
goal: normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveGroup(query: string): ReturnType<typeof resolveMd5> {
|
||||
const normalize = (value: string): string =>
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s,,。!??::、“”'‘’]/g, '')
|
||||
.replace(/(?:群聊|群)+$/g, '')
|
||||
const target = normalize(query)
|
||||
if (!target) return null
|
||||
|
||||
const groups = listContacts().filter((contact) => contact.type === 'group')
|
||||
return (
|
||||
groups.find((contact) => normalize(contact.m_nsNickName) === target) ||
|
||||
groups.find((contact) => {
|
||||
const name = normalize(contact.m_nsNickName)
|
||||
return name.includes(target) || target.includes(name)
|
||||
}) ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
private matchGroupReportIntent(text: string): GroupReportIntent | null {
|
||||
|
||||
@@ -195,6 +195,9 @@ export class AIProviderService {
|
||||
const imageError = validateVisionImage(imagePart.dataUrl)
|
||||
if (imageError) throw new Error(imageError)
|
||||
const result = await this.request(messages as AIMessage[], options)
|
||||
if (options?.providerId && options.modelId) {
|
||||
this.markCapabilities(options.providerId, options.modelId, { vision: true, ocr: true })
|
||||
}
|
||||
return { success: true, ...result }
|
||||
} catch (error) {
|
||||
return { success: false, error: safeAIError(error) }
|
||||
|
||||
@@ -31,6 +31,10 @@ interface BootstrapCacheFile {
|
||||
const CACHE_VERSION = 1
|
||||
const MAX_MESSAGE_BUCKETS = 24
|
||||
const MAX_MESSAGES_PER_BUCKET = 1200
|
||||
const WRITE_DEBOUNCE_MS = 300
|
||||
const memoryCache = new Map<string, BootstrapCacheFile>()
|
||||
const writeTimers = new Map<string, NodeJS.Timeout>()
|
||||
const writeQueues = new Map<string, Promise<void>>()
|
||||
|
||||
function normalizeRoot(accountRoot?: string): string {
|
||||
return String(accountRoot || '').trim()
|
||||
@@ -43,19 +47,26 @@ function getCacheFile(accountRoot?: string): string {
|
||||
.update(`${process.platform}:${normalizedRoot}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16)
|
||||
return path.join(app.getPath('userData'), 'cache', 'bootstrap', `${process.platform}-${hash}.json`)
|
||||
return path.join(
|
||||
app.getPath('userData'),
|
||||
'cache',
|
||||
'bootstrap',
|
||||
`${process.platform}-${hash}.json`
|
||||
)
|
||||
}
|
||||
|
||||
function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const file = getCacheFile(normalizedRoot)
|
||||
const cached = memoryCache.get(file)
|
||||
if (cached) return cached
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null
|
||||
const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile>
|
||||
if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null
|
||||
if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null
|
||||
return {
|
||||
const result: BootstrapCacheFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
@@ -64,6 +75,8 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
||||
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
|
||||
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {}
|
||||
}
|
||||
memoryCache.set(file, result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] read failed:', error)
|
||||
return null
|
||||
@@ -71,28 +84,48 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
||||
}
|
||||
|
||||
function writeCacheFile(cache: BootstrapCacheFile): void {
|
||||
try {
|
||||
const file = getCacheFile(cache.accountRoot)
|
||||
fs.ensureDirSync(path.dirname(file))
|
||||
fs.writeJsonSync(file, cache, { spaces: 2 })
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] write failed:', error)
|
||||
}
|
||||
const file = getCacheFile(cache.accountRoot)
|
||||
memoryCache.set(file, cache)
|
||||
const existingTimer = writeTimers.get(file)
|
||||
if (existingTimer) clearTimeout(existingTimer)
|
||||
writeTimers.set(
|
||||
file,
|
||||
setTimeout(() => {
|
||||
writeTimers.delete(file)
|
||||
const serialized = JSON.stringify(memoryCache.get(file) || cache)
|
||||
const previous = writeQueues.get(file) || Promise.resolve()
|
||||
const next = previous
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await fs.ensureDir(path.dirname(file))
|
||||
await fs.writeFile(file, serialized, 'utf8')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[BootstrapCache] write failed:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (writeQueues.get(file) === next) writeQueues.delete(file)
|
||||
})
|
||||
writeQueues.set(file, next)
|
||||
}, WRITE_DEBOUNCE_MS)
|
||||
)
|
||||
}
|
||||
|
||||
function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
return (
|
||||
readCacheFile(normalizedRoot) || {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Date.now(),
|
||||
contacts: [],
|
||||
messages: {}
|
||||
}
|
||||
)
|
||||
const existing = readCacheFile(normalizedRoot)
|
||||
if (existing) return existing
|
||||
const created: BootstrapCacheFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Date.now(),
|
||||
contacts: [],
|
||||
messages: {}
|
||||
}
|
||||
memoryCache.set(getCacheFile(normalizedRoot), created)
|
||||
return created
|
||||
}
|
||||
|
||||
function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string {
|
||||
@@ -144,7 +177,9 @@ export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact
|
||||
)
|
||||
const nameByUsername = new Map(
|
||||
cache.contacts
|
||||
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact))
|
||||
.filter(
|
||||
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
|
||||
)
|
||||
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
|
||||
)
|
||||
if (avatarByUsername.size === 0 && nameByUsername.size === 0) return contacts
|
||||
@@ -179,7 +214,9 @@ export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]):
|
||||
)
|
||||
const nameByUsername = new Map(
|
||||
(cache.contacts || [])
|
||||
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact))
|
||||
.filter(
|
||||
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
|
||||
)
|
||||
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
|
||||
)
|
||||
cache.contacts = contacts.map((contact) => ({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { app } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { discoverWindowsDbRoots } from '../windows-db-root-discovery'
|
||||
|
||||
export interface AppSettings {
|
||||
dbRoot: string
|
||||
@@ -37,14 +38,7 @@ function getDefaultDbRootCandidates(home: string): string[] {
|
||||
path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
|
||||
]
|
||||
|
||||
for (const drive of getWindowsDrives()) {
|
||||
candidates.push(path.join(`${drive}:\\`, 'xwechat_files'))
|
||||
candidates.push(path.join(`${drive}:\\`, 'WeChat Files'))
|
||||
for (const child of listDirectories(`${drive}:\\`)) {
|
||||
candidates.push(path.join(child, 'xwechat_files'))
|
||||
candidates.push(path.join(child, 'WeChat Files'))
|
||||
}
|
||||
}
|
||||
candidates.push(...discoverWindowsDbRoots())
|
||||
|
||||
return unique(candidates)
|
||||
}
|
||||
@@ -68,32 +62,6 @@ function getWeflowDbPathCandidates(home: string): string[] {
|
||||
return candidates
|
||||
}
|
||||
|
||||
function getWindowsDrives(): string[] {
|
||||
const drives: string[] = []
|
||||
for (let code = 67; code <= 90; code += 1) {
|
||||
const drive = String.fromCharCode(code)
|
||||
if (fs.existsSync(`${drive}:\\`)) drives.push(drive)
|
||||
}
|
||||
return drives
|
||||
}
|
||||
|
||||
function listDirectories(root: string): string[] {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(root)
|
||||
.map((name) => path.join(root, name))
|
||||
.filter((candidate) => {
|
||||
try {
|
||||
return fs.statSync(candidate).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return Array.from(new Set(values))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { discoverWindowsDbRoots } from './windows-db-root-discovery'
|
||||
import crypto from 'crypto'
|
||||
import { createRequire } from 'module'
|
||||
import { createConnection, Socket } from 'net'
|
||||
@@ -303,14 +304,7 @@ export class Wcdb4Client {
|
||||
path.join(home, 'WeChat Files'),
|
||||
path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
|
||||
]
|
||||
for (const drive of Wcdb4Client.getWindowsDrives()) {
|
||||
candidates.push(path.join(`${drive}:\\`, 'xwechat_files'))
|
||||
candidates.push(path.join(`${drive}:\\`, 'WeChat Files'))
|
||||
for (const child of Wcdb4Client.listDirectories(`${drive}:\\`)) {
|
||||
candidates.push(path.join(child, 'xwechat_files'))
|
||||
candidates.push(path.join(child, 'WeChat Files'))
|
||||
}
|
||||
}
|
||||
candidates.push(...discoverWindowsDbRoots())
|
||||
return Array.from(new Set(candidates))
|
||||
}
|
||||
return [path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')]
|
||||
@@ -335,32 +329,6 @@ export class Wcdb4Client {
|
||||
return candidates
|
||||
}
|
||||
|
||||
private static getWindowsDrives(): string[] {
|
||||
const drives: string[] = []
|
||||
for (let code = 67; code <= 90; code += 1) {
|
||||
const drive = String.fromCharCode(code)
|
||||
if (fs.existsSync(`${drive}:\\`)) drives.push(drive)
|
||||
}
|
||||
return drives
|
||||
}
|
||||
|
||||
private static listDirectories(root: string): string[] {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(root)
|
||||
.map((name) => path.join(root, name))
|
||||
.filter((candidate) => {
|
||||
try {
|
||||
return fs.statSync(candidate).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private static hasDbStorage(candidate: string): boolean {
|
||||
try {
|
||||
return fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage'))
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
|
||||
const DB_ROOT_NAMES = new Set(['xwechat_files', 'wechat files'])
|
||||
const SKIPPED_DIRECTORY_NAMES = new Set([
|
||||
'$recycle.bin',
|
||||
'system volume information',
|
||||
'windows',
|
||||
'program files',
|
||||
'program files (x86)',
|
||||
'programdata',
|
||||
'recovery'
|
||||
])
|
||||
const MAX_VISITED_DIRECTORIES_PER_DRIVE = 20_000
|
||||
let cachedDiscoveredRoots: string[] | null = null
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return Array.from(new Set(values.map((value) => path.normalize(value))))
|
||||
}
|
||||
|
||||
export function getWindowsDrives(): string[] {
|
||||
if (process.platform !== 'win32') return []
|
||||
const drives: string[] = []
|
||||
for (let code = 67; code <= 90; code += 1) {
|
||||
const root = `${String.fromCharCode(code)}:\\`
|
||||
if (fs.existsSync(root)) drives.push(root)
|
||||
}
|
||||
return drives
|
||||
}
|
||||
|
||||
export function scanWindowsDbRoots(driveRoots: string[], maxDepth = 3): string[] {
|
||||
const results: string[] = []
|
||||
|
||||
for (const driveRoot of driveRoots) {
|
||||
const queue: Array<{ directory: string; depth: number }> = [{ directory: driveRoot, depth: 0 }]
|
||||
let visited = 0
|
||||
|
||||
while (queue.length > 0 && visited < MAX_VISITED_DIRECTORIES_PER_DRIVE) {
|
||||
const current = queue.shift()
|
||||
if (!current || current.depth >= maxDepth) continue
|
||||
|
||||
let entries: fs.Dirent[]
|
||||
try {
|
||||
entries = fs.readdirSync(current.directory, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) continue
|
||||
const lowered = entry.name.toLowerCase()
|
||||
const fullPath = path.join(current.directory, entry.name)
|
||||
const depth = current.depth + 1
|
||||
visited += 1
|
||||
|
||||
if (DB_ROOT_NAMES.has(lowered)) {
|
||||
results.push(fullPath)
|
||||
continue
|
||||
}
|
||||
if (depth < maxDepth && !SKIPPED_DIRECTORY_NAMES.has(lowered)) {
|
||||
queue.push({ directory: fullPath, depth })
|
||||
}
|
||||
if (visited >= MAX_VISITED_DIRECTORIES_PER_DRIVE) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return unique(results)
|
||||
}
|
||||
|
||||
export function discoverWindowsDbRoots(): string[] {
|
||||
if (!cachedDiscoveredRoots) {
|
||||
cachedDiscoveredRoots = scanWindowsDbRoots(getWindowsDrives(), 3)
|
||||
}
|
||||
return [...cachedDiscoveredRoots]
|
||||
}
|
||||
Vendored
+7
-1
@@ -38,6 +38,7 @@ import type {
|
||||
ImageInsight
|
||||
} from '../shared/image-insight'
|
||||
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
|
||||
export type ParsedContent =
|
||||
| { type: 'text'; content: string }
|
||||
@@ -71,6 +72,9 @@ declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
api: {
|
||||
writeAppLog: (entry: AppLogEntry) => Promise<void>
|
||||
getAppLogPath: () => Promise<string>
|
||||
revealAppLog: () => Promise<void>
|
||||
initDb: (
|
||||
key: string
|
||||
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
||||
@@ -261,7 +265,9 @@ declare global {
|
||||
}>
|
||||
selectDbRoot: () => Promise<{ canceled: boolean; path?: string }>
|
||||
openAccountRoot: () => Promise<{ success: boolean; error?: string }>
|
||||
disconnectDb: () => Promise<{ success: boolean; error?: string }>
|
||||
disconnectDb: (options?: {
|
||||
closeNative?: boolean
|
||||
}) => Promise<{ success: boolean; error?: string }>
|
||||
apiStatus: () => Promise<{
|
||||
running: boolean
|
||||
host: string
|
||||
|
||||
@@ -16,9 +16,13 @@ import type {
|
||||
ImageInsight
|
||||
} from '../shared/image-insight'
|
||||
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
|
||||
// 渲染器的自定义 API
|
||||
const api = {
|
||||
writeAppLog: (entry: AppLogEntry) => ipcRenderer.invoke('app-log:write', entry),
|
||||
getAppLogPath: () => ipcRenderer.invoke('app-log:getPath'),
|
||||
revealAppLog: () => ipcRenderer.invoke('app-log:reveal'),
|
||||
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
||||
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
||||
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
||||
@@ -106,7 +110,8 @@ const api = {
|
||||
reopenWithRoot: (accountRoot: string) => ipcRenderer.invoke('db:reopenWithRoot', accountRoot),
|
||||
selectDbRoot: () => ipcRenderer.invoke('settings:selectDbRoot'),
|
||||
openAccountRoot: () => ipcRenderer.invoke('settings:openAccountRoot'),
|
||||
disconnectDb: () => ipcRenderer.invoke('db:disconnect'),
|
||||
disconnectDb: (options?: { closeNative?: boolean }) =>
|
||||
ipcRenderer.invoke('db:disconnect', options),
|
||||
apiStatus: () => ipcRenderer.invoke('api:getStatus'),
|
||||
apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port),
|
||||
apiStop: () => ipcRenderer.invoke('api:stop'),
|
||||
@@ -120,7 +125,9 @@ const api = {
|
||||
// ============================================================
|
||||
// AI 图片理解基础设施(ImageInsightService)
|
||||
// ============================================================
|
||||
imageListCandidates: (query: ImageCandidateQuery): Promise<{
|
||||
imageListCandidates: (
|
||||
query: ImageCandidateQuery
|
||||
): Promise<{
|
||||
success: boolean
|
||||
candidates: ImageCandidate[]
|
||||
error?: string
|
||||
|
||||
+61
-109
@@ -19,6 +19,7 @@ import type { GeneratedReportRecord, ReportWorkspaceView } from './components/re
|
||||
import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportGeneration'
|
||||
import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
|
||||
import { Contact, Message } from '../../shared/types'
|
||||
import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage'
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 260
|
||||
const SIDEBAR_MAX_WIDTH = 380
|
||||
@@ -28,31 +29,6 @@ function getDevelopmentDatabaseKey(): string {
|
||||
return String(import.meta.env.VITE_DB_KEY || '').trim()
|
||||
}
|
||||
|
||||
function EyeIcon({ hidden }: { hidden: boolean }): React.ReactElement {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path
|
||||
d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
{hidden && (
|
||||
<path
|
||||
d="M4 4l16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface SelfInfo {
|
||||
wxid: string
|
||||
nickname: string
|
||||
@@ -173,6 +149,9 @@ function App(): React.ReactElement {
|
||||
const [showDbKey, setShowDbKey] = useState(false)
|
||||
const [dbRootInput, setDbRootInput] = useState('')
|
||||
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
|
||||
const [databaseConnectionMode, setDatabaseConnectionMode] = useState<DatabaseConnectionMode>(
|
||||
getDevelopmentDatabaseKey() ? 'manual' : 'automatic'
|
||||
)
|
||||
const [activePage, setActivePage] = useState<AppPage>('archive')
|
||||
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
||||
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
||||
@@ -256,18 +235,25 @@ function App(): React.ReactElement {
|
||||
|
||||
const waitForPaint = (): Promise<void> => new Promise((resolve) => window.setTimeout(resolve, 80))
|
||||
|
||||
const refreshSelfInfo = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await window.api.getSelf()
|
||||
if (result.ready) {
|
||||
setSelfInfo(result.info)
|
||||
} else {
|
||||
setSelfInfo(null)
|
||||
const refreshSelfInfo = async (attempts = 1): Promise<SelfInfo | null> => {
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt < Math.max(1, attempts); attempt += 1) {
|
||||
try {
|
||||
const result = await window.api.getSelf()
|
||||
if (result.ready && result.info) {
|
||||
setSelfInfo(result.info)
|
||||
return result.info
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
if (attempt + 1 < attempts) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 180))
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[SelfInfo] 加载失败:', error)
|
||||
setSelfInfo(null)
|
||||
}
|
||||
if (lastError) console.warn('[SelfInfo] 加载失败:', lastError)
|
||||
setSelfInfo(null)
|
||||
return null
|
||||
}
|
||||
|
||||
const loadBootstrapCache = async (): Promise<boolean> => {
|
||||
@@ -330,7 +316,7 @@ function App(): React.ReactElement {
|
||||
usernames.length ? 55 : 90
|
||||
)
|
||||
let loadedCount = 0
|
||||
const chunkSize = 8
|
||||
const chunkSize = 32
|
||||
for (let index = 0; index < usernames.length; index += chunkSize) {
|
||||
if (runId !== contactAvatarHydrationRunRef.current) return
|
||||
const chunk = usernames.slice(index, index + chunkSize)
|
||||
@@ -361,7 +347,7 @@ function App(): React.ReactElement {
|
||||
} catch (error) {
|
||||
console.warn('[Contacts] avatar hydrate failed:', error)
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 150))
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 50))
|
||||
}
|
||||
if (usernames.length) onProgress?.('头像加载完成', 90)
|
||||
}
|
||||
@@ -385,11 +371,15 @@ function App(): React.ReactElement {
|
||||
}
|
||||
const key = envKey || savedKey
|
||||
if (!key) {
|
||||
if (active) setBootState('login')
|
||||
if (active) {
|
||||
setDatabaseConnectionMode('automatic')
|
||||
setBootState('login')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (active) {
|
||||
setDbKey(key)
|
||||
setDatabaseConnectionMode('manual')
|
||||
setAutoConnectSource(envKey ? 'env' : 'saved')
|
||||
setDbKeyStatus(
|
||||
autoLoginEnabled
|
||||
@@ -413,12 +403,12 @@ function App(): React.ReactElement {
|
||||
void window.api.setSettings({ autoLogin: true })
|
||||
}
|
||||
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
||||
setIsAuthenticated(true)
|
||||
setIsDatabaseConnected(true)
|
||||
setDbKeyStatus('已自动连接')
|
||||
setDbKeyStatusKind('success')
|
||||
await loadContacts()
|
||||
void refreshSelfInfo()
|
||||
await refreshSelfInfo(3)
|
||||
setIsAuthenticated(true)
|
||||
} else {
|
||||
const error = typeof result === 'boolean' ? '' : result.error
|
||||
setDbKeyStatus(`自动连接失败,请重新输入${error ? `: ${error}` : ''}`)
|
||||
@@ -488,7 +478,7 @@ function App(): React.ReactElement {
|
||||
detail: '正在读取本地缓存',
|
||||
percent: 25
|
||||
})
|
||||
const hasBootstrapCache = await loadBootstrapCache()
|
||||
await loadBootstrapCache()
|
||||
// 持久化手动输入的密钥,供下次启动继续使用
|
||||
void window.api.saveDbKey(keyToUse).catch(() => undefined)
|
||||
void window.api.getSettings().then((current) => {
|
||||
@@ -499,10 +489,13 @@ function App(): React.ReactElement {
|
||||
setStartupProgress({
|
||||
title: '正在加载账号信息...',
|
||||
subtitle: '即将进入 WechatExplorer',
|
||||
detail: '正在读取当前账号信息',
|
||||
percent: 95
|
||||
detail: '正在读取联系人和当前账号',
|
||||
percent: 70
|
||||
})
|
||||
void refreshSelfInfo()
|
||||
// 账号识别依赖联系人数据就绪。返回登录后数据已被清空,如果先查账号,
|
||||
// 会出现“数据库已连接,但账号未连接”的分离状态。手动连接与启动自动连接保持同一顺序。
|
||||
await loadContacts({ waitForAvatars: false })
|
||||
await refreshSelfInfo(3)
|
||||
setStartupProgress({
|
||||
title: '加载完成',
|
||||
subtitle: '正在进入主页面',
|
||||
@@ -514,7 +507,6 @@ function App(): React.ReactElement {
|
||||
setBootState('login')
|
||||
window.setTimeout(() => {
|
||||
setStartupProgress(null)
|
||||
if (!hasBootstrapCache) void loadContacts({ waitForAvatars: false })
|
||||
}, 500)
|
||||
} else {
|
||||
const error = typeof result === 'boolean' ? '' : result.error
|
||||
@@ -619,6 +611,7 @@ function App(): React.ReactElement {
|
||||
throw new Error(result.error || '获取密钥失败')
|
||||
}
|
||||
setDbKey(result.key)
|
||||
setDatabaseConnectionMode('manual')
|
||||
setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取')
|
||||
setDbKeyStatusKind(result.saved ? 'success' : 'normal')
|
||||
} catch (error) {
|
||||
@@ -634,6 +627,7 @@ function App(): React.ReactElement {
|
||||
const result = await window.api.pasteAndSaveDbKey()
|
||||
if (result.success && result.key) {
|
||||
setDbKey(result.key)
|
||||
setDatabaseConnectionMode('manual')
|
||||
setDbKeyStatus('已从剪贴板粘贴并安全保存')
|
||||
setDbKeyStatusKind('success')
|
||||
} else {
|
||||
@@ -651,6 +645,7 @@ function App(): React.ReactElement {
|
||||
return
|
||||
}
|
||||
setDbKey('')
|
||||
setDatabaseConnectionMode('automatic')
|
||||
setDbKeyStatus('已清除保存的密钥')
|
||||
setDbKeyStatusKind('normal')
|
||||
}
|
||||
@@ -659,6 +654,7 @@ function App(): React.ReactElement {
|
||||
setIsAuthenticated(false)
|
||||
setIsDatabaseConnected(false)
|
||||
setBootState('login')
|
||||
setDatabaseConnectionMode(dbKey ? 'manual' : 'automatic')
|
||||
setActivePage('archive')
|
||||
setSettingsCategory('database-key')
|
||||
setSelectedContact(null)
|
||||
@@ -1282,70 +1278,26 @@ function App(): React.ReactElement {
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="login-modal">
|
||||
<div className="login-box">
|
||||
<h2>Enter WeChat DB Key</h2>
|
||||
<div className="login-input-wrapper">
|
||||
<input
|
||||
type={showDbKey ? 'text' : 'password'}
|
||||
className="login-input"
|
||||
value={dbKey}
|
||||
onChange={(e) => setDbKey(e.target.value)}
|
||||
placeholder="Key (e.g. 0x...)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="login-input-toggle"
|
||||
onClick={() => setShowDbKey(!showDbKey)}
|
||||
title={showDbKey ? '隐藏密钥' : '显示密钥'}
|
||||
>
|
||||
<EyeIcon hidden={showDbKey} />
|
||||
</button>
|
||||
</div>
|
||||
{window.electron.process.platform === 'win32' && (
|
||||
<div className="login-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
className="login-input"
|
||||
value={dbRootInput}
|
||||
onChange={(e) => setDbRootInput(e.target.value)}
|
||||
placeholder="微信聊天文件路径 (如 D:\\Tencent\\WeChat\\xwechat_files)"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="login-btn login-btn-secondary"
|
||||
onClick={handleAutoGetDbKey}
|
||||
disabled={isFetchingDbKey}
|
||||
>
|
||||
{isFetchingDbKey ? '正在获取...' : '自动获取密钥'}
|
||||
</button>
|
||||
<button className="login-btn login-btn-secondary" onClick={handlePasteAndSaveDbKey}>
|
||||
粘贴并安全保存
|
||||
</button>
|
||||
<button className="login-btn" onClick={() => handleLogin()}>
|
||||
Connect
|
||||
</button>
|
||||
<button className="login-clear-btn" onClick={handleClearSavedDbKey}>
|
||||
清除已保存密钥
|
||||
</button>
|
||||
{dbKeyStatus && (
|
||||
<div className={`login-key-status ${dbKeyStatusKind}`}>{dbKeyStatus}</div>
|
||||
)}
|
||||
{showMacKeyFaq && (
|
||||
<a
|
||||
className="login-key-help-link"
|
||||
href={MAC_KEY_FAQ_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
查看 macOS 获取密钥排障指引
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DatabaseConnectionPage
|
||||
platform={window.electron.process.platform}
|
||||
mode={databaseConnectionMode}
|
||||
dbKey={dbKey}
|
||||
dbRoot={dbRootInput}
|
||||
showDbKey={showDbKey}
|
||||
isFetching={isFetchingDbKey}
|
||||
status={dbKeyStatus}
|
||||
statusKind={dbKeyStatusKind}
|
||||
showMacKeyFaq={showMacKeyFaq}
|
||||
macKeyFaqUrl={MAC_KEY_FAQ_URL}
|
||||
onModeChange={setDatabaseConnectionMode}
|
||||
onDbKeyChange={setDbKey}
|
||||
onDbRootChange={setDbRootInput}
|
||||
onToggleDbKey={() => setShowDbKey((visible) => !visible)}
|
||||
onAutoGetKey={handleAutoGetDbKey}
|
||||
onManualConnect={() => handleLogin()}
|
||||
onPasteKey={handlePasteAndSaveDbKey}
|
||||
onClearKey={handleClearSavedDbKey}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+532
-107
@@ -1143,6 +1143,20 @@ body {
|
||||
background: #f0f2ef;
|
||||
}
|
||||
|
||||
.virtual-message-list {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.virtual-message-group {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flow-root;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wechat-message-row {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
@@ -1752,148 +1766,542 @@ body {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.login-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
.database-login-page {
|
||||
--login-primary: #247a63;
|
||||
--login-primary-dark: #00604c;
|
||||
--login-border: #d7dfda;
|
||||
--login-surface: #f7faf6;
|
||||
--login-surface-low: #f1f4f1;
|
||||
--login-text: #181c1b;
|
||||
--login-muted: #5f6965;
|
||||
width: 100vw;
|
||||
min-width: 760px;
|
||||
height: 100vh;
|
||||
min-height: 560px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 40%) 1fr;
|
||||
overflow: hidden;
|
||||
color: var(--login-text);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background-color: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
width: 360px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.login-input-wrapper {
|
||||
.database-login-brand {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
padding: clamp(36px, 6vh, 72px) clamp(36px, 5vw, 72px) 28px;
|
||||
background: var(--login-surface-low);
|
||||
border-right: 1px solid var(--login-border);
|
||||
}
|
||||
|
||||
.login-input-wrapper .login-input {
|
||||
padding-right: 40px;
|
||||
margin: 10px 0 10px 0;
|
||||
.database-login-brand-content {
|
||||
width: min(360px, 100%);
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.login-input-toggle {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.login-input-toggle svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.login-input-toggle:hover {
|
||||
color: #07c160;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
background-color: #07c160;
|
||||
.database-login-logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 24px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
background: var(--login-primary-dark);
|
||||
box-shadow: 0 4px 12px rgba(0, 96, 76, 0.12);
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
background-color: #06ad56;
|
||||
.database-login-logo svg {
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
.database-login-brand h1 {
|
||||
margin: 0;
|
||||
font-size: 25px;
|
||||
line-height: 32px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--login-primary-dark);
|
||||
}
|
||||
|
||||
.login-btn-secondary {
|
||||
border: 1px solid #cfd5d8;
|
||||
.database-login-tagline {
|
||||
margin: 4px 0 26px;
|
||||
color: var(--login-primary);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.database-login-description {
|
||||
max-width: 320px;
|
||||
margin: 0 0 30px;
|
||||
color: var(--login-muted);
|
||||
font-size: 13px;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.database-login-promises {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.database-login-promises > div {
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid rgba(190, 201, 195, 0.62);
|
||||
border-radius: 7px;
|
||||
color: #35423d;
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
font-size: 13px;
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.database-login-promises svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--login-primary);
|
||||
}
|
||||
|
||||
.database-login-brand-footer {
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
color: #78827e;
|
||||
font-family: 'Work Sans', Inter, sans-serif;
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.11em;
|
||||
}
|
||||
|
||||
.database-login-workspace {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 0;
|
||||
padding: 48px clamp(40px, 7vw, 96px);
|
||||
background: #fff;
|
||||
color: #30383d;
|
||||
}
|
||||
|
||||
.login-btn-secondary:hover {
|
||||
border-color: #07c160;
|
||||
background: #f2fbf6;
|
||||
color: #078f49;
|
||||
.database-login-panel {
|
||||
width: min(520px, 100%);
|
||||
}
|
||||
|
||||
.login-clear-btn {
|
||||
margin-top: 10px;
|
||||
.database-login-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
margin-bottom: 28px;
|
||||
border: 1px solid var(--login-border);
|
||||
border-radius: 8px;
|
||||
background: #e9eeeb;
|
||||
}
|
||||
|
||||
.database-login-tabs button {
|
||||
min-height: 38px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
color: #66716c;
|
||||
background: transparent;
|
||||
color: #8a949a;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.login-clear-btn:hover {
|
||||
color: #d33b3b;
|
||||
.database-login-tabs button.active {
|
||||
color: var(--login-primary-dark);
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(24, 28, 27, 0.08);
|
||||
}
|
||||
|
||||
.login-key-status {
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: #f3f5f6;
|
||||
color: #59636a;
|
||||
.database-login-state-card {
|
||||
padding: 18px;
|
||||
border: 1px solid var(--login-border);
|
||||
border-radius: 9px;
|
||||
background: var(--login-surface);
|
||||
}
|
||||
|
||||
.database-login-state-card.error {
|
||||
border-color: #efc4be;
|
||||
border-left: 3px solid #ba1a1a;
|
||||
background: #fff9f8;
|
||||
}
|
||||
|
||||
.database-login-state-heading {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.database-login-state-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: var(--login-primary);
|
||||
background: #e2f0eb;
|
||||
}
|
||||
|
||||
.database-login-state-card.error .database-login-state-icon {
|
||||
color: #ba1a1a;
|
||||
background: #ffebe8;
|
||||
}
|
||||
|
||||
.database-login-state-icon svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.database-login-state-heading strong {
|
||||
display: block;
|
||||
margin: 1px 0 4px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.database-login-state-heading p {
|
||||
margin: 0;
|
||||
color: var(--login-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
line-height: 18px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.login-key-status.success {
|
||||
background: #eefaf3;
|
||||
color: #078f49;
|
||||
.database-login-diagnostics {
|
||||
margin: 0;
|
||||
border-top: 1px solid #e2e7e4;
|
||||
}
|
||||
|
||||
.login-key-status.error {
|
||||
background: #fff1f0;
|
||||
color: #c73737;
|
||||
.database-login-diagnostics > div {
|
||||
min-height: 43px;
|
||||
display: grid;
|
||||
grid-template-columns: 112px 1fr;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e2e7e4;
|
||||
}
|
||||
|
||||
.login-key-help-link {
|
||||
display: inline-block;
|
||||
margin-top: 9px;
|
||||
color: #1677ff;
|
||||
.database-login-diagnostics > div:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.database-login-diagnostics dt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #6e7874;
|
||||
font-family: 'Work Sans', Inter, sans-serif;
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.database-login-diagnostics dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: #35423d;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.database-login-path-input-wrap {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.database-login-diagnostics input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 7px 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: #4c5752;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.database-login-path-value {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 7px);
|
||||
z-index: 25;
|
||||
width: min(520px, calc(100vw - 48px));
|
||||
padding: 9px 11px;
|
||||
border: 1px solid #d7dfda;
|
||||
border-radius: 7px;
|
||||
color: #35423d;
|
||||
background: #fff;
|
||||
box-shadow: 0 6px 18px rgba(24, 28, 27, 0.13);
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(4px);
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.database-login-path-input-wrap:hover .database-login-path-value,
|
||||
.database-login-path-input-wrap:focus-within .database-login-path-value {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.database-login-primary,
|
||||
.database-login-secondary {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin-top: 22px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.database-login-primary {
|
||||
border: 1px solid var(--login-primary);
|
||||
color: #fff;
|
||||
background: var(--login-primary);
|
||||
box-shadow: 0 2px 5px rgba(0, 96, 76, 0.16);
|
||||
}
|
||||
|
||||
.database-login-primary:hover:not(:disabled) {
|
||||
border-color: var(--login-primary-dark);
|
||||
background: var(--login-primary-dark);
|
||||
}
|
||||
|
||||
.database-login-primary:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.52;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.database-login-auto > a {
|
||||
display: block;
|
||||
margin-top: 16px;
|
||||
color: var(--login-primary);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login-key-help-link:hover {
|
||||
text-decoration: underline;
|
||||
.database-login-manual {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.database-login-field {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.database-login-field > label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-bottom: 8px;
|
||||
color: #35423d;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.database-login-path-help {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.database-login-path-help-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 1px solid #9a6b18;
|
||||
border-radius: 50%;
|
||||
color: #8a5d0b;
|
||||
background: #fff8e6;
|
||||
cursor: help;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.database-login-path-tooltip {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 9px);
|
||||
z-index: 20;
|
||||
width: 260px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid #d7dfda;
|
||||
border-radius: 7px;
|
||||
color: #35423d;
|
||||
background: #fff;
|
||||
box-shadow: 0 6px 18px rgba(24, 28, 27, 0.12);
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 17px;
|
||||
letter-spacing: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 4px);
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.database-login-path-help:hover .database-login-path-tooltip,
|
||||
.database-login-path-help:focus-within .database-login-path-tooltip {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.database-login-field > input,
|
||||
.database-login-key-input {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--login-border);
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.database-login-field > input {
|
||||
padding: 0 12px;
|
||||
color: var(--login-text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.database-login-key-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.database-login-key-input:focus-within,
|
||||
.database-login-field > input:focus {
|
||||
border-color: var(--login-primary);
|
||||
box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.1);
|
||||
}
|
||||
|
||||
.database-login-key-input input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
padding: 0 4px 0 12px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--login-text);
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.database-login-key-input button {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
color: #65706b;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.database-login-key-input svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.database-login-field small {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
color: #77817d;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.database-login-message {
|
||||
padding: 10px 12px;
|
||||
border-radius: 7px;
|
||||
color: #57625d;
|
||||
background: var(--login-surface-low);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.database-login-message.success {
|
||||
color: var(--login-primary-dark);
|
||||
background: #edf7f3;
|
||||
}
|
||||
|
||||
.database-login-message.error {
|
||||
color: #93000a;
|
||||
background: #fff0ee;
|
||||
}
|
||||
|
||||
.database-login-secondary {
|
||||
margin-top: 10px;
|
||||
border: 1px solid var(--login-border);
|
||||
color: #3f4945;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.database-login-secondary:hover {
|
||||
border-color: var(--login-primary);
|
||||
color: var(--login-primary-dark);
|
||||
background: var(--login-surface);
|
||||
}
|
||||
|
||||
.database-login-footer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 42px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e9e6;
|
||||
color: #8a938f;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.database-login-footer-actions button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: #78827e;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.database-login-footer-actions button:hover {
|
||||
color: #93000a;
|
||||
}
|
||||
|
||||
@media (max-width: 900px), (max-height: 620px) {
|
||||
.database-login-brand {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.database-login-description {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.database-login-workspace {
|
||||
padding: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
@@ -3446,6 +3854,18 @@ body {
|
||||
margin: 4px 0 10px;
|
||||
}
|
||||
|
||||
.report-task-error button + button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.report-task-log-path {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--wxex-text-secondary);
|
||||
font: 11px/16px var(--wxex-font);
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.report-page {
|
||||
grid-template-columns: 268px minmax(360px, 1fr) 280px;
|
||||
@@ -6824,17 +7244,17 @@ body {
|
||||
}
|
||||
|
||||
.report-viewer-stage {
|
||||
display: flex;
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
overflow: auto;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.report-canvas {
|
||||
align-self: flex-start;
|
||||
width: max-content;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(32, 39, 36, 0.14);
|
||||
@@ -7061,7 +7481,9 @@ body {
|
||||
border: 1.5px solid var(--wxex-border);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
|
||||
.report-template-item.active {
|
||||
@@ -7114,7 +7536,10 @@ body {
|
||||
background: #fff;
|
||||
color: var(--wxex-text-primary, #1f2933);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s,
|
||||
border-color 0.2s;
|
||||
}
|
||||
|
||||
.report-template-preview-btn:hover {
|
||||
|
||||
@@ -20,7 +20,6 @@ interface ChatWindowProps {
|
||||
isAiLoading?: boolean
|
||||
}
|
||||
|
||||
const MAX_RENDERED_MESSAGES = 600
|
||||
const DATE_RANGE_LABELS: Record<string, string> = {
|
||||
today: '今天',
|
||||
yesterday: '昨日',
|
||||
@@ -242,12 +241,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
return typeMatch && contentMatch
|
||||
})
|
||||
}, [messages, contentFilter])
|
||||
const hiddenMessageCount = Math.max(0, filteredMessages.length - MAX_RENDERED_MESSAGES)
|
||||
const renderedMessages = React.useMemo(
|
||||
() => filteredMessages.slice(-MAX_RENDERED_MESSAGES),
|
||||
[filteredMessages]
|
||||
)
|
||||
|
||||
if (!contact) return <EmptyConversationState />
|
||||
|
||||
const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
|
||||
@@ -272,8 +265,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
<DataTrustBar messageCount={messages.length} />
|
||||
<MessageList
|
||||
contact={contact}
|
||||
messages={renderedMessages}
|
||||
hiddenMessageCount={hiddenMessageCount}
|
||||
messages={filteredMessages}
|
||||
hiddenMessageCount={0}
|
||||
isLoadingMessages={isLoadingMessages}
|
||||
isGroupChat={isGroupChat}
|
||||
showAvatar={showAvatar}
|
||||
@@ -283,7 +276,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
onImageClick={openImagePreview}
|
||||
/>
|
||||
<ChatStatusBar
|
||||
count={renderedMessages.length}
|
||||
count={filteredMessages.length}
|
||||
showAvatar={showAvatar}
|
||||
isAtLatest={isAtLatest}
|
||||
onShowAvatarChange={setShowAvatar}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import React from 'react'
|
||||
|
||||
export type DatabaseConnectionMode = 'automatic' | 'manual'
|
||||
export type DatabaseConnectionStatusKind = 'normal' | 'success' | 'error'
|
||||
|
||||
interface DatabaseConnectionPageProps {
|
||||
platform: string
|
||||
mode: DatabaseConnectionMode
|
||||
dbKey: string
|
||||
dbRoot: string
|
||||
showDbKey: boolean
|
||||
isFetching: boolean
|
||||
status: string
|
||||
statusKind: DatabaseConnectionStatusKind
|
||||
showMacKeyFaq: boolean
|
||||
macKeyFaqUrl: string
|
||||
onModeChange: (mode: DatabaseConnectionMode) => void
|
||||
onDbKeyChange: (value: string) => void
|
||||
onDbRootChange: (value: string) => void
|
||||
onToggleDbKey: () => void
|
||||
onAutoGetKey: () => void
|
||||
onManualConnect: () => void
|
||||
onPasteKey: () => void
|
||||
onClearKey: () => void
|
||||
}
|
||||
|
||||
function LineIcon({
|
||||
name
|
||||
}: {
|
||||
name: 'shield' | 'lock' | 'cloud' | 'info' | 'database'
|
||||
}): React.ReactElement {
|
||||
const paths = {
|
||||
shield: <path d="M12 3 5 6v5c0 4.5 2.8 7.7 7 10 4.2-2.3 7-5.5 7-10V6l-7-3Z" />,
|
||||
lock: <path d="M7 10V8a5 5 0 0 1 10 0v2m-11 0h12v10H6V10Z" />,
|
||||
cloud: (
|
||||
<path d="m4 4 16 16M7.5 16H6a4 4 0 0 1-.5-8A6.5 6.5 0 0 1 17 6.8M18.5 10A4 4 0 0 1 18 18h-7" />
|
||||
),
|
||||
info: <path d="M12 8h.01M11 12h1v4h1M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z" />,
|
||||
database: (
|
||||
<path d="M5 6c0-1.7 3.1-3 7-3s7 1.3 7 3-3.1 3-7 3-7-1.3-7-3Zm0 0v6c0 1.7 3.1 3 7 3s7-1.3 7-3V6m-14 6v6c0 1.7 3.1 3 7 3s7-1.3 7-3v-6" />
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{paths[name]}
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function EyeIcon({ visible }: { visible: boolean }): React.ReactElement {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
{!visible && <path d="M4 4l16 16" />}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function StoragePathHelp(): React.ReactElement {
|
||||
return (
|
||||
<span className="database-login-path-help">
|
||||
<span
|
||||
className="database-login-path-help-icon"
|
||||
tabIndex={0}
|
||||
aria-describedby="storage-path-help"
|
||||
>
|
||||
!
|
||||
</span>
|
||||
<span id="storage-path-help" className="database-login-path-tooltip" role="tooltip">
|
||||
打开微信设置,在缓存管理中复制存储路径,然后粘贴到这里。
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function DatabaseConnectionPage({
|
||||
platform,
|
||||
mode,
|
||||
dbKey,
|
||||
dbRoot,
|
||||
showDbKey,
|
||||
isFetching,
|
||||
status,
|
||||
statusKind,
|
||||
showMacKeyFaq,
|
||||
macKeyFaqUrl,
|
||||
onModeChange,
|
||||
onDbKeyChange,
|
||||
onDbRootChange,
|
||||
onToggleDbKey,
|
||||
onAutoGetKey,
|
||||
onManualConnect,
|
||||
onPasteKey,
|
||||
onClearKey
|
||||
}: DatabaseConnectionPageProps): React.ReactElement {
|
||||
const isMac = platform === 'darwin'
|
||||
const defaultPath = isMac
|
||||
? '~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/'
|
||||
: 'C:\\Users\\...\\WeChat Files\\Msg'
|
||||
const keyIsValid = /^[0-9a-f]{64}$/i.test(dbKey.trim().replace(/^0x/i, ''))
|
||||
|
||||
return (
|
||||
<main className="database-login-page">
|
||||
<section className="database-login-brand" aria-label="WechatExplorer 产品说明">
|
||||
<div className="database-login-brand-content">
|
||||
<div className="database-login-logo" aria-hidden="true">
|
||||
<LineIcon name="database" />
|
||||
</div>
|
||||
<h1>WechatExplorer</h1>
|
||||
<p className="database-login-tagline">你的本地微信聊天档案</p>
|
||||
<p className="database-login-description">
|
||||
连接本机微信数据库,开始检索、整理和分析聊天记录。
|
||||
</p>
|
||||
<div className="database-login-promises">
|
||||
<div>
|
||||
<LineIcon name="shield" />
|
||||
<span>仅限本机</span>
|
||||
</div>
|
||||
<div>
|
||||
<LineIcon name="lock" />
|
||||
<span>加密保存</span>
|
||||
</div>
|
||||
<div>
|
||||
<LineIcon name="cloud" />
|
||||
<span>不会上传</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="database-login-brand-footer">LOCAL-FIRST · PRIVATE · SECURE</div>
|
||||
</section>
|
||||
|
||||
<section className="database-login-workspace" aria-label="数据库连接">
|
||||
<div className="database-login-panel">
|
||||
<div className="database-login-tabs" role="tablist" aria-label="连接方式">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'automatic'}
|
||||
className={mode === 'automatic' ? 'active' : ''}
|
||||
onClick={() => onModeChange('automatic')}
|
||||
>
|
||||
自动获取
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'manual'}
|
||||
className={mode === 'manual' ? 'active' : ''}
|
||||
onClick={() => onModeChange('manual')}
|
||||
>
|
||||
手动输入
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'automatic' ? (
|
||||
<div className="database-login-auto" role="tabpanel">
|
||||
<div className={`database-login-state-card ${statusKind}`}>
|
||||
<div className="database-login-state-heading">
|
||||
<span className="database-login-state-icon">
|
||||
<LineIcon name="info" />
|
||||
</span>
|
||||
<div>
|
||||
<strong>
|
||||
{statusKind === 'error' ? '未能获取数据库密钥' : '已准备检测微信数据库'}
|
||||
</strong>
|
||||
<p>
|
||||
{statusKind === 'error'
|
||||
? status
|
||||
: status || '请保持微信客户端正在运行,系统将尝试安全获取数据库密钥。'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="database-login-diagnostics">
|
||||
<div>
|
||||
<dt>微信客户端</dt>
|
||||
<dd>{isFetching ? '正在检测' : '等待检测'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
存储路径
|
||||
<StoragePathHelp />
|
||||
</dt>
|
||||
<dd>
|
||||
<span className="database-login-path-input-wrap">
|
||||
<input
|
||||
type="text"
|
||||
value={dbRoot}
|
||||
onChange={(event) => onDbRootChange(event.target.value)}
|
||||
placeholder={defaultPath}
|
||||
title={dbRoot || defaultPath}
|
||||
aria-label="微信数据存储路径"
|
||||
spellCheck={false}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
<span className="database-login-path-value" role="status">
|
||||
{dbRoot || defaultPath}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>数据库状态</dt>
|
||||
<dd>{statusKind === 'error' ? '无法连接' : '准备连接'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-primary"
|
||||
onClick={onAutoGetKey}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching
|
||||
? '正在获取密钥…'
|
||||
: statusKind === 'error'
|
||||
? '重新检测'
|
||||
: '自动获取密钥'}
|
||||
</button>
|
||||
{showMacKeyFaq && (
|
||||
<a href={macKeyFaqUrl} target="_blank" rel="noreferrer">
|
||||
查看详情 · 连接帮助
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="database-login-manual" role="tabpanel">
|
||||
<div className="database-login-field">
|
||||
<label htmlFor="database-login-key">数据库密钥</label>
|
||||
<div className="database-login-key-input">
|
||||
<input
|
||||
id="database-login-key"
|
||||
type={showDbKey ? 'text' : 'password'}
|
||||
value={dbKey}
|
||||
onChange={(event) => onDbKeyChange(event.target.value)}
|
||||
placeholder="输入或粘贴 64 位数据库密钥"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleDbKey}
|
||||
title={showDbKey ? '隐藏密钥' : '显示密钥'}
|
||||
>
|
||||
<EyeIcon visible={showDbKey} />
|
||||
</button>
|
||||
</div>
|
||||
<small>密钥通过系统安全存储加密保存在当前设备。</small>
|
||||
</div>
|
||||
{platform === 'win32' && (
|
||||
<div className="database-login-field">
|
||||
<label htmlFor="database-login-root">
|
||||
微信数据目录
|
||||
<StoragePathHelp />
|
||||
</label>
|
||||
<input
|
||||
id="database-login-root"
|
||||
value={dbRoot}
|
||||
onChange={(event) => onDbRootChange(event.target.value)}
|
||||
placeholder={defaultPath}
|
||||
title={dbRoot || defaultPath}
|
||||
spellCheck={false}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{status && <div className={`database-login-message ${statusKind}`}>{status}</div>}
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-primary"
|
||||
onClick={onManualConnect}
|
||||
disabled={!keyIsValid}
|
||||
>
|
||||
连接数据库
|
||||
</button>
|
||||
<button type="button" className="database-login-secondary" onClick={onPasteKey}>
|
||||
从剪贴板粘贴并安全保存
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="database-login-footer-actions">
|
||||
<button type="button" onClick={onClearKey}>
|
||||
清除已保存密钥
|
||||
</button>
|
||||
<span>WechatExplorer</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { Contact, Message } from '../../../../shared/types'
|
||||
import { MessageGroup } from './MessageGroup'
|
||||
import { buildMessageGroups } from './messageGrouping'
|
||||
@@ -29,6 +30,16 @@ export function MessageList({
|
||||
onImageClick
|
||||
}: MessageListProps): React.ReactElement {
|
||||
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
|
||||
// TanStack Virtual intentionally exposes mutable measurement methods.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const virtualizer = useVirtualizer({
|
||||
count: groups.length,
|
||||
getScrollElement: () => listRef.current,
|
||||
estimateSize: () => 96,
|
||||
getItemKey: (index) => groups[index]?.id || index,
|
||||
overscan: 8
|
||||
})
|
||||
const virtualItems = virtualizer.getVirtualItems()
|
||||
|
||||
return (
|
||||
<div className="message-list wechat-message-list" ref={listRef} onScroll={onScroll}>
|
||||
@@ -40,16 +51,29 @@ export function MessageList({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{groups.map((group) => (
|
||||
<MessageGroup
|
||||
key={group.id}
|
||||
group={group}
|
||||
contact={contact}
|
||||
isGroupChat={isGroupChat}
|
||||
showAvatar={showAvatar}
|
||||
onImageClick={onImageClick}
|
||||
/>
|
||||
))}
|
||||
<div className="virtual-message-list" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const group = groups[virtualItem.index]
|
||||
if (!group) return null
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={virtualItem.index}
|
||||
className="virtual-message-group"
|
||||
style={{ transform: `translateY(${virtualItem.start}px)` }}
|
||||
>
|
||||
<MessageGroup
|
||||
group={group}
|
||||
contact={contact}
|
||||
isGroupChat={isGroupChat}
|
||||
showAvatar={showAvatar}
|
||||
onImageClick={onImageClick}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { ReportGenerationPhase } from '../../hooks/useGroupReportGeneration'
|
||||
|
||||
interface ReportTaskStatusPanelProps {
|
||||
@@ -27,6 +27,14 @@ export function ReportTaskStatusPanel({
|
||||
}: ReportTaskStatusPanelProps): React.ReactElement {
|
||||
const activeIndex = phaseIndex(phase)
|
||||
const completedAll = phase === 'success'
|
||||
const [logPath, setLogPath] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
void window.api
|
||||
.getAppLogPath()
|
||||
.then(setLogPath)
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<aside className="report-task-panel">
|
||||
@@ -70,6 +78,10 @@ export function ReportTaskStatusPanel({
|
||||
<button type="button" onClick={onRetry}>
|
||||
重试
|
||||
</button>
|
||||
<button type="button" onClick={() => void window.api.revealAppLog()}>
|
||||
打开诊断日志
|
||||
</button>
|
||||
{logPath && <small className="report-task-log-path">{logPath}</small>}
|
||||
</div>
|
||||
)}
|
||||
{phase === 'success' && (
|
||||
|
||||
@@ -37,10 +37,7 @@ export function ReportViewer({
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [report?.id])
|
||||
|
||||
const title = useMemo(
|
||||
() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'),
|
||||
[report]
|
||||
)
|
||||
const title = useMemo(() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'), [report])
|
||||
|
||||
const fitWidth = (): void => {
|
||||
const viewport = viewportRef.current
|
||||
@@ -108,10 +105,16 @@ export function ReportViewer({
|
||||
}}
|
||||
onLoad={(event) => {
|
||||
const image = event.currentTarget
|
||||
setNaturalSize({
|
||||
const nextSize = {
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight
|
||||
})
|
||||
}
|
||||
setNaturalSize(nextSize)
|
||||
const viewport = viewportRef.current
|
||||
if (viewport && nextSize.width > viewport.clientWidth - 48) {
|
||||
const fittedZoom = Math.max(0.25, (viewport.clientWidth - 48) / nextSize.width)
|
||||
setZoom(Number(fittedZoom.toFixed(2)))
|
||||
}
|
||||
}}
|
||||
onError={() => {
|
||||
setImageError('日报图片加载失败')
|
||||
|
||||
@@ -195,8 +195,9 @@ export function AgentHubWorkspace(): React.ReactElement {
|
||||
<h2>微信数据助手</h2>
|
||||
<p>机器人通过本机 Agent Hub 调用 WechatExplorer,不向公网暴露数据库。</p>
|
||||
<div className="agent-hub-example">
|
||||
<span>可以这样问</span>
|
||||
<span>支持自然语言,可以这样问</span>
|
||||
<strong>“最近 5 条消息是谁?”</strong>
|
||||
<strong>“帮我看看最近跟xx聊了些什么”</strong>
|
||||
<strong>“生成产品交流群今天的群聊总结图片”</strong>
|
||||
</div>
|
||||
<ul>
|
||||
@@ -212,6 +213,10 @@ export function AgentHubWorkspace(): React.ReactElement {
|
||||
<i />
|
||||
消息重复保护
|
||||
</li>
|
||||
<li>
|
||||
<i />
|
||||
使用已配置 AI 理解自然语言
|
||||
</li>
|
||||
<li>
|
||||
<i className={status.dataApi === 'online' ? '' : 'offline'} />
|
||||
本地数据 API:{status.dataApi === 'online' ? '已连接' : '未连接'}
|
||||
|
||||
@@ -191,7 +191,9 @@ export function useDatabaseKeyController({
|
||||
])
|
||||
|
||||
const returnToLogin = useCallback(async (): Promise<void> => {
|
||||
const result = await window.api.disconnectDb()
|
||||
// macOS WCDB 的 close 会关闭进程级原生运行时,返回登录时只退出 UI 连接态。
|
||||
// 用户再次点击连接后由 db:init 复用已验证的本机连接。
|
||||
const result = await window.api.disconnectDb({ closeNative: false })
|
||||
if (!result.success) {
|
||||
onNotice(result.error || '断开数据库连接失败')
|
||||
return
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Contact, Message } from '../../../shared/types'
|
||||
import {
|
||||
buildGroupReportInput,
|
||||
getSummaryDateRange,
|
||||
GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT,
|
||||
GROUP_REPORT_SYSTEM_PROMPT,
|
||||
isInternalName,
|
||||
parseGroupDailyReport,
|
||||
@@ -108,6 +109,31 @@ const withTimeout = async <T>(
|
||||
const errorMessage = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error)
|
||||
|
||||
const writeReportLog = (
|
||||
level: 'info' | 'warn' | 'error',
|
||||
message: string,
|
||||
details?: Record<string, unknown>
|
||||
): void => {
|
||||
void window.api
|
||||
.writeAppLog({ level, scope: 'group-report', message, details })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
const jsonErrorContext = (raw: string, error: unknown): Record<string, unknown> => {
|
||||
const message = errorMessage(error)
|
||||
const position = Number(/\bposition\s+(\d+)/i.exec(message)?.[1])
|
||||
const safePosition = Number.isFinite(position) ? Math.max(0, Math.min(raw.length, position)) : 0
|
||||
return {
|
||||
error: message,
|
||||
outputLength: raw.length,
|
||||
position: Number.isFinite(position) ? position : undefined,
|
||||
context:
|
||||
raw.length && Number.isFinite(position)
|
||||
? raw.slice(Math.max(0, safePosition - 300), Math.min(raw.length, safePosition + 300))
|
||||
: raw.slice(0, 600)
|
||||
}
|
||||
}
|
||||
|
||||
const isGroupContact = (contact: Contact | null): boolean =>
|
||||
Boolean(contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom'))
|
||||
|
||||
@@ -136,6 +162,16 @@ const estimateTokenUsage = (
|
||||
}
|
||||
}
|
||||
|
||||
const mergeTokenUsage = (
|
||||
first: NonNullable<ReportGenerationMetadata['tokenUsage']>,
|
||||
second: NonNullable<ReportGenerationMetadata['tokenUsage']>
|
||||
): NonNullable<ReportGenerationMetadata['tokenUsage']> => ({
|
||||
input: (first.input || 0) + (second.input || 0),
|
||||
output: (first.output || 0) + (second.output || 0),
|
||||
total: (first.total || 0) + (second.total || 0),
|
||||
estimated: Boolean(first.estimated || second.estimated)
|
||||
})
|
||||
|
||||
const applyGroupMemberNames = async (
|
||||
contact: Contact,
|
||||
messages: Message[],
|
||||
@@ -175,13 +211,18 @@ const applyGroupMemberNames = async (
|
||||
const member = memberMap.get(senderId)
|
||||
if (!member) return message
|
||||
const preferredNames: Record<ReportMemberNamePreference, string[]> = {
|
||||
groupNickname: [member.groupNickname, member.wechatNickname, member.remark, member.nickname],
|
||||
wechatNickname: [member.wechatNickname, member.groupNickname, member.remark, member.nickname],
|
||||
// Keep the three modes semantically distinct. `member.nickname` may
|
||||
// already be a contact remark, so it must not leak into the first two.
|
||||
groupNickname: [member.groupNickname, member.wechatNickname],
|
||||
wechatNickname: [member.wechatNickname, member.groupNickname],
|
||||
remark: [member.remark, member.groupNickname, member.wechatNickname, member.nickname]
|
||||
}
|
||||
const name = preferredNames[preference].find((value) => value && !isInternalName(value))
|
||||
if (!name) return message
|
||||
return { ...message, name, img: message.img || member.avatar }
|
||||
return {
|
||||
...message,
|
||||
name: name || (preference === 'remark' ? message.name : ''),
|
||||
img: message.img || member.avatar
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -222,11 +263,12 @@ export function useGroupReportGeneration({
|
||||
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
||||
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
|
||||
const [templateId, setTemplateId] = useState<ReportTemplateId>('v1')
|
||||
const [memberNamePreference, setMemberNamePreferenceState] =
|
||||
useState<ReportMemberNamePreference>(() => {
|
||||
const [memberNamePreference, setMemberNamePreferenceState] = useState<ReportMemberNamePreference>(
|
||||
() => {
|
||||
const saved = localStorage.getItem('group_report_member_name_preference')
|
||||
return saved === 'wechatNickname' || saved === 'remark' ? saved : 'groupNickname'
|
||||
})
|
||||
}
|
||||
)
|
||||
const [reportTimeoutSeconds, setReportTimeoutSecondsState] = useState<number>(() => {
|
||||
const saved = Number(localStorage.getItem('group_report_timeout_seconds'))
|
||||
return Number.isFinite(saved) && saved >= 30 ? saved : 300
|
||||
@@ -352,6 +394,7 @@ export function useGroupReportGeneration({
|
||||
}
|
||||
|
||||
const startGenerateTime = Date.now()
|
||||
let failedAt = '初始化'
|
||||
const logs: ReportGenerationLog[] = []
|
||||
const pushLog = (log: ReportGenerationLog): void => {
|
||||
logs.push(log)
|
||||
@@ -382,15 +425,29 @@ export function useGroupReportGeneration({
|
||||
modelName: modelConfig.model,
|
||||
generationLogs: []
|
||||
})
|
||||
writeReportLog('info', '开始生成群聊日报', {
|
||||
groupName: sourceContact.m_nsNickName || sourceContact.m_nsUsrName,
|
||||
dateRange: summaryDateRange,
|
||||
selectedMessageTypes: summaryMessageTypes,
|
||||
providerName: modelConfig.providerName,
|
||||
model: modelConfig.model,
|
||||
templateId
|
||||
})
|
||||
|
||||
try {
|
||||
failedAt = '读取聊天记录'
|
||||
const sourceMessages = await trackStep('读取聊天记录', () => loadRangeMessages(true))
|
||||
|
||||
const selectedTypes = selectedMessageTypeSet(summaryMessageTypes)
|
||||
const filteredMessages = sourceMessages.filter((message) => selectedTypes.has(message.type))
|
||||
if (!filteredMessages.length) throw new Error('当前范围没有可总结消息')
|
||||
writeReportLog('info', '聊天记录读取完成', {
|
||||
sourceMessageCount: sourceMessages.length,
|
||||
filteredMessageCount: filteredMessages.length
|
||||
})
|
||||
|
||||
setPhase('preparingInput')
|
||||
failedAt = '整理日报输入'
|
||||
const input = await trackStep('整理输入', async () => {
|
||||
const namedReportMessages = await applyGroupMemberNames(
|
||||
sourceContact,
|
||||
@@ -401,6 +458,7 @@ export function useGroupReportGeneration({
|
||||
})
|
||||
|
||||
setPhase('requestingModel')
|
||||
failedAt = '调用模型生成内容'
|
||||
const aiMessages = [
|
||||
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: input.prompt }
|
||||
@@ -417,22 +475,82 @@ export function useGroupReportGeneration({
|
||||
)
|
||||
)
|
||||
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
||||
writeReportLog('info', '模型响应完成', {
|
||||
outputLength: result.data.length,
|
||||
usage: result.usage
|
||||
})
|
||||
|
||||
const tokenUsage =
|
||||
let tokenUsage =
|
||||
result.usage && result.usage.total
|
||||
? result.usage
|
||||
: estimateTokenUsage(aiMessages, result.data)
|
||||
|
||||
const report = parseGroupDailyReport(
|
||||
result.data,
|
||||
input.topSpeakers,
|
||||
input.activeTimeline,
|
||||
input.voiceLeaderboard || [],
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
let report: ReturnType<typeof parseGroupDailyReport>
|
||||
try {
|
||||
report = parseGroupDailyReport(
|
||||
result.data,
|
||||
input.topSpeakers,
|
||||
input.activeTimeline,
|
||||
input.voiceLeaderboard || [],
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
} catch (parseError) {
|
||||
writeReportLog('warn', '本地修复日报 JSON 失败,尝试由模型纠正', {
|
||||
...jsonErrorContext(result.data, parseError),
|
||||
retry: 1
|
||||
})
|
||||
const repairMessages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT
|
||||
},
|
||||
{ role: 'user', content: result.data }
|
||||
]
|
||||
const repairResult = await trackStep('AI 修复 JSON', () =>
|
||||
withTimeout(
|
||||
window.api.aiChat(repairMessages, {
|
||||
providerId: modelConfig.providerId,
|
||||
modelId: modelConfig.model,
|
||||
timeoutMs: reportTimeoutSeconds * 1000
|
||||
}),
|
||||
'AI 修复日报 JSON',
|
||||
reportTimeoutSeconds * 1000 + REPORT_MODEL_TIMEOUT_BUFFER_MS
|
||||
)
|
||||
)
|
||||
if (!repairResult.success || !repairResult.data) {
|
||||
throw new Error(repairResult.error || 'AI 修复日报 JSON 失败', { cause: parseError })
|
||||
}
|
||||
const repairUsage =
|
||||
repairResult.usage && repairResult.usage.total
|
||||
? repairResult.usage
|
||||
: estimateTokenUsage(repairMessages, repairResult.data)
|
||||
tokenUsage = mergeTokenUsage(tokenUsage, repairUsage)
|
||||
try {
|
||||
report = parseGroupDailyReport(
|
||||
repairResult.data,
|
||||
input.topSpeakers,
|
||||
input.activeTimeline,
|
||||
input.voiceLeaderboard || [],
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
writeReportLog('info', '模型已纠正日报 JSON', {
|
||||
retry: 1,
|
||||
outputLength: repairResult.data.length
|
||||
})
|
||||
} catch (retryParseError) {
|
||||
writeReportLog(
|
||||
'error',
|
||||
'日报 JSON 重试后仍解析失败',
|
||||
jsonErrorContext(repairResult.data, retryParseError)
|
||||
)
|
||||
throw retryParseError
|
||||
}
|
||||
}
|
||||
|
||||
setPhase('exportingReport')
|
||||
failedAt = '导出 HTML 与 PNG'
|
||||
const exported = await withTimeout(
|
||||
window.api.exportGroupReport({ report, metadata: input.metadata, templateId }),
|
||||
'日报图片导出'
|
||||
@@ -468,8 +586,19 @@ export function useGroupReportGeneration({
|
||||
generationLogs: [...logs]
|
||||
})
|
||||
setPhase('success')
|
||||
writeReportLog('info', '群聊日报生成成功', {
|
||||
durationMs: Date.now() - startGenerateTime,
|
||||
htmlPath: exported.htmlPath,
|
||||
pngPath: exported.pngPath
|
||||
})
|
||||
} catch (generateError) {
|
||||
setError(errorMessage(generateError))
|
||||
const message = errorMessage(generateError)
|
||||
writeReportLog('error', '群聊日报生成失败', {
|
||||
error: message,
|
||||
failedAt,
|
||||
durationMs: Date.now() - startGenerateTime
|
||||
})
|
||||
setError(message)
|
||||
setPhase('error')
|
||||
}
|
||||
}, [
|
||||
@@ -479,6 +608,7 @@ export function useGroupReportGeneration({
|
||||
modelConfig,
|
||||
reportTimeoutSeconds,
|
||||
sourceContact,
|
||||
summaryDateRange,
|
||||
summaryMessageTypes,
|
||||
templateId
|
||||
])
|
||||
|
||||
@@ -4,6 +4,37 @@ import App from './App'
|
||||
import './styles/tokens.css'
|
||||
import './assets/main.css'
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
void window.api
|
||||
.writeAppLog({
|
||||
level: 'error',
|
||||
scope: 'renderer',
|
||||
message: event.message || 'Renderer 未捕获错误',
|
||||
details: {
|
||||
filename: event.filename,
|
||||
line: event.lineno,
|
||||
column: event.colno,
|
||||
stack: event.error instanceof Error ? event.error.stack : undefined
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
})
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const reason = event.reason
|
||||
void window.api
|
||||
.writeAppLog({
|
||||
level: 'error',
|
||||
scope: 'renderer',
|
||||
message: reason instanceof Error ? reason.message : 'Renderer Promise 未处理拒绝',
|
||||
details: {
|
||||
stack: reason instanceof Error ? reason.stack : undefined,
|
||||
reason: reason instanceof Error ? undefined : String(reason)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ReportVoiceLeaderboardItem
|
||||
} from '../../../shared/group-report'
|
||||
import { Contact, Message } from '../../../shared/types'
|
||||
import { jsonrepair } from 'jsonrepair'
|
||||
import { buildGroupReportFacts } from './group-report-facts'
|
||||
|
||||
export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。
|
||||
@@ -73,6 +74,9 @@ JSON 结构必须为:
|
||||
- 完整版:可以保留更多候选项,但仍要去重和排序。
|
||||
- 如果某个字段不确定,请返回 null 或空数组,不要猜。`
|
||||
|
||||
export const GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT =
|
||||
'你是 JSON 格式修复器。只修复输入中的 JSON 语法,不改写、删减或新增任何业务内容。只输出一个可被 JSON.parse 解析的 JSON 对象,不要输出 Markdown 或解释。'
|
||||
|
||||
export interface GroupReportInput {
|
||||
prompt: string
|
||||
metadata: GroupReportMetadata
|
||||
@@ -259,7 +263,19 @@ const extractJson = (raw: string): unknown => {
|
||||
const start = cleaned.indexOf('{')
|
||||
const end = cleaned.lastIndexOf('}')
|
||||
if (start < 0 || end <= start) throw new Error('AI 未返回可解析的日报 JSON')
|
||||
return JSON.parse(cleaned.slice(start, end + 1))
|
||||
const json = cleaned.slice(start, end + 1)
|
||||
try {
|
||||
return JSON.parse(json)
|
||||
} catch (strictError) {
|
||||
try {
|
||||
return JSON.parse(jsonrepair(json))
|
||||
} catch (repairError) {
|
||||
throw new Error(
|
||||
`日报 JSON 自动修复失败:${repairError instanceof Error ? repairError.message : String(repairError)}`,
|
||||
{ cause: strictError }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createSignature = (...parts: Array<string | null | undefined>): string =>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export type AppLogLevel = 'info' | 'warn' | 'error'
|
||||
|
||||
export interface AppLogEntry {
|
||||
level: AppLogLevel
|
||||
scope: string
|
||||
message: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
Reference in New Issue
Block a user