import { ref, reactive } from 'vue' import { apiCall } from '@/api' import { isTauri, getApiBase } from './api/config' import type { PoseResponse, AppearanceResponse, FaceLandmarksResponse } from '@/api/types' export const filesCache = ref([]) export const filesLoaded = ref(false) export const peopleCache = ref([]) export const peopleLoaded = ref(false) export const faceCandidatesCache = ref([]) export const faceCandidatesLoaded = ref(false) export const unassignedTracesCache = ref([]) export const unassignedTracesLoaded = ref(false) export const unassignedTracesTotal = ref(0) export const unassignedTracesPage = ref(1) export const faceCountsCache = ref>({}) const UNASSIGNED_PER_PAGE = 20 export async function loadFaceCount(fileUuid: string) { if (faceCountsCache.value[fileUuid] !== undefined) return try { const result = await apiCall('get_file_traces', { fileUuid, page: 1, page_size: 1, limit: 1 }) faceCountsCache.value[fileUuid] = result.total || 0 } catch (e) { console.error('loadFaceCount error:', e) } } export const thumbnailsCache = ref>({}) export const profilesCache = ref>({}) export const faceThumbsCache = ref>({}) export const poseCache = ref>({}) export const appearanceCache = ref>({}) export const faceLandmarksCache = ref>({}) let thumbCacheBust = 0 export function bumpThumbCacheBust() { thumbCacheBust += 1 } export function clearFaceThumbs() { for (const k of Object.keys(faceThumbsCache.value)) { const v = faceThumbsCache.value[k] if (v && v.startsWith('blob:')) URL.revokeObjectURL(v) } faceThumbsCache.value = {} } export interface ProcessorOutputInfo { has_json: boolean frame_count: number | null segment_count: number | null chunk_count: number | null last_modified: string | null } export interface ProcessorProgress { processor_type: string status: 'pending' | 'running' | 'complete' | 'failed' progress: number current?: number total?: number eta_seconds?: number } export interface FileProgress { file_uuid: string overall_progress: number processors: ProcessorProgress[] cpu_percent?: number gpu_percent?: number memory_percent?: number } // Pipeline阶段接口 export interface PipelineStage { name: string weight: number progress: number status: 'pending' | 'running' | 'completed' | 'failed' } export interface PipelineStats { overall_progress: number stages: PipelineStage[] } export interface SystemStats { cpu_used_percent: number memory_used_percent: number memory_available_mb: number memory_total_mb: number swap_used_percent: number swap_available_mb: number swap_total_mb: number gpu_available: boolean gpu_utilization: number | null gpu_memory_used_pct: number | null ane_available: boolean ane_utilization: number | null source: string chip_name?: string cpu_power?: number gpu_power?: number ane_power?: number sys_power?: number ram_power?: number cpu_temp?: number gpu_temp?: number ecpu_freq_mhz?: number pcpu_freq_mhz?: number gpu_freq_mhz?: number ecpu_cores?: number pcpu_cores?: number gpu_cores?: number } // File详细统计接口 export interface JsonProcessorInfo { status: string segment_count?: number frame_count?: number chunk_count?: number } export interface JsonStats { file_name: string status: string processors: Record } export interface PostgresqlStats { sentence_chunks: number trace_chunks: number relationship_chunks: number identities: number file_identities: number } export interface QdrantStats { faces: number face_traces: number face_identities: number text_chunks: number speakers: number } export interface TkgNodeStats { character: number object: number scene: number action: number location: number event: number dialogue: number emotion: number relation: number } export interface TkgEdgeStats { appear_in: number interact_with: number located_at: number perform_action: number cause_effect: number temporal_link: number speak_to: number feel: number } export interface TkgStats { nodes: TkgNodeStats edges: TkgEdgeStats total_nodes: number total_edges: number } export interface IdentityAgentStats { clusters: number identities_created: number tmdb_matches: number speaker_bindings: number confirmations: number } export interface FileStats { json: JsonStats postgresql: PostgresqlStats qdrant: QdrantStats tkg: TkgStats identity_agent: IdentityAgentStats vlm_summary: VlmSummaryStats scene_vlm: SceneVlmStats } export interface VlmSummaryStats { total: number done: number } export interface SceneVlmStats { total_scenes: number status: string vlm_done: number vlm_total: number } export const processorCountsCache = ref>>({}) export const fileProgressCache = ref>({}) export const fileStatusCache = ref>({}) // Cluster agent state (persistent across navigation) export const clusterResultsCache = ref>({}) export const clusterRunningState = ref>({}) const clusterRunningSince = ref>({}) const CLUSTER_RUNNING_STALE_MS = 5 * 60 * 1000 function loadClusterStateFromStorage() { try { const saved = localStorage.getItem('momentry_cluster_running') if (saved) clusterRunningState.value = JSON.parse(saved) } catch (e) {} try { const since = localStorage.getItem('momentry_cluster_running_since') if (since) clusterRunningSince.value = JSON.parse(since) } catch (e) {} const now = Date.now() let changed = false for (const uuid of Object.keys(clusterRunningState.value)) { const sinceTs = clusterRunningSince.value[uuid] if (clusterRunningState.value[uuid] && (!sinceTs || now - sinceTs > CLUSTER_RUNNING_STALE_MS)) { clusterRunningState.value[uuid] = false delete clusterRunningSince.value[uuid] changed = true } } if (changed) saveClusterStateToStorage() } function saveClusterStateToStorage() { try { localStorage.setItem('momentry_cluster_running', JSON.stringify(clusterRunningState.value)) } catch (e) {} try { localStorage.setItem('momentry_cluster_running_since', JSON.stringify(clusterRunningSince.value)) } catch (e) {} } loadClusterStateFromStorage() export function setClusterRunning(fileUuid: string, running: boolean) { clusterRunningState.value[fileUuid] = running if (running) { clusterRunningSince.value[fileUuid] = Date.now() } else { delete clusterRunningSince.value[fileUuid] } saveClusterStateToStorage() } export function isClusterRunning(fileUuid: string): boolean { return clusterRunningState.value[fileUuid] || false } export async function pollClusterResults(fileUuid: string, onDone?: (results: any[]) => void) { if (!fileUuid) return setClusterRunning(fileUuid, true) try { for (let i = 0; i < 120; i++) { if (!isClusterRunning(fileUuid)) return await new Promise(r => setTimeout(r, 3000)) try { const results: any = await apiCall('get_cluster_results', { fileHash: fileUuid }) const groups = results?.face_groups || results?.clusters || [] if (groups.length) { await loadClusterNames(fileUuid, groups) clusterResultsCache.value[fileUuid] = groups setClusterRunning(fileUuid, false) onDone?.(groups) return } } catch { /* not ready */ } } } finally { setClusterRunning(fileUuid, false) } } export async function loadClusterNames(fileUuid: string, clusters: any[]) { console.log('[loadClusterNames] loading names for', clusters.length, 'clusters') const promises = clusters.map(async (c) => { const repTid = c.representative_trace !== undefined && c.representative_trace !== null ? c.representative_trace : c.trace_ids?.[0] if (repTid != null) { try { const key = `${fileUuid}:${repTid}` delete traceProfileCache.value[key] const profile = await getTraceProfile(fileUuid, repTid) console.log('[loadClusterNames] cluster', c.cluster_id, 'trace', repTid, 'profile:', profile?.name) if (profile?.name) c.name = profile.name } catch (e) { console.error('[loadClusterNames] failed:', repTid, e) } } }) await Promise.all(promises) console.log('[loadClusterNames] done, clusters:', clusters.map(c => ({ id: c.cluster_id, name: c.name }))) } export function getClusterResults(fileUuid: string): any[] { return clusterResultsCache.value[fileUuid] || [] } export function setClusterResults(fileUuid: string, results: any[]) { clusterResultsCache.value[fileUuid] = results } export function clearClusterState(fileUuid: string) { delete clusterRunningState.value[fileUuid] delete clusterResultsCache.value[fileUuid] saveClusterStateToStorage() } // Trace & File Profile cache export const traceProfileCache = ref>({}) export const fileProfileCache = ref>({}) export async function getTraceProfile(fileUuid: string, traceId: number) { const key = `${fileUuid}:${traceId}` if (traceProfileCache.value[key]) return traceProfileCache.value[key] try { const data = await apiCall('get_trace_profile', { fileUuid, traceId }) if (data) traceProfileCache.value[key] = data return data } catch (e) { console.error('getTraceProfile failed:', fileUuid, traceId, e) return null } } export async function updateTraceProfile(fileUuid: string, traceId: number, updates: any) { try { const data = await apiCall('update_trace_profile', { fileUuid, traceId, ...updates }) const key = `${fileUuid}:${traceId}` if (data) traceProfileCache.value[key] = data return data } catch (e) { console.error('updateTraceProfile failed:', fileUuid, traceId, e) return null } } export async function updateTraceProfileGroup(fileUuid: string, traceIds: number[], updates: any) { try { const data = await apiCall('update_trace_profile_group', { fileUuid, traceIds, ...updates }) // Update cache for all traces for (const tid of traceIds) { const key = `${fileUuid}:${tid}` if (traceProfileCache.value[key]) { traceProfileCache.value[key] = { ...traceProfileCache.value[key], ...updates } } else { traceProfileCache.value[key] = { file_uuid: fileUuid, trace_id: tid, ...updates } } } return data } catch (e) { console.error('updateTraceProfileGroup failed:', fileUuid, e) return null } } export async function getFileProfile(fileUuid: string) { if (fileProfileCache.value[fileUuid]) return fileProfileCache.value[fileUuid] try { const data = await apiCall('get_file_profile', { fileUuid }) if (data) fileProfileCache.value[fileUuid] = data return data } catch (e) { console.error('getFileProfile failed:', fileUuid, e) return null } } export async function updateFileProfile(fileUuid: string, updates: any) { try { const data = await apiCall('update_file_profile', { fileUuid, ...updates }) if (data) fileProfileCache.value[fileUuid] = { ...fileProfileCache.value[fileUuid], ...data } return data } catch (e) { console.error('updateFileProfile failed:', fileUuid, e) return null } } export function invalidateTraceProfile(fileUuid: string, traceId: number) { delete traceProfileCache.value[`${fileUuid}:${traceId}`] } export function invalidateFileProfile(fileUuid: string) { delete fileProfileCache.value[fileUuid] } function loadProgressCacheFromStorage() { try { const savedProgress = localStorage.getItem('momentry_file_progress') const savedStatus = localStorage.getItem('momentry_file_status') if (savedProgress) { fileProgressCache.value = JSON.parse(savedProgress) } if (savedStatus) { fileStatusCache.value = JSON.parse(savedStatus) } } catch (e) { console.error('Failed to load progress cache:', e) } } function saveProgressCacheToStorage() { try { localStorage.setItem('momentry_file_progress', JSON.stringify(fileProgressCache.value)) localStorage.setItem('momentry_file_status', JSON.stringify(fileStatusCache.value)) } catch (e) { console.error('Failed to save progress cache:', e) } } loadProgressCacheFromStorage() const thumbQueue: (() => Promise)[] = [] let activeThumbLoads = 0 const MAX_CONCURRENT = 10 const profileQueue: (() => Promise)[] = [] let activeProfileLoads = 0 const MAX_PROFILE_CONCURRENT = 4 const loadingThumbs = new Set() const loadingProfiles = new Set() export const loadingFaceThumbs = reactive(new Set()) const loadingProcessorCounts = new Set() const pollingProgress = new Set() export const pollingProgressSet = pollingProgress export const pipelineStatsCache = ref>({}) export const fileStatsCache = ref>({}) export const systemStatsCache = ref(null) const loadingPipelineStats = new Set() const loadingFileStats = new Set() let statsRefreshInterval: ReturnType | null = null let processingPollInterval: ReturnType | null = null let filesWatchInterval: ReturnType | null = null export function startStatsAutoRefresh() { if (statsRefreshInterval) return statsRefreshInterval = setInterval(async () => { for (const fileUuid of Object.keys(pipelineStatsCache.value)) { await loadPipelineStats(fileUuid, true) } for (const fileUuid of Object.keys(fileStatsCache.value)) { await loadFileStats(fileUuid, true) } }, 30000) } export function stopStatsAutoRefresh() { if (statsRefreshInterval) { clearInterval(statsRefreshInterval) statsRefreshInterval = null } } export function startProcessingFilesPolling() { if (processingPollInterval) return processingPollInterval = setInterval(async () => { const processingFiles = filesCache.value.filter((f: any) => f.status === 'processing' && f.file_uuid) if (processingFiles.length === 0) return // Use Promise.all to sync in parallel, not sequentially await Promise.all(processingFiles.map((f: any) => syncFileStatus(f.file_uuid))) }, 10000) // Increased from 5s to 10s } export function stopProcessingFilesPolling() { if (processingPollInterval) { clearInterval(processingPollInterval) processingPollInterval = null } } export function startFilesWatch() { if (filesWatchInterval) return filesWatchInterval = setInterval(async () => { try { const result = await apiCall('get_files', { args: { pageSize: 500 } }) const newFiles = Array.isArray(result) ? result : [] // Check for new files const oldUuids = new Set(filesCache.value.map((f: any) => f.file_uuid).filter(Boolean)) const newUuids = newFiles.map((f: any) => f.file_uuid).filter(Boolean) // Detect new registered files const added = newUuids.filter((uuid: string) => !oldUuids.has(uuid)) const removed = Array.from(oldUuids).filter((uuid: string) => !newUuids.includes(uuid)) if (added.length > 0 || removed.length > 0) { filesCache.value = newFiles } // Check status changes for existing files let hasStatusChange = false for (const newFile of newFiles) { if (!newFile.file_uuid) continue const oldFile = filesCache.value.find((f: any) => f.file_uuid === newFile.file_uuid) if (oldFile && oldFile.status !== newFile.status) { // Status changed, update cache const idx = filesCache.value.findIndex((f: any) => f.file_uuid === newFile.file_uuid) if (idx >= 0) { filesCache.value.splice(idx, 1, newFile) hasStatusChange = true } } } if (hasStatusChange) { console.log('[startFilesWatch] Status changes detected and updated') } } catch (e) { console.error('[startFilesWatch] Error:', e) } }, 3000) } export function stopFilesWatch() { if (filesWatchInterval) { clearInterval(filesWatchInterval) filesWatchInterval = null } } function drainThumbQueue() { if (activeThumbLoads >= MAX_CONCURRENT || thumbQueue.length === 0) return activeThumbLoads++ const task = thumbQueue.shift()! task().then(() => { activeThumbLoads-- drainThumbQueue() }).catch(() => { activeThumbLoads-- drainThumbQueue() }) } function drainProfileQueue() { if (activeProfileLoads >= MAX_PROFILE_CONCURRENT || profileQueue.length === 0) return activeProfileLoads++ const task = profileQueue.shift()! task().then(() => { activeProfileLoads-- drainProfileQueue() }).catch(() => { activeProfileLoads-- drainProfileQueue() }) } function queueThumb(fn: () => Promise) { thumbQueue.push(fn) drainThumbQueue() } function queueProfile(fn: () => Promise) { profileQueue.push(fn) drainProfileQueue() } let _peopleLoading = false let _filesLoading = false let _faceCandidatesLoading = false let _fileUuidForCandidates: string | undefined = undefined let _unassignedTracesLoading = false let _lastLoadedFileUuid: string | undefined = undefined export async function ensureFiles() { console.error('[ensureFiles] starting, filesLoaded.value:', filesLoaded.value) if (filesLoaded.value) return if (_filesLoading) { await new Promise(r => { const check = setInterval(() => { if (filesLoaded.value || !_filesLoading) { clearInterval(check); r() } }, 100) }) return } _filesLoading = true try { const result = await apiCall('get_files', { args: { pageSize: 500 } }) filesCache.value = Array.isArray(result) ? result : [] } catch (e) { console.error('Failed to load files:', e) } finally { _filesLoading = false filesLoaded.value = true } } export async function ensurePeople(force = false) { if (peopleLoaded.value && !force) return if (_peopleLoading) { await new Promise(r => { const check = setInterval(() => { if (peopleLoaded.value || !_peopleLoading) { clearInterval(check); r() } }, 100) }) if (!force) return } _peopleLoading = true try { if (isTauri) { const result: any = await apiCall('get_people', { page: 1, perPage: 100 }) peopleCache.value = Array.isArray(result) ? result : [] } else { const allPeople: any[] = [] for (let page = 1; page <= 5; page++) { const batch: any = await apiCall('get_people', { page, perPage: 20 }) const arr = Array.isArray(batch) ? batch : [] if (!arr.length) break allPeople.push(...arr) if (arr.length < 20) break } peopleCache.value = allPeople } } catch (e) { console.error('Failed to load people:', e) } finally { _peopleLoading = false peopleLoaded.value = true } } export async function ensureFaceCandidates(fileUuid: string) { if (!fileUuid) return if (faceCandidatesLoaded.value && _fileUuidForCandidates === fileUuid) return if (_faceCandidatesLoading) { await new Promise(r => { const check = setInterval(() => { if (faceCandidatesLoaded.value || !_faceCandidatesLoading) { clearInterval(check); r() } }, 100) }) return } _faceCandidatesLoading = true _fileUuidForCandidates = fileUuid try { if (isTauri) { const args: any = { page: 1, perPage: 20, fileUuid } const fc: any = await apiCall('get_face_candidates', args) faceCandidatesCache.value = Array.isArray(fc) ? fc : [] } else { const all: any[] = [] for (let page = 1; page <= 3; page++) { const args: any = { page, perPage: 20, fileUuid } const batch: any = await apiCall('get_face_candidates', args) const arr = Array.isArray(batch) ? batch : [] if (!arr.length) break all.push(...arr) if (arr.length < 20) break } faceCandidatesCache.value = all } } catch (e) { console.error('Failed to load face candidates:', e) } finally { _faceCandidatesLoading = false faceCandidatesLoaded.value = true } } export async function ensureUnassignedTraces(fileUuid: string, page?: number) { if (!fileUuid) return const targetPage = page ?? unassignedTracesPage.value if (unassignedTracesLoaded.value && _lastLoadedFileUuid === fileUuid && targetPage === unassignedTracesPage.value) return if (_unassignedTracesLoading) { await new Promise(r => { const check = setInterval(() => { if (unassignedTracesLoaded.value || !_unassignedTracesLoading) { clearInterval(check); r() } }, 100) }) if (unassignedTracesLoaded.value && _lastLoadedFileUuid === fileUuid && targetPage === unassignedTracesPage.value) return } _unassignedTracesLoading = true try { const args: any = { page: 1, page_size: 3000, limit: 3000, fileUuid } const batch: any = await apiCall('get_file_traces', args) const traces = batch?.traces || [] unassignedTracesCache.value = traces unassignedTracesTotal.value = batch?.total ?? traces.length unassignedTracesPage.value = targetPage _lastLoadedFileUuid = fileUuid } catch (e) { console.error('Failed to load unassigned traces:', e) } finally { _unassignedTracesLoading = false unassignedTracesLoaded.value = true } } export function loadTraceThumb(t: any) { if (!t.file_uuid || t.trace_id === undefined || t.trace_id === null) return const key = `${t.file_uuid}-${t.trace_id}` if (faceThumbsCache.value[key] || loadingFaceThumbs.has(key)) return loadingFaceThumbs.add(key) queueThumb(async () => { try { // 改用自己的 robust face-thumbnail 端點(三層 fallback:.faces cache → // key_face.jpg → live 重切格),避免 trace-thumb 缺檔時 404 破圖。 const base = getApiBase() const ver = thumbCacheBust > 0 ? `&t=${thumbCacheBust}` : '' const url = `${base}/api/v1/file/${t.file_uuid}/thumbnail?trace_id=${t.trace_id}${ver}` const resp = await fetch(url) if (resp.ok) { const blob = await resp.blob() faceThumbsCache.value[key] = URL.createObjectURL(blob) } } catch (e) { console.error('loadTraceThumb failed:', key, e) } finally { loadingFaceThumbs.delete(key) } }) } export function loadThumbnail(uuid: string, frame = 30) { const key = `${uuid}:${frame}` if (!uuid || thumbnailsCache.value[key] || loadingThumbs.has(key)) return loadingThumbs.add(key) queueThumb(async () => { try { const result = await apiCall('get_thumbnail', { uuid, frame }) if (result) thumbnailsCache.value[key] = result } catch (e) { console.error('loadThumbnail failed:', uuid, frame, e) } finally { loadingThumbs.delete(key) } }) } export function loadUnregisteredThumbnail(filePath: string, frame = 30) { const key = `unreg:${filePath}:${frame}` if (!filePath || thumbnailsCache.value[key] || loadingThumbs.has(key)) return loadingThumbs.add(key) queueThumb(async () => { try { const result = await apiCall('get_file_thumbnail_by_path', { path: filePath, frame }) if (result) thumbnailsCache.value[key] = result } catch (e) { console.error('loadUnregisteredThumbnail failed:', filePath, frame, e) } finally { loadingThumbs.delete(key) } }) } export function loadProfile(uuid: string, forceReload = false) { if (!uuid) { console.error('[loadProfile] called with empty uuid'); return } if (!forceReload && profilesCache.value[uuid]) return if (loadingProfiles.has(uuid)) return loadingProfiles.add(uuid) console.error('[loadProfile] requesting', uuid) queueProfile(async () => { try { const result = await apiCall('get_identity_profile', { uuid }) console.error('[loadProfile] got result for', uuid, typeof result, result ? (result as string).substring(0, 30) : 'null') if (result) profilesCache.value[uuid] = result else console.error('[loadProfile] empty result for', uuid) } catch (e) { console.error('loadProfile failed:', uuid, e) } finally { loadingProfiles.delete(uuid) } }) } export function loadFaceThumb(key: string, uuid: string, frame: number, bbox?: any) { if (!uuid || faceThumbsCache.value[key] || loadingFaceThumbs.has(key)) return loadingFaceThumbs.add(key) queueThumb(async () => { try { const args: any = { uuid, frame } if (bbox) { args.bboxX = bbox.x args.bboxY = bbox.y args.bboxW = bbox.width args.bboxH = bbox.height } const result = await apiCall('get_face_thumbnail', args) if (result) faceThumbsCache.value[key] = result } catch (e) { console.error('loadFaceThumb failed:', key, uuid, e) } finally { loadingFaceThumbs.delete(key) } }) } export async function loadProcessorCounts(fileUuid: string, force = false) { if (!fileUuid) return if (!force && processorCountsCache.value[fileUuid]) return if (loadingProcessorCounts.has(fileUuid)) return loadingProcessorCounts.add(fileUuid) try { const data: any = await apiCall('get_processor_counts', { uuid: fileUuid }) const counts: Record = {} for (const p of data.processors || []) { counts[p.processor] = { has_json: p.has_json ?? false, frame_count: p.frame_count ?? null, segment_count: p.segment_count ?? null, chunk_count: p.chunk_count ?? null, last_modified: p.last_modified ?? null, } } processorCountsCache.value = { ...processorCountsCache.value, [fileUuid]: counts } } catch (e) { console.error('loadProcessorCounts failed:', fileUuid, e) } finally { loadingProcessorCounts.delete(fileUuid) } } export function getProcessorCounts(fileUuid: string): Record { return processorCountsCache.value[fileUuid] || {} } export type ProcessingLevel = 'unprocessed' | 'partially_processed' | 'fully_processed' export interface ProcessingStatus { level: ProcessingLevel hasTraces: boolean hasIdentities: boolean hasTranscription: boolean hasOcr: boolean hasFrameExtraction: boolean details?: Record } export async function getFileProcessingStatus(fileUuid: string): Promise { try { const traces: any = await apiCall('get_traces', { fileUuid, pageSize: 1 }) const hasTraces = traces?.total > 0 await loadProcessorCounts(fileUuid) const outputs = getProcessorCounts(fileUuid) const hasTranscription = outputs.transcription?.has_json || false const hasOcr = outputs.ocr?.has_json || false const hasFrameExtraction = outputs.frame_extraction?.frame_count > 0 || false let identities: any = null let hasIdentities = false try { identities = await apiCall('get_file_identities', { fileUuid, pageSize: 1 }) hasIdentities = identities?.total > 0 } catch (e) { } let level: ProcessingLevel if (!hasTraces && !hasTranscription && !hasOcr) { level = 'unprocessed' } else if (hasTraces && hasIdentities && hasTranscription && hasOcr && hasFrameExtraction) { level = 'fully_processed' } else { level = 'partially_processed' } return { level, hasTraces, hasIdentities, hasTranscription, hasOcr, hasFrameExtraction, details: outputs } } catch (e) { console.error('getFileProcessingStatus failed:', fileUuid, e) return { level: 'unprocessed', hasTraces: false, hasIdentities: false, hasTranscription: false, hasOcr: false, hasFrameExtraction: false } } } export async function pollProgress(fileUuid: string) { if (!fileUuid || pollingProgress.has(fileUuid)) return pollingProgress.add(fileUuid) try { while (pollingProgress.has(fileUuid)) { const data: any = await apiCall('get_progress', { fileUuid }) fileProgressCache.value[fileUuid] = { file_uuid: data.file_uuid || fileUuid, overall_progress: data.overall_progress || 0, processors: (data.processors || []).map((p: any) => ({ processor_type: p.processor_type, status: p.status, progress: p.progress || 0, current: p.current, total: p.total, eta_seconds: p.eta_seconds, })), cpu_percent: data.cpu_percent, gpu_percent: data.gpu_percent, memory_percent: data.memory_percent, } // No local status promotion - status comes from Core API only saveProgressCacheToStorage() const allComplete = data.processors?.every((p: any) => p.status === 'complete' || p.status === 'failed') if (allComplete || data.overall_progress >= 100) { stopPolling(fileUuid) saveProgressCacheToStorage() await loadProcessorCounts(fileUuid) break } await new Promise(r => setTimeout(r, 2000)) } } catch (e) { console.error('pollProgress failed:', fileUuid, e) } finally { pollingProgress.delete(fileUuid) } } export function stopPolling(fileUuid: string) { pollingProgress.delete(fileUuid) } export function getProgress(fileUuid: string): FileProgress | null { return fileProgressCache.value[fileUuid] || null } export function getFileStatus(fileUuid: string): string { return fileStatusCache.value[fileUuid] || '' } export function clearFileStatusCache(fileUuid: string) { delete fileStatusCache.value[fileUuid] saveProgressCacheToStorage() } export async function refreshFileStatus(fileUuid: string) { try { const pipeline = await loadPipelineStats(fileUuid, true) if (pipeline) { // No local status promotion - status comes from Core API only await loadFileStats(fileUuid, true) } } catch (e) { console.error('refreshFileStatus failed:', fileUuid, e) } } export async function refreshAllFilesStatus() { const tasks = filesCache.value .filter((f: any) => f.isRegistered && f.file_uuid) .map((f: any) => refreshFileStatus(f.file_uuid)) await Promise.all(tasks) } export async function syncFileStatus(fileUuid: string): Promise { try { const result = await apiCall('sync_file_status', { fileUuid }) if (result?.status) { const newStatus = result.status const oldStatus = fileStatusCache.value[fileUuid] fileStatusCache.value[fileUuid] = newStatus const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid) if (f) { // If status changed from processing to completed, clear pipeline cache if (oldStatus === 'processing' && newStatus === 'completed') { delete pipelineStatsCache.value[fileUuid] delete fileProgressCache.value[fileUuid] } f.status = newStatus if (newStatus === 'processing') { await loadPipelineStats(fileUuid, true) } } } return result } catch (e) { console.error('syncFileStatus failed:', fileUuid, e) return null } } export async function syncAllProcessingFiles(): Promise { const processingFiles = filesCache.value.filter((f: any) => f.status === 'processing' && f.file_uuid) for (const f of processingFiles) { await syncFileStatus(f.file_uuid) } } export async function loadPipelineStats(fileUuid: string, force = false): Promise { if (!fileUuid) return null if (!force && pipelineStatsCache.value[fileUuid]) return pipelineStatsCache.value[fileUuid] if (loadingPipelineStats.has(fileUuid)) return null loadingPipelineStats.add(fileUuid) try { const data: any = await apiCall('get_pipeline_stats', { fileUuid }) const stages = data.stages || [] const WEIGHT_MAP: Record = { processors: 0.30, rule1_ingestion: 0.05, face_tracing: 0.05, face_dedup: 0.10, tkg_nodes: 0.20, tkg_edges: 0.15, rule2_ingestion: 0.10, vlm_summary: 0.04, moment_chunks: 0.01 } const mappedStages = stages.map((s: any) => ({ name: s.name, weight: WEIGHT_MAP[s.name] ?? s.weight ?? 0, progress: s.progress || 0, status: s.status || 'pending' })) const calculatedProgress = mappedStages.reduce((sum, s) => sum + s.weight * s.progress, 0) const pipelineStats: PipelineStats = { overall_progress: calculatedProgress, stages: mappedStages } console.log('[loadPipelineStats]', fileUuid, pipelineStats) pipelineStatsCache.value = { ...pipelineStatsCache.value, [fileUuid]: pipelineStats } // No local status promotion - status comes from Core API only return pipelineStats } catch (e) { console.warn('get_pipeline_stats failed, falling back to get_progress:', fileUuid, e) try { const oldData: any = await apiCall('get_progress', { fileUuid }) const allComplete = oldData.processors?.length > 0 && oldData.processors?.every((p: any) => p.status === 'complete' || p.status === 'failed') const hasRunning = oldData.processors?.some((p: any) => p.status === 'running') const progress = allComplete ? 1.0 : (oldData.overall_progress || 0) / 100 const status = allComplete ? 'completed' : (hasRunning ? 'running' : 'pending') console.log('[loadPipelineStats fallback]', fileUuid, { allComplete, hasRunning, progress, status, processorCount: oldData.processors?.length }) const fallbackStats: PipelineStats = { overall_progress: progress, stages: [ { name: 'processors', weight: 1.0, progress, status } ] } pipelineStatsCache.value = { ...pipelineStatsCache.value, [fileUuid]: fallbackStats } return fallbackStats } catch (fallbackError) { console.error('Both pipeline APIs failed:', fileUuid, fallbackError) return null } } finally { loadingPipelineStats.delete(fileUuid) } } export async function loadSystemStats(): Promise { try { const resp = await fetch('/api/v1/system-stats') if (!resp.ok) throw new Error(`HTTP ${resp.status}`) const data: SystemStats = await resp.json() systemStatsCache.value = data return data } catch (e) { console.error('loadSystemStats failed:', e) return null } } export async function loadFileStats(fileUuid: string, force = false): Promise { if (!fileUuid) return null if (!force && fileStatsCache.value[fileUuid]) return fileStatsCache.value[fileUuid] if (loadingFileStats.has(fileUuid)) return null loadingFileStats.add(fileUuid) try { const data: any = await apiCall('get_file_stats', { fileUuid }) // 转换新API格式为前端格式 const processors: Record = {} for (const p of data.processors || []) { const name = p.name.toLowerCase() processors[name] = { status: p.status, segment_count: p.name === 'ASR' || p.name === 'ASRX' ? null : null, frame_count: p.name === 'FACE' || p.name === 'OCR' || p.name === 'POSE' || p.name === 'APPEARANCE' || p.name === 'CUT' ? null : null, chunk_count: null } } const fileStats: FileStats = { json: { file_name: data.file_name || '', status: data.status || '', processors }, postgresql: { sentence_chunks: data.postgres?.sentence_chunks || 0, trace_chunks: data.postgres?.trace_chunks || 0, relationship_chunks: data.postgres?.relationship_chunks || 0, identities: data.postgres?.identities || 0, file_identities: data.postgres?.file_identities || 0 }, qdrant: { faces: data.qdrant?.faces || 0, face_traces: data.qdrant?.face_traces || 0, face_identities: data.qdrant?.face_identities || 0, text_chunks: data.qdrant?.text_chunks || 0, speakers: data.qdrant?.speakers || 0 }, tkg: { nodes: { character: data.tkg?.face_track_nodes || 0, object: data.tkg?.object_nodes || 0, scene: 0, action: 0, location: 0, event: 0, dialogue: data.tkg?.text_region_nodes || 0, emotion: 0, relation: 0 }, edges: { appear_in: data.tkg?.face_face_edges || 0, interact_with: data.tkg?.co_occurrence_edges || 0, located_at: 0, perform_action: 0, cause_effect: 0, temporal_link: 0, speak_to: data.tkg?.speaker_face_edges || 0, feel: 0 }, total_nodes: data.tkg?.total_nodes || 0, total_edges: data.tkg?.total_edges || 0 }, identity_agent: { clusters: data.identity_agent?.clusters || 0, identities_created: data.identity_agent?.identities_created || 0, tmdb_matches: data.identity_agent?.tmdb_matches || 0, speaker_bindings: data.identity_agent?.speaker_bindings || 0, confirmations: data.identity_agent?.confirmations || 0 }, vlm_summary: { total: data.vlm_summary?.total || 0, done: data.vlm_summary?.done || 0 }, scene_vlm: { total_scenes: data.scene_vlm?.total_scenes || 0, status: data.scene_vlm?.status || '', vlm_done: data.scene_vlm?.vlm_done || 0, vlm_total: data.scene_vlm?.vlm_total || 0 } } fileStatsCache.value = { ...fileStatsCache.value, [fileUuid]: fileStats } if (data.status === 'completed') { pipelineStatsCache.value[fileUuid] = { overall_progress: 1.0, stages: [{ name: 'processors', weight: 1.0, progress: 1.0, status: 'completed' }] } } return fileStats } catch (e) { console.warn('get_file_stats failed, falling back to get_processor_counts:', fileUuid, e) try { // 降级到processor-counts API await loadProcessorCounts(fileUuid, true) const procCounts = getProcessorCounts(fileUuid) // 构建降级版FileStats const fallbackStats: FileStats = { json: { file_name: '', status: 'completed', processors: {} }, postgresql: { sentence_chunks: 0, trace_chunks: 0, relationship_chunks: 0, identities: 0, file_identities: 0 }, qdrant: { faces: 0, face_traces: 0, face_identities: 0, text_chunks: 0, speakers: 0 }, tkg: { nodes: { character: 0, object: 0, scene: 0, action: 0, location: 0, event: 0, dialogue: 0, emotion: 0, relation: 0 }, edges: { appear_in: 0, interact_with: 0, located_at: 0, perform_action: 0, cause_effect: 0, temporal_link: 0, speak_to: 0, feel: 0 }, total_nodes: 0, total_edges: 0 }, identity_agent: { clusters: 0, identities_created: 0, tmdb_matches: 0, speaker_bindings: 0, confirmations: 0 }, vlm_summary: { total: 0, done: 0 }, scene_vlm: { total_scenes: 0, status: '', vlm_done: 0, vlm_total: 0 } } // 从processorCounts填充json.processors for (const [proc, info] of Object.entries(procCounts)) { fallbackStats.json.processors[proc] = { status: info.has_json ? 'completed' : 'pending', segment_count: info.segment_count, frame_count: info.frame_count, chunk_count: info.chunk_count } } fileStatsCache.value = { ...fileStatsCache.value, [fileUuid]: fallbackStats } return fallbackStats } catch (fallbackError) { console.error('Both file stats APIs failed:', fileUuid, fallbackError) return null } } finally { loadingFileStats.delete(fileUuid) } } export function getPipelineStats(fileUuid: string): PipelineStats | null { return pipelineStatsCache.value[fileUuid] || null } export function getFileStats(fileUuid: string): FileStats | null { return fileStatsCache.value[fileUuid] || null } export async function pollPipelineProgress(fileUuid: string) { if (!fileUuid || pollingProgress.has(fileUuid)) return pollingProgress.add(fileUuid) try { while (pollingProgress.has(fileUuid)) { const stats = await loadPipelineStats(fileUuid, true) if (stats) { const idx = filesCache.value.findIndex((x: any) => x.file_uuid === fileUuid) if (idx === -1) continue // No local status promotion - status comes from Core API only saveProgressCacheToStorage() const allComplete = stats.stages.every(s => s.status === 'completed' || s.status === 'failed') if (allComplete || stats.overall_progress >= 1.0) { stopPolling(fileUuid) saveProgressCacheToStorage() await loadFileStats(fileUuid, true) break } } await new Promise(r => setTimeout(r, 2000)) } } catch (e) { console.error('pollPipelineProgress failed:', fileUuid, e) } finally { pollingProgress.delete(fileUuid) } } export function invalidateFiles() { filesLoaded.value = false filesCache.value = [] } export function invalidatePeople() { peopleLoaded.value = false peopleCache.value = [] faceCandidatesLoaded.value = false faceCandidatesCache.value = [] } export function invalidateProfile(uuid: string) { delete profilesCache.value[uuid] loadingProfiles.delete(uuid) } export async function getPose(fileUuid: string, frame: number, bbox?: { x: number; y: number; width: number; height: number } | null): Promise { const key = bbox ? `${fileUuid}:${frame}:${bbox.x}:${bbox.y}` : `${fileUuid}:${frame}` if (poseCache.value[key]) return poseCache.value[key] try { const args: any = { fileUuid, frame } if (bbox) { args.bboxX = bbox.x args.bboxY = bbox.y args.bboxW = bbox.width args.bboxH = bbox.height } const data = await apiCall('get_pose', args) if (data) poseCache.value[key] = data return data } catch (e) { console.error('getPose failed:', fileUuid, frame, e) return null } } export async function getAppearance(fileUuid: string, frame: number, bbox?: { x: number; y: number; width: number; height: number } | null): Promise { const key = bbox ? `${fileUuid}:${frame}:${bbox.x}:${bbox.y}` : `${fileUuid}:${frame}` if (appearanceCache.value[key]) return appearanceCache.value[key] try { const args: any = { fileUuid, frame } if (bbox) { args.bboxX = bbox.x args.bboxY = bbox.y args.bboxW = bbox.width args.bboxH = bbox.height } const data = await apiCall('get_appearance', args) if (data) appearanceCache.value[key] = data return data } catch (e) { console.error('getAppearance failed:', fileUuid, frame, e) return null } } export async function getFaceLandmarks(fileUuid: string, frame: number, bbox?: { x: number; y: number; width: number; height: number } | null): Promise { const key = bbox ? `${fileUuid}:${frame}:${bbox.x}:${bbox.y}` : `${fileUuid}:${frame}` if (faceLandmarksCache.value[key]) return faceLandmarksCache.value[key] try { const args: any = { fileUuid, frame } if (bbox) { args.bboxX = bbox.x args.bboxY = bbox.y args.bboxW = bbox.width args.bboxH = bbox.height } const data = await apiCall('get_face_landmarks', args) if (data) faceLandmarksCache.value[key] = data return data } catch (e) { console.error('getFaceLandmarks failed:', fileUuid, frame, e) return null } }