feat: bind/unbind by id, video streaming proxy, unmute default, library refresh, hamburger click-outside
- Core API: bind/unbind accept id (integer PK) when face_id is null - Tauri proxy: stream video responses instead of buffering for seek bar - VideoPlayer: remove muted attribute, unmute by default - LibraryView: call invalidateFiles() before ensureFiles() on register/unregister - PeopleView: close sort panel on click-outside, not just hamburger toggle - bind_face: prefer face_id, fallback to id when face_id is null
This commit is contained in:
6
src/api/config.ts
Normal file
6
src/api/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const isTauri = typeof window !== 'undefined' && !!(window as any).__TAURI__
|
||||
|
||||
export function getApiBase(): string {
|
||||
if (isTauri) return ''
|
||||
return localStorage.getItem('proxy_url') || window.location.origin
|
||||
}
|
||||
446
src/api/index.ts
Normal file
446
src/api/index.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, getApiBase } from './config'
|
||||
|
||||
// API proxy: Tauri IPC or HTTP fetch
|
||||
export async function apiCall(cmd: string, args: Record<string, any>): Promise<any> {
|
||||
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) })
|
||||
}
|
||||
if (isTauri) {
|
||||
return invoke(cmd, args)
|
||||
}
|
||||
const data = await httpCall(cmd, args)
|
||||
return transformResponse(cmd, data)
|
||||
}
|
||||
|
||||
async function httpCall(cmd: string, args: Record<string, any>, retries = 3): Promise<any> {
|
||||
const base = getApiBase()
|
||||
const { url, method, body } = buildHttpRequest(cmd, args)
|
||||
const fullUrl = `${base}${url}`
|
||||
|
||||
let response: Response | null = null
|
||||
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,
|
||||
})
|
||||
if (response.ok || response.status < 500) break
|
||||
} catch (e: any) {
|
||||
if (i < retries - 1) {
|
||||
await new Promise(r => setTimeout(r, 1000 * (i + 1)))
|
||||
continue
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
if (!response) throw new Error('No response')
|
||||
|
||||
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') {
|
||||
const ext = contentType.includes('png') ? 'png' : 'jpeg'
|
||||
const blob = new Blob([bytes], { type: `image/${ext}` })
|
||||
return new Promise<string>((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<string, any>): { url: string; method: string; body?: any } {
|
||||
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_faces': {
|
||||
return { url: `/api/v1/identity/${a.uuid}/faces?page_size=${a.perPage || 100}`, method: 'GET' }
|
||||
}
|
||||
case 'get_traces': {
|
||||
return { url: `/api/v1/identity/${a.uuid}/traces?page_size=${a.perPage || 50}`, method: 'GET' }
|
||||
}
|
||||
case 'get_face_candidates': {
|
||||
return { url: `/api/v1/faces/candidates?page=${a.page || 1}&page_size=${a.perPage || 100}`, method: 'GET' }
|
||||
}
|
||||
case 'get_identity': {
|
||||
return { url: `/api/v1/identity/${a.uuid}`, method: 'GET' }
|
||||
}
|
||||
|
||||
// --- Search APIs ---
|
||||
case 'search_llm_smart': {
|
||||
return { url: '/api/v1/search/llm-smart', method: 'POST', body: { query: a.query, limit: a.limit || 20 } }
|
||||
}
|
||||
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 }
|
||||
}
|
||||
case 'search_identities': {
|
||||
return { url: `/api/v1/identities/search?q=${encodeURIComponent(a.query)}&limit=${a.limit || 50}`, method: 'GET' }
|
||||
}
|
||||
|
||||
// --- 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' }
|
||||
}
|
||||
|
||||
// --- Video API ---
|
||||
case 'get_video_stream': {
|
||||
let url = `/api/v1/file/${a.uuid}/video?start_time=${a.startTime}&end_time=${a.endTime}`
|
||||
if (a.startFrame != null) url += `&start_frame=${a.startFrame}`
|
||||
if (a.endFrame != null) url += `&end_frame=${a.endFrame}`
|
||||
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 'delete_identity': {
|
||||
return { url: `/api/v1/identity/${a.uuid}`, method: 'DELETE' }
|
||||
}
|
||||
|
||||
// --- Identity Operations ---
|
||||
case 'merge_identities': {
|
||||
return { url: `/api/v1/identity/${a.uuid}/mergeinto`, method: 'POST', body: { into_uuid: a.intoUuid } }
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
// --- 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 'register_file': {
|
||||
return { url: '/api/v1/files/register', method: 'POST', body: { file_path: a.filePath } }
|
||||
}
|
||||
case 'process_file': {
|
||||
return { url: `/api/v1/file/${a.fileUuid}/process`, method: 'POST', body: { processors: a.processors } }
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
// --- File Detail ---
|
||||
case 'get_file_info': {
|
||||
return { url: `/api/v1/file/${a.uuid}`, 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' }
|
||||
}
|
||||
|
||||
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): any {
|
||||
if (isTauri) return data
|
||||
|
||||
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.is_registered ?? false,
|
||||
status: 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.metadata?.status ?? p.status ?? 'pending',
|
||||
metadata: p.metadata || {},
|
||||
}))
|
||||
}
|
||||
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 || data || []
|
||||
return 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,
|
||||
}))
|
||||
}
|
||||
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 'search_llm_smart': {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
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,
|
||||
file_uuid: data.file_uuid ?? '',
|
||||
file_name: data.file_name ?? '',
|
||||
message: data.message ?? '',
|
||||
}
|
||||
}
|
||||
case 'process_file': {
|
||||
return {
|
||||
success: data.success ?? false,
|
||||
file_uuid: data.file_uuid ?? '',
|
||||
message: data.message ?? '',
|
||||
}
|
||||
}
|
||||
case 'unregister_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_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
|
||||
default:
|
||||
return data
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user