/** * Dual-mode API client for Portal * - In Tauri app: uses `invoke` to call Rust commands * - In browser dev mode: uses direct HTTP fetch to backend API */ import { ref } from 'vue' // ── Global API Debug State ────────────────────────────────────────────── export const lastApiCall = ref(null) // ── Types ─────────────────────────────────────────────────────────────── export interface PortalConfig { api_base_url: string api_key: string timeout_secs: number } export interface SearchRequest { query: string limit?: number mode?: string uuid?: string filters?: Record } export interface SearchResult { query: string count: number hits: SearchHit[] } export interface SearchHit { id: string vid: string start_frame: number end_frame: number fps: number start: number end: number text: string score: number title?: string file_path?: string has_visual_stats?: boolean parent_id?: string } export interface RegisterResponse { success: boolean file_uuid: string file_name: string file_path: string duration: number width: number height: number fps: number total_frames: number registration_time: string | null already_exists: boolean message: string } export interface UnregisterResponse { success: boolean message: string file_uuid?: string uuid?: string deleted_face_detections?: number deleted_processor_results?: number deleted_chunks?: number } // ── Config (browser-only, stored in localStorage) ─────────────────────── const DEFAULT_CONFIG: PortalConfig = { api_base_url: import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:3003', api_key: import.meta.env.VITE_API_KEY || '', timeout_secs: 30, } function getConfig(): PortalConfig { const stored = localStorage.getItem('portal_config') if (stored) { try { return JSON.parse(stored) } catch { return DEFAULT_CONFIG } } return DEFAULT_CONFIG } export function saveConfig(config: PortalConfig): void { localStorage.setItem('portal_config', JSON.stringify(config)) } export async function logout(): Promise { try { const config = getConfig(); const apiKey = config.api_key || localStorage.getItem('momentry_api_key'); if (apiKey) { // Call logout API to invalidate session on server side (if implemented) // For now, just best effort await fetch(`${config.api_base_url}/api/v1/auth/logout`, { method: 'POST', headers: { 'X-API-Key': apiKey } }).catch(() => {}); // Ignore network errors } } catch (e) { console.warn('Logout API call failed:', e); } finally { handleSessionExpired(); } } // ── Environment detection ─────────────────────────────────────────────── function isTauri(): boolean { return '__TAURI_INTERNALS__' in window || '__TAURI__' in window } // ── HTTP fetch wrapper (browser) ──────────────────────────────────────── // Helper to handle session expiration function handleSessionExpired() { console.warn("Session expired or connection error, redirecting to login..."); localStorage.removeItem('momentry_user'); localStorage.removeItem('portal_config'); localStorage.removeItem('momentry_api_key'); if (window.location.pathname !== '/login') { window.location.href = '/login'; } } // Retry fetch logic export async function httpFetch(url: string, options?: RequestInit, retries = 3): Promise { // Re-read config to ensure we have the latest key if it changed const config = getConfig(); // Fallback key check const apiKey = config.api_key || localStorage.getItem('momentry_api_key') || ''; const headers = new Headers(options?.headers); headers.set('Content-Type', 'application/json'); if (apiKey) { headers.set('X-API-Key', apiKey); } const method = options?.method || 'GET' // Update debug state (only on first attempt) if (retries === 3) { lastApiCall.value = { type: 'HTTP', method, url, headers: { ...headers, 'X-API-Key': apiKey ? apiKey.substring(0, 10) + '...' : 'none' }, body: options?.body ? JSON.parse(options.body as string) : null, status: 'loading', data: null, timestamp: new Date().toISOString() } } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), config.timeout_secs * 1000) try { const resp = await fetch(url, { ...options, headers, signal: controller.signal, }) // Handle 401 Unauthorized immediately if (resp.status === 401) { handleSessionExpired(); throw new Error("Unauthorized"); } if (!resp.ok) { const text = await resp.text() lastApiCall.value = { ...lastApiCall.value, status: `Error ${resp.status}`, data: text } // Don't redirect on 500/404, just throw throw new Error(`HTTP ${resp.status}: ${text}`) } const contentType = resp.headers.get('content-type') let data: any; if (contentType?.includes('application/json')) { data = await resp.json() } else { data = await resp.text() } lastApiCall.value = { ...lastApiCall.value, status: `OK ${resp.status}`, data } return data as Promise } catch (e: any) { // Network error (server restart/timeout) // e.name === 'TypeError' usually indicates network error in fetch if (e.name === 'TypeError' && retries > 0) { console.warn(`Network error, retrying... (${retries} attempts left)`); await new Promise(r => setTimeout(r, 1000)); // Wait 1s before retry return httpFetch(url, options, retries - 1); } // If retries exhausted or it's a different error lastApiCall.value = { ...lastApiCall.value, status: 'Error', data: e?.message || e } // If network error and no retries left, redirect to login if (e.name === 'TypeError') { handleSessionExpired(); } throw e } finally { clearTimeout(timeout) } } async function tauriInvoke(command: string, args?: Record): Promise { const { invoke } = await import('@tauri-apps/api/core') lastApiCall.value = { type: 'TAURI', command, args: args || {}, status: 'loading', data: null, timestamp: new Date().toISOString() } try { const result = await invoke(command, args) lastApiCall.value = { ...lastApiCall.value, status: 'Success', data: result } return result } catch (e) { lastApiCall.value = { ...lastApiCall.value, status: 'Error', data: e } throw e } } // ── Unified API functions ─────────────────────────────────────────────── export async function searchVideos(query: string, limit = 10, mode = 'vector', fileUuid?: string): Promise { if (isTauri()) { return tauriInvoke('search_videos', { query, limit, mode, uuid: fileUuid }) } const config = getConfig() const url = `${config.api_base_url}/api/v1/search/universal` const body: any = { query, limit, mode } if (fileUuid) body.uuid = fileUuid const response: any = await httpFetch(url, { method: 'POST', body: JSON.stringify(body), }) return { query: response.query || query, count: response.results?.length || 0, hits: (response.results || []).map((r: any) => { const chunkId = r.chunk_id || r.id || '' const fileUuid = r.uuid || r.vid || r.file_uuid || chunkId.split('_').slice(0, -1).join('_') || '' return { id: chunkId, vid: fileUuid, start_frame: r.start_frame || Math.floor((r.start_time || 0) * (r.fps || 30)), end_frame: r.end_frame || Math.floor((r.end_time || 0) * (r.fps || 30)), fps: r.fps || 30, start: r.start_time || r.start || 0, end: r.end_time || r.end || 0, text: r.text || r.text_content || '', score: r.score || 0, title: r.title || r.file_name || '', file_path: r.file_path, has_visual_stats: !!r.visual_stats, parent_id: r.parent_chunk_id, } }), } } export async function searchChunks(query: string, uuid?: string): Promise { if (isTauri()) { return tauriInvoke('search_chunks', { query, uuid }) } const config = getConfig() const url = `${config.api_base_url}/api/v1/search/universal` const response: any = await httpFetch(url, { method: 'POST', body: JSON.stringify({ query, uuid, limit: 20 }), }) return { query: response.query || query, count: response.results?.length || 0, hits: (response.results || []).map((r: any) => ({ id: r.chunk_id || r.id, vid: r.uuid || r.vid || r.file_uuid || '', start_frame: Math.floor((r.start_time || 0) * (r.fps || 30)), end_frame: Math.floor((r.end_time || 0) * (r.fps || 30)), fps: r.fps || 30, start: r.start_time || r.start || 0, end: r.end_time || r.end || 0, text: r.text || r.text_content || '', score: r.score || 0, title: r.title || r.file_name || '', file_path: r.file_path, has_visual_stats: !!r.visual_stats, parent_id: r.parent_chunk_id, })), } } export async function getHealth(): Promise { if (isTauri()) { return tauriInvoke('get_health_detailed') } const config = getConfig() return httpFetch(`${config.api_base_url}/health/detailed`) } export async function getIngestStats(): Promise { if (isTauri()) { return tauriInvoke('get_ingest_stats') } const config = getConfig() return httpFetch(`${config.api_base_url}/api/v1/stats/ingest`) } export async function getSftpgoStatus(): Promise { if (isTauri()) { return tauriInvoke('get_sftpgo_status') } const config = getConfig() return httpFetch(`${config.api_base_url}/api/v1/stats/sftpgo`) } export async function getInferenceHealth(): Promise { if (isTauri()) { return tauriInvoke('get_inference_health') } const config = getConfig() return httpFetch(`${config.api_base_url}/api/v1/stats/inference`) } export async function getVideos( query?: string, status?: string, page: number = 1, page_size: number = 10, uuid?: string ): Promise { if (isTauri()) { return tauriInvoke('get_videos', { query, status, page, page_size, uuid }) } const config = getConfig() const params = new URLSearchParams() if (query) params.append('q', query) if (status) params.append('status', status) if (uuid) params.append('uuid', uuid) params.append('page', String(page)) params.append('page_size', String(page_size)) return httpFetch(`${config.api_base_url}/api/v1/files?${params.toString()}`) } export async function listIdentities(uuid?: string): Promise { if (isTauri()) { return tauriInvoke('list_identities', { uuid }) } const config = getConfig() const url = uuid ? `${config.api_base_url}/api/v1/identities?uuid=${encodeURIComponent(uuid)}` : `${config.api_base_url}/api/v1/identities` return httpFetch(url) } export async function translateText(text: string, targetLang: string = 'zh-TW'): Promise { if (isTauri()) { return tauriInvoke('translate_text', { text, target_lang: targetLang }) } try { // Use our internal Agent API const config = getConfig() const response = await httpFetch(`${config.api_base_url}/api/v1/agents/translate`, { method: 'POST', body: JSON.stringify({ text, target_language: targetLang }) }) if (response.success && response.translated_text) { return response.translated_text } } catch (e) { console.warn('Translation Agent failed:', e) } return text } export async function getPersonThumbnail(fileUuid: string, traceId?: number): Promise { if (isTauri()) { return tauriInvoke('get_person_thumbnail_b64', { file_uuid: fileUuid, trace_id: traceId }) } const config = getConfig() if (traceId !== undefined) { return `${config.api_base_url}/api/v1/file/${fileUuid}/trace/${traceId}/video` } return `${config.api_base_url}/api/v1/file/${fileUuid}/thumbnail` } export async function registerIdentity(name: string, images: string[]): Promise { if (isTauri()) { return tauriInvoke('register_identity', { name, images }) } const config = getConfig() return httpFetch(`${config.api_base_url}/api/v1/identity`, { method: 'POST', body: JSON.stringify({ face_json_path: images[0] || '', identity_name: name }), }) } export async function processVideo(uuid: string, processors?: string[]): Promise { if (isTauri()) { return tauriInvoke('process_video', { uuid, processors }) } const config = getConfig() const body = processors ? { processors } : {} return httpFetch(`${config.api_base_url}/api/v1/file/${uuid}/process`, { method: 'POST', body: JSON.stringify(body), }) } export async function registerVideo(filePath: string): Promise { if (isTauri()) { return tauriInvoke('register_video', { file_path: filePath }) } const config = getConfig() return httpFetch(`${config.api_base_url}/api/v1/files/register`, { method: 'POST', body: JSON.stringify({ file_path: filePath }), }) } export async function unregisterVideo(fileUuid: string): Promise { if (isTauri()) { return tauriInvoke('unregister_video', { file_uuid: fileUuid }) } const config = getConfig() return httpFetch(`${config.api_base_url}/api/v1/unregister`, { method: 'POST', body: JSON.stringify({ file_uuid: fileUuid }), }) } export async function listFaceCandidates(fileUuid?: string, minConfidence = 0.5, page = 1, pageSize = 20): Promise { if (isTauri()) { return tauriInvoke('list_face_candidates', { file_uuid: fileUuid, min_confidence: minConfidence, page, page_size: pageSize }) } const config = getConfig() const params = new URLSearchParams() if (fileUuid) params.append('file_uuid', fileUuid) params.append('min_confidence', String(minConfidence)) params.append('page', String(page)) params.append('page_size', String(pageSize)) return httpFetch(`${config.api_base_url}/api/v1/faces/candidates?${params.toString()}`) } export async function listTracesSorted( fileUuid: string, sortBy = 'face_count', limit = 100, minFaces = 1 ): Promise { if (isTauri()) { return tauriInvoke('list_traces_sorted', { file_uuid: fileUuid, sort_by: sortBy, limit, min_faces: minFaces }) } const config = getConfig() return httpFetch(`${config.api_base_url}/api/v1/file/${fileUuid}/face_trace/sortby`, { method: 'POST', body: JSON.stringify({ sort_by: sortBy, limit, min_faces: minFaces }), }) } /** * Embed query text using EmbeddingGemma with fallback. * Tries M5 (192.168.110.201:11436) first, falls back to M4 localhost. */ export async function embedQuery(text: string): Promise { const servers = [ 'http://192.168.110.201:11436/v1/embeddings', 'http://localhost:11436/v1/embeddings', ] for (const url of servers) { try { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: text, model: 'embeddinggemma-300m' }), signal: AbortSignal.timeout(5000), }) if (!res.ok) continue const data = await res.json() if (data?.data?.[0]?.embedding) return data.data[0].embedding } catch { continue } } throw new Error('Embedding servers unreachable') } export async function listTraceFaces(fileUuid: string, traceId: number, limit = 200, offset = 0): Promise { if (isTauri()) { return tauriInvoke('list_trace_faces', { file_uuid: fileUuid, trace_id: traceId, limit, offset }) } const config = getConfig() return httpFetch(`${config.api_base_url}/api/v1/file/${fileUuid}/trace/${traceId}/faces?limit=${limit}&offset=${offset}`) } export async function getIdentityFaces(identityId: number, page = 1, pageSize = 100): Promise { if (isTauri()) { return tauriInvoke('get_identity_faces', { identity_id: identityId, page, page_size: pageSize }) } const config = getConfig() return httpFetch(`${config.api_base_url}/api/v1/identity/${identityId}/files?page_size=${pageSize}&offset=${(page-1)*pageSize}`) } // ── Config helpers ────────────────────────────────────────────────────── export function getCurrentConfig(): PortalConfig { if (isTauri()) { return getConfig() // Will be overridden by Tauri config if needed } return getConfig() } export { isTauri }