fix: unregister timeout 200s + async mode

- Add UNREGISTER_TIMEOUT=200000 (200s) for large file unregister
- Add timeout field to buildHttpRequest return type
- httpCall now uses per-request timeout override
- unregister_file now uses asyncMode: true for better UX
- Show "Removing in background..." toast for async mode

Fixes timeout error when unregistering large files (83K faces, 20K nodes).
This commit is contained in:
2026-07-31 18:32:50 +08:00
parent 2b944cf9d1
commit 9d58fdb9d9
2 changed files with 358 additions and 174 deletions

View File

@@ -41,16 +41,6 @@ export async function apiCall(cmd: string, args: Record<string, any>): Promise<a
}
return httpCall('update_identity', { uuid: args.uuid, metadataJson: JSON.stringify(metadata) })
}
// run_identity_agent 沒有 Tauri command,直接走 HTTP 避免 invoke error
if (cmd === 'run_identity_agent') {
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
// run_identity_for_seed 也沒有 Tauri command
if (cmd === 'run_identity_for_seed') {
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
// run_cluster_agent 也沒有 Tauri command
if (cmd === 'run_cluster_agent') {
const data = await httpCall(cmd, args)
@@ -61,6 +51,11 @@ export async function apiCall(cmd: string, args: Record<string, any>): Promise<a
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
// get_similar_face_groups 也沒有 Tauri command
if (cmd === 'get_similar_face_groups') {
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
// create_pending_identity 也沒有 Tauri command
if (cmd === 'create_pending_identity') {
const data = await httpCall(cmd, args)
@@ -93,6 +88,7 @@ export async function apiCall(cmd: string, args: Record<string, any>): Promise<a
}
const DEFAULT_TIMEOUT = 30000
const UNREGISTER_TIMEOUT = 200000 // 200s for large file unregister (was timing out at 30s)
export enum ApiErrorType {
NETWORK = 'network',
@@ -114,11 +110,11 @@ export class ApiError extends Error {
async function httpCall(cmd: string, args: Record<string, any>, retries = 3): Promise<any> {
const base = getApiBase()
const { url, method, body } = buildHttpRequest(cmd, args)
const { url, method, body, timeout: requestTimeout } = buildHttpRequest(cmd, args)
const fullUrl = url.startsWith('http://') || url.startsWith('https://') ? url : `${base}${url}`
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
const timeout = setTimeout(() => controller.abort(), requestTimeout ?? DEFAULT_TIMEOUT)
let response: Response | null = null
try {
@@ -202,7 +198,7 @@ async function httpCall(cmd: string, args: Record<string, any>, retries = 3): Pr
}
}
function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string; method: string; body?: any } {
function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string; method: string; body?: any; timeout?: number } {
const a = args
switch (cmd) {
@@ -346,12 +342,19 @@ case 'search_keyword': {
}
case 'get_trace_profile': {
const params = new URLSearchParams()
if (a.file_uuid) params.set('file_uuid', a.file_uuid)
if (a.trace_id) params.set('trace_id', String(a.trace_id))
const fu = a.file_uuid ?? a.fileUuid
const tid = a.trace_id ?? a.traceId
if (fu) params.set('file_uuid', fu)
if (tid != null) params.set('trace_id', String(tid))
return { url: `/api/v1/trace-profile?${params.toString()}`, method: 'GET' }
}
case 'update_trace_profile': {
return { url: '/api/v1/trace-profile', method: 'PUT', body: a.body }
const body = a.body ?? {
file_uuid: a.file_uuid ?? a.fileUuid,
trace_id: a.trace_id ?? a.traceId,
...Object.fromEntries(Object.entries(a).filter(([k]) => !['fileUuid','traceId','file_uuid','trace_id','body'].includes(k)))
}
return { url: '/api/v1/trace-profile', method: 'PUT', body }
}
case 'delete_trace': {
const body: any = { hard_delete: a.hard_delete ?? true }
@@ -460,21 +463,22 @@ case 'search_keyword': {
case 'process_file': {
return { url: `/api/v1/file/${a.fileUuid}/process`, method: 'POST', body: { processors: a.processors } }
}
case 'run_identity_agent': {
return { url: `/api/v1/file/${a.fileUuid}/identity-agent`, method: 'POST' }
}
case 'run_identity_for_seed': {
return { url: '/api/v1/agents/identity/run-for-seed', method: 'POST', body: { file_uuid: a.fileUuid, identity_uuid: a.identityUuid } }
}
case 'get_identity_matches': {
return { url: `/api/v1/identity-matches?file_hash=${a.fileHash}`, method: 'GET' }
}
case 'run_cluster_agent': {
return { url: `/api/v1/file/${a.fileUuid}/cluster-agent`, method: 'POST' }
return { url: `/api/v1/file/${a.fileUuid}/cluster-agent`, method: 'POST', body: { confirm: true } }
}
case 'get_cluster_results': {
return { url: `/api/v1/cluster-results?file_hash=${a.fileHash}`, method: 'GET' }
}
case 'get_similar_face_groups': {
const params = new URLSearchParams()
if (a.traceIds) params.set('trace_ids', String(a.traceIds))
if (a.topK) params.set('top_k', String(a.topK))
if (a.minScore) params.set('min_score', String(a.minScore))
return { url: `/api/v1/file/${a.fileUuid}/face-groups/similar?${params.toString()}`, method: 'GET' }
}
case 'update_trace_profile_group': {
return { url: '/api/v1/trace-profile/group', method: 'PUT', body: { file_uuid: a.fileUuid, trace_ids: a.traceIds, name: a.name, key_frame: a.keyFrame, key_face: a.keyFace } }
}
@@ -496,7 +500,7 @@ case 'search_keyword': {
case 'unregister_file': {
const body: any = { file_uuid: a.fileUuid }
if (a.deleteOutputFiles != null) body.delete_output_files = a.deleteOutputFiles
return { url: '/api/v1/unregister', method: 'POST', body }
return { url: '/api/v1/unregister', method: 'POST', body, timeout: UNREGISTER_TIMEOUT }
}
case 'ingest_file': {
return { url: `/api/v1/file/${a.fileUuid}/checkin`, method: 'POST' }
@@ -611,7 +615,7 @@ export function transformResponse(cmd: string, data: any): any {
isRegistered: f.isRegistered ?? f.is_registered ?? false,
status: f.status || '',
registrationTime: f.registration_time || null,
ingested: f.ingested ?? (f.status === 'completed'),
ingested: f.ingested ?? ['completed','keyword_ready','semantic_ready','face_mgmt_ready','agent_ready'].includes(f.status),
}))
}
case 'get_people': {
@@ -705,19 +709,19 @@ case 'get_unassigned_traces': {
const asrLangProb = asr?.language_probability || 0
let asrStatus: 'no_audio_track' | 'silent_audio' | 'has_transcript' | 'processing' = 'processing'
let asrMessage = '處理中'
let asrMessage = 'Processing'
if (asrSegments.length === 0) {
if (asrLang === '' && asrLangProb === 0) {
asrStatus = 'no_audio_track'
asrMessage = '無音軌'
asrMessage = 'No Audio Track'
} else {
asrStatus = 'silent_audio'
asrMessage = asrLang ? `無語音 (${asrLang})` : '無語音'
asrMessage = asrLang ? `No Speech (${asrLang})` : 'No Speech'
}
} else {
asrStatus = 'has_transcript'
asrMessage = `${asrSegments.length} 段語音 (${asrLang || 'unknown'})`
asrMessage = `${asrSegments.length} segments (${asrLang || 'unknown'})`
}
return {
@@ -797,13 +801,6 @@ case 'get_unassigned_traces': {
message: data.message ?? '',
}
}
case 'run_identity_agent': {
return {
success: data.success ?? false,
file_uuid: data.file_uuid ?? '',
message: data.message ?? '',
}
}
case 'run_cluster_agent': {
return {
success: data.success ?? false,
@@ -816,6 +813,9 @@ case 'get_unassigned_traces': {
case 'get_cluster_results': {
return data || null
}
case 'get_similar_face_groups': {
return data || null
}
case 'get_trace_profile': { return data || null }
case 'update_trace_profile': { return data || { success: true } }
case 'update_trace_profile_group': { return data || { success: true } }