559 lines
17 KiB
TypeScript
559 lines
17 KiB
TypeScript
/**
|
|
* 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<any>(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<string, unknown>
|
|
}
|
|
|
|
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<void> {
|
|
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<T>(url: string, options?: RequestInit, retries = 3): Promise<T> {
|
|
// 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<T>
|
|
|
|
} 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<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
|
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<T>(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<SearchResult> {
|
|
if (isTauri()) {
|
|
return tauriInvoke<SearchResult>('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<any>(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<SearchResult> {
|
|
if (isTauri()) {
|
|
return tauriInvoke<SearchResult>('search_chunks', { query, uuid })
|
|
}
|
|
|
|
const config = getConfig()
|
|
const url = `${config.api_base_url}/api/v1/search/universal`
|
|
|
|
const response: any = await httpFetch<any>(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<any> {
|
|
if (isTauri()) {
|
|
return tauriInvoke('get_health_detailed')
|
|
}
|
|
|
|
const config = getConfig()
|
|
return httpFetch(`${config.api_base_url}/health/detailed`)
|
|
}
|
|
|
|
export async function getIngestStats(): Promise<any> {
|
|
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<any> {
|
|
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<any> {
|
|
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<any> {
|
|
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<any> {
|
|
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<string> {
|
|
if (isTauri()) {
|
|
return tauriInvoke<string>('translate_text', { text, target_lang: targetLang })
|
|
}
|
|
|
|
try {
|
|
// Use our internal Agent API
|
|
const config = getConfig()
|
|
const response = await httpFetch<any>(`${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<string> {
|
|
if (isTauri()) {
|
|
return tauriInvoke<string>('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<any> {
|
|
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<any> {
|
|
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<RegisterResponse> {
|
|
if (isTauri()) {
|
|
return tauriInvoke<RegisterResponse>('register_video', { file_path: filePath })
|
|
}
|
|
const config = getConfig()
|
|
return httpFetch<RegisterResponse>(`${config.api_base_url}/api/v1/files/register`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ file_path: filePath }),
|
|
})
|
|
}
|
|
|
|
export async function unregisterVideo(fileUuid: string): Promise<UnregisterResponse> {
|
|
if (isTauri()) {
|
|
return tauriInvoke<UnregisterResponse>('unregister_video', { file_uuid: fileUuid })
|
|
}
|
|
const config = getConfig()
|
|
return httpFetch<UnregisterResponse>(`${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<any> {
|
|
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<any> {
|
|
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<number[]> {
|
|
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<any> {
|
|
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<any> {
|
|
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 }
|