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:
Warren
2026-04-30 15:11:53 +08:00
parent 4d75b2e251
commit b54c2def30
192 changed files with 46721 additions and 0 deletions

511
portal/src/api/client.ts Normal file
View 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 }