feat: add migrations, test scripts, and utility tools
- Add database migrations (006-028) for face recognition, identity, file_uuid - Add test scripts for ASR, face, search, processing - Add portal frontend (Tauri) - Add config, benchmark, and monitoring utilities - Add model checkpoints and pretrained model references
This commit is contained in:
511
portal/src/api/client.ts
Normal file
511
portal/src/api/client.ts
Normal file
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* 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
|
||||
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'): Promise<SearchResult> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke<SearchResult>('search_videos', { query, limit, mode })
|
||||
}
|
||||
|
||||
const config = getConfig()
|
||||
const url = mode === 'smart' || mode === 'bm25'
|
||||
? `${config.api_base_url}/api/v1/search`
|
||||
: `${config.api_base_url}/api/v1/search`
|
||||
|
||||
const response: any = await httpFetch<any>(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query, limit }),
|
||||
})
|
||||
|
||||
// Map backend response ({ results: [...], query: string }) to frontend SearchResult ({ hits: [...], query: string, count: number })
|
||||
return {
|
||||
query: response.query,
|
||||
count: response.results?.length || 0,
|
||||
hits: (response.results || []).map((r: any) => ({
|
||||
id: r.chunk_id || r.id,
|
||||
vid: r.uuid || r.vid,
|
||||
start_frame: Math.floor((r.start_time || 0) * 30),
|
||||
end_frame: Math.floor((r.end_time || 0) * 30),
|
||||
fps: 30,
|
||||
start: r.start_time || r.start || 0,
|
||||
end: r.end_time || r.end || 0,
|
||||
text: r.text || '',
|
||||
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 = uuid
|
||||
? `${config.api_base_url}/api/v1/search?uuid=${encodeURIComponent(uuid)}`
|
||||
: `${config.api_base_url}/api/v1/search`
|
||||
|
||||
const response: any = await httpFetch<any>(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query, limit: 10 }),
|
||||
})
|
||||
|
||||
return {
|
||||
query: response.query,
|
||||
count: response.results?.length || 0,
|
||||
hits: (response.results || []).map((r: any) => ({
|
||||
id: r.chunk_id || r.id,
|
||||
vid: r.uuid || r.vid,
|
||||
start_frame: Math.floor((r.start_time || 0) * 30),
|
||||
end_frame: Math.floor((r.end_time || 0) * 30),
|
||||
fps: 30,
|
||||
start: r.start_time || r.start || 0,
|
||||
end: r.end_time || r.end || 0,
|
||||
text: r.text || '',
|
||||
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/videos?${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(personId: string): Promise<string> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke<string>('get_person_thumbnail_b64', { person_id: personId })
|
||||
}
|
||||
|
||||
const config = getConfig()
|
||||
return `${config.api_base_url}/api/v1/people/${personId}/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/identities/from-person`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, images }),
|
||||
})
|
||||
}
|
||||
|
||||
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/assets/${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/videos/${fileUuid}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
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 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()
|
||||
const params = new URLSearchParams()
|
||||
params.append('page', String(page))
|
||||
params.append('page_size', String(pageSize))
|
||||
|
||||
return httpFetch(`${config.api_base_url}/api/v1/identities/${identityId}/faces?${params.toString()}`)
|
||||
}
|
||||
|
||||
// ── Config helpers ──────────────────────────────────────────────────────
|
||||
|
||||
export function getCurrentConfig(): PortalConfig {
|
||||
if (isTauri()) {
|
||||
return getConfig() // Will be overridden by Tauri config if needed
|
||||
}
|
||||
return getConfig()
|
||||
}
|
||||
|
||||
export { isTauri }
|
||||
Reference in New Issue
Block a user