mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-20 13:06:58 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ae9e6019 | ||
|
|
49684f3365 |
@@ -9,10 +9,23 @@ const imageDecryptLog = (...args: unknown[]): void => {
|
|||||||
if (imageDecryptDebugEnabled) console.log(...args)
|
if (imageDecryptDebugEnabled) console.log(...args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DecodedImage = {
|
||||||
|
data: string
|
||||||
|
filePath: string
|
||||||
|
isThumbnail: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DECODED_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
|
||||||
|
|
||||||
export class ImageDecryptService {
|
export class ImageDecryptService {
|
||||||
private xorKey: number = 0
|
private xorKey: number = 0
|
||||||
private aesKey: string = ''
|
private aesKey: string = ''
|
||||||
private wcdb4Client: Wcdb4Client | null = null
|
private wcdb4Client: Wcdb4Client | null = null
|
||||||
|
private accountDirResolved = false
|
||||||
|
private cachedAccountDir: string | null = null
|
||||||
|
private imagePathCache = new Map<string, string>()
|
||||||
|
private decodedImageCache = new Map<string, DecodedImage>()
|
||||||
|
private decodedImageCacheBytes = 0
|
||||||
|
|
||||||
constructor(xorKey: string, aesKey: string, wcdb4Client?: Wcdb4Client | null) {
|
constructor(xorKey: string, aesKey: string, wcdb4Client?: Wcdb4Client | null) {
|
||||||
// 解析 XOR Key (支持 0x40 或 64 格式)
|
// 解析 XOR Key (支持 0x40 或 64 格式)
|
||||||
@@ -32,9 +45,13 @@ export class ImageDecryptService {
|
|||||||
* 获取账号目录
|
* 获取账号目录
|
||||||
*/
|
*/
|
||||||
private getAccountDir(): string | null {
|
private getAccountDir(): string | null {
|
||||||
|
if (this.accountDirResolved) return this.cachedAccountDir
|
||||||
|
this.accountDirResolved = true
|
||||||
|
|
||||||
const wcdbAccountRoot = this.wcdb4Client?.getAccountRoot()
|
const wcdbAccountRoot = this.wcdb4Client?.getAccountRoot()
|
||||||
if (wcdbAccountRoot && existsSync(wcdbAccountRoot)) {
|
if (wcdbAccountRoot && existsSync(wcdbAccountRoot)) {
|
||||||
return wcdbAccountRoot
|
this.cachedAccountDir = wcdbAccountRoot
|
||||||
|
return this.cachedAccountDir
|
||||||
}
|
}
|
||||||
|
|
||||||
const homeDir = os.homedir()
|
const homeDir = os.homedir()
|
||||||
@@ -69,7 +86,8 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 返回最新的账号目录
|
// 返回最新的账号目录
|
||||||
return join(accountRoot, accounts[0].name)
|
this.cachedAccountDir = join(accountRoot, accounts[0].name)
|
||||||
|
return this.cachedAccountDir
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,16 +96,32 @@ export class ImageDecryptService {
|
|||||||
findImageFile(
|
findImageFile(
|
||||||
md5?: string,
|
md5?: string,
|
||||||
imageDatName?: string,
|
imageDatName?: string,
|
||||||
options?: { allowThumbnail?: boolean; accountDir?: string }
|
options?: { allowThumbnail?: boolean; accountDir?: string; preferThumbnail?: boolean }
|
||||||
): string | null {
|
): string | null {
|
||||||
// 测试场景下可显式指定根目录;不传则维持原 getAccountDir() 行为
|
|
||||||
const accountDir =
|
|
||||||
options?.accountDir && existsSync(options.accountDir) ? options.accountDir : this.getAccountDir()
|
|
||||||
if (!accountDir) return null
|
|
||||||
const allowThumbnail = options?.allowThumbnail !== false
|
const allowThumbnail = options?.allowThumbnail !== false
|
||||||
|
|
||||||
const normalizedMd5 = this.normalizeDatBase(md5 || '')
|
const normalizedMd5 = this.normalizeDatBase(md5 || '')
|
||||||
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
|
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
|
||||||
|
const pathCacheKey = [
|
||||||
|
normalizedMd5,
|
||||||
|
normalizedDatName,
|
||||||
|
allowThumbnail ? 'thumb' : 'original',
|
||||||
|
options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original',
|
||||||
|
options?.accountDir || ''
|
||||||
|
].join('|')
|
||||||
|
const cachedPath = this.imagePathCache.get(pathCacheKey)
|
||||||
|
if (cachedPath && existsSync(cachedPath)) return cachedPath
|
||||||
|
|
||||||
|
const rememberPath = (path: string | null): string | null => {
|
||||||
|
if (path) this.imagePathCache.set(pathCacheKey, path)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试场景下可显式指定根目录;不传则维持原 getAccountDir() 行为
|
||||||
|
const accountDir =
|
||||||
|
options?.accountDir && existsSync(options.accountDir)
|
||||||
|
? options.accountDir
|
||||||
|
: this.getAccountDir()
|
||||||
|
if (!accountDir) return null
|
||||||
imageDecryptLog('[ImageDecrypt] findImageFile:', {
|
imageDecryptLog('[ImageDecrypt] findImageFile:', {
|
||||||
md5: normalizedMd5,
|
md5: normalizedMd5,
|
||||||
imageDatName: normalizedDatName,
|
imageDatName: normalizedDatName,
|
||||||
@@ -99,10 +133,14 @@ export class ImageDecryptService {
|
|||||||
const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
|
const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
|
||||||
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
|
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
|
||||||
if (fullPath && existsSync(fullPath)) {
|
if (fullPath && existsSync(fullPath)) {
|
||||||
const selected = this.getPreferredDatVariantPath(fullPath, allowThumbnail)
|
const selected = this.getPreferredDatVariantPath(
|
||||||
|
fullPath,
|
||||||
|
allowThumbnail,
|
||||||
|
options?.preferThumbnail
|
||||||
|
)
|
||||||
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
|
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
|
||||||
imageDecryptLog('[ImageDecrypt] hardlink hit:', selected)
|
imageDecryptLog('[ImageDecrypt] hardlink hit:', selected)
|
||||||
return selected
|
return rememberPath(selected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,28 +149,75 @@ export class ImageDecryptService {
|
|||||||
const attachDir = join(accountDir, 'msg', 'attach')
|
const attachDir = join(accountDir, 'msg', 'attach')
|
||||||
if (!existsSync(attachDir)) {
|
if (!existsSync(attachDir)) {
|
||||||
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
|
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
|
||||||
return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName)
|
return rememberPath(
|
||||||
|
this.findImageFileInLegacyDirs(
|
||||||
|
accountDir,
|
||||||
|
normalizedMd5 || normalizedDatName,
|
||||||
|
allowThumbnail,
|
||||||
|
options?.preferThumbnail
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchKeys = this.uniq([normalizedMd5, normalizedDatName])
|
const searchKeys = this.uniq([normalizedMd5, normalizedDatName])
|
||||||
if (searchKeys.length === 0) return null
|
if (searchKeys.length === 0) return null
|
||||||
|
|
||||||
for (const key of searchKeys) {
|
for (const key of searchKeys) {
|
||||||
const directHit = this.fastProbabilisticSearch(attachDir, key, allowThumbnail)
|
const directHit = this.fastProbabilisticSearch(
|
||||||
if (directHit) return directHit
|
attachDir,
|
||||||
|
key,
|
||||||
|
allowThumbnail,
|
||||||
|
options?.preferThumbnail
|
||||||
|
)
|
||||||
|
if (directHit) return rememberPath(directHit)
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail)
|
const legacyHit = this.findImageFileInLegacyDirs(
|
||||||
if (legacyHit) return legacyHit
|
accountDir,
|
||||||
|
searchKeys[0],
|
||||||
|
allowThumbnail,
|
||||||
|
options?.preferThumbnail
|
||||||
|
)
|
||||||
|
if (legacyHit) return rememberPath(legacyHit)
|
||||||
|
|
||||||
imageDecryptLog('[ImageDecrypt] findImageFile miss for:', searchKeys)
|
imageDecryptLog('[ImageDecrypt] findImageFile miss for:', searchKeys)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCachedDecodedImage(key: string): DecodedImage | null {
|
||||||
|
const cached = this.decodedImageCache.get(key)
|
||||||
|
if (!cached) return null
|
||||||
|
this.decodedImageCache.delete(key)
|
||||||
|
this.decodedImageCache.set(key, cached)
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheDecodedImage(key: string, image: DecodedImage): void {
|
||||||
|
const size = image.data.length * 2
|
||||||
|
const previous = this.decodedImageCache.get(key)
|
||||||
|
if (previous) {
|
||||||
|
this.decodedImageCacheBytes -= previous.data.length * 2
|
||||||
|
this.decodedImageCache.delete(key)
|
||||||
|
}
|
||||||
|
this.decodedImageCache.set(key, image)
|
||||||
|
this.decodedImageCacheBytes += size
|
||||||
|
while (
|
||||||
|
this.decodedImageCacheBytes > MAX_DECODED_IMAGE_CACHE_BYTES &&
|
||||||
|
this.decodedImageCache.size > 1
|
||||||
|
) {
|
||||||
|
const oldestKey = this.decodedImageCache.keys().next().value
|
||||||
|
if (!oldestKey) break
|
||||||
|
const oldest = this.decodedImageCache.get(oldestKey)
|
||||||
|
this.decodedImageCache.delete(oldestKey)
|
||||||
|
this.decodedImageCacheBytes -= oldest?.data.length ? oldest.data.length * 2 : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fastProbabilisticSearch(
|
private fastProbabilisticSearch(
|
||||||
attachDir: string,
|
attachDir: string,
|
||||||
datName: string,
|
datName: string,
|
||||||
allowThumbnail = true
|
allowThumbnail = true,
|
||||||
|
preferThumbnail = false
|
||||||
): string | null {
|
): string | null {
|
||||||
const normalized = this.normalizeDatBase(datName)
|
const normalized = this.normalizeDatBase(datName)
|
||||||
if (!normalized) return null
|
if (!normalized) return null
|
||||||
@@ -149,7 +234,7 @@ export class ImageDecryptService {
|
|||||||
join(attachDir, dir1, dir2, 'Image', variant),
|
join(attachDir, dir1, dir2, 'Image', variant),
|
||||||
join(attachDir, dir1, dir2, 'image', variant)
|
join(attachDir, dir1, dir2, 'image', variant)
|
||||||
]
|
]
|
||||||
const found = this.getLargestExistingPath(candidates, allowThumbnail)
|
const found = this.getLargestExistingPath(candidates, allowThumbnail, preferThumbnail)
|
||||||
if (found) {
|
if (found) {
|
||||||
imageDecryptLog('[ImageDecrypt] prefix path hit:', found)
|
imageDecryptLog('[ImageDecrypt] prefix path hit:', found)
|
||||||
return found
|
return found
|
||||||
@@ -177,7 +262,8 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
const found = this.getLargestExistingPath(
|
const found = this.getLargestExistingPath(
|
||||||
variants.map((variant) => join(imgDir, variant)),
|
variants.map((variant) => join(imgDir, variant)),
|
||||||
allowThumbnail
|
allowThumbnail,
|
||||||
|
preferThumbnail
|
||||||
)
|
)
|
||||||
if (found) {
|
if (found) {
|
||||||
imageDecryptLog('[ImageDecrypt] found at:', found)
|
imageDecryptLog('[ImageDecrypt] found at:', found)
|
||||||
@@ -196,7 +282,8 @@ export class ImageDecryptService {
|
|||||||
private findImageFileInLegacyDirs(
|
private findImageFileInLegacyDirs(
|
||||||
accountDir: string,
|
accountDir: string,
|
||||||
datName: string,
|
datName: string,
|
||||||
allowThumbnail = true
|
allowThumbnail = true,
|
||||||
|
preferThumbnail = false
|
||||||
): string | null {
|
): string | null {
|
||||||
const normalized = this.normalizeDatBase(datName)
|
const normalized = this.normalizeDatBase(datName)
|
||||||
if (!normalized) return null
|
if (!normalized) return null
|
||||||
@@ -208,7 +295,7 @@ export class ImageDecryptService {
|
|||||||
].filter((root) => existsSync(root))
|
].filter((root) => existsSync(root))
|
||||||
|
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail)
|
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail, preferThumbnail)
|
||||||
if (found) return found
|
if (found) return found
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,30 +306,52 @@ export class ImageDecryptService {
|
|||||||
dir: string,
|
dir: string,
|
||||||
datName: string,
|
datName: string,
|
||||||
depth: number,
|
depth: number,
|
||||||
allowThumbnail = true
|
allowThumbnail = true,
|
||||||
|
preferThumbnail = false
|
||||||
): string | null {
|
): string | null {
|
||||||
if (depth < 0) return null
|
if (depth < 0) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const variants = new Set(
|
const variantNames = this.buildPreferredDatNames(datName).filter(
|
||||||
this.buildPreferredDatNames(datName).filter(
|
|
||||||
(name) => allowThumbnail || !this.isThumbnailName(name)
|
(name) => allowThumbnail || !this.isThumbnailName(name)
|
||||||
)
|
)
|
||||||
|
const variants = new Set(
|
||||||
|
preferThumbnail
|
||||||
|
? [
|
||||||
|
...variantNames.filter((name) => this.isThumbnailName(name)),
|
||||||
|
...variantNames.filter((name) => !this.isThumbnailName(name))
|
||||||
|
]
|
||||||
|
: variantNames
|
||||||
)
|
)
|
||||||
const entries = readdirSync(dir)
|
const entries = readdirSync(dir)
|
||||||
|
const matchingFiles: string[] = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = join(dir, entry)
|
const fullPath = join(dir, entry)
|
||||||
const stat = statSync(fullPath)
|
const stat = statSync(fullPath)
|
||||||
if (stat.isFile() && variants.has(entry.toLowerCase())) {
|
if (stat.isFile() && variants.has(entry.toLowerCase())) {
|
||||||
imageDecryptLog('[ImageDecrypt] legacy path hit:', fullPath)
|
matchingFiles.push(fullPath)
|
||||||
return fullPath
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const preferredFile = this.getLargestExistingPath(
|
||||||
|
matchingFiles,
|
||||||
|
allowThumbnail,
|
||||||
|
preferThumbnail
|
||||||
|
)
|
||||||
|
if (preferredFile) {
|
||||||
|
imageDecryptLog('[ImageDecrypt] legacy path hit:', preferredFile)
|
||||||
|
return preferredFile
|
||||||
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = join(dir, entry)
|
const fullPath = join(dir, entry)
|
||||||
if (!statSync(fullPath).isDirectory()) continue
|
if (!statSync(fullPath).isDirectory()) continue
|
||||||
const found = this.recursiveFindDat(fullPath, datName, depth - 1, allowThumbnail)
|
const found = this.recursiveFindDat(
|
||||||
|
fullPath,
|
||||||
|
datName,
|
||||||
|
depth - 1,
|
||||||
|
allowThumbnail,
|
||||||
|
preferThumbnail
|
||||||
|
)
|
||||||
if (found) return found
|
if (found) return found
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -491,7 +600,11 @@ export class ImageDecryptService {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPreferredDatVariantPath(inputPath: string, allowThumbnail: boolean): string {
|
private getPreferredDatVariantPath(
|
||||||
|
inputPath: string,
|
||||||
|
allowThumbnail: boolean,
|
||||||
|
preferThumbnail = false
|
||||||
|
): string {
|
||||||
const actualDir = dirname(inputPath)
|
const actualDir = dirname(inputPath)
|
||||||
const base = this.normalizeDatBase(basename(inputPath))
|
const base = this.normalizeDatBase(basename(inputPath))
|
||||||
const variants = this.buildPreferredDatNames(base)
|
const variants = this.buildPreferredDatNames(base)
|
||||||
@@ -500,13 +613,18 @@ export class ImageDecryptService {
|
|||||||
: variants.filter((name) => !this.isThumbnailName(name))
|
: variants.filter((name) => !this.isThumbnailName(name))
|
||||||
const largest = this.getLargestExistingPath(
|
const largest = this.getLargestExistingPath(
|
||||||
ordered.map((variant) => join(actualDir, variant)),
|
ordered.map((variant) => join(actualDir, variant)),
|
||||||
allowThumbnail
|
allowThumbnail,
|
||||||
|
preferThumbnail
|
||||||
)
|
)
|
||||||
if (largest) return largest
|
if (largest) return largest
|
||||||
return inputPath
|
return inputPath
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLargestExistingPath(paths: string[], allowThumbnail: boolean): string | null {
|
private getLargestExistingPath(
|
||||||
|
paths: string[],
|
||||||
|
allowThumbnail: boolean,
|
||||||
|
preferThumbnail = false
|
||||||
|
): string | null {
|
||||||
const toSized = (candidates: string[]): { candidate: string; size: number }[] =>
|
const toSized = (candidates: string[]): { candidate: string; size: number }[] =>
|
||||||
candidates
|
candidates
|
||||||
.filter((candidate) => existsSync(candidate))
|
.filter((candidate) => existsSync(candidate))
|
||||||
@@ -519,6 +637,10 @@ export class ImageDecryptService {
|
|||||||
})
|
})
|
||||||
.sort((left, right) => right.size - left.size)
|
.sort((left, right) => right.size - left.size)
|
||||||
|
|
||||||
|
const thumbnail = toSized(
|
||||||
|
paths.filter((candidate) => this.isThumbnailName(basename(candidate)))
|
||||||
|
)
|
||||||
|
if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate
|
||||||
const nonThumb = toSized(
|
const nonThumb = toSized(
|
||||||
paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))
|
paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))
|
||||||
)
|
)
|
||||||
|
|||||||
+32
-5
@@ -579,7 +579,13 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'db:getMessages',
|
'db:getMessages',
|
||||||
async (_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => {
|
async (
|
||||||
|
_,
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
options?: { limit?: number }
|
||||||
|
) => {
|
||||||
const messages = await chat.listMessagesAsync(userMd5, startTime, endTime, options)
|
const messages = await chat.listMessagesAsync(userMd5, startTime, endTime, options)
|
||||||
if (chat.isReady()) {
|
if (chat.isReady()) {
|
||||||
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
|
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
|
||||||
@@ -654,7 +660,6 @@ app.whenReady().then(async () => {
|
|||||||
return { success: false, error: String(error) }
|
return { success: false, error: String(error) }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('report:listGenerated', async () => {
|
ipcMain.handle('report:listGenerated', async () => {
|
||||||
return listGeneratedReports()
|
return listGeneratedReports()
|
||||||
})
|
})
|
||||||
@@ -697,7 +702,7 @@ app.whenReady().then(async () => {
|
|||||||
imageMd5?: string,
|
imageMd5?: string,
|
||||||
imageDatNameOrThumb?: string | boolean,
|
imageDatNameOrThumb?: string | boolean,
|
||||||
_sessionId?: string,
|
_sessionId?: string,
|
||||||
options?: { force?: boolean }
|
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||||
) => {
|
) => {
|
||||||
void _sessionId
|
void _sessionId
|
||||||
if (!imageDecryptService) {
|
if (!imageDecryptService) {
|
||||||
@@ -714,12 +719,28 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
||||||
const force = options?.force === true
|
const force = options?.force === true
|
||||||
|
const preferThumbnail = options?.preferThumbnail === true
|
||||||
|
const imageCacheKey = [
|
||||||
|
imageMd5 || '',
|
||||||
|
imageDatName || '',
|
||||||
|
force ? 'original' : preferThumbnail ? 'thumbnail' : 'auto'
|
||||||
|
].join('|')
|
||||||
|
const cachedImage = imageDecryptService.getCachedDecodedImage(imageCacheKey)
|
||||||
|
if (cachedImage) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: cachedImage.data,
|
||||||
|
isThumb: cachedImage.isThumbnail,
|
||||||
|
filePath: cachedImage.filePath
|
||||||
|
}
|
||||||
|
}
|
||||||
let filePath = force
|
let filePath = force
|
||||||
? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false })
|
? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false })
|
||||||
: null
|
: null
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, {
|
filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, {
|
||||||
allowThumbnail: true
|
allowThumbnail: true,
|
||||||
|
preferThumbnail
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
@@ -731,12 +752,18 @@ app.whenReady().then(async () => {
|
|||||||
return { success: false, error: '图片解密失败' }
|
return { success: false, error: '图片解密失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
success: true,
|
success: true,
|
||||||
data: decrypted.data,
|
data: decrypted.data,
|
||||||
isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath),
|
isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath),
|
||||||
filePath: decrypted.filePath
|
filePath: decrypted.filePath
|
||||||
}
|
}
|
||||||
|
imageDecryptService.cacheDecodedImage(imageCacheKey, {
|
||||||
|
data: result.data,
|
||||||
|
filePath: result.filePath,
|
||||||
|
isThumbnail: result.isThumb
|
||||||
|
})
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface AppSettings {
|
|||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
imageKeyFallbackDisabled: boolean
|
imageKeyFallbackDisabled: boolean
|
||||||
recallProtectionEnabled: boolean
|
recallProtectionEnabled: boolean
|
||||||
|
debugEnabled: boolean
|
||||||
autoLogin: boolean
|
autoLogin: boolean
|
||||||
autoLoginPreferenceSet: boolean
|
autoLoginPreferenceSet: boolean
|
||||||
}
|
}
|
||||||
@@ -105,6 +106,7 @@ const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
imageAesKey: '',
|
imageAesKey: '',
|
||||||
imageKeyFallbackDisabled: false,
|
imageKeyFallbackDisabled: false,
|
||||||
recallProtectionEnabled: false,
|
recallProtectionEnabled: false,
|
||||||
|
debugEnabled: false,
|
||||||
autoLogin: ['1', 'true', 'yes', 'on'].includes(
|
autoLogin: ['1', 'true', 'yes', 'on'].includes(
|
||||||
String(import.meta.env.VITE_AUTO_LOGIN || '')
|
String(import.meta.env.VITE_AUTO_LOGIN || '')
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
Vendored
+5
-1
@@ -196,7 +196,7 @@ declare global {
|
|||||||
imageMd5?: string,
|
imageMd5?: string,
|
||||||
imageDatNameOrThumb?: string | boolean,
|
imageDatNameOrThumb?: string | boolean,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
options?: { force?: boolean }
|
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
data?: string
|
data?: string
|
||||||
@@ -252,6 +252,7 @@ declare global {
|
|||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
recallProtectionEnabled: boolean
|
recallProtectionEnabled: boolean
|
||||||
|
debugEnabled: boolean
|
||||||
autoLogin: boolean
|
autoLogin: boolean
|
||||||
autoLoginPreferenceSet: boolean
|
autoLoginPreferenceSet: boolean
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
@@ -279,6 +280,7 @@ declare global {
|
|||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
recallProtectionEnabled: boolean
|
recallProtectionEnabled: boolean
|
||||||
|
debugEnabled: boolean
|
||||||
autoLogin: boolean
|
autoLogin: boolean
|
||||||
autoLoginPreferenceSet: boolean
|
autoLoginPreferenceSet: boolean
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
@@ -294,6 +296,7 @@ declare global {
|
|||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
recallProtectionEnabled: boolean
|
recallProtectionEnabled: boolean
|
||||||
|
debugEnabled: boolean
|
||||||
autoLogin: boolean
|
autoLogin: boolean
|
||||||
autoLoginPreferenceSet: boolean
|
autoLoginPreferenceSet: boolean
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
@@ -307,6 +310,7 @@ declare global {
|
|||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
recallProtectionEnabled: boolean
|
recallProtectionEnabled: boolean
|
||||||
|
debugEnabled: boolean
|
||||||
autoLogin: boolean
|
autoLogin: boolean
|
||||||
autoLoginPreferenceSet: boolean
|
autoLoginPreferenceSet: boolean
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const api = {
|
|||||||
imageMd5?: string,
|
imageMd5?: string,
|
||||||
imageDatNameOrThumb?: string | boolean,
|
imageDatNameOrThumb?: string | boolean,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
options?: { force?: boolean }
|
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||||
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
||||||
getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes),
|
getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes),
|
||||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||||
|
|||||||
+64
-44
@@ -21,6 +21,7 @@ import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
|
|||||||
import { Contact, Message } from '../../shared/types'
|
import { Contact, Message } from '../../shared/types'
|
||||||
import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage'
|
import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage'
|
||||||
import { ExportWorkspace } from './components/export/ExportWorkspace'
|
import { ExportWorkspace } from './components/export/ExportWorkspace'
|
||||||
|
import { AISearchWorkspace } from './components/search/AISearchWorkspace'
|
||||||
import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
|
import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
|
||||||
|
|
||||||
const SIDEBAR_MIN_WIDTH = 260
|
const SIDEBAR_MIN_WIDTH = 260
|
||||||
@@ -101,10 +102,7 @@ const enrichQuotedMessages = (messages: Message[], referenceMessages: Message[])
|
|||||||
if (source?.name && !isInternalReferenceSender(source.name)) quotedSender = source.name
|
if (source?.name && !isInternalReferenceSender(source.name)) quotedSender = source.name
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (quotedSender === quote.quotedSender && quotedImageDatName === quote.quotedImageDatName) {
|
||||||
quotedSender === quote.quotedSender &&
|
|
||||||
quotedImageDatName === quote.quotedImageDatName
|
|
||||||
) {
|
|
||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -227,6 +225,7 @@ function App(): React.ReactElement {
|
|||||||
getDevelopmentDatabaseKey() ? 'manual' : 'automatic'
|
getDevelopmentDatabaseKey() ? 'manual' : 'automatic'
|
||||||
)
|
)
|
||||||
const [activePage, setActivePage] = useState<AppPage>('archive')
|
const [activePage, setActivePage] = useState<AppPage>('archive')
|
||||||
|
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
|
||||||
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
||||||
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
||||||
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
|
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
|
||||||
@@ -326,7 +325,9 @@ function App(): React.ReactElement {
|
|||||||
localStorage.setItem('wxe_export_tasks', JSON.stringify(exportTasks.slice(0, 20)))
|
localStorage.setItem('wxe_export_tasks', JSON.stringify(exportTasks.slice(0, 20)))
|
||||||
}, [exportTasks])
|
}, [exportTasks])
|
||||||
|
|
||||||
const handleStartExport = async (request: ExportRequest): Promise<import('../../shared/export').ExportResult> => {
|
const handleStartExport = async (
|
||||||
|
request: ExportRequest
|
||||||
|
): Promise<import('../../shared/export').ExportResult> => {
|
||||||
const task: ExportTaskRecord = {
|
const task: ExportTaskRecord = {
|
||||||
jobId: request.jobId,
|
jobId: request.jobId,
|
||||||
contactId: request.userMd5,
|
contactId: request.userMd5,
|
||||||
@@ -336,7 +337,9 @@ function App(): React.ReactElement {
|
|||||||
progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 },
|
progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 },
|
||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
}
|
}
|
||||||
setExportTasks((current) => [task, ...current.filter((item) => item.jobId !== task.jobId)].slice(0, 20))
|
setExportTasks((current) =>
|
||||||
|
[task, ...current.filter((item) => item.jobId !== task.jobId)].slice(0, 20)
|
||||||
|
)
|
||||||
const result = await window.api.startExport(request)
|
const result = await window.api.startExport(request)
|
||||||
setExportTasks((current) =>
|
setExportTasks((current) =>
|
||||||
current.map((item) =>
|
current.map((item) =>
|
||||||
@@ -561,7 +564,8 @@ function App(): React.ReactElement {
|
|||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
setIsDatabaseConnected(false)
|
setIsDatabaseConnected(false)
|
||||||
setBootState('login')
|
setBootState('login')
|
||||||
void initPromise.then(async (result) => {
|
void initPromise
|
||||||
|
.then(async (result) => {
|
||||||
const success = typeof result === 'boolean' ? result : result.success
|
const success = typeof result === 'boolean' ? result : result.success
|
||||||
if (!success) {
|
if (!success) {
|
||||||
const error = typeof result === 'boolean' ? '' : result.error
|
const error = typeof result === 'boolean' ? '' : result.error
|
||||||
@@ -574,7 +578,8 @@ function App(): React.ReactElement {
|
|||||||
setDbKeyStatus('已连接数据库')
|
setDbKeyStatus('已连接数据库')
|
||||||
// Cached contacts/self info are enough for startup. Native refresh is
|
// Cached contacts/self info are enough for startup. Native refresh is
|
||||||
// intentionally user-triggered so it cannot freeze the first session.
|
// intentionally user-triggered so it cannot freeze the first session.
|
||||||
}).catch((error) => {
|
})
|
||||||
|
.catch((error) => {
|
||||||
console.warn('[Startup] background database init failed:', error)
|
console.warn('[Startup] background database init failed:', error)
|
||||||
setDbKeyStatusKind('error')
|
setDbKeyStatusKind('error')
|
||||||
})
|
})
|
||||||
@@ -686,17 +691,13 @@ function App(): React.ReactElement {
|
|||||||
if (hasBootstrap) {
|
if (hasBootstrap) {
|
||||||
// Cached contacts are sufficient for the first paint. Refresh native data in the background.
|
// Cached contacts are sufficient for the first paint. Refresh native data in the background.
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
void Promise.all([
|
void Promise.all([loadContacts({ waitForAvatars: false }), refreshSelfInfo(3)]).catch(
|
||||||
loadContacts({ waitForAvatars: false }),
|
(error) => {
|
||||||
refreshSelfInfo(3)
|
|
||||||
]).catch((error) => {
|
|
||||||
console.warn('[Startup] background refresh failed:', error)
|
console.warn('[Startup] background refresh failed:', error)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
await Promise.all([
|
await Promise.all([loadContacts({ waitForAvatars: false }), refreshSelfInfo(3)])
|
||||||
loadContacts({ waitForAvatars: false }),
|
|
||||||
refreshSelfInfo(3)
|
|
||||||
])
|
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
}
|
}
|
||||||
setStartupProgress({
|
setStartupProgress({
|
||||||
@@ -911,6 +912,7 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
|
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
|
||||||
|
setArchiveJumpTime(null)
|
||||||
setSelectedContact(contact)
|
setSelectedContact(contact)
|
||||||
selectedContactMd5Ref.current = contact.md5
|
selectedContactMd5Ref.current = contact.md5
|
||||||
currentGroupSnapshotRef.current = null
|
currentGroupSnapshotRef.current = null
|
||||||
@@ -984,6 +986,26 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOpenSearchEvidence = async (contact: Contact, createTime?: number): Promise<void> => {
|
||||||
|
setActivePage('archive')
|
||||||
|
await handleSelectContact(contact)
|
||||||
|
if (!createTime || selectedContactMd5Ref.current !== contact.md5) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const windowStart = Math.max(0, createTime - 12 * 3600)
|
||||||
|
const windowEnd = createTime + 12 * 3600
|
||||||
|
const nearbyMessages = await window.api.getMessages(contact.md5, windowStart, windowEnd)
|
||||||
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
|
const focusedMessages = sortMessagesChronologically(nearbyMessages)
|
||||||
|
messageHistoryRef.current = focusedMessages
|
||||||
|
setMessages(applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, focusedMessages)))
|
||||||
|
setArchiveJumpTime(createTime)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Search] evidence context load failed:', error)
|
||||||
|
setReportNotice('证据所在时间段加载失败,请在档案中手动查看')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isDatabaseConnected || !selectedContact) return
|
if (!isDatabaseConnected || !selectedContact) return
|
||||||
void handleSelectContact(selectedContact)
|
void handleSelectContact(selectedContact)
|
||||||
@@ -1045,7 +1067,10 @@ function App(): React.ReactElement {
|
|||||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages)
|
messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages)
|
||||||
setMessages((current) =>
|
setMessages((current) =>
|
||||||
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, mergeMessagePages(olderMessages, current)))
|
applyGroupMemberMeta(
|
||||||
|
contact,
|
||||||
|
mergeSyntheticMessages(contact, mergeMessagePages(olderMessages, current))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Messages] older page load failed:', error)
|
console.warn('[Messages] older page load failed:', error)
|
||||||
@@ -1083,12 +1108,9 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
refreshInFlight = true
|
refreshInFlight = true
|
||||||
try {
|
try {
|
||||||
const latestMessages = await window.api.getMessages(
|
const latestMessages = await window.api.getMessages(contactMd5, undefined, undefined, {
|
||||||
contactMd5,
|
limit: INITIAL_MESSAGE_COUNT
|
||||||
undefined,
|
})
|
||||||
undefined,
|
|
||||||
{ limit: INITIAL_MESSAGE_COUNT }
|
|
||||||
)
|
|
||||||
const nextMessages = applyGroupMemberMeta(
|
const nextMessages = applyGroupMemberMeta(
|
||||||
selectedContact,
|
selectedContact,
|
||||||
mergeSyntheticMessages(selectedContact, latestMessages)
|
mergeSyntheticMessages(selectedContact, latestMessages)
|
||||||
@@ -1114,7 +1136,9 @@ function App(): React.ReactElement {
|
|||||||
|
|
||||||
const unsubscribe = window.api.onWcdbChange(({ json }) => {
|
const unsubscribe = window.api.onWcdbChange(({ json }) => {
|
||||||
const eventText = String(json || '').toLowerCase()
|
const eventText = String(json || '').toLowerCase()
|
||||||
const targetIds = [contactMd5, selectedContact.m_nsUsrName].filter(Boolean).map((value) => value.toLowerCase())
|
const targetIds = [contactMd5, selectedContact.m_nsUsrName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((value) => value.toLowerCase())
|
||||||
if (!targetIds.some((targetId) => eventText.includes(targetId))) return
|
if (!targetIds.some((targetId) => eventText.includes(targetId))) return
|
||||||
if (refreshTimer) window.clearTimeout(refreshTimer)
|
if (refreshTimer) window.clearTimeout(refreshTimer)
|
||||||
refreshTimer = window.setTimeout(() => {
|
refreshTimer = window.setTimeout(() => {
|
||||||
@@ -1327,24 +1351,6 @@ function App(): React.ReactElement {
|
|||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderPlaceholderPage = (
|
|
||||||
page: Exclude<AppPage, 'archive' | 'report' | 'agent-hub'>
|
|
||||||
): React.ReactElement => {
|
|
||||||
const labels: Record<Exclude<AppPage, 'archive' | 'report' | 'agent-hub'>, string> = {
|
|
||||||
search: '检索',
|
|
||||||
export: '导出',
|
|
||||||
api: 'API',
|
|
||||||
settings: '设置'
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="app-page-placeholder">
|
|
||||||
<div className="app-page-placeholder-eyebrow">WechatExplorer</div>
|
|
||||||
<h2>{labels[page]}</h2>
|
|
||||||
<p>这个工作区会在后续 UI 重构阶段接入真实功能。</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderArchiveWorkspace = (): React.ReactElement => (
|
const renderArchiveWorkspace = (): React.ReactElement => (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
@@ -1372,6 +1378,7 @@ function App(): React.ReactElement {
|
|||||||
onLoadOlderMessages={handleLoadOlderMessages}
|
onLoadOlderMessages={handleLoadOlderMessages}
|
||||||
onCreateGroupReport={handleOpenReportWorkspace}
|
onCreateGroupReport={handleOpenReportWorkspace}
|
||||||
isAiLoading={reportGeneration.isGenerating}
|
isAiLoading={reportGeneration.isGenerating}
|
||||||
|
jumpToTime={archiveJumpTime}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1489,7 +1496,20 @@ function App(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case 'search':
|
case 'search':
|
||||||
return renderPlaceholderPage(activePage)
|
return (
|
||||||
|
<AISearchWorkspace
|
||||||
|
contacts={contacts}
|
||||||
|
selectedContact={selectedContact}
|
||||||
|
dbReady={isDatabaseConnected}
|
||||||
|
aiModelConfig={aiModelConfig}
|
||||||
|
onSelectContact={(contact) => void handleSelectContact(contact)}
|
||||||
|
onOpenEvidence={(contact, createTime) =>
|
||||||
|
void handleOpenSearchEvidence(contact, createTime)
|
||||||
|
}
|
||||||
|
onOpenAISettings={openModelSettings}
|
||||||
|
onNotice={setReportNotice}
|
||||||
|
/>
|
||||||
|
)
|
||||||
case 'export':
|
case 'export':
|
||||||
return (
|
return (
|
||||||
<ExportWorkspace
|
<ExportWorkspace
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface ChatWindowProps {
|
|||||||
onLoadOlderMessages?: () => Promise<void>
|
onLoadOlderMessages?: () => Promise<void>
|
||||||
onCreateGroupReport?: () => void
|
onCreateGroupReport?: () => void
|
||||||
isAiLoading?: boolean
|
isAiLoading?: boolean
|
||||||
|
jumpToTime?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatWindow: React.FC<ChatWindowProps> = ({
|
const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||||
@@ -31,7 +32,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
onReloadAvatars,
|
onReloadAvatars,
|
||||||
onLoadOlderMessages,
|
onLoadOlderMessages,
|
||||||
onCreateGroupReport,
|
onCreateGroupReport,
|
||||||
isAiLoading = false
|
isAiLoading = false,
|
||||||
|
jumpToTime
|
||||||
}) => {
|
}) => {
|
||||||
const isGroupChat = Boolean(
|
const isGroupChat = Boolean(
|
||||||
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
|
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
|
||||||
@@ -73,19 +75,23 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
}, [contact?.md5])
|
}, [contact?.md5])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAtLatest) return
|
if (jumpToTime !== undefined && jumpToTime !== null) setIsAtLatest(false)
|
||||||
const frame = window.requestAnimationFrame(() => scrollToBottom())
|
}, [jumpToTime])
|
||||||
return () => window.cancelAnimationFrame(frame)
|
|
||||||
}, [isAtLatest, messages, scrollToBottom])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAtLatest) return
|
if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return
|
||||||
|
const frame = window.requestAnimationFrame(() => scrollToBottom())
|
||||||
|
return () => window.cancelAnimationFrame(frame)
|
||||||
|
}, [isAtLatest, jumpToTime, messages, scrollToBottom])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return
|
||||||
const content = messageListRef.current?.querySelector('.virtual-message-list')
|
const content = messageListRef.current?.querySelector('.virtual-message-list')
|
||||||
if (!content) return
|
if (!content) return
|
||||||
const observer = new ResizeObserver(() => scrollToBottom())
|
const observer = new ResizeObserver(() => scrollToBottom())
|
||||||
observer.observe(content)
|
observer.observe(content)
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [contact?.md5, isAtLatest, scrollToBottom])
|
}, [contact?.md5, isAtLatest, jumpToTime, scrollToBottom])
|
||||||
|
|
||||||
const openImagePreview = (imageUrl: string): void => {
|
const openImagePreview = (imageUrl: string): void => {
|
||||||
setPreviewImage(imageUrl)
|
setPreviewImage(imageUrl)
|
||||||
@@ -206,6 +212,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
onScroll={handleMessageListScroll}
|
onScroll={handleMessageListScroll}
|
||||||
onReachTop={onLoadOlderMessages}
|
onReachTop={onLoadOlderMessages}
|
||||||
onImageClick={openImagePreview}
|
onImageClick={openImagePreview}
|
||||||
|
jumpToTime={jumpToTime}
|
||||||
/>
|
/>
|
||||||
<ChatStatusBar
|
<ChatStatusBar
|
||||||
count={filteredMessages.length}
|
count={filteredMessages.length}
|
||||||
|
|||||||
@@ -1,44 +1,6 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import type { JSX, MouseEvent } from 'react'
|
import type { JSX, MouseEvent } from 'react'
|
||||||
|
import { getCachedLoadedImage, requestImage } from './image-loader'
|
||||||
type CachedImage = { data: string; isThumbnail: boolean }
|
|
||||||
|
|
||||||
const MAX_IMAGE_CACHE_ENTRIES = 80
|
|
||||||
const imageDataUrlCache = new Map<string, CachedImage>()
|
|
||||||
|
|
||||||
function imageCacheKeys(imageMd5?: string, imageDatName?: string): string[] {
|
|
||||||
return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter(
|
|
||||||
Boolean
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCachedImage(imageMd5?: string, imageDatName?: string): CachedImage | undefined {
|
|
||||||
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
|
|
||||||
const cached = imageDataUrlCache.get(key)
|
|
||||||
if (cached) {
|
|
||||||
imageDataUrlCache.delete(key)
|
|
||||||
imageDataUrlCache.set(key, cached)
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function cacheImage(
|
|
||||||
imageMd5: string | undefined,
|
|
||||||
imageDatName: string | undefined,
|
|
||||||
cached: CachedImage
|
|
||||||
): void {
|
|
||||||
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
|
|
||||||
imageDataUrlCache.delete(key)
|
|
||||||
imageDataUrlCache.set(key, cached)
|
|
||||||
}
|
|
||||||
while (imageDataUrlCache.size > MAX_IMAGE_CACHE_ENTRIES) {
|
|
||||||
const oldestKey = imageDataUrlCache.keys().next().value
|
|
||||||
if (!oldestKey) break
|
|
||||||
imageDataUrlCache.delete(oldestKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImageBubbleProps {
|
interface ImageBubbleProps {
|
||||||
imageMd5?: string
|
imageMd5?: string
|
||||||
@@ -53,11 +15,12 @@ export function ImageBubble({
|
|||||||
imageMd5,
|
imageMd5,
|
||||||
imageDatName,
|
imageDatName,
|
||||||
sessionId,
|
sessionId,
|
||||||
isThumb = false,
|
|
||||||
fallbackUrl,
|
fallbackUrl,
|
||||||
onImageClick
|
onImageClick
|
||||||
}: ImageBubbleProps): JSX.Element {
|
}: ImageBubbleProps): JSX.Element {
|
||||||
const initialCachedImage = getCachedImage(imageMd5, imageDatName)
|
const initialCachedImage = getCachedLoadedImage(imageMd5, imageDatName, {
|
||||||
|
preferThumbnail: true
|
||||||
|
})
|
||||||
const [imageUrl, setImageUrl] = useState<string | null>(initialCachedImage?.data || null)
|
const [imageUrl, setImageUrl] = useState<string | null>(initialCachedImage?.data || null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [upgrading, setUpgrading] = useState(false)
|
const [upgrading, setUpgrading] = useState(false)
|
||||||
@@ -65,6 +28,26 @@ export function ImageBubble({
|
|||||||
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
|
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
|
||||||
const [usingFallback, setUsingFallback] = useState(false)
|
const [usingFallback, setUsingFallback] = useState(false)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
const backgroundUpgradeRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const upgradeOriginalInBackground = useCallback(() => {
|
||||||
|
if (!isThumbnail || backgroundUpgradeRef.current || (!imageMd5 && !imageDatName)) return
|
||||||
|
backgroundUpgradeRef.current = true
|
||||||
|
void requestImage(imageMd5, imageDatName, sessionId, { force: true }, 1)
|
||||||
|
.then((original) => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
setImageUrl(original.data)
|
||||||
|
setIsThumbnail(false)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}, [imageDatName, imageMd5, isThumbnail, sessionId])
|
||||||
|
|
||||||
const loadImage = useCallback(async () => {
|
const loadImage = useCallback(async () => {
|
||||||
if (imageUrl || loading) return
|
if (imageUrl || loading) return
|
||||||
@@ -81,37 +64,42 @@ export function ImageBubble({
|
|||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
|
const result = await requestImage(
|
||||||
if (result.success && result.data?.startsWith('data:image/')) {
|
imageMd5,
|
||||||
cacheImage(imageMd5, imageDatName, {
|
imageDatName,
|
||||||
data: result.data,
|
sessionId,
|
||||||
isThumbnail: Boolean(result.isThumb)
|
{ preferThumbnail: true },
|
||||||
})
|
0
|
||||||
|
)
|
||||||
setImageUrl(result.data)
|
setImageUrl(result.data)
|
||||||
setUsingFallback(false)
|
setUsingFallback(false)
|
||||||
setIsThumbnail(Boolean(result.isThumb))
|
setIsThumbnail(result.isThumbnail)
|
||||||
setError(null)
|
setError(null)
|
||||||
} else {
|
if (result.isThumbnail) upgradeOriginalInBackground()
|
||||||
|
} catch (error) {
|
||||||
if (fallbackUrl) {
|
if (fallbackUrl) {
|
||||||
setImageUrl(fallbackUrl)
|
setImageUrl(fallbackUrl)
|
||||||
setUsingFallback(true)
|
setUsingFallback(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || '加载图片失败')
|
setError(error instanceof Error ? error.message : '加载图片失败')
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (fallbackUrl) {
|
|
||||||
setImageUrl(fallbackUrl)
|
|
||||||
setUsingFallback(true)
|
|
||||||
setError(null)
|
|
||||||
} else {
|
|
||||||
setError('加载图片失败')
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [fallbackUrl, imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
|
}, [
|
||||||
|
fallbackUrl,
|
||||||
|
imageDatName,
|
||||||
|
imageMd5,
|
||||||
|
imageUrl,
|
||||||
|
loading,
|
||||||
|
sessionId,
|
||||||
|
upgradeOriginalInBackground
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialCachedImage?.isThumbnail) upgradeOriginalInBackground()
|
||||||
|
}, [initialCachedImage?.isThumbnail, upgradeOriginalInBackground])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (imageUrl || loading || error) return
|
if (imageUrl || loading || error) return
|
||||||
@@ -154,17 +142,11 @@ export function ImageBubble({
|
|||||||
|
|
||||||
setUpgrading(true)
|
setUpgrading(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId, {
|
const result = await requestImage(imageMd5, imageDatName, sessionId, { force: true }, 0)
|
||||||
force: true
|
if (result.data.startsWith('data:image/')) {
|
||||||
})
|
|
||||||
if (result.success && result.data?.startsWith('data:image/')) {
|
|
||||||
cacheImage(imageMd5, imageDatName, {
|
|
||||||
data: result.data,
|
|
||||||
isThumbnail: Boolean(result.isThumb)
|
|
||||||
})
|
|
||||||
setImageUrl(result.data)
|
setImageUrl(result.data)
|
||||||
setUsingFallback(false)
|
setUsingFallback(false)
|
||||||
setIsThumbnail(Boolean(result.isThumb))
|
setIsThumbnail(result.isThumbnail)
|
||||||
setError(null)
|
setError(null)
|
||||||
onImageClick?.(result.data)
|
onImageClick?.(result.data)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface MessageBubbleProps {
|
|||||||
isMine: boolean
|
isMine: boolean
|
||||||
showAvatarSpace: boolean
|
showAvatarSpace: boolean
|
||||||
onImageClick: (imageUrl: string) => void
|
onImageClick: (imageUrl: string) => void
|
||||||
|
isJumpTarget?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const RICH_MESSAGE_TYPES = [
|
const RICH_MESSAGE_TYPES = [
|
||||||
@@ -39,7 +40,8 @@ export function MessageBubble({
|
|||||||
isGroupChat,
|
isGroupChat,
|
||||||
isMine,
|
isMine,
|
||||||
showAvatarSpace,
|
showAvatarSpace,
|
||||||
onImageClick
|
onImageClick,
|
||||||
|
isJumpTarget
|
||||||
}: MessageBubbleProps): React.ReactElement {
|
}: MessageBubbleProps): React.ReactElement {
|
||||||
const isVoice = message.type === '语音'
|
const isVoice = message.type === '语音'
|
||||||
const isImage = message.type === '图片'
|
const isImage = message.type === '图片'
|
||||||
@@ -48,7 +50,9 @@ export function MessageBubble({
|
|||||||
const hoverTime = formatMessageTime(message)
|
const hoverTime = formatMessageTime(message)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`message-bubble-wrap ${showAvatarSpace ? '' : 'is-followup'}`}>
|
<div
|
||||||
|
className={`message-bubble-wrap ${showAvatarSpace ? '' : 'is-followup'} ${isJumpTarget ? 'archive-jump-message' : ''}`}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${
|
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${
|
||||||
isImage ? 'image-message-bubble' : ''
|
isImage ? 'image-message-bubble' : ''
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface MessageGroupProps {
|
|||||||
isGroupChat: boolean
|
isGroupChat: boolean
|
||||||
showAvatar: boolean
|
showAvatar: boolean
|
||||||
onImageClick: (imageUrl: string) => void
|
onImageClick: (imageUrl: string) => void
|
||||||
|
jumpTargetMessageId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageGroup({
|
export function MessageGroup({
|
||||||
@@ -16,7 +17,8 @@ export function MessageGroup({
|
|||||||
contact,
|
contact,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
showAvatar,
|
showAvatar,
|
||||||
onImageClick
|
onImageClick,
|
||||||
|
jumpTargetMessageId
|
||||||
}: MessageGroupProps): React.ReactElement {
|
}: MessageGroupProps): React.ReactElement {
|
||||||
const firstMessage = group.messages[0]
|
const firstMessage = group.messages[0]
|
||||||
|
|
||||||
@@ -57,9 +59,7 @@ export function MessageGroup({
|
|||||||
)}
|
)}
|
||||||
{!isMine && !shouldShowAvatar && <div className="message-avatar-spacer" aria-hidden />}
|
{!isMine && !shouldShowAvatar && <div className="message-avatar-spacer" aria-hidden />}
|
||||||
<div className="message-stack">
|
<div className="message-stack">
|
||||||
{!isMine && isGroupChat && (
|
{!isMine && isGroupChat && <div className="message-sender-name">{displayName}</div>}
|
||||||
<div className="message-sender-name">{displayName}</div>
|
|
||||||
)}
|
|
||||||
{group.messages.map((message, index) => (
|
{group.messages.map((message, index) => (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
key={message.id}
|
key={message.id}
|
||||||
@@ -69,6 +69,7 @@ export function MessageGroup({
|
|||||||
isMine={isMine}
|
isMine={isMine}
|
||||||
showAvatarSpace={index === 0}
|
showAvatarSpace={index === 0}
|
||||||
onImageClick={onImageClick}
|
onImageClick={onImageClick}
|
||||||
|
isJumpTarget={message.id === jumpTargetMessageId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface MessageListProps {
|
|||||||
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
|
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
|
||||||
onReachTop?: () => Promise<void>
|
onReachTop?: () => Promise<void>
|
||||||
onImageClick: (imageUrl: string) => void
|
onImageClick: (imageUrl: string) => void
|
||||||
|
jumpToTime?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageList({
|
export function MessageList({
|
||||||
@@ -29,14 +30,13 @@ export function MessageList({
|
|||||||
bottomRef,
|
bottomRef,
|
||||||
onScroll,
|
onScroll,
|
||||||
onReachTop,
|
onReachTop,
|
||||||
onImageClick
|
onImageClick,
|
||||||
|
jumpToTime
|
||||||
}: MessageListProps): React.ReactElement {
|
}: MessageListProps): React.ReactElement {
|
||||||
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
|
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
|
||||||
const groupsRef = React.useRef(groups)
|
const groupsRef = React.useRef(groups)
|
||||||
const loadingOlderRef = React.useRef(false)
|
const loadingOlderRef = React.useRef(false)
|
||||||
groupsRef.current = groups
|
groupsRef.current = groups
|
||||||
// TanStack Virtual intentionally exposes mutable measurement methods.
|
|
||||||
// eslint-disable-next-line react-hooks/incompatible-library
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: groups.length,
|
count: groups.length,
|
||||||
getScrollElement: () => listRef.current,
|
getScrollElement: () => listRef.current,
|
||||||
@@ -45,11 +45,29 @@ export function MessageList({
|
|||||||
overscan: 8
|
overscan: 8
|
||||||
})
|
})
|
||||||
const virtualItems = virtualizer.getVirtualItems()
|
const virtualItems = virtualizer.getVirtualItems()
|
||||||
|
const jumpTarget = React.useMemo(() => {
|
||||||
|
if (jumpToTime === undefined || jumpToTime === null) return null
|
||||||
|
const groupIndex = groups.findIndex((group) =>
|
||||||
|
group.messages.some((message) => (message.createTime || 0) >= jumpToTime)
|
||||||
|
)
|
||||||
|
if (groupIndex < 0) return null
|
||||||
|
const message = groups[groupIndex].messages.find((item) => (item.createTime || 0) >= jumpToTime)
|
||||||
|
return { groupIndex, messageId: message?.id }
|
||||||
|
}, [groups, jumpToTime])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!jumpTarget) return
|
||||||
|
const frame = window.requestAnimationFrame(() => {
|
||||||
|
virtualizer.scrollToIndex(jumpTarget.groupIndex, { align: 'center' })
|
||||||
|
})
|
||||||
|
return () => window.cancelAnimationFrame(frame)
|
||||||
|
}, [jumpTarget, virtualizer])
|
||||||
|
|
||||||
const handleScroll = (event: React.UIEvent<HTMLDivElement>): void => {
|
const handleScroll = (event: React.UIEvent<HTMLDivElement>): void => {
|
||||||
onScroll(event)
|
onScroll(event)
|
||||||
const scrollElement = event.currentTarget
|
const scrollElement = event.currentTarget
|
||||||
if (
|
if (
|
||||||
|
(jumpToTime !== undefined && jumpToTime !== null) ||
|
||||||
scrollElement.scrollTop >= 48 ||
|
scrollElement.scrollTop >= 48 ||
|
||||||
loadingOlderRef.current ||
|
loadingOlderRef.current ||
|
||||||
isLoadingMessages ||
|
isLoadingMessages ||
|
||||||
@@ -117,7 +135,7 @@ export function MessageList({
|
|||||||
key={virtualItem.key}
|
key={virtualItem.key}
|
||||||
ref={virtualizer.measureElement}
|
ref={virtualizer.measureElement}
|
||||||
data-index={virtualItem.index}
|
data-index={virtualItem.index}
|
||||||
className="virtual-message-group"
|
className={`virtual-message-group ${jumpTarget?.groupIndex === virtualItem.index ? 'archive-jump-target-group' : ''}`}
|
||||||
style={{ transform: `translateY(${virtualItem.start}px)` }}
|
style={{ transform: `translateY(${virtualItem.start}px)` }}
|
||||||
>
|
>
|
||||||
<MessageGroup
|
<MessageGroup
|
||||||
@@ -126,6 +144,7 @@ export function MessageList({
|
|||||||
isGroupChat={isGroupChat}
|
isGroupChat={isGroupChat}
|
||||||
showAvatar={showAvatar}
|
showAvatar={showAvatar}
|
||||||
onImageClick={onImageClick}
|
onImageClick={onImageClick}
|
||||||
|
jumpTargetMessageId={jumpTarget?.messageId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
export type LoadedImage = {
|
||||||
|
data: string
|
||||||
|
isThumbnail: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImageLoadOptions = {
|
||||||
|
force?: boolean
|
||||||
|
preferThumbnail?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueueItem = {
|
||||||
|
priority: number
|
||||||
|
run: () => Promise<LoadedImage>
|
||||||
|
resolve: (value: LoadedImage) => void
|
||||||
|
reject: (error: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_CONCURRENT_IMAGE_LOADS = 3
|
||||||
|
const MAX_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
|
||||||
|
const imageCache = new Map<string, LoadedImage>()
|
||||||
|
const imageCacheSizes = new Map<string, number>()
|
||||||
|
const imageRequests = new Map<string, Promise<LoadedImage>>()
|
||||||
|
const imageQueue: QueueItem[] = []
|
||||||
|
let activeImageLoads = 0
|
||||||
|
let imageCacheBytes = 0
|
||||||
|
|
||||||
|
function imageIdentityKeys(imageMd5?: string, imageDatName?: string): string[] {
|
||||||
|
return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter(
|
||||||
|
Boolean
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheMode(options: ImageLoadOptions): string {
|
||||||
|
if (options.force) return 'original'
|
||||||
|
if (options.preferThumbnail) return 'thumbnail'
|
||||||
|
return 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheKeys(
|
||||||
|
imageMd5: string | undefined,
|
||||||
|
imageDatName: string | undefined,
|
||||||
|
options: ImageLoadOptions
|
||||||
|
): string[] {
|
||||||
|
return imageIdentityKeys(imageMd5, imageDatName).map(
|
||||||
|
(identity) => `${identity}:${cacheMode(options)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedImage(
|
||||||
|
imageMd5: string | undefined,
|
||||||
|
imageDatName: string | undefined,
|
||||||
|
options: ImageLoadOptions
|
||||||
|
): LoadedImage | undefined {
|
||||||
|
const keys = cacheKeys(imageMd5, imageDatName, options)
|
||||||
|
for (const key of keys) {
|
||||||
|
const cached = imageCache.get(key)
|
||||||
|
if (!cached) continue
|
||||||
|
imageCache.delete(key)
|
||||||
|
imageCache.set(key, cached)
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.force && options.preferThumbnail) {
|
||||||
|
for (const identity of imageIdentityKeys(imageMd5, imageDatName)) {
|
||||||
|
const fallbackKey = `${identity}:auto`
|
||||||
|
const cached = imageCache.get(fallbackKey)
|
||||||
|
if (cached) return cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheImage(
|
||||||
|
imageMd5: string | undefined,
|
||||||
|
imageDatName: string | undefined,
|
||||||
|
options: ImageLoadOptions,
|
||||||
|
image: LoadedImage
|
||||||
|
): void {
|
||||||
|
const keys = cacheKeys(imageMd5, imageDatName, options)
|
||||||
|
const size = image.data.length * 2
|
||||||
|
for (const key of keys) {
|
||||||
|
const previousSize = imageCacheSizes.get(key) || 0
|
||||||
|
imageCacheBytes -= previousSize
|
||||||
|
imageCache.delete(key)
|
||||||
|
imageCacheSizes.delete(key)
|
||||||
|
imageCache.set(key, image)
|
||||||
|
imageCacheSizes.set(key, size)
|
||||||
|
imageCacheBytes += size
|
||||||
|
}
|
||||||
|
|
||||||
|
while (imageCacheBytes > MAX_IMAGE_CACHE_BYTES && imageCache.size > 1) {
|
||||||
|
const oldestKey = imageCache.keys().next().value
|
||||||
|
if (!oldestKey) break
|
||||||
|
imageCache.delete(oldestKey)
|
||||||
|
imageCacheBytes -= imageCacheSizes.get(oldestKey) || 0
|
||||||
|
imageCacheSizes.delete(oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pumpImageQueue(): void {
|
||||||
|
while (activeImageLoads < MAX_CONCURRENT_IMAGE_LOADS && imageQueue.length > 0) {
|
||||||
|
imageQueue.sort((left, right) => left.priority - right.priority)
|
||||||
|
const item = imageQueue.shift()
|
||||||
|
if (!item) return
|
||||||
|
activeImageLoads += 1
|
||||||
|
void item
|
||||||
|
.run()
|
||||||
|
.then(item.resolve, item.reject)
|
||||||
|
.finally(() => {
|
||||||
|
activeImageLoads -= 1
|
||||||
|
pumpImageQueue()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedLoadedImage(
|
||||||
|
imageMd5?: string,
|
||||||
|
imageDatName?: string,
|
||||||
|
options: ImageLoadOptions = {}
|
||||||
|
): LoadedImage | undefined {
|
||||||
|
return getCachedImage(imageMd5, imageDatName, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestImage(
|
||||||
|
imageMd5: string | undefined,
|
||||||
|
imageDatName: string | undefined,
|
||||||
|
sessionId: string | undefined,
|
||||||
|
options: ImageLoadOptions = {},
|
||||||
|
priority = 0
|
||||||
|
): Promise<LoadedImage> {
|
||||||
|
const cached = getCachedImage(imageMd5, imageDatName, options)
|
||||||
|
if (cached) return Promise.resolve(cached)
|
||||||
|
|
||||||
|
const identity = imageIdentityKeys(imageMd5, imageDatName)[0]
|
||||||
|
if (!identity) return Promise.reject(new Error('缺少图片标识'))
|
||||||
|
const requestKey = `${identity}:${cacheMode(options)}`
|
||||||
|
const existingRequest = imageRequests.get(requestKey)
|
||||||
|
if (existingRequest) return existingRequest
|
||||||
|
|
||||||
|
const request = new Promise<LoadedImage>((resolve, reject) => {
|
||||||
|
imageQueue.push({
|
||||||
|
priority,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
run: async () => {
|
||||||
|
const result = await window.api.getImage(imageMd5, imageDatName, sessionId, options)
|
||||||
|
if (!result.success || !result.data?.startsWith('data:image/')) {
|
||||||
|
throw new Error(result.error || '加载图片失败')
|
||||||
|
}
|
||||||
|
const loadedImage = {
|
||||||
|
data: result.data,
|
||||||
|
isThumbnail: Boolean(result.isThumb)
|
||||||
|
}
|
||||||
|
cacheImage(imageMd5, imageDatName, options, loadedImage)
|
||||||
|
return loadedImage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pumpImageQueue()
|
||||||
|
})
|
||||||
|
imageRequests.set(requestKey, request)
|
||||||
|
void request.then(
|
||||||
|
() => imageRequests.delete(requestKey),
|
||||||
|
() => imageRequests.delete(requestKey)
|
||||||
|
)
|
||||||
|
return request
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import { DatabaseKeyPage } from './pages/DatabaseKeyPage'
|
|||||||
import { ImageDecryptionPage } from './pages/ImageDecryptionPage'
|
import { ImageDecryptionPage } from './pages/ImageDecryptionPage'
|
||||||
import { AIModelPage } from './pages/AIModelPage'
|
import { AIModelPage } from './pages/AIModelPage'
|
||||||
import { RecallProtectionPage } from './pages/RecallProtectionPage'
|
import { RecallProtectionPage } from './pages/RecallProtectionPage'
|
||||||
|
import { AdvancedPage } from './pages/AdvancedPage'
|
||||||
import type { Contact } from '../../../../shared/types'
|
import type { Contact } from '../../../../shared/types'
|
||||||
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
||||||
|
|
||||||
@@ -50,8 +51,15 @@ export function SettingsWorkspace({
|
|||||||
dbReady={dbReady}
|
dbReady={dbReady}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
/>
|
/>
|
||||||
<div className={`settings-page-panel ${selectedCategory === 'account-database' ? 'active' : ''}`}>
|
<div
|
||||||
<AccountDatabasePage dbKey={dbKey} dbReady={dbReady} selfInfo={selfInfo} onNotice={onNotice} />
|
className={`settings-page-panel ${selectedCategory === 'account-database' ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
<AccountDatabasePage
|
||||||
|
dbKey={dbKey}
|
||||||
|
dbReady={dbReady}
|
||||||
|
selfInfo={selfInfo}
|
||||||
|
onNotice={onNotice}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={`settings-page-panel ${selectedCategory === 'database-key' ? 'active' : ''}`}>
|
<div className={`settings-page-panel ${selectedCategory === 'database-key' ? 'active' : ''}`}>
|
||||||
<DatabaseKeyPage
|
<DatabaseKeyPage
|
||||||
@@ -73,10 +81,22 @@ export function SettingsWorkspace({
|
|||||||
<div className={`settings-page-panel ${selectedCategory === 'ai-model' ? 'active' : ''}`}>
|
<div className={`settings-page-panel ${selectedCategory === 'ai-model' ? 'active' : ''}`}>
|
||||||
<AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
|
<AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
|
||||||
</div>
|
</div>
|
||||||
<div className={`settings-page-panel ${selectedCategory === 'recall-protection' ? 'active' : ''}`}>
|
<div
|
||||||
|
className={`settings-page-panel ${selectedCategory === 'recall-protection' ? 'active' : ''}`}
|
||||||
|
>
|
||||||
<RecallProtectionPage onNotice={onNotice} />
|
<RecallProtectionPage onNotice={onNotice} />
|
||||||
</div>
|
</div>
|
||||||
{!['account-database', 'database-key', 'image-key', 'ai-model', 'recall-protection'].includes(selectedCategory) && (
|
<div className={`settings-page-panel ${selectedCategory === 'advanced' ? 'active' : ''}`}>
|
||||||
|
<AdvancedPage onNotice={onNotice} />
|
||||||
|
</div>
|
||||||
|
{![
|
||||||
|
'account-database',
|
||||||
|
'database-key',
|
||||||
|
'image-key',
|
||||||
|
'ai-model',
|
||||||
|
'recall-protection',
|
||||||
|
'advanced'
|
||||||
|
].includes(selectedCategory) && (
|
||||||
<div className="settings-page-panel active">
|
<div className="settings-page-panel active">
|
||||||
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
|
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,14 +27,12 @@ export function AccountDatabasePage({
|
|||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const controller = useAccountDatabaseController({ dbKey, dbReady, selfInfo, onNotice })
|
const controller = useAccountDatabaseController({ dbKey, dbReady, selfInfo, onNotice })
|
||||||
const [autoLogin, setAutoLogin] = useState(false)
|
const [autoLogin, setAutoLogin] = useState(false)
|
||||||
const [recallProtectionEnabled, setRecallProtectionEnabled] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
void window.api.getSettings().then((result) => {
|
void window.api.getSettings().then((result) => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
setAutoLogin(result.settings.autoLogin)
|
setAutoLogin(result.settings.autoLogin)
|
||||||
setRecallProtectionEnabled(result.settings.recallProtectionEnabled)
|
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
@@ -50,11 +48,6 @@ export function AccountDatabasePage({
|
|||||||
onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接')
|
onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接')
|
||||||
}
|
}
|
||||||
|
|
||||||
const changeRecallProtection = async (checked: boolean): Promise<void> => {
|
|
||||||
const result = await window.api.setSettings({ recallProtectionEnabled: checked })
|
|
||||||
setRecallProtectionEnabled(result.settings.recallProtectionEnabled)
|
|
||||||
onNotice(checked ? '已开启防撤回' : '已关闭防撤回')
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-page">
|
<div className="settings-page">
|
||||||
<header className="settings-page-header">
|
<header className="settings-page-header">
|
||||||
@@ -102,26 +95,6 @@ export function AccountDatabasePage({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
<h2 className="settings-section-heading">数据读取</h2>
|
|
||||||
<section className="settings-card settings-recall-card">
|
|
||||||
<div className="settings-recall-grid">
|
|
||||||
<label className="settings-recall-option">
|
|
||||||
<span>
|
|
||||||
<b>开启防撤回</b>
|
|
||||||
<small>尽量保留已撤回的聊天内容,方便后续查看。</small>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={recallProtectionEnabled}
|
|
||||||
onChange={(event) => void changeRecallProtection(event.target.checked)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<aside className="settings-recall-notice">
|
|
||||||
<strong>性能提示</strong>
|
|
||||||
<span>开启后会为消息表增加监听,数据库较大或磁盘较慢时可能让加载变慢。</span>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export function AdvancedPage({
|
||||||
|
onNotice
|
||||||
|
}: {
|
||||||
|
onNotice: (message: string) => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
const [debugEnabled, setDebugEnabled] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void window.api.getSettings().then((result) => {
|
||||||
|
if (active) setDebugEnabled(result.settings.debugEnabled)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const changeDebugEnabled = async (checked: boolean): Promise<void> => {
|
||||||
|
const result = await window.api.setSettings({ debugEnabled: checked })
|
||||||
|
setDebugEnabled(result.settings.debugEnabled)
|
||||||
|
onNotice(checked ? '已开启调试日志' : '已关闭调试日志')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-page">
|
||||||
|
<header className="settings-page-header">
|
||||||
|
<div>
|
||||||
|
<h1>高级</h1>
|
||||||
|
<p>用于开发人员排查本地数据库和检索问题</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="settings-page-scroll">
|
||||||
|
<div className="settings-page-content">
|
||||||
|
<h2 className="settings-section-heading">诊断</h2>
|
||||||
|
<section className="settings-card settings-debug-card">
|
||||||
|
<label>
|
||||||
|
<span>
|
||||||
|
<b>显示诊断日志</b>
|
||||||
|
<small>开启后,检索页显示诊断日志入口并记录匹配统计,不记录聊天正文。</small>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={debugEnabled}
|
||||||
|
onChange={(event) => void changeDebugEnabled(event.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="settings-debug-actions">
|
||||||
|
<button type="button" onClick={() => void window.api.revealAppLog()}>
|
||||||
|
打开诊断日志
|
||||||
|
</button>
|
||||||
|
<small>关闭调试日志后,仍会保留错误和崩溃日志。</small>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ import ReactDOM from 'react-dom/client'
|
|||||||
import App from './App'
|
import App from './App'
|
||||||
import './styles/tokens.css'
|
import './styles/tokens.css'
|
||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
import './styles/search.css'
|
||||||
|
import './styles/archive.css'
|
||||||
|
import './styles/settings-advanced.css'
|
||||||
|
|
||||||
window.addEventListener('error', (event) => {
|
window.addEventListener('error', (event) => {
|
||||||
void window.api
|
void window.api
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
.archive-jump-target-group {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-jump-message {
|
||||||
|
z-index: 1;
|
||||||
|
animation: archive-jump-flash 0.72s ease-in-out 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes archive-jump-flash {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
22%,
|
||||||
|
58% {
|
||||||
|
filter: drop-shadow(0 0 0.45rem rgba(38, 128, 103, 0.68));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.archive-jump-message {
|
||||||
|
outline: 3px solid rgba(38, 128, 103, 0.58);
|
||||||
|
outline-offset: 5px;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
.settings-debug-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-debug-card > label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-debug-card > label > span {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-debug-card b {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-debug-card small {
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-debug-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--wxex-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-debug-actions button {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user