import { invoke } from '@tauri-apps/api/core' import { isTauri, getApiBase } from './config' // Detect base URL: use current origin when remote, fallback to localStorage or localhost function detectBaseUrl(): string { const hostname = typeof window !== 'undefined' ? window.location.hostname : '' const isLocal = hostname === 'localhost' || hostname === '127.0.0.1' if (!isLocal && typeof window !== 'undefined') { return window.location.origin } return localStorage.getItem('markbase_url') || 'http://localhost:11438' } // Poll command status until completed or failed export async function pollCommandStatus(commandId: string, intervalMs: number = 2000, maxAttempts: number = 150): Promise { for (let attempt = 0; attempt < maxAttempts; attempt++) { const status = await apiCall('get_command_status', { commandId }) if (status?.status === 'completed') { return status } if (status?.status === 'failed') { throw new Error(status.last_error || 'Command failed') } await new Promise(resolve => setTimeout(resolve, intervalMs)) } throw new Error('Command polling timeout') } // API proxy: Tauri IPC or HTTP fetch export async function apiCall(cmd: string, args: Record): Promise { if (cmd === 'get_video_stream') { const base = isTauri ? 'http://localhost:8888' : getApiBase() const { url } = buildHttpRequest(cmd, args) return `${base}${url}` } if (cmd === 'upload_profile_image') { if (isTauri) { return invoke('upload_profile_image', { uuid: args.uuid, filePath: args.filePath }) } const base = getApiBase() const { url } = buildHttpRequest(cmd, args) const formData = new FormData() formData.append('image', args.file) const fullUrl = `${base}${url}` const response = await fetch(fullUrl, { method: 'POST', body: formData }) if (!response.ok) throw new Error(`Upload failed: ${response.status}`) return response.json() } if (!isTauri && (cmd === 'update_identity_starred' || cmd === 'update_identity_status')) { const current: any = await httpCall('get_identity', { uuid: args.uuid }) const metadata = { ...(current.metadata || {}), ...(current.metadataJson ? JSON.parse(current.metadataJson) : {}) } if (cmd === 'update_identity_starred') { metadata.starred = args.starred } else { metadata.status = args.status } return httpCall('update_identity', { uuid: args.uuid, metadataJson: JSON.stringify(metadata) }) } // run_cluster_agent 也沒有 Tauri command if (cmd === 'run_cluster_agent') { const data = await httpCall(cmd, args) return transformResponse(cmd, data) } // get_cluster_results 也沒有 Tauri command if (cmd === 'get_cluster_results') { 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) return transformResponse(cmd, data) } // bind_speakers 也沒有 Tauri command if (cmd === 'bind_speakers') { const data = await httpCall(cmd, args) return transformResponse(cmd, data) } // get_processor_json 也沒有 Tauri command if (cmd === 'get_processor_json') { const data = await httpCall(cmd, args) return transformResponse(cmd, data) } // get_trace_profile / get_trace_profile_file 也沒有 Tauri command if (cmd === 'get_trace_profile' || cmd === 'get_trace_profile_file') { const data = await httpCall(cmd, args) return transformResponse(cmd, data) } // get_file_traces 也沒有 Tauri command if (cmd === 'get_file_traces') { const data = await httpCall(cmd, args) return transformResponse(cmd, data, args) } // search_file_faces 也沒有 Tauri command if (cmd === 'search_file_faces') { const data = await httpCall(cmd, args) return transformResponse(cmd, data, args) } // get_face_landmarks 也沒有 Tauri command if (cmd === 'get_face_landmarks') { const data = await httpCall(cmd, args) return transformResponse(cmd, data, args) } // Admin data comes from MarkBase API (/api/v2/admin/*) in all modes — no Tauri command if (cmd === 'get_system_stats' || cmd === 'list_admin_users' || cmd === 'list_shares' || cmd === 'validate_system_stats') { const data = await httpCall(cmd, args) return transformResponse(cmd, data) } if (isTauri) { try { const data = await invoke(cmd, args) return transformResponse(cmd, data) } catch (e: any) { console.error('[apiCall] invoke error:', typeof e, e) const errMsg = typeof e === 'string' ? e : (e?.message || String(e)) console.error('[apiCall] falling back to HTTP for', cmd, '- error:', errMsg) const data = await httpCall(cmd, args) return transformResponse(cmd, data) } } const data = await httpCall(cmd, args) return transformResponse(cmd, data) } const DEFAULT_TIMEOUT = 30000 const UNREGISTER_TIMEOUT = 200000 // 200s for large file unregister (was timing out at 30s) export enum ApiErrorType { NETWORK = 'network', TIMEOUT = 'timeout', SERVER = 'server', VALIDATION = 'validation', NOT_FOUND = 'not_found', } export class ApiError extends Error { type: ApiErrorType status?: number constructor(type: ApiErrorType, message: string, status?: number) { super(message) this.type = type this.status = status } } async function httpCall(cmd: string, args: Record, retries = 3): Promise { const base = getApiBase() 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(), requestTimeout ?? DEFAULT_TIMEOUT) let response: Response | null = null try { for (let i = 0; i < retries; i++) { try { response = await fetch(fullUrl, { method, headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }) if (response.ok) break if (response.status >= 500 && i < retries - 1) { await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i))) response = null continue } break } catch (e: any) { if (e.name === 'AbortError') { throw new ApiError(ApiErrorType.TIMEOUT, `Request timeout: ${cmd}`) } if (i < retries - 1) { await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i))) continue } throw new ApiError(ApiErrorType.NETWORK, `Network error: ${e.message}`) } } } finally { clearTimeout(timeout) } if (!response) throw new ApiError(ApiErrorType.NETWORK, 'No response') if (!response.ok) { const status = response.status const errorText = await response.text().catch(() => 'Unknown error') if (status === 404) { throw new ApiError(ApiErrorType.NOT_FOUND, errorText, status) } if (status >= 400 && status < 500) { throw new ApiError(ApiErrorType.VALIDATION, errorText, status) } throw new ApiError(ApiErrorType.SERVER, errorText, status) } if (response.status === 204) { return { success: true } } const contentType = response.headers.get('content-type') || '' if (contentType.includes('image/') || contentType.includes('video/') || contentType.includes('octet-stream')) { const buffer = await response.arrayBuffer() const bytes = new Uint8Array(buffer) if (cmd === 'get_identity_profile' || cmd === 'get_thumbnail' || cmd === 'get_face_thumbnail' || cmd === 'get_file_thumbnail_by_path') { const ext = contentType.includes('png') ? 'png' : 'jpeg' const blob = new Blob([bytes], { type: `image/${ext}` }) return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(reader.result as string) reader.onerror = () => reject(new Error('FileReader failed')) reader.readAsDataURL(blob) }) } const chunks: string[] = [] const chunkSize = 8192 for (let i = 0; i < bytes.length; i += chunkSize) { const slice = bytes.subarray(i, Math.min(i + chunkSize, bytes.length)) chunks.push(String.fromCharCode(...slice)) } return btoa(chunks.join('')) } const text = await response.text() try { return JSON.parse(text) } catch { return text } } function buildHttpRequest(cmd: string, args: Record): { url: string; method: string; body?: any; timeout?: number } { const a = args switch (cmd) { // --- Data APIs --- case 'get_files': { const ps = a.args?.pageSize || 500 return { url: `/api/v1/files/scan?page_size=${ps}`, method: 'GET' } } case 'get_people': { return { url: `/api/v1/identities?page=${a.page || 1}&per_page=${a.perPage || 100}`, method: 'GET' } } case 'get_file_identities': { return { url: `/api/v1/file/${a.fileUuid}/identities?page=${a.page || 1}&page_size=${a.pageSize || 50}`, method: 'GET' } } case 'get_identity_files': { return { url: `/api/v1/identity/${a.uuid}/files?page=${a.page || 1}&page_size=${a.pageSize || 20}`, method: 'GET' } } case 'get_faces': { return { url: `/api/v1/identity/${a.uuid}/faces?page_size=${a.perPage || 100}`, method: 'GET' } } case 'get_traces': { let url = `/api/v1/identity/${a.uuid}/traces?page_size=${a.perPage || 50}` if (a.page) url += `&page=${a.page}` return { url, method: 'GET' } } case 'get_face_candidates': { let url = `/api/v1/faces/candidates?page=${a.page || 1}&page_size=${a.perPage || 100}` if (a.fileUuid) url += `&file_uuid=${a.fileUuid}` return { url, method: 'GET' } } case 'get_unassigned_traces': { let url = `/api/v1/traces/unassigned?page=${a.page || 1}&page_size=${a.perPage || 20}` if (a.fileUuid) url += `&file_uuid=${a.fileUuid}` return { url, method: 'GET' } } case 'get_pending_persons': { return { url: `/api/v1/file/${a.fileUuid}/pending-persons`, method: 'GET' } } case 'get_identity': { return { url: `/api/v1/identity/${a.uuid}`, method: 'GET' } } // --- Search APIs --- case 'search_keyword': { return { url: '/api/v1/search/keyword', method: 'POST', body: { query: a.query, limit: a.limit || 20 } } } case 'search_semantic': { return { url: '/api/v1/search/semantic', method: 'POST', body: { query: a.query, limit: a.limit || 20 } } } case 'search_llm_smart': { return { url: '/api/v1/search/llm-smart', method: 'POST', body: { query: a.query, limit: a.limit || 20 } } } case 'search_people': { const params = new URLSearchParams() if (a.query) params.set('query', a.query) if (a.fileUuid) params.set('file_uuid', a.fileUuid) if (a.limit) params.set('limit', String(a.limit)) return { url: `/api/v1/search/people?${params.toString()}`, method: 'GET' } } 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 } } // --- Image APIs --- case 'get_thumbnail': { return { url: `/api/v1/file/${a.uuid}/thumbnail?frame=${a.frame || 30}`, method: 'GET' } } case 'get_identity_profile': { return { url: `/api/v1/identity/${a.uuid}/profile`, method: 'GET' } } case 'get_face_thumbnail': { let url = `/api/v1/face-thumbnail?uuid=${a.uuid}&frame=${a.frame || 0}` if (a.bboxX != null) url += `&bbox_x=${a.bboxX}` if (a.bboxY != null) url += `&bbox_y=${a.bboxY}` if (a.bboxW != null) url += `&bbox_w=${a.bboxW}` if (a.bboxH != null) url += `&bbox_h=${a.bboxH}` return { url, method: 'GET' } } case 'get_file_thumbnail_by_path': { return { url: `/api/v1/file/thumbnail?path=${encodeURIComponent(a.path)}&frame=${a.frame || 30}`, method: 'GET' } } case 'regenerate_trace_thumbnail': { return { url: `/api/v1/file/${a.fileUuid}/trace/${a.traceId}/regenerate-thumbnail`, method: 'POST' } } // --- Video API --- case 'get_video_stream': { let url = `/api/v1/file/${a.uuid}/video` const params: string[] = [] if (a.startFrame != null) params.push(`start_frame=${a.startFrame}`) if (a.endFrame != null) params.push(`end_frame=${a.endFrame}`) if (a.original === true) params.push('original=true') if (params.length) url += '?' + params.join('&') return { url, method: 'GET' } } // --- Identity Management --- case 'update_identity': { const meta: any = a.metadataJson ? JSON.parse(a.metadataJson) : {} const body: any = {} if (a.name) body.name = a.name if (Object.keys(meta).length) body.metadata = meta return { url: `/api/v1/identity/${a.uuid}`, method: 'PATCH', body } } case 'update_identity_name': { return { url: `/api/v1/identity/${a.uuid}`, method: 'PATCH', body: { name: a.name } } } case 'update_identity_status': { return { url: `/api/v1/identity/${a.uuid}`, method: 'PATCH', body: { metadata: { status: a.status } } } } case 'update_identity_starred': { return { url: `/api/v1/identity/${a.uuid}`, method: 'PATCH', body: { metadata: { starred: a.starred } } } } case 'upload_profile_image': { return { url: `/api/v1/identity/${a.uuid}/profile-image`, method: 'POST' } } case 'set_profile_from_face': { const body: any = { file_uuid: a.fileUuid } if (a.faceId) body.face_id = a.faceId if (a.id) body.id = a.id if (a.traceId) body.trace_id = a.traceId if (a.frameNumber) body.frame_number = a.frameNumber return { url: `/api/v1/identity/${a.uuid}/profile-image/from-face`, method: 'POST', body } } case 'delete_identity': { return { url: `/api/v1/identity/${a.uuid}`, method: 'DELETE' } } case 'create_identity_from_face': { const body: any = { name: a.name } if (a.traceIds) body.trace_ids = a.traceIds return { url: `/api/v1/file/${a.fileUuid}/pending-person`, method: 'POST', body } } // --- Identity Operations --- case 'merge_identities': { return { url: `/api/v1/identity/${a.uuid}/mergeinto`, method: 'POST', body: { into_uuid: a.intoUuid } } } // --- Trace Management --- case 'list_traces': { return { url: `/api/v1/file/${a.file_uuid}/traces`, method: 'POST' } } case 'get_file_traces': { const fu = a.fileUuid ?? a.file_uuid return { url: `/api/v1/file/${fu}/traces`, method: 'POST', body: { page: a.page || 1, page_size: a.page_size || 3000, limit: a.limit || 3000 } } } case 'search_file_faces': { const fu = a.fileUuid ?? a.file_uuid const params = new URLSearchParams() if (a.q) params.set('q', a.q) params.set('page', String(a.page || 1)) params.set('page_size', String(a.perPage || 20)) if (a.binding) params.set('binding', a.binding) if (a.minConfidence != null) params.set('min_confidence', String(a.minConfidence)) return { url: `/api/v1/file/${fu}/faces/search?${params.toString()}`, method: 'GET' } } case 'get_trace_profile': { const params = new URLSearchParams() 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 'get_trace_profile_file': { const params = new URLSearchParams() 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-file?${params.toString()}`, method: 'GET' } } case 'update_trace_profile': { 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 = { mode: a.mode ?? 'ungroup' } if (a.mode === 'delete') { if (a.hard_delete !== undefined) body.hard_delete = a.hard_delete } else { body.hard_delete = a.hard_delete ?? true } return { url: `/api/v1/file/${a.file_uuid}/trace/${a.trace_id}`, method: 'DELETE', body } } case 'restore_trace': { return { url: `/api/v1/file/${a.file_uuid}/trace/${a.trace_id}/restore`, method: 'POST' } } case 'merge_trace': { return { url: `/api/v1/file/${a.file_uuid}/trace/${a.source_id}/merge/${a.target_id}`, method: 'POST' } } case 'merge_groups': { return { url: `/api/v1/file/${a.fileUuid}/groups/merge`, method: 'POST', body: { file_uuid: a.fileUuid, source_groups: a.sourceGroups, target_group_name: a.targetGroupName } } } case 'bind_face': { const bindBody: any = { file_uuid: a.fileUuid } if (a.faceId) bindBody.face_id = a.faceId if (a.faceRowId) bindBody.id = a.faceRowId return { url: `/api/v1/identity/${a.uuid}/bind`, method: 'POST', body: bindBody } } case 'unbind_face': { const unbindBody: any = { file_uuid: a.fileUuid } if (a.faceId) unbindBody.face_id = a.faceId if (a.faceRowId) unbindBody.id = a.faceRowId if (a.frameNumber != null) unbindBody.frame_number = a.frameNumber return { url: `/api/v1/identity/${a.uuid}/unbind`, method: 'POST', body: unbindBody } } // --- Trace Operations --- case 'merge_traces': { return { url: `/api/v1/file/${a.file_uuid}/trace/${a.source_trace_id}/merge/${a.target_trace_id}`, method: 'POST' } } // --- Identity Undo/Redo --- case 'identity_undo': { const body: any = {} if (a.steps != null) body.steps = a.steps return { url: `/api/v1/identity/${a.uuid}/undo`, method: 'POST', body } } case 'identity_redo': { const body: any = {} if (a.steps != null) body.steps = a.steps return { url: `/api/v1/identity/${a.uuid}/redo`, method: 'POST', body } } case 'identity_history': { let url = `/api/v1/identity/${a.uuid}/history` const params: string[] = [] if (a.page != null) params.push(`page=${a.page}`) if (a.pageSize != null) params.push(`page_size=${a.pageSize}`) if (params.length) url += '?' + params.join('&') return { url, method: 'GET' } } case 'identity_bind_undo': { const body: any = {} if (a.steps != null) body.steps = a.steps return { url: `/api/v1/identity/${a.uuid}/bind/undo`, method: 'POST', body } } case 'identity_bind_redo': { const body: any = {} if (a.steps != null) body.steps = a.steps return { url: `/api/v1/identity/${a.uuid}/bind/redo`, method: 'POST', body } } case 'identity_bind_history': { let url = `/api/v1/identity/${a.uuid}/bind/history` const params: string[] = [] if (a.page != null) params.push(`page=${a.page}`) if (a.pageSize != null) params.push(`page_size=${a.pageSize}`) if (params.length) url += '?' + params.join('&') return { url, method: 'GET' } } case 'merge_undo': { return { url: `/api/v1/identity/merge/${a.mergeId}/undo`, method: 'POST' } } case 'merge_redo': { return { url: `/api/v1/identity/merge/${a.mergeId}/redo`, method: 'POST' } } case 'merge_history': { let url = '/api/v1/identity/merge/history' const params: string[] = [] if (a.sourceUuid) params.push(`source_uuid=${encodeURIComponent(a.sourceUuid)}`) if (a.targetUuid) params.push(`target_uuid=${encodeURIComponent(a.targetUuid)}`) if (a.page != null) params.push(`page=${a.page}`) if (a.pageSize != null) params.push(`page_size=${a.pageSize}`) if (params.length) url += '?' + params.join('&') return { url, method: 'GET' } } // --- File Operations --- case 'get_health': { return { url: '/api/v1/health', method: 'GET' } } case 'list_jobs': { return { url: '/api/v1/jobs', method: 'POST', body: {} } } case 'register_file': { return { url: '/api/v1/files/register', method: 'POST', body: { file_path: a.filePath } } } case 'get_command_status': { return { url: `/api/v1/command/${a.commandId}`, method: 'GET' } } case 'process_file': { return { url: `/api/v1/file/${a.fileUuid}/process`, method: 'POST', body: { processors: a.processors } } } 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', body: { confirm: true }, timeout: 600000 } } case 'get_cluster_results': { return { url: `/api/v1/file/${a.fileHash}/json/face_cluster`, method: 'POST', body: {} } } 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 } } } case 'get_file_profile': { return { url: `/api/v1/file-profile?file_uuid=${a.fileUuid}`, method: 'GET' } } case 'update_file_profile': { const body: any = { file_uuid: a.fileUuid, file_path: a.filePath, file_name: a.fileName } if (a.faceFilter != null) body.face_filter = a.faceFilter return { url: '/api/v1/file-profile', method: 'PUT', body } } case 'get_processor_json': { return { url: `/api/v1/processor-json?file_hash=${a.fileHash}&processor=${a.processor}`, method: 'GET' } } case 'get_file_processor_json': { return { url: `/api/v1/file/${a.fileUuid}/json/${a.processor}`, method: 'POST', body: {} } } case 'create_pending_identity': { return { url: '/api/v1/identities/pending', method: 'POST', body: { file_uuid: a.fileUuid, trace_id: a.traceId, face_id: a.faceId, cluster_id: a.clusterId, trace_count: a.traceCount } } } case 'bind_speakers': { return { url: `/api/v1/file/${a.fileUuid}/bind-speakers`, method: 'POST' } } 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, timeout: UNREGISTER_TIMEOUT } } case 'ingest_file': { return { url: `/api/v1/file/${a.fileUuid}/checkin`, method: 'POST' } } case 'checkout_file': { return { url: `/api/v1/file/${a.fileUuid}/checkout`, method: 'POST' } } // --- File Detail --- case 'get_file_info': { return { url: `/api/v1/file/${a.uuid}`, method: 'GET' } } case 'get_processor_counts': { return { url: `/api/v1/file/${a.uuid}/processor-counts`, method: 'GET' } } case 'get_progress': { return { url: `/api/v1/progress/${a.fileUuid}`, method: 'POST', body: {} } } case 'get_pipeline_stats': { return { url: `/api/v1/stats/pipeline/${a.fileUuid}`, method: 'GET' } } case 'get_file_stats': { return { url: `/api/v1/stats/file/${a.fileUuid}`, method: 'GET' } } // --- Search History --- case 'get_search_history': { const limit = a.limit ?? 30 return { url: `/api/v1/search-history?limit=${limit}`, method: 'GET' } } case 'save_search_history': { return { url: '/api/v1/search-history', method: 'POST', body: { id: a.id, query: a.query, title: a.title, chat_state: a.chatState, mode: a.mode } } } case 'rename_search_history': { return { url: `/api/v1/search-history/${a.id}/rename`, method: 'PATCH', body: { title: a.title } } } case 'pin_search_history': { return { url: `/api/v1/search-history/${a.id}/pin`, method: 'PATCH', body: { pinned: a.pinned } } } case 'delete_search_history': { return { url: `/api/v1/search-history/${a.id}`, method: 'DELETE' } } // --- Bookmarks --- case 'get_bookmarks': { return { url: '/api/v1/bookmarks', method: 'GET' } } case 'save_bookmark': { return { url: '/api/v1/bookmarks', method: 'POST', body: { label: a.label, history_id: a.historyId } } } case 'delete_bookmark': { return { url: `/api/v1/bookmarks/${a.id}`, method: 'DELETE' } } // --- Video Marks --- case 'get_video_marks': { return { url: `/api/v1/video-marks?file_uuid=${encodeURIComponent(a.fileUuid)}`, method: 'GET' } } case 'save_video_mark': { return { url: '/api/v1/video-marks', method: 'POST', body: { file_uuid: a.fileUuid, tag: a.tag, start_frame: a.startFrame, end_frame: a.endFrame, note: a.note } } } case 'delete_video_mark': { return { url: `/api/v1/video-mark/${a.id}`, method: 'DELETE' } } // --- Service Management --- case 'stop_service': { const mbUrl = detectBaseUrl() return { url: `${mbUrl}/api/v2/service/stop`, method: 'POST', body: { name: a.name } } } case 'start_service': { const mbUrl = detectBaseUrl() return { url: `${mbUrl}/api/v2/service/start`, method: 'POST', body: { name: a.name } } } // --- Admin Panel --- case 'get_system_stats': return { url: '/api/v2/admin/system-stats', method: 'GET' } case 'validate_system_stats': { let url = '/api/v1/system-stats/validate' if (a.layers) url += `?layers=${a.layers}` return { url, method: 'GET' } } case 'list_admin_users': return { url: '/api/v2/admin/users', method: 'GET' } case 'list_shares': return { url: '/api/v2/admin/shares', method: 'GET' } // --- Pose & Appearance --- case 'get_pose': { let url = `/api/v1/file/${a.fileUuid}/pose?frame=${a.frame}` if (a.bboxX != null) url += `&bbox_x=${a.bboxX}` if (a.bboxY != null) url += `&bbox_y=${a.bboxY}` if (a.bboxW != null) url += `&bbox_w=${a.bboxW}` if (a.bboxH != null) url += `&bbox_h=${a.bboxH}` return { url, method: 'GET' } } case 'get_appearance': { let url = `/api/v1/file/${a.fileUuid}/appearance?frame=${a.frame}` if (a.bboxX != null) url += `&bbox_x=${a.bboxX}` if (a.bboxY != null) url += `&bbox_y=${a.bboxY}` if (a.bboxW != null) url += `&bbox_w=${a.bboxW}` if (a.bboxH != null) url += `&bbox_h=${a.bboxH}` return { url, method: 'GET' } } case 'get_face_landmarks': { let url = `/api/v1/file/${a.fileUuid}/face-landmarks?frame=${a.frame}` if (a.bboxX != null) url += `&bbox_x=${a.bboxX}` if (a.bboxY != null) url += `&bbox_y=${a.bboxY}` if (a.bboxW != null) url += `&bbox_w=${a.bboxW}` if (a.bboxH != null) url += `&bbox_h=${a.bboxH}` return { url, method: 'GET' } } case 'sync_file_status': { return { url: `/api/v1/file/${a.fileUuid}/sync-status`, method: 'POST' } } default: throw new Error(`Unknown command: ${cmd}`) } } // Transform HTTP response to match Tauri invoke format // Some endpoints need data reshaping to match what the Rust commands return export function transformResponse(cmd: string, data: any, args?: Record): any { switch (cmd) { case 'get_files': { const files = data.files || data.data || data || [] return files.map((f: any) => ({ file_uuid: f.file_uuid || '', file_name: f.file_name || '', file_path: f.file_path || '', file_size: f.file_size || 0, modified_time: f.modified_time || '', isRegistered: f.isRegistered ?? f.is_registered ?? false, status: f.status || '', status_updated_at: f.status_updated_at || null, registrationTime: f.registration_time || null, ingested: f.ingested ?? ['completed','keyword_ready','semantic_ready','face_mgmt_ready','agent_ready'].includes(f.status), })) } case 'get_people': { const identities = data.identities || data.data || data || [] return identities.map((p: any) => ({ identity_uuid: p.identity_uuid || '', name: p.name || '', starred: p.metadata?.starred ?? p.starred ?? false, status: p.status || p.metadata?.status || 'pending', metadata: p.metadata || {}, file_uuids: p.file_uuids || [], source: p.source || '', tmdb_id: p.tmdb_id ?? null, })) } case 'get_file_identities': { return data } case 'get_faces': { const faces = data.data || data.faces || data || [] return faces.map((f: any) => ({ id: f.id, file_uuid: f.file_uuid || '', frame_number: f.frame_number || 0, timestamp_secs: f.timestamp_secs || 0, face_id: f.face_id ?? null, confidence: f.confidence || 0, bbox: f.bbox ? { x: f.bbox.x, y: f.bbox.y, width: f.bbox.width, height: f.bbox.height } : null, })) } case 'get_traces': { const traces = data.traces || data.data || [] return { total: data.total || traces.length || 0, traces: traces.map((t: any) => ({ trace_id: t.trace_id, file_uuid: t.file_uuid || '', frame_count: t.frame_count || 0, first_frame: t.first_frame || 0, last_frame: t.last_frame || 0, first_sec: t.first_sec || 0, last_sec: t.last_sec || 0, avg_confidence: t.avg_confidence || 0, face_id: t.face_id ?? null, })), } } case 'get_face_candidates': { const candidates = data.candidates || data.data || data || [] return candidates.map((c: any) => ({ id: c.id, face_id: c.face_id ?? null, file_uuid: c.file_uuid || '', frame_number: c.frame_number || 0, confidence: c.confidence || 0, bbox: c.bbox ? { x: c.bbox.x, y: c.bbox.y, width: c.bbox.width, height: c.bbox.height } : null, })) } case 'get_unassigned_traces': { const traces = data.traces || data.data || [] return { total: data.total || traces.length || 0, traces: traces.map((t: any) => ({ trace_id: t.trace_id, file_uuid: t.file_uuid || '', frame_count: t.frame_count || 0, start_frame: t.start_frame || 0, end_frame: t.end_frame || 1, best_face_id: t.best_face_id, best_face_frame: t.best_face_frame || 1, best_face_confidence: t.best_face_confidence || 1, best_face_bbox: t.best_face_bbox ? { x: t.best_face_bbox.x, y: t.best_face_bbox.y, width: t.best_face_bbox.width, height: t.best_face_bbox.height } : null, best_face_capture_quality: t.best_capture_quality ?? null, best_face_eye_ratio: t.best_face_eye_ratio ?? null, best_face_nose_ratio: t.best_face_nose_ratio ?? null, best_face_pose_score: t.best_face_pose_score ?? null, })), } } case 'get_pending_persons': { return { pending_persons: (data.pending_persons || data.data || []).map((p: any) => ({ identity_uuid: p.identity_uuid || '', name: p.name || '', trace_count: p.trace_count || 0, })), } } case 'get_file_traces': { const traces = data.traces || data.data || [] const fu = args?.fileUuid ?? args?.file_uuid ?? '' return { total: data.total_traces || traces.length || 0, traces: traces.map((t: any) => ({ trace_id: t.trace_id, file_uuid: t.file_uuid || fu, face_count: t.face_count || 0, frame_count: t.face_count || 0, start_frame: t.start_frame || 0, end_frame: t.end_frame || 1, start_time: t.start_time || 0, end_time: t.end_time || 0, duration_sec: t.duration_sec || 0, avg_confidence: t.avg_confidence || 0, best_face_id: t.best_face_id ?? null, best_face_frame: t.best_face_frame || 0, best_face_confidence: t.best_face_confidence || 0, best_face_bbox: t.best_face_bbox || null, best_face_capture_quality: t.best_face_capture_quality ?? null, avg_capture_quality: t.avg_capture_quality ?? null, thumbnail_url: t.thumbnail_url || '', })), } } case 'search_file_faces': { const items = data.data || data.traces || [] const fu = args?.fileUuid ?? args?.file_uuid ?? '' return { total: data.total || items.length || 0, page: data.page || 1, page_size: data.page_size || 20, traces: items.map((t: any) => ({ trace_id: t.trace_id ?? 0, file_uuid: t.file_uuid || fu, name: t.name ?? null, start_frame: t.start_frame || 0, end_frame: t.end_frame || 1, frame_count: t.frame_count || 0, avg_confidence: t.avg_confidence || 0, best_face_bbox: t.best_face_bbox ?? null, thumbnail_url: t.thumbnail_url || '', })), } } case 'search_llm_smart': { const results = data.results || data.data || data || [] return results.map((r: any) => { const asr = r.asr || null const asrSegments = asr?.segments || [] const asrLang = asr?.language || '' const asrLangProb = asr?.language_probability || 0 let asrStatus: 'no_audio_track' | 'silent_audio' | 'has_transcript' | 'processing' = 'processing' let asrMessage = 'Processing' if (asrSegments.length === 0) { if (asrLang === '' && asrLangProb === 0) { asrStatus = 'no_audio_track' asrMessage = 'No Audio Track' } else { asrStatus = 'silent_audio' asrMessage = asrLang ? `No Speech (${asrLang})` : 'No Speech' } } else { asrStatus = 'has_transcript' asrMessage = `${asrSegments.length} segments (${asrLang || 'unknown'})` } return { file_uuid: r.file_uuid || '', start_time: r.start_time ?? 0, end_time: r.end_time ?? 0, start_frame: r.start_frame ?? 0, end_frame: r.end_frame ?? 0, summary: r.summary || r.raw_text || '', similarity: r.similarity || 0, file_name: r.file_name || null, asr_status: asrStatus, asr_message: asrMessage, asr_segment_count: asrSegments.length, asr_language: asrLang, } }) } case 'search_keyword': { const results = data.results || data.data || data || [] return results.map((r: any) => ({ file_uuid: r.file_uuid || '', start_time: r.start_time ?? 0, end_time: r.end_time ?? 0, start_frame: r.start_frame ?? 0, end_frame: r.end_frame ?? 0, summary: r.summary || r.raw_text || '', similarity: r.similarity || 0, file_name: r.file_name || null, source_type: r.source_type || null, })) } case 'search_semantic': { const results = data.results || data.data || data || [] return results.map((r: any) => ({ file_uuid: r.file_uuid || '', start_time: r.start_time ?? 0, end_time: r.end_time ?? 0, start_frame: r.start_frame ?? 0, end_frame: r.end_frame ?? 0, summary: r.summary || r.raw_text || '', similarity: r.similarity || 0, file_name: r.file_name || null, source_type: r.source_type || null, })) } case 'search_people': { return data || { people: [] } } case 'search_identities': { const results = data.results || data.data || data || [] return results.map((r: any) => ({ identity_id: r.identity_id, name: r.name, source: r.source || '', tmdb_id: r.tmdb_id ?? null, file_uuid: r.file_uuid ?? null, start_time: r.start_time ?? 0, end_time: r.end_time ?? 0, start_frame: r.start_frame ?? null, end_frame: r.end_frame ?? null, text_content: r.text_content ?? null, })) } case 'register_file': { return { success: data.success ?? false, command_id: data.command_id ?? '', status: data.status ?? '', file_uuid: data.file_uuid ?? '', file_name: data.file_name ?? '', message: data.message ?? '', } } case 'get_command_status': { return data || null } case 'process_file': { return { success: data.success ?? false, file_uuid: data.file_uuid ?? '', message: data.message ?? '', } } case 'run_cluster_agent': { return { success: data.success ?? false, file_uuid: data.file_uuid ?? '', message: data.message ?? '', clusters: data.clusters ?? 0, total_traces: data.total_traces ?? 0, } } case 'get_cluster_results': { return data || null } case 'get_similar_face_groups': { return data || null } case 'get_trace_profile': { return data || null } case 'get_trace_profile_file': { return data || null } case 'update_trace_profile': { return data || { success: true } } case 'update_trace_profile_group': { return data || { success: true } } case 'get_file_profile': { return data || null } case 'update_file_profile': { return data || { success: true } } case 'get_processor_json': { return data || null } case 'bind_speakers': { return { success: data.success ?? false, bindings: data.bindings ?? 0, message: data.message ?? '', } } case 'create_pending_identity': { return { success: data.success ?? false, identity_uuid: data.identity_uuid ?? '', identity_id: data.identity_id ?? 0, message: data.message ?? '', } } case 'unregister_file': { return { success: data.success ?? false, command_id: data.command_id ?? '', file_uuid: data.file_uuid ?? '', message: data.message ?? '', } } case 'ingest_file': case 'checkout_file': { return { success: data.success ?? false, file_uuid: data.file_uuid ?? '', message: data.message ?? '', } } case 'update_identity': case 'upload_profile_image': case 'get_identity_profile': case 'get_face_thumbnail': case 'get_file_thumbnail_by_path': case 'get_search_history': case 'save_search_history': case 'rename_search_history': case 'pin_search_history': case 'delete_search_history': case 'get_bookmarks': case 'save_bookmark': case 'delete_bookmark': case 'identity_undo': case 'identity_redo': case 'identity_history': case 'identity_bind_undo': case 'identity_bind_redo': case 'identity_bind_history': case 'merge_undo': case 'merge_redo': case 'merge_history': return data case 'create_identity_from_face': { const respData = data.data || data return { success: data.success ?? false, identity_uuid: respData.identity_uuid ?? respData.uuid ?? '', name: respData.name ?? '', } } case 'get_pipeline_stats': case 'get_file_stats': { return data } case 'get_pose': case 'get_appearance': case 'get_face_landmarks': return data case 'get_system_stats': case 'list_shares': return data case 'list_admin_users': { const users = data.users || data.data || data || [] return users.map((u: any) => ({ username: u.username || '', homeDir: u.home_dir ?? u.homeDir ?? '', status: u.status ?? 1, })) } default: return data } }