This commit is contained in:
cc committed 2026-07-01 15:34:37 +08:00
1 parent 99dcb6e43b
commit 301cb5abce
48 files changed
+5444 -1015

No files matched your search

@@ -34,6 +34,7 @@ const exportFormatOptions = [
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'markdown', label: 'Markdown', desc: '支持文本、图片与链接,适合 AI 场景' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
+69 -1
View File
@@ -6,7 +6,7 @@
*/
import { memo, useEffect, useState, useCallback } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import ExportTopBar from './components/ExportTopBar'
import SessionTable from './components/SessionTable'
import ExportDialog from './components/ExportDialog'
@@ -28,18 +28,26 @@ import { AutomationTaskForm } from './components/Automation/AutomationTaskForm'
import type { SessionRow } from './types'
import { getSelectionScopeFromRows, resolveScopeDisplayNames } from './utils/session'
import {
emitSingleExportDialogStatus,
onOpenSingleExport,
type OpenSingleExportPayload
} from '../../services/exportBridge'
import './ExportPage.scss'
function ExportPage() {
const location = useLocation()
const navigate = useNavigate()
const isExportRoute = location.pathname === '/export'
const [selectedSessionIds, setSelectedSessionIds] = useState<Set<string>>(new Set())
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false)
const [isAutomationModalOpen, setIsAutomationModalOpen] = useState(false)
const [draftAutomationPayload, setDraftAutomationPayload] = useState<any>(null)
const [pendingSingleExportRequest, setPendingSingleExportRequest] = useState<OpenSingleExportPayload | null>(null)
const {
isLoaded: isConfigLoaded,
options,
updateOptions,
exportPath,
@@ -63,6 +71,7 @@ function ExportPage() {
const { metricsMap, fetchMetrics } = useSessionMetrics()
const {
sessions,
filteredSessions,
isLoading: isSessionsLoading,
activeTab,
@@ -115,6 +124,13 @@ function ExportPage() {
// ── 4. Dialog & Exports ──
const { dialogState, openDialog, closeDialog } = useExportDialog()
const resolveSingleExportName = useCallback((payload: OpenSingleExportPayload) => {
const sessionId = String(payload.sessionId || '').trim()
const payloadName = typeof payload.sessionName === 'string' ? payload.sessionName.trim() : ''
const sessionRow = sessions.find(s => s.username === sessionId)
return payloadName || sessionRow?.displayName || sessionRow?.remark || sessionRow?.nickname || sessionId
}, [sessions])
const handleExportDefaultsChanged = useCallback((patch: ExportDefaultsSettingsPatch) => {
updateOptions({
...(patch.format ? { defaultFormat: patch.format as any } : {}),
@@ -171,6 +187,58 @@ function ExportPage() {
})
}, [displayedSessions, options.displayNamePreference, openDialog])
const handleOpenSingleExportRequest = useCallback((payload: OpenSingleExportPayload) => {
const requestId = typeof payload?.requestId === 'string' ? payload.requestId.trim() : ''
const sessionId = typeof payload?.sessionId === 'string' ? payload.sessionId.trim() : ''
if (!requestId || !sessionId) {
if (requestId) {
emitSingleExportDialogStatus({
requestId,
status: 'failed',
message: '无法打开导出配置:缺少会话信息'
})
}
return
}
emitSingleExportDialogStatus({ requestId, status: 'initializing' })
if (!isConfigLoaded) {
setPendingSingleExportRequest({
...payload,
requestId,
sessionId
})
return
}
const sessionName = resolveSingleExportName(payload)
setSelectedSessionIds(new Set([sessionId]))
setActiveTab(sessionId.includes('@chatroom') ? 'group' : 'private')
navigate('/export')
openDialog({
intent: 'manual',
scope: 'single',
sessionIds: [sessionId],
sessionNames: [sessionName],
title: `导出: ${sessionName}`
})
emitSingleExportDialogStatus({ requestId, status: 'opened' })
}, [isConfigLoaded, navigate, openDialog, resolveSingleExportName, setActiveTab])
useEffect(() => {
return onOpenSingleExport(handleOpenSingleExportRequest)
}, [handleOpenSingleExportRequest])
useEffect(() => {
if (!isConfigLoaded || !pendingSingleExportRequest) return
setPendingSingleExportRequest(null)
handleOpenSingleExportRequest(pendingSingleExportRequest)
}, [handleOpenSingleExportRequest, isConfigLoaded, pendingSingleExportRequest])
const handleConfirmExport = useCallback((finalOptions: any) => {
startTask({
sessionIds: dialogState.sessionIds,
@@ -326,7 +326,7 @@
}
}
.file-size-limit, .concurrency-control, .path-style-control {
.file-size-limit, .concurrency-control, .path-style-control, .conflict-strategy-control {
margin-top: 16px;
display: flex;
align-items: center;
@@ -354,7 +354,7 @@
}
}
.path-style-control {
.path-style-control, .conflict-strategy-control {
flex: 1 1 100%;
margin-top: 4px;
@@ -362,7 +362,7 @@
flex: 0 0 auto;
}
.path-style-segmented {
.path-style-segmented, .conflict-strategy-segmented {
display: inline-flex;
min-height: 34px;
padding: 3px;
@@ -21,7 +21,7 @@ import {
type ExportDefaultDateRangeConfig
} from '../../../../utils/exportDateRange'
import type { ExportDialogState, ExportOptions, TextExportFormat } from '../../types'
import { formatOptions, MAX_EXPORT_FILE_SIZE_MB_LIMIT } from '../../constants'
import { conflictStrategyOptions, formatOptions, MAX_EXPORT_FILE_SIZE_MB_LIMIT } from '../../constants'
import { formatPathBrief } from '../../utils/format'
import './ExportDialog.scss'
@@ -282,6 +282,23 @@ const ExportDialog: React.FC<ExportDialogProps> = ({
</div>
</div>
<div className="conflict-strategy-control">
<span>同名文件:</span>
<div className="conflict-strategy-segmented" title="控制导出目标已有同名文件时的处理方式">
{conflictStrategyOptions.map(item => (
<button
key={item.value}
type="button"
className={options.exportConflictStrategy === item.value ? 'active' : ''}
title={item.desc}
onClick={() => onOptionsChange({ exportConflictStrategy: item.value })}
>
{item.label}
</button>
))}
</div>
</div>
<div className="concurrency-control">
<span>导出并发数:</span>
<select
+9
View File
@@ -7,6 +7,7 @@ import type {
ContentType,
ConversationTab,
DisplayNamePreference,
ExportConflictStrategy,
TextExportFormat,
TaskProgress,
ContactsSortConfig
@@ -45,6 +46,7 @@ export const formatOptions: Array<{ value: TextExportFormat; label: string; desc
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'markdown', label: 'Markdown', desc: '支持文本、图片与链接,适合 AI 场景' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' },
@@ -59,6 +61,12 @@ export const displayNameOptions: Array<{ value: DisplayNamePreference; label: st
{ value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' }
]
export const conflictStrategyOptions: Array<{ value: ExportConflictStrategy; label: string; desc: string }> = [
{ value: 'incremental', label: '增量跳过', desc: '复用已有同名媒体;聊天文件有变化时更新原文件' },
{ value: 'overwrite', label: '全量覆盖', desc: '同名文件直接替换,适合强制刷新导出结果' },
{ value: 'rename', label: '保留副本', desc: '同名导出追加序号,保留旧版本' }
]
// ─── Write layout options ────────────────────────────────────
export const writeLayoutOptions: Array<{ value: ExportWriteLayout; label: string; desc: string }> = [
@@ -163,6 +171,7 @@ export const createDefaultExportOptions = (): import('./types').ExportOptions =>
maxFileSizeMb: 200,
exportVoiceAsText: false,
exportPathStyle: 'auto',
exportConflictStrategy: 'incremental',
excelCompactColumns: true,
txtColumns: defaultTxtColumns,
displayNamePreference: 'remark',
+19 -5
View File
@@ -11,6 +11,7 @@ import { createEmptyProgress } from '../constants'
import { useExportTaskStore } from '../../../stores/exportTaskStore'
import { buildProgressPayloadSignature } from '../utils/progress'
import { resolvePerfStageByPhase, applyProgressToTaskPerformance } from '../utils/performance'
import { emitExportSessionStatus, onExportSessionStatusRequest } from '../../../services/exportBridge'
import type { ExportProgress } from '../types'
export interface ExportTasksResult {
@@ -32,19 +33,32 @@ export function useExportTasks(): ExportTasksResult {
const { setSessionStatus } = useExportTaskStore()
// Track the ongoing tasks to update the global zustand store badge
useEffect(() => {
const publishSessionStatus = useCallback(() => {
const activeTasks = tasksRef.current.filter(t => t.status === 'running' || t.status === 'cancel_requested')
const inProgressSessionIds = new Set<string>()
activeTasks.forEach(task => {
task.payload.sessionIds.forEach(id => inProgressSessionIds.add(id))
})
setSessionStatus({
const payload = {
activeTaskCount: activeTasks.length,
inProgressSessionIds: Array.from(inProgressSessionIds)
})
}, [tasks, setSessionStatus])
}
setSessionStatus(payload)
emitExportSessionStatus(payload)
}, [setSessionStatus])
// Track the ongoing tasks to update the global zustand store badge
useEffect(() => {
publishSessionStatus()
}, [tasks, publishSessionStatus])
useEffect(() => {
const unsubscribe = onExportSessionStatusRequest(publishSessionStatus)
publishSessionStatus()
return unsubscribe
}, [publishSessionStatus])
const updateTask = useCallback((taskId: string, updater: (prev: ExportTask) => ExportTask) => {
setTasks(prev => prev.map(t => t.id === taskId ? updater(t) : t))
+4
View File
@@ -30,12 +30,15 @@ export type SessionLayout = 'shared' | 'per-session'
export type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname'
export type ExportConflictStrategy = 'incremental' | 'overwrite' | 'rename'
export type TextExportFormat =
| 'chatlab'
| 'chatlab-jsonl'
| 'json'
| 'arkme-json'
| 'html'
| 'markdown'
| 'txt'
| 'excel'
| 'weclone'
@@ -67,6 +70,7 @@ export interface ExportOptions {
maxFileSizeMb: number
exportVoiceAsText: boolean
exportPathStyle: 'auto' | 'posix' | 'windows'
exportConflictStrategy: ExportConflictStrategy
excelCompactColumns: boolean
txtColumns: string[]
displayNamePreference: DisplayNamePreference
+3 -2
View File
@@ -38,7 +38,7 @@ interface GroupMessageRank {
}
type AnalysisFunction = 'members' | 'memberMessages' | 'memberAnalytics' | 'ranking' | 'activeHours' | 'mediaStats'
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'markdown' | 'txt' | 'excel' | 'weclone'
interface MemberMessageExportOptions {
format: MemberExportFormat
@@ -234,6 +234,7 @@ function GroupAnalyticsPage() {
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'markdown', label: 'Markdown', desc: '支持文本、图片与链接,适合 AI 场景' },
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' }
]), [])
const displayNameOptions = useMemo<Array<{
@@ -886,7 +887,7 @@ function GroupAnalyticsPage() {
const handleMemberExportFormatChange = (format: MemberExportFormat) => {
setMemberExportOptions(prev => {
const next = { ...prev, format }
if (format === 'html') {
if (format === 'html' || format === 'markdown') {
return {
...next,
exportMedia: true,
+36 -2
View File
@@ -209,6 +209,7 @@
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
.stream-grid {
@@ -389,6 +390,19 @@
color: var(--text-tertiary);
padding: 12px;
text-align: center;
position: relative;
overflow: hidden;
background: color-mix(in srgb, var(--bg-tertiary) 72%, transparent);
svg,
span {
position: relative;
z-index: 1;
}
svg {
animation: decrypt-pulse 1.4s ease-in-out infinite;
}
span {
font-size: 12px;
@@ -415,8 +429,9 @@
content: '';
position: absolute;
inset: -40%;
background: none;
animation: none;
background:
linear-gradient(110deg, transparent 38%, rgba(255, 255, 255, 0.38) 50%, transparent 62%);
animation: decrypt-sheen 1.1s ease-in-out infinite;
pointer-events: none;
}
}
@@ -488,6 +503,25 @@
color: var(--text-tertiary);
}
.grid-refreshing {
position: absolute;
top: 10px;
right: 10px;
z-index: 5;
min-height: 32px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color));
background: color-mix(in srgb, var(--bg-secondary) 92%, transparent);
color: var(--text-secondary);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
pointer-events: none;
}
.spin {
animation: resources-spin 1s linear infinite;
}
+1882 -580
View File
@@ -1,51 +1,46 @@
import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react'
import { forwardRef, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type HTMLAttributes, type MutableRefObject } from 'react'
import { Calendar, Image as ImageIcon, Info, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react'
import { VirtuosoGrid } from 'react-virtuoso'
import { finishBackgroundTask, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor'
import {
BATCH_IMAGE_DECRYPT_CONCURRENCY,
BATCH_IMAGE_DECRYPT_YIELD_MS,
BATCH_IMAGE_HARDLINK_PRELOAD_CHUNK_SIZE,
BATCH_IMAGE_HARDLINK_PRELOAD_YIELD_MS,
IMAGE_PREDECRYPT_LOOKAHEAD,
IMAGE_PREDECRYPT_IDLE_DELAY_MS,
IMAGE_PREDECRYPT_TIMER_MS,
INITIAL_IMAGE_PREDECRYPT_END,
INITIAL_IMAGE_PRELOAD_END,
INITIAL_IMAGE_RESOLVE_END,
MAX_IMAGE_CACHE_PRELOAD_PER_TICK,
MAX_IMAGE_CACHE_RESOLVE_PER_TICK,
MAX_IMAGE_PREDECRYPT_PER_TICK,
MAX_MEDIA_PATCHES_PER_FLUSH,
MAX_VIDEO_POSTER_RESOLVE_PER_TICK,
PAGE_SIZE,
TASK_PROGRESS_UPDATE_MAX_STEPS,
TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS,
extractVideoMd5,
extractVideoTitle,
formatInfoValue,
formatTimeLabel,
getItemKey,
getRangeTimestampEnd,
getRangeTimestampStart,
getSafeImageDatName,
hasImageLocator,
normalizeMediaToken,
toRenderableMediaSrc,
type ContactOption,
type DialogState,
type ImagePreloadPayload,
type MediaStreamItem,
type MediaTab
} from './ResourcesPage.utils'
import './ResourcesPage.scss'
type MediaTab = 'image' | 'video'
interface MediaStreamItem {
sessionId: string
sessionDisplayName?: string
mediaType: 'image' | 'video'
localId: number
serverId?: string
createTime: number
localType: number
senderUsername?: string
isSend?: number | null
imageMd5?: string
imageDatName?: string
videoMd5?: string
content?: string
}
interface ContactOption {
id: string
name: string
}
type DialogState = {
mode: 'alert' | 'confirm' | 'info'
title: string
message?: string
infoRows?: Array<{ label: string; value: string }>
confirmText?: string
cancelText?: string
onConfirm?: (() => void) | null
}
const PAGE_SIZE = 96
const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 12
const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 24
const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 3
const INITIAL_IMAGE_PRELOAD_END = 48
const INITIAL_IMAGE_RESOLVE_END = 12
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
const BATCH_IMAGE_DECRYPT_CONCURRENCY = 8
const waitForBatchDecryptYield = () => new Promise<void>((resolve) => window.setTimeout(resolve, BATCH_IMAGE_DECRYPT_YIELD_MS))
const GridList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function GridList(props, ref) {
const { className = '', ...rest } = props
@@ -53,120 +48,178 @@ const GridList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(func
return <div ref={ref} className={mergedClassName} {...rest} />
})
function GridItem(props: HTMLAttributes<HTMLDivElement>) {
const GridItem = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function GridItem(props, ref) {
const { className = '', ...rest } = props
const mergedClassName = ['stream-grid-item', className].filter(Boolean).join(' ')
return <div className={mergedClassName} {...rest} />
return <div ref={ref} className={mergedClassName} {...rest} />
})
const GRID_COMPONENTS = {
List: GridList,
Item: GridItem
}
function getRangeTimestampStart(date: string): number | undefined {
if (!date) return undefined
const parsed = new Date(`${date}T00:00:00`)
const n = Math.floor(parsed.getTime() / 1000)
return Number.isFinite(n) ? n : undefined
type MediaItemMeta = {
itemKey: string
imageMd5: string
imageDatName: string
imageIdentity: string
videoMd5: string
videoTitle: string
hasImageLocator: boolean
}
function getRangeTimestampEnd(date: string): number | undefined {
if (!date) return undefined
const parsed = new Date(`${date}T23:59:59`)
const n = Math.floor(parsed.getTime() / 1000)
return Number.isFinite(n) ? n : undefined
type ResourceRuntimeCounters = {
cacheResolvedEvents: number
cacheResolvedPatchedItems: number
cacheResolvedUnmatchedEvents: number
imageResolveStarted: number
imageResolveCompleted: number
imageResolveSuperseded: number
imageResolveIdentityMissSkipped: number
imageResolveMisses: number
imageResolveContainedSkips: number
imageResolvePendingMerges: number
imageResolvePendingReplacements: number
imagePreloadRequests: number
imagePreloadRejectedCapacity: number
imagePreloadSubmittingSkips: number
imagePredecryptRequests: number
imagePredecryptBackpressureSkips: number
imagePredecryptRejectedCapacity: number
imagePredecryptDeferred: number
imagePredecryptSubmittingSkips: number
imagePredecryptPreviewUpgrades: number
loadMoreRequests: number
loadMoreSkipped: number
loadResets: number
mediaStreamLoadSamples: number
mediaStreamLoadMsTotal: number
mediaStreamMaxLoadMs: number
mediaStreamPageCacheHits: number
mediaStreamInflightMerges: number
mediaStreamNativeLoads: number
mediaStreamRowsLoaded: number
mediaStreamDuplicateRows: number
mediaStreamNoProgressStops: number
previewPatchesQueued: number
rangeFlushes: number
rangeSchedules: number
rangeDuplicateSkips: number
rangeHiddenSkips: number
rangeVisibilityReschedules: number
rangeContainedSkips: number
rangePendingMerges: number
rangePendingReplacements: number
predecryptFlushes: number
predecryptContainedSkips: number
predecryptHiddenSkips: number
predecryptPendingUpdates: number
predecryptPendingReplacements: number
predecryptSchedules: number
transientStatePruneRuns: number
videoPosterBatches: number
videoPosterMd5CacheHits: number
videoPosterMd5MissSkips: number
videoPosterContainedSkips: number
videoPosterPendingMerges: number
videoPosterPendingReplacements: number
}
function normalizeMediaToken(value?: string): string {
return String(value || '').trim().toLowerCase()
type ResourceFrameMetrics = {
samples: number
longFrames: number
maxFrameMs: number
lastFrameMs: number
}
function getSafeImageDatName(item: Pick<MediaStreamItem, 'imageDatName' | 'imageMd5'>): string {
const datName = normalizeMediaToken(item.imageDatName)
if (!datName) return ''
return datName
}
const createResourceRuntimeCounters = (): ResourceRuntimeCounters => ({
cacheResolvedEvents: 0,
cacheResolvedPatchedItems: 0,
cacheResolvedUnmatchedEvents: 0,
imageResolveStarted: 0,
imageResolveCompleted: 0,
imageResolveSuperseded: 0,
imageResolveIdentityMissSkipped: 0,
imageResolveMisses: 0,
imageResolveContainedSkips: 0,
imageResolvePendingMerges: 0,
imageResolvePendingReplacements: 0,
imagePreloadRequests: 0,
imagePreloadRejectedCapacity: 0,
imagePreloadSubmittingSkips: 0,
imagePredecryptRequests: 0,
imagePredecryptBackpressureSkips: 0,
imagePredecryptRejectedCapacity: 0,
imagePredecryptDeferred: 0,
imagePredecryptSubmittingSkips: 0,
imagePredecryptPreviewUpgrades: 0,
loadMoreRequests: 0,
loadMoreSkipped: 0,
loadResets: 0,
mediaStreamLoadSamples: 0,
mediaStreamLoadMsTotal: 0,
mediaStreamMaxLoadMs: 0,
mediaStreamPageCacheHits: 0,
mediaStreamInflightMerges: 0,
mediaStreamNativeLoads: 0,
mediaStreamRowsLoaded: 0,
mediaStreamDuplicateRows: 0,
mediaStreamNoProgressStops: 0,
previewPatchesQueued: 0,
rangeFlushes: 0,
rangeSchedules: 0,
rangeDuplicateSkips: 0,
rangeHiddenSkips: 0,
rangeVisibilityReschedules: 0,
rangeContainedSkips: 0,
rangePendingMerges: 0,
rangePendingReplacements: 0,
predecryptFlushes: 0,
predecryptContainedSkips: 0,
predecryptHiddenSkips: 0,
predecryptPendingUpdates: 0,
predecryptPendingReplacements: 0,
predecryptSchedules: 0,
transientStatePruneRuns: 0,
videoPosterBatches: 0,
videoPosterMd5CacheHits: 0,
videoPosterMd5MissSkips: 0,
videoPosterContainedSkips: 0,
videoPosterPendingMerges: 0,
videoPosterPendingReplacements: 0
})
function hasImageLocator(item: Pick<MediaStreamItem, 'imageDatName' | 'imageMd5'>): boolean {
return Boolean(normalizeMediaToken(item.imageMd5) || getSafeImageDatName(item))
}
const createResourceFrameMetrics = (): ResourceFrameMetrics => ({
samples: 0,
longFrames: 0,
maxFrameMs: 0,
lastFrameMs: 0
})
function getItemKey(item: MediaStreamItem): string {
const sessionId = String(item.sessionId || '').trim().toLowerCase()
const localId = Number(item.localId || 0)
if (sessionId && Number.isFinite(localId) && localId > 0) {
return `${sessionId}|${localId}`
type ResourcePreloadStatsSnapshot = {
queuedDecrypt?: number
queuedLow?: number
activeDecrypt?: number
highWater?: {
queuedDecrypt?: number
queuedLow?: number
activeDecrypt?: number
}
const serverId = String(item.serverId || '').trim().toLowerCase()
const createTime = Number(item.createTime || 0)
const localType = Number(item.localType || 0)
const mediaId = String(
item.mediaType === 'video'
? (item.videoMd5 || '')
: (item.imageMd5 || getSafeImageDatName(item) || '')
).trim().toLowerCase()
return `${sessionId}|${createTime}|${localType}|${serverId}|${mediaId}`
}
function formatTimeLabel(timestampSec: number): string {
if (!timestampSec) return '--:--'
return new Date(timestampSec * 1000).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
type ImagePreloadCandidate = {
payload: ImagePreloadPayload
itemKey: string
imageIdentity: string
previewUpgrade: boolean
}
function formatInfoValue(value: unknown): string {
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
function extractVideoTitle(content?: string): string {
const xml = String(content || '')
if (!xml) return '视频'
const match = /<title>([\s\S]*?)<\/title>/i.exec(xml)
const text = String(match?.[1] || '').replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
return text || '视频'
}
function toRenderableMediaSrc(rawPath?: string): string {
const src = String(rawPath || '').trim()
if (!src) return ''
if (/^(data:image\/|blob:|https?:\/\/)/i.test(src)) {
return src
}
if (/^file:\/\//i.test(src)) {
return src.replace(/#/g, '%23')
}
if (src.startsWith('/')) {
return encodeURI(`file://${src}`).replace(/#/g, '%23')
}
if (/^[a-zA-Z]:[\\/]/.test(src)) {
return encodeURI(`file:///${src.replace(/\\/g, '/')}`).replace(/#/g, '%23')
}
return encodeURI(`file://${src.startsWith('/') ? '' : '/'}${src.replace(/\\/g, '/')}`).replace(/#/g, '%23')
}
const MediaCard = memo(function MediaCard({
item,
sessionName,
previewPath,
videoPosterPath,
imageIsLong,
hasPreviewUpdate,
selected,
decrypting,
onToggleSelect,
onDelete,
onShowInfo,
onImagePreviewAction,
onUpdateImageQuality,
onOpenVideo,
onImageLoaded
}: {
type MediaCardProps = {
item: MediaStreamItem
itemKey: string
sessionName: string
videoTitle: string
previewPath: string
videoPosterPath: string
imageIsLong: boolean
@@ -180,10 +233,291 @@ const MediaCard = memo(function MediaCard({
onUpdateImageQuality: (item: MediaStreamItem) => void
onOpenVideo: (item: MediaStreamItem) => void
onImageLoaded: (item: MediaStreamItem, width: number, height: number) => void
}) {
}
type MediaCardDynamicState = Pick<
MediaCardProps,
'previewPath' | 'videoPosterPath' | 'imageIsLong' | 'hasPreviewUpdate' | 'selected' | 'decrypting'
>
type MediaCardStore = {
getSnapshot: (itemKey: string) => MediaCardDynamicState
subscribe: (itemKey: string, listener: () => void) => () => void
update: (itemKey: string, patch: Partial<MediaCardDynamicState>) => void
remove: (itemKey: string) => void
pruneTransient: (keepKeys: Set<string>) => void
clear: () => void
}
type MediaCardContainerProps = Omit<MediaCardProps, keyof MediaCardDynamicState> & {
stateStore: MediaCardStore
}
const EMPTY_MEDIA_CARD_STATE: MediaCardDynamicState = {
previewPath: '',
videoPosterPath: '',
imageIsLong: false,
hasPreviewUpdate: false,
selected: false,
decrypting: false
}
function areMediaCardStatesEqual(prev: MediaCardDynamicState, next: MediaCardDynamicState): boolean {
return (
prev.previewPath === next.previewPath &&
prev.videoPosterPath === next.videoPosterPath &&
prev.imageIsLong === next.imageIsLong &&
prev.hasPreviewUpdate === next.hasPreviewUpdate &&
prev.selected === next.selected &&
prev.decrypting === next.decrypting
)
}
function createMediaCardStore(): MediaCardStore {
const snapshots = new Map<string, MediaCardDynamicState>()
const listeners = new Map<string, Set<() => void>>()
const notify = (itemKey: string) => {
const itemListeners = listeners.get(itemKey)
if (!itemListeners || itemListeners.size === 0) return
for (const listener of Array.from(itemListeners)) listener()
}
return {
getSnapshot: (itemKey: string) => snapshots.get(itemKey) || EMPTY_MEDIA_CARD_STATE,
subscribe: (itemKey: string, listener: () => void) => {
const itemListeners = listeners.get(itemKey) || new Set<() => void>()
itemListeners.add(listener)
listeners.set(itemKey, itemListeners)
return () => {
itemListeners.delete(listener)
if (itemListeners.size === 0) listeners.delete(itemKey)
}
},
update: (itemKey: string, patch: Partial<MediaCardDynamicState>) => {
if (!itemKey) return
const previous = snapshots.get(itemKey) || EMPTY_MEDIA_CARD_STATE
const next = { ...previous, ...patch }
if (areMediaCardStatesEqual(previous, next)) return
snapshots.set(itemKey, next)
notify(itemKey)
},
remove: (itemKey: string) => {
if (!snapshots.delete(itemKey)) return
notify(itemKey)
},
pruneTransient: (keepKeys: Set<string>) => {
for (const [itemKey, previous] of snapshots.entries()) {
if (keepKeys.has(itemKey)) continue
const next: MediaCardDynamicState = {
...EMPTY_MEDIA_CARD_STATE,
selected: previous.selected,
decrypting: previous.decrypting
}
if (areMediaCardStatesEqual(previous, next)) continue
if (next.selected || next.decrypting) {
snapshots.set(itemKey, next)
} else {
snapshots.delete(itemKey)
}
notify(itemKey)
}
},
clear: () => {
if (snapshots.size === 0) return
const keys = Array.from(snapshots.keys())
snapshots.clear()
keys.forEach(notify)
}
}
}
function buildMediaItemMeta(item: MediaStreamItem): MediaItemMeta {
const imageMd5 = item.mediaType === 'image' ? normalizeMediaToken(item.imageMd5) : ''
const imageDatName = item.mediaType === 'image' ? getSafeImageDatName(item) : ''
const videoMd5 = item.mediaType === 'video'
? (normalizeMediaToken(item.videoMd5) || extractVideoMd5(item.content))
: ''
const videoTitle = item.mediaType === 'video' ? extractVideoTitle(item.content) : ''
const sessionId = String(item.sessionId || '').trim().toLowerCase()
const createTime = Number(item.createTime || 0) || 0
return {
itemKey: getItemKey(item),
imageMd5,
imageDatName,
imageIdentity: imageMd5 ? `md5:${imageMd5}` : (imageDatName ? `dat:${sessionId}|${createTime}|${imageDatName}` : ''),
videoMd5,
videoTitle,
hasImageLocator: Boolean(imageMd5 || imageDatName)
}
}
function mergeRecordPatch<T extends string | number | boolean>(prev: Record<string, T>, patch: Record<string, T>): Record<string, T> {
let next: Record<string, T> | null = null
for (const key in patch) {
const value = patch[key]
if (prev[key] === value) continue
if (!next) next = { ...prev }
next[key] = value
}
return next || prev
}
function removeRecordKeys<T extends string | number | boolean>(prev: Record<string, T>, keys: Set<string>): Record<string, T> {
let next: Record<string, T> | null = null
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(prev, key)) continue
if (!next) next = { ...prev }
delete next[key]
}
return next || prev
}
function pruneRecordToKeys<T extends string | number | boolean>(prev: Record<string, T>, keepKeys: Set<string>): Record<string, T> {
let next: Record<string, T> | null = null
for (const key in prev) {
if (keepKeys.has(key)) continue
if (!next) next = { ...prev }
delete next[key]
}
return next || prev
}
function removeItemsByKeys(
prev: MediaStreamItem[],
keys: Set<string>,
getMeta: (item: MediaStreamItem) => MediaItemMeta
): MediaStreamItem[] {
if (keys.size === 0 || prev.length === 0) return prev
let removed = false
const next = prev.filter((item) => {
const keep = !keys.has(getMeta(item).itemKey)
if (!keep) removed = true
return keep
})
return removed ? next : prev
}
function clearArray<T>(prev: T[]): T[] {
return prev.length === 0 ? prev : []
}
function clearSet<T>(prev: Set<T>): Set<T> {
return prev.size === 0 ? prev : new Set()
}
function clearRecord<T extends string | number | boolean>(prev: Record<string, T>): Record<string, T> {
return hasRecordEntries(prev) ? {} : prev
}
function addSetValue<T>(prev: Set<T>, value: T): Set<T> {
if (prev.has(value)) return prev
const next = new Set(prev)
next.add(value)
return next
}
function removeSetValue<T>(prev: Set<T>, value: T): Set<T> {
if (!prev.has(value)) return prev
const next = new Set(prev)
next.delete(value)
return next
}
function hasRecordEntries<T>(record: Record<string, T>): boolean {
for (const key in record) {
if (Object.prototype.hasOwnProperty.call(record, key)) return true
}
return false
}
function mergeRange(
current: { start: number; end: number } | null,
next: { start: number; end: number }
): { start: number; end: number } {
if (!current) return next
return {
start: Math.min(current.start, next.start),
end: Math.max(current.end, next.end)
}
}
function mergeNearbyRange(
current: { start: number; end: number } | null,
next: { start: number; end: number },
maxGap: number
): { range: { start: number; end: number }; merged: boolean } {
if (!current) return { range: next, merged: false }
if (next.start > current.end + maxGap || current.start > next.end + maxGap) {
return { range: next, merged: false }
}
return { range: mergeRange(current, next), merged: true }
}
function areMediaItemsEquivalent(prev: MediaStreamItem, next: MediaStreamItem): boolean {
if (prev === next) return true
return (
prev.mediaType === next.mediaType &&
prev.createTime === next.createTime &&
prev.senderUsername === next.senderUsername &&
prev.sessionId === next.sessionId &&
prev.sessionDisplayName === next.sessionDisplayName &&
prev.localId === next.localId &&
prev.serverId === next.serverId &&
prev.localType === next.localType &&
prev.isSend === next.isSend &&
prev.imageMd5 === next.imageMd5 &&
prev.imageDatName === next.imageDatName &&
prev.videoMd5 === next.videoMd5 &&
prev.content === next.content
)
}
function areMediaCardPropsEqual(prev: MediaCardProps, next: MediaCardProps): boolean {
return (
prev.itemKey === next.itemKey &&
areMediaItemsEquivalent(prev.item, next.item) &&
prev.sessionName === next.sessionName &&
prev.videoTitle === next.videoTitle &&
prev.previewPath === next.previewPath &&
prev.videoPosterPath === next.videoPosterPath &&
prev.imageIsLong === next.imageIsLong &&
prev.hasPreviewUpdate === next.hasPreviewUpdate &&
prev.selected === next.selected &&
prev.decrypting === next.decrypting &&
prev.onToggleSelect === next.onToggleSelect &&
prev.onDelete === next.onDelete &&
prev.onShowInfo === next.onShowInfo &&
prev.onImagePreviewAction === next.onImagePreviewAction &&
prev.onUpdateImageQuality === next.onUpdateImageQuality &&
prev.onOpenVideo === next.onOpenVideo &&
prev.onImageLoaded === next.onImageLoaded
)
}
const MediaCard = memo(function MediaCard({
item,
sessionName,
videoTitle,
previewPath,
videoPosterPath,
imageIsLong,
hasPreviewUpdate,
selected,
decrypting,
onToggleSelect,
onDelete,
onShowInfo,
onImagePreviewAction,
onUpdateImageQuality,
onOpenVideo,
onImageLoaded
}: MediaCardProps) {
const isImage = item.mediaType === 'image'
const isDecryptingVisual = decrypting
const showDecryptOverlay = isImage && isDecryptingVisual
const previewSrc = useMemo(() => previewPath ? toRenderableMediaSrc(previewPath) : '', [previewPath])
const videoPosterSrc = useMemo(() => videoPosterPath ? toRenderableMediaSrc(videoPosterPath) : '', [videoPosterPath])
const timeLabel = useMemo(() => formatTimeLabel(item.createTime), [item.createTime])
return (
<article className={`media-card ${selected ? 'selected' : ''} ${isDecryptingVisual ? 'decrypting' : ''}`}>
@@ -223,7 +557,7 @@ const MediaCard = memo(function MediaCard({
{isImage ? (
previewPath
? <img
src={toRenderableMediaSrc(previewPath)}
src={previewSrc}
alt="图片资源"
className={imageIsLong ? 'long-image' : ''}
loading="lazy"
@@ -237,10 +571,10 @@ const MediaCard = memo(function MediaCard({
: <div className="placeholder"><ImageIcon size={30} /></div>
) : (
videoPosterPath
? <img src={toRenderableMediaSrc(videoPosterPath)} alt="视频封面" loading="lazy" decoding="async" />
? <img src={videoPosterSrc} alt="视频封面" loading="lazy" decoding="async" />
: <div className="placeholder">
<PlayCircle size={34} />
<span>{extractVideoTitle(item.content)}</span>
<span>{videoTitle}</span>
</div>
)}
{showDecryptOverlay && (
@@ -253,7 +587,7 @@ const MediaCard = memo(function MediaCard({
<div className="card-meta" onClick={() => onToggleSelect(item)}>
<div className="title-row">
<span className="session" title={sessionName}>{sessionName}</span>
<span className="time">{formatTimeLabel(item.createTime)}</span>
<span className="time">{timeLabel}</span>
</div>
<div className="sub-row">
<span>{item.mediaType === 'image' ? '图片' : '视频'}</span>
@@ -262,6 +596,30 @@ const MediaCard = memo(function MediaCard({
</div>
</article>
)
}, areMediaCardPropsEqual)
const MediaCardContainer = memo(function MediaCardContainer({
stateStore,
itemKey,
...props
}: MediaCardContainerProps) {
const state = useSyncExternalStore(
useCallback((listener) => stateStore.subscribe(itemKey, listener), [itemKey, stateStore]),
useCallback(() => stateStore.getSnapshot(itemKey), [itemKey, stateStore]),
() => EMPTY_MEDIA_CARD_STATE
)
return (
<MediaCard
{...props}
itemKey={itemKey}
previewPath={state.previewPath}
videoPosterPath={state.videoPosterPath}
imageIsLong={state.imageIsLong}
hasPreviewUpdate={state.hasPreviewUpdate}
selected={state.selected}
decrypting={state.decrypting}
/>
)
})
function ResourcesPage() {
@@ -279,26 +637,37 @@ function ResourcesPage() {
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState('')
const [batchBusy, setBatchBusy] = useState(false)
const [decryptingKeys, setDecryptingKeys] = useState<Set<string>>(new Set())
const [actionMessage, setActionMessage] = useState('')
const [sessionNameMap, setSessionNameMap] = useState<Record<string, string>>({})
const [previewPathMap, setPreviewPathMap] = useState<Record<string, string>>({})
const [previewUpdateMap, setPreviewUpdateMap] = useState<Record<string, boolean>>({})
const [videoPosterMap, setVideoPosterMap] = useState<Record<string, string>>({})
const [imageAspectMap, setImageAspectMap] = useState<Record<string, number>>({})
const [dialog, setDialog] = useState<DialogState | null>(null)
const previewPathMapRef = useRef<Record<string, string>>({})
const previewUpdateMapRef = useRef<Record<string, boolean>>({})
const videoPosterMapRef = useRef<Record<string, string>>({})
const imageAspectMapRef = useRef<Record<string, number>>({})
const selectedKeysRef = useRef<Set<string>>(new Set())
const decryptingKeysRef = useRef<Set<string>>(new Set())
const mediaCardStateStoreRef = useRef<MediaCardStore>(createMediaCardStore())
const imageResolveBatchIdRef = useRef(0)
const resolvingImageCacheBatchRef = useRef(false)
const pendingImageResolveRangeRef = useRef<{ start: number; end: number } | null>(null)
const imagePreloadUntilRef = useRef<Record<string, number>>({})
const imagePredecryptUntilRef = useRef<Record<string, number>>({})
const imagePreloadIdentityUntilRef = useRef<Record<string, number>>({})
const imagePredecryptIdentityUntilRef = useRef<Record<string, number>>({})
const imagePreloadSubmittingIdentityRef = useRef<Set<string>>(new Set())
const imagePredecryptSubmittingIdentityRef = useRef<Set<string>>(new Set())
const preloadSubmissionEpochRef = useRef(0)
const imageCacheMissUntilRef = useRef<Record<string, number>>({})
const imageCacheIdentityMissUntilRef = useRef<Record<string, number>>({})
const videoPosterBatchIdRef = useRef(0)
const resolvingVideoPosterBatchRef = useRef(false)
const pendingVideoPosterRangeRef = useRef<{ start: number; end: number } | null>(null)
const resolvingVideoPosterKeysRef = useRef<Set<string>>(new Set())
const attemptedVideoPosterKeysRef = useRef<Set<string>>(new Set())
const resolvedVideoMd5Ref = useRef<Record<string, string>>({})
const resolvingVideoPosterMd5Ref = useRef<Set<string>>(new Set())
const attemptedVideoPosterMd5Ref = useRef<Set<string>>(new Set())
const videoPosterByMd5Ref = useRef<Record<string, string>>({})
const previewPatchRef = useRef<Record<string, string>>({})
const updatePatchRef = useRef<Record<string, boolean>>({})
const previewPatchTimerRef = useRef<number | null>(null)
@@ -307,25 +676,323 @@ function ResourcesPage() {
const aspectPatchRef = useRef<Record<string, number>>({})
const aspectPatchTimerRef = useRef<number | null>(null)
const pendingRangeRef = useRef<{ start: number; end: number } | null>(null)
const lastVisibleRangeRef = useRef<{ start: number; end: number } | null>(null)
const lastRangeActivityAtRef = useRef(0)
const rangeTimerRef = useRef<number | null>(null)
const transientStatePruneTimerRef = useRef<number | null>(null)
const pendingTransientPruneRangeRef = useRef<{ start: number; end: number } | null>(null)
const pendingPredecryptRangeRef = useRef<{ start: number; end: number } | null>(null)
const predecryptTimerRef = useRef<number | null>(null)
const streamRequestIdRef = useRef(0)
const loadingRef = useRef(false)
const loadingMoreRef = useRef(false)
const nextOffsetRef = useRef(0)
const hasMoreRef = useRef(false)
const preloadScopeRef = useRef('resources-initial')
const initialRangeScheduleKeyRef = useRef('')
const itemMetaMapRef = useRef<WeakMap<MediaStreamItem, MediaItemMeta>>(new WeakMap())
const itemByKeyRef = useRef<Record<string, MediaStreamItem>>({})
const itemMetaByKeyRef = useRef<Record<string, MediaItemMeta>>({})
const loadedItemKeysRef = useRef<Set<string>>(new Set())
const validItemKeysRef = useRef<Set<string>>(new Set())
const imageItemKeysRef = useRef<Set<string>>(new Set())
const videoItemKeysRef = useRef<Set<string>>(new Set())
const imageIdentityKeysRef = useRef<Set<string>>(new Set())
const imageIdentityItemKeyMapRef = useRef<Map<string, Set<string>>>(new Map())
const runtimeCountersRef = useRef<ResourceRuntimeCounters>(createResourceRuntimeCounters())
const lastDiagnosticsSampleRef = useRef<ResourceRuntimeCounters>(createResourceRuntimeCounters())
const frameMetricsRef = useRef<ResourceFrameMetrics>(createResourceFrameMetrics())
const lastDiagnosticsFrameMetricsRef = useRef<ResourceFrameMetrics>(createResourceFrameMetrics())
const recentMaxFrameMsRef = useRef(0)
const diagnosticsSamplingRef = useRef(false)
const diagnosticsEpochRef = useRef(0)
const lastPreloadStatsRef = useRef<ResourcePreloadStatsSnapshot | null>(null)
const tabRef = useRef<MediaTab>(tab)
const itemsRef = useRef<MediaStreamItem[]>([])
const itemsLengthRef = useRef(0)
const selectedContactRef = useRef(selectedContact)
const bumpRuntimeCounter = useCallback((key: keyof ResourceRuntimeCounters, amount = 1) => {
runtimeCountersRef.current[key] += amount
}, [])
const resetResourceDiagnosticsWindow = useCallback(() => {
diagnosticsEpochRef.current += 1
lastDiagnosticsSampleRef.current = { ...runtimeCountersRef.current }
lastDiagnosticsFrameMetricsRef.current = { ...frameMetricsRef.current }
recentMaxFrameMsRef.current = 0
void window.electronAPI.diagnostics.clearResourceStats().catch(() => {})
}, [])
useEffect(() => {
previewPathMapRef.current = previewPathMap
}, [previewPathMap])
tabRef.current = tab
}, [tab])
useLayoutEffect(() => {
itemsRef.current = items
itemsLengthRef.current = items.length
}, [items])
useEffect(() => {
previewUpdateMapRef.current = previewUpdateMap
}, [previewUpdateMap])
selectedContactRef.current = selectedContact
}, [selectedContact])
useEffect(() => {
videoPosterMapRef.current = videoPosterMap
}, [videoPosterMap])
loadingRef.current = loading
}, [loading])
useEffect(() => {
imageAspectMapRef.current = imageAspectMap
}, [imageAspectMap])
loadingMoreRef.current = loadingMore
}, [loadingMore])
useEffect(() => () => {
useEffect(() => {
nextOffsetRef.current = nextOffset
}, [nextOffset])
useEffect(() => {
hasMoreRef.current = hasMore
}, [hasMore])
useEffect(() => {
selectedKeysRef.current = selectedKeys
}, [selectedKeys])
useEffect(() => {
let animationFrameId = 0
let lastFrameAt = performance.now()
const scheduleNextFrame = () => {
if (document.visibilityState === 'hidden') return
animationFrameId = window.requestAnimationFrame(tick)
}
const tick = (now: number) => {
const deltaMs = Math.max(0, now - lastFrameAt)
lastFrameAt = now
const roundedDelta = Math.round(deltaMs * 10) / 10
const metrics = frameMetricsRef.current
metrics.samples += 1
metrics.lastFrameMs = roundedDelta
if (roundedDelta > metrics.maxFrameMs) metrics.maxFrameMs = roundedDelta
if (roundedDelta > recentMaxFrameMsRef.current) recentMaxFrameMsRef.current = roundedDelta
if (roundedDelta >= 50) metrics.longFrames += 1
scheduleNextFrame()
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
window.cancelAnimationFrame(animationFrameId)
animationFrameId = 0
return
}
lastFrameAt = performance.now()
if (!animationFrameId) scheduleNextFrame()
}
document.addEventListener('visibilitychange', handleVisibilityChange)
scheduleNextFrame()
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.cancelAnimationFrame(animationFrameId)
}
}, [])
useEffect(() => {
const sample = async () => {
if (document.visibilityState === 'hidden') return
if (diagnosticsSamplingRef.current) return
diagnosticsSamplingRef.current = true
const sampleEpoch = diagnosticsEpochRef.current
try {
const counters = { ...runtimeCountersRef.current }
const previous = lastDiagnosticsSampleRef.current
const delta = Object.fromEntries(
(Object.keys(counters) as Array<keyof ResourceRuntimeCounters>).map((key) => [
key,
counters[key] - previous[key]
])
)
const frameMetrics = { ...frameMetricsRef.current }
const previousFrameMetrics = lastDiagnosticsFrameMetricsRef.current
const frameDelta = {
samples: frameMetrics.samples - previousFrameMetrics.samples,
longFrames: frameMetrics.longFrames - previousFrameMetrics.longFrames,
recentMaxFrameMs: recentMaxFrameMsRef.current,
lastFrameMs: frameMetrics.lastFrameMs
}
const preloadStats = await window.electronAPI.image.getPreloadStats()
if (sampleEpoch !== diagnosticsEpochRef.current) return
lastDiagnosticsSampleRef.current = counters
lastDiagnosticsFrameMetricsRef.current = frameMetrics
recentMaxFrameMsRef.current = 0
lastPreloadStatsRef.current = preloadStats
await window.electronAPI.diagnostics.recordResourceStats({
page: 'resources',
tab: tabRef.current,
selectedContact: selectedContactRef.current,
scope: preloadScopeRef.current,
items: itemsLengthRef.current,
nextOffset: nextOffsetRef.current,
hasMore: hasMoreRef.current,
loading: loadingRef.current,
loadingMore: loadingMoreRef.current,
counters,
delta,
frameMetrics,
frameDelta,
preloadStats
})
} catch {
// diagnostics must never affect browsing
} finally {
diagnosticsSamplingRef.current = false
}
}
const timer = window.setInterval(() => {
void sample()
}, 3000)
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void sample()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
void sample()
return () => {
window.clearInterval(timer)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [])
const getMeta = useCallback((item: MediaStreamItem): MediaItemMeta => {
const cached = itemMetaMapRef.current.get(item)
if (cached) return cached
const meta = buildMediaItemMeta(item)
itemMetaMapRef.current.set(item, meta)
return meta
}, [])
const getScopedDatIdentity = useCallback((sessionId: unknown, createTime: unknown, imageDatName: unknown): string => {
const datName = normalizeMediaToken(String(imageDatName || ''))
if (!datName) return ''
const safeSessionId = String(sessionId || '').trim().toLowerCase()
const safeCreateTime = Number(createTime || 0) || 0
return `dat:${safeSessionId}|${safeCreateTime}|${datName}`
}, [])
const removeImageIdentityMapping = useCallback((identity: string, itemKey: string) => {
if (!identity || !itemKey) return
const current = imageIdentityItemKeyMapRef.current.get(identity)
if (!current) return
current.delete(itemKey)
if (current.size === 0) {
imageIdentityItemKeyMapRef.current.delete(identity)
imageIdentityKeysRef.current.delete(identity)
}
}, [])
const addResourceIndexItem = useCallback((item: MediaStreamItem, meta: MediaItemMeta = getMeta(item)) => {
const { itemKey, imageIdentity } = meta
if (!itemKey) return
itemByKeyRef.current[itemKey] = item
itemMetaByKeyRef.current[itemKey] = meta
validItemKeysRef.current.add(itemKey)
if (item.mediaType === 'video') {
videoItemKeysRef.current.add(itemKey)
return
}
imageItemKeysRef.current.add(itemKey)
if (imageIdentity) {
imageIdentityKeysRef.current.add(imageIdentity)
const identitySet = imageIdentityItemKeyMapRef.current.get(imageIdentity) || new Set<string>()
identitySet.add(itemKey)
imageIdentityItemKeyMapRef.current.set(imageIdentity, identitySet)
}
}, [getMeta])
const pruneImageIdentityCooldowns = useCallback(() => {
const validIdentities = imageIdentityKeysRef.current
const nextImagePreloadIdentityUntil: Record<string, number> = {}
for (const [key, value] of Object.entries(imagePreloadIdentityUntilRef.current)) {
if (!validIdentities.has(key)) continue
nextImagePreloadIdentityUntil[key] = value
}
imagePreloadIdentityUntilRef.current = nextImagePreloadIdentityUntil
const nextImagePredecryptIdentityUntil: Record<string, number> = {}
for (const [key, value] of Object.entries(imagePredecryptIdentityUntilRef.current)) {
if (!validIdentities.has(key)) continue
nextImagePredecryptIdentityUntil[key] = value
}
imagePredecryptIdentityUntilRef.current = nextImagePredecryptIdentityUntil
const nextImageCacheIdentityMissUntil: Record<string, number> = {}
for (const [key, value] of Object.entries(imageCacheIdentityMissUntilRef.current)) {
if (!validIdentities.has(key)) continue
nextImageCacheIdentityMissUntil[key] = value
}
imageCacheIdentityMissUntilRef.current = nextImageCacheIdentityMissUntil
}, [])
const removeResourceIndexItem = useCallback((itemKey: string, meta?: MediaItemMeta) => {
if (!itemKey) return
loadedItemKeysRef.current.delete(itemKey)
validItemKeysRef.current.delete(itemKey)
videoItemKeysRef.current.delete(itemKey)
imageItemKeysRef.current.delete(itemKey)
delete itemByKeyRef.current[itemKey]
delete itemMetaByKeyRef.current[itemKey]
if (meta) {
removeImageIdentityMapping(meta.imageIdentity, itemKey)
} else {
for (const identity of Array.from(imageIdentityItemKeyMapRef.current.keys())) {
removeImageIdentityMapping(identity, itemKey)
}
}
delete imageCacheMissUntilRef.current[itemKey]
if (meta?.imageIdentity && !imageIdentityKeysRef.current.has(meta.imageIdentity)) {
delete imageCacheIdentityMissUntilRef.current[meta.imageIdentity]
}
delete imagePreloadUntilRef.current[itemKey]
delete imagePredecryptUntilRef.current[itemKey]
attemptedVideoPosterKeysRef.current.delete(itemKey)
resolvingVideoPosterKeysRef.current.delete(itemKey)
pruneImageIdentityCooldowns()
}, [pruneImageIdentityCooldowns, removeImageIdentityMapping])
const resetResourceIndexes = useCallback((nextItems: MediaStreamItem[]) => {
imageIdentityItemKeyMapRef.current = new Map()
itemByKeyRef.current = {}
itemMetaByKeyRef.current = {}
loadedItemKeysRef.current = new Set()
validItemKeysRef.current = new Set()
imageItemKeysRef.current = new Set()
videoItemKeysRef.current = new Set()
imageIdentityKeysRef.current = new Set()
for (const item of nextItems) {
const meta = getMeta(item)
loadedItemKeysRef.current.add(meta.itemKey)
addResourceIndexItem(item, meta)
}
}, [addResourceIndexItem, getMeta])
const updateMediaCardState = useCallback((itemKey: string, patch: Partial<MediaCardDynamicState>) => {
mediaCardStateStoreRef.current.update(itemKey, patch)
}, [])
const removeMediaCardState = useCallback((itemKey: string) => {
mediaCardStateStoreRef.current.remove(itemKey)
}, [])
const clearMediaCardStates = useCallback(() => {
mediaCardStateStoreRef.current.clear()
}, [])
const cancelPendingResourceTimers = useCallback(() => {
previewPatchRef.current = {}
updatePatchRef.current = {}
posterPatchRef.current = {}
aspectPatchRef.current = {}
previewPathMapRef.current = {}
previewUpdateMapRef.current = {}
videoPosterMapRef.current = {}
imageAspectMapRef.current = {}
if (previewPatchTimerRef.current !== null) {
window.clearTimeout(previewPatchTimerRef.current)
previewPatchTimerRef.current = null
@@ -342,8 +1009,59 @@ function ResourcesPage() {
window.clearTimeout(rangeTimerRef.current)
rangeTimerRef.current = null
}
if (transientStatePruneTimerRef.current !== null) {
window.clearTimeout(transientStatePruneTimerRef.current)
transientStatePruneTimerRef.current = null
}
if (predecryptTimerRef.current !== null) {
window.clearTimeout(predecryptTimerRef.current)
predecryptTimerRef.current = null
}
pendingRangeRef.current = null
pendingTransientPruneRangeRef.current = null
pendingPredecryptRangeRef.current = null
}, [])
const resetAsyncResourceWork = useCallback((options?: { clearIndexes?: boolean; cancelScope?: boolean }) => {
cancelPendingResourceTimers()
initialRangeScheduleKeyRef.current = ''
imageResolveBatchIdRef.current += 1
resolvingImageCacheBatchRef.current = false
pendingImageResolveRangeRef.current = null
preloadSubmissionEpochRef.current += 1
imagePreloadUntilRef.current = {}
imagePredecryptUntilRef.current = {}
imagePreloadIdentityUntilRef.current = {}
imagePredecryptIdentityUntilRef.current = {}
imagePreloadSubmittingIdentityRef.current.clear()
imagePredecryptSubmittingIdentityRef.current.clear()
selectedKeysRef.current = new Set()
decryptingKeysRef.current = new Set()
lastPreloadStatsRef.current = null
imageCacheMissUntilRef.current = {}
imageCacheIdentityMissUntilRef.current = {}
videoPosterBatchIdRef.current += 1
resolvingVideoPosterBatchRef.current = false
pendingVideoPosterRangeRef.current = null
resolvingVideoPosterKeysRef.current.clear()
attemptedVideoPosterKeysRef.current.clear()
resolvingVideoPosterMd5Ref.current.clear()
attemptedVideoPosterMd5Ref.current.clear()
videoPosterByMd5Ref.current = {}
if (options?.clearIndexes) {
itemsRef.current = []
itemsLengthRef.current = 0
lastVisibleRangeRef.current = null
resetResourceIndexes([])
clearMediaCardStates()
}
if (options?.cancelScope) void window.electronAPI.image.cancelPreloadScope(preloadScopeRef.current)
}, [cancelPendingResourceTimers, clearMediaCardStates, resetResourceIndexes])
useEffect(() => () => {
resetAsyncResourceWork({ cancelScope: true })
}, [resetAsyncResourceWork])
const showAlert = useCallback((message: string, title: string = '提示') => {
setDialog({
mode: 'alert',
@@ -375,66 +1093,253 @@ function ResourcesPage() {
return lower.includes('_thumb') || lower.includes('_t.') || lower.includes('.t.')
}, [])
const isCurrentItemKey = useCallback((itemKey: string): boolean => {
return Boolean(itemKey && validItemKeysRef.current.has(itemKey))
}, [])
const flushPreviewPatch = useCallback(() => {
const pathPatch = previewPatchRef.current
const updatePatch = updatePatchRef.current
previewPatchRef.current = {}
updatePatchRef.current = {}
const pathPatch: Record<string, string> = {}
const updatePatch: Record<string, boolean> = {}
let drained = 0
let hasRemaining = false
let hasPathPatch = false
let hasUpdatePatch = false
for (const key in previewPatchRef.current) {
if (drained >= MAX_MEDIA_PATCHES_PER_FLUSH) {
hasRemaining = true
break
}
const value = previewPatchRef.current[key]
const hasUpdate = updatePatchRef.current[key]
delete previewPatchRef.current[key]
delete updatePatchRef.current[key]
drained += 1
if (!isCurrentItemKey(key)) continue
pathPatch[key] = value
updatePatch[key] = hasUpdate
updateMediaCardState(key, {
previewPath: value,
hasPreviewUpdate: Boolean(hasUpdate)
})
hasPathPatch = true
hasUpdatePatch = true
}
previewPatchTimerRef.current = null
const hasPathPatch = Object.keys(pathPatch).length > 0
const hasUpdatePatch = Object.keys(updatePatch).length > 0
if (hasPathPatch) {
setPreviewPathMap((prev) => ({ ...prev, ...pathPatch }))
previewPathMapRef.current = mergeRecordPatch(previewPathMapRef.current, pathPatch)
}
if (hasUpdatePatch) {
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
previewUpdateMapRef.current = mergeRecordPatch(previewUpdateMapRef.current, updatePatch)
}
}, [])
if (hasRemaining) {
previewPatchTimerRef.current = window.setTimeout(flushPreviewPatch, 16)
}
}, [isCurrentItemKey, updateMediaCardState])
const queuePreviewPatch = useCallback((itemKey: string, localPath: string, hasUpdate: boolean) => {
if (!itemKey || !localPath) return
if (!isCurrentItemKey(itemKey)) return
if (previewPatchRef.current[itemKey] === localPath && updatePatchRef.current[itemKey] === hasUpdate) return
if (previewPathMapRef.current[itemKey] === localPath && previewUpdateMapRef.current[itemKey] === hasUpdate) return
const meta = itemMetaByKeyRef.current[itemKey]
delete imageCacheMissUntilRef.current[itemKey]
if (meta?.imageIdentity) delete imageCacheIdentityMissUntilRef.current[meta.imageIdentity]
bumpRuntimeCounter('previewPatchesQueued')
previewPatchRef.current[itemKey] = localPath
updatePatchRef.current[itemKey] = hasUpdate
if (previewPatchTimerRef.current !== null) return
previewPatchTimerRef.current = window.setTimeout(flushPreviewPatch, 16)
}, [flushPreviewPatch])
}, [bumpRuntimeCounter, flushPreviewPatch, isCurrentItemKey])
const flushPosterPatch = useCallback(() => {
const patch = posterPatchRef.current
posterPatchRef.current = {}
const patch: Record<string, string> = {}
let drained = 0
let hasRemaining = false
let hasPatch = false
for (const key in posterPatchRef.current) {
if (drained >= MAX_MEDIA_PATCHES_PER_FLUSH) {
hasRemaining = true
break
}
const value = posterPatchRef.current[key]
delete posterPatchRef.current[key]
drained += 1
if (!isCurrentItemKey(key)) continue
patch[key] = value
updateMediaCardState(key, { videoPosterPath: value })
hasPatch = true
}
posterPatchTimerRef.current = null
if (Object.keys(patch).length === 0) return
setVideoPosterMap((prev) => ({ ...prev, ...patch }))
}, [])
if (hasPatch) {
videoPosterMapRef.current = mergeRecordPatch(videoPosterMapRef.current, patch)
}
if (hasRemaining) {
posterPatchTimerRef.current = window.setTimeout(flushPosterPatch, 16)
}
}, [isCurrentItemKey, updateMediaCardState])
const queuePosterPatch = useCallback((itemKey: string, posterPath: string) => {
if (!itemKey || !posterPath) return
if (!isCurrentItemKey(itemKey)) return
if (posterPatchRef.current[itemKey] === posterPath) return
if (videoPosterMapRef.current[itemKey] === posterPath) return
posterPatchRef.current[itemKey] = posterPath
if (posterPatchTimerRef.current !== null) return
posterPatchTimerRef.current = window.setTimeout(flushPosterPatch, 16)
}, [flushPosterPatch])
}, [flushPosterPatch, isCurrentItemKey])
const flushAspectPatch = useCallback(() => {
const patch = aspectPatchRef.current
aspectPatchRef.current = {}
const patch: Record<string, number> = {}
let drained = 0
let hasRemaining = false
let hasPatch = false
for (const key in aspectPatchRef.current) {
if (drained >= MAX_MEDIA_PATCHES_PER_FLUSH) {
hasRemaining = true
break
}
const value = aspectPatchRef.current[key]
delete aspectPatchRef.current[key]
drained += 1
if (!isCurrentItemKey(key)) continue
patch[key] = value
updateMediaCardState(key, { imageIsLong: value >= 2.8 })
hasPatch = true
}
aspectPatchTimerRef.current = null
if (Object.keys(patch).length === 0) return
setImageAspectMap((prev) => ({ ...prev, ...patch }))
}, [])
if (hasPatch) {
imageAspectMapRef.current = mergeRecordPatch(imageAspectMapRef.current, patch)
}
if (hasRemaining) {
aspectPatchTimerRef.current = window.setTimeout(flushAspectPatch, 24)
}
}, [isCurrentItemKey, updateMediaCardState])
const queueAspectPatch = useCallback((itemKey: string, ratio: number) => {
if (!isCurrentItemKey(itemKey)) return
const old = imageAspectMapRef.current[itemKey]
if (typeof old === 'number' && Math.abs(old - ratio) < 0.01) return
const pending = aspectPatchRef.current[itemKey]
if (typeof pending === 'number' && Math.abs(pending - ratio) < 0.01) return
aspectPatchRef.current[itemKey] = ratio
if (aspectPatchTimerRef.current !== null) return
aspectPatchTimerRef.current = window.setTimeout(flushAspectPatch, 24)
}, [flushAspectPatch])
}, [flushAspectPatch, isCurrentItemKey])
const pruneTransientMediaState = useCallback((range: { start: number; end: number }) => {
const currentItems = itemsRef.current
if (currentItems.length === 0) return
const retainStart = Math.max(0, range.start - 160)
const retainEnd = Math.min(currentItems.length - 1, range.end + 320)
if (retainEnd < retainStart) return
const keepKeys = new Set<string>()
for (let i = retainStart; i <= retainEnd; i += 1) {
const item = currentItems[i]
if (!item) continue
keepKeys.add(getMeta(item).itemKey)
}
bumpRuntimeCounter('transientStatePruneRuns')
previewPathMapRef.current = pruneRecordToKeys(previewPathMapRef.current, keepKeys)
previewUpdateMapRef.current = pruneRecordToKeys(previewUpdateMapRef.current, keepKeys)
videoPosterMapRef.current = pruneRecordToKeys(videoPosterMapRef.current, keepKeys)
imageAspectMapRef.current = pruneRecordToKeys(imageAspectMapRef.current, keepKeys)
previewPatchRef.current = pruneRecordToKeys(previewPatchRef.current, keepKeys)
updatePatchRef.current = pruneRecordToKeys(updatePatchRef.current, keepKeys)
posterPatchRef.current = pruneRecordToKeys(posterPatchRef.current, keepKeys)
aspectPatchRef.current = pruneRecordToKeys(aspectPatchRef.current, keepKeys)
mediaCardStateStoreRef.current.pruneTransient(keepKeys)
}, [bumpRuntimeCounter, getMeta])
const flushTransientStatePrune = useCallback(() => {
transientStatePruneTimerRef.current = null
const pending = pendingTransientPruneRangeRef.current
pendingTransientPruneRangeRef.current = null
if (!pending) return
pruneTransientMediaState(pending)
}, [pruneTransientMediaState])
const scheduleTransientStatePrune = useCallback((range: { start: number; end: number }) => {
pendingTransientPruneRangeRef.current = range
if (transientStatePruneTimerRef.current !== null) return
transientStatePruneTimerRef.current = window.setTimeout(flushTransientStatePrune, 650)
}, [flushTransientStatePrune])
useEffect(() => {
const unsubscribe = window.electronAPI.image.onCacheResolved((payload) => {
bumpRuntimeCounter('cacheResolvedEvents')
const localPath = String(payload.localPath || '').trim()
if (!localPath) return
const cacheKey = normalizeMediaToken(payload.cacheKey)
let patchedCount = 0
const md5Candidates = [
normalizeMediaToken(payload.imageMd5),
!payload.imageDatName && /^[a-f0-9]{32}$/i.test(cacheKey) ? cacheKey : ''
].filter(Boolean)
for (const token of md5Candidates) {
const itemKeys = imageIdentityItemKeyMapRef.current.get(`md5:${token}`)
if (!itemKeys || itemKeys.size === 0) continue
for (const itemKey of itemKeys) {
queuePreviewPatch(itemKey, localPath, isLikelyThumbnailPreview(localPath))
patchedCount += 1
}
bumpRuntimeCounter('cacheResolvedPatchedItems', patchedCount)
return
}
const datCandidates = [
normalizeMediaToken(payload.imageDatName),
cacheKey && !/^[a-f0-9]{32}$/i.test(cacheKey) ? cacheKey : ''
].filter(Boolean)
for (const token of datCandidates) {
const eventIdentity = getScopedDatIdentity(payload.sessionId, payload.createTime, token)
if (!eventIdentity) continue
const itemKeys = imageIdentityItemKeyMapRef.current.get(eventIdentity)
if (!itemKeys || itemKeys.size === 0) continue
for (const itemKey of itemKeys) {
queuePreviewPatch(itemKey, localPath, isLikelyThumbnailPreview(localPath))
patchedCount += 1
}
bumpRuntimeCounter('cacheResolvedPatchedItems', patchedCount)
return
}
bumpRuntimeCounter('cacheResolvedUnmatchedEvents')
})
return unsubscribe
}, [bumpRuntimeCounter, getScopedDatIdentity, isLikelyThumbnailPreview, queuePreviewPatch])
const loadStream = useCallback(async (reset: boolean) => {
if (reset) setLoading(true)
else setLoadingMore(true)
if (!reset) {
if (loadingRef.current || loadingMoreRef.current || !hasMoreRef.current) {
bumpRuntimeCounter('loadMoreSkipped')
return
}
bumpRuntimeCounter('loadMoreRequests')
loadingMoreRef.current = true
} else {
bumpRuntimeCounter('loadResets')
loadingRef.current = true
loadingMoreRef.current = false
}
const requestId = streamRequestIdRef.current + 1
streamRequestIdRef.current = requestId
if (reset) {
const previousScope = preloadScopeRef.current
preloadScopeRef.current = `resources-${requestId}-${Date.now()}`
resetAsyncResourceWork({ clearIndexes: true })
resetResourceDiagnosticsWindow()
void window.electronAPI.image.cancelPreloadScope(previousScope)
}
if (reset) {
setLoading(true)
setLoadingMore(false)
setItems(clearArray)
setSelectedKeys(clearSet)
nextOffsetRef.current = 0
hasMoreRef.current = false
setNextOffset(0)
setHasMore(false)
} else {
setLoadingMore(true)
}
if (reset) {
setError('')
setActionMessage('')
@@ -443,12 +1348,14 @@ function ResourcesPage() {
try {
if (reset) {
const connectResult = await window.electronAPI.chat.connect()
if (requestId !== streamRequestIdRef.current) return
if (!connectResult.success) {
setError(connectResult.error || '连接数据库失败')
return
}
}
const requestOffset = reset ? 0 : nextOffset
const requestOffset = reset ? 0 : nextOffsetRef.current
const streamLoadStartedAt = performance.now()
const streamResult = await window.electronAPI.chat.getMediaStream({
sessionId: selectedContact === 'all' ? undefined : selectedContact,
mediaType: tab,
@@ -457,106 +1364,85 @@ function ResourcesPage() {
offset: requestOffset,
limit: PAGE_SIZE
})
if (requestId !== streamRequestIdRef.current) return
const streamLoadMs = Math.max(0, Math.round((performance.now() - streamLoadStartedAt) * 10) / 10)
bumpRuntimeCounter('mediaStreamLoadSamples')
bumpRuntimeCounter('mediaStreamLoadMsTotal', streamLoadMs)
if (streamLoadMs > runtimeCountersRef.current.mediaStreamMaxLoadMs) {
runtimeCountersRef.current.mediaStreamMaxLoadMs = streamLoadMs
}
if (streamResult.pageCacheHit || streamResult.streamSource === 'pageCache') {
bumpRuntimeCounter('mediaStreamPageCacheHits')
} else if (streamResult.inflightMerged || streamResult.streamSource === 'inflight') {
bumpRuntimeCounter('mediaStreamInflightMerges')
} else if (streamResult.success) {
bumpRuntimeCounter('mediaStreamNativeLoads')
}
if (!streamResult.success) {
setError(streamResult.error || '加载失败')
if (reset) {
previewPatchRef.current = {}
updatePatchRef.current = {}
posterPatchRef.current = {}
aspectPatchRef.current = {}
if (previewPatchTimerRef.current !== null) {
window.clearTimeout(previewPatchTimerRef.current)
previewPatchTimerRef.current = null
}
if (posterPatchTimerRef.current !== null) {
window.clearTimeout(posterPatchTimerRef.current)
posterPatchTimerRef.current = null
}
if (aspectPatchTimerRef.current !== null) {
window.clearTimeout(aspectPatchTimerRef.current)
aspectPatchTimerRef.current = null
}
if (rangeTimerRef.current !== null) {
window.clearTimeout(rangeTimerRef.current)
rangeTimerRef.current = null
}
pendingRangeRef.current = null
resolvingImageCacheBatchRef.current = false
pendingImageResolveRangeRef.current = null
imagePreloadUntilRef.current = {}
imageCacheMissUntilRef.current = {}
resolvingVideoPosterKeysRef.current.clear()
attemptedVideoPosterKeysRef.current.clear()
resolvedVideoMd5Ref.current = {}
setItems([])
setNextOffset(0)
setHasMore(false)
setSelectedKeys(new Set())
setPreviewPathMap({})
setPreviewUpdateMap({})
setVideoPosterMap({})
setImageAspectMap({})
}
return
}
const incoming = (streamResult.items || []) as MediaStreamItem[]
bumpRuntimeCounter('mediaStreamRowsLoaded', incoming.length)
if (reset) {
previewPatchRef.current = {}
updatePatchRef.current = {}
posterPatchRef.current = {}
aspectPatchRef.current = {}
if (previewPatchTimerRef.current !== null) {
window.clearTimeout(previewPatchTimerRef.current)
previewPatchTimerRef.current = null
const nextLoadedKeys = new Set<string>()
const uniqueIncoming: MediaStreamItem[] = []
for (const item of incoming) {
const meta = getMeta(item)
if (nextLoadedKeys.has(meta.itemKey)) continue
nextLoadedKeys.add(meta.itemKey)
uniqueIncoming.push(item)
}
if (posterPatchTimerRef.current !== null) {
window.clearTimeout(posterPatchTimerRef.current)
posterPatchTimerRef.current = null
}
if (aspectPatchTimerRef.current !== null) {
window.clearTimeout(aspectPatchTimerRef.current)
aspectPatchTimerRef.current = null
}
if (rangeTimerRef.current !== null) {
window.clearTimeout(rangeTimerRef.current)
rangeTimerRef.current = null
}
pendingRangeRef.current = null
resolvingImageCacheBatchRef.current = false
pendingImageResolveRangeRef.current = null
imagePreloadUntilRef.current = {}
imageCacheMissUntilRef.current = {}
resolvingVideoPosterKeysRef.current.clear()
attemptedVideoPosterKeysRef.current.clear()
resolvedVideoMd5Ref.current = {}
setItems(incoming)
setSelectedKeys(new Set())
setPreviewPathMap({})
setPreviewUpdateMap({})
setVideoPosterMap({})
setImageAspectMap({})
bumpRuntimeCounter('mediaStreamDuplicateRows', incoming.length - uniqueIncoming.length)
resetResourceIndexes(uniqueIncoming)
setItems(uniqueIncoming)
setSelectedKeys(clearSet)
} else {
setItems((prev) => {
const map = new Map(prev.map((row) => [getItemKey(row), row]))
incoming.forEach((row) => map.set(getItemKey(row), row))
return Array.from(map.values())
})
const uniqueIncoming: MediaStreamItem[] = []
const loadedKeys = loadedItemKeysRef.current
for (const item of incoming) {
const meta = getMeta(item)
if (loadedKeys.has(meta.itemKey)) continue
loadedKeys.add(meta.itemKey)
addResourceIndexItem(item, meta)
uniqueIncoming.push(item)
}
bumpRuntimeCounter('mediaStreamDuplicateRows', incoming.length - uniqueIncoming.length)
if (uniqueIncoming.length > 0) {
setItems((prev) => prev.concat(uniqueIncoming))
}
}
setNextOffset(Number(streamResult.nextOffset || requestOffset + incoming.length))
setHasMore(Boolean(streamResult.hasMore))
const reportedNextOffset = Number(streamResult.nextOffset)
const nextOffsetValue = Number.isFinite(reportedNextOffset)
? Math.max(requestOffset, reportedNextOffset)
: requestOffset + incoming.length
let hasMoreValue = Boolean(streamResult.hasMore)
if (hasMoreValue && nextOffsetValue <= requestOffset) {
bumpRuntimeCounter('mediaStreamNoProgressStops')
hasMoreValue = false
}
nextOffsetRef.current = nextOffsetValue
hasMoreRef.current = hasMoreValue
setNextOffset(nextOffsetValue)
setHasMore(hasMoreValue)
} catch (e) {
if (requestId !== streamRequestIdRef.current) return
setError(String(e))
} finally {
setLoading(false)
setLoadingMore(false)
if (requestId === streamRequestIdRef.current) {
if (reset) loadingRef.current = false
else loadingMoreRef.current = false
setLoading(false)
setLoadingMore(false)
}
}
}, [dateEnd, dateStart, nextOffset, selectedContact, tab])
}, [addResourceIndexItem, bumpRuntimeCounter, dateEnd, dateStart, getMeta, resetAsyncResourceWork, resetResourceDiagnosticsWindow, resetResourceIndexes, selectedContact, tab])
useEffect(() => {
void loadStream(true)
}, [tab, selectedContact, dateStart, dateEnd])
}, [loadStream])
useEffect(() => {
let cancelled = false
@@ -587,167 +1473,133 @@ function ResourcesPage() {
}
}, [])
const displayItems = useMemo(() => (
items.map((item) => ({
...item,
sessionDisplayName: item.sessionDisplayName || sessionNameMap[item.sessionId] || item.sessionId
}))
), [items, sessionNameMap])
useEffect(() => {
const imageKeySet = new Set(
displayItems
.filter((item) => item.mediaType === 'image')
.map((item) => getItemKey(item))
)
const videoKeySet = new Set(
displayItems
.filter((item) => item.mediaType === 'video')
.map((item) => getItemKey(item))
)
setPreviewPathMap((prev) => {
let changed = false
const next: Record<string, string> = {}
for (const [key, value] of Object.entries(prev)) {
if (!imageKeySet.has(key)) continue
next[key] = value
}
if (Object.keys(next).length !== Object.keys(prev).length) changed = true
return changed ? next : prev
})
setPreviewUpdateMap((prev) => {
let changed = false
const next: Record<string, boolean> = {}
for (const [key, value] of Object.entries(prev)) {
if (!imageKeySet.has(key)) continue
next[key] = value
}
if (Object.keys(next).length !== Object.keys(prev).length) changed = true
return changed ? next : prev
})
setImageAspectMap((prev) => {
let changed = false
const next: Record<string, number> = {}
for (const [key, value] of Object.entries(prev)) {
if (!imageKeySet.has(key)) continue
next[key] = value
}
if (Object.keys(next).length !== Object.keys(prev).length) changed = true
return changed ? next : prev
})
setVideoPosterMap((prev) => {
let changed = false
const next: Record<string, string> = {}
for (const [key, value] of Object.entries(prev)) {
if (!videoKeySet.has(key)) continue
next[key] = value
}
if (Object.keys(next).length !== Object.keys(prev).length) changed = true
return changed ? next : prev
})
const validKeys = new Set<string>(displayItems.map((item) => getItemKey(item)))
const nextResolvedVideoMd5: Record<string, string> = {}
for (const [key, value] of Object.entries(resolvedVideoMd5Ref.current)) {
if (!validKeys.has(key)) continue
nextResolvedVideoMd5[key] = value
}
resolvedVideoMd5Ref.current = nextResolvedVideoMd5
const nextAttempted = new Set<string>()
attemptedVideoPosterKeysRef.current.forEach((key) => {
if (validKeys.has(key)) nextAttempted.add(key)
})
attemptedVideoPosterKeysRef.current = nextAttempted
const nextImageMissUntil: Record<string, number> = {}
for (const [key, value] of Object.entries(imageCacheMissUntilRef.current)) {
if (!validKeys.has(key)) continue
nextImageMissUntil[key] = value
}
imageCacheMissUntilRef.current = nextImageMissUntil
const nextImagePreloadUntil: Record<string, number> = {}
for (const [key, value] of Object.entries(imagePreloadUntilRef.current)) {
if (!validKeys.has(key)) continue
nextImagePreloadUntil[key] = value
}
imagePreloadUntilRef.current = nextImagePreloadUntil
}, [displayItems])
const resolveImageCacheRange = useCallback((start: number, end: number) => {
const currentItems = itemsRef.current
const from = Math.max(0, start)
const to = Math.min(displayItems.length - 1, end)
const to = Math.min(currentItems.length - 1, end)
if (to < from) return
if (resolvingImageCacheBatchRef.current) {
const previous = pendingImageResolveRangeRef.current
if (previous && from >= previous.start && to <= previous.end) {
bumpRuntimeCounter('imageResolveContainedSkips')
return
}
const merged = mergeNearbyRange(pendingImageResolveRangeRef.current, { start: from, end: to }, 24)
pendingImageResolveRangeRef.current = merged.range
bumpRuntimeCounter(merged.merged ? 'imageResolvePendingMerges' : 'imageResolvePendingReplacements')
return
}
const now = Date.now()
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = []
const itemKeys: string[] = []
const payloadItemKeys: string[][] = []
const payloadIdentities: string[] = []
const dedup = new Map<string, number>()
for (let i = from; i <= to; i += 1) {
const item = displayItems[i]
const item = currentItems[i]
if (!item || item.mediaType !== 'image') continue
const itemKey = getItemKey(item)
const meta = getMeta(item)
const { itemKey, imageMd5, imageDatName, imageIdentity } = meta
if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue
if (!hasImageLocator(item)) continue
if (!meta.hasImageLocator) continue
if ((imageCacheMissUntilRef.current[itemKey] || 0) > now) continue
if ((imageCacheIdentityMissUntilRef.current[imageIdentity] || 0) > now) {
imageCacheMissUntilRef.current[itemKey] = imageCacheIdentityMissUntilRef.current[imageIdentity]
bumpRuntimeCounter('imageResolveIdentityMissSkipped')
continue
}
const dedupKey = imageIdentity
const existingIndex = dedup.get(dedupKey)
if (existingIndex !== undefined) {
payloadItemKeys[existingIndex].push(itemKey)
continue
}
payloads.push({
sessionId: item.sessionId,
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
imageDatName: getSafeImageDatName(item) || undefined,
imageMd5: imageMd5 || undefined,
imageDatName: imageDatName || undefined,
createTime: Number(item.createTime || 0) || undefined
})
itemKeys.push(itemKey)
dedup.set(dedupKey, payloads.length - 1)
payloadItemKeys.push([itemKey])
payloadIdentities.push(imageIdentity)
if (payloads.length >= MAX_IMAGE_CACHE_RESOLVE_PER_TICK) break
}
if (payloads.length === 0) return
if (resolvingImageCacheBatchRef.current) {
pendingImageResolveRangeRef.current = { start: from, end: to }
return
}
resolvingImageCacheBatchRef.current = true
bumpRuntimeCounter('imageResolveStarted')
const batchId = imageResolveBatchIdRef.current + 1
imageResolveBatchIdRef.current = batchId
void (async () => {
try {
const result = await window.electronAPI.image.resolveCacheBatch(payloads, {
disableUpdateCheck: true,
allowCacheIndex: true,
allowCachePromotion: false,
allowFilesystemScan: false,
preferFilePath: true,
hardlinkOnly: true,
suppressEvents: true
})
if (batchId !== imageResolveBatchIdRef.current) {
bumpRuntimeCounter('imageResolveSuperseded')
return
}
const rows = Array.isArray(result?.rows) ? result.rows : []
const pathPatch: Record<string, string> = {}
const updatePatch: Record<string, boolean> = {}
const missUntil = Date.now() + 4500
let hasPathPatch = false
let hasUpdatePatch = false
for (let i = 0; i < itemKeys.length; i += 1) {
const itemKey = itemKeys[i]
for (let i = 0; i < payloadItemKeys.length; i += 1) {
const row = rows[i]
if (row?.success && row.localPath) {
delete imageCacheMissUntilRef.current[itemKey]
pathPatch[itemKey] = row.localPath
updatePatch[itemKey] = Boolean(row.hasUpdate)
} else {
imageCacheMissUntilRef.current[itemKey] = missUntil
const imageIdentity = payloadIdentities[i] || ''
for (const itemKey of payloadItemKeys[i] || []) {
if (!isCurrentItemKey(itemKey)) continue
if (row?.success && row.localPath) {
delete imageCacheMissUntilRef.current[itemKey]
if (imageIdentity) delete imageCacheIdentityMissUntilRef.current[imageIdentity]
pathPatch[itemKey] = row.localPath
updatePatch[itemKey] = Boolean(row.hasUpdate)
updateMediaCardState(itemKey, {
previewPath: row.localPath,
hasPreviewUpdate: Boolean(row.hasUpdate)
})
hasPathPatch = true
hasUpdatePatch = true
} else {
imageCacheMissUntilRef.current[itemKey] = missUntil
if (imageIdentity) imageCacheIdentityMissUntilRef.current[imageIdentity] = missUntil
bumpRuntimeCounter('imageResolveMisses')
}
}
}
if (Object.keys(pathPatch).length > 0) {
setPreviewPathMap((prev) => ({ ...prev, ...pathPatch }))
if (hasPathPatch) {
previewPathMapRef.current = mergeRecordPatch(previewPathMapRef.current, pathPatch)
}
if (Object.keys(updatePatch).length > 0) {
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
if (hasUpdatePatch) {
previewUpdateMapRef.current = mergeRecordPatch(previewUpdateMapRef.current, updatePatch)
}
} catch {
if (batchId !== imageResolveBatchIdRef.current) {
bumpRuntimeCounter('imageResolveSuperseded')
return
}
const missUntil = Date.now() + 4500
itemKeys.forEach((itemKey) => {
imageCacheMissUntilRef.current[itemKey] = missUntil
payloadItemKeys.forEach((itemKeys, index) => {
const imageIdentity = payloadIdentities[index] || ''
if (imageIdentity) imageCacheIdentityMissUntilRef.current[imageIdentity] = missUntil
itemKeys.forEach((itemKey) => {
if (!isCurrentItemKey(itemKey)) return
imageCacheMissUntilRef.current[itemKey] = missUntil
bumpRuntimeCounter('imageResolveMisses')
})
})
} finally {
if (batchId !== imageResolveBatchIdRef.current) return
bumpRuntimeCounter('imageResolveCompleted')
resolvingImageCacheBatchRef.current = false
const pending = pendingImageResolveRangeRef.current
pendingImageResolveRangeRef.current = null
@@ -756,60 +1608,331 @@ function ResourcesPage() {
}
}
})()
}, [displayItems])
}, [bumpRuntimeCounter, getMeta, isCurrentItemKey, updateMediaCardState])
const preloadImageCacheRange = useCallback((start: number, end: number) => {
const collectImagePreloadPayloads = useCallback((
start: number,
end: number,
options: {
cooldownRef: MutableRefObject<Record<string, number>>
identityCooldownRef: MutableRefObject<Record<string, number>>
submittingIdentityRef: MutableRefObject<Set<string>>
submittingSkipCounter: keyof ResourceRuntimeCounters
cooldownMs: number
limit: number
includeExistingPreview?: boolean
}
): ImagePreloadCandidate[] => {
const currentItems = itemsRef.current
const from = Math.max(0, start)
const to = Math.min(displayItems.length - 1, end)
if (to < from) return
const to = Math.min(currentItems.length - 1, end)
if (to < from) return []
const now = Date.now()
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = []
const candidates: ImagePreloadCandidate[] = []
const dedup = new Set<string>()
for (let i = from; i <= to; i += 1) {
const item = displayItems[i]
const item = currentItems[i]
if (!item || item.mediaType !== 'image') continue
const itemKey = getItemKey(item)
if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue
if (!hasImageLocator(item)) continue
if ((imagePreloadUntilRef.current[itemKey] || 0) > now) continue
const dedupKey = `${item.sessionId || ''}|${normalizeMediaToken(item.imageMd5)}|${getSafeImageDatName(item)}`
const meta = getMeta(item)
const { itemKey, imageMd5, imageDatName, imageIdentity } = meta
const previewPath = previewPatchRef.current[itemKey] || previewPathMapRef.current[itemKey] || ''
const isExistingPreviewCandidate = Boolean(previewPath && options.includeExistingPreview === true)
if (previewPath) {
const previewMayNeedUpgrade =
options.includeExistingPreview === true &&
(
previewUpdateMapRef.current[itemKey] === true ||
updatePatchRef.current[itemKey] === true ||
isLikelyThumbnailPreview(previewPath)
)
if (!previewMayNeedUpgrade) continue
}
if (!meta.hasImageLocator) continue
if ((options.cooldownRef.current[itemKey] || 0) > now) continue
const dedupKey = imageIdentity
if ((options.identityCooldownRef.current[dedupKey] || 0) > now) {
options.cooldownRef.current[itemKey] = options.identityCooldownRef.current[dedupKey]
continue
}
if (options.submittingIdentityRef.current.has(dedupKey)) {
bumpRuntimeCounter(options.submittingSkipCounter)
continue
}
if (dedup.has(dedupKey)) continue
dedup.add(dedupKey)
imagePreloadUntilRef.current[itemKey] = now + 12000
payloads.push({
sessionId: item.sessionId,
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
imageDatName: getSafeImageDatName(item) || undefined,
createTime: Number(item.createTime || 0) || undefined
candidates.push({
itemKey,
imageIdentity,
previewUpgrade: isExistingPreviewCandidate,
payload: {
sessionId: item.sessionId,
imageMd5: imageMd5 || undefined,
imageDatName: imageDatName || undefined,
createTime: Number(item.createTime || 0) || undefined
}
})
if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break
if (candidates.length >= options.limit) break
}
if (payloads.length === 0) return
void window.electronAPI.image.preload(payloads, {
allowDecrypt: false,
allowCacheIndex: true
})
}, [displayItems])
return candidates
}, [bumpRuntimeCounter, getMeta, isLikelyThumbnailPreview])
const resolveItemVideoMd5 = useCallback(async (item: MediaStreamItem): Promise<string> => {
const itemKey = getItemKey(item)
const cached = resolvedVideoMd5Ref.current[itemKey]
if (cached) return cached
const commitImagePreloadCooldowns = useCallback((
candidates: ImagePreloadCandidate[],
handledIdentities: string[],
cooldownRef: MutableRefObject<Record<string, number>>,
identityCooldownRef: MutableRefObject<Record<string, number>>,
cooldownMs: number
) => {
if (candidates.length === 0 || handledIdentities.length === 0) return
const handled = new Set(handledIdentities)
const until = Date.now() + cooldownMs
for (const candidate of candidates) {
if (!handled.has(candidate.imageIdentity)) continue
cooldownRef.current[candidate.itemKey] = until
identityCooldownRef.current[candidate.imageIdentity] = until
if (candidate.previewUpgrade) {
bumpRuntimeCounter('imagePredecryptPreviewUpgrades')
}
}
}, [bumpRuntimeCounter])
let md5 = String(item.videoMd5 || '').trim().toLowerCase()
if (md5) {
resolvedVideoMd5Ref.current[itemKey] = md5
return md5
const commitImagePreloadRetryCooldowns = useCallback((
candidates: ImagePreloadCandidate[],
rejectedIdentities: string[],
cooldownRef: MutableRefObject<Record<string, number>>,
identityCooldownRef: MutableRefObject<Record<string, number>>,
cooldownMs: number
) => {
if (candidates.length === 0 || rejectedIdentities.length === 0) return
const rejected = new Set(rejectedIdentities)
const until = Date.now() + cooldownMs
for (const candidate of candidates) {
if (!rejected.has(candidate.imageIdentity)) continue
cooldownRef.current[candidate.itemKey] = until
identityCooldownRef.current[candidate.imageIdentity] = until
}
const parsed = await window.electronAPI.video.parseVideoMd5(String(item.content || ''))
if (parsed.success && parsed.md5) md5 = String(parsed.md5).trim().toLowerCase()
if (md5) resolvedVideoMd5Ref.current[itemKey] = md5
return md5
}, [])
const markImagePreloadSubmitting = useCallback((
candidates: ImagePreloadCandidate[],
submittingIdentityRef: MutableRefObject<Set<string>>
) => {
candidates.forEach((candidate) => {
submittingIdentityRef.current.add(candidate.imageIdentity)
})
}, [])
const clearImagePreloadSubmitting = useCallback((
candidates: ImagePreloadCandidate[],
submittingIdentityRef: MutableRefObject<Set<string>>
) => {
candidates.forEach((candidate) => {
submittingIdentityRef.current.delete(candidate.imageIdentity)
})
}, [])
const preloadImageCacheRange = useCallback((start: number, end: number) => {
const candidates = collectImagePreloadPayloads(start, end, {
cooldownRef: imagePreloadUntilRef,
identityCooldownRef: imagePreloadIdentityUntilRef,
submittingIdentityRef: imagePreloadSubmittingIdentityRef,
submittingSkipCounter: 'imagePreloadSubmittingSkips',
cooldownMs: 12000,
limit: MAX_IMAGE_CACHE_PRELOAD_PER_TICK
})
const payloads = candidates.map((candidate) => candidate.payload)
if (payloads.length === 0) return
markImagePreloadSubmitting(candidates, imagePreloadSubmittingIdentityRef)
const submissionEpoch = preloadSubmissionEpochRef.current
bumpRuntimeCounter('imagePreloadRequests', payloads.length)
void window.electronAPI.image.preload(payloads, {
allowDecrypt: false,
allowCacheIndex: true,
allowFilesystemScan: false,
emitResolved: true,
scope: preloadScopeRef.current,
priority: 'high'
}).then((result) => {
if (submissionEpoch !== preloadSubmissionEpochRef.current) return
const rejectedIdentities = Array.isArray(result?.rejectedIdentities) ? result.rejectedIdentities : []
if (rejectedIdentities.length > 0) {
bumpRuntimeCounter('imagePreloadRejectedCapacity', rejectedIdentities.length)
commitImagePreloadRetryCooldowns(
candidates,
rejectedIdentities,
imagePreloadUntilRef,
imagePreloadIdentityUntilRef,
1600
)
}
commitImagePreloadCooldowns(
candidates,
Array.isArray(result?.handledIdentities) ? result.handledIdentities : [],
imagePreloadUntilRef,
imagePreloadIdentityUntilRef,
12000
)
}).catch(() => { }).finally(() => {
if (submissionEpoch !== preloadSubmissionEpochRef.current) return
clearImagePreloadSubmitting(candidates, imagePreloadSubmittingIdentityRef)
})
}, [bumpRuntimeCounter, clearImagePreloadSubmitting, collectImagePreloadPayloads, commitImagePreloadCooldowns, commitImagePreloadRetryCooldowns, markImagePreloadSubmitting])
const shouldSkipPredecryptForBackpressure = useCallback((): boolean => {
const stats = lastPreloadStatsRef.current
if (!stats) return false
const queuedDecrypt = Number(stats.queuedDecrypt || 0)
const queuedLow = Number(stats.queuedLow || 0)
const activeDecrypt = Number(stats.activeDecrypt || 0)
const highWaterQueuedDecrypt = Number(stats.highWater?.queuedDecrypt || 0)
const highWaterQueuedLow = Number(stats.highWater?.queuedLow || 0)
return (
queuedDecrypt >= 48 ||
queuedLow >= 96 ||
highWaterQueuedDecrypt >= 96 ||
highWaterQueuedLow >= 160 ||
(activeDecrypt >= 2 && queuedDecrypt >= 24)
)
}, [])
const predecryptImageRange = useCallback((start: number, end: number) => {
if (document.visibilityState === 'hidden') {
bumpRuntimeCounter('predecryptHiddenSkips')
return
}
if (shouldSkipPredecryptForBackpressure()) {
bumpRuntimeCounter('imagePredecryptBackpressureSkips')
return
}
const candidates = collectImagePreloadPayloads(start, end, {
cooldownRef: imagePredecryptUntilRef,
identityCooldownRef: imagePredecryptIdentityUntilRef,
submittingIdentityRef: imagePredecryptSubmittingIdentityRef,
submittingSkipCounter: 'imagePredecryptSubmittingSkips',
cooldownMs: 30000,
limit: MAX_IMAGE_PREDECRYPT_PER_TICK,
includeExistingPreview: true
})
const payloads = candidates.map((candidate) => candidate.payload)
if (payloads.length === 0) return
markImagePreloadSubmitting(candidates, imagePredecryptSubmittingIdentityRef)
const submissionEpoch = preloadSubmissionEpochRef.current
bumpRuntimeCounter('imagePredecryptRequests', payloads.length)
void window.electronAPI.image.preload(payloads, {
allowDecrypt: true,
allowCacheIndex: true,
allowFilesystemScan: true,
emitResolved: true,
scope: preloadScopeRef.current,
priority: 'low'
}).then((result) => {
if (submissionEpoch !== preloadSubmissionEpochRef.current) return
const rejectedIdentities = Array.isArray(result?.rejectedIdentities) ? result.rejectedIdentities : []
const deferredIdentities = Array.isArray(result?.deferredIdentities) ? result.deferredIdentities : []
if (rejectedIdentities.length > 0) {
bumpRuntimeCounter('imagePredecryptRejectedCapacity', rejectedIdentities.length)
commitImagePreloadRetryCooldowns(
candidates,
rejectedIdentities,
imagePredecryptUntilRef,
imagePredecryptIdentityUntilRef,
3000
)
}
if (deferredIdentities.length > 0) {
bumpRuntimeCounter('imagePredecryptDeferred', deferredIdentities.length)
commitImagePreloadRetryCooldowns(
candidates,
deferredIdentities,
imagePredecryptUntilRef,
imagePredecryptIdentityUntilRef,
3600
)
}
commitImagePreloadCooldowns(
candidates,
Array.isArray(result?.acceptedIdentities) ? result.acceptedIdentities : [],
imagePredecryptUntilRef,
imagePredecryptIdentityUntilRef,
12000
)
commitImagePreloadCooldowns(
candidates,
[
...(Array.isArray(result?.mergedQueuedIdentities) ? result.mergedQueuedIdentities : []),
...(Array.isArray(result?.skippedActiveIdentities) ? result.skippedActiveIdentities : []),
...(Array.isArray(result?.skippedPendingIdentities) ? result.skippedPendingIdentities : [])
],
imagePredecryptUntilRef,
imagePredecryptIdentityUntilRef,
30000
)
}).catch(() => { }).finally(() => {
if (submissionEpoch !== preloadSubmissionEpochRef.current) return
clearImagePreloadSubmitting(candidates, imagePredecryptSubmittingIdentityRef)
})
}, [bumpRuntimeCounter, clearImagePreloadSubmitting, collectImagePreloadPayloads, commitImagePreloadCooldowns, commitImagePreloadRetryCooldowns, markImagePreloadSubmitting, shouldSkipPredecryptForBackpressure])
const flushPredecryptRange = useCallback(() => {
predecryptTimerRef.current = null
const pending = pendingPredecryptRangeRef.current
pendingPredecryptRangeRef.current = null
if (!pending) return
if (document.visibilityState === 'hidden') {
bumpRuntimeCounter('predecryptHiddenSkips')
return
}
const idleForMs = Date.now() - lastRangeActivityAtRef.current
if (lastRangeActivityAtRef.current > 0 && idleForMs < IMAGE_PREDECRYPT_IDLE_DELAY_MS) {
pendingPredecryptRangeRef.current = pending
predecryptTimerRef.current = window.setTimeout(
flushPredecryptRange,
Math.max(80, IMAGE_PREDECRYPT_IDLE_DELAY_MS - idleForMs)
)
return
}
bumpRuntimeCounter('predecryptFlushes')
predecryptImageRange(pending.start, pending.end)
}, [bumpRuntimeCounter, predecryptImageRange])
const schedulePredecryptRange = useCallback((start: number, end: number) => {
bumpRuntimeCounter('predecryptSchedules')
if (document.visibilityState === 'hidden') {
bumpRuntimeCounter('predecryptHiddenSkips')
return
}
const nextRange = {
start: Math.max(0, start),
end: Math.max(Math.max(0, start), end)
}
const previous = pendingPredecryptRangeRef.current
if (previous && nextRange.start >= previous.start && nextRange.end <= previous.end) {
bumpRuntimeCounter('predecryptContainedSkips')
if (predecryptTimerRef.current !== null) {
window.clearTimeout(predecryptTimerRef.current)
predecryptTimerRef.current = window.setTimeout(flushPredecryptRange, IMAGE_PREDECRYPT_TIMER_MS)
}
return
}
if (previous) {
bumpRuntimeCounter('predecryptPendingUpdates')
bumpRuntimeCounter('predecryptPendingReplacements')
}
pendingPredecryptRangeRef.current = nextRange
if (predecryptTimerRef.current !== null) {
window.clearTimeout(predecryptTimerRef.current)
}
predecryptTimerRef.current = window.setTimeout(flushPredecryptRange, IMAGE_PREDECRYPT_TIMER_MS)
}, [bumpRuntimeCounter, flushPredecryptRange])
const resolveItemVideoMd5 = useCallback((item: MediaStreamItem): string => {
return getMeta(item).videoMd5
}, [getMeta])
const showMediaInfo = useCallback(async (item: MediaStreamItem) => {
const itemKey = getItemKey(item)
const meta = getMeta(item)
const itemKey = meta.itemKey
const mediaLabel = item.mediaType === 'image' ? '图片' : '视频'
const baseRows: Array<{ label: string; value: string }> = [
{ label: '资源类型', value: mediaLabel },
@@ -893,114 +2016,227 @@ function ResourcesPage() {
onConfirm: null
})
}
}, [resolveItemVideoMd5])
const resolveVideoPoster = useCallback(async (item: MediaStreamItem) => {
if (item.mediaType !== 'video') return
const itemKey = getItemKey(item)
if (videoPosterMapRef.current[itemKey]) return
if (attemptedVideoPosterKeysRef.current.has(itemKey)) return
if (resolvingVideoPosterKeysRef.current.has(itemKey)) return
resolvingVideoPosterKeysRef.current.add(itemKey)
try {
const md5 = await resolveItemVideoMd5(item)
if (!md5) {
attemptedVideoPosterKeysRef.current.add(itemKey)
return
}
const info = await window.electronAPI.video.getVideoInfo(md5, { includePoster: true, posterFormat: 'fileUrl' })
if (!info.success || !info.exists) {
attemptedVideoPosterKeysRef.current.add(itemKey)
return
}
const poster = String(info.coverUrl || '')
if (!poster) {
attemptedVideoPosterKeysRef.current.add(itemKey)
return
}
queuePosterPatch(itemKey, poster)
attemptedVideoPosterKeysRef.current.add(itemKey)
} catch {
attemptedVideoPosterKeysRef.current.add(itemKey)
} finally {
resolvingVideoPosterKeysRef.current.delete(itemKey)
}
}, [queuePosterPatch, resolveItemVideoMd5])
}, [getMeta, resolveItemVideoMd5])
const resolvePosterRange = useCallback((start: number, end: number) => {
const currentItems = itemsRef.current
const from = Math.max(0, start)
const to = Math.min(displayItems.length - 1, end)
const to = Math.min(currentItems.length - 1, end)
if (to < from) return
let resolvedCount = 0
for (let i = from; i <= to; i += 1) {
const item = displayItems[i]
if (!item || item.mediaType !== 'video') continue
void resolveVideoPoster(item)
resolvedCount += 1
if (resolvedCount >= MAX_VIDEO_POSTER_RESOLVE_PER_TICK) break
if (resolvingVideoPosterBatchRef.current) {
const previous = pendingVideoPosterRangeRef.current
if (previous && from >= previous.start && to <= previous.end) {
bumpRuntimeCounter('videoPosterContainedSkips')
return
}
const merged = mergeNearbyRange(pendingVideoPosterRangeRef.current, { start: from, end: to }, 18)
pendingVideoPosterRangeRef.current = merged.range
bumpRuntimeCounter(merged.merged ? 'videoPosterPendingMerges' : 'videoPosterPendingReplacements')
return
}
}, [displayItems, resolveVideoPoster])
const md5List: string[] = []
const md5ItemKeys: string[][] = []
const md5IndexMap = new Map<string, number>()
for (let i = from; i <= to; i += 1) {
const item = currentItems[i]
if (!item || item.mediaType !== 'video') continue
const itemKey = getMeta(item).itemKey
if (videoPosterMapRef.current[itemKey] || posterPatchRef.current[itemKey]) continue
if (attemptedVideoPosterKeysRef.current.has(itemKey)) continue
if (resolvingVideoPosterKeysRef.current.has(itemKey)) continue
const md5 = resolveItemVideoMd5(item)
if (!md5) {
attemptedVideoPosterKeysRef.current.add(itemKey)
continue
}
const cachedPoster = videoPosterByMd5Ref.current[md5]
if (cachedPoster) {
bumpRuntimeCounter('videoPosterMd5CacheHits')
queuePosterPatch(itemKey, cachedPoster)
attemptedVideoPosterKeysRef.current.add(itemKey)
continue
}
if (attemptedVideoPosterMd5Ref.current.has(md5)) {
bumpRuntimeCounter('videoPosterMd5MissSkips')
attemptedVideoPosterKeysRef.current.add(itemKey)
continue
}
const existingIndex = md5IndexMap.get(md5)
if (existingIndex !== undefined) {
resolvingVideoPosterKeysRef.current.add(itemKey)
md5ItemKeys[existingIndex].push(itemKey)
continue
}
if (resolvingVideoPosterMd5Ref.current.has(md5)) continue
resolvingVideoPosterKeysRef.current.add(itemKey)
md5IndexMap.set(md5, md5List.length)
resolvingVideoPosterMd5Ref.current.add(md5)
md5ItemKeys.push([itemKey])
md5List.push(md5)
if (md5List.length >= MAX_VIDEO_POSTER_RESOLVE_PER_TICK) break
}
if (md5List.length === 0) return
resolvingVideoPosterBatchRef.current = true
bumpRuntimeCounter('videoPosterBatches')
const batchId = videoPosterBatchIdRef.current + 1
videoPosterBatchIdRef.current = batchId
void (async () => {
try {
const result = await window.electronAPI.video.getVideoInfoBatch(md5List, {
includePoster: true,
posterFormat: 'fileUrl'
})
if (batchId !== videoPosterBatchIdRef.current) return
const rows = Array.isArray(result?.rows) ? result.rows : []
const rowsByIndex = new Map(rows.map((row) => [Number(row.index), row]))
for (let i = 0; i < md5ItemKeys.length; i += 1) {
const row = rowsByIndex.get(i)
const md5 = md5List[i] || ''
for (const itemKey of md5ItemKeys[i] || []) {
if (row?.success && row.exists && row.coverUrl) {
const coverUrl = String(row.coverUrl)
if (md5) videoPosterByMd5Ref.current[md5] = coverUrl
queuePosterPatch(itemKey, coverUrl)
}
attemptedVideoPosterKeysRef.current.add(itemKey)
}
if (md5) attemptedVideoPosterMd5Ref.current.add(md5)
}
} catch {
if (batchId !== videoPosterBatchIdRef.current) return
md5ItemKeys.forEach((itemKeys, index) => {
const md5 = md5List[index] || ''
if (md5) attemptedVideoPosterMd5Ref.current.add(md5)
itemKeys.forEach((itemKey) => {
attemptedVideoPosterKeysRef.current.add(itemKey)
})
})
} finally {
if (batchId !== videoPosterBatchIdRef.current) return
md5ItemKeys.forEach((itemKeys, index) => {
const md5 = md5List[index] || ''
if (md5) resolvingVideoPosterMd5Ref.current.delete(md5)
itemKeys.forEach((itemKey) => {
resolvingVideoPosterKeysRef.current.delete(itemKey)
})
})
resolvingVideoPosterBatchRef.current = false
const pending = pendingVideoPosterRangeRef.current
pendingVideoPosterRangeRef.current = null
if (pending) resolvePosterRange(pending.start, pending.end)
}
})()
}, [bumpRuntimeCounter, getMeta, queuePosterPatch, resolveItemVideoMd5])
const flushRangeResolve = useCallback(() => {
rangeTimerRef.current = null
const pending = pendingRangeRef.current
if (!pending) return
if (document.visibilityState === 'hidden') {
bumpRuntimeCounter('rangeHiddenSkips')
return
}
pendingRangeRef.current = null
bumpRuntimeCounter('rangeFlushes')
if (tab === 'image') {
preloadImageCacheRange(pending.start - 4, pending.end + 20)
resolveImageCacheRange(pending.start - 1, pending.end + 6)
schedulePredecryptRange(pending.start - 1, pending.end + IMAGE_PREDECRYPT_LOOKAHEAD)
return
}
resolvePosterRange(pending.start, pending.end)
}, [preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab])
}, [bumpRuntimeCounter, preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, schedulePredecryptRange, tab])
const scheduleRangeResolve = useCallback((start: number, end: number) => {
bumpRuntimeCounter('rangeSchedules')
const armRangeTimer = () => {
if (rangeTimerRef.current !== null) return
if (document.visibilityState === 'hidden') return
rangeTimerRef.current = window.setTimeout(flushRangeResolve, 120)
}
const previous = pendingRangeRef.current
if (previous && start >= previous.start && end <= previous.end) {
bumpRuntimeCounter('rangeContainedSkips')
armRangeTimer()
return
}
pendingRangeRef.current = { start, end }
if (rangeTimerRef.current !== null) {
window.clearTimeout(rangeTimerRef.current)
rangeTimerRef.current = null
if (previous) {
const merged = mergeNearbyRange(previous, { start, end }, 18)
pendingRangeRef.current = merged.range
bumpRuntimeCounter(merged.merged ? 'rangePendingMerges' : 'rangePendingReplacements')
} else {
pendingRangeRef.current = { start, end }
}
rangeTimerRef.current = window.setTimeout(flushRangeResolve, 120)
}, [flushRangeResolve])
armRangeTimer()
}, [bumpRuntimeCounter, flushRangeResolve])
useEffect(() => {
if (displayItems.length === 0) return
if (tab === 'image') {
preloadImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_PRELOAD_END))
resolveImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_RESOLVE_END))
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') return
if (itemsLengthRef.current <= 0) return
const range = lastVisibleRangeRef.current || {
start: 0,
end: Math.min(itemsLengthRef.current - 1, INITIAL_IMAGE_RESOLVE_END)
}
bumpRuntimeCounter('rangeVisibilityReschedules')
scheduleRangeResolve(range.start, range.end)
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [bumpRuntimeCounter, scheduleRangeResolve])
useEffect(() => {
if (items.length === 0) return
if (document.visibilityState === 'hidden') {
bumpRuntimeCounter('rangeHiddenSkips')
return
}
resolvePosterRange(0, Math.min(displayItems.length - 1, 12))
}, [displayItems, preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab])
const scheduleKey = `${preloadScopeRef.current}|${tab}`
if (initialRangeScheduleKeyRef.current === scheduleKey) return
initialRangeScheduleKeyRef.current = scheduleKey
if (tab === 'image') {
preloadImageCacheRange(0, Math.min(items.length - 1, INITIAL_IMAGE_PRELOAD_END))
resolveImageCacheRange(0, Math.min(items.length - 1, INITIAL_IMAGE_RESOLVE_END))
schedulePredecryptRange(0, Math.min(items.length - 1, INITIAL_IMAGE_PREDECRYPT_END))
return
}
resolvePosterRange(0, Math.min(items.length - 1, 12))
}, [bumpRuntimeCounter, items, preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, schedulePredecryptRange, tab])
const selectedItems = useMemo(() => {
if (selectedKeys.size === 0) return []
return displayItems.filter((item) => selectedKeys.has(getItemKey(item)))
}, [displayItems, selectedKeys])
const selected: MediaStreamItem[] = []
for (const key of selectedKeys) {
const item = itemByKeyRef.current[key]
if (item) selected.push(item)
}
return selected
}, [selectedKeys])
const toggleSelect = useCallback((item: MediaStreamItem) => {
const key = getItemKey(item)
const key = getMeta(item).itemKey
setSelectedKeys((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
selectedKeysRef.current = next
updateMediaCardState(key, { selected: next.has(key) })
return next
})
}, [])
}, [getMeta, updateMediaCardState])
const onImageLoaded = useCallback((item: MediaStreamItem, width: number, height: number) => {
if (item.mediaType !== 'image') return
if (!width || !height) return
const ratio = height / width
if (!Number.isFinite(ratio) || ratio <= 0) return
const itemKey = getItemKey(item)
const itemKey = getMeta(item).itemKey
queueAspectPatch(itemKey, ratio)
}, [queueAspectPatch])
}, [getMeta, queueAspectPatch])
const deleteOne = useCallback((item: MediaStreamItem) => {
showConfirm('确认删除该原始记录?此操作不可恢复。', () => {
@@ -1011,29 +2247,24 @@ function ResourcesPage() {
return
}
const key = getItemKey(item)
setItems((prev) => prev.filter((row) => getItemKey(row) !== key))
const key = getMeta(item).itemKey
removeResourceIndexItem(key, getMeta(item))
removeMediaCardState(key)
const deletedKeys = new Set([key])
setItems((prev) => removeItemsByKeys(prev, deletedKeys, getMeta))
setSelectedKeys((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
setPreviewPathMap((prev) => {
if (!prev[key]) return prev
const next = { ...prev }
delete next[key]
return next
})
setPreviewUpdateMap((prev) => {
if (prev[key] === undefined) return prev
const next = { ...prev }
delete next[key]
const next = removeSetValue(prev, key)
if (next !== prev) selectedKeysRef.current = next
return next
})
previewPathMapRef.current = removeRecordKeys(previewPathMapRef.current, deletedKeys)
previewUpdateMapRef.current = removeRecordKeys(previewUpdateMapRef.current, deletedKeys)
videoPosterMapRef.current = removeRecordKeys(videoPosterMapRef.current, deletedKeys)
imageAspectMapRef.current = removeRecordKeys(imageAspectMapRef.current, deletedKeys)
setActionMessage('删除成功')
})()
}, '删除确认')
}, [showAlert, showConfirm])
}, [getMeta, removeMediaCardState, removeResourceIndexItem, showAlert, showConfirm])
const batchDelete = useCallback(() => {
if (selectedItems.length === 0 || batchBusy) return
@@ -1048,22 +2279,29 @@ function ResourcesPage() {
const result = await window.electronAPI.chat.deleteMessage(item.sessionId, item.localId, item.createTime)
if (result.success) {
success += 1
deletedKeys.add(getItemKey(item))
deletedKeys.add(getMeta(item).itemKey)
}
}
setItems((prev) => prev.filter((item) => !deletedKeys.has(getItemKey(item))))
setSelectedKeys(new Set())
setPreviewPathMap((prev) => {
const next = { ...prev }
deletedKeys.forEach((key) => { delete next[key] })
return next
})
setPreviewUpdateMap((prev) => {
const next = { ...prev }
deletedKeys.forEach((key) => { delete next[key] })
return next
if (deletedKeys.size > 0) {
setItems((prev) => removeItemsByKeys(prev, deletedKeys, getMeta))
selectedItems.forEach((item) => {
const meta = getMeta(item)
if (deletedKeys.has(meta.itemKey)) {
removeResourceIndexItem(meta.itemKey, meta)
removeMediaCardState(meta.itemKey)
}
})
}
setSelectedKeys(clearSet)
selectedKeysRef.current = new Set()
selectedItems.forEach((item) => {
updateMediaCardState(getMeta(item).itemKey, { selected: false })
})
previewPathMapRef.current = removeRecordKeys(previewPathMapRef.current, deletedKeys)
previewUpdateMapRef.current = removeRecordKeys(previewUpdateMapRef.current, deletedKeys)
videoPosterMapRef.current = removeRecordKeys(videoPosterMapRef.current, deletedKeys)
imageAspectMapRef.current = removeRecordKeys(imageAspectMapRef.current, deletedKeys)
setActionMessage(`批量删除完成:成功 ${success},失败 ${selectedItems.length - success}`)
showAlert(`批量删除完成:成功 ${success},失败 ${selectedItems.length - success}`, '批量删除完成')
} finally {
@@ -1071,7 +2309,7 @@ function ResourcesPage() {
}
})()
}, '批量删除确认')
}, [batchBusy, selectedItems, showAlert, showConfirm])
}, [batchBusy, getMeta, removeMediaCardState, removeResourceIndexItem, selectedItems, showAlert, showConfirm, updateMediaCardState])
const decryptImage = useCallback(async (
item: MediaStreamItem,
@@ -1079,17 +2317,17 @@ function ResourcesPage() {
): Promise<string | undefined> => {
if (item.mediaType !== 'image') return
const key = getItemKey(item)
const key = getMeta(item).itemKey
if (!hasImageLocator(item)) {
showAlert('当前图片缺少解密所需字段(imageMd5/imageDatName)', '无法解密')
return
}
setDecryptingKeys((prev) => {
const next = new Set(prev)
next.add(key)
return next
})
const nextDecryptingKeys = addSetValue(decryptingKeysRef.current, key)
if (nextDecryptingKeys !== decryptingKeysRef.current) {
decryptingKeysRef.current = nextDecryptingKeys
updateMediaCardState(key, { decrypting: true })
}
try {
const result = await window.electronAPI.image.decrypt({
@@ -1104,6 +2342,7 @@ function ResourcesPage() {
suppressEvents: true
})
if (!result?.success) {
if (!isCurrentItemKey(key)) return undefined
if (result?.failureKind === 'decrypt_failed') {
showAlert(`解密失败:${result?.error || '解密后不是有效图片'}`, '解密失败')
} else {
@@ -1114,9 +2353,10 @@ function ResourcesPage() {
if (result.localPath) {
const localPath = result.localPath as string
setPreviewPathMap((prev) => ({ ...prev, [key]: localPath }))
setPreviewUpdateMap((prev) => ({ ...prev, [key]: isLikelyThumbnailPreview(localPath) }))
setActionMessage('图片解密完成')
if (isCurrentItemKey(key)) {
queuePreviewPatch(key, localPath, isLikelyThumbnailPreview(localPath))
setActionMessage('图片解密完成')
}
return localPath
}
try {
@@ -1132,34 +2372,38 @@ function ResourcesPage() {
})
if (resolved?.success && resolved.localPath) {
const localPath = resolved.localPath
setPreviewPathMap((prev) => ({ ...prev, [key]: localPath }))
setPreviewUpdateMap((prev) => ({ ...prev, [key]: Boolean(resolved.hasUpdate) }))
setActionMessage('图片解密完成')
if (isCurrentItemKey(key)) {
queuePreviewPatch(key, localPath, Boolean(resolved.hasUpdate))
setActionMessage('图片解密完成')
}
return localPath
}
} catch {
// ignore
}
setActionMessage('图片解密完成')
if (isCurrentItemKey(key)) setActionMessage('图片解密完成')
return undefined
} catch (e) {
if (!isCurrentItemKey(key)) return undefined
showAlert(`本地无数据:${String(e)}`, '未找到本地数据')
return undefined
} finally {
setDecryptingKeys((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
const nextDecryptingKeys = removeSetValue(decryptingKeysRef.current, key)
if (nextDecryptingKeys !== decryptingKeysRef.current) {
decryptingKeysRef.current = nextDecryptingKeys
updateMediaCardState(key, { decrypting: false })
}
}
}, [isLikelyThumbnailPreview, showAlert])
}, [getMeta, isCurrentItemKey, isLikelyThumbnailPreview, queuePreviewPatch, showAlert, updateMediaCardState])
const onImagePreviewAction = useCallback(async (item: MediaStreamItem) => {
if (item.mediaType !== 'image') return
const key = getItemKey(item)
const key = getMeta(item).itemKey
let localPath = previewPathMapRef.current[key] || previewPatchRef.current[key] || ''
const hadRenderedPreviewAtClick = Boolean(localPath)
if (!isCurrentItemKey(key)) return
if (localPath) {
if (hadRenderedPreviewAtClick) {
try {
const resolved = await window.electronAPI.image.resolveCache({
sessionId: item.sessionId,
@@ -1178,12 +2422,17 @@ function ResourcesPage() {
} catch {
// ignore
}
if (localPath) {
if (localPath && isCurrentItemKey(key)) {
await window.electronAPI.window.openImageViewerWindow(localPath)
return
}
}
if (!hadRenderedPreviewAtClick) {
await decryptImage(item)
return
}
try {
const resolved = await window.electronAPI.image.resolveCache({
sessionId: item.sessionId,
@@ -1198,7 +2447,6 @@ function ResourcesPage() {
if (resolved?.success && resolved.localPath) {
localPath = resolved.localPath
queuePreviewPatch(key, localPath, Boolean(resolved.hasUpdate))
await window.electronAPI.window.openImageViewerWindow(localPath)
return
}
} catch {
@@ -1206,7 +2454,7 @@ function ResourcesPage() {
}
await decryptImage(item)
}, [decryptImage, queuePreviewPatch])
}, [decryptImage, getMeta, isCurrentItemKey, queuePreviewPatch])
const updateImageQuality = useCallback(async (item: MediaStreamItem) => {
await decryptImage(item)
@@ -1267,7 +2515,11 @@ function ResourcesPage() {
}
if (hardlinkMd5Set.size > 0) {
try {
await window.electronAPI.image.preloadHardlinkMd5s(Array.from(hardlinkMd5Set))
await window.electronAPI.image.preloadHardlinkMd5s(Array.from(hardlinkMd5Set), {
chunkSize: BATCH_IMAGE_HARDLINK_PRELOAD_CHUNK_SIZE,
yieldMs: BATCH_IMAGE_HARDLINK_PRELOAD_YIELD_MS,
filesystemFallback: false
})
} catch {
// ignore preload failures and continue decrypt
}
@@ -1303,9 +2555,14 @@ function ResourcesPage() {
} else {
success += 1
if (result.localPath) {
const key = getItemKey(item)
const key = getMeta(item).itemKey
if (!isCurrentItemKey(key)) continue
previewPatch[key] = result.localPath
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
updateMediaCardState(key, {
previewPath: result.localPath,
hasPreviewUpdate: isLikelyThumbnailPreview(result.localPath)
})
}
}
} catch {
@@ -1313,17 +2570,38 @@ function ResourcesPage() {
} finally {
completed += 1
updateTaskProgress()
if (BATCH_IMAGE_DECRYPT_YIELD_MS > 0 && cursor < imageItems.length) {
await waitForBatchDecryptYield()
}
}
}
}
await Promise.all(Array.from({ length: concurrency }, () => worker()))
updateTaskProgress(true)
if (Object.keys(previewPatch).length > 0) {
setPreviewPathMap((prev) => ({ ...prev, ...previewPatch }))
if (hasRecordEntries(previewPatch)) {
const validPatch: Record<string, string> = {}
let hasValidPatch = false
for (const key in previewPatch) {
if (!isCurrentItemKey(key)) continue
validPatch[key] = previewPatch[key]
hasValidPatch = true
}
if (hasValidPatch) {
previewPathMapRef.current = mergeRecordPatch(previewPathMapRef.current, validPatch)
}
}
if (Object.keys(updatePatch).length > 0) {
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
if (hasRecordEntries(updatePatch)) {
const validPatch: Record<string, boolean> = {}
let hasValidPatch = false
for (const key in updatePatch) {
if (!isCurrentItemKey(key)) continue
validPatch[key] = updatePatch[key]
hasValidPatch = true
}
if (hasValidPatch) {
previewUpdateMapRef.current = mergeRecordPatch(previewUpdateMapRef.current, validPatch)
}
}
setActionMessage(`批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`)
showAlert(`批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`, '批量解密完成')
@@ -1339,7 +2617,7 @@ function ResourcesPage() {
} finally {
setBatchBusy(false)
}
}, [batchBusy, isLikelyThumbnailPreview, selectedItems, showAlert])
}, [batchBusy, getMeta, isCurrentItemKey, isLikelyThumbnailPreview, selectedItems, showAlert, updateMediaCardState])
const openVideo = useCallback(async (item: MediaStreamItem) => {
if (item.mediaType !== 'video') return
@@ -1359,6 +2637,59 @@ function ResourcesPage() {
await window.electronAPI.window.openVideoPlayerWindow(info.videoUrl)
}, [resolveItemVideoMd5, showAlert])
const computeGridItemKey = useCallback((_: number, item: MediaStreamItem) => {
return getMeta(item).itemKey
}, [getMeta])
const handleGridRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => {
lastRangeActivityAtRef.current = Date.now()
const nextRange = { start: range.startIndex - 3, end: range.endIndex + 6 }
const previousRange = lastVisibleRangeRef.current
if (previousRange && previousRange.start === nextRange.start && previousRange.end === nextRange.end) {
bumpRuntimeCounter('rangeDuplicateSkips')
return
}
lastVisibleRangeRef.current = nextRange
scheduleRangeResolve(nextRange.start, nextRange.end)
scheduleTransientStatePrune(nextRange)
}, [bumpRuntimeCounter, scheduleRangeResolve, scheduleTransientStatePrune])
const handleGridEndReached = useCallback(() => {
if (!hasMoreRef.current || loadingRef.current || loadingMoreRef.current) return
void loadStream(false)
}, [loadStream])
const renderGridItem = useCallback((_: number, item: MediaStreamItem) => {
const meta = getMeta(item)
const itemKey = meta.itemKey
return (
<MediaCardContainer
item={item}
itemKey={itemKey}
stateStore={mediaCardStateStoreRef.current}
sessionName={item.sessionDisplayName || sessionNameMap[item.sessionId] || item.sessionId}
videoTitle={meta.videoTitle}
onToggleSelect={toggleSelect}
onDelete={deleteOne}
onShowInfo={showMediaInfo}
onImagePreviewAction={onImagePreviewAction}
onUpdateImageQuality={updateImageQuality}
onOpenVideo={openVideo}
onImageLoaded={onImageLoaded}
/>
)
}, [
deleteOne,
getMeta,
onImageLoaded,
onImagePreviewAction,
openVideo,
sessionNameMap,
showMediaInfo,
toggleSelect,
updateImageQuality
])
return (
<div className="resources-page stream-rebuild">
<header className="stream-toolbar">
@@ -1442,43 +2773,14 @@ function ResourcesPage() {
<VirtuosoGrid
className="stream-grid"
overscan={48}
components={{
List: GridList,
Item: GridItem
}}
data={displayItems}
computeItemKey={(_, item) => getItemKey(item)}
rangeChanged={(range) => {
scheduleRangeResolve(range.startIndex - 3, range.endIndex + 6)
}}
endReached={() => {
if (!hasMore || loading || loadingMore) return
void loadStream(false)
}}
itemContent={(_, item) => {
const itemKey = getItemKey(item)
const aspect = imageAspectMap[itemKey] || 0
return (
<MediaCard
item={item}
sessionName={item.sessionDisplayName || item.sessionId}
previewPath={previewPathMap[itemKey] || ''}
videoPosterPath={videoPosterMap[itemKey] || ''}
imageIsLong={aspect >= 2.8}
hasPreviewUpdate={Boolean(previewUpdateMap[itemKey])}
selected={selectedKeys.has(itemKey)}
decrypting={decryptingKeys.has(itemKey)}
onToggleSelect={toggleSelect}
onDelete={deleteOne}
onShowInfo={showMediaInfo}
onImagePreviewAction={onImagePreviewAction}
onUpdateImageQuality={updateImageQuality}
onOpenVideo={openVideo}
onImageLoaded={onImageLoaded}
/>
)
}}
components={GRID_COMPONENTS}
data={items}
computeItemKey={computeGridItemKey}
rangeChanged={handleGridRangeChanged}
endReached={handleGridEndReached}
itemContent={renderGridItem}
/>
{loading && <div className="grid-refreshing"><Loader2 size={16} className="spin" /> 正在刷新...</div>}
{loadingMore && <div className="grid-loading-more"><Loader2 size={16} className="spin" /> 加载更多中...</div>}
{!hasMore && <div className="grid-end">已加载到底</div>}
</div>
+164
View File
@@ -0,0 +1,164 @@
export type MediaTab = 'image' | 'video'
export interface MediaStreamItem {
sessionId: string
sessionDisplayName?: string
mediaType: 'image' | 'video'
localId: number
serverId?: string
createTime: number
localType: number
senderUsername?: string
isSend?: number | null
imageMd5?: string
imageDatName?: string
videoMd5?: string
content?: string
}
export interface ContactOption {
id: string
name: string
}
export type ImagePreloadPayload = {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
}
export type DialogState = {
mode: 'alert' | 'confirm' | 'info'
title: string
message?: string
infoRows?: Array<{ label: string; value: string }>
confirmText?: string
cancelText?: string
onConfirm?: (() => void) | null
}
export const PAGE_SIZE = 96
export const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 12
export const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 12
export const MAX_IMAGE_PREDECRYPT_PER_TICK = 4
export const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 8
export const MAX_MEDIA_PATCHES_PER_FLUSH = 32
export const INITIAL_IMAGE_PRELOAD_END = 48
export const INITIAL_IMAGE_RESOLVE_END = 12
export const INITIAL_IMAGE_PREDECRYPT_END = 28
export const IMAGE_PREDECRYPT_LOOKAHEAD = 36
export const IMAGE_PREDECRYPT_IDLE_DELAY_MS = 1200
export const IMAGE_PREDECRYPT_TIMER_MS = 900
export const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
export const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
export const BATCH_IMAGE_DECRYPT_CONCURRENCY = 2
export const BATCH_IMAGE_DECRYPT_YIELD_MS = 16
export const BATCH_IMAGE_HARDLINK_PRELOAD_CHUNK_SIZE = 8
export const BATCH_IMAGE_HARDLINK_PRELOAD_YIELD_MS = 16
export function getRangeTimestampStart(date: string): number | undefined {
if (!date) return undefined
const parsed = new Date(`${date}T00:00:00`)
const n = Math.floor(parsed.getTime() / 1000)
return Number.isFinite(n) ? n : undefined
}
export function getRangeTimestampEnd(date: string): number | undefined {
if (!date) return undefined
const parsed = new Date(`${date}T23:59:59`)
const n = Math.floor(parsed.getTime() / 1000)
return Number.isFinite(n) ? n : undefined
}
export function normalizeMediaToken(value?: string): string {
return String(value || '').trim().toLowerCase()
}
export function getSafeImageDatName(item: Pick<MediaStreamItem, 'imageDatName' | 'imageMd5'>): string {
const datName = normalizeMediaToken(item.imageDatName)
if (!datName) return ''
return datName
}
export function hasImageLocator(item: Pick<MediaStreamItem, 'imageDatName' | 'imageMd5'>): boolean {
return Boolean(normalizeMediaToken(item.imageMd5) || getSafeImageDatName(item))
}
export function getItemKey(item: MediaStreamItem): string {
const sessionId = String(item.sessionId || '').trim().toLowerCase()
const localId = Number(item.localId || 0)
if (sessionId && Number.isFinite(localId) && localId > 0) {
return `${sessionId}|${localId}`
}
const serverId = String(item.serverId || '').trim().toLowerCase()
const createTime = Number(item.createTime || 0)
const localType = Number(item.localType || 0)
const mediaId = String(
item.mediaType === 'video'
? (item.videoMd5 || '')
: (item.imageMd5 || getSafeImageDatName(item) || '')
).trim().toLowerCase()
return `${sessionId}|${createTime}|${localType}|${serverId}|${mediaId}`
}
export function formatTimeLabel(timestampSec: number): string {
if (!timestampSec) return '--:--'
return new Date(timestampSec * 1000).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
export function formatInfoValue(value: unknown): string {
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
export function extractVideoTitle(content?: string): string {
const xml = String(content || '')
if (!xml) return '视频'
const match = /<title>([\s\S]*?)<\/title>/i.exec(xml)
const text = String(match?.[1] || '').replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
return text || '视频'
}
export function extractVideoMd5(content?: string): string {
const xml = String(content || '')
if (!xml) return ''
const patterns = [
/<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i,
/<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i,
/(?:^|[^a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i,
/<md5>([a-fA-F0-9]+)<\/md5>/i,
/\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i
]
for (const pattern of patterns) {
const match = pattern.exec(xml)
if (match?.[1]) return match[1].toLowerCase()
}
return ''
}
export function toRenderableMediaSrc(rawPath?: string): string {
const src = String(rawPath || '').trim()
if (!src) return ''
if (/^(data:image\/|blob:|https?:\/\/)/i.test(src)) {
return src
}
if (/^file:\/\//i.test(src)) {
return src.replace(/#/g, '%23')
}
if (src.startsWith('/')) {
return encodeURI(`file://${src}`).replace(/#/g, '%23')
}
if (/^[a-zA-Z]:[\\/]/.test(src)) {
return encodeURI(`file:///${src.replace(/\\/g, '/')}`).replace(/#/g, '%23')
}
return encodeURI(`file://${src.startsWith('/') ? '' : '/'}${src.replace(/\\/g, '/')}`).replace(/#/g, '%23')
}
+167 -7
View File
@@ -428,6 +428,73 @@ export interface ElectronAPI {
}
}>
clearExportCardLogs: () => Promise<{ success: boolean }>
recordResourceStats: (payload: unknown) => Promise<{ success: boolean; count?: number }>
getResourceStats: (options?: { limit?: number }) => Promise<{
entries: Array<{ ts: number; payload: Record<string, unknown> }>
summary: {
count: number
firstTs: number
lastTs: number
samples?: number
longFrames?: number
maxRecentFrameMs?: number
maxQueued?: number
maxQueuedCache?: number
maxQueuedDecrypt?: number
maxQueuedHigh?: number
maxQueuedNormal?: number
maxQueuedLow?: number
maxPending?: number
maxActiveCache?: number
maxActiveDecrypt?: number
maxHighWaterQueued?: number
maxHighWaterQueuedDecrypt?: number
maxHighWaterQueuedLow?: number
maxHighWaterActiveDecrypt?: number
mediaStreamLoadSamples?: number
mediaStreamAvgLoadMs?: number
mediaStreamMaxLoadMs?: number
mediaStreamNativeLoads?: number
mediaStreamPageCacheHits?: number
mediaStreamInflightMerges?: number
mediaStreamAvoidedNativeLoads?: number
mediaStreamAvoidedNativeRate?: number
mediaStreamPageCacheHitRate?: number
mediaStreamRowsLoaded?: number
mediaStreamDuplicateRows?: number
mediaStreamDuplicateRate?: number
mediaStreamNoProgressStops?: number
preloadAccepted?: number
preloadDeduped?: number
preloadHandled?: number
preloadDedupRate?: number
preloadCanceledActive?: number
preloadDroppedQueued?: number
preloadDeferredLowPriority?: number
preloadLowPriorityIdleDeferrals?: number
preloadActiveCacheSnapshots?: number
preloadActiveCacheSnapshotSkipped?: number
preloadActiveCacheSnapshotCanceled?: number
preloadLowPriorityRejected?: number
imagePreloadRejectedCapacity?: number
imagePredecryptRequests?: number
imagePredecryptBackpressureSkips?: number
imagePredecryptRejectedCapacity?: number
imagePredecryptDeferred?: number
imagePredecryptPreviewUpgrades?: number
imagePredecryptRejectRate?: number
imagePredecryptBackpressureRate?: number
predecryptHiddenSkips?: number
rangeHiddenSkips?: number
rangeDuplicateSkips?: number
rangeVisibilityReschedules?: number
transientStatePruneRuns?: number
preloadTotalsBaselineCaptured?: boolean
counterDeltas?: Record<string, number>
preloadTotalDeltas?: Record<string, number>
}
}>
clearResourceStats: () => Promise<{ success: boolean }>
exportExportCardLogs: (payload: {
filePath: string
frontendLogs?: unknown[]
@@ -726,6 +793,12 @@ export interface ElectronAPI {
}>
hasMore?: boolean
nextOffset?: number
streamSource?: 'native' | 'pageCache' | 'inflight'
pageCacheHit?: boolean
inflightMerged?: boolean
nativeLimit?: number
nativeRows?: number
elapsedMs?: number
error?: string
}>
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
@@ -840,6 +913,8 @@ export interface ElectronAPI {
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
allowCachePromotion?: boolean
allowFilesystemScan?: boolean
suppressEvents?: boolean
}) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string; failureKind?: 'not_found' | 'decrypt_failed' }>
resolveCache: (payload: {
@@ -851,11 +926,13 @@ export interface ElectronAPI {
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
allowCachePromotion?: boolean
allowFilesystemScan?: boolean
suppressEvents?: boolean
}) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string; failureKind?: 'not_found' | 'decrypt_failed' }>
resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; allowCachePromotion?: boolean; allowFilesystemScan?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
) => Promise<{
success: boolean
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string; failureKind?: 'not_found' | 'decrypt_failed' }>
@@ -863,11 +940,79 @@ export interface ElectronAPI {
}>
preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => Promise<boolean>
preloadHardlinkMd5s: (md5List: string[]) => Promise<boolean>
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean; allowFilesystemScan?: boolean; emitResolved?: boolean; scope?: string; priority?: 'high' | 'normal' | 'low' }
) => Promise<{
success: true
requested: number
accepted: number
mergedQueued: number
skippedActive: number
skippedPending: number
ignoredCanceled: number
rejectedCapacity: number
deferred: number
handledIdentities: string[]
acceptedIdentities: string[]
mergedQueuedIdentities: string[]
skippedActiveIdentities: string[]
skippedPendingIdentities: string[]
rejectedIdentities: string[]
deferredIdentities: string[]
}>
cancelPreloadScope: (scope: string) => Promise<boolean>
getPreloadStats: () => Promise<{
queued: number
pending: number
activeCache: number
activeDecrypt: number
queuedIdentities: number
queuedCache: number
queuedDecrypt: number
queuedHigh: number
queuedNormal: number
queuedLow: number
activeIdentities: number
queuedScopes: number
activeScopes: number
canceledScopes: number
highWater: {
queued: number
pending: number
queuedCache: number
queuedDecrypt: number
queuedHigh: number
queuedNormal: number
queuedLow: number
activeCache: number
activeDecrypt: number
activeIdentities: number
}
totals: {
accepted: number
mergedQueued: number
skippedActive: number
ignoredCanceled: number
droppedQueued: number
canceledQueued: number
canceledActive: number
promotedActive: number
rejectedCapacity: number
deferredLowPriority: number
lowPriorityIdleDeferrals: number
skippedPending: number
forcedThumbnailRefreshes: number
forcedThumbnailRefreshFailures: number
lowPriorityRejected: number
activeCacheSnapshots: number
activeCacheSnapshotSkipped: number
activeCacheSnapshotCanceled: number
started: number
completed: number
}
}>
preloadHardlinkMd5s: (md5List: string[], options?: { chunkSize?: number; yieldMs?: number; filesystemFallback?: boolean }) => Promise<boolean>
onUpdateAvailable: (callback: (payload: { cacheKey: string; sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string }) => void) => () => void
onCacheResolved: (callback: (payload: { cacheKey: string; sessionId?: string; createTime?: number; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
onDecryptProgress: (callback: (payload: {
cacheKey: string
imageMd5?: string
@@ -887,6 +1032,20 @@ export interface ElectronAPI {
thumbUrl?: string
error?: string
}>
getVideoInfoBatch: (videoMd5List: string[], options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => Promise<{
success: boolean
rows?: Array<{
index: number
md5: string
success: boolean
exists: boolean
videoUrl?: string
coverUrl?: string
thumbUrl?: string
error?: string
}>
error?: string
}>
parseVideoMd5: (content: string) => Promise<{
success: boolean
md5?: string
@@ -1529,12 +1688,13 @@ export interface ElectronAPI {
}
export interface ExportOptions {
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'markdown' | 'txt' | 'excel' | 'weclone' | 'sql'
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
dateRange?: { start: number; end: number } | null
senderUsername?: string
fileNameSuffix?: string
exportMedia?: boolean
exportConflictStrategy?: 'incremental' | 'overwrite' | 'rename'
exportAvatars?: boolean
exportImages?: boolean
exportVoices?: boolean