feat: Library 批量注册 + PeopleView 修复 + 国际化

Library:
- 添加 checkbox 和鼠标框选功能
- Unregistered 卡片添加快速注册按钮
- 状态标签移至左下方,人脸图标移至左侧中间
- Deep Scan 改为 unregister -> register -> process
- 完整国际化(日语、繁体中文、英语)

PeopleView:
- 修复卡片不显示问题(使用 get_file_traces API)
- 移除对 identity_id 字段的依赖

其他:
- 添加日语语言包
- 添加 useDragSelect composable
- 新增文档:Face Bbox Null Bug Report
This commit is contained in:
2026-10-01 00:42:13 +08:00
parent 7b60ca3d2d
commit f3d75ebce8
35 changed files with 8054 additions and 961 deletions

View File

@@ -1,7 +1,7 @@
import { ref } from 'vue'
import { ref, reactive } from 'vue'
import { apiCall } from '@/api'
import { isTauri } from './api/config'
import type { PoseResponse, AppearanceResponse } from '@/api/types'
import { isTauri, getApiBase } from './api/config'
import type { PoseResponse, AppearanceResponse, FaceLandmarksResponse } from '@/api/types'
export const filesCache = ref<any[]>([])
export const filesLoaded = ref(false)
@@ -16,13 +16,37 @@ export const unassignedTracesCache = ref<any[]>([])
export const unassignedTracesLoaded = ref(false)
export const unassignedTracesTotal = ref(0)
export const unassignedTracesPage = ref(1)
export const faceCountsCache = ref<Record<string, number>>({})
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<Record<string, string>>({})
export const profilesCache = ref<Record<string, string>>({})
export const faceThumbsCache = ref<Record<string, string>>({})
export const poseCache = ref<Record<string, PoseResponse>>({})
export const appearanceCache = ref<Record<string, AppearanceResponse>>({})
export const faceLandmarksCache = ref<Record<string, FaceLandmarksResponse>>({})
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
@@ -63,6 +87,36 @@ export interface PipelineStats {
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
@@ -131,13 +185,27 @@ export interface IdentityAgentStats {
confirmations: number
}
export interface FileStats {
json: JsonStats
postgresql: PostgresqlStats
qdrant: QdrantStats
tkg: TkgStats
identity_agent: IdentityAgentStats
}
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<Record<string, Record<string, ProcessorOutputInfo>>>({})
export const fileProgressCache = ref<Record<string, FileProgress>>({})
@@ -146,22 +214,47 @@ export const fileStatusCache = ref<Record<string, string>>({})
// Cluster agent state (persistent across navigation)
export const clusterResultsCache = ref<Record<string, any[]>>({})
export const clusterRunningState = ref<Record<string, boolean>>({})
const clusterRunningSince = ref<Record<string, number>>({})
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()
}
@@ -170,7 +263,7 @@ export function isClusterRunning(fileUuid: string): boolean {
}
export async function pollClusterResults(fileUuid: string, onDone?: (results: any[]) => void) {
if (!fileUuid || isClusterRunning(fileUuid)) return
if (!fileUuid) return
setClusterRunning(fileUuid, true)
try {
for (let i = 0; i < 120; i++) {
@@ -196,7 +289,7 @@ export async function pollClusterResults(fileUuid: string, onDone?: (results: an
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 || c.trace_ids?.[0]
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}`
@@ -340,13 +433,14 @@ const MAX_PROFILE_CONCURRENT = 4
const loadingThumbs = new Set<string>()
const loadingProfiles = new Set<string>()
const loadingFaceThumbs = new Set<string>()
export const loadingFaceThumbs = reactive(new Set<string>())
const loadingProcessorCounts = new Set<string>()
const pollingProgress = new Set<string>()
export const pollingProgressSet = pollingProgress
export const pipelineStatsCache = ref<Record<string, PipelineStats>>({})
export const fileStatsCache = ref<Record<string, FileStats>>({})
export const systemStatsCache = ref<SystemStats | null>(null)
const loadingPipelineStats = new Set<string>()
const loadingFileStats = new Set<string>()
@@ -410,6 +504,7 @@ export function startFilesWatch() {
}
// 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)
@@ -417,14 +512,19 @@ export function startFilesWatch() {
// Status changed, update cache
const idx = filesCache.value.findIndex((f: any) => f.file_uuid === newFile.file_uuid)
if (idx >= 0) {
filesCache.value[idx] = newFile
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)
}
}, 5000)
}, 3000)
}
export function stopFilesWatch() {
@@ -570,8 +670,8 @@ export async function ensureUnassignedTraces(fileUuid: string, page?: number) {
}
_unassignedTracesLoading = true
try {
const args: any = { page: targetPage, perPage: UNASSIGNED_PER_PAGE, fileUuid }
const batch: any = await apiCall('get_unassigned_traces', args)
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
@@ -586,19 +686,21 @@ export async function ensureUnassignedTraces(fileUuid: string, page?: number) {
}
export function loadTraceThumb(t: any) {
if (!t.file_uuid || !t.trace_id) return
const key = `${t.trace_id}-${t.file_uuid}-${t.best_face_id || 0}`
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 {
// Use direct trace-thumb endpoint for fast keyFace.jpg loading (~8ms)
const url = `/api/v1/trace-thumb/${t.file_uuid}/${t.trace_id}`
// 改用自己的 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()
const dataUrl = URL.createObjectURL(blob)
faceThumbsCache.value[key] = dataUrl
faceThumbsCache.value[key] = URL.createObjectURL(blob)
}
} catch (e) {
console.error('loadTraceThumb failed:', key, e)
@@ -795,15 +897,11 @@ export async function pollProgress(fileUuid: string) {
gpu_percent: data.gpu_percent,
memory_percent: data.memory_percent,
}
fileStatusCache.value[fileUuid] = 'processing'
const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (f) f.status = 'processing'
// 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)
fileStatusCache.value[fileUuid] = 'completed'
if (f) f.status = 'completed'
saveProgressCacheToStorage()
await loadProcessorCounts(fileUuid)
break
@@ -838,22 +936,8 @@ export async function refreshFileStatus(fileUuid: string) {
try {
const pipeline = await loadPipelineStats(fileUuid, true)
if (pipeline) {
const isComplete = pipeline.overall_progress >= 1.0 || pipeline.stages?.every((s: any) => s.status === 'completed' || s.status === 'failed')
const isRunning = pipeline.stages?.some((s: any) => s.status === 'running')
if (isComplete) {
fileStatusCache.value[fileUuid] = 'completed'
await loadFileStats(fileUuid, true)
} else if (isRunning) {
fileStatusCache.value[fileUuid] = 'processing'
}
// Also update the file object in filesCache
const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (f) {
if (isComplete) f.status = 'completed'
else if (isRunning) f.status = 'processing'
}
// No local status promotion - status comes from Core API only
await loadFileStats(fileUuid, true)
}
} catch (e) {
console.error('refreshFileStatus failed:', fileUuid, e)
@@ -921,10 +1005,12 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis
processors: 0.30,
rule1_ingestion: 0.05,
face_tracing: 0.05,
identity_agent: 0.10,
face_dedup: 0.10,
tkg_nodes: 0.20,
tkg_edges: 0.15,
rule2_ingestion: 0.15
rule2_ingestion: 0.10,
vlm_summary: 0.04,
moment_chunks: 0.01
}
const mappedStages = stages.map((s: any) => ({
@@ -934,7 +1020,6 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis
status: s.status || 'pending'
}))
// 用前端权重重新计算 overall_progress(不信任 API 返回的值)
const calculatedProgress = mappedStages.reduce((sum, s) => sum + s.weight * s.progress, 0)
const pipelineStats: PipelineStats = {
@@ -946,14 +1031,7 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis
pipelineStatsCache.value = { ...pipelineStatsCache.value, [fileUuid]: pipelineStats }
// If pipeline is complete, update file status
if (calculatedProgress >= 1.0) {
const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (f && f.status !== 'completed') {
f.status = 'completed'
fileStatusCache.value[fileUuid] = 'completed'
}
}
// No local status promotion - status comes from Core API only
return pipelineStats
} catch (e) {
@@ -961,11 +1039,9 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis
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')
// 如果都完成,显示100%
const progress = allComplete ? 1.0 : (oldData.overall_progress || 0) / 100
const status = allComplete ? 'completed' : (hasRunning ? 'running' : 'pending')
@@ -988,6 +1064,19 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis
}
}
export async function loadSystemStats(): Promise<SystemStats | null> {
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<FileStats | null> {
if (!fileUuid) return null
if (!force && fileStatsCache.value[fileUuid]) return fileStatsCache.value[fileUuid]
@@ -1060,6 +1149,16 @@ export async function loadFileStats(fileUuid: string, force = false): Promise<Fi
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
}
}
@@ -1090,7 +1189,9 @@ export async function loadFileStats(fileUuid: string, force = false): Promise<Fi
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 }
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
@@ -1132,15 +1233,12 @@ export async function pollPipelineProgress(fileUuid: string) {
const idx = filesCache.value.findIndex((x: any) => x.file_uuid === fileUuid)
if (idx === -1) continue
fileStatusCache.value[fileUuid] = 'processing'
filesCache.value.splice(idx, 1, { ...filesCache.value[idx], status: 'processing' })
// 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)
fileStatusCache.value[fileUuid] = 'completed'
filesCache.value.splice(idx, 1, { ...filesCache.value[idx], status: 'completed' })
saveProgressCacheToStorage()
await loadFileStats(fileUuid, true)
break
@@ -1210,4 +1308,24 @@ export async function getAppearance(fileUuid: string, frame: number, bbox?: { x:
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<FaceLandmarksResponse | null> {
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
}
}