diff --git a/src/api/index.ts b/src/api/index.ts index aa67dc7..f8568db 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -301,7 +301,7 @@ case 'search_keyword': { case 'search_agents': { const body: any = { query: a.query } if (a.conversationId) body.conversation_id = a.conversationId - return { url: '/api/v1/agents/search', method: 'POST', body } + return { url: '/api/v1/agents/search', method: 'POST', body, timeout: 90000 } } // --- Image APIs --- diff --git a/src/locales/en.json b/src/locales/en.json index 5202338..4df3c7d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -49,6 +49,7 @@ "registered": "Registered", "unregistered": "Unregistered", "pending": "Pending", + "preparing": "Preparing", "processing": "Processing", "completed": "Completed", "scanning": "Scanning", @@ -136,7 +137,8 @@ "rerun_processor": "Re-run {processor} for this file?", "run_processor": "Run {processor} on {filename}?", "unregister_title": "Unregister", - "unregister_message": "Unregister \"{filename}\"?\n\nThis will remove the file and its output data." + "unregister_message": "Unregister \"{filename}\"?\n\nThis will remove the file and its output data.", + "batch_unregister_message": "Unregister {count} files?\n\nThis will remove the files and their output data." }, "processor": { "started": "{processor} started", diff --git a/src/locales/ja.json b/src/locales/ja.json index 31c103e..c5aaef7 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -49,6 +49,7 @@ "registered": "登録済み", "unregistered": "未登録", "pending": "保留中", + "preparing": "準備中", "processing": "処理中", "completed": "完了", "scanning": "スキャン中", @@ -136,7 +137,8 @@ "rerun_processor": "{processor} を再実行しますか?", "run_processor": "{filename} で {processor} を実行しますか?", "unregister_title": "登録解除", - "unregister_message": "「{filename}」を登録解除しますか?\n\nファイルとその出力データが削除されます。" + "unregister_message": "「{filename}」を登録解除しますか?\n\nファイルとその出力データが削除されます。", + "batch_unregister_message": "{count} ファイルを登録解除しますか?\n\nファイルとその出力データが削除されます。" }, "processor": { "started": "{processor} が開始されました", diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index f65192a..71256ae 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -49,6 +49,7 @@ "registered": "已註冊", "unregistered": "未註冊", "pending": "等待中", + "preparing": "準備中", "processing": "處理中", "completed": "已完成", "scanning": "掃描中", @@ -136,7 +137,8 @@ "rerun_processor": "重新執行 {processor}?", "run_processor": "在 {filename} 上執行 {processor}?", "unregister_title": "取消註冊", - "unregister_message": "取消註冊「{filename}」?\n\n這將移除檔案及其輸出資料。" + "unregister_message": "取消註冊「{filename}」?\n\n這將移除檔案及其輸出資料。", + "batch_unregister_message": "取消註冊 {count} 個檔案?\n\n這將移除檔案及其輸出資料。" }, "processor": { "started": "{processor} 已開始", diff --git a/src/store.ts b/src/store.ts index 89f89e5..3535ed5 100644 --- a/src/store.ts +++ b/src/store.ts @@ -425,7 +425,7 @@ loadProgressCacheFromStorage() const thumbQueue: (() => Promise)[] = [] let activeThumbLoads = 0 -const MAX_CONCURRENT = 32 +const MAX_CONCURRENT = 10 const profileQueue: (() => Promise)[] = [] let activeProfileLoads = 0 diff --git a/src/views/LibraryView.vue b/src/views/LibraryView.vue index 5267362..b6576f9 100644 --- a/src/views/LibraryView.vue +++ b/src/views/LibraryView.vue @@ -97,7 +97,12 @@
-
✅ {{ t('library.status.completed') }} ({{ completedFiles.length }})
+
+ ✅ {{ t('library.status.completed') }} ({{ completedFiles.length }}) + +
@@ -682,6 +687,7 @@ import { getAsrStatus } from '@/utils/asrStatus' import { useAdvancedMode } from '@/stores/advancedMode' import { useAutoScan } from '@/stores/autoScan' import VideoPlayer from '../components/VideoPlayer.vue' +import pLimit from 'p-limit' const { t } = useI18n() const { isAdvanced } = useAdvancedMode() @@ -743,7 +749,7 @@ const durationMax = ref(null) // Sort & Filter const registeredFiles = computed(() => { - let result = files.value.filter((f: any) => f.isRegistered && f.status !== 'completed') + let result = filesCache.value.filter((f: any) => f.isRegistered && f.status !== 'completed') if (displayFilter.value === 'video') result = result.filter(isVideo) else if (displayFilter.value === 'photo') result = result.filter(isPhoto) if (filterText.value) { @@ -756,8 +762,20 @@ const registeredFiles = computed(() => { if (onlyPhotos.value) result = result.filter(isPhoto) if (sizeMin.value) result = result.filter((f: any) => (f.file_size || 0) >= sizeMin.value! * 1048576) if (sizeMax.value) result = result.filter((f: any) => (f.file_size || 0) <= sizeMax.value! * 1048576) - // Sort + + // Sort: scanning/preparing files first, then by user selection result.sort((a: any, b: any) => { + const getPriority = (f: any) => { + if (f.status === 'scanning') return 0 + if (f.status === 'preparing') return 1 + return 2 + } + + const priorityA = getPriority(a) + const priorityB = getPriority(b) + + if (priorityA !== priorityB) return priorityA - priorityB + switch (sortBy.value) { case 'time_desc': return new Date(b.modified_time || 0).getTime() - new Date(a.modified_time || 0).getTime() case 'time_asc': return new Date(a.modified_time || 0).getTime() - new Date(b.modified_time || 0).getTime() @@ -838,7 +856,7 @@ const { onMousedown: dragMousedown, onClickCapture: dragClickCapture } = useDrag }) const sortedFilteredFiles = computed(() => { - let result = [...files.value] + let result = [...filesCache.value] // Display filter if (displayFilter.value === 'video') result = result.filter(isVideo) @@ -874,8 +892,21 @@ const sortedFilteredFiles = computed(() => { }) } - // Sort + // Sort: scanning/preparing files first, then by user selection result.sort((a: any, b: any) => { + // Priority: scanning > preparing > others + const getPriority = (f: any) => { + if (f.status === 'scanning') return 0 + if (f.status === 'preparing') return 1 + return 2 + } + + const priorityA = getPriority(a) + const priorityB = getPriority(b) + + if (priorityA !== priorityB) return priorityA - priorityB + + // Within same priority, sort by user selection switch (sortBy.value) { case 'time_desc': return new Date(b.modified_time || 0).getTime() - new Date(a.modified_time || 0).getTime() case 'time_asc': return new Date(a.modified_time || 0).getTime() - new Date(b.modified_time || 0).getTime() @@ -896,6 +927,12 @@ const completedCount = computed(() => files.value.filter((f: any) => f.status == const scanningCount = computed(() => files.value.filter((f: any) => f.status === 'scanning').length) const readyCount = computed(() => files.value.filter((f: any) => ['completed','keyword_ready','semantic_ready','face_mgmt_ready'].includes(f.status)).length) const selectedUnregisteredFiles = computed(() => unregisteredFiles.value.filter((f: any) => selectedFiles.value.includes(f.file_path))) +const selectedRegisteredFiles = computed(() => { + const selected = selectedFiles.value + const registered = registeredFiles.value.filter((f: any) => selected.includes(f.file_uuid)) + const completed = completedFiles.value.filter((f: any) => selected.includes(f.file_uuid)) + return [...registered, ...completed] +}) onMounted(() => { loadFiles(); document.addEventListener('click', docClickClose); startStatsAutoRefresh(); startProcessingFilesPolling(); startFilesWatch() }) onUnmounted(() => { document.removeEventListener('click', docClickClose); stopStatsAutoRefresh(); stopProcessingFilesPolling(); stopFilesWatch() }) @@ -908,31 +945,41 @@ function docClickClose(e: MouseEvent) { } } +// Concurrency limit for status API calls +const statusLimit = pLimit(5) + async function loadFiles() { statusText.value = 'Loading...' invalidateFiles() await ensureFiles() statusText.value = `${files.value.length} files` + // Load thumbnails asynchronously (non-blocking) for (const f of files.value) { if ((isPhoto(f) || isVideo(f)) && f.file_uuid) loadThumbnail(f.file_uuid) } for (const f of files.value) { if ((isPhoto(f) || isVideo(f)) && !f.isRegistered && f.file_path) loadUnregisteredThumbnail(f.file_path) } + // Start status loading in background (non-blocking) + loadAllStatus() +} + +// Load status for all registered files progressively +async function loadAllStatus() { const registeredFiles = files.value.filter((f: any) => f.isRegistered && f.file_uuid) - await Promise.all(registeredFiles.map(async (f: any) => { - try { - await loadPipelineStats(f.file_uuid, true) - await loadFileStats(f.file_uuid, true) - } catch (e) { - console.error('check progress failed:', f.file_uuid, e) - } - })) + await Promise.all(registeredFiles.map((f: any) => + statusLimit(() => Promise.all([ + loadPipelineStats(f.file_uuid, true), + loadFileStats(f.file_uuid, true) + ]).catch(() => {})) + )) } async function refreshAllStatus() { statusText.value = 'Refreshing status...' - await refreshAllFilesStatus() + // Clear caches first + pipelineStatsCache.value = {} + await loadAllStatus() statusText.value = `${files.value.length} files` } @@ -1438,17 +1485,30 @@ async function runProcessorQC(f: any) { async function redoProcessor(proc: string) { if (!ctxMenu.value.file?.file_uuid) return const fileUuid = ctxMenu.value.file.file_uuid + const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid) + if (!f) return if (!confirm(t('library.confirm.rerun_processor', { processor: proc.toUpperCase() }))) return + const oldStatus = f.status + try { + f.status = 'preparing' + fileStatusCache.value[f.file_uuid] = 'preparing' + const result = await apiCall('process_file', { fileUuid, processors: [proc] }) + if (result.success) { + await syncFileStatus(f.file_uuid) alert(t('library.processor.started', { processor: proc.toUpperCase() })) } else { + f.status = oldStatus + fileStatusCache.value[f.file_uuid] = oldStatus alert(t('library.processor.failed', { message: result.message })) } } catch (e: any) { + f.status = oldStatus + fileStatusCache.value[f.file_uuid] = oldStatus alert(t('library.processor.error', { message: e.message })) } } @@ -1460,21 +1520,33 @@ async function redoProcessorFromQC(proc: string) { return } const fileUuid = qcFile.value.file_uuid + const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid) + if (!f) return if (!confirm(t('library.confirm.run_processor', { processor: proc.toUpperCase(), filename: qcFile.value.title }))) return + const oldStatus = f.status + try { + f.status = 'preparing' + fileStatusCache.value[f.file_uuid] = 'preparing' + const result = await apiCall('process_file', { fileUuid, processors: [proc] }) + if (result.success) { + await syncFileStatus(f.file_uuid) alert(t('library.processor.started', { processor: proc.toUpperCase() })) - // Re-run QC to refresh status setTimeout(() => { runProcessorQC(qcFile.value) }, 2000) } else { + f.status = oldStatus + fileStatusCache.value[f.file_uuid] = oldStatus alert(t('library.processor.failed', { message: result.message || result.error || 'Unknown error' })) } } catch (e: any) { + f.status = oldStatus + fileStatusCache.value[f.file_uuid] = oldStatus alert(t('library.processor.error', { message: e.message })) console.error('[QC] Redo failed:', e) } @@ -1578,6 +1650,7 @@ function statusFromDB(status: string): string { } function statusBadgeClass(f: any): string { + if (f.status === 'preparing') return 'ms-badge-off' if (f.status === 'registered') return 'ms-badge-warn' if (f.status === 'scanning') return 'ms-badge-proc' if (f.status === 'keyword_ready' || f.status === 'semantic_ready') return 'ms-badge-part' @@ -1587,6 +1660,7 @@ function statusBadgeClass(f: any): string { } function statusBadgeIcon(f: any): string { + if (f.status === 'preparing') return '⏳' if (f.status === 'registered') return '📋' if (f.status === 'scanning') return '🔍' if (f.status === 'keyword_ready') return '🔎' @@ -1792,6 +1866,53 @@ async function batchRegister() { setTimeout(() => { actionMsg.value = '' }, 8000) } +async function batchUnregister() { + if (unregistering.value) return + if (selectedRegisteredFiles.value.length === 0) return + + const filesToUnregister = selectedRegisteredFiles.value + + if (!confirm(t('library.confirm.batch_unregister_message', { count: filesToUnregister.length }))) return + + unregistering.value = true + actionMsg.value = `Unregistering ${filesToUnregister.length} files...` + + let successCount = 0 + let failCount = 0 + + for (const f of filesToUnregister) { + const fileUuid = f.file_uuid + if (!fileUuid) { + failCount++ + continue + } + + try { + const result: any = await apiCall('unregister_file', { fileUuid, deleteOutputFiles: true }) + if (result.success) { + successCount++ + } else { + failCount++ + } + } catch (e: any) { + failCount++ + console.error('[batchUnregister] Failed:', f.file_name, e) + } + } + + selectedFiles.value = [] + unregistering.value = false + + if (successCount > 0) { + actionMsg.value = `Unregistered ${successCount} files${failCount > 0 ? `, ${failCount} failed` : ''}` + } else { + actionMsg.value = `Failed to unregister all ${failCount} files` + } + + await loadFiles() + setTimeout(() => { actionMsg.value = '' }, 8000) +} + async function quickRegister(f: any) { if (registering.value) return @@ -1845,97 +1966,46 @@ async function doProcess(f: any) { if (processing.value) return if (!f.isRegistered) { actionMsg.value = 'File not registered'; return } processing.value = true - const oldUuid = f.file_uuid - actionMsg.value = '🔄 Unregistering...' + + const file = filesCache.value.find((x: any) => x.file_uuid === f.file_uuid) + if (!file) { + actionMsg.value = 'File not found in cache' + processing.value = false + return + } + + const oldStatus = file.status try { - // Step 1: Unregister with deleteOutputFiles=true - const unregResult: any = await apiCall('unregister_file', { fileUuid: oldUuid, deleteOutputFiles: true }) - if (!unregResult.success) { - actionMsg.value = `Unregister failed: ${unregResult.message}` - processing.value = false - setTimeout(() => { actionMsg.value = '' }, 8000) - return - } + file.status = 'preparing' + fileStatusCache.value[file.file_uuid] = 'preparing' + actionMsg.value = '🔍 Starting deep scan...' - if (unregResult.command_id) { - actionMsg.value = '🔄 Waiting for unregister...' - await pollCommandStatus(unregResult.command_id) - } - - // Clear all cached state for this file - delete fileProgressCache.value[oldUuid] - delete processorCountsCache.value[oldUuid] - - actionMsg.value = '⏳ Waiting for unregister to stabilize...' - await waitForStable(async () => { - await invalidateFiles() - await ensureFiles() - return !filesCache.value.some((x: any) => x.file_uuid === oldUuid && x.isRegistered) + const result = await apiCall('re_scan_file', { + fileUuid: file.file_uuid, + autoPipeline: true }) - - // Step 2: Register again - actionMsg.value = '📥 Registering...' - const filePath = f.file_path || f.relative_path || f.file_name - if (!filePath) { actionMsg.value = 'Missing file path'; processing.value = false; return } - const regResult: any = await apiCall('register_file', { filePath }) - if (!regResult.success) { - actionMsg.value = `Register failed: ${regResult.message}` - processing.value = false - setTimeout(() => { actionMsg.value = '' }, 8000) - return - } - let newUuid = regResult.file_uuid - if (regResult.command_id && !newUuid) { - actionMsg.value = '📥 Waiting for registration...' - const finalStatus = await pollCommandStatus(regResult.command_id) - newUuid = finalStatus.file_uuid - } - - if (!newUuid) { - actionMsg.value = `Registration completed but no file_uuid` - processing.value = false - return - } - - f.file_uuid = newUuid - f.isRegistered = true - - actionMsg.value = '⏳ Waiting for registration to stabilize...' - await waitForStable(async () => { - await invalidateFiles() - await ensureFiles() - const found = filesCache.value.find((x: any) => x.file_uuid === f.file_uuid) - return found && found.isRegistered && found.status === 'registered' - }) - - // Step 3: Process (deep scan) - actionMsg.value = '🔍 Scanning...' - const procs = selectedProcessors() - if (!procs.length) { actionMsg.value = 'Please select at least one processor'; processing.value = false; return } - - // Immediately update status to show user the action started - f.status = 'scanning' - fileStatusCache.value[f.file_uuid] = 'scanning' - - const procResult: any = await apiCall('process_file', { fileUuid: f.file_uuid, processors: procs }) - if (procResult.success) { - actionMsg.value = `Deep scan started: ${f.file_name}` - syncFileStatus(f.file_uuid) + if (result.success) { + file.status = 'scanning' + fileStatusCache.value[file.file_uuid] = 'scanning' + actionMsg.value = '✅ Deep scan started' + await syncFileStatus(file.file_uuid) } else { - actionMsg.value = `Scan failed: ${procResult.message}` - // Revert status on failure - f.status = 'registered' - fileStatusCache.value[f.file_uuid] = 'registered' + file.status = oldStatus + fileStatusCache.value[file.file_uuid] = oldStatus + actionMsg.value = `Failed: ${result.message || result.error}` } } catch (e: any) { - actionMsg.value = `Deep scan error: ${e}` - console.error('[deepScan]', e) + file.status = oldStatus + fileStatusCache.value[file.file_uuid] = oldStatus + actionMsg.value = `Error: ${e.message}` + console.error('[deep-scan]', e) + } finally { + processing.value = false + await loadFiles() + setTimeout(() => { actionMsg.value = '' }, 5000) } - processing.value = false - await loadFiles() - setTimeout(() => { actionMsg.value = '' }, 8000) } async function waitForStable(condition: () => boolean | Promise, timeoutMs = 15000, intervalMs = 500): Promise { @@ -1959,26 +2029,38 @@ async function doReprocess(f: any) { if (!f.isRegistered) { actionMsg.value = 'File not registered'; return } reprocessing.value = true + const file = filesCache.value.find((x: any) => x.file_uuid === f.file_uuid) + if (!file) { + actionMsg.value = 'File not found in cache' + reprocessing.value = false + return + } + + const oldStatus = file.status + try { - actionMsg.value = '🔄 Re-scanning...' + file.status = 'preparing' + fileStatusCache.value[file.file_uuid] = 'preparing' + actionMsg.value = '🔍 Starting deep scan...' - // Use single re-scan API with auto_pipeline: true const result = await apiCall('re_scan_file', { - fileUuid: f.file_uuid, + fileUuid: file.file_uuid, autoPipeline: true }) if (result.success) { - actionMsg.value = '✅ Re-scan started' - // Update status to scanning - f.status = 'scanning' - fileStatusCache.value[f.file_uuid] = 'scanning' - // Sync status from Core API - await syncFileStatus(f.file_uuid) + file.status = 'scanning' + fileStatusCache.value[file.file_uuid] = 'scanning' + actionMsg.value = '✅ Deep scan started' + await syncFileStatus(file.file_uuid) } else { + file.status = oldStatus + fileStatusCache.value[file.file_uuid] = oldStatus actionMsg.value = `Failed: ${result.message || result.error}` } } catch (e: any) { + file.status = oldStatus + fileStatusCache.value[file.file_uuid] = oldStatus actionMsg.value = `Error: ${e.message}` console.error('[re-scan]', e) } finally { @@ -2161,6 +2243,8 @@ function resetFilters() { .ms-fm-section-title { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 12px; padding-left: 4px; display: flex; align-items: center; gap: 12px; } .ms-batch-register-btn { margin-left: auto; padding: 4px 12px; font-size: 12px; font-weight: 600; background: var(--primary-color); color: white; border: none; border-radius: 6px; cursor: pointer; } .ms-batch-register-btn:hover { background: var(--primary-hover); } +.ms-batch-unregister-btn { margin-left: auto; padding: 4px 12px; font-size: 12px; font-weight: 600; background: var(--danger-color, #dc3545); color: white; border: none; border-radius: 6px; cursor: pointer; } +.ms-batch-unregister-btn:hover { background: var(--danger-hover, #c82333); } .ms-fm-card-checkbox { position: absolute; bottom: 8px; right: 8px; width: 24px; height: 24px; border: 2px solid var(--border-color); border-radius: 4px; background: var(--card-background); cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; color: var(--primary-color); z-index: 10; } .ms-fm-card-checkbox:hover { border-color: var(--primary-color); } .ms-fm-card-register-btn { position: absolute; bottom: 8px; right: 40px; width: 28px; height: 28px; border: none; border-radius: 4px; background: var(--primary-color); color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; opacity: 0; transition: opacity .2s, transform .15s; z-index: 10; } diff --git a/src/views/PeopleView.vue b/src/views/PeopleView.vue index 1f8a06a..bb3e632 100644 --- a/src/views/PeopleView.vue +++ b/src/views/PeopleView.vue @@ -2028,11 +2028,15 @@ async function loadClusterResults() { } const groups = results?.face_groups || results?.clusters || [] const frames = results?.frames || [] - // 統一 group_id → cluster_id + // 統一 group_id → cluster_id, 並添加 group_uuid for (const g of groups) { if (g.group_id !== undefined && g.cluster_id === undefined) { g.cluster_id = g.group_id } + // 添加 group_uuid 字段供前端使用 + if (g.group_uuid === undefined) { + g.group_uuid = `group_${g.cluster_id || g.group_id}` + } } // Build cluster_id → faces[] mapping from frames const clusterFacesMap: Record = {}