图片解密重构 #527 #522 #696;修复 #752

This commit is contained in:
cc
2026-04-14 23:02:06 +08:00
parent 9af1a0ad56
commit 419a53d6ec
27 changed files with 1161 additions and 1247 deletions

View File

@@ -93,7 +93,6 @@ jobs:
with: with:
node-version: 24 node-version: 24
cache: "npm" cache: "npm"
- name: Install Dependencies - name: Install Dependencies
run: npm install run: npm install
@@ -160,7 +159,6 @@ jobs:
with: with:
node-version: 24 node-version: 24
cache: "npm" cache: "npm"
- name: Install Dependencies - name: Install Dependencies
run: npm install run: npm install
@@ -208,7 +206,6 @@ jobs:
with: with:
node-version: 24 node-version: 24
cache: "npm" cache: "npm"
- name: Install Dependencies - name: Install Dependencies
run: npm install run: npm install
@@ -256,7 +253,6 @@ jobs:
with: with:
node-version: 24 node-version: 24
cache: "npm" cache: "npm"
- name: Install Dependencies - name: Install Dependencies
run: npm install run: npm install

View File

@@ -120,7 +120,6 @@ jobs:
with: with:
node-version: 24 node-version: 24
cache: "npm" cache: "npm"
- name: Install Dependencies - name: Install Dependencies
run: npm install run: npm install
@@ -190,7 +189,6 @@ jobs:
with: with:
node-version: 24 node-version: 24
cache: "npm" cache: "npm"
- name: Install Dependencies - name: Install Dependencies
run: npm install run: npm install
@@ -242,7 +240,6 @@ jobs:
with: with:
node-version: 24 node-version: 24
cache: "npm" cache: "npm"
- name: Install Dependencies - name: Install Dependencies
run: npm install run: npm install
@@ -294,7 +291,6 @@ jobs:
with: with:
node-version: 24 node-version: 24
cache: "npm" cache: "npm"
- name: Install Dependencies - name: Install Dependencies
run: npm install run: npm install

View File

@@ -27,7 +27,6 @@ jobs:
with: with:
node-version: 24 node-version: 24
cache: "npm" cache: "npm"
- name: Install Dependencies - name: Install Dependencies
run: npm install run: npm install
@@ -84,7 +83,6 @@ jobs:
with: with:
node-version: 24 node-version: 24
cache: "npm" cache: "npm"
- name: Install Dependencies - name: Install Dependencies
run: npm install run: npm install
@@ -140,7 +138,6 @@ jobs:
with: with:
node-version: 24 node-version: 24
cache: 'npm' cache: 'npm'
- name: Install Dependencies - name: Install Dependencies
run: npm install run: npm install
@@ -191,7 +188,6 @@ jobs:
with: with:
node-version: 24 node-version: 24
cache: 'npm' cache: 'npm'
- name: Install Dependencies - name: Install Dependencies
run: npm install run: npm install

2
.gitignore vendored
View File

@@ -75,4 +75,4 @@ pnpm-lock.yaml
wechat-research-site wechat-research-site
.codex .codex
weflow-web-offical weflow-web-offical
Insight /Wedecrypt

View File

@@ -2636,13 +2636,24 @@ function registerIpcHandlers() {
// 私聊克隆 // 私聊克隆
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => { ipcMain.handle('image:decrypt', async (_, payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
force?: boolean
preferFilePath?: boolean
hardlinkOnly?: boolean
}) => {
return imageDecryptService.decryptImage(payload) return imageDecryptService.decryptImage(payload)
}) })
ipcMain.handle('image:resolveCache', async (_, payload: { ipcMain.handle('image:resolveCache', async (_, payload: {
sessionId?: string sessionId?: string
imageMd5?: string imageMd5?: string
imageDatName?: string imageDatName?: string
createTime?: number
preferFilePath?: boolean
hardlinkOnly?: boolean
disableUpdateCheck?: boolean disableUpdateCheck?: boolean
allowCacheIndex?: boolean allowCacheIndex?: boolean
}) => { }) => {
@@ -2652,13 +2663,15 @@ function registerIpcHandlers() {
'image:resolveCacheBatch', 'image:resolveCacheBatch',
async ( async (
_, _,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean } options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean }
) => { ) => {
const list = Array.isArray(payloads) ? payloads : [] const list = Array.isArray(payloads) ? payloads : []
const rows = await Promise.all(list.map(async (payload) => { const rows = await Promise.all(list.map(async (payload) => {
return imageDecryptService.resolveCachedImage({ return imageDecryptService.resolveCachedImage({
...payload, ...payload,
preferFilePath: payload.preferFilePath ?? options?.preferFilePath === true,
hardlinkOnly: payload.hardlinkOnly ?? options?.hardlinkOnly === true,
disableUpdateCheck: options?.disableUpdateCheck === true, disableUpdateCheck: options?.disableUpdateCheck === true,
allowCacheIndex: options?.allowCacheIndex !== false allowCacheIndex: options?.allowCacheIndex !== false
}) })
@@ -2670,7 +2683,7 @@ function registerIpcHandlers() {
'image:preload', 'image:preload',
async ( async (
_, _,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean } options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => { ) => {
imagePreloadService.enqueue(payloads || [], options) imagePreloadService.enqueue(payloads || [], options)

View File

@@ -286,22 +286,25 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 图片解密 // 图片解密
image: { image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; force?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean }) =>
ipcRenderer.invoke('image:decrypt', payload), ipcRenderer.invoke('image:decrypt', payload),
resolveCache: (payload: { resolveCache: (payload: {
sessionId?: string sessionId?: string
imageMd5?: string imageMd5?: string
imageDatName?: string imageDatName?: string
createTime?: number
preferFilePath?: boolean
hardlinkOnly?: boolean
disableUpdateCheck?: boolean disableUpdateCheck?: boolean
allowCacheIndex?: boolean allowCacheIndex?: boolean
}) => }) =>
ipcRenderer.invoke('image:resolveCache', payload), ipcRenderer.invoke('image:resolveCache', payload),
resolveCacheBatch: ( resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean } options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean }
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options), ) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
preload: ( preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean } options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => ipcRenderer.invoke('image:preload', payloads, options), ) => ipcRenderer.invoke('image:preload', payloads, options),
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => { onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {

View File

@@ -486,7 +486,7 @@ class ChatService {
return Number.isFinite(parsed) ? parsed : null return Number.isFinite(parsed) ? parsed : null
} }
private toCodeOnlyMessage(rawMessage?: string, fallbackCode = -3999): string { private toCodeOnlyMessage(rawMessage?: string | null, fallbackCode = -3999): string {
const code = this.extractErrorCode(rawMessage) ?? fallbackCode const code = this.extractErrorCode(rawMessage) ?? fallbackCode
return `错误码: ${code}` return `错误码: ${code}`
} }
@@ -7105,13 +7105,23 @@ class ChatService {
return { success: false, error: '未找到消息' } return { success: false, error: '未找到消息' }
} }
const msg = msgResult.message const msg = msgResult.message
const rawImageInfo = msg.rawContent ? this.parseImageInfo(msg.rawContent) : {}
const imageMd5 = msg.imageMd5 || rawImageInfo.md5
const imageDatName = msg.imageDatName
// 2. 使用 imageDecryptService 解密图片 if (!imageMd5 && !imageDatName) {
return { success: false, error: '图片缺少 md5/datName无法定位原文件' }
}
// 2. 使用 imageDecryptService 解密图片(仅使用真实图片标识)
const result = await this.imageDecryptService.decryptImage({ const result = await this.imageDecryptService.decryptImage({
sessionId, sessionId,
imageMd5: msg.imageMd5, imageMd5,
imageDatName: msg.imageDatName || String(msg.localId), imageDatName,
force: false createTime: msg.createTime,
force: false,
preferFilePath: true,
hardlinkOnly: true
}) })
if (!result.success || !result.localPath) { if (!result.success || !result.localPath) {
@@ -8358,7 +8368,6 @@ class ChatService {
if (normalized.length === 0) return [] if (normalized.length === 0) return []
// 规避 native options_json 可能存在的固定缓冲上限:按 payload 字节安全分块。 // 规避 native options_json 可能存在的固定缓冲上限:按 payload 字节安全分块。
// 这不是降级或裁剪范围,而是完整遍历所有群并做结果合并。
const maxBytesRaw = Number(process.env.WEFLOW_MY_FOOTPRINT_GROUP_OPTIONS_MAX_BYTES || 900) const maxBytesRaw = Number(process.env.WEFLOW_MY_FOOTPRINT_GROUP_OPTIONS_MAX_BYTES || 900)
const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw >= 512 const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw >= 512
? Math.floor(maxBytesRaw) ? Math.floor(maxBytesRaw)
@@ -9325,7 +9334,7 @@ class ChatService {
latest_ts: this.toSafeInt(item?.latest_ts, 0), latest_ts: this.toSafeInt(item?.latest_ts, 0),
anchor_local_id: this.toSafeInt(item?.anchor_local_id, 0), anchor_local_id: this.toSafeInt(item?.anchor_local_id, 0),
anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0) anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0)
})).filter((item) => item.session_id) })).filter((item: MyFootprintPrivateSession) => item.session_id)
const private_segments: MyFootprintPrivateSegment[] = privateSegmentsRaw.map((item: any) => ({ const private_segments: MyFootprintPrivateSegment[] = privateSegmentsRaw.map((item: any) => ({
session_id: String(item?.session_id || '').trim(), session_id: String(item?.session_id || '').trim(),
@@ -9344,7 +9353,7 @@ class ChatService {
anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0), anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0),
displayName: String(item?.displayName || '').trim() || undefined, displayName: String(item?.displayName || '').trim() || undefined,
avatarUrl: String(item?.avatarUrl || '').trim() || undefined avatarUrl: String(item?.avatarUrl || '').trim() || undefined
})).filter((item) => item.session_id && item.start_ts > 0) })).filter((item: MyFootprintPrivateSegment) => item.session_id && item.start_ts > 0)
const mentions: MyFootprintMentionItem[] = mentionsRaw.map((item: any) => ({ const mentions: MyFootprintMentionItem[] = mentionsRaw.map((item: any) => ({
session_id: String(item?.session_id || '').trim(), session_id: String(item?.session_id || '').trim(),
@@ -9353,13 +9362,13 @@ class ChatService {
sender_username: String(item?.sender_username || '').trim(), sender_username: String(item?.sender_username || '').trim(),
message_content: String(item?.message_content || ''), message_content: String(item?.message_content || ''),
source: String(item?.source || '') source: String(item?.source || '')
})).filter((item) => item.session_id) })).filter((item: MyFootprintMentionItem) => item.session_id)
const mention_groups: MyFootprintMentionGroup[] = mentionGroupsRaw.map((item: any) => ({ const mention_groups: MyFootprintMentionGroup[] = mentionGroupsRaw.map((item: any) => ({
session_id: String(item?.session_id || '').trim(), session_id: String(item?.session_id || '').trim(),
count: this.toSafeInt(item?.count, 0), count: this.toSafeInt(item?.count, 0),
latest_ts: this.toSafeInt(item?.latest_ts, 0) latest_ts: this.toSafeInt(item?.latest_ts, 0)
})).filter((item) => item.session_id) })).filter((item: MyFootprintMentionGroup) => item.session_id)
const diagnostics: MyFootprintDiagnostics = { const diagnostics: MyFootprintDiagnostics = {
truncated: Boolean(diagnosticsRaw.truncated), truncated: Boolean(diagnosticsRaw.truncated),

View File

@@ -42,7 +42,6 @@ interface ConfigSchema {
autoTranscribeVoice: boolean autoTranscribeVoice: boolean
transcribeLanguages: string[] transcribeLanguages: string[]
exportDefaultConcurrency: number exportDefaultConcurrency: number
exportDefaultImageDeepSearchOnMiss: boolean
analyticsExcludedUsernames: string[] analyticsExcludedUsernames: string[]
// 安全相关 // 安全相关
@@ -165,7 +164,6 @@ export class ConfigService {
autoTranscribeVoice: false, autoTranscribeVoice: false,
transcribeLanguages: ['zh'], transcribeLanguages: ['zh'],
exportDefaultConcurrency: 4, exportDefaultConcurrency: 4,
exportDefaultImageDeepSearchOnMiss: true,
analyticsExcludedUsernames: [], analyticsExcludedUsernames: [],
authEnabled: false, authEnabled: false,
authPassword: '', authPassword: '',

View File

@@ -108,7 +108,6 @@ export interface ExportOptions {
sessionNameWithTypePrefix?: boolean sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number exportConcurrency?: number
imageDeepSearchOnMiss?: boolean
} }
const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
@@ -1092,8 +1091,7 @@ class ExportService {
private getImageMissingRunCacheKey( private getImageMissingRunCacheKey(
sessionId: string, sessionId: string,
imageMd5?: unknown, imageMd5?: unknown,
imageDatName?: unknown, imageDatName?: unknown
imageDeepSearchOnMiss = true
): string | null { ): string | null {
const normalizedSessionId = String(sessionId || '').trim() const normalizedSessionId = String(sessionId || '').trim()
const normalizedImageMd5 = String(imageMd5 || '').trim().toLowerCase() const normalizedImageMd5 = String(imageMd5 || '').trim().toLowerCase()
@@ -1105,8 +1103,7 @@ class ExportService {
const secondaryToken = normalizedImageMd5 && normalizedImageDatName && normalizedImageDatName !== normalizedImageMd5 const secondaryToken = normalizedImageMd5 && normalizedImageDatName && normalizedImageDatName !== normalizedImageMd5
? normalizedImageDatName ? normalizedImageDatName
: '' : ''
const lookupMode = imageDeepSearchOnMiss ? 'deep' : 'hardlink' return `${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}`
return `${lookupMode}\u001f${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}`
} }
private normalizeEmojiMd5(value: unknown): string | undefined { private normalizeEmojiMd5(value: unknown): string | undefined {
@@ -3583,7 +3580,6 @@ class ExportService {
exportVoiceAsText?: boolean exportVoiceAsText?: boolean
includeVideoPoster?: boolean includeVideoPoster?: boolean
includeVoiceWithTranscript?: boolean includeVoiceWithTranscript?: boolean
imageDeepSearchOnMiss?: boolean
dirCache?: Set<string> dirCache?: Set<string>
} }
): Promise<MediaExportItem | null> { ): Promise<MediaExportItem | null> {
@@ -3596,8 +3592,7 @@ class ExportService {
sessionId, sessionId,
mediaRootDir, mediaRootDir,
mediaRelativePrefix, mediaRelativePrefix,
options.dirCache, options.dirCache
options.imageDeepSearchOnMiss !== false
) )
if (result) { if (result) {
} }
@@ -3654,8 +3649,7 @@ class ExportService {
sessionId: string, sessionId: string,
mediaRootDir: string, mediaRootDir: string,
mediaRelativePrefix: string, mediaRelativePrefix: string,
dirCache?: Set<string>, dirCache?: Set<string>
imageDeepSearchOnMiss = true
): Promise<MediaExportItem | null> { ): Promise<MediaExportItem | null> {
try { try {
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images') const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
@@ -3675,8 +3669,7 @@ class ExportService {
const missingRunCacheKey = this.getImageMissingRunCacheKey( const missingRunCacheKey = this.getImageMissingRunCacheKey(
sessionId, sessionId,
imageMd5, imageMd5,
imageDatName, imageDatName
imageDeepSearchOnMiss
) )
if (missingRunCacheKey && this.mediaRunMissingImageKeys.has(missingRunCacheKey)) { if (missingRunCacheKey && this.mediaRunMissingImageKeys.has(missingRunCacheKey)) {
return null return null
@@ -3686,25 +3679,20 @@ class ExportService {
sessionId, sessionId,
imageMd5, imageMd5,
imageDatName, imageDatName,
createTime: msg.createTime,
force: true, // 导出优先高清,失败再回退缩略图 force: true, // 导出优先高清,失败再回退缩略图
preferFilePath: true, preferFilePath: true,
hardlinkOnly: !imageDeepSearchOnMiss hardlinkOnly: true
}) })
if (!result.success || !result.localPath) { if (!result.success || !result.localPath) {
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`) console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
if (!imageDeepSearchOnMiss) {
console.log(`[Export] 未命中 hardlink已关闭缺图深度搜索→ 将显示 [图片] 占位符`)
if (missingRunCacheKey) {
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
}
return null
}
// 尝试获取缩略图 // 尝试获取缩略图
const thumbResult = await imageDecryptService.resolveCachedImage({ const thumbResult = await imageDecryptService.resolveCachedImage({
sessionId, sessionId,
imageMd5, imageMd5,
imageDatName, imageDatName,
createTime: msg.createTime,
preferFilePath: true preferFilePath: true
}) })
if (thumbResult.success && thumbResult.localPath) { if (thumbResult.success && thumbResult.localPath) {
@@ -5302,7 +5290,6 @@ class ExportService {
maxFileSizeMb: options.maxFileSizeMb, maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -5813,7 +5800,6 @@ class ExportService {
maxFileSizeMb: options.maxFileSizeMb, maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -6685,7 +6671,6 @@ class ExportService {
maxFileSizeMb: options.maxFileSizeMb, maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -7436,7 +7421,6 @@ class ExportService {
maxFileSizeMb: options.maxFileSizeMb, maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -7816,7 +7800,6 @@ class ExportService {
maxFileSizeMb: options.maxFileSizeMb, maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -8240,7 +8223,6 @@ class ExportService {
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
includeVoiceWithTranscript: true, includeVoiceWithTranscript: true,
exportVideos: options.exportVideos, exportVideos: options.exportVideos,
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)

View File

@@ -1208,6 +1208,30 @@ class HttpService {
const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session')) const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session'))
this.ensureDir(sessionDir) this.ensureDir(sessionDir)
// 预热图片 hardlink 索引,减少逐条导出时的查找开销
if (options.exportImages) {
const imageMd5Set = new Set<string>()
for (const msg of messages) {
if (msg.localType !== 3) continue
const imageMd5 = String(msg.imageMd5 || '').trim().toLowerCase()
if (imageMd5) {
imageMd5Set.add(imageMd5)
continue
}
const imageDatName = String(msg.imageDatName || '').trim().toLowerCase()
if (/^[a-f0-9]{32}$/i.test(imageDatName)) {
imageMd5Set.add(imageDatName)
}
}
if (imageMd5Set.size > 0) {
try {
await imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set))
} catch {
// ignore preload failures
}
}
}
for (const msg of messages) { for (const msg of messages) {
const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options) const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options)
if (exported) { if (exported) {
@@ -1230,27 +1254,50 @@ class HttpService {
sessionId: talker, sessionId: talker,
imageMd5: msg.imageMd5, imageMd5: msg.imageMd5,
imageDatName: msg.imageDatName, imageDatName: msg.imageDatName,
force: true createTime: msg.createTime,
force: true,
preferFilePath: true,
hardlinkOnly: true
}) })
if (result.success && result.localPath) {
let imagePath = result.localPath let imagePath = result.success ? result.localPath : undefined
if (!imagePath) {
try {
const cached = await imageDecryptService.resolveCachedImage({
sessionId: talker,
imageMd5: msg.imageMd5,
imageDatName: msg.imageDatName,
createTime: msg.createTime,
preferFilePath: true,
hardlinkOnly: true
})
if (cached.success && cached.localPath) {
imagePath = cached.localPath
}
} catch {
// ignore resolve failures
}
}
if (imagePath) {
if (imagePath.startsWith('data:')) { if (imagePath.startsWith('data:')) {
const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/) const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/)
if (base64Match) { if (!base64Match) return null
const imageBuffer = Buffer.from(base64Match[1], 'base64') const imageBuffer = Buffer.from(base64Match[1], 'base64')
const ext = this.detectImageExt(imageBuffer) const ext = this.detectImageExt(imageBuffer)
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`) const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
const fileName = `${fileBase}${ext}` const fileName = `${fileBase}${ext}`
const targetDir = path.join(sessionDir, 'images') const targetDir = path.join(sessionDir, 'images')
const fullPath = path.join(targetDir, fileName) const fullPath = path.join(targetDir, fileName)
this.ensureDir(targetDir) this.ensureDir(targetDir)
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
fs.writeFileSync(fullPath, imageBuffer) fs.writeFileSync(fullPath, imageBuffer)
}
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
return { kind: 'image', fileName, fullPath, relativePath }
} }
} else if (fs.existsSync(imagePath)) { const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
return { kind: 'image', fileName, fullPath, relativePath }
}
if (fs.existsSync(imagePath)) {
const imageBuffer = fs.readFileSync(imagePath) const imageBuffer = fs.readFileSync(imagePath)
const ext = this.detectImageExt(imageBuffer) const ext = this.detectImageExt(imageBuffer)
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`) const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ type PreloadImagePayload = {
sessionId?: string sessionId?: string
imageMd5?: string imageMd5?: string
imageDatName?: string imageDatName?: string
createTime?: number
} }
type PreloadOptions = { type PreloadOptions = {
@@ -74,6 +75,9 @@ export class ImagePreloadService {
sessionId: task.sessionId, sessionId: task.sessionId,
imageMd5: task.imageMd5, imageMd5: task.imageMd5,
imageDatName: task.imageDatName, imageDatName: task.imageDatName,
createTime: task.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: !task.allowDecrypt, disableUpdateCheck: !task.allowDecrypt,
allowCacheIndex: task.allowCacheIndex allowCacheIndex: task.allowCacheIndex
}) })
@@ -82,7 +86,10 @@ export class ImagePreloadService {
await imageDecryptService.decryptImage({ await imageDecryptService.decryptImage({
sessionId: task.sessionId, sessionId: task.sessionId,
imageMd5: task.imageMd5, imageMd5: task.imageMd5,
imageDatName: task.imageDatName imageDatName: task.imageDatName,
createTime: task.createTime,
preferFilePath: true,
hardlinkOnly: true
}) })
} catch { } catch {
// ignore preload failures // ignore preload failures

View File

@@ -0,0 +1,110 @@
import { existsSync } from 'fs'
import { join } from 'path'
type NativeDecryptResult = {
data: Buffer
ext: string
isWxgf?: boolean
is_wxgf?: boolean
}
type NativeAddon = {
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
}
let cachedAddon: NativeAddon | null | undefined
function shouldEnableNative(): boolean {
return process.env.WEFLOW_IMAGE_NATIVE !== '0'
}
function expandAsarCandidates(filePath: string): string[] {
if (!filePath.includes('app.asar') || filePath.includes('app.asar.unpacked')) {
return [filePath]
}
return [filePath.replace('app.asar', 'app.asar.unpacked'), filePath]
}
function getPlatformDir(): string {
if (process.platform === 'win32') return 'win32'
if (process.platform === 'darwin') return 'macos'
if (process.platform === 'linux') return 'linux'
return process.platform
}
function getArchDir(): string {
if (process.arch === 'x64') return 'x64'
if (process.arch === 'arm64') return 'arm64'
return process.arch
}
function getAddonCandidates(): string[] {
const platformDir = getPlatformDir()
const archDir = getArchDir()
const cwd = process.cwd()
const fileNames = [
`weflow-image-native-${platformDir}-${archDir}.node`
]
const roots = [
join(cwd, 'resources', 'wedecrypt', platformDir, archDir),
...(process.resourcesPath
? [
join(process.resourcesPath, 'resources', 'wedecrypt', platformDir, archDir),
join(process.resourcesPath, 'wedecrypt', platformDir, archDir)
]
: [])
]
const candidates = roots.flatMap((root) => fileNames.map((name) => join(root, name)))
return Array.from(new Set(candidates.flatMap(expandAsarCandidates)))
}
function loadAddon(): NativeAddon | null {
if (!shouldEnableNative()) return null
if (cachedAddon !== undefined) return cachedAddon
for (const candidate of getAddonCandidates()) {
if (!existsSync(candidate)) continue
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const addon = require(candidate) as NativeAddon
if (addon && typeof addon.decryptDatNative === 'function') {
cachedAddon = addon
return addon
}
} catch {
// try next candidate
}
}
cachedAddon = null
return null
}
export function nativeAddonLocation(): string | null {
for (const candidate of getAddonCandidates()) {
if (existsSync(candidate)) return candidate
}
return null
}
export function decryptDatViaNative(
inputPath: string,
xorKey: number,
aesKey?: string
): { data: Buffer; ext: string; isWxgf: boolean } | null {
const addon = loadAddon()
if (!addon) return null
try {
const result = addon.decryptDatNative(inputPath, xorKey, aesKey)
const isWxgf = Boolean(result?.isWxgf ?? result?.is_wxgf)
if (!result || !Buffer.isBuffer(result.data)) return null
const rawExt = typeof result.ext === 'string' && result.ext.trim()
? result.ext.trim().toLowerCase()
: ''
const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : ''
return { data: result.data, ext, isWxgf }
} catch {
return null
}
}

29
package-lock.json generated
View File

@@ -9,7 +9,6 @@
"version": "4.3.0", "version": "4.3.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@vscode/sudo-prompt": "^9.3.2",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",
"electron-store": "^11.0.2", "electron-store": "^11.0.2",
@@ -28,8 +27,9 @@
"react-router-dom": "^7.14.0", "react-router-dom": "^7.14.0",
"react-virtuoso": "^4.18.1", "react-virtuoso": "^4.18.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sherpa-onnx-node": "^1.12.35", "sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1", "silk-wasm": "^3.7.1",
"sudo-prompt": "^9.2.1",
"wechat-emojis": "^1.0.2", "wechat-emojis": "^1.0.2",
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
@@ -40,11 +40,11 @@
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"electron": "^41.1.1", "electron": "^41.1.1",
"electron-builder": "^26.8.1", "electron-builder": "^26.8.1",
"sass": "^1.99.0", "sass": "^1.98.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^7.3.2", "vite": "^7.0.0",
"vite-plugin-electron": "^0.29.1", "vite-plugin-electron": "^0.28.8",
"vite-plugin-electron-renderer": "^0.14.6" "vite-plugin-electron-renderer": "^0.14.6"
} }
}, },
@@ -3050,12 +3050,6 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vscode/sudo-prompt": {
"version": "9.3.2",
"resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz",
"integrity": "sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==",
"license": "MIT"
},
"node_modules/@xmldom/xmldom": { "node_modules/@xmldom/xmldom": {
"version": "0.8.12", "version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
@@ -9462,6 +9456,13 @@
"inline-style-parser": "0.2.7" "inline-style-parser": "0.2.7"
} }
}, },
"node_modules/sudo-prompt": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.2.1.tgz",
"integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/sumchecker": { "node_modules/sumchecker": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
@@ -10140,9 +10141,9 @@
} }
}, },
"node_modules/vite-plugin-electron": { "node_modules/vite-plugin-electron": {
"version": "0.29.1", "version": "0.28.8",
"resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.29.1.tgz", "resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.28.8.tgz",
"integrity": "sha512-AejNed5BgHFnuw8h5puTa61C6vdP4ydbsbo/uVjH1fTdHAlCDz1+o6pDQ/scQj1udDrGvH01+vTbzQh/vMnR9w==", "integrity": "sha512-ir+B21oSGK9j23OEvt4EXyco9xDCaF6OGFe0V/8Zc0yL2+HMyQ6mmNQEIhXsEsZCSfIowBpwQBeHH4wVsfraeg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {

View File

@@ -9,7 +9,7 @@
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/Jasonzhu1207/WeFlow" "url": "https://github.com/hicccc77/WeFlow"
}, },
"//": "二改不应改变此处的作者与应用信息", "//": "二改不应改变此处的作者与应用信息",
"scripts": { "scripts": {
@@ -77,7 +77,7 @@
"appId": "com.WeFlow.app", "appId": "com.WeFlow.app",
"publish": { "publish": {
"provider": "github", "provider": "github",
"owner": "Jasonzhu1207", "owner": "hicccc77",
"repo": "WeFlow", "repo": "WeFlow",
"releaseType": "release" "releaseType": "release"
}, },
@@ -186,7 +186,8 @@
"node_modules/sherpa-onnx-node/**/*", "node_modules/sherpa-onnx-node/**/*",
"node_modules/sherpa-onnx-*/*", "node_modules/sherpa-onnx-*/*",
"node_modules/sherpa-onnx-*/**/*", "node_modules/sherpa-onnx-*/**/*",
"node_modules/ffmpeg-static/**/*" "node_modules/ffmpeg-static/**/*",
"resources/wedecrypt/**/*.node"
], ],
"icon": "resources/icon.icns" "icon": "resources/icon.icns"
}, },

View File

@@ -154,6 +154,21 @@ function hasRenderableChatRecordName(value?: string): boolean {
return value !== undefined && value !== null && String(value).length > 0 return value !== undefined && value !== null && String(value).length > 0
} }
function toRenderableImageSrc(path?: string): string | undefined {
const raw = String(path || '').trim()
if (!raw) return undefined
if (/^(data:|blob:|https?:|file:)/i.test(raw)) return raw
const normalized = raw.replace(/\\/g, '/')
if (/^[a-zA-Z]:\//.test(normalized)) {
return encodeURI(`file:///${normalized}`)
}
if (normalized.startsWith('/')) {
return encodeURI(`file://${normalized}`)
}
return raw
}
function getChatRecordPreviewText(item: ChatRecordItem): string { function getChatRecordPreviewText(item: ChatRecordItem): string {
const text = normalizeChatRecordText(item.datadesc) || normalizeChatRecordText(item.datatitle) const text = normalizeChatRecordText(item.datadesc) || normalizeChatRecordText(item.datatitle)
if (item.datatype === 17) { if (item.datatype === 17) {
@@ -4853,7 +4868,7 @@ function ChatPage(props: ChatPageProps) {
const candidates = [...head, ...tail] const candidates = [...head, ...tail]
const queued = preloadImageKeysRef.current const queued = preloadImageKeysRef.current
const seen = new Set<string>() const seen = new Set<string>()
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = [] const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = []
for (const msg of candidates) { for (const msg of candidates) {
if (payloads.length >= maxPreload) break if (payloads.length >= maxPreload) break
if (msg.localType !== 3) continue if (msg.localType !== 3) continue
@@ -4867,11 +4882,14 @@ function ChatPage(props: ChatPageProps) {
payloads.push({ payloads.push({
sessionId: currentSessionId, sessionId: currentSessionId,
imageMd5: msg.imageMd5 || undefined, imageMd5: msg.imageMd5 || undefined,
imageDatName: msg.imageDatName imageDatName: msg.imageDatName,
createTime: msg.createTime
}) })
} }
if (payloads.length > 0) { if (payloads.length > 0) {
window.electronAPI.image.preload(payloads).catch(() => { }) window.electronAPI.image.preload(payloads, {
allowCacheIndex: false
}).catch(() => { })
} }
}, [currentSessionId, messages]) }, [currentSessionId, messages])
@@ -5840,7 +5858,10 @@ function ChatPage(props: ChatPageProps) {
sessionId: session.username, sessionId: session.username,
imageMd5: img.imageMd5, imageMd5: img.imageMd5,
imageDatName: img.imageDatName, imageDatName: img.imageDatName,
force: true createTime: img.createTime,
force: true,
preferFilePath: true,
hardlinkOnly: true
}) })
if (r?.success) successCount++ if (r?.success) successCount++
else failCount++ else failCount++
@@ -7882,7 +7903,7 @@ function MessageBubble({
) )
const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}` const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}`
const [imageLocalPath, setImageLocalPath] = useState<string | undefined>( const [imageLocalPath, setImageLocalPath] = useState<string | undefined>(
() => imageDataUrlCache.get(imageCacheKey) () => toRenderableImageSrc(imageDataUrlCache.get(imageCacheKey))
) )
const voiceIdentityKey = buildVoiceCacheIdentity(session.username, message) const voiceIdentityKey = buildVoiceCacheIdentity(session.username, message)
const voiceCacheKey = `voice:${voiceIdentityKey}` const voiceCacheKey = `voice:${voiceIdentityKey}`
@@ -7904,6 +7925,7 @@ function MessageBubble({
const imageUpdateCheckedRef = useRef<string | null>(null) const imageUpdateCheckedRef = useRef<string | null>(null)
const imageClickTimerRef = useRef<number | null>(null) const imageClickTimerRef = useRef<number | null>(null)
const imageContainerRef = useRef<HTMLDivElement>(null) const imageContainerRef = useRef<HTMLDivElement>(null)
const imageElementRef = useRef<HTMLImageElement | null>(null)
const emojiContainerRef = useRef<HTMLDivElement>(null) const emojiContainerRef = useRef<HTMLDivElement>(null)
const imageResizeBaselineRef = useRef<number | null>(null) const imageResizeBaselineRef = useRef<number | null>(null)
const emojiResizeBaselineRef = useRef<number | null>(null) const emojiResizeBaselineRef = useRef<number | null>(null)
@@ -8260,19 +8282,27 @@ function MessageBubble({
sessionId: session.username, sessionId: session.username,
imageMd5: message.imageMd5 || undefined, imageMd5: message.imageMd5 || undefined,
imageDatName: message.imageDatName, imageDatName: message.imageDatName,
force: forceUpdate createTime: message.createTime,
force: forceUpdate,
preferFilePath: true,
hardlinkOnly: true
}) as SharedImageDecryptResult }) as SharedImageDecryptResult
}) })
if (result.success && result.localPath) { if (result.success && result.localPath) {
imageDataUrlCache.set(imageCacheKey, result.localPath) const renderPath = toRenderableImageSrc(result.localPath)
if (imageLocalPath !== result.localPath) { if (!renderPath) {
if (!silent) setImageError(true)
return { success: false }
}
imageDataUrlCache.set(imageCacheKey, renderPath)
if (imageLocalPath !== renderPath) {
captureImageResizeBaseline() captureImageResizeBaseline()
lockImageStageHeight() lockImageStageHeight()
} }
setImageLocalPath(result.localPath) setImageLocalPath(renderPath)
setImageHasUpdate(false) setImageHasUpdate(false)
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath) if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
return result return { ...result, localPath: renderPath }
} }
} }
@@ -8297,7 +8327,7 @@ function MessageBubble({
imageDecryptPendingRef.current = false imageDecryptPendingRef.current = false
} }
return { success: false } return { success: false }
}, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight]) }, [isImage, message.imageMd5, message.imageDatName, message.createTime, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight])
const triggerForceHd = useCallback(() => { const triggerForceHd = useCallback(() => {
if (!message.imageMd5 && !message.imageDatName) return if (!message.imageMd5 && !message.imageDatName) return
@@ -8352,24 +8382,29 @@ function MessageBubble({
const resolved = await window.electronAPI.image.resolveCache({ const resolved = await window.electronAPI.image.resolveCache({
sessionId: session.username, sessionId: session.username,
imageMd5: message.imageMd5 || undefined, imageMd5: message.imageMd5 || undefined,
imageDatName: message.imageDatName imageDatName: message.imageDatName,
createTime: message.createTime,
preferFilePath: true,
hardlinkOnly: true
}) })
if (resolved?.success && resolved.localPath) { if (resolved?.success && resolved.localPath) {
finalImagePath = resolved.localPath const renderPath = toRenderableImageSrc(resolved.localPath)
if (!renderPath) return
finalImagePath = renderPath
finalLiveVideoPath = resolved.liveVideoPath || finalLiveVideoPath finalLiveVideoPath = resolved.liveVideoPath || finalLiveVideoPath
imageDataUrlCache.set(imageCacheKey, resolved.localPath) imageDataUrlCache.set(imageCacheKey, renderPath)
if (imageLocalPath !== resolved.localPath) { if (imageLocalPath !== renderPath) {
captureImageResizeBaseline() captureImageResizeBaseline()
lockImageStageHeight() lockImageStageHeight()
} }
setImageLocalPath(resolved.localPath) setImageLocalPath(renderPath)
if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath) if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath)
setImageHasUpdate(Boolean(resolved.hasUpdate)) setImageHasUpdate(Boolean(resolved.hasUpdate))
} }
} catch { } } catch { }
} }
void window.electronAPI.window.openImageViewerWindow(finalImagePath, finalLiveVideoPath) void window.electronAPI.window.openImageViewerWindow(toRenderableImageSrc(finalImagePath) || finalImagePath, finalLiveVideoPath)
}, [ }, [
imageLiveVideoPath, imageLiveVideoPath,
imageLocalPath, imageLocalPath,
@@ -8378,6 +8413,7 @@ function MessageBubble({
lockImageStageHeight, lockImageStageHeight,
message.imageDatName, message.imageDatName,
message.imageMd5, message.imageMd5,
message.createTime,
requestImageDecrypt, requestImageDecrypt,
session.username session.username
]) ])
@@ -8391,8 +8427,19 @@ function MessageBubble({
}, []) }, [])
useEffect(() => { useEffect(() => {
setImageLoaded(false) if (!isImage) return
}, [imageLocalPath]) if (!imageLocalPath) {
setImageLoaded(false)
return
}
// 某些 file:// 缓存图在 src 切换时可能不会稳定触发 onLoad
// 这里用 complete/naturalWidth 做一次兜底,避免图片进入 pending 隐身态。
const img = imageElementRef.current
if (img && img.complete && img.naturalWidth > 0) {
setImageLoaded(true)
}
}, [isImage, imageLocalPath])
useEffect(() => { useEffect(() => {
if (imageLoading) return if (imageLoading) return
@@ -8401,7 +8448,7 @@ function MessageBubble({
}, [imageError, imageLoading, imageLocalPath]) }, [imageError, imageLoading, imageLocalPath])
useEffect(() => { useEffect(() => {
if (!isImage || imageLoading) return if (!isImage || imageLoading || !imageInView) return
if (!message.imageMd5 && !message.imageDatName) return if (!message.imageMd5 && !message.imageDatName) return
if (imageUpdateCheckedRef.current === imageCacheKey) return if (imageUpdateCheckedRef.current === imageCacheKey) return
imageUpdateCheckedRef.current = imageCacheKey imageUpdateCheckedRef.current = imageCacheKey
@@ -8409,15 +8456,21 @@ function MessageBubble({
window.electronAPI.image.resolveCache({ window.electronAPI.image.resolveCache({
sessionId: session.username, sessionId: session.username,
imageMd5: message.imageMd5 || undefined, imageMd5: message.imageMd5 || undefined,
imageDatName: message.imageDatName imageDatName: message.imageDatName,
createTime: message.createTime,
preferFilePath: true,
hardlinkOnly: true,
allowCacheIndex: false
}).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }) => { }).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }) => {
if (cancelled) return if (cancelled) return
if (result.success && result.localPath) { if (result.success && result.localPath) {
imageDataUrlCache.set(imageCacheKey, result.localPath) const renderPath = toRenderableImageSrc(result.localPath)
if (!imageLocalPath || imageLocalPath !== result.localPath) { if (!renderPath) return
imageDataUrlCache.set(imageCacheKey, renderPath)
if (!imageLocalPath || imageLocalPath !== renderPath) {
captureImageResizeBaseline() captureImageResizeBaseline()
lockImageStageHeight() lockImageStageHeight()
setImageLocalPath(result.localPath) setImageLocalPath(renderPath)
setImageError(false) setImageError(false)
} }
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath) if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
@@ -8427,7 +8480,7 @@ function MessageBubble({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline, lockImageStageHeight]) }, [isImage, imageInView, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, message.createTime, imageCacheKey, session.username, captureImageResizeBaseline, lockImageStageHeight])
useEffect(() => { useEffect(() => {
if (!isImage) return if (!isImage) return
@@ -8455,15 +8508,17 @@ function MessageBubble({
(payload.imageMd5 && payload.imageMd5 === message.imageMd5) || (payload.imageMd5 && payload.imageMd5 === message.imageMd5) ||
(payload.imageDatName && payload.imageDatName === message.imageDatName) (payload.imageDatName && payload.imageDatName === message.imageDatName)
if (matchesCacheKey) { if (matchesCacheKey) {
const renderPath = toRenderableImageSrc(payload.localPath)
if (!renderPath) return
const cachedPath = imageDataUrlCache.get(imageCacheKey) const cachedPath = imageDataUrlCache.get(imageCacheKey)
if (cachedPath !== payload.localPath) { if (cachedPath !== renderPath) {
imageDataUrlCache.set(imageCacheKey, payload.localPath) imageDataUrlCache.set(imageCacheKey, renderPath)
} }
if (imageLocalPath !== payload.localPath) { if (imageLocalPath !== renderPath) {
captureImageResizeBaseline() captureImageResizeBaseline()
lockImageStageHeight() lockImageStageHeight()
} }
setImageLocalPath((prev) => (prev === payload.localPath ? prev : payload.localPath)) setImageLocalPath((prev) => (prev === renderPath ? prev : renderPath))
setImageError(false) setImageError(false)
} }
}) })
@@ -9093,6 +9148,7 @@ function MessageBubble({
<> <>
<div className="image-message-wrapper"> <div className="image-message-wrapper">
<img <img
ref={imageElementRef}
src={imageLocalPath} src={imageLocalPath}
alt="图片" alt="图片"
className={`image-message ${imageLoaded ? 'ready' : 'pending'}`} className={`image-message ${imageLoaded ? 'ready' : 'pending'}`}

View File

@@ -105,7 +105,6 @@ interface ExportOptions {
txtColumns: string[] txtColumns: string[]
displayNamePreference: DisplayNamePreference displayNamePreference: DisplayNamePreference
exportConcurrency: number exportConcurrency: number
imageDeepSearchOnMiss: boolean
} }
interface SessionRow extends AppChatSession { interface SessionRow extends AppChatSession {
@@ -336,6 +335,15 @@ const isTextBatchTask = (task: ExportTask): boolean => (
task.payload.scope === 'content' && task.payload.contentType === 'text' task.payload.scope === 'content' && task.payload.contentType === 'text'
) )
const isImageExportTask = (task: ExportTask): boolean => {
if (task.payload.scope === 'sns') {
return Boolean(task.payload.snsOptions?.exportImages)
}
if (task.payload.scope !== 'content') return false
if (task.payload.contentType === 'image') return true
return Boolean(task.payload.options?.exportImages)
}
const resolvePerfStageByPhase = (phase?: ExportProgress['phase']): TaskPerfStage => { const resolvePerfStageByPhase = (phase?: ExportProgress['phase']): TaskPerfStage => {
if (phase === 'preparing') return 'collect' if (phase === 'preparing') return 'collect'
if (phase === 'writing') return 'write' if (phase === 'writing') return 'write'
@@ -1705,6 +1713,24 @@ const TaskCenterModal = memo(function TaskCenterModal({
const currentSessionRatio = task.progress.phaseTotal > 0 const currentSessionRatio = task.progress.phaseTotal > 0
? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal)) ? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal))
: null : null
const imageTask = isImageExportTask(task)
const imageTimingElapsedMs = imageTask
? Math.max(0, (
typeof task.finishedAt === 'number'
? task.finishedAt
: nowTick
) - (task.startedAt || task.createdAt))
: 0
const imageTimingAvgMs = imageTask && mediaDoneFiles > 0
? Math.floor(imageTimingElapsedMs / Math.max(1, mediaDoneFiles))
: 0
const imageTimingLabel = imageTask
? (
mediaDoneFiles > 0
? `图片耗时 ${formatDurationMs(imageTimingElapsedMs)} · 平均 ${imageTimingAvgMs}ms/张`
: `图片耗时 ${formatDurationMs(imageTimingElapsedMs)}`
)
: ''
return ( return (
<div key={task.id} className={`task-card ${task.status}`}> <div key={task.id} className={`task-card ${task.status}`}>
<div className="task-main"> <div className="task-main">
@@ -1734,6 +1760,11 @@ const TaskCenterModal = memo(function TaskCenterModal({
</div> </div>
</> </>
)} )}
{imageTimingLabel && task.status !== 'queued' && (
<div className="task-perf-summary">
<span>{imageTimingLabel}</span>
</div>
)}
{canShowPerfDetail && stageTotals && ( {canShowPerfDetail && stageTotals && (
<div className="task-perf-summary"> <div className="task-perf-summary">
<span> {formatDurationMs(stageTotalMs)}</span> <span> {formatDurationMs(stageTotalMs)}</span>
@@ -1903,7 +1934,6 @@ function ExportPage() {
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2) const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
const [exportDefaultImageDeepSearchOnMiss, setExportDefaultImageDeepSearchOnMiss] = useState(true)
const [options, setOptions] = useState<ExportOptions>({ const [options, setOptions] = useState<ExportOptions>({
format: 'json', format: 'json',
@@ -1924,8 +1954,7 @@ function ExportPage() {
excelCompactColumns: true, excelCompactColumns: true,
txtColumns: defaultTxtColumns, txtColumns: defaultTxtColumns,
displayNamePreference: 'remark', displayNamePreference: 'remark',
exportConcurrency: 2, exportConcurrency: 2
imageDeepSearchOnMiss: true
}) })
const [exportDialog, setExportDialog] = useState<ExportDialogState>({ const [exportDialog, setExportDialog] = useState<ExportDialogState>({
@@ -2622,7 +2651,7 @@ function ExportPage() {
automationTasksReadyRef.current = false automationTasksReadyRef.current = false
let isReady = true let isReady = true
try { try {
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([ const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([
configService.getExportPath(), configService.getExportPath(),
configService.getExportDefaultFormat(), configService.getExportDefaultFormat(),
configService.getExportDefaultAvatars(), configService.getExportDefaultAvatars(),
@@ -2631,7 +2660,6 @@ function ExportPage() {
configService.getExportDefaultExcelCompactColumns(), configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultTxtColumns(), configService.getExportDefaultTxtColumns(),
configService.getExportDefaultConcurrency(), configService.getExportDefaultConcurrency(),
configService.getExportDefaultImageDeepSearchOnMiss(),
configService.getExportLastSessionRunMap(), configService.getExportLastSessionRunMap(),
configService.getExportLastContentRunMap(), configService.getExportLastContentRunMap(),
configService.getExportSessionRecordMap(), configService.getExportSessionRecordMap(),
@@ -2671,7 +2699,6 @@ function ExportPage() {
setExportDefaultVoiceAsText(savedVoiceAsText ?? false) setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedConcurrency ?? 2) setExportDefaultConcurrency(savedConcurrency ?? 2)
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic') setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
setAutomationTasks(automationTaskItem?.tasks || []) setAutomationTasks(automationTaskItem?.tasks || [])
automationTasksReadyRef.current = true automationTasksReadyRef.current = true
@@ -2709,8 +2736,7 @@ function ExportPage() {
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText, exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns, excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
txtColumns, txtColumns,
exportConcurrency: savedConcurrency ?? prev.exportConcurrency, exportConcurrency: savedConcurrency ?? prev.exportConcurrency
imageDeepSearchOnMiss: savedImageDeepSearchOnMiss ?? prev.imageDeepSearchOnMiss
})) }))
} catch (error) { } catch (error) {
isReady = false isReady = false
@@ -4491,8 +4517,7 @@ function ExportPage() {
maxFileSizeMb: prev.maxFileSizeMb, maxFileSizeMb: prev.maxFileSizeMb,
exportVoiceAsText: exportDefaultVoiceAsText, exportVoiceAsText: exportDefaultVoiceAsText,
excelCompactColumns: exportDefaultExcelCompactColumns, excelCompactColumns: exportDefaultExcelCompactColumns,
exportConcurrency: exportDefaultConcurrency, exportConcurrency: exportDefaultConcurrency
imageDeepSearchOnMiss: exportDefaultImageDeepSearchOnMiss
} }
if (payload.scope === 'sns') { if (payload.scope === 'sns') {
@@ -4527,8 +4552,7 @@ function ExportPage() {
exportDefaultAvatars, exportDefaultAvatars,
exportDefaultMedia, exportDefaultMedia,
exportDefaultVoiceAsText, exportDefaultVoiceAsText,
exportDefaultConcurrency, exportDefaultConcurrency
exportDefaultImageDeepSearchOnMiss
]) ])
const closeExportDialog = useCallback(() => { const closeExportDialog = useCallback(() => {
@@ -4755,7 +4779,6 @@ function ExportPage() {
txtColumns: options.txtColumns, txtColumns: options.txtColumns,
displayNamePreference: options.displayNamePreference, displayNamePreference: options.displayNamePreference,
exportConcurrency: options.exportConcurrency, exportConcurrency: options.exportConcurrency,
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
fileNamingMode: exportDefaultFileNamingMode, fileNamingMode: exportDefaultFileNamingMode,
sessionLayout, sessionLayout,
sessionNameWithTypePrefix, sessionNameWithTypePrefix,
@@ -5691,8 +5714,6 @@ function ExportPage() {
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns) await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
await configService.setExportDefaultTxtColumns(options.txtColumns) await configService.setExportDefaultTxtColumns(options.txtColumns)
await configService.setExportDefaultConcurrency(options.exportConcurrency) await configService.setExportDefaultConcurrency(options.exportConcurrency)
await configService.setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
} }
const openSingleExport = useCallback((session: SessionRow) => { const openSingleExport = useCallback((session: SessionRow) => {
@@ -7393,14 +7414,6 @@ function ExportPage() {
const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
const shouldShowMediaSection = !isContentScopeDialog const shouldShowMediaSection = !isContentScopeDialog
const shouldRenderImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
isSessionScopeDialog ||
(isContentScopeDialog && exportDialog.contentType === 'image')
)
const shouldShowImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
(isSessionScopeDialog && options.exportImages) ||
(isContentScopeDialog && exportDialog.contentType === 'image')
)
const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像' const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像'
const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。' const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。'
const activeDialogFormatLabel = exportDialog.scope === 'sns' const activeDialogFormatLabel = exportDialog.scope === 'sns'
@@ -9710,30 +9723,6 @@ function ExportPage() {
</div> </div>
)} )}
{shouldRenderImageDeepSearchToggle && (
<div className={`dialog-collapse-slot ${shouldShowImageDeepSearchToggle ? 'open' : ''}`} aria-hidden={!shouldShowImageDeepSearchToggle}>
<div className="dialog-collapse-inner">
<div className="dialog-section">
<div className="dialog-switch-row">
<div className="dialog-switch-copy">
<h4></h4>
<div className="format-note"> hardlink </div>
</div>
<button
type="button"
className={`dialog-switch ${options.imageDeepSearchOnMiss ? 'on' : ''}`}
aria-pressed={options.imageDeepSearchOnMiss}
aria-label="切换缺图时深度搜索"
onClick={() => setOptions(prev => ({ ...prev, imageDeepSearchOnMiss: !prev.imageDeepSearchOnMiss }))}
>
<span className="dialog-switch-thumb" />
</button>
</div>
</div>
</div>
</div>
)}
{isSessionScopeDialog && ( {isSessionScopeDialog && (
<div className="dialog-section"> <div className="dialog-section">
<div className="dialog-switch-row"> <div className="dialog-switch-row">

View File

@@ -37,7 +37,6 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns', EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns', EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency', EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS: 'exportDefaultImageDeepSearchOnMiss',
EXPORT_WRITE_LAYOUT: 'exportWriteLayout', EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled', EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap', EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
@@ -548,18 +547,6 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise<
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency) await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
} }
// 获取缺图时是否深度搜索(默认导出行为)
export async function getExportDefaultImageDeepSearchOnMiss(): Promise<boolean | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS)
if (typeof value === 'boolean') return value
return null
}
// 设置缺图时是否深度搜索(默认导出行为)
export async function setExportDefaultImageDeepSearchOnMiss(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS, enabled)
}
export type ExportWriteLayout = 'A' | 'B' | 'C' export type ExportWriteLayout = 'A' | 'B' | 'C'
export async function getExportWriteLayout(): Promise<ExportWriteLayout> { export async function getExportWriteLayout(): Promise<ExportWriteLayout> {

View File

@@ -491,24 +491,35 @@ export interface ElectronAPI {
} }
image: { image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }> decrypt: (payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
force?: boolean
preferFilePath?: boolean
hardlinkOnly?: boolean
}) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
resolveCache: (payload: { resolveCache: (payload: {
sessionId?: string sessionId?: string
imageMd5?: string imageMd5?: string
imageDatName?: string imageDatName?: string
createTime?: number
preferFilePath?: boolean
hardlinkOnly?: boolean
disableUpdateCheck?: boolean disableUpdateCheck?: boolean
allowCacheIndex?: boolean allowCacheIndex?: boolean
}) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }> }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
resolveCacheBatch: ( resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean } options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean }
) => Promise<{ ) => Promise<{
success: boolean success: boolean
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
error?: string error?: string
}> }>
preload: ( preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean } options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => Promise<boolean> ) => Promise<boolean>
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
@@ -1117,7 +1128,6 @@ export interface ExportOptions {
sessionNameWithTypePrefix?: boolean sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number exportConcurrency?: number
imageDeepSearchOnMiss?: boolean
} }
export interface ExportProgress { export interface ExportProgress {