Merge branch 'dev' into feat-chatlab

This commit is contained in:
xuncha
2026-04-20 23:17:50 +08:00
committed by GitHub
32 changed files with 3076 additions and 2540 deletions

View File

@@ -105,9 +105,13 @@ jobs:
- name: Package macOS arm64 dev artifacts - name: Package macOS arm64 dev artifacts
shell: bash shell: bash
run: | run: |
set -euo pipefail
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/" export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR" echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}' if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'; then
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'
fi
- name: Upload macOS arm64 assets to fixed release - name: Upload macOS arm64 assets to fixed release
env: env:
@@ -314,6 +318,9 @@ jobs:
WINDOWS_ASSET="$(pick_asset "dev-x64-Setup[.]exe$")" WINDOWS_ASSET="$(pick_asset "dev-x64-Setup[.]exe$")"
WINDOWS_ARM64_ASSET="$(pick_asset "dev-arm64-Setup[.]exe$")" WINDOWS_ARM64_ASSET="$(pick_asset "dev-arm64-Setup[.]exe$")"
MAC_ASSET="$(pick_asset "dev-arm64[.]dmg$")" MAC_ASSET="$(pick_asset "dev-arm64[.]dmg$")"
if [ -z "$MAC_ASSET" ]; then
MAC_ASSET="$(pick_asset "dev-arm64[.]zip$")"
fi
LINUX_TAR_ASSET="$(pick_asset "dev-linux[.]tar[.]gz$")" LINUX_TAR_ASSET="$(pick_asset "dev-linux[.]tar[.]gz$")"
LINUX_APPIMAGE_ASSET="$(pick_asset "dev-linux[.]AppImage$")" LINUX_APPIMAGE_ASSET="$(pick_asset "dev-linux[.]AppImage$")"

View File

@@ -134,9 +134,13 @@ jobs:
CSC_IDENTITY_AUTO_DISCOVERY: "false" CSC_IDENTITY_AUTO_DISCOVERY: "false"
shell: bash shell: bash
run: | run: |
set -euo pipefail
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/" export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR" echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}' if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'; then
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'
fi
- name: Upload macOS arm64 assets to fixed preview release - name: Upload macOS arm64 assets to fixed preview release
env: env:
@@ -359,6 +363,9 @@ jobs:
fi fi
WINDOWS_ARM64_ASSET="$(pick_asset "arm64.*[.]exe$")" WINDOWS_ARM64_ASSET="$(pick_asset "arm64.*[.]exe$")"
MAC_ASSET="$(pick_asset "[.]dmg$")" MAC_ASSET="$(pick_asset "[.]dmg$")"
if [ -z "$MAC_ASSET" ]; then
MAC_ASSET="$(pick_asset "[.]zip$")"
fi
LINUX_TAR_ASSET="$(pick_asset "[.]tar[.]gz$")" LINUX_TAR_ASSET="$(pick_asset "[.]tar[.]gz$")"
LINUX_APPIMAGE_ASSET="$(pick_asset "[.]AppImage$")" LINUX_APPIMAGE_ASSET="$(pick_asset "[.]AppImage$")"

View File

@@ -49,9 +49,13 @@ jobs:
CSC_IDENTITY_AUTO_DISCOVERY: "false" CSC_IDENTITY_AUTO_DISCOVERY: "false"
shell: bash shell: bash
run: | run: |
set -euo pipefail
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/" export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR" echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
npx electron-builder --mac dmg zip --arm64 --publish always if ! npx electron-builder --mac dmg zip --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'; then
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
npx electron-builder --mac zip --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'
fi
- name: Inject minimumVersion into latest yml - name: Inject minimumVersion into latest yml
env: env:
@@ -114,7 +118,7 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
npx electron-builder --linux --publish always npx electron-builder --linux --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'
- name: Inject minimumVersion into latest yml - name: Inject minimumVersion into latest yml
env: env:
@@ -167,7 +171,7 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
npx electron-builder --win nsis --x64 --publish always '--config.artifactName=${productName}-${version}-x64-Setup.${ext}' npx electron-builder --win nsis --x64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
- name: Inject minimumVersion into latest yml - name: Inject minimumVersion into latest yml
env: env:
@@ -220,7 +224,7 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
npx electron-builder --win nsis --arm64 --publish always '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}' npx electron-builder --win nsis --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
- name: Inject minimumVersion into latest yml - name: Inject minimumVersion into latest yml
env: env:
@@ -274,6 +278,9 @@ jobs:
fi fi
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')" WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
MAC_ASSET="$(pick_asset "\\.dmg$")" MAC_ASSET="$(pick_asset "\\.dmg$")"
if [ -z "$MAC_ASSET" ]; then
MAC_ASSET="$(pick_asset "arm64\\.zip$")"
fi
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")" LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")" LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"

View File

@@ -3555,6 +3555,24 @@ function registerIpcHandlers() {
} }
}) })
ipcMain.handle('annualReport:captureCurrentWindow', async (event) => {
try {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win || win.isDestroyed()) {
return { success: false, error: '窗口不可用' }
}
const image = await win.webContents.capturePage()
return {
success: true,
dataUrl: image.toDataURL(),
size: image.getSize()
}
} catch (e) {
return { success: false, error: String(e) }
}
})
// 密钥获取 // 密钥获取
ipcMain.handle('key:autoGetDbKey', async (event) => { ipcMain.handle('key:autoGetDbKey', async (event) => {
return keyService.autoGetDbKey(180_000, (message: string, level: number) => { return keyService.autoGetDbKey(180_000, (message: string, level: number) => {

View File

@@ -412,6 +412,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year), generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
ipcRenderer.invoke('annualReport:exportImages', payload), ipcRenderer.invoke('annualReport:exportImages', payload),
captureCurrentWindow: () => ipcRenderer.invoke('annualReport:captureCurrentWindow'),
onAvailableYearsProgress: (callback: (payload: { onAvailableYearsProgress: (callback: (payload: {
taskId: string taskId: string
years?: number[] years?: number[]

View File

@@ -59,6 +59,8 @@ export interface AnnualReportData {
initiatedChats: number initiatedChats: number
receivedChats: number receivedChats: number
initiativeRate: number initiativeRate: number
topInitiatedFriend?: string
topInitiatedCount?: number
} | null } | null
responseSpeed: { responseSpeed: {
avgResponseTime: number avgResponseTime: number
@@ -1190,7 +1192,9 @@ class AnnualReportService {
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[] topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
} | undefined } | undefined
const snsStats = await wcdbService.getSnsAnnualStats(actualStartTime, actualEndTime) const snsBeginTime = isAllTime ? 0 : actualStartTime
const snsEndTime = isAllTime ? Math.floor(Date.now() / 1000) : actualEndTime
const snsStats = await wcdbService.getSnsAnnualStats(snsBeginTime, snsEndTime)
if (snsStats.success && snsStats.data) { if (snsStats.success && snsStats.data) {
const d = snsStats.data const d = snsStats.data
@@ -1217,6 +1221,20 @@ class AnnualReportService {
} }
} }
// ALL YEARS 兼容:部分底层实现 begin/end 为 0 时会返回 0兜底使用导出统计总数。
if (isAllTime && (!snsStatsResult || Number(snsStatsResult.totalPosts || 0) <= 0)) {
const snsExportStats = await wcdbService.getSnsExportStats(cleanedWxid || rawWxid)
if (snsExportStats.success && snsExportStats.data) {
const fallbackTotalPosts = Math.max(0, Number(snsExportStats.data.totalPosts || 0))
snsStatsResult = {
totalPosts: fallbackTotalPosts,
typeCounts: snsStatsResult?.typeCounts,
topLikers: snsStatsResult?.topLikers || [],
topLiked: snsStatsResult?.topLiked || []
}
}
}
this.reportProgress('整理联系人信息...', 85, onProgress) this.reportProgress('整理联系人信息...', 85, onProgress)
const contactIds = Array.from(contactStats.keys()) const contactIds = Array.from(contactStats.keys())
@@ -1346,16 +1364,27 @@ class AnnualReportService {
let socialInitiative: AnnualReportData['socialInitiative'] = null let socialInitiative: AnnualReportData['socialInitiative'] = null
let totalInitiated = 0 let totalInitiated = 0
let totalReceived = 0 let totalReceived = 0
for (const stats of conversationStarts.values()) { let topInitiatedSessionId = ''
let topInitiatedCount = 0
for (const [sessionId, stats] of conversationStarts.entries()) {
totalInitiated += stats.initiated totalInitiated += stats.initiated
totalReceived += stats.received totalReceived += stats.received
if (stats.initiated > topInitiatedCount) {
topInitiatedCount = stats.initiated
topInitiatedSessionId = sessionId
}
} }
const totalConversations = totalInitiated + totalReceived const totalConversations = totalInitiated + totalReceived
if (totalConversations > 0) { if (totalConversations > 0) {
const topInitiatedInfo = topInitiatedSessionId ? contactInfoMap.get(topInitiatedSessionId) : null
socialInitiative = { socialInitiative = {
initiatedChats: totalInitiated, initiatedChats: totalInitiated,
receivedChats: totalReceived, receivedChats: totalReceived,
initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10 initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10,
topInitiatedFriend: topInitiatedCount > 0
? (topInitiatedInfo?.displayName || topInitiatedSessionId)
: undefined,
topInitiatedCount: topInitiatedCount > 0 ? topInitiatedCount : undefined
} }
} }

View File

@@ -85,6 +85,8 @@ interface ConfigSchema {
aiInsightSilenceDays: number aiInsightSilenceDays: number
aiInsightAllowContext: boolean aiInsightAllowContext: boolean
aiInsightAllowSocialContext: boolean aiInsightAllowSocialContext: boolean
aiInsightFilterMode: 'whitelist' | 'blacklist'
aiInsightFilterList: string[]
aiInsightWhitelistEnabled: boolean aiInsightWhitelistEnabled: boolean
aiInsightWhitelist: string[] aiInsightWhitelist: string[]
/** 活跃分析冷却时间分钟0 表示无冷却 */ /** 活跃分析冷却时间分钟0 表示无冷却 */
@@ -202,6 +204,8 @@ export class ConfigService {
aiInsightSilenceDays: 3, aiInsightSilenceDays: 3,
aiInsightAllowContext: false, aiInsightAllowContext: false,
aiInsightAllowSocialContext: false, aiInsightAllowSocialContext: false,
aiInsightFilterMode: 'whitelist',
aiInsightFilterList: [],
aiInsightWhitelistEnabled: false, aiInsightWhitelistEnabled: false,
aiInsightWhitelist: [], aiInsightWhitelist: [],
aiInsightCooldownMinutes: 120, aiInsightCooldownMinutes: 120,

View File

@@ -79,6 +79,9 @@ const MESSAGE_TYPE_MAP: Record<number, number> = {
34: 2, // 语音 -> VOICE 34: 2, // 语音 -> VOICE
43: 3, // 视频 -> VIDEO 43: 3, // 视频 -> VIDEO
49: 7, // 链接/文件 -> LINK (需要进一步判断) 49: 7, // 链接/文件 -> LINK (需要进一步判断)
34359738417: 7, // 文件消息变体 -> LINK
103079215153: 7, // 文件消息变体 -> LINK
25769803825: 7, // 文件消息变体 -> LINK
47: 5, // 表情包 -> EMOJI 47: 5, // 表情包 -> EMOJI
48: 8, // 位置 -> LOCATION 48: 8, // 位置 -> LOCATION
42: 27, // 名片 -> CONTACT 42: 27, // 名片 -> CONTACT
@@ -86,9 +89,13 @@ const MESSAGE_TYPE_MAP: Record<number, number> = {
10000: 80, // 系统消息 -> SYSTEM 10000: 80, // 系统消息 -> SYSTEM
} }
// 与 chatService 的资源消息识别保持一致,覆盖桌面微信里的多种文件消息 localType。
const FILE_APP_LOCAL_TYPES = [49, 34359738417, 103079215153, 25769803825] as const
const FILE_APP_LOCAL_TYPE_SET = new Set<number>(FILE_APP_LOCAL_TYPES)
export interface ExportOptions { export interface ExportOptions {
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
dateRange?: { start: number; end: number } | null dateRange?: { start: number; end: number } | null
senderUsername?: string senderUsername?: string
fileNameSuffix?: string fileNameSuffix?: string
@@ -137,11 +144,19 @@ interface ExportDisplayProfile {
} }
type MessageCollectMode = 'full' | 'text-fast' | 'media-fast' type MessageCollectMode = 'full' | 'text-fast' | 'media-fast'
type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' | 'file'
interface FileExportCandidate { interface FileExportCandidate {
sourcePath: string sourcePath: string
matchedBy: 'md5' | 'name' matchedBy: 'md5' | 'name'
yearMonth?: string yearMonth?: string
preferredMonth?: boolean
mtimeMs: number
searchOrder: number
}
interface FileAttachmentSearchRoot {
accountDir: string
msgFileRoot?: string
fileStorageRoot?: string
} }
export interface ExportProgress { export interface ExportProgress {
@@ -501,6 +516,13 @@ class ExportService {
.trim() .trim()
} }
private resolveFileAttachmentExtensionDir(msg: any, fileName: string): string {
const rawExt = String(msg?.fileExt || '').trim() || path.extname(String(fileName || ''))
const normalizedExt = rawExt.replace(/^\.+/, '').trim().toLowerCase()
const safeExt = this.sanitizeExportFileNamePart(normalizedExt).replace(/\s+/g, '_')
return safeExt || 'no-extension'
}
private normalizeFileNamingMode(value: unknown): 'classic' | 'date-range' { private normalizeFileNamingMode(value: unknown): 'classic' | 'date-range' {
return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic' return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic'
} }
@@ -947,7 +969,7 @@ class ExportService {
private getMediaContentType(options: ExportOptions): MediaContentType | null { private getMediaContentType(options: ExportOptions): MediaContentType | null {
const value = options.contentType const value = options.contentType
if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji') { if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji' || value === 'file') {
return value return value
} }
return null return null
@@ -963,15 +985,117 @@ class ExportService {
if (mediaContentType === 'image') return new Set([3]) if (mediaContentType === 'image') return new Set([3])
if (mediaContentType === 'video') return new Set([43]) if (mediaContentType === 'video') return new Set([43])
if (mediaContentType === 'emoji') return new Set([47]) if (mediaContentType === 'emoji') return new Set([47])
if (mediaContentType === 'file') return new Set(FILE_APP_LOCAL_TYPES)
const selected = new Set<number>() const selected = new Set<number>()
if (options.exportImages) selected.add(3) if (options.exportImages) selected.add(3)
if (options.exportVoices) selected.add(34) if (options.exportVoices) selected.add(34)
if (options.exportVideos) selected.add(43) if (options.exportVideos) selected.add(43)
if (options.exportFiles) selected.add(49) if (options.exportFiles) {
for (const fileType of FILE_APP_LOCAL_TYPES) {
selected.add(fileType)
}
}
return selected return selected
} }
private isFileAppLocalType(localType: number): boolean {
return FILE_APP_LOCAL_TYPE_SET.has(localType)
}
private isFileOnlyMediaFilter(targetMediaTypes: Set<number> | null): boolean {
return Boolean(
targetMediaTypes &&
targetMediaTypes.size === FILE_APP_LOCAL_TYPES.length &&
FILE_APP_LOCAL_TYPES.every((fileType) => targetMediaTypes.has(fileType))
)
}
private getFileAppMessageHints(message: Record<string, any> | null | undefined): {
xmlType?: string
fileName?: string
fileSize?: number
fileExt?: string
fileMd5?: string
} {
const xmlType = String(message?.xmlType ?? message?.xml_type ?? '').trim() || undefined
const fileName = String(message?.fileName ?? message?.file_name ?? '').trim() || undefined
const fileExt = String(message?.fileExt ?? message?.file_ext ?? '').trim() || undefined
const fileSizeRaw = Number(message?.fileSize ?? message?.file_size ?? message?.total_len ?? message?.totalLen ?? message?.totallen ?? 0)
const fileSize = Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : undefined
const fileMd5Raw = String(message?.fileMd5 ?? message?.file_md5 ?? '').trim()
const fileMd5 = /^[a-f0-9]{32}$/i.test(fileMd5Raw) ? fileMd5Raw.toLowerCase() : undefined
return { xmlType, fileName, fileSize, fileExt, fileMd5 }
}
private hasFileAppMessageHints(message: Record<string, any> | null | undefined): boolean {
const hints = this.getFileAppMessageHints(message)
if (hints.xmlType) return hints.xmlType === '6'
return Boolean(hints.fileName || hints.fileExt || hints.fileMd5 || hints.fileSize)
}
private isFileAppMessage(msg: {
localType?: unknown
xmlType?: unknown
xml_type?: unknown
content?: unknown
fileName?: unknown
file_name?: unknown
fileSize?: unknown
file_size?: unknown
fileExt?: unknown
file_ext?: unknown
fileMd5?: unknown
file_md5?: unknown
}): boolean {
const { xmlType, fileName, fileExt, fileMd5, fileSize } = this.getFileAppMessageHints(msg as Record<string, any>)
if (xmlType) return xmlType === '6'
if (fileName || fileExt || fileMd5 || fileSize) return true
const normalized = this.normalizeAppMessageContent(String(msg?.content || ''))
if (!normalized || (!normalized.includes('<appmsg') && !normalized.includes('<msg>'))) {
return false
}
return this.extractAppMessageType(normalized) === '6'
}
private extractFileAppMessageMeta(content: string): {
xmlType?: string
fileName?: string
fileSize?: number
fileExt?: string
fileMd5?: string
} | null {
const normalized = this.normalizeAppMessageContent(content || '')
if (!normalized || (!normalized.includes('<appmsg') && !normalized.includes('<msg>'))) {
return null
}
const xmlType = this.extractAppMessageType(normalized)
if (!xmlType) return null
const rawFileName = this.extractXmlValue(normalized, 'filename') || this.extractXmlValue(normalized, 'title')
const rawFileExt = this.extractXmlValue(normalized, 'fileext')
const rawFileSize =
this.extractXmlValue(normalized, 'totallen') ||
this.extractXmlValue(normalized, 'datasize') ||
this.extractXmlValue(normalized, 'filesize')
const rawFileMd5 =
this.extractXmlValue(normalized, 'md5') ||
this.extractXmlAttribute(normalized, 'appattach', 'md5') ||
this.extractLooseHexMd5(normalized)
const fileSize = Number.parseInt(rawFileSize, 10)
const fileMd5 = String(rawFileMd5 || '').trim()
return {
xmlType,
fileName: this.decodeHtmlEntities(rawFileName).trim() || undefined,
fileSize: Number.isFinite(fileSize) && fileSize > 0 ? fileSize : undefined,
fileExt: this.decodeHtmlEntities(rawFileExt).trim() || undefined,
fileMd5: /^[a-f0-9]{32}$/i.test(fileMd5) ? fileMd5.toLowerCase() : undefined
}
}
private resolveCollectMode(options: ExportOptions): MessageCollectMode { private resolveCollectMode(options: ExportOptions): MessageCollectMode {
if (this.isMediaContentBatchExport(options)) { if (this.isMediaContentBatchExport(options)) {
return 'media-fast' return 'media-fast'
@@ -1020,12 +1144,17 @@ class ExportService {
return true return true
} }
private shouldDecodeMessageContentInMediaMode(localType: number, targetMediaTypes: Set<number> | null): boolean { private shouldDecodeMessageContentInMediaMode(
if (!targetMediaTypes || !targetMediaTypes.has(localType)) return false localType: number,
targetMediaTypes: Set<number> | null,
options?: { allowFileProbe?: boolean }
): boolean {
const allowFileProbe = options?.allowFileProbe === true
if (!targetMediaTypes || (!targetMediaTypes.has(localType) && !allowFileProbe)) return false
// 语音导出仅需要 localId 读取音频数据,不依赖 XML 内容 // 语音导出仅需要 localId 读取音频数据,不依赖 XML 内容
if (localType === 34) return false if (localType === 34) return false
// 图片/视频/表情可能需要从 XML 提取 md5/datName/cdnUrl // 图片/视频/表情/文件可能需要从 XML 提取 md5/datName/附件信息
if (localType === 3 || localType === 43 || localType === 47) return true if (localType === 3 || localType === 43 || localType === 47 || this.isFileAppLocalType(localType) || allowFileProbe) return true
return false return false
} }
@@ -3628,7 +3757,7 @@ class ExportService {
) )
} }
if ((localType === 49 || localType === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') { if (options.exportFiles && this.isFileAppMessage(msg)) {
return this.exportFileAttachment( return this.exportFileAttachment(
msg, msg,
mediaRootDir, mediaRootDir,
@@ -4183,33 +4312,104 @@ class ExportService {
return this.normalizeVideoFileToken(this.extractVideoMd5(content || '')) return this.normalizeVideoFileToken(this.extractVideoMd5(content || ''))
} }
private resolveFileAttachmentRoots(): string[] { private isFileAttachmentAccountDir(dirPath: string): boolean {
if (!dirPath) return false
return fs.existsSync(path.join(dirPath, 'db_storage')) ||
fs.existsSync(path.join(dirPath, 'msg', 'file')) ||
fs.existsSync(path.join(dirPath, 'FileStorage', 'File')) ||
fs.existsSync(path.join(dirPath, 'FileStorage', 'Image')) ||
fs.existsSync(path.join(dirPath, 'FileStorage', 'Image2'))
}
private resolveAccountDirForFileExport(basePath: string, wxid: string): string | null {
const cleanedWxid = this.cleanAccountDirName(wxid)
if (!basePath || !cleanedWxid) return null
const normalized = path.resolve(basePath.replace(/[\\/]+$/, ''))
const parentDir = path.dirname(normalized)
const dbStorageParent = path.basename(normalized).toLowerCase() === 'db_storage'
? path.dirname(normalized)
: ''
const fileInsideDbStorageParent = path.basename(parentDir).toLowerCase() === 'db_storage'
? path.dirname(parentDir)
: ''
const candidateBases = Array.from(new Set([
normalized,
parentDir,
path.join(normalized, 'WeChat Files'),
path.join(parentDir, 'WeChat Files'),
dbStorageParent,
fileInsideDbStorageParent
].filter(Boolean)))
const lowerWxid = cleanedWxid.toLowerCase()
const tryResolveBase = (candidateBase: string): string | null => {
if (!candidateBase || !fs.existsSync(candidateBase)) return null
if (this.isFileAttachmentAccountDir(candidateBase)) return candidateBase
const direct = path.join(candidateBase, cleanedWxid)
if (this.isFileAttachmentAccountDir(direct)) return direct
try {
const entries = fs.readdirSync(candidateBase, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory()) continue
const lowerEntry = entry.name.toLowerCase()
if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) {
const entryPath = path.join(candidateBase, entry.name)
if (this.isFileAttachmentAccountDir(entryPath)) {
return entryPath
}
}
}
} catch {
return null
}
return null
}
for (const candidateBase of candidateBases) {
const resolved = tryResolveBase(candidateBase)
if (resolved) return resolved
}
return null
}
private resolveFileAttachmentSearchRoots(): FileAttachmentSearchRoot[] {
const dbPath = String(this.configService.get('dbPath') || '').trim() const dbPath = String(this.configService.get('dbPath') || '').trim()
const rawWxid = String(this.configService.get('myWxid') || '').trim() const rawWxid = String(this.configService.get('myWxid') || '').trim()
const cleanedWxid = this.cleanAccountDirName(rawWxid) const cleanedWxid = this.cleanAccountDirName(rawWxid)
if (!dbPath) return [] if (!dbPath) return []
const normalized = dbPath.replace(/[\\/]+$/, '') const normalized = path.resolve(dbPath.replace(/[\\/]+$/, ''))
const roots = new Set<string>() const accountDirs = new Set<string>()
const tryAddRoot = (candidate: string) => { const maybeAddAccountDir = (candidate: string | null | undefined) => {
const fileRoot = path.join(candidate, 'msg', 'file') if (!candidate) return
if (fs.existsSync(fileRoot)) { const resolved = path.resolve(candidate)
roots.add(fileRoot) if (this.isFileAttachmentAccountDir(resolved)) {
accountDirs.add(resolved)
} }
} }
tryAddRoot(normalized) maybeAddAccountDir(normalized)
if (rawWxid) tryAddRoot(path.join(normalized, rawWxid)) maybeAddAccountDir(path.dirname(normalized))
if (cleanedWxid && cleanedWxid !== rawWxid) tryAddRoot(path.join(normalized, cleanedWxid))
const dbStoragePath = const wxidCandidates = Array.from(new Set([cleanedWxid, rawWxid].filter(Boolean)))
this.resolveDbStoragePathForExport(normalized, cleanedWxid) || for (const wxid of wxidCandidates) {
this.resolveDbStoragePathForExport(normalized, rawWxid) maybeAddAccountDir(this.resolveAccountDirForFileExport(normalized, wxid))
if (dbStoragePath) {
tryAddRoot(path.dirname(dbStoragePath))
} }
return Array.from(roots) return Array.from(accountDirs).map((accountDir) => {
const msgFileRoot = path.join(accountDir, 'msg', 'file')
const fileStorageRoot = path.join(accountDir, 'FileStorage', 'File')
return {
accountDir,
msgFileRoot: fs.existsSync(msgFileRoot) ? msgFileRoot : undefined,
fileStorageRoot: fs.existsSync(fileStorageRoot) ? fileStorageRoot : undefined
}
}).filter((root) => Boolean(root.msgFileRoot || root.fileStorageRoot))
} }
private buildPreferredFileYearMonths(createTime?: unknown): string[] { private buildPreferredFileYearMonths(createTime?: unknown): string[] {
@@ -4241,52 +4441,147 @@ class ExportService {
} }
} }
private async resolveFileAttachmentCandidates(msg: any): Promise<FileExportCandidate[]> { private collectFileStorageCandidatesByName(rootDir: string, fileName: string, maxDepth = 3): string[] {
const fileName = String(msg?.fileName || '').trim() const normalizedName = String(fileName || '').trim().toLowerCase()
if (!fileName) return [] if (!rootDir || !normalizedName) return []
const roots = this.resolveFileAttachmentRoots() const matches: string[] = []
if (roots.length === 0) return [] const stack: Array<{ dir: string; depth: number }> = [{ dir: rootDir, depth: 0 }]
const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase() while (stack.length > 0) {
const preferredMonths = this.buildPreferredFileYearMonths(msg?.createTime) const current = stack.pop()!
const candidates: FileExportCandidate[] = [] let entries: fs.Dirent[]
const seen = new Set<string>()
for (const root of roots) {
let monthDirs: string[] = []
try { try {
monthDirs = fs.readdirSync(root) entries = fs.readdirSync(current.dir, { withFileTypes: true })
.filter(entry => /^\d{4}-\d{2}$/.test(entry) && fs.existsSync(path.join(root, entry)))
.sort()
} catch { } catch {
continue continue
} }
const orderedMonths = Array.from(new Set([ for (const entry of entries) {
...preferredMonths, const entryPath = path.join(current.dir, entry.name)
...monthDirs.slice().reverse() if (entry.isFile() && entry.name.toLowerCase() === normalizedName) {
])) matches.push(entryPath)
for (const month of orderedMonths) {
const sourcePath = path.join(root, month, fileName)
if (!fs.existsSync(sourcePath)) continue
const resolvedPath = path.resolve(sourcePath)
if (seen.has(resolvedPath)) continue
seen.add(resolvedPath)
if (normalizedMd5) {
const ok = await this.verifyFileHash(resolvedPath, normalizedMd5)
if (ok) {
candidates.unshift({ sourcePath: resolvedPath, matchedBy: 'md5', yearMonth: month })
continue continue
} }
if (entry.isDirectory() && current.depth < maxDepth) {
stack.push({ dir: entryPath, depth: current.depth + 1 })
}
}
} }
candidates.push({ sourcePath: resolvedPath, matchedBy: 'name', yearMonth: month }) return matches
}
private getFileAttachmentLogContext(msg: any): Record<string, unknown> {
return {
localId: msg?.localId,
createTime: msg?.createTime,
localType: msg?.localType,
xmlType: msg?.xmlType,
fileName: msg?.fileName,
fileMd5: msg?.fileMd5
} }
} }
private logFileAttachmentEvent(
level: 'warn' | 'error',
action: string,
msg: any,
extra: Record<string, unknown> = {}
): void {
const logger = level === 'error' ? console.error : console.warn
logger(`[Export][File] ${action}`, {
...this.getFileAttachmentLogContext(msg),
...extra
})
}
private recordFileAttachmentMiss(msg: any, action: string, extra: Record<string, unknown> = {}): void {
this.logFileAttachmentEvent('warn', action, msg, extra)
this.noteMediaTelemetry({ cacheMissFiles: 1 })
}
private async resolveFileAttachmentCandidates(msg: any): Promise<FileExportCandidate[]> {
const fileName = String(msg?.fileName || '').trim()
if (!fileName) return []
const roots = this.resolveFileAttachmentSearchRoots()
if (roots.length === 0) return []
const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase()
const preferredMonths = new Set(this.buildPreferredFileYearMonths(msg?.createTime))
const candidates: FileExportCandidate[] = []
const seen = new Set<string>()
let searchOrder = 0
const appendCandidate = async (sourcePath: string, yearMonth?: string) => {
if (!sourcePath || !fs.existsSync(sourcePath)) return
const resolvedPath = path.resolve(sourcePath)
if (seen.has(resolvedPath)) return
let stat: fs.Stats
try {
stat = await fs.promises.stat(resolvedPath)
} catch {
return
}
if (!stat.isFile()) return
seen.add(resolvedPath)
const matchedBy = normalizedMd5 && await this.verifyFileHash(resolvedPath, normalizedMd5) ? 'md5' : 'name'
candidates.push({
sourcePath: resolvedPath,
matchedBy,
yearMonth,
preferredMonth: Boolean(yearMonth && preferredMonths.has(yearMonth)),
mtimeMs: Number.isFinite(stat.mtimeMs) ? stat.mtimeMs : 0,
searchOrder: searchOrder++
})
}
for (const root of roots) {
if (root.msgFileRoot) {
for (const month of preferredMonths) {
await appendCandidate(path.join(root.msgFileRoot, month, fileName), month)
}
let monthDirs: string[] = []
try {
monthDirs = fs.readdirSync(root.msgFileRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory() && /^\d{4}-\d{2}$/.test(entry.name) && !preferredMonths.has(entry.name))
.map(entry => entry.name)
.sort()
} catch {
monthDirs = []
}
for (const month of monthDirs) {
await appendCandidate(path.join(root.msgFileRoot, month, fileName), month)
}
await appendCandidate(path.join(root.msgFileRoot, fileName))
}
if (root.fileStorageRoot) {
for (const candidatePath of this.collectFileStorageCandidatesByName(root.fileStorageRoot, fileName, 3)) {
await appendCandidate(candidatePath)
}
}
}
candidates.sort((left, right) => {
if (left.matchedBy !== right.matchedBy) {
return left.matchedBy === 'md5' ? -1 : 1
}
if (left.preferredMonth !== right.preferredMonth) {
return left.preferredMonth ? -1 : 1
}
if (left.mtimeMs !== right.mtimeMs) {
return right.mtimeMs - left.mtimeMs
}
return left.searchOrder - right.searchOrder
})
return candidates return candidates
} }
@@ -4301,14 +4596,20 @@ class ExportService {
const fileNameRaw = String(msg?.fileName || '').trim() const fileNameRaw = String(msg?.fileName || '').trim()
if (!fileNameRaw) return null if (!fileNameRaw) return null
const filesDir = path.join(mediaRootDir, mediaRelativePrefix, 'files') const fileExtDir = this.resolveFileAttachmentExtensionDir(msg, fileNameRaw)
if (!dirCache?.has(filesDir)) { const fileDir = path.join(mediaRootDir, mediaRelativePrefix, 'file', fileExtDir)
await fs.promises.mkdir(filesDir, { recursive: true }) if (!dirCache?.has(fileDir)) {
dirCache?.add(filesDir) await fs.promises.mkdir(fileDir, { recursive: true })
dirCache?.add(fileDir)
} }
const candidates = await this.resolveFileAttachmentCandidates(msg) const candidates = await this.resolveFileAttachmentCandidates(msg)
if (candidates.length === 0) return null if (candidates.length === 0) {
this.recordFileAttachmentMiss(msg, '附件候选未命中', {
searchRoots: this.resolveFileAttachmentSearchRoots().map(root => root.accountDir)
})
return null
}
const maxBytes = Number.isFinite(maxFileSizeMb) const maxBytes = Number.isFinite(maxFileSizeMb)
? Math.max(0, Math.floor(Number(maxFileSizeMb) * 1024 * 1024)) ? Math.max(0, Math.floor(Number(maxFileSizeMb) * 1024 * 1024))
@@ -4316,28 +4617,54 @@ class ExportService {
const selected = candidates[0] const selected = candidates[0]
const stat = await fs.promises.stat(selected.sourcePath) const stat = await fs.promises.stat(selected.sourcePath)
if (!stat.isFile()) return null if (!stat.isFile()) {
if (maxBytes > 0 && stat.size > maxBytes) return null this.recordFileAttachmentMiss(msg, '附件候选不是普通文件', {
sourcePath: selected.sourcePath
})
return null
}
if (maxBytes > 0 && stat.size > maxBytes) {
this.recordFileAttachmentMiss(msg, '附件超过大小限制', {
sourcePath: selected.sourcePath,
size: stat.size,
maxBytes
})
return null
}
const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase() const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase()
if (normalizedMd5 && selected.matchedBy !== 'md5') { if (normalizedMd5 && selected.matchedBy !== 'md5') {
const verified = await this.verifyFileHash(selected.sourcePath, normalizedMd5) this.recordFileAttachmentMiss(msg, '附件哈希校验失败', {
if (!verified) return null sourcePath: selected.sourcePath,
expectedMd5: normalizedMd5
})
return null
} }
const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file' const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file'
const messageId = String(msg?.localId || Date.now()) const messageId = String(msg?.localId || Date.now())
const destFileName = `${messageId}_${safeBaseName}` const destFileName = `${messageId}_${safeBaseName}`
const destPath = path.join(filesDir, destFileName) const destPath = path.join(fileDir, destFileName)
const copied = await this.copyFileOptimized(selected.sourcePath, destPath) const copied = await this.copyFileOptimized(selected.sourcePath, destPath)
if (!copied.success) return null if (!copied.success) {
this.recordFileAttachmentMiss(msg, '附件复制失败', {
sourcePath: selected.sourcePath,
destPath,
code: copied.code
})
return null
}
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size }) this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
return { return {
relativePath: path.posix.join(mediaRelativePrefix, 'files', destFileName), relativePath: path.posix.join(mediaRelativePrefix, 'file', fileExtDir, destFileName),
kind: 'file' kind: 'file'
} }
} catch { } catch (error) {
this.logFileAttachmentEvent('error', '附件导出异常', msg, {
error: error instanceof Error ? error.message : String(error || 'unknown')
})
this.noteMediaTelemetry({ cacheMissFiles: 1 })
return null return null
} }
} }
@@ -4420,6 +4747,38 @@ class ExportService {
return { exportMediaEnabled, mediaRootDir: outputDir, mediaRelativePrefix } return { exportMediaEnabled, mediaRootDir: outputDir, mediaRelativePrefix }
} }
private collectMediaMessagesForExport(messages: any[], options: ExportOptions): any[] {
if (!this.isMediaExportEnabled(options)) return []
return messages.filter((msg) => {
const localType = Number(msg?.localType || 0)
return (localType === 3 && options.exportImages) ||
(localType === 47 && options.exportEmojis) ||
(localType === 43 && options.exportVideos) ||
(localType === 34 && options.exportVoices) ||
(options.exportFiles === true && this.isFileAppMessage(msg))
})
}
private getMediaDoneFilesCount(): number {
return this.mediaExportTelemetry?.doneFiles ?? 0
}
private buildFileOnlyExportFailure(
options: ExportOptions,
mediaMessages: any[],
beforeDoneFiles: number
): { success: boolean; error?: string } | null {
if (options.contentType !== 'file') return null
if (!mediaMessages.some(msg => this.isFileAppMessage(msg))) return null
if (this.getMediaDoneFilesCount() > beforeDoneFiles) return null
return {
success: false,
error: '检测到文件消息,但未找到可导出的源文件,请检查数据库路径或文件存储目录配置'
}
}
/** /**
* 下载文件 * 下载文件
*/ */
@@ -4485,6 +4844,7 @@ class ExportService {
const mediaTypeFilter = collectMode === 'media-fast' && targetMediaTypes && targetMediaTypes.size > 0 const mediaTypeFilter = collectMode === 'media-fast' && targetMediaTypes && targetMediaTypes.size > 0
? targetMediaTypes ? targetMediaTypes
: null : null
const fileOnlyMediaFilter = this.isFileOnlyMediaFilter(mediaTypeFilter)
// 修复时间范围0 表示不限制,而不是时间戳 0 // 修复时间范围0 表示不限制,而不是时间戳 0
const beginTime = dateRange?.start || 0 const beginTime = dateRange?.start || 0
@@ -4545,12 +4905,14 @@ class ExportService {
const localType = this.getIntFromRow(row, [ const localType = this.getIntFromRow(row, [
'local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type' 'local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'
], 1) ], 1)
if (mediaTypeFilter && !mediaTypeFilter.has(localType)) { const rowFileHints = this.getFileAppMessageHints(row)
const allowFileProbe = fileOnlyMediaFilter && this.hasFileAppMessageHints(row)
if (mediaTypeFilter && !mediaTypeFilter.has(localType) && !allowFileProbe) {
continue continue
} }
const shouldDecodeContent = collectMode === 'full' const shouldDecodeContent = collectMode === 'full'
|| (collectMode === 'text-fast' && this.shouldDecodeMessageContentInFastMode(localType)) || (collectMode === 'text-fast' && this.shouldDecodeMessageContentInFastMode(localType))
|| (collectMode === 'media-fast' && this.shouldDecodeMessageContentInMediaMode(localType, mediaTypeFilter)) || (collectMode === 'media-fast' && this.shouldDecodeMessageContentInMediaMode(localType, mediaTypeFilter, { allowFileProbe }))
const content = shouldDecodeContent const content = shouldDecodeContent
? this.decodeMessageContent(row.message_content, row.compress_content) ? this.decodeMessageContent(row.message_content, row.compress_content)
: '' : ''
@@ -4619,6 +4981,11 @@ class ExportService {
let locationLabel: string | undefined let locationLabel: string | undefined
let chatRecordList: any[] | undefined let chatRecordList: any[] | undefined
let emojiCaption: string | undefined let emojiCaption: string | undefined
let xmlType: string | undefined
let fileName: string | undefined
let fileSize: number | undefined
let fileExt: string | undefined
let fileMd5: string | undefined
if (localType === 48 && content) { if (localType === 48 && content) {
const locationMeta = this.extractLocationMeta(content, localType) const locationMeta = this.extractLocationMeta(content, localType)
@@ -4649,6 +5016,22 @@ class ExportService {
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined
videoMd5 = this.extractVideoFileNameFromRow(row, content) videoMd5 = this.extractVideoFileNameFromRow(row, content)
xmlType = rowFileHints.xmlType
fileName = rowFileHints.fileName
fileExt = rowFileHints.fileExt
fileSize = rowFileHints.fileSize
fileMd5 = rowFileHints.fileMd5
if (content && (this.isFileAppLocalType(localType) || allowFileProbe || this.hasFileAppMessageHints({ xmlType, fileName, fileSize, fileExt, fileMd5 }))) {
const fileMeta = this.extractFileAppMessageMeta(content)
if (fileMeta) {
xmlType = fileMeta.xmlType || xmlType
fileName = fileMeta.fileName || fileName
fileSize = fileMeta.fileSize || fileSize
fileExt = fileMeta.fileExt || fileExt
fileMd5 = fileMeta.fileMd5 || fileMd5
}
}
if (localType === 3 && content) { if (localType === 3 && content) {
// 图片消息 // 图片消息
@@ -4667,6 +5050,10 @@ class ExportService {
} }
} }
if (fileOnlyMediaFilter && !this.isFileAppMessage({ localType, xmlType, content, fileName, fileExt, fileMd5, fileSize })) {
continue
}
rows.push({ rows.push({
localId, localId,
serverId, serverId,
@@ -4682,6 +5069,11 @@ class ExportService {
emojiMd5, emojiMd5,
emojiCaption, emojiCaption,
videoMd5, videoMd5,
xmlType,
fileName,
fileSize,
fileExt,
fileMd5,
locationLat, locationLat,
locationLng, locationLng,
locationPoiname, locationPoiname,
@@ -4746,7 +5138,12 @@ class ExportService {
targetMediaTypes: Set<number>, targetMediaTypes: Set<number>,
control?: ExportTaskControl control?: ExportTaskControl
): Promise<void> { ): Promise<void> {
const fileOnlyMediaFilter = this.isFileOnlyMediaFilter(targetMediaTypes)
const needsBackfill = rows.filter((msg) => { const needsBackfill = rows.filter((msg) => {
const isFileCandidate = this.isFileAppLocalType(Number(msg.localType || 0)) || (fileOnlyMediaFilter && this.hasFileAppMessageHints(msg))
if (isFileCandidate) {
return !msg.xmlType || !msg.fileName || !msg.fileMd5 || !msg.fileSize || !msg.fileExt
}
if (!targetMediaTypes.has(msg.localType)) return false if (!targetMediaTypes.has(msg.localType)) return false
if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName
if (msg.localType === 47) return !msg.emojiMd5 if (msg.localType === 47) return !msg.emojiMd5
@@ -4803,6 +5200,24 @@ class ExportService {
if (msg.localType === 43) { if (msg.localType === 43) {
const videoMd5 = this.extractVideoFileNameFromRow(row, content) const videoMd5 = this.extractVideoFileNameFromRow(row, content)
if (videoMd5) msg.videoMd5 = videoMd5 if (videoMd5) msg.videoMd5 = videoMd5
return
}
if (this.isFileAppLocalType(Number(msg.localType || 0)) || this.hasFileAppMessageHints(msg)) {
const rowFileHints = this.getFileAppMessageHints(row)
const fileMeta = this.extractFileAppMessageMeta(content)
const mergedFileMeta = {
xmlType: fileMeta?.xmlType || rowFileHints.xmlType,
fileName: fileMeta?.fileName || rowFileHints.fileName,
fileSize: fileMeta?.fileSize || rowFileHints.fileSize,
fileExt: fileMeta?.fileExt || rowFileHints.fileExt,
fileMd5: fileMeta?.fileMd5 || rowFileHints.fileMd5
}
if (mergedFileMeta.xmlType) msg.xmlType = mergedFileMeta.xmlType
if (mergedFileMeta.fileName) msg.fileName = mergedFileMeta.fileName
if (mergedFileMeta.fileSize) msg.fileSize = mergedFileMeta.fileSize
if (mergedFileMeta.fileExt) msg.fileExt = mergedFileMeta.fileExt
if (mergedFileMeta.fileMd5) msg.fileMd5 = mergedFileMeta.fileMd5
} }
} catch (error) { } catch (error) {
// 详情补取失败时保持降级导出(占位符),避免中断整批任务。 // 详情补取失败时保持降级导出(占位符),避免中断整批任务。
@@ -5329,19 +5744,11 @@ class ExportService {
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
// ========== 阶段1并行导出媒体文件 ========== // ========== 阶段1并行导出媒体文件 ==========
const mediaMessages = exportMediaEnabled const mediaMessages = this.collectMediaMessagesForExport(allMessages, options)
? allMessages.filter(msg => {
const t = msg.localType
return (t === 3 && options.exportImages) || // 图片
(t === 47 && options.exportEmojis) || // 表情
(t === 43 && options.exportVideos) || // 视频
(t === 34 && options.exportVoices) || // 语音文件
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
})
: []
const mediaCache = new Map<string, MediaExportItem | null>() const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>() const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.getMediaDoneFilesCount()
if (mediaMessages.length > 0) { if (mediaMessages.length > 0) {
await this.preloadMediaLookupCaches(sessionId, mediaMessages, { await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
@@ -5400,6 +5807,8 @@ class ExportService {
} }
}) })
} }
const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
// ========== 阶段2并行语音转文字 ========== // ========== 阶段2并行语音转文字 ==========
const voiceTranscriptMap = new Map<string, string>() const voiceTranscriptMap = new Map<string, string>()
@@ -5840,19 +6249,11 @@ class ExportService {
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
// ========== 阶段1并行导出媒体文件 ========== // ========== 阶段1并行导出媒体文件 ==========
const mediaMessages = exportMediaEnabled const mediaMessages = this.collectMediaMessagesForExport(collected.rows, options)
? collected.rows.filter(msg => {
const t = msg.localType
return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) ||
(t === 43 && options.exportVideos) ||
(t === 34 && options.exportVoices) ||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
})
: []
const mediaCache = new Map<string, MediaExportItem | null>() const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>() const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.getMediaDoneFilesCount()
if (mediaMessages.length > 0) { if (mediaMessages.length > 0) {
await this.preloadMediaLookupCaches(sessionId, mediaMessages, { await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
@@ -5910,6 +6311,8 @@ class ExportService {
} }
}) })
} }
const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
// ========== 阶段2并行语音转文字 ========== // ========== 阶段2并行语音转文字 ==========
const voiceTranscriptMap = new Map<string, string>() const voiceTranscriptMap = new Map<string, string>()
@@ -6711,19 +7114,11 @@ class ExportService {
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
// ========== 并行预处理:媒体文件 ========== // ========== 并行预处理:媒体文件 ==========
const mediaMessages = exportMediaEnabled const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options)
? sortedMessages.filter(msg => {
const t = msg.localType
return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) ||
(t === 43 && options.exportVideos) ||
(t === 34 && options.exportVoices) ||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
})
: []
const mediaCache = new Map<string, MediaExportItem | null>() const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>() const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.getMediaDoneFilesCount()
if (mediaMessages.length > 0) { if (mediaMessages.length > 0) {
await this.preloadMediaLookupCaches(sessionId, mediaMessages, { await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
@@ -6781,6 +7176,8 @@ class ExportService {
} }
}) })
} }
const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
// ========== 并行预处理:语音转文字 ========== // ========== 并行预处理:语音转文字 ==========
const voiceTranscriptMap = new Map<string, string>() const voiceTranscriptMap = new Map<string, string>()
@@ -7461,19 +7858,11 @@ class ExportService {
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
const mediaMessages = exportMediaEnabled const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options)
? sortedMessages.filter(msg => {
const t = msg.localType
return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) ||
(t === 43 && options.exportVideos) ||
(t === 34 && options.exportVoices) ||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
})
: []
const mediaCache = new Map<string, MediaExportItem | null>() const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>() const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.getMediaDoneFilesCount()
if (mediaMessages.length > 0) { if (mediaMessages.length > 0) {
await this.preloadMediaLookupCaches(sessionId, mediaMessages, { await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
@@ -7531,6 +7920,8 @@ class ExportService {
} }
}) })
} }
const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
const voiceTranscriptMap = new Map<string, string>() const voiceTranscriptMap = new Map<string, string>()
@@ -7840,19 +8231,11 @@ class ExportService {
} }
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
const mediaMessages = exportMediaEnabled const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options)
? sortedMessages.filter(msg => {
const t = msg.localType
return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) ||
(t === 43 && options.exportVideos) ||
(t === 34 && options.exportVoices) ||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
})
: []
const mediaCache = new Map<string, MediaExportItem | null>() const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>() const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.getMediaDoneFilesCount()
if (mediaMessages.length > 0) { if (mediaMessages.length > 0) {
await this.preloadMediaLookupCaches(sessionId, mediaMessages, { await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
@@ -7910,6 +8293,8 @@ class ExportService {
} }
}) })
} }
const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
const voiceTranscriptMap = new Map<string, string>() const voiceTranscriptMap = new Map<string, string>()
@@ -8263,18 +8648,11 @@ class ExportService {
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
const mediaMessages = exportMediaEnabled const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options)
? sortedMessages.filter(msg => {
const t = msg.localType
return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices) ||
(t === 43 && options.exportVideos)
})
: []
const mediaCache = new Map<string, MediaExportItem | null>() const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>() const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.getMediaDoneFilesCount()
if (mediaMessages.length > 0) { if (mediaMessages.length > 0) {
await this.preloadMediaLookupCaches(sessionId, mediaMessages, { await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
@@ -8333,6 +8711,8 @@ class ExportService {
} }
}) })
} }
const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
const useVoiceTranscript = options.exportVoiceAsText === true const useVoiceTranscript = options.exportVoiceAsText === true
const voiceMessages = useVoiceTranscript const voiceMessages = useVoiceTranscript
@@ -9051,7 +9431,7 @@ class ExportService {
: options : options
const exportMediaEnabled = effectiveOptions.exportMedia === true && const exportMediaEnabled = effectiveOptions.exportMedia === true &&
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis) Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles)
attachMediaTelemetry = exportMediaEnabled attachMediaTelemetry = exportMediaEnabled
if (exportMediaEnabled) { if (exportMediaEnabled) {
this.triggerMediaFileCacheCleanup() this.triggerMediaFileCacheCleanup()

View File

@@ -104,8 +104,6 @@ export class ImageDecryptService {
const timestamp = new Date().toISOString() const timestamp = new Date().toISOString()
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n` const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n`
// 只写入文件,不输出到控制台
this.writeLog(logLine) this.writeLog(logLine)
} }
@@ -115,11 +113,7 @@ export class ImageDecryptService {
const errorStr = error ? ` Error: ${String(error)}` : '' const errorStr = error ? ` Error: ${String(error)}` : ''
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logLine = `[${timestamp}] [ImageDecrypt] ERROR: ${message}${errorStr}${metaStr}\n` const logLine = `[${timestamp}] [ImageDecrypt] ERROR: ${message}${errorStr}${metaStr}\n`
// 同时输出到控制台
console.error(message, error, meta) console.error(message, error, meta)
// 写入日志文件
this.writeLog(logLine) this.writeLog(logLine)
} }
@@ -143,7 +137,7 @@ export class ImageDecryptService {
} }
for (const key of cacheKeys) { for (const key of cacheKeys) {
const cached = this.resolvedCache.get(key) const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached) && this.isImageFile(cached)) { if (cached && existsSync(cached) && this.isUsableImageCacheFile(cached)) {
const upgraded = !this.isHdPath(cached) const upgraded = !this.isHdPath(cached)
? await this.tryPromoteThumbnailCache(payload, key, cached) ? await this.tryPromoteThumbnailCache(payload, key, cached)
: null : null
@@ -161,7 +155,7 @@ export class ImageDecryptService {
this.emitCacheResolved(payload, key, this.resolveEmitPath(finalPath, payload.preferFilePath)) this.emitCacheResolved(payload, key, this.resolveEmitPath(finalPath, payload.preferFilePath))
return { success: true, localPath, hasUpdate } return { success: true, localPath, hasUpdate }
} }
if (cached && !this.isImageFile(cached)) { if (cached && !this.isUsableImageCacheFile(cached)) {
this.resolvedCache.delete(key) this.resolvedCache.delete(key)
} }
} }
@@ -219,7 +213,7 @@ export class ImageDecryptService {
if (payload.force) { if (payload.force) {
for (const key of cacheKeys) { for (const key of cacheKeys) {
const cached = this.resolvedCache.get(key) const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached) && this.isImageFile(cached) && this.isHdPath(cached)) { if (cached && existsSync(cached) && this.isUsableImageCacheFile(cached) && this.isHdPath(cached)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
@@ -227,7 +221,7 @@ export class ImageDecryptService {
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath } return { success: true, localPath }
} }
if (cached && !this.isImageFile(cached)) { if (cached && !this.isUsableImageCacheFile(cached)) {
this.resolvedCache.delete(key) this.resolvedCache.delete(key)
} }
} }
@@ -236,7 +230,7 @@ export class ImageDecryptService {
if (!payload.force) { if (!payload.force) {
const cached = this.resolvedCache.get(cacheKey) const cached = this.resolvedCache.get(cacheKey)
if (cached && existsSync(cached) && this.isImageFile(cached)) { if (cached && existsSync(cached) && this.isUsableImageCacheFile(cached)) {
const upgraded = !this.isHdPath(cached) const upgraded = !this.isHdPath(cached)
? await this.tryPromoteThumbnailCache(payload, cacheKey, cached) ? await this.tryPromoteThumbnailCache(payload, cacheKey, cached)
: null : null
@@ -246,7 +240,7 @@ export class ImageDecryptService {
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath } return { success: true, localPath }
} }
if (cached && !this.isImageFile(cached)) { if (cached && !this.isUsableImageCacheFile(cached)) {
this.resolvedCache.delete(cacheKey) this.resolvedCache.delete(cacheKey)
} }
} }
@@ -1404,7 +1398,8 @@ export class ImageDecryptService {
private findCachedOutputByDatPath(datPath: string, sessionId?: string, preferHd = false): string | null { private findCachedOutputByDatPath(datPath: string, sessionId?: string, preferHd = false): string | null {
const candidates = this.buildCacheOutputCandidatesFromDat(datPath, sessionId, preferHd) const candidates = this.buildCacheOutputCandidatesFromDat(datPath, sessionId, preferHd)
for (const candidate of candidates) { for (const candidate of candidates) {
if (existsSync(candidate)) return candidate if (!existsSync(candidate)) continue
if (this.isUsableImageCacheFile(candidate)) return candidate
} }
return null return null
} }
@@ -1630,6 +1625,73 @@ export class ImageDecryptService {
return ext === '.gif' || ext === '.png' || ext === '.jpg' || ext === '.jpeg' || ext === '.webp' return ext === '.gif' || ext === '.png' || ext === '.jpg' || ext === '.jpeg' || ext === '.webp'
} }
private isUsableImageCacheFile(filePath: string): boolean {
if (!this.isImageFile(filePath)) return false
if (!existsSync(filePath)) return false
if (this.isLikelyCorruptedDecodedImage(filePath)) {
this.logInfo('[ImageDecrypt] 跳过疑似损坏缓存文件', { filePath })
void rm(filePath, { force: true }).catch(() => { })
return false
}
return true
}
private isLikelyCorruptedDecodedImage(filePath: string): boolean {
try {
const ext = extname(filePath).toLowerCase()
if (ext !== '.jpg' && ext !== '.jpeg') return false
const data = readFileSync(filePath)
return this.isLikelyCorruptedJpegBuffer(data)
} catch {
return false
}
}
private isLikelyCorruptedJpegBuffer(data: Buffer): boolean {
if (data.length < 4096) return false
let zeroCount = 0
for (let i = 0; i < data.length; i += 1) {
if (data[i] === 0x00) zeroCount += 1
}
const zeroRatio = zeroCount / data.length
if (zeroRatio >= 0.985) return true
const hasLavcTag = data.length >= 24 && data.subarray(0, 24).includes(Buffer.from('Lavc'))
if (!hasLavcTag) return false
// JPEG 扫描段若几乎全是 0通常表示解码失败但被编码器强行输出。
let sosPos = -1
for (let i = 2; i < data.length - 1; i += 1) {
if (data[i] === 0xff && data[i + 1] === 0xda) {
sosPos = i
break
}
}
if (sosPos < 0 || sosPos + 4 >= data.length) return zeroRatio >= 0.95
const sosLength = (data[sosPos + 2] << 8) | data[sosPos + 3]
const scanStart = sosPos + 2 + sosLength
if (scanStart >= data.length - 2) return zeroRatio >= 0.95
let eoiPos = -1
for (let i = data.length - 2; i >= scanStart; i -= 1) {
if (data[i] === 0xff && data[i + 1] === 0xd9) {
eoiPos = i
break
}
}
if (eoiPos < 0 || eoiPos <= scanStart) return zeroRatio >= 0.95
const scanData = data.subarray(scanStart, eoiPos)
if (scanData.length < 1024) return zeroRatio >= 0.95
let scanZeroCount = 0
for (let i = 0; i < scanData.length; i += 1) {
if (scanData[i] === 0x00) scanZeroCount += 1
}
const scanZeroRatio = scanZeroCount / scanData.length
return scanZeroRatio >= 0.985
}
/** /**
* 解包 wxgf 格式 * 解包 wxgf 格式
* wxgf 是微信的图片格式,内部使用 HEVC 编码 * wxgf 是微信的图片格式,内部使用 HEVC 编码
@@ -1653,41 +1715,96 @@ export class ImageDecryptService {
} }
} }
// 提取 HEVC NALU 裸流 const hevcCandidates = this.buildWxgfHevcCandidates(buffer)
const hevcData = this.extractHevcNalu(buffer)
// 优先用提取的 NALU 裸流,提取失败则跳过 wxgf 头部直接用原始数据
const feedData = (hevcData && hevcData.length >= 100) ? hevcData : buffer.subarray(4)
this.logInfo('unwrapWxgf: 准备 ffmpeg 转换', { this.logInfo('unwrapWxgf: 准备 ffmpeg 转换', {
naluExtracted: !!(hevcData && hevcData.length >= 100), candidateCount: hevcCandidates.length,
feedSize: feedData.length candidates: hevcCandidates.map((item) => `${item.name}:${item.data.length}`)
}) })
// 尝试用 ffmpeg 转换 for (const candidate of hevcCandidates) {
try { try {
const jpgData = await this.convertHevcToJpg(feedData) const jpgData = await this.convertHevcToJpg(candidate.data)
if (jpgData && jpgData.length > 0) { if (!jpgData || jpgData.length === 0) continue
return { data: jpgData, isWxgf: false } return { data: jpgData, isWxgf: false }
}
} catch (e) { } catch (e) {
this.logError('unwrapWxgf: ffmpeg 转换失败', e) this.logError('unwrapWxgf: 候选流转换失败', e, { candidate: candidate.name })
}
} }
return { data: feedData, isWxgf: true } const fallback = hevcCandidates[0]?.data || buffer.subarray(4)
return { data: fallback, isWxgf: true }
} }
/** private buildWxgfHevcCandidates(buffer: Buffer): Array<{ name: string; data: Buffer }> {
* 从 wxgf 数据中提取 HEVC NALU 裸流 const units = this.extractHevcNaluUnits(buffer)
*/ const candidates: Array<{ name: string; data: Buffer }> = []
private extractHevcNalu(buffer: Buffer): Buffer | null {
const addCandidate = (name: string, data: Buffer | null | undefined): void => {
if (!data || data.length < 100) return
if (candidates.some((item) => item.data.equals(data))) return
candidates.push({ name, data })
}
// 1) 优先尝试按 VPS(32) 分组后的候选流
const vpsStarts: number[] = []
for (let i = 0; i < units.length; i += 1) {
const unit = units[i]
if (!unit || unit.length < 2) continue
const type = (unit[0] >> 1) & 0x3f
if (type === 32) vpsStarts.push(i)
}
const groups: Array<{ index: number; data: Buffer; size: number }> = []
for (let i = 0; i < vpsStarts.length; i += 1) {
const start = vpsStarts[i]
const end = i + 1 < vpsStarts.length ? vpsStarts[i + 1] : units.length
const groupUnits = units.slice(start, end)
if (groupUnits.length === 0) continue
let hasVcl = false
for (const unit of groupUnits) {
if (!unit || unit.length < 2) continue
const type = (unit[0] >> 1) & 0x3f
if (type === 19 || type === 20 || type === 1) {
hasVcl = true
break
}
}
if (!hasVcl) continue
const merged = this.mergeHevcNaluUnits(groupUnits)
groups.push({ index: i, data: merged, size: merged.length })
}
groups.sort((a, b) => b.size - a.size)
for (const group of groups) {
addCandidate(`group_${group.index}`, group.data)
}
// 2) 全量扫描提取流
addCandidate('scan_all_nalus', this.mergeHevcNaluUnits(units))
// 3) 兜底:直接跳过 wxgf 头喂 ffmpeg
addCandidate('raw_skip4', buffer.subarray(4))
return candidates
}
private mergeHevcNaluUnits(units: Buffer[]): Buffer {
if (!Array.isArray(units) || units.length === 0) return Buffer.alloc(0)
const merged: Buffer[] = []
for (const unit of units) {
if (!unit || unit.length < 2) continue
merged.push(Buffer.from([0x00, 0x00, 0x00, 0x01]))
merged.push(unit)
}
return Buffer.concat(merged)
}
private extractHevcNaluUnits(buffer: Buffer): Buffer[] {
const starts: number[] = [] const starts: number[] = []
let i = 4 let i = 4
while (i < buffer.length - 3) { while (i < buffer.length - 3) {
const hasPrefix4 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 && const hasPrefix4 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01 buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01
const hasPrefix3 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 && const hasPrefix3 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
buffer[i + 2] === 0x01 buffer[i + 2] === 0x01
if (hasPrefix4 || hasPrefix3) { if (hasPrefix4 || hasPrefix3) {
starts.push(i) starts.push(i)
i += hasPrefix4 ? 4 : 3 i += hasPrefix4 ? 4 : 3
@@ -1695,10 +1812,11 @@ export class ImageDecryptService {
} }
i += 1 i += 1
} }
if (starts.length === 0) return []
if (starts.length === 0) return null const units: Buffer[] = []
let keptUnits = 0
const nalUnits: Buffer[] = [] let droppedUnits = 0
for (let index = 0; index < starts.length; index += 1) { for (let index = 0; index < starts.length; index += 1) {
const start = starts[index] const start = starts[index]
const end = index + 1 < starts.length ? starts[index + 1] : buffer.length const end = index + 1 < starts.length ? starts[index + 1] : buffer.length
@@ -1707,12 +1825,29 @@ export class ImageDecryptService {
const prefixLength = hasPrefix4 ? 4 : 3 const prefixLength = hasPrefix4 ? 4 : 3
const payloadStart = start + prefixLength const payloadStart = start + prefixLength
if (payloadStart >= end) continue if (payloadStart >= end) continue
nalUnits.push(Buffer.from([0x00, 0x00, 0x00, 0x01])) const payload = buffer.subarray(payloadStart, end)
nalUnits.push(buffer.subarray(payloadStart, end)) if (payload.length < 2) {
droppedUnits += 1
continue
}
if ((payload[0] & 0x80) !== 0) {
droppedUnits += 1
continue
}
units.push(payload)
keptUnits += 1
}
return units
} }
if (nalUnits.length === 0) return null /**
return Buffer.concat(nalUnits) * 从 wxgf 数据中提取 HEVC NALU 裸流
*/
private extractHevcNalu(buffer: Buffer): Buffer | null {
const units = this.extractHevcNaluUnits(buffer)
if (units.length === 0) return null
const merged = this.mergeHevcNaluUnits(units)
return merged.length > 0 ? merged : null
} }
/** /**
@@ -1747,18 +1882,26 @@ export class ImageDecryptService {
await writeFile(tmpInput, hevcData) await writeFile(tmpInput, hevcData)
// 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测 // 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测
const attempts: { label: string; inputArgs: string[] }[] = [ const attempts: { label: string; inputArgs: string[]; outputArgs?: string[] }[] = [
{ label: 'hevc raw', inputArgs: ['-f', 'hevc', '-i', tmpInput] }, { label: 'hevc raw frame0', inputArgs: ['-f', 'hevc', '-i', tmpInput] },
{ label: 'h265 raw', inputArgs: ['-f', 'h265', '-i', tmpInput] }, { label: 'hevc raw frame1', inputArgs: ['-f', 'hevc', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,1)'] },
{ label: 'auto detect', inputArgs: ['-i', tmpInput] }, { label: 'hevc raw frame5', inputArgs: ['-f', 'hevc', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,5)'] },
{ label: 'h265 raw frame0', inputArgs: ['-f', 'h265', '-i', tmpInput] },
{ label: 'h265 raw frame1', inputArgs: ['-f', 'h265', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,1)'] },
{ label: 'h265 raw frame5', inputArgs: ['-f', 'h265', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,5)'] },
{ label: 'auto detect frame0', inputArgs: ['-i', tmpInput] },
{ label: 'auto detect frame1', inputArgs: ['-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,1)'] },
{ label: 'auto detect frame5', inputArgs: ['-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,5)'] },
] ]
for (const attempt of attempts) { for (const attempt of attempts) {
// 清理上一轮的输出 // 清理上一轮的输出
try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {} try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {}
const result = await this.runFfmpegConvert(ffmpeg, attempt.inputArgs, tmpOutput, attempt.label) const result = await this.runFfmpegConvert(ffmpeg, attempt.inputArgs, tmpOutput, attempt.label, attempt.outputArgs)
if (result) return result if (!result) continue
if (this.isLikelyCorruptedJpegBuffer(result)) continue
return result
} }
return null return null
@@ -1771,7 +1914,13 @@ export class ImageDecryptService {
} }
} }
private runFfmpegConvert(ffmpeg: string, inputArgs: string[], tmpOutput: string, label: string): Promise<Buffer | null> { private runFfmpegConvert(
ffmpeg: string,
inputArgs: string[],
tmpOutput: string,
label: string,
outputArgs?: string[]
): Promise<Buffer | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
const { spawn } = require('child_process') const { spawn } = require('child_process')
const errChunks: Buffer[] = [] const errChunks: Buffer[] = []
@@ -1780,6 +1929,7 @@ export class ImageDecryptService {
'-hide_banner', '-loglevel', 'error', '-hide_banner', '-loglevel', 'error',
'-y', '-y',
...inputArgs, ...inputArgs,
...(outputArgs || []),
'-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput '-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput
] ]
this.logInfo(`ffmpeg 尝试 [${label}]`, { args: args.join(' ') }) this.logInfo(`ffmpeg 尝试 [${label}]`, { args: args.join(' ') })

View File

@@ -50,6 +50,8 @@ const INSIGHT_CONFIG_KEYS = new Set([
'aiModelApiKey', 'aiModelApiKey',
'aiModelApiModel', 'aiModelApiModel',
'aiModelApiMaxTokens', 'aiModelApiMaxTokens',
'aiInsightFilterMode',
'aiInsightFilterList',
'aiInsightAllowSocialContext', 'aiInsightAllowSocialContext',
'aiInsightSocialContextCount', 'aiInsightSocialContextCount',
'aiInsightWeiboCookie', 'aiInsightWeiboCookie',
@@ -73,6 +75,8 @@ interface SharedAiModelConfig {
maxTokens: number maxTokens: number
} }
type InsightFilterMode = 'whitelist' | 'blacklist'
// ─── 日志 ───────────────────────────────────────────────────────────────────── // ─── 日志 ─────────────────────────────────────────────────────────────────────
type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR' type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR'
@@ -196,6 +200,11 @@ function normalizeApiMaxTokens(value: unknown): number {
return Math.min(API_MAX_TOKENS_MAX, Math.max(API_MAX_TOKENS_MIN, Math.floor(numeric))) return Math.min(API_MAX_TOKENS_MAX, Math.max(API_MAX_TOKENS_MIN, Math.floor(numeric)))
} }
function normalizeSessionIdList(value: unknown): string[] {
if (!Array.isArray(value)) return []
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
}
/** /**
* 调用 OpenAI 兼容 API非流式返回模型第一条消息内容。 * 调用 OpenAI 兼容 API非流式返回模型第一条消息内容。
* 使用 Node 原生 https/http 模块,无需任何第三方 SDK。 * 使用 Node 原生 https/http 模块,无需任何第三方 SDK。
@@ -495,7 +504,7 @@ class InsightService {
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') && this.isSessionAllowed(id) return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') && this.isSessionAllowed(id)
}) })
if (!session) { if (!session) {
return { success: false, message: '未找到任何私聊会话(若已启用白名单,请检查是否有勾选的私聊' } return { success: false, message: '未找到任何可触发的私聊会话(请检查黑白名单模式与选择列表' }
} }
const sessionId = session.username?.trim() || '' const sessionId = session.username?.trim() || ''
const displayName = session.displayName || sessionId const displayName = session.displayName || sessionId
@@ -747,14 +756,23 @@ ${topMentionText}
/** /**
* 判断某个会话是否允许触发见解。 * 判断某个会话是否允许触发见解。
* 若白名单未启用,则所有私聊会话均允许; * white/black 模式二选一:
* 若白名单已启用,则只有在白名单中的会话才允许 * - whitelist仅名单内允许
* - blacklist名单内屏蔽其他允许
*/ */
private getInsightFilterConfig(): { mode: InsightFilterMode; list: string[] } {
const modeRaw = String(this.config.get('aiInsightFilterMode') || '').trim().toLowerCase()
const mode: InsightFilterMode = modeRaw === 'blacklist' ? 'blacklist' : 'whitelist'
const list = normalizeSessionIdList(this.config.get('aiInsightFilterList'))
return { mode, list }
}
private isSessionAllowed(sessionId: string): boolean { private isSessionAllowed(sessionId: string): boolean {
const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean const normalizedSessionId = String(sessionId || '').trim()
if (!whitelistEnabled) return true if (!normalizedSessionId) return false
const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || [] const { mode, list } = this.getInsightFilterConfig()
return whitelist.includes(sessionId) if (mode === 'whitelist') return list.includes(normalizedSessionId)
return !list.includes(normalizedSessionId)
} }
/** /**
@@ -966,8 +984,8 @@ ${topMentionText}
* 1. 会话有真正的新消息lastTimestamp 比上次见到的更新) * 1. 会话有真正的新消息lastTimestamp 比上次见到的更新)
* 2. 该会话距上次活跃分析已超过冷却期 * 2. 该会话距上次活跃分析已超过冷却期
* *
* 白名单启用时:直接使用名单里的 sessionId完全跳过 getSessions()。 * whitelist 模式:直接使用名单里的 sessionId完全跳过 getSessions()。
* 白名单未启用时:从缓存拉取全量会话后过滤私聊 * blacklist 模式:从缓存拉取会话后过滤名单
*/ */
private async analyzeRecentActivity(): Promise<void> { private async analyzeRecentActivity(): Promise<void> {
if (!this.isEnabled()) return if (!this.isEnabled()) return
@@ -978,12 +996,11 @@ ${topMentionText}
const now = Date.now() const now = Date.now()
const cooldownMinutes = (this.config.get('aiInsightCooldownMinutes') as number) ?? 120 const cooldownMinutes = (this.config.get('aiInsightCooldownMinutes') as number) ?? 120
const cooldownMs = cooldownMinutes * 60 * 1000 const cooldownMs = cooldownMinutes * 60 * 1000
const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean const { mode: filterMode, list: filterList } = this.getInsightFilterConfig()
const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || []
// 白名单启用且有勾选项时,直接用名单 sessionId无需查数据库全量会话列表。 // whitelist 模式且有勾选项时,直接用名单 sessionId无需查数据库全量会话列表。
// 通过拉取该会话最新 1 条消息时间戳判断是否真正有新消息,开销极低。 // 通过拉取该会话最新 1 条消息时间戳判断是否真正有新消息,开销极低。
if (whitelistEnabled && whitelist.length > 0) { if (filterMode === 'whitelist' && filterList.length > 0) {
// 确保数据库已连接(首次时连接,之后复用) // 确保数据库已连接(首次时连接,之后复用)
if (!this.dbConnected) { if (!this.dbConnected) {
const connectResult = await chatService.connect() const connectResult = await chatService.connect()
@@ -991,8 +1008,8 @@ ${topMentionText}
this.dbConnected = true this.dbConnected = true
} }
for (const sessionId of whitelist) { for (const sessionId of filterList) {
if (!sessionId || sessionId.endsWith('@chatroom')) continue if (!sessionId || sessionId.toLowerCase().includes('placeholder')) continue
// 冷却期检查(先过滤,减少不必要的 DB 查询) // 冷却期检查(先过滤,减少不必要的 DB 查询)
if (cooldownMs > 0) { if (cooldownMs > 0) {
@@ -1029,16 +1046,22 @@ ${topMentionText}
return return
} }
// 白名单未启用:需要拉取全量会话列表,从中过滤私聊 if (filterMode === 'whitelist' && filterList.length === 0) {
insightLog('INFO', '白名单模式且名单为空,跳过活跃分析')
return
}
// blacklist 模式:拉取会话缓存后按过滤规则筛选
const sessions = await this.getSessionsCached() const sessions = await this.getSessionsCached()
if (sessions.length === 0) return if (sessions.length === 0) return
const privateSessions = sessions.filter((s) => { const candidateSessions = sessions.filter((s) => {
const id = s.username?.trim() || '' const id = s.username?.trim() || ''
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') if (!id || id.toLowerCase().includes('placeholder')) return false
return this.isSessionAllowed(id)
}) })
for (const session of privateSessions.slice(0, 10)) { for (const session of candidateSessions.slice(0, 10)) {
const sessionId = session.username?.trim() || '' const sessionId = session.username?.trim() || ''
if (!sessionId) continue if (!sessionId) continue

View File

@@ -25,9 +25,7 @@ export class WcdbService {
private logEnabled = false private logEnabled = false
private monitorListener: ((type: string, json: string) => void) | null = null private monitorListener: ((type: string, json: string) => void) | null = null
constructor() { constructor() {}
this.initWorker()
}
/** /**
* 初始化 Worker 线程 * 初始化 Worker 线程

View File

@@ -13,13 +13,13 @@
}, },
"//": "二改不应改变此处的作者与应用信息", "//": "二改不应改变此处的作者与应用信息",
"scripts": { "scripts": {
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
"rebuild": "electron-rebuild", "rebuild": "electron-rebuild",
"dev": "vite", "dev": "node scripts/prepare-electron-runtime.cjs && vite",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build": "tsc && vite build && electron-builder", "build": "tsc && vite build && electron-builder",
"preview": "vite preview", "preview": "vite preview",
"electron:dev": "vite --mode electron", "electron:dev": "node scripts/prepare-electron-runtime.cjs && vite --mode electron",
"electron:build": "npm run build" "electron:build": "npm run build"
}, },
"dependencies": { "dependencies": {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,57 @@
const fs = require('node:fs');
const path = require('node:path');
const runtimeNames = [
'msvcp140.dll',
'msvcp140_1.dll',
'vcruntime140.dll',
'vcruntime140_1.dll',
];
function copyIfDifferent(sourcePath, targetPath) {
const source = fs.statSync(sourcePath);
const targetExists = fs.existsSync(targetPath);
if (targetExists) {
const target = fs.statSync(targetPath);
if (target.size === source.size && target.mtimeMs >= source.mtimeMs) {
return false;
}
}
fs.copyFileSync(sourcePath, targetPath);
return true;
}
function main() {
if (process.platform !== 'win32') {
return;
}
const projectRoot = path.resolve(__dirname, '..');
const sourceDir = path.join(projectRoot, 'resources', 'runtime', 'win32');
const targetDir = path.join(projectRoot, 'node_modules', 'electron', 'dist');
if (!fs.existsSync(sourceDir) || !fs.existsSync(targetDir)) {
return;
}
let copiedCount = 0;
for (const name of runtimeNames) {
const sourcePath = path.join(sourceDir, name);
const targetPath = path.join(targetDir, name);
if (!fs.existsSync(sourcePath)) {
continue;
}
if (copyIfDifferent(sourcePath, targetPath)) {
copiedCount += 1;
}
}
if (copiedCount > 0) {
console.log(`[prepare-electron-runtime] synced ${copiedCount} runtime DLL(s) to ${targetDir}`);
}
}
main();

View File

@@ -80,6 +80,7 @@ function App() {
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') || location.pathname.startsWith('/chat-history-inline/') const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') || location.pathname.startsWith('/chat-history-inline/')
const isStandaloneChatWindow = location.pathname === '/chat-window' const isStandaloneChatWindow = location.pathname === '/chat-window'
const isNotificationWindow = location.pathname === '/notification-window' const isNotificationWindow = location.pathname === '/notification-window'
const isAnnualReportWindow = location.pathname === '/annual-report/view'
const isSettingsRoute = location.pathname === '/settings' const isSettingsRoute = location.pathname === '/settings'
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
const routeLocation = isSettingsRoute const routeLocation = isSettingsRoute
@@ -127,7 +128,7 @@ function App() {
const body = document.body const body = document.body
const appRoot = document.getElementById('app') const appRoot = document.getElementById('app')
if (isOnboardingWindow || isNotificationWindow) { if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow) {
root.style.background = 'transparent' root.style.background = 'transparent'
body.style.background = 'transparent' body.style.background = 'transparent'
body.style.overflow = 'hidden' body.style.overflow = 'hidden'
@@ -144,7 +145,7 @@ function App() {
appRoot.style.overflow = '' appRoot.style.overflow = ''
} }
} }
}, [isOnboardingWindow]) }, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
// 应用主题 // 应用主题
useEffect(() => { useEffect(() => {
@@ -165,7 +166,7 @@ function App() {
} }
mq.addEventListener('change', handler) mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler) return () => mq.removeEventListener('change', handler)
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow]) }, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
// 读取已保存的主题设置 // 读取已保存的主题设置
useEffect(() => { useEffect(() => {
@@ -511,6 +512,11 @@ function App() {
return <NotificationWindow /> return <NotificationWindow />
} }
// 独立年度报告全屏窗口
if (isAnnualReportWindow) {
return <AnnualReportWindow />
}
// 主窗口 - 完整布局 // 主窗口 - 完整布局
const handleCloseSettings = () => { const handleCloseSettings = () => {
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current

View File

@@ -1,4 +1,5 @@
.annual-report-page { .annual-report-page {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -8,6 +9,11 @@
padding: 40px 24px; padding: 40px 24px;
} }
.annual-report-page.report-route-transitioning > :not(.report-launch-overlay) {
animation: report-page-exit 420ms cubic-bezier(0.4, 0, 0.2, 1) both;
pointer-events: none;
}
.header-icon { .header-icon {
color: var(--primary); color: var(--primary);
margin-bottom: 16px; margin-bottom: 16px;
@@ -199,6 +205,11 @@
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
} }
&.disabled {
pointer-events: none;
opacity: 0.72;
}
&.selected { &.selected {
border-color: var(--primary); border-color: var(--primary);
background: var(--primary-light); background: var(--primary-light);
@@ -251,6 +262,10 @@
cursor: not-allowed; cursor: not-allowed;
} }
&.is-pending {
pointer-events: none;
}
&.secondary { &.secondary {
background: var(--card-bg); background: var(--card-bg);
color: var(--text-primary); color: var(--text-primary);
@@ -259,6 +274,40 @@
} }
} }
.report-launch-overlay {
position: fixed;
inset: 0;
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--bg-primary) 78%, transparent);
backdrop-filter: blur(8px);
animation: report-launch-overlay-in 420ms ease-out both;
}
.launch-core {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
text-align: center;
color: var(--text-primary);
animation: report-launch-core-in 420ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.launch-title {
margin: 4px 0 0;
font-size: 18px;
font-weight: 650;
}
.launch-subtitle {
margin: 0;
font-size: 13px;
color: var(--text-tertiary);
}
.spin { .spin {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@@ -271,3 +320,36 @@
@keyframes dot-ellipsis { @keyframes dot-ellipsis {
to { width: 1.4em; } to { width: 1.4em; }
} }
@keyframes report-page-exit {
from {
opacity: 1;
filter: blur(0);
transform: scale(1);
}
to {
opacity: 0;
filter: blur(8px);
transform: scale(0.985);
}
}
@keyframes report-launch-overlay-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes report-launch-core-in {
from {
opacity: 0;
transform: translateY(18px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react' import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
import { import {
@@ -25,6 +25,8 @@ type YearsLoadPayload = {
nativeTimedOut?: boolean nativeTimedOut?: boolean
} }
const REPORT_LAUNCH_DELAY_MS = 420
const formatLoadElapsed = (ms: number) => { const formatLoadElapsed = (ms: number) => {
const totalSeconds = Math.max(0, ms) / 1000 const totalSeconds = Math.max(0, ms) / 1000
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s` if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`
@@ -50,7 +52,10 @@ function AnnualReportPage() {
const [hasSwitchedStrategy, setHasSwitchedStrategy] = useState(false) const [hasSwitchedStrategy, setHasSwitchedStrategy] = useState(false)
const [nativeTimedOut, setNativeTimedOut] = useState(false) const [nativeTimedOut, setNativeTimedOut] = useState(false)
const [isGenerating, setIsGenerating] = useState(false) const [isGenerating, setIsGenerating] = useState(false)
const [isRouteTransitioning, setIsRouteTransitioning] = useState(false)
const [launchingYearLabel, setLaunchingYearLabel] = useState('')
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
const launchTimerRef = useRef<number | null>(null)
useEffect(() => { useEffect(() => {
let disposed = false let disposed = false
@@ -186,21 +191,37 @@ function AnnualReportPage() {
} }
}, []) }, [])
const handleGenerateReport = async () => { useEffect(() => {
if (selectedYear === null) return return () => {
setIsGenerating(true) if (launchTimerRef.current !== null) {
try { window.clearTimeout(launchTimerRef.current)
}
}
}, [])
const handleGenerateReport = () => {
if (selectedYear === null || isRouteTransitioning) return
const yearParam = selectedYear === 'all' ? 0 : selectedYear const yearParam = selectedYear === 'all' ? 0 : selectedYear
const yearLabel = selectedYear === 'all' ? '全部时间' : `${selectedYear}`
setIsGenerating(true)
setIsRouteTransitioning(true)
setLaunchingYearLabel(yearLabel)
if (launchTimerRef.current !== null) {
window.clearTimeout(launchTimerRef.current)
}
launchTimerRef.current = window.setTimeout(() => {
try {
navigate(`/annual-report/view?year=${yearParam}`) navigate(`/annual-report/view?year=${yearParam}`)
} catch (e) { } catch (e) {
console.error('生成报告失败:', e) console.error('生成报告失败:', e)
} finally {
setIsGenerating(false) setIsGenerating(false)
setIsRouteTransitioning(false)
} }
}, REPORT_LAUNCH_DELAY_MS)
} }
const handleGenerateDualReport = () => { const handleGenerateDualReport = () => {
if (selectedPairYear === null) return if (selectedPairYear === null || isRouteTransitioning) return
const yearParam = selectedPairYear === 'all' ? 0 : selectedPairYear const yearParam = selectedPairYear === 'all' ? 0 : selectedPairYear
navigate(`/dual-report?year=${yearParam}`) navigate(`/dual-report?year=${yearParam}`)
} }
@@ -251,7 +272,7 @@ function AnnualReportPage() {
) )
return ( return (
<div className="annual-report-page"> <div className={`annual-report-page ${isRouteTransitioning ? 'report-route-transitioning' : ''}`}>
<Sparkles size={32} className="header-icon" /> <Sparkles size={32} className="header-icon" />
<h1 className="page-title"></h1> <h1 className="page-title"></h1>
<p className="page-desc"></p> <p className="page-desc"></p>
@@ -270,8 +291,11 @@ function AnnualReportPage() {
{yearOptions.map(option => ( {yearOptions.map(option => (
<div <div
key={option} key={option}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`} className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''} ${isRouteTransitioning ? 'disabled' : ''}`}
onClick={() => setSelectedYear(option)} onClick={() => {
if (isRouteTransitioning) return
setSelectedYear(option)
}}
> >
<span className="year-number">{option === 'all' ? '全部' : option}</span> <span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span> <span className="year-label">{option === 'all' ? '时间' : '年'}</span>
@@ -281,14 +305,14 @@ function AnnualReportPage() {
</div> </div>
<button <button
className="generate-btn" className={`generate-btn ${isRouteTransitioning ? 'is-pending' : ''}`}
onClick={handleGenerateReport} onClick={handleGenerateReport}
disabled={!selectedYear || isGenerating} disabled={!selectedYear || isGenerating || isRouteTransitioning}
> >
{isGenerating ? ( {isGenerating ? (
<> <>
<Loader2 size={20} className="spin" /> <Loader2 size={20} className="spin" />
<span>...</span> <span>{isRouteTransitioning ? '正在进入报告...' : '正在生成...'}</span>
</> </>
) : ( ) : (
<> <>
@@ -316,8 +340,11 @@ function AnnualReportPage() {
{yearOptions.map(option => ( {yearOptions.map(option => (
<div <div
key={`pair-${option}`} key={`pair-${option}`}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`} className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''} ${isRouteTransitioning ? 'disabled' : ''}`}
onClick={() => setSelectedPairYear(option)} onClick={() => {
if (isRouteTransitioning) return
setSelectedPairYear(option)
}}
> >
<span className="year-number">{option === 'all' ? '全部' : option}</span> <span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span> <span className="year-label">{option === 'all' ? '时间' : '年'}</span>
@@ -327,9 +354,9 @@ function AnnualReportPage() {
</div> </div>
<button <button
className="generate-btn secondary" className={`generate-btn secondary ${isRouteTransitioning ? 'is-pending' : ''}`}
onClick={handleGenerateDualReport} onClick={handleGenerateDualReport}
disabled={!selectedPairYear} disabled={!selectedPairYear || isRouteTransitioning}
> >
<Users size={20} /> <Users size={20} />
<span></span> <span></span>
@@ -337,6 +364,16 @@ function AnnualReportPage() {
<p className="section-hint"></p> <p className="section-hint"></p>
</section> </section>
</div> </div>
{isRouteTransitioning && (
<div className="report-launch-overlay" role="status" aria-live="polite">
<div className="launch-core">
<Loader2 size={30} className="spin" />
<p className="launch-title">{launchingYearLabel}</p>
<p className="launch-subtitle">...</p>
</div>
</div>
)}
</div> </div>
) )
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1898,6 +1898,9 @@ const TaskCenterModal = memo(function TaskCenterModal({
const mediaCacheMetricLabel = mediaCacheTotal > 0 const mediaCacheMetricLabel = mediaCacheTotal > 0
? `缓存命中 ${mediaCacheHitFiles}/${mediaCacheTotal}` ? `缓存命中 ${mediaCacheHitFiles}/${mediaCacheTotal}`
: '' : ''
const mediaMissMetricLabel = mediaCacheMissFiles > 0
? `未导出 ${mediaCacheMissFiles} 个文件/媒体`
: ''
const mediaDedupMetricLabel = mediaDedupReuseFiles > 0 const mediaDedupMetricLabel = mediaDedupReuseFiles > 0
? `复用 ${mediaDedupReuseFiles}` ? `复用 ${mediaDedupReuseFiles}`
: '' : ''
@@ -1958,6 +1961,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
{phaseMetricLabel ? ` · ${phaseMetricLabel}` : ''} {phaseMetricLabel ? ` · ${phaseMetricLabel}` : ''}
{mediaLiveMetricLabel ? ` · ${mediaLiveMetricLabel}` : ''} {mediaLiveMetricLabel ? ` · ${mediaLiveMetricLabel}` : ''}
{mediaCacheMetricLabel ? ` · ${mediaCacheMetricLabel}` : ''} {mediaCacheMetricLabel ? ` · ${mediaCacheMetricLabel}` : ''}
{mediaMissMetricLabel ? ` · ${mediaMissMetricLabel}` : ''}
{mediaDedupMetricLabel ? ` · ${mediaDedupMetricLabel}` : ''} {mediaDedupMetricLabel ? ` · ${mediaDedupMetricLabel}` : ''}
{task.status === 'running' && currentSessionRatio !== null {task.status === 'running' && currentSessionRatio !== null
? `(当前会话 ${Math.round(currentSessionRatio * 100)}%` ? `(当前会话 ${Math.round(currentSessionRatio * 100)}%`

View File

@@ -75,6 +75,7 @@ interface WxidOption {
type SessionFilterType = configService.MessagePushSessionType type SessionFilterType = configService.MessagePushSessionType
type SessionFilterTypeValue = 'all' | SessionFilterType type SessionFilterTypeValue = 'all' | SessionFilterType
type SessionFilterMode = 'all' | 'whitelist' | 'blacklist' type SessionFilterMode = 'all' | 'whitelist' | 'blacklist'
type InsightSessionFilterTypeValue = 'all' | 'private' | 'group' | 'official'
interface SessionFilterOption { interface SessionFilterOption {
username: string username: string
@@ -91,6 +92,13 @@ const sessionFilterTypeOptions: Array<{ value: SessionFilterTypeValue; label: st
{ value: 'other', label: '其他/非好友' } { value: 'other', label: '其他/非好友' }
] ]
const insightFilterTypeOptions: Array<{ value: InsightSessionFilterTypeValue; label: string }> = [
{ value: 'all', label: '全部' },
{ value: 'private', label: '私聊' },
{ value: 'group', label: '群聊' },
{ value: 'official', label: '订阅号/服务号' }
]
interface SettingsPageProps { interface SettingsPageProps {
onClose?: () => void onClose?: () => void
} }
@@ -194,6 +202,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false) const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false)
const [insightFilterModeDropdownOpen, setInsightFilterModeDropdownOpen] = useState(false)
const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState<string[]>([]) const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState<string[]>([])
const [excludeWordsInput, setExcludeWordsInput] = useState('') const [excludeWordsInput, setExcludeWordsInput] = useState('')
@@ -275,8 +284,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [showInsightApiKey, setShowInsightApiKey] = useState(false) const [showInsightApiKey, setShowInsightApiKey] = useState(false)
const [isTriggeringInsightTest, setIsTriggeringInsightTest] = useState(false) const [isTriggeringInsightTest, setIsTriggeringInsightTest] = useState(false)
const [insightTriggerResult, setInsightTriggerResult] = useState<{ success: boolean; message: string } | null>(null) const [insightTriggerResult, setInsightTriggerResult] = useState<{ success: boolean; message: string } | null>(null)
const [aiInsightWhitelistEnabled, setAiInsightWhitelistEnabled] = useState(false) const [aiInsightFilterMode, setAiInsightFilterMode] = useState<configService.AiInsightFilterMode>('whitelist')
const [aiInsightWhitelist, setAiInsightWhitelist] = useState<Set<string>>(new Set()) const [aiInsightFilterList, setAiInsightFilterList] = useState<Set<string>>(new Set())
const [insightFilterType, setInsightFilterType] = useState<InsightSessionFilterTypeValue>('all')
const [insightWhitelistSearch, setInsightWhitelistSearch] = useState('') const [insightWhitelistSearch, setInsightWhitelistSearch] = useState('')
const [aiInsightCooldownMinutes, setAiInsightCooldownMinutes] = useState(120) const [aiInsightCooldownMinutes, setAiInsightCooldownMinutes] = useState(120)
const [aiInsightScanIntervalHours, setAiInsightScanIntervalHours] = useState(4) const [aiInsightScanIntervalHours, setAiInsightScanIntervalHours] = useState(4)
@@ -397,15 +407,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setPositionDropdownOpen(false) setPositionDropdownOpen(false)
setCloseBehaviorDropdownOpen(false) setCloseBehaviorDropdownOpen(false)
setMessagePushFilterDropdownOpen(false) setMessagePushFilterDropdownOpen(false)
setInsightFilterModeDropdownOpen(false)
} }
} }
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen) { if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen || insightFilterModeDropdownOpen) {
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)
} }
return () => { return () => {
document.removeEventListener('click', handleClickOutside) document.removeEventListener('click', handleClickOutside)
} }
}, [closeBehaviorDropdownOpen, filterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen]) }, [closeBehaviorDropdownOpen, filterModeDropdownOpen, insightFilterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen])
const loadConfig = async () => { const loadConfig = async () => {
@@ -531,8 +542,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedAiModelApiMaxTokens = await configService.getAiModelApiMaxTokens() const savedAiModelApiMaxTokens = await configService.getAiModelApiMaxTokens()
const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays() const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays()
const savedAiInsightAllowContext = await configService.getAiInsightAllowContext() const savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled() const savedAiInsightFilterMode = await configService.getAiInsightFilterMode()
const savedAiInsightWhitelist = await configService.getAiInsightWhitelist() const savedAiInsightFilterList = await configService.getAiInsightFilterList()
const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes() const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes()
const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours() const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours()
const savedAiInsightContextCount = await configService.getAiInsightContextCount() const savedAiInsightContextCount = await configService.getAiInsightContextCount()
@@ -555,8 +566,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setAiModelApiMaxTokens(savedAiModelApiMaxTokens) setAiModelApiMaxTokens(savedAiModelApiMaxTokens)
setAiInsightSilenceDays(savedAiInsightSilenceDays) setAiInsightSilenceDays(savedAiInsightSilenceDays)
setAiInsightAllowContext(savedAiInsightAllowContext) setAiInsightAllowContext(savedAiInsightAllowContext)
setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled) setAiInsightFilterMode(savedAiInsightFilterMode)
setAiInsightWhitelist(new Set(savedAiInsightWhitelist)) setAiInsightFilterList(new Set(savedAiInsightFilterList))
setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes) setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes)
setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours) setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours)
setAiInsightContextCount(savedAiInsightContextCount) setAiInsightContextCount(savedAiInsightContextCount)
@@ -3390,98 +3401,129 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="divider" /> <div className="divider" />
{/* 对话名单 */} {/* 对话过滤名单 */}
{(() => { {(() => {
const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0)) const selectableSessions = sessionFilterOptions.filter((session) =>
session.type === 'private' || session.type === 'group' || session.type === 'official'
)
const keyword = insightWhitelistSearch.trim().toLowerCase() const keyword = insightWhitelistSearch.trim().toLowerCase()
const filteredSessions = sortedSessions.filter((s) => { const filteredSessions = selectableSessions.filter((session) => {
const id = s.username?.trim() || '' if (insightFilterType !== 'all' && session.type !== insightFilterType) return false
if (!id || id.endsWith('@chatroom') || id.toLowerCase().includes('placeholder')) return false const id = session.username?.trim() || ''
if (!id || id.toLowerCase().includes('placeholder')) return false
if (!keyword) return true if (!keyword) return true
return ( return (
String(s.displayName || '').toLowerCase().includes(keyword) || String(session.displayName || '').toLowerCase().includes(keyword) ||
id.toLowerCase().includes(keyword) id.toLowerCase().includes(keyword)
) )
}) })
const filteredIds = filteredSessions.map((s) => s.username) const filteredIds = filteredSessions.map((session) => session.username)
const selectedCount = aiInsightWhitelist.size const selectedCount = aiInsightFilterList.size
const selectedInFilteredCount = filteredIds.filter((id) => aiInsightWhitelist.has(id)).length const selectedInFilteredCount = filteredIds.filter((id) => aiInsightFilterList.has(id)).length
const allFilteredSelected = filteredIds.length > 0 && selectedInFilteredCount === filteredIds.length const allFilteredSelected = filteredIds.length > 0 && selectedInFilteredCount === filteredIds.length
const toggleSession = (id: string) => { const saveFilterList = async (next: Set<string>) => {
setAiInsightWhitelist((prev) => { await configService.setAiInsightFilterList(Array.from(next))
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
} }
const saveWhitelist = async (next: Set<string>) => { const saveFilterMode = async (mode: configService.AiInsightFilterMode) => {
await configService.setAiInsightWhitelist(Array.from(next)) setAiInsightFilterMode(mode)
setInsightFilterModeDropdownOpen(false)
await configService.setAiInsightFilterMode(mode)
showMessage(mode === 'whitelist' ? '已切换为白名单模式' : '已切换为黑名单模式', true)
} }
const selectAllFiltered = () => { const selectAllFiltered = () => {
setAiInsightWhitelist((prev) => { setAiInsightFilterList((prev) => {
const next = new Set(prev) const next = new Set(prev)
for (const id of filteredIds) next.add(id) for (const id of filteredIds) next.add(id)
void saveWhitelist(next) void saveFilterList(next)
return next return next
}) })
} }
const clearSelection = () => { const clearSelection = () => {
const next = new Set<string>() const next = new Set<string>()
setAiInsightWhitelist(next) setAiInsightFilterList(next)
void saveWhitelist(next) void saveFilterList(next)
} }
return ( return (
<div className="anti-revoke-tab insight-social-tab"> <div className="anti-revoke-tab insight-social-tab">
<div className="anti-revoke-hero"> <div className="anti-revoke-hero">
<div className="anti-revoke-hero-main"> <div className="anti-revoke-hero-main">
<h3></h3> <h3></h3>
<p> <p>
AI UID /
</p> </p>
</div> </div>
<div className="anti-revoke-metrics"> <div className="anti-revoke-metrics">
<div className="anti-revoke-metric is-total"> <div className="anti-revoke-metric is-total">
<span className="label"></span> <span className="label"></span>
<span className="value">{filteredIds.length + (keyword ? 0 : 0)}</span> <span className="value">{selectableSessions.length}</span>
</div> </div>
<div className="anti-revoke-metric is-installed"> <div className="anti-revoke-metric is-installed">
<span className="label"></span> <span className="label"></span>
<span className="value">{selectedCount}</span> <span className="value">{selectedCount}</span>
</div> </div>
</div> </div>
</div> </div>
<div className="log-toggle-line" style={{ marginBottom: 12 }}> <div className="form-group" style={{ marginBottom: 12 }}>
<div className="log-toggle-line">
<span className="log-status" style={{ fontWeight: 600 }}> <span className="log-status" style={{ fontWeight: 600 }}>
{aiInsightWhitelistEnabled ? '白名单已启用(仅对勾选对话生效)' : '白名单未启用(对所有私聊生效)'} {aiInsightFilterMode === 'whitelist'
? '白名单模式(仅对名单内会话生效)'
: '黑名单模式(名单内会话将被忽略)'}
</span> </span>
<label className="switch"> <div className="custom-select" style={{ minWidth: 210 }}>
<input <div
type="checkbox" className={`custom-select-trigger ${insightFilterModeDropdownOpen ? 'open' : ''}`}
checked={aiInsightWhitelistEnabled} onClick={() => setInsightFilterModeDropdownOpen(!insightFilterModeDropdownOpen)}
onChange={async (e) => { >
const val = e.target.checked <span className="custom-select-value">
setAiInsightWhitelistEnabled(val) {aiInsightFilterMode === 'whitelist' ? '白名单模式' : '黑名单模式'}
await configService.setAiInsightWhitelistEnabled(val) </span>
}} <ChevronDown size={14} className={`custom-select-arrow ${insightFilterModeDropdownOpen ? 'rotate' : ''}`} />
/> </div>
<span className="switch-slider" /> <div className={`custom-select-dropdown ${insightFilterModeDropdownOpen ? 'open' : ''}`}>
</label> {[
{ value: 'whitelist', label: '白名单模式' },
{ value: 'blacklist', label: '黑名单模式' }
].map(option => (
<div
key={option.value}
className={`custom-select-option ${aiInsightFilterMode === option.value ? 'selected' : ''}`}
onClick={() => { void saveFilterMode(option.value as configService.AiInsightFilterMode) }}
>
{option.label}
{aiInsightFilterMode === option.value && <Check size={14} />}
</div>
))}
</div>
</div>
</div>
</div> </div>
<div className="anti-revoke-control-card"> <div className="anti-revoke-control-card">
<div className="push-filter-type-tabs" style={{ marginBottom: 10 }}>
{insightFilterTypeOptions.map(option => (
<button
key={option.value}
type="button"
className={`push-filter-type-tab ${insightFilterType === option.value ? 'active' : ''}`}
onClick={() => setInsightFilterType(option.value)}
>
{option.label}
</button>
))}
</div>
<div className="anti-revoke-toolbar"> <div className="anti-revoke-toolbar">
<div className="filter-search-box anti-revoke-search"> <div className="filter-search-box anti-revoke-search">
<Search size={14} /> <Search size={14} />
<input <input
type="text" type="text"
placeholder="搜索私聊对话..." placeholder="搜索对话..."
value={insightWhitelistSearch} value={insightWhitelistSearch}
onChange={(e) => setInsightWhitelistSearch(e.target.value)} onChange={(e) => setInsightWhitelistSearch(e.target.value)}
/> />
@@ -3517,7 +3559,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="anti-revoke-list"> <div className="anti-revoke-list">
{filteredSessions.length === 0 ? ( {filteredSessions.length === 0 ? (
<div className="anti-revoke-empty"> <div className="anti-revoke-empty">
{insightWhitelistSearch ? '没有匹配的对话' : '暂无私聊对话'} {insightWhitelistSearch || insightFilterType !== 'all' ? '没有匹配的对话' : '暂无可选对话'}
</div> </div>
) : ( ) : (
<> <>
@@ -3527,7 +3569,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span></span> <span></span>
</div> </div>
{filteredSessions.map((session) => { {filteredSessions.map((session) => {
const isSelected = aiInsightWhitelist.has(session.username) const isSelected = aiInsightFilterList.has(session.username)
const weiboBinding = aiInsightWeiboBindings[session.username] const weiboBinding = aiInsightWeiboBindings[session.username]
const weiboDraftValue = getWeiboBindingDraftValue(session.username) const weiboDraftValue = getWeiboBindingDraftValue(session.username)
const isBindingLoading = weiboBindingLoadingSessionId === session.username const isBindingLoading = weiboBindingLoadingSessionId === session.username
@@ -3543,11 +3585,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected}
onChange={async () => { onChange={async () => {
setAiInsightWhitelist((prev) => { setAiInsightFilterList((prev) => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(session.username)) next.delete(session.username) if (next.has(session.username)) next.delete(session.username)
else next.add(session.username) else next.add(session.username)
void configService.setAiInsightWhitelist(Array.from(next)) void configService.setAiInsightFilterList(Array.from(next))
return next return next
}) })
}} }}
@@ -3563,9 +3605,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
/> />
<div className="anti-revoke-row-text"> <div className="anti-revoke-row-text">
<span className="name">{session.displayName || session.username}</span> <span className="name">{session.displayName || session.username}</span>
<span className="desc">{getSessionFilterTypeLabel(session.type)}</span>
</div> </div>
</label> </label>
<div className="insight-social-binding-cell"> <div className="insight-social-binding-cell">
{session.type === 'private' ? (
<>
<div className="insight-social-binding-input-wrap"> <div className="insight-social-binding-input-wrap">
<span className="binding-platform-chip"></span> <span className="binding-platform-chip"></span>
<input <input
@@ -3606,11 +3651,19 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span className="binding-feedback muted"> UID</span> <span className="binding-feedback muted"> UID</span>
)} )}
</div> </div>
</>
) : (
<div className="insight-social-binding-feedback">
<span className="binding-feedback muted"></span>
</div>
)}
</div> </div>
<div className="anti-revoke-row-status"> <div className="anti-revoke-row-status">
<span className={`status-badge ${isSelected ? 'installed' : 'not-installed'}`}> <span className={`status-badge ${isSelected ? 'installed' : 'not-installed'}`}>
<i className="status-dot" aria-hidden="true" /> <i className="status-dot" aria-hidden="true" />
{isSelected ? '已加入' : '未加入'} {isSelected
? (aiInsightFilterMode === 'whitelist' ? '已允许' : '已屏蔽')
: (aiInsightFilterMode === 'whitelist' ? '未允许' : '允许')}
</span> </span>
</div> </div>
</div> </div>
@@ -3631,7 +3684,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="api-docs"> <div className="api-docs">
<div className="api-item"> <div className="api-item">
<p className="api-desc" style={{ lineHeight: 1.7 }}> <p className="api-desc" style={{ lineHeight: 1.7 }}>
<strong></strong> 500ms <br /> <strong></strong> 500ms <br />
<strong></strong> 4 <br /> <strong></strong> 4 <br />
<strong></strong> AI AI <br /> <strong></strong> AI AI <br />
<strong></strong> API WeFlow <strong></strong> API WeFlow

View File

@@ -97,6 +97,8 @@ export const CONFIG_KEYS = {
AI_INSIGHT_SILENCE_DAYS: 'aiInsightSilenceDays', AI_INSIGHT_SILENCE_DAYS: 'aiInsightSilenceDays',
AI_INSIGHT_ALLOW_CONTEXT: 'aiInsightAllowContext', AI_INSIGHT_ALLOW_CONTEXT: 'aiInsightAllowContext',
AI_INSIGHT_ALLOW_SOCIAL_CONTEXT: 'aiInsightAllowSocialContext', AI_INSIGHT_ALLOW_SOCIAL_CONTEXT: 'aiInsightAllowSocialContext',
AI_INSIGHT_FILTER_MODE: 'aiInsightFilterMode',
AI_INSIGHT_FILTER_LIST: 'aiInsightFilterList',
AI_INSIGHT_WHITELIST_ENABLED: 'aiInsightWhitelistEnabled', AI_INSIGHT_WHITELIST_ENABLED: 'aiInsightWhitelistEnabled',
AI_INSIGHT_WHITELIST: 'aiInsightWhitelist', AI_INSIGHT_WHITELIST: 'aiInsightWhitelist',
AI_INSIGHT_COOLDOWN_MINUTES: 'aiInsightCooldownMinutes', AI_INSIGHT_COOLDOWN_MINUTES: 'aiInsightCooldownMinutes',
@@ -1917,22 +1919,49 @@ export async function setAiInsightAllowSocialContext(allow: boolean): Promise<vo
await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_SOCIAL_CONTEXT, allow) await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_SOCIAL_CONTEXT, allow)
} }
export type AiInsightFilterMode = 'whitelist' | 'blacklist'
const normalizeAiInsightFilterList = (value: unknown): string[] => {
if (!Array.isArray(value)) return []
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
}
export async function getAiInsightFilterMode(): Promise<AiInsightFilterMode> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_FILTER_MODE)
if (value === 'blacklist') return 'blacklist'
if (value === 'whitelist') return 'whitelist'
return 'whitelist'
}
export async function setAiInsightFilterMode(mode: AiInsightFilterMode): Promise<void> {
const normalizedMode: AiInsightFilterMode = mode === 'blacklist' ? 'blacklist' : 'whitelist'
await config.set(CONFIG_KEYS.AI_INSIGHT_FILTER_MODE, normalizedMode)
}
export async function getAiInsightFilterList(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_FILTER_LIST)
return normalizeAiInsightFilterList(value)
}
export async function setAiInsightFilterList(list: string[]): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_FILTER_LIST, normalizeAiInsightFilterList(list))
}
// 兼容旧字段命名:内部已映射到新的黑白名单模式
export async function getAiInsightWhitelistEnabled(): Promise<boolean> { export async function getAiInsightWhitelistEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_WHITELIST_ENABLED) return (await getAiInsightFilterMode()) === 'whitelist'
return value === true
} }
export async function setAiInsightWhitelistEnabled(enabled: boolean): Promise<void> { export async function setAiInsightWhitelistEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_WHITELIST_ENABLED, enabled) await setAiInsightFilterMode(enabled ? 'whitelist' : 'blacklist')
} }
export async function getAiInsightWhitelist(): Promise<string[]> { export async function getAiInsightWhitelist(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_WHITELIST) return getAiInsightFilterList()
return Array.isArray(value) ? (value as string[]) : []
} }
export async function setAiInsightWhitelist(list: string[]): Promise<void> { export async function setAiInsightWhitelist(list: string[]): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_WHITELIST, list) await setAiInsightFilterList(list)
} }
export async function getAiInsightCooldownMinutes(): Promise<number> { export async function getAiInsightCooldownMinutes(): Promise<number> {

View File

@@ -849,6 +849,8 @@ export interface ElectronAPI {
initiatedChats: number initiatedChats: number
receivedChats: number receivedChats: number
initiativeRate: number initiativeRate: number
topInitiatedFriend?: string
topInitiatedCount?: number
} | null } | null
responseSpeed: { responseSpeed: {
avgResponseTime: number avgResponseTime: number
@@ -881,6 +883,12 @@ export interface ElectronAPI {
dir?: string dir?: string
error?: string error?: string
}> }>
captureCurrentWindow: () => Promise<{
success: boolean
dataUrl?: string
size?: { width: number; height: number }
error?: string
}>
onAvailableYearsProgress: (callback: (payload: { onAvailableYearsProgress: (callback: (payload: {
taskId: string taskId: string
years?: number[] years?: number[]