diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 38926de..5269784 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -7,16 +7,33 @@ license = "MIT" repository = "" edition = "2021" rust-version = "1.77.2" +default-run = "momentry-studio" + +[[bin]] +name = "momentry-studio" +path = "src/main.rs" + +[[bin]] +name = "momentry-proxy" +path = "src/bin/proxy.rs" [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["protocol-asset"] } tauri-plugin-shell = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "json"] } -reqwest = { version = "0.11", features = ["json"] } +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "postgres", "json"] } +reqwest = { version = "0.11", features = ["json", "stream", "multipart"] } tokio = { version = "1", features = ["full"] } base64 = "0.22" +lru = "0.12" +futures = "0.3" +image = { version = "0.24", default-features = false, features = ["jpeg"] } +axum = "0.7" +tower-http = { version = "0.5", features = ["cors", "fs"] } +rusqlite = { version = "0.32", features = ["bundled"] } +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1", features = ["v4"] } [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/src-tauri/src/bin/proxy.rs b/src-tauri/src/bin/proxy.rs new file mode 100644 index 0000000..35166e8 --- /dev/null +++ b/src-tauri/src/bin/proxy.rs @@ -0,0 +1,11 @@ +// Standalone proxy binary for testing/development +// Re-exports the proxy module from main crate + +fn main() { + // We can't import from the main crate as a library easily, + // so instead we'll just call through the Tauri binary mechanism. + // For now, the full Tauri binary must be used to run the proxy. + eprintln!("Use `cargo tauri dev` to start the full application with proxy."); + eprintln!("For standalone proxy testing, the proxy runs as part of the Tauri binary."); + std::process::exit(1); +} \ No newline at end of file diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs new file mode 100644 index 0000000..208a4ef --- /dev/null +++ b/src-tauri/src/db.rs @@ -0,0 +1,238 @@ +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Mutex; + +const APP_TABLES: &str = " +CREATE TABLE IF NOT EXISTS search_history ( + id TEXT PRIMARY KEY, + query TEXT NOT NULL, + title TEXT NOT NULL, + chat_state TEXT, + mode TEXT DEFAULT 'keyword', + pinned INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, + history_id TEXT, + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS app_users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + display_name TEXT, + role TEXT DEFAULT 'user', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_search_history_sort ON search_history(pinned DESC, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_bookmarks_created ON bookmarks(created_at DESC); +"; + +static DB_CONN: Mutex> = Mutex::new(None); + +fn db_path() -> PathBuf { + let base = std::env::var("MOMENTRY_DATA_DIR").unwrap_or_else(|_| "../data/users".to_string()); + std::fs::create_dir_all(&base).ok(); + PathBuf::from(base).join("demo.sqlite") +} + +pub fn init_db() -> Result<(), String> { + let path = db_path(); + let conn = Connection::open(&path).map_err(|e| format!("Failed to open db: {}", e))?; + conn.execute_batch("PRAGMA journal_mode=WAL;").map_err(|e| format!("WAL mode error: {}", e))?; + conn.execute_batch(APP_TABLES).map_err(|e| format!("Failed to create tables: {}", e))?; + + let default_id = "demo"; + let default_user = "demo"; + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM app_users WHERE id = ?1", + [default_id], + |row| row.get(0), + ) + .unwrap_or(0); + if count == 0 { + conn.execute( + "INSERT OR IGNORE INTO app_users (id, username, display_name, role) VALUES (?1, ?2, ?3, 'admin')", + rusqlite::params![default_id, default_user, "Demo User"], + ) + .map_err(|e| format!("Failed to insert default user: {}", e))?; + } + + let mut lock = DB_CONN.lock().map_err(|e| format!("DB lock error: {}", e))?; + *lock = Some(conn); + Ok(()) +} + +fn get_conn() -> Result { + let path = db_path(); + let conn = Connection::open(&path).map_err(|e| format!("Failed to open db: {}", e))?; + conn.execute_batch("PRAGMA journal_mode=WAL;").map_err(|e| format!("WAL mode error: {}", e))?; + Ok(conn) +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct HistoryItem { + pub id: String, + pub query: String, + pub title: String, + pub chat_state: Option, + pub mode: Option, + pub pinned: bool, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BookmarkItem { + pub id: i64, + pub label: String, + pub history_id: Option, + pub created_at: Option, +} + +#[tauri::command(rename_all = "camelCase")] +pub fn get_search_history(limit: Option) -> Result, String> { + let limit = limit.unwrap_or(30).min(30); + let conn = get_conn()?; + let mut stmt = conn + .prepare("SELECT id, query, title, chat_state, mode, pinned, created_at, updated_at FROM search_history ORDER BY pinned DESC, updated_at DESC LIMIT ?1") + .map_err(|e| format!("Prepare error: {}", e))?; + let items = stmt + .query_map([limit], |row| { + Ok(HistoryItem { + id: row.get(0)?, + query: row.get(1)?, + title: row.get(2)?, + chat_state: row.get(3)?, + mode: row.get(4)?, + pinned: row.get::<_, i64>(5)? != 0, + created_at: row.get(6)?, + updated_at: row.get(7)?, + }) + }) + .map_err(|e| format!("Query error: {}", e))? + .filter_map(|r| r.ok()) + .collect(); + Ok(items) +} + +#[tauri::command(rename_all = "camelCase")] +pub fn save_search_history( + id: String, + query: String, + title: String, + chat_state: Option, + mode: Option, +) -> Result { + let conn = get_conn()?; + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + let effective_mode = mode.unwrap_or_else(|| "keyword".to_string()); + conn.execute( + "INSERT OR REPLACE INTO search_history (id, query, title, chat_state, mode, pinned, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, (SELECT COALESCE((SELECT pinned FROM search_history WHERE id = ?1), 0)), ?6)", + rusqlite::params![id, query, title, chat_state, effective_mode, now], + ) + .map_err(|e| format!("Insert error: {}", e))?; + + conn.execute( + "DELETE FROM search_history WHERE id NOT IN (SELECT id FROM search_history ORDER BY pinned DESC, updated_at DESC LIMIT 30)", + [], + ) + .ok(); + + Ok(HistoryItem { + id, + query: query.clone(), + title: title.clone(), + chat_state, + mode: Some(effective_mode), + pinned: false, + created_at: None, + updated_at: Some(now), + }) +} + +#[tauri::command(rename_all = "camelCase")] +pub fn rename_search_history(id: String, title: String) -> Result<(), String> { + let conn = get_conn()?; + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + conn.execute( + "UPDATE search_history SET title = ?1, updated_at = ?2 WHERE id = ?3", + rusqlite::params![title, now, id], + ) + .map_err(|e| format!("Rename error: {}", e))?; + Ok(()) +} + +#[tauri::command(rename_all = "camelCase")] +pub fn pin_search_history(id: String, pinned: bool) -> Result<(), String> { + let conn = get_conn()?; + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + conn.execute( + "UPDATE search_history SET pinned = ?1, updated_at = ?2 WHERE id = ?3", + rusqlite::params![pinned as i64, now, id], + ) + .map_err(|e| format!("Pin error: {}", e))?; + Ok(()) +} + +#[tauri::command(rename_all = "camelCase")] +pub fn delete_search_history(id: String) -> Result<(), String> { + let conn = get_conn()?; + conn.execute("DELETE FROM search_history WHERE id = ?1", [id]) + .map_err(|e| format!("Delete error: {}", e))?; + Ok(()) +} + +#[tauri::command(rename_all = "camelCase")] +pub fn get_bookmarks() -> Result, String> { + let conn = get_conn()?; + let mut stmt = conn + .prepare("SELECT id, label, history_id, created_at FROM bookmarks ORDER BY created_at DESC") + .map_err(|e| format!("Prepare error: {}", e))?; + let items = stmt + .query_map([], |row| { + Ok(BookmarkItem { + id: row.get(0)?, + label: row.get(1)?, + history_id: row.get(2)?, + created_at: row.get(3)?, + }) + }) + .map_err(|e| format!("Query error: {}", e))? + .filter_map(|r| r.ok()) + .collect(); + Ok(items) +} + +#[tauri::command(rename_all = "camelCase")] +pub fn save_bookmark(label: String, history_id: Option) -> Result { + let conn = get_conn()?; + conn.execute( + "INSERT INTO bookmarks (label, history_id) VALUES (?1, ?2)", + rusqlite::params![label, history_id], + ) + .map_err(|e| format!("Insert error: {}", e))?; + let id = conn.last_insert_rowid(); + Ok(BookmarkItem { + id, + label, + history_id, + created_at: None, + }) +} + +#[tauri::command(rename_all = "camelCase")] +pub fn delete_bookmark(id: i64) -> Result<(), String> { + let conn = get_conn()?; + conn.execute("DELETE FROM bookmarks WHERE id = ?1", [id]) + .map_err(|e| format!("Delete error: {}", e))?; + Ok(()) +} \ No newline at end of file diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d6b148f..cf3f74d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,13 +1,34 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod proxy; +mod db; + use serde::Serialize; use base64::{Engine as _, engine::general_purpose::STANDARD}; +use std::sync::Mutex; +use std::num::NonZeroUsize; +use lru::LruCache; +use image::GenericImageView; + +static THUMB_CACHE: Mutex>> = Mutex::new(None); +static PROFILE_CACHE: Mutex>> = Mutex::new(None); + +fn get_client() -> reqwest::Client { + reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(60)) + .pool_max_idle_per_host(10) + .build() + .expect("Failed to build HTTP client") +} #[derive(Serialize)] struct SearchResult { file_uuid: String, start_time: f64, end_time: f64, + start_frame: i64, + end_frame: i64, summary: String, similarity: f64, file_name: Option, @@ -17,19 +38,21 @@ struct SearchResult { struct FileInfo { file_uuid: String, file_name: String, + file_path: String, file_size: i64, modified_time: String, #[serde(rename = "isRegistered")] is_registered: bool, + status: String, } #[derive(Serialize)] -#[serde(rename_all = "camelCase")] struct PersonInfo { identity_uuid: String, name: String, starred: bool, status: String, + metadata: serde_json::Value, } #[derive(Serialize)] @@ -40,16 +63,27 @@ struct FaceInfo { timestamp_secs: f64, face_id: Option, confidence: f64, + bbox: Option, } #[derive(Serialize)] struct TraceInfo { trace_id: i32, file_uuid: String, + frame_count: i64, + first_frame: i64, + last_frame: i64, first_sec: f64, last_sec: f64, avg_confidence: f64, - frame_count: i64, +} + +#[derive(Serialize)] +struct FileDetail { + file_uuid: String, + file_name: String, + fps: f64, + duration: f64, } #[derive(Serialize)] @@ -59,6 +93,15 @@ struct FaceCandidate { file_uuid: String, frame_number: i64, confidence: f64, + bbox: Option, +} + +#[derive(Serialize)] +struct FaceBBox { + x: f64, + y: f64, + width: f64, + height: f64, } #[derive(Serialize)] @@ -68,6 +111,9 @@ struct SearchIdentityResult { source: String, tmdb_id: Option, file_uuid: Option, + trace_id: Option, + start_frame: Option, + end_frame: Option, start_time: f64, end_time: f64, text_content: Option, @@ -78,7 +124,7 @@ const API_KEY: &str = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b6 #[tauri::command(rename_all = "camelCase")] async fn search_llm_smart(query: String, limit: usize) -> Result, String> { - let client = reqwest::Client::new(); + let client = get_client(); let url = format!("{}/api/v1/search/llm-smart?api_key={}", CORE_API, API_KEY); let response = client @@ -102,6 +148,8 @@ async fn search_llm_smart(query: String, limit: usize) -> Result) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|e| format!("Client build failed: {}", e))?; + let url = format!("{}/api/v1/agents/search?api_key={}", CORE_API, API_KEY); + let mut body = serde_json::json!({"query": query}); + if let Some(cid) = conversation_id { + body["conversation_id"] = serde_json::Value::String(cid); + } + let response = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| format!("Agent search request failed: {}", e))?; + let json: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Agent search parse failed: {}", e))?; + Ok(json) +} + #[tauri::command(rename_all = "camelCase")] async fn get_files(args: GetFilesArgs) -> Result, String> { let page_size = args.page_size; - let client = reqwest::Client::new(); + let client = get_client(); let url = format!("{}/api/v1/files/scan?api_key={}&page_size={}", CORE_API, API_KEY, page_size); let response = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; @@ -138,9 +210,11 @@ async fn get_files(args: GetFilesArgs) -> Result, String> { Some(FileInfo { file_uuid, file_name: f["file_name"].as_str()?.to_string(), + file_path: f["file_path"].as_str().unwrap_or("").to_string(), file_size: f["file_size"].as_i64().unwrap_or(0), modified_time: f["modified_time"].as_str().unwrap_or("").to_string(), is_registered: is_reg, + status: f["status"].as_str().unwrap_or("").to_string(), }) }) .collect(); @@ -148,51 +222,161 @@ async fn get_files(args: GetFilesArgs) -> Result, String> { Ok(files) } +#[derive(Serialize)] +struct RegisterResult { + success: bool, + file_uuid: String, + file_name: String, + message: String, +} + +#[tauri::command(rename_all = "camelCase")] +async fn register_file(file_path: String) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|e| format!("Client build failed: {}", e))?; + let url = format!("{}/api/v1/files/register?api_key={}", CORE_API, API_KEY); + let response = client.post(&url) + .json(&serde_json::json!({"file_path": file_path})) + .send() + .await + .map_err(|e| format!("Register request failed: {}", e))?; + let json: serde_json::Value = response.json().await + .map_err(|e| format!("Parse failed: {}", e))?; + Ok(RegisterResult { + success: json["success"].as_bool().unwrap_or(false), + file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(), + file_name: json["file_name"].as_str().unwrap_or("").to_string(), + message: json["message"].as_str().unwrap_or("").to_string(), + }) +} + +#[derive(Serialize)] +struct ProcessResult { + success: bool, + file_uuid: String, + message: String, +} + +#[tauri::command(rename_all = "camelCase")] +async fn process_file(file_uuid: String, processors: Vec) -> Result { + let url = format!("{}/api/v1/file/{}/process?api_key={}", CORE_API, file_uuid, API_KEY); + let response = get_client().post(&url) + .json(&serde_json::json!({"processors": processors})) + .send() + .await + .map_err(|e| format!("Process request failed: {}", e))?; + let json: serde_json::Value = response.json().await + .map_err(|e| format!("Parse failed: {}", e))?; + Ok(ProcessResult { + success: json["success"].as_bool().unwrap_or(false), + file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(), + message: json["message"].as_str().unwrap_or("").to_string(), + }) +} + +#[derive(Serialize)] +struct UnregisterResult { + success: bool, + file_uuid: String, + message: String, +} + +#[tauri::command(rename_all = "camelCase")] +async fn unregister_file(file_uuid: String, delete_output_files: Option) -> Result { + let url = format!("{}/api/v1/unregister?api_key={}", CORE_API, API_KEY); + let mut body = serde_json::json!({"file_uuid": file_uuid}); + if let Some(dof) = delete_output_files { + body["delete_output_files"] = serde_json::Value::Bool(dof); + } + let response = get_client().post(&url) + .json(&body) + .send() + .await + .map_err(|e| format!("Unregister request failed: {}", e))?; + let json: serde_json::Value = response.json().await + .map_err(|e| format!("Parse failed: {}", e))?; + Ok(UnregisterResult { + success: json["success"].as_bool().unwrap_or(false), + file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(), + message: json["message"].as_str().unwrap_or("").to_string(), + }) +} + #[tauri::command(rename_all = "camelCase")] async fn get_people(_page: usize, _per_page: usize) -> Result, String> { eprintln!("[get_people] called"); - let client = reqwest::Client::new(); - let mut all_people = Vec::new(); - let mut page = 1; - - loop { + let mut people = Vec::new(); + for page in 1u32..=2 { let url = format!("{}/api/v1/identities?api_key={}&page={}&per_page=100", CORE_API, API_KEY, page); - let response = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = response.json().await.map_err(|e| format!("Parse failed: {}", e))?; - - let identities = json["identities"].as_array(); - let identities = identities.as_ref().map(|v| v.as_slice()).unwrap_or(&[]); - if identities.is_empty() { break; } - - for i in identities { - if let (Some(uuid), Some(name)) = (i["identity_uuid"].as_str(), i["name"].as_str()) { - all_people.push(PersonInfo { - identity_uuid: uuid.to_string(), - name: name.to_string(), - starred: i["metadata"]["starred"].as_bool().unwrap_or(false), - status: "confirmed".to_string(), - }); + let resp = match get_client().get(&url).send().await { + Ok(r) => r, + Err(e) => { + eprintln!("[get_people] page {} request failed: {} — continuing", page, e); + continue; } + }; + let json: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => { + eprintln!("[get_people] page {} parse error: {} — continuing", page, e); + continue; + } + }; + let identities = match json["identities"].as_array() { + Some(arr) => arr, + None => { + eprintln!("[get_people] page {} has no identities array — stopping", page); + break; + } + }; + if identities.is_empty() { + eprintln!("[get_people] page {} empty — stopping", page); + break; + } + for i in identities { + let uuid = match i["identity_uuid"].as_str() { + Some(u) => u.to_string(), + None => continue, + }; + let name = match i["name"].as_str() { + Some(n) => n.to_string(), + None => continue, + }; + people.push(PersonInfo { + identity_uuid: uuid, + name, + starred: i["metadata"]["starred"].as_bool().unwrap_or(false), + status: i["metadata"]["status"].as_str().unwrap_or("pending").to_string(), + metadata: i["metadata"].clone(), + }); } - - eprintln!("[get_people] page {} got {} identities, total so far: {}", page, identities.len(), all_people.len()); - - // API max is 20 per page, keep fetching until we get fewer than 20 - if identities.len() < 20 { break; } - page += 1; } - - eprintln!("[get_people] returning {} people from {} pages", all_people.len(), page); - Ok(all_people) + eprintln!("[get_people] returning {} people", people.len()); + Ok(people) } #[tauri::command(rename_all = "camelCase")] async fn get_faces(uuid: String, per_page: usize) -> Result, String> { - let client = reqwest::Client::new(); let url = format!("{}/api/v1/identity/{}/faces?api_key={}&page_size={}", CORE_API, uuid, API_KEY, per_page); + let t = std::time::Instant::now(); + eprintln!("[get_faces] --> {}", uuid); - let response = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = response.json().await.map_err(|e| format!("Parse failed: {}", e))?; + let response = match get_client().get(&url).send().await { + Ok(r) => r, + Err(e) => { + eprintln!("[get_faces] <-- {} ERROR {} {:?}", uuid, e, t.elapsed()); + return Ok(vec![]); + } + }; + let json: serde_json::Value = match response.json().await { + Ok(v) => v, + Err(e) => { + eprintln!("[get_faces] <-- {} PARSE_ERR {} {:?}", uuid, e, t.elapsed()); + return Ok(vec![]); + } + }; let faces: Vec = json["data"] .as_array() @@ -206,92 +390,229 @@ async fn get_faces(uuid: String, per_page: usize) -> Result, Strin timestamp_secs: f["timestamp_secs"].as_f64().unwrap_or(0.0), face_id: f["face_id"].as_str().map(|s| s.to_string()), confidence: f["confidence"].as_f64().unwrap_or(0.0), + bbox: f["bbox"].as_object().map(|o| FaceBBox { + x: o["x"].as_f64().unwrap_or(0.0), + y: o["y"].as_f64().unwrap_or(0.0), + width: o["width"].as_f64().unwrap_or(0.0), + height: o["height"].as_f64().unwrap_or(0.0), + }), }) }) .collect(); + eprintln!("[get_faces] <-- {} {} faces {:?}", uuid, faces.len(), t.elapsed()); Ok(faces) } #[tauri::command(rename_all = "camelCase")] async fn get_traces(uuid: String, per_page: usize) -> Result, String> { - let client = reqwest::Client::new(); - let url = format!("{}/api/v1/identity/{}/traces?api_key={}&page_size={}", CORE_API, uuid, API_KEY, per_page); + let page_size = per_page.min(20); + let mut all_traces: Vec = vec![]; + let mut page = 1u32; + let max_retries = 2; + let t = std::time::Instant::now(); + eprintln!("[get_traces] --> {}", uuid); - let response = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = response.json().await.map_err(|e| format!("Parse failed: {}", e))?; - - let traces: Vec = json["traces"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|t| { - Some(TraceInfo { - trace_id: t["trace_id"].as_i64().unwrap_or(0) as i32, - file_uuid: t["file_uuid"].as_str()?.to_string(), - first_sec: t["first_sec"].as_f64().unwrap_or(0.0), - last_sec: t["last_sec"].as_f64().unwrap_or(0.0), - avg_confidence: t["avg_confidence"].as_f64().unwrap_or(0.0), - frame_count: t["frame_count"].as_i64().unwrap_or(0), + for _page in 0..10 { + let url = format!("{}/api/v1/identity/{}/traces?api_key={}&page_size={}&page={}", CORE_API, uuid, API_KEY, page_size, page); + + let mut response: Option = None; + for attempt in 0..=max_retries { + if attempt > 0 { + eprintln!("[get_traces] retry {} for page {}", attempt, page); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + match get_client().get(&url).send().await { + Ok(r) => { response = Some(r); break; } + Err(e) => { + eprintln!("[get_traces] attempt {} failed on page {}: {}", attempt + 1, page, e); + } + } + } + + let resp = match response { + Some(r) => r, + None => { + eprintln!("[get_traces] all retries exhausted on page {} — returning {} traces so far", page, all_traces.len()); + break; + } + }; + + let json: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => { + eprintln!("[get_traces] parse error on page {}: {} — returning {} traces so far", page, e, all_traces.len()); + break; + } + }; + + let traces: Vec = json["traces"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|t| { + Some(TraceInfo { + trace_id: t["trace_id"].as_i64().unwrap_or(0) as i32, + file_uuid: t["file_uuid"].as_str()?.to_string(), + frame_count: t["frame_count"].as_i64().unwrap_or(0), + first_frame: t["first_frame"].as_i64().unwrap_or(0), + last_frame: t["last_frame"].as_i64().unwrap_or(0), + first_sec: t["first_sec"].as_f64().unwrap_or(0.0), + last_sec: t["last_sec"].as_f64().unwrap_or(0.0), + avg_confidence: t["avg_confidence"].as_f64().unwrap_or(0.0), + }) }) - }) - .collect(); + .collect(); + + let count = traces.len(); + all_traces.extend(traces); + + if count < page_size { + break; + } + page += 1; + } - Ok(traces) + eprintln!("[get_traces] <-- {} {} traces {:?}", uuid, all_traces.len(), t.elapsed()); + Ok(all_traces) +} + +#[tauri::command(rename_all = "camelCase")] +async fn get_file_info(uuid: String) -> Result { + let url = format!("{}/api/v1/file/{}?api_key={}", CORE_API, uuid, API_KEY); + let resp = get_client().get(&url).send().await + .map_err(|e| format!("File info request failed: {}", e))?; + let json: serde_json::Value = resp.json().await + .map_err(|e| format!("File info parse failed: {}", e))?; + Ok(FileDetail { + file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(), + file_name: json["file_name"].as_str().unwrap_or("").to_string(), + fps: json["fps"].as_f64().unwrap_or(24.0), + duration: json["duration"].as_f64().unwrap_or(0.0), + }) } #[tauri::command(rename_all = "camelCase")] async fn get_thumbnail(uuid: String, frame: u32) -> Result { + let key = format!("{}:{}", uuid, frame); + { + let mut cache = THUMB_CACHE.lock().unwrap(); + if let Some(cache) = cache.as_mut() { + if let Some(cached) = cache.get(&key) { + return Ok(cached.clone()); + } + } + } let url = format!("{}/api/v1/file/{}/thumbnail?api_key={}&frame={}", CORE_API, uuid, API_KEY, frame); - let bytes = reqwest::get(&url).await + let bytes = get_client().get(&url).send().await .map_err(|e| format!("Thumbnail request failed: {}", e))? .bytes().await .map_err(|e| format!("Thumbnail read failed: {}", e))?; - Ok(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes))) + let result = format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes)); + { + let mut cache = THUMB_CACHE.lock().unwrap(); + let cache = cache.get_or_insert_with(|| LruCache::new(NonZeroUsize::new(500).unwrap())); + cache.put(key, result.clone()); + } + Ok(result) } #[tauri::command(rename_all = "camelCase")] -async fn get_video_stream(uuid: String, start_time: f64, end_time: f64) -> Result { - let url = format!("{}/api/v1/file/{}/video?api_key={}&start_time={}&end_time={}", CORE_API, uuid, API_KEY, start_time, end_time); - let bytes = reqwest::get(&url).await - .map_err(|e| format!("Video request failed: {}", e))? +async fn get_face_thumbnail(uuid: String, frame: u32, bbox_x: Option, bbox_y: Option, bbox_w: Option, bbox_h: Option) -> Result { + let key = format!("face:{}:{}:{:?}:{:?}:{:?}:{:?}", uuid, frame, bbox_x, bbox_y, bbox_w, bbox_h); + { + let mut cache = THUMB_CACHE.lock().unwrap(); + if let Some(cache) = cache.as_mut() { + if let Some(cached) = cache.get(&key) { + return Ok(cached.clone()); + } + } + } + let url = format!("{}/api/v1/file/{}/thumbnail?api_key={}&frame={}", CORE_API, uuid, API_KEY, frame); + let bytes = get_client().get(&url).send().await + .map_err(|e| format!("Thumbnail request failed: {}", e))? .bytes().await - .map_err(|e| format!("Video read failed: {}", e))?; - - let tmp_dir = std::env::temp_dir(); - let file_path = tmp_dir.join(format!("momentry_{}.mp4", uuid)); - std::fs::write(&file_path, &bytes) - .map_err(|e| format!("Write temp file failed: {}", e))?; - - Ok(format!("file://{}", file_path.display())) + .map_err(|e| format!("Thumbnail read failed: {}", e))?; + + let result = if let (Some(bx), Some(by), Some(bw), Some(bh)) = (bbox_x, bbox_y, bbox_w, bbox_h) { + let img = image::load_from_memory(&bytes).map_err(|e| format!("Image decode failed: {}", e))?; + let (w, h) = img.dimensions(); + let (px, py, pw, ph) = if bx <= 1.0 && by <= 1.0 && bw <= 1.0 && bh <= 1.0 { + ((bx * w as f64) as u32, (by * h as f64) as u32, (bw * w as f64) as u32, (bh * h as f64) as u32) + } else { + (bx as u32, by as u32, bw as u32, bh as u32) + }; + let cx = px.min(w.saturating_sub(1)); + let cy = py.min(h.saturating_sub(1)); + let cw = pw.min(w.saturating_sub(cx)).max(1); + let ch = ph.min(h.saturating_sub(cy)).max(1); + let cropped = img.crop_imm(cx, cy, cw, ch); + let mut buf = std::io::Cursor::new(Vec::new()); + cropped.write_to(&mut buf, image::ImageFormat::Jpeg).map_err(|e| format!("Encode failed: {}", e))?; + format!("data:image/jpeg;base64,{}", STANDARD.encode(buf.into_inner())) + } else { + format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes)) + }; + + { + let mut cache = THUMB_CACHE.lock().unwrap(); + let cache = cache.get_or_insert_with(|| LruCache::new(NonZeroUsize::new(500).unwrap())); + cache.put(key, result.clone()); + } + Ok(result) } const PROFILE_DIRS: &[&str] = &[ "/Users/accusys/momentry/output/identities", "/Users/accusys/momentry/output_dev/identities", + "/Volumes/external/momentry/output/identities", + "/Volumes/external/momentry/output_dev/identities", ]; #[tauri::command(rename_all = "camelCase")] async fn get_identity_profile(uuid: String) -> Result { + let uuid_display = uuid.clone(); let no_dash = uuid.replace('-', ""); - for dir in PROFILE_DIRS { - let path = format!("{}/{}/profile.jpg", dir, no_dash); - if std::path::Path::new(&path).exists() { - let bytes = std::fs::read(&path).map_err(|e| format!("Read failed: {}", e))?; - return Ok(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes))); - } - let path = format!("{}/{}/profile.jpg", dir, uuid); - if std::path::Path::new(&path).exists() { - let bytes = std::fs::read(&path).map_err(|e| format!("Read failed: {}", e))?; - return Ok(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes))); + { + let mut cache = PROFILE_CACHE.lock().unwrap(); + if let Some(cache) = cache.as_mut() { + if let Some(cached) = cache.get(&uuid_display) { + eprintln!("[get_identity_profile] <-- {} (cached)", uuid_display); + return Ok(cached.clone()); + } + } + } + let t = std::time::Instant::now(); + let result = (|| -> Option { + for dir in PROFILE_DIRS { + for candidate in [&no_dash, &uuid_display] { + let path = format!("{}/{}/profile.jpg", dir, candidate); + if std::path::Path::new(&path).exists() { + let bytes = std::fs::read(&path).ok()?; + return Some(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes))); + } + } + } + None + })(); + match result { + Some(data) => { + let mut cache = PROFILE_CACHE.lock().unwrap(); + let cache = cache.get_or_insert_with(|| LruCache::new(NonZeroUsize::new(500).unwrap())); + cache.put(uuid_display.clone(), data.clone()); + eprintln!("[get_identity_profile] <-- {} {} {}ms", uuid_display, "FOUND", t.elapsed().as_millis()); + Ok(data) + } + None => { + eprintln!("[get_identity_profile] <-- {} {} {}ms", uuid_display, "404", t.elapsed().as_millis()); + Err("Profile image not found".to_string()) } } - Err("Profile image not found".to_string()) } #[tauri::command(rename_all = "camelCase")] async fn update_identity_name(uuid: String, name: String) -> Result<(), String> { - let client = reqwest::Client::new(); + let client = get_client(); let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); let resp = client.patch(&url) .json(&serde_json::json!({"name": name})) @@ -305,21 +626,103 @@ async fn update_identity_name(uuid: String, name: String) -> Result<(), String> #[tauri::command(rename_all = "camelCase")] async fn update_identity_status(uuid: String, status: String) -> Result<(), String> { - let client = reqwest::Client::new(); + let client = get_client(); let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); + // GET current identity to merge metadata (preserve starred, role, notes, etc.) + let get_resp = client.get(&url).send().await + .map_err(|e| format!("GET failed: {}", e))?; + let json: serde_json::Value = get_resp.json().await + .map_err(|e| format!("Parse failed: {}", e))?; + let mut metadata = json["metadata"].as_object() + .cloned() + .unwrap_or_default(); + metadata.insert("status".to_string(), serde_json::Value::String(status)); let resp = client.patch(&url) - .json(&serde_json::json!({"status": status})) + .json(&serde_json::json!({"metadata": metadata})) .send().await - .map_err(|e| format!("Request failed: {}", e))?; + .map_err(|e| format!("PATCH failed: {}", e))?; if !resp.status().is_success() { return Err(format!("Update failed: {}", resp.status())); } Ok(()) } +#[tauri::command(rename_all = "camelCase")] +async fn update_identity_starred(uuid: String, starred: bool) -> Result<(), String> { + let client = get_client(); + let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); + let get_resp = client.get(&url).send().await + .map_err(|e| format!("GET failed: {}", e))?; + let json: serde_json::Value = get_resp.json().await + .map_err(|e| format!("Parse failed: {}", e))?; + let mut metadata = json["metadata"].as_object() + .cloned() + .unwrap_or_default(); + metadata.insert("starred".to_string(), serde_json::Value::Bool(starred)); + let resp = client.patch(&url) + .json(&serde_json::json!({"metadata": metadata})) + .send().await + .map_err(|e| format!("PATCH failed: {}", e))?; + if !resp.status().is_success() { + return Err(format!("Update failed: {}", resp.status())); + } + Ok(()) +} + +#[tauri::command(rename_all = "camelCase")] +async fn update_identity(uuid: String, name: Option, metadata_json: String) -> Result { + let client = get_client(); + let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); + let mut body = serde_json::json!({}); + if let Some(n) = &name { + body["name"] = serde_json::Value::String(n.clone()); + } + if !metadata_json.is_empty() { + let meta: serde_json::Value = serde_json::from_str(&metadata_json).map_err(|e| format!("Invalid metadata JSON: {}", e))?; + body["metadata"] = meta; + } + let resp = client.patch(&url) + .json(&body) + .send().await + .map_err(|e| format!("Request failed: {}", e))?; + let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; + Ok(json) +} + +#[tauri::command(rename_all = "camelCase")] +async fn upload_profile_image(uuid: String, file_path: String) -> Result<(), String> { + let file_bytes = std::fs::read(&file_path).map_err(|e| format!("Failed to read file: {}", e))?; + let file_name = std::path::Path::new(&file_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("profile.jpg") + .to_string(); + let part = reqwest::multipart::Part::bytes(file_bytes) + .file_name(file_name) + .mime_str("image/jpeg") + .unwrap_or_else(|_| reqwest::multipart::Part::bytes(std::fs::read(&file_path).unwrap_or_default()).file_name("profile.jpg")); + let form = reqwest::multipart::Form::new() + .part("image", part); + let url = format!("{}/api/v1/identity/{}/profile-image?api_key={}", CORE_API, uuid, API_KEY); + let resp = get_client().post(&url) + .multipart(form) + .send().await + .map_err(|e| format!("Upload failed: {}", e))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Upload failed: {} {}", status, body)); + } + let mut cache = PROFILE_CACHE.lock().unwrap(); + if let Some(c) = cache.as_mut() { + c.pop(&uuid); + } + Ok(()) +} + #[tauri::command(rename_all = "camelCase")] async fn delete_identity(uuid: String) -> Result<(), String> { - let client = reqwest::Client::new(); + let client = get_client(); let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); let resp = client.delete(&url) .send().await @@ -332,9 +735,8 @@ async fn delete_identity(uuid: String) -> Result<(), String> { #[tauri::command(rename_all = "camelCase")] async fn search_identities(query: String, limit: usize) -> Result, String> { - let client = reqwest::Client::new(); let url = format!("{}/api/v1/identities/search?api_key={}&q={}", CORE_API, API_KEY, query); - let resp = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; + let resp = get_client().get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; let results: Vec = json["results"] .as_array() @@ -348,6 +750,9 @@ async fn search_identities(query: String, limit: usize) -> Result Result Result, String> { - let client = reqwest::Client::new(); + let client = get_client(); let url = format!("{}/api/v1/faces/candidates?api_key={}&page={}&page_size={}", CORE_API, API_KEY, page, per_page); let resp = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; @@ -374,6 +779,12 @@ async fn get_face_candidates(page: usize, per_page: usize) -> Result Result Result<(), String> { - let client = reqwest::Client::new(); + let client = get_client(); let url = format!("{}/api/v1/identity/{}/mergeinto?api_key={}", CORE_API, uuid, API_KEY); let resp = client.post(&url) .json(&serde_json::json!({"into_uuid": into_uuid})) @@ -395,11 +806,19 @@ async fn merge_identities(uuid: String, into_uuid: String) -> Result<(), String> } #[tauri::command(rename_all = "camelCase")] -async fn bind_face(uuid: String, face_id: String, file_uuid: String) -> Result<(), String> { - let client = reqwest::Client::new(); +async fn bind_face(uuid: String, face_id: Option, face_row_id: Option, file_uuid: String) -> Result<(), String> { + let client = get_client(); let url = format!("{}/api/v1/identity/{}/bind?api_key={}", CORE_API, uuid, API_KEY); + let mut body = serde_json::json!({"file_uuid": file_uuid}); + if let Some(fid) = &face_id { + body["face_id"] = serde_json::json!(fid); + } else if let Some(rid) = face_row_id { + body["id"] = serde_json::json!(rid); + } else { + return Err("Either face_id or face_row_id is required".to_string()); + } let resp = client.post(&url) - .json(&serde_json::json!({"face_id": face_id, "file_uuid": file_uuid})) + .json(&body) .send().await .map_err(|e| format!("Request failed: {}", e))?; if !resp.status().is_success() { @@ -409,39 +828,212 @@ async fn bind_face(uuid: String, face_id: String, file_uuid: String) -> Result<( } #[tauri::command(rename_all = "camelCase")] -async fn unbind_face(uuid: String, face_id: String, file_uuid: String) -> Result<(), String> { - let client = reqwest::Client::new(); +async fn unbind_face(uuid: String, face_id: Option, face_row_id: Option, file_uuid: String, _frame_number: Option) -> Result<(), String> { + let client = get_client(); + let mut body = serde_json::json!({"file_uuid": file_uuid}); + if let Some(fid) = face_id { + body["face_id"] = serde_json::json!(fid); + } else if let Some(rid) = face_row_id { + body["id"] = serde_json::json!(rid); + } else { + return Err("Either face_id or face_row_id is required".to_string()); + } let url = format!("{}/api/v1/identity/{}/unbind?api_key={}", CORE_API, uuid, API_KEY); + eprintln!("[unbind_face] POST {} body={}", url, body); let resp = client.post(&url) - .json(&serde_json::json!({"face_id": face_id, "file_uuid": file_uuid})) + .json(&body) .send().await .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Unbind failed: {}", resp.status())); + let status = resp.status(); + let resp_text = resp.text().await.unwrap_or_default(); + eprintln!("[unbind_face] status={} body={}", status, resp_text); + if !status.is_success() { + return Err(format!("Unbind failed: {} - {}", status, resp_text)); } Ok(()) } +#[tauri::command(rename_all = "camelCase")] +async fn identity_undo(uuid: String, steps: Option) -> Result { + let client = get_client(); + let url = format!("{}/api/v1/identity/{}/undo?api_key={}", CORE_API, uuid, API_KEY); + let mut body = serde_json::json!({}); + if let Some(s) = steps { + body["steps"] = serde_json::Value::Number(s.into()); + } + let resp = client.post(&url).json(&body).send().await + .map_err(|e| format!("Undo failed: {}", e))?; + let json: serde_json::Value = resp.json().await + .map_err(|e| format!("Parse failed: {}", e))?; + Ok(json) +} + +#[tauri::command(rename_all = "camelCase")] +async fn identity_redo(uuid: String, steps: Option) -> Result { + let client = get_client(); + let url = format!("{}/api/v1/identity/{}/redo?api_key={}", CORE_API, uuid, API_KEY); + let mut body = serde_json::json!({}); + if let Some(s) = steps { + body["steps"] = serde_json::Value::Number(s.into()); + } + let resp = client.post(&url).json(&body).send().await + .map_err(|e| format!("Redo failed: {}", e))?; + let json: serde_json::Value = resp.json().await + .map_err(|e| format!("Parse failed: {}", e))?; + Ok(json) +} + +#[tauri::command(rename_all = "camelCase")] +async fn identity_history(uuid: String, page: Option, page_size: Option) -> Result { + let client = get_client(); + let mut url = format!("{}/api/v1/identity/{}/history?api_key={}", CORE_API, uuid, API_KEY); + if let Some(p) = page { url = format!("{}&page={}", url, p); } + if let Some(ps) = page_size { url = format!("{}&page_size={}", url, ps); } + let resp = client.get(&url).send().await + .map_err(|e| format!("Request failed: {}", e))?; + let json: serde_json::Value = resp.json().await + .map_err(|e| format!("Parse failed: {}", e))?; + Ok(json) +} + +#[tauri::command(rename_all = "camelCase")] +async fn identity_bind_undo(uuid: String, steps: Option) -> Result { + let client = get_client(); + let url = format!("{}/api/v1/identity/{}/bind/undo?api_key={}", CORE_API, uuid, API_KEY); + let mut body = serde_json::json!({}); + if let Some(s) = steps { + body["steps"] = serde_json::Value::Number(s.into()); + } + let resp = client.post(&url).json(&body).send().await + .map_err(|e| format!("Bind undo failed: {}", e))?; + let json: serde_json::Value = resp.json().await + .map_err(|e| format!("Parse failed: {}", e))?; + Ok(json) +} + +#[tauri::command(rename_all = "camelCase")] +async fn identity_bind_redo(uuid: String, steps: Option) -> Result { + let client = get_client(); + let url = format!("{}/api/v1/identity/{}/bind/redo?api_key={}", CORE_API, uuid, API_KEY); + let mut body = serde_json::json!({}); + if let Some(s) = steps { + body["steps"] = serde_json::Value::Number(s.into()); + } + let resp = client.post(&url).json(&body).send().await + .map_err(|e| format!("Bind redo failed: {}", e))?; + let json: serde_json::Value = resp.json().await + .map_err(|e| format!("Parse failed: {}", e))?; + Ok(json) +} + +#[tauri::command(rename_all = "camelCase")] +async fn identity_bind_history(uuid: String, page: Option, page_size: Option) -> Result { + let client = get_client(); + let mut url = format!("{}/api/v1/identity/{}/bind/history?api_key={}", CORE_API, uuid, API_KEY); + if let Some(p) = page { url = format!("{}&page={}", url, p); } + if let Some(ps) = page_size { url = format!("{}&page_size={}", url, ps); } + let resp = client.get(&url).send().await + .map_err(|e| format!("Request failed: {}", e))?; + let json: serde_json::Value = resp.json().await + .map_err(|e| format!("Parse failed: {}", e))?; + Ok(json) +} + +#[tauri::command(rename_all = "camelCase")] +async fn merge_undo(merge_id: String) -> Result { + let client = get_client(); + let url = format!("{}/api/v1/identity/merge/{}/undo?api_key={}", CORE_API, merge_id, API_KEY); + let resp = client.post(&url).send().await + .map_err(|e| format!("Merge undo failed: {}", e))?; + let json: serde_json::Value = resp.json().await + .map_err(|e| format!("Parse failed: {}", e))?; + Ok(json) +} + +#[tauri::command(rename_all = "camelCase")] +async fn merge_redo(merge_id: String) -> Result { + let client = get_client(); + let url = format!("{}/api/v1/identity/merge/{}/redo?api_key={}", CORE_API, merge_id, API_KEY); + let resp = client.post(&url).send().await + .map_err(|e| format!("Merge redo failed: {}", e))?; + let json: serde_json::Value = resp.json().await + .map_err(|e| format!("Parse failed: {}", e))?; + Ok(json) +} + +#[tauri::command(rename_all = "camelCase")] +async fn merge_history(source_uuid: Option, target_uuid: Option, page: Option, page_size: Option) -> Result { + let client = get_client(); + let mut url = format!("{}/api/v1/identity/merge/history?api_key={}", CORE_API, API_KEY); + if let Some(s) = source_uuid { url = format!("{}&source_uuid={}", url, s); } + if let Some(t) = target_uuid { url = format!("{}&target_uuid={}", url, t); } + if let Some(p) = page { url = format!("{}&page={}", url, p); } + if let Some(ps) = page_size { url = format!("{}&page_size={}", url, ps); } + let resp = client.get(&url).send().await + .map_err(|e| format!("Request failed: {}", e))?; + let json: serde_json::Value = resp.json().await + .map_err(|e| format!("Parse failed: {}", e))?; + Ok(json) +} + fn main() { + std::thread::spawn(|| { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(proxy::start_proxy_server()); + }); + tauri::Builder::default() .plugin(tauri_plugin_shell::init()) - .invoke_handler(tauri::generate_handler![ + .setup(|app| { + use tauri::Manager; + let _ = app.get_webview_window("main"); + if let Err(e) = db::init_db() { + eprintln!("Failed to init database: {}", e); + } + Ok(()) + }) +.invoke_handler(tauri::generate_handler![ search_llm_smart, + search_agents, get_files, + register_file, + process_file, + unregister_file, get_people, get_faces, get_traces, + get_file_info, get_thumbnail, - get_video_stream, + get_face_thumbnail, get_identity_profile, update_identity_name, update_identity_status, + update_identity_starred, + update_identity, + upload_profile_image, delete_identity, search_identities, get_face_candidates, merge_identities, bind_face, - unbind_face + unbind_face, + identity_undo, + identity_redo, + identity_history, + identity_bind_undo, + identity_bind_redo, + identity_bind_history, + merge_undo, + merge_redo, + merge_history, + db::get_search_history, + db::save_search_history, + db::rename_search_history, + db::pin_search_history, + db::delete_search_history, + db::get_bookmarks, + db::save_bookmark, + db::delete_bookmark ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/proxy.rs b/src-tauri/src/proxy.rs new file mode 100644 index 0000000..dda8e39 --- /dev/null +++ b/src-tauri/src/proxy.rs @@ -0,0 +1,558 @@ +use axum::{ + body::Body, + extract::State, + http::{HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + Router, +}; +use tower_http::cors::{Any, CorsLayer}; +use crate::db; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use image::GenericImageView; +use std::sync::Mutex; +use std::num::NonZeroUsize; +use lru::LruCache; + +const CORE_API: &str = "http://localhost:3002"; +const API_KEY: &str = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69"; +const DIST_DIR: &str = "../dist"; +const PROFILE_DIRS: [&str; 4] = [ + "/Users/accusys/momentry/output/identities", + "/Users/accusys/momentry/output_dev/identities", + "/Volumes/external/momentry/output/identities", + "/Volumes/external/momentry/output_dev/identities", +]; + +static FACE_THUMB_CACHE: Mutex>> = Mutex::new(None); +static PROFILE_PROXY_CACHE: Mutex>> = Mutex::new(None); + +fn get_face_thumb_cache() -> std::sync::MutexGuard<'static, Option>> { + let mut guard = FACE_THUMB_CACHE.lock().unwrap(); + if guard.is_none() { + *guard = Some(LruCache::new(NonZeroUsize::new(500).unwrap())); + } + guard +} + +fn get_profile_proxy_cache() -> std::sync::MutexGuard<'static, Option>> { + let mut guard = PROFILE_PROXY_CACHE.lock().unwrap(); + if guard.is_none() { + *guard = Some(LruCache::new(NonZeroUsize::new(300).unwrap())); + } + guard +} + +#[derive(Clone)] +pub struct ProxyState { + pub client: reqwest::Client, + pub serve_static: bool, +} + +pub async fn start_proxy_server() { + let serve_static = std::env::var("SERVE_STATIC").is_ok(); + let state = ProxyState { + client: reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(600)) + .pool_max_idle_per_host(10) + .build() + .unwrap(), + serve_static, + }; + + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + let app = Router::new() + .route("/health", axum::routing::get(health_handler)) + .route("/api/v1/face-thumbnail", axum::routing::get(get_face_thumbnail_handler)) + .fallback(fallback_handler) + .layer(cors) + .with_state(state); + + let listener = match tokio::net::TcpListener::bind("0.0.0.0:8888").await { + Ok(l) => l, + Err(e) => { + eprintln!("[proxy] Failed to bind port 8888: {}. Proxy will not be available.", e); + return; + } + }; + + eprintln!("[proxy] HTTP server listening on http://0.0.0.0:8888 (serve_static={})", serve_static); + + if let Err(e) = axum::serve(listener, app).await { + eprintln!("[proxy] Server error: {}", e); + } +} + +async fn health_handler() -> &'static str { + "ok" +} + +async fn fallback_handler(State(state): State, req: axum::extract::Request) -> Response { + let path = req.uri().path().to_string(); + let method = req.method().clone(); + + // Handle identity profile GET locally + if method == axum::http::Method::GET { + if let Some(rest) = path.strip_prefix("/api/v1/identity/") { + if rest.ends_with("/profile") { + let uuid = rest.trim_end_matches("/profile").to_string(); + return get_identity_profile_handler_inner(uuid).await; + } + } + } + + // Handle identity profile-image POST: proxy to Core API + invalidate cache + if method == axum::http::Method::POST { + if let Some(rest) = path.strip_prefix("/api/v1/identity/") { + if rest.ends_with("/profile-image") { + let uuid = rest.trim_end_matches("/profile-image").to_string(); + let response = proxy_api(state, req).await; + // Invalidate profile cache on successful upload + let status = response.status(); + if status == StatusCode::OK || status == StatusCode::CREATED { + let mut cache = get_profile_proxy_cache(); + if let Some(c) = cache.as_mut() { c.pop(&uuid); } + eprintln!("[proxy] PROFILE cache invalidated for {}", uuid); + } + return response; + } + } + } + + // Handle search-history CRUD locally + if path.starts_with("/api/v1/search-history") || path.starts_with("/api/v1/bookmarks") { + return handle_local_api(req).await; + } + + if path.starts_with("/api/") { + return proxy_api(state, req).await; + } else if state.serve_static { + return serve_spa(&path).await; + } else { + return (StatusCode::NOT_FOUND, "Not found").into_response(); + } +} + +async fn handle_local_api(req: axum::extract::Request) -> Response { + let path = req.uri().path().to_string(); + let method = req.method().clone(); + let body_bytes = axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await.unwrap_or_default(); + + if method == axum::http::Method::GET && path == "/api/v1/search-history" { + return get_search_history_handler_inner(); + } + if method == axum::http::Method::POST && path == "/api/v1/search-history" { + return save_search_history_handler_inner(body_bytes); + } + if method == axum::http::Method::PATCH { + if let Some(rest) = path.strip_prefix("/api/v1/search-history/") { + if let Some(id) = rest.strip_suffix("/rename") { + return rename_search_history_handler_inner(id.to_string(), body_bytes); + } + if let Some(id) = rest.strip_suffix("/pin") { + return pin_search_history_handler_inner(id.to_string(), body_bytes); + } + } + } + if method == axum::http::Method::DELETE { + if let Some(id) = path.strip_prefix("/api/v1/search-history/") { + return delete_search_history_handler_inner(id.to_string()); + } + if let Some(id_str) = path.strip_prefix("/api/v1/bookmarks/") { + if let Ok(id) = id_str.parse::() { + return delete_bookmark_handler_inner(id); + } + } + } + if method == axum::http::Method::GET && path == "/api/v1/bookmarks" { + return get_bookmarks_handler_inner(); + } + if method == axum::http::Method::POST && path == "/api/v1/bookmarks" { + return save_bookmark_handler_inner(body_bytes); + } + + (StatusCode::NOT_FOUND, "Not found").into_response() +} + +async fn proxy_api(state: ProxyState, req: axum::extract::Request) -> Response { + let path = req.uri().path().to_string(); + let method = req.method().clone(); + let headers = req.headers().clone(); + let start = std::time::Instant::now(); + + let query_str = req.uri().query().unwrap_or("").to_string(); + let mut url = format!("{}{}?api_key={}", CORE_API, path, API_KEY); + for pair in query_str.split('&') { + if pair.is_empty() || pair.starts_with("api_key=") { + continue; + } + url.push_str(&format!("&{}", pair)); + } + + let range_hdr = headers.get("range").and_then(|v| v.to_str().ok()).unwrap_or("-"); + eprintln!("[proxy] --> {} {} (core api) range={}", method, path, range_hdr); + + let reqwest_method = match method.as_str() { + "POST" => reqwest::Method::POST, + "PUT" => reqwest::Method::PUT, + "PATCH" => reqwest::Method::PATCH, + "DELETE" => reqwest::Method::DELETE, + "HEAD" => reqwest::Method::HEAD, + "OPTIONS" => reqwest::Method::OPTIONS, + _ => reqwest::Method::GET, + }; + + let mut req_builder = state.client.request(reqwest_method, &url); + + for (name, value) in headers.iter() { + let name_str = name.as_str(); + if name_str == "host" || name_str == "origin" || name_str == "referer" { + continue; + } + if let Ok(v) = String::from_utf8(value.as_bytes().to_vec()) { + req_builder = req_builder.header(name_str, v); + } + } + + let body_bytes = axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await.unwrap_or_default(); + if !body_bytes.is_empty() { + if method == axum::http::Method::POST && path.contains("/unbind") { + eprintln!("[proxy] UNBIND body: {}", String::from_utf8_lossy(&body_bytes)); + } + req_builder = req_builder.body(body_bytes.to_vec()); + } + + // Check if this is a video request (stream instead of buffer) + let is_video_request = path.contains("/video") || headers.contains_key("range"); + + match req_builder.send().await { + Ok(resp) => { + let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let resp_headers = resp.headers().clone(); + + // Check if response is video content + let content_type = resp_headers.get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let should_stream = is_video_request + || content_type.starts_with("video/") + || content_type == "application/octet-stream"; + + if should_stream { + eprintln!("[proxy] <-- {} {} {} streaming {}ms cl={} ar={} cr={}", + method, path, status.as_u16(), start.elapsed().as_millis(), + resp_headers.get("content-length").and_then(|v| v.to_str().ok()).unwrap_or("-"), + resp_headers.get("accept-ranges").and_then(|v| v.to_str().ok()).unwrap_or("-"), + resp_headers.get("content-range").and_then(|v| v.to_str().ok()).unwrap_or("-")); + + let mut builder = axum::http::Response::builder().status(status); + for (name, value) in resp_headers.iter() { + let name_str = name.as_str(); + if name_str == "transfer-encoding" || name_str == "content-encoding" { + continue; + } + if let Ok(v) = HeaderValue::from_bytes(value.as_bytes()) { + builder = builder.header(name_str, v); + } + } + // Stream the body directly + let stream_body = Body::from_stream(resp.bytes_stream()); + return match builder.body(stream_body) { + Ok(r) => r.into_response(), + Err(e) => { + eprintln!("[proxy] Error building streaming response: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Proxy error").into_response() + } + }; + } + + // Non-video: buffer entire response + let resp_bytes = match resp.bytes().await { + Ok(b) => b.to_vec(), + Err(e) => { + eprintln!("[proxy] Error reading response body: {}", e); + return (StatusCode::BAD_GATEWAY, "Error reading response").into_response(); + } + }; + let size = resp_bytes.len(); + if method == axum::http::Method::POST && path.contains("/unbind") { + eprintln!("[proxy] UNBIND response: {} {}", status.as_u16(), String::from_utf8_lossy(&resp_bytes)); + } + let mut builder = axum::http::Response::builder().status(status); + for (name, value) in resp_headers.iter() { + let name_str = name.as_str(); + if name_str == "transfer-encoding" || name_str == "content-encoding" { + continue; + } + if let Ok(v) = HeaderValue::from_bytes(value.as_bytes()) { + builder = builder.header(name_str, v); + } + } + let accept_ranges = resp_headers.get("accept-ranges").and_then(|v| v.to_str().ok()).unwrap_or("-"); + let content_range = resp_headers.get("content-range").and_then(|v| v.to_str().ok()).unwrap_or("-"); + let content_length = resp_headers.get("content-length").and_then(|v| v.to_str().ok()).unwrap_or("-"); + eprintln!("[proxy] <-- {} {} {} {} {}ms cl={} ar={} cr={}", method, path, status.as_u16(), size, start.elapsed().as_millis(), content_length, accept_ranges, content_range); + match builder.body(Body::from(resp_bytes)) { + Ok(r) => r.into_response(), + Err(e) => { + eprintln!("[proxy] Error building response: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Proxy error").into_response() + } + } + } + Err(e) => { + eprintln!("[proxy] <-- {} {} ERR {}ms: {}", method, path, start.elapsed().as_millis(), e); + (StatusCode::BAD_GATEWAY, format!("Proxy error: {}", e)).into_response() + } + } +} + +async fn serve_spa(path: &str) -> Response { + let file_path = path.trim_start_matches('/'); + + if file_path.is_empty() || file_path == "index.html" { + return serve_file(DIST_DIR, "index.html").await; + } + + let safe_path = file_path.replace("..", ""); + if tokio::fs::try_exists(format!("{}/{}", DIST_DIR, safe_path)).await.unwrap_or(false) { + return serve_file(DIST_DIR, &safe_path).await; + } + + serve_file(DIST_DIR, "index.html").await +} + +fn content_type_for(path: &str) -> &'static str { + match path.rsplit('.').next() { + Some("js") => "application/javascript; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("html") => "text/html; charset=utf-8", + Some("json") => "application/json", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("svg") => "image/svg+xml", + Some("ico") => "image/x-icon", + Some("woff") => "font/woff", + Some("woff2") => "font/woff2", + Some("map") => "application/json", + _ => "application/octet-stream", + } +} + +async fn serve_file(dir: &str, name: &str) -> Response { + match tokio::fs::read(format!("{}/{}", dir, name)).await { + Ok(bytes) => { + let ct = content_type_for(name); + ( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(ct))], + bytes, + ) + .into_response() + } + Err(_) => (StatusCode::NOT_FOUND, "Not found").into_response(), + } +} + +// ===== Local SQLite API handlers ===== + +fn get_search_history_handler_inner() -> Response { + match db::get_search_history(Some(30)) { + Ok(items) => axum::Json(items).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + } +} + +fn save_search_history_handler_inner(body: axum::body::Bytes) -> Response { + let req: serde_json::Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid JSON: {}", e)).into_response(), + }; + let id = req.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let query = req.get("query").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let title = req.get("title").and_then(|v| v.as_str()).unwrap_or(&query).to_string(); + let chat_state = req.get("chat_state").and_then(|v| v.as_str()).map(|s| s.to_string()); + let mode = req.get("mode").and_then(|v| v.as_str()).map(|s| s.to_string()); + match db::save_search_history(id, query, title, chat_state, mode) { + Ok(item) => axum::Json(item).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + } +} + +fn rename_search_history_handler_inner(id: String, body: axum::body::Bytes) -> Response { + let req: serde_json::Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(_) => return (StatusCode::BAD_REQUEST, "Invalid JSON").into_response(), + }; + let title = req.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); + match db::rename_search_history(id, title) { + Ok(()) => axum::Json(serde_json::json!({"success": true})).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + } +} + +fn pin_search_history_handler_inner(id: String, body: axum::body::Bytes) -> Response { + let req: serde_json::Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(_) => return (StatusCode::BAD_REQUEST, "Invalid JSON").into_response(), + }; + let pinned = req.get("pinned").and_then(|v| v.as_bool()).unwrap_or(false); + match db::pin_search_history(id, pinned) { + Ok(()) => axum::Json(serde_json::json!({"success": true})).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + } +} + +fn delete_search_history_handler_inner(id: String) -> Response { + match db::delete_search_history(id) { + Ok(()) => axum::Json(serde_json::json!({"success": true})).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + } +} + +fn get_bookmarks_handler_inner() -> Response { + match db::get_bookmarks() { + Ok(items) => axum::Json(items).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + } +} + +fn save_bookmark_handler_inner(body: axum::body::Bytes) -> Response { + let req: serde_json::Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid JSON: {}", e)).into_response(), + }; + let label = req.get("label").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let history_id = req.get("history_id").and_then(|v| v.as_str()).map(|s| s.to_string()); + match db::save_bookmark(label, history_id) { + Ok(item) => axum::Json(item).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + } +} + +fn delete_bookmark_handler_inner(id: i64) -> Response { + match db::delete_bookmark(id) { + Ok(()) => axum::Json(serde_json::json!({"success": true})).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + } +} + +async fn get_identity_profile_handler_inner(uuid: String) -> Response { + let start = std::time::Instant::now(); + let no_dash = uuid.replace('-', ""); + eprintln!("[proxy] PROFILE handler called for {} (no_dash={})", uuid, no_dash); + { + let mut cache = get_profile_proxy_cache(); + if let Some(c) = cache.as_mut() { + if let Some(val) = c.get(&uuid) { + eprintln!("[proxy] <-- PROFILE {} 200 (cached) {}ms", uuid, start.elapsed().as_millis()); + return ([("content-type", "text/plain")], val.clone()).into_response(); + } + } + } + let result = (|| -> Option { + for dir in &PROFILE_DIRS { + for candidate in [&no_dash, &uuid] { + let path = format!("{}/{}/profile.jpg", dir, candidate); + if std::path::Path::new(&path).exists() { + eprintln!("[proxy] PROFILE FOUND: {}", path); + let bytes = std::fs::read(&path).ok()?; + return Some(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes))); + } + } + } + None + })(); + match result { + Some(data) => { + let mut cache = get_profile_proxy_cache(); + if let Some(c) = cache.as_mut() { + c.put(uuid.clone(), data.clone()); + } + eprintln!("[proxy] <-- PROFILE {} 200 {} {}ms", uuid, data.len(), start.elapsed().as_millis()); + ([("content-type", "text/plain")], data).into_response() + } + None => { + eprintln!("[proxy] <-- PROFILE {} 404 {}ms", uuid, start.elapsed().as_millis()); + (StatusCode::NOT_FOUND, "Profile image not found").into_response() + } + } +} + +async fn get_face_thumbnail_handler(State(state): State, axum::extract::Query(params): axum::extract::Query>) -> Response { + let start = std::time::Instant::now(); + let uuid = match params.get("uuid") { + Some(v) => v.clone(), + None => return (StatusCode::BAD_REQUEST, "Missing uuid").into_response(), + }; + let frame: u32 = params.get("frame").and_then(|v| v.parse().ok()).unwrap_or(0); + let bbox_x = params.get("bbox_x").and_then(|v| v.parse::().ok()); + let bbox_y = params.get("bbox_y").and_then(|v| v.parse::().ok()); + let bbox_w = params.get("bbox_w").and_then(|v| v.parse::().ok()); + let bbox_h = params.get("bbox_h").and_then(|v| v.parse::().ok()); + + let cache_key = format!("{}:{}:{}:{}:{}:{}", uuid, frame, + bbox_x.map_or(0.0, |v| v), + bbox_y.map_or(0.0, |v| v), + bbox_w.map_or(0.0, |v| v), + bbox_h.map_or(0.0, |v| v)); + + { + let mut cache = get_face_thumb_cache(); + if let Some(c) = cache.as_mut() { + if let Some(val) = c.get(&cache_key) { + eprintln!("[proxy] <-- FACE-THUMB {} (cached) {}ms", uuid, start.elapsed().as_millis()); + return ([("content-type", "text/plain")], val.clone()).into_response(); + } + } + } + + let url = format!("{}/api/v1/file/{}/thumbnail?api_key={}&frame={}", CORE_API, uuid, API_KEY, frame); + + let bytes = match state.client.get(&url).send().await { + Ok(resp) => match resp.bytes().await { + Ok(b) => b.to_vec(), + Err(e) => return (StatusCode::BAD_GATEWAY, format!("Read failed: {}", e)).into_response(), + }, + Err(e) => return (StatusCode::BAD_GATEWAY, format!("Request failed: {}", e)).into_response(), + }; + + let result = if let (Some(bx), Some(by), Some(bw), Some(bh)) = (bbox_x, bbox_y, bbox_w, bbox_h) { + let img = match image::load_from_memory(&bytes) { + Ok(i) => i, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Image decode failed: {}", e)).into_response(), + }; + let (w, h) = img.dimensions(); + let (px, py, pw, ph) = if bx <= 1.0 && by <= 1.0 && bw <= 1.0 && bh <= 1.0 { + ((bx * w as f64) as u32, (by * h as f64) as u32, (bw * w as f64) as u32, (bh * h as f64) as u32) + } else { + (bx as u32, by as u32, bw as u32, bh as u32) + }; + let cx = px.min(w.saturating_sub(1)); + let cy = py.min(h.saturating_sub(1)); + let cw = pw.min(w.saturating_sub(cx)).max(1); + let ch = ph.min(h.saturating_sub(cy)).max(1); + let cropped = img.crop_imm(cx, cy, cw, ch); + let mut buf = std::io::Cursor::new(Vec::new()); + if let Err(e) = cropped.write_to(&mut buf, image::ImageFormat::Jpeg) { + return (StatusCode::INTERNAL_SERVER_ERROR, format!("Encode failed: {}", e)).into_response(); + } + format!("data:image/jpeg;base64,{}", STANDARD.encode(buf.into_inner())) + } else { + format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes)) + }; + + { + let mut cache = get_face_thumb_cache(); + if let Some(c) = cache.as_mut() { + c.put(cache_key, result.clone()); + } + } + + eprintln!("[proxy] <-- FACE-THUMB {} {} {}ms", uuid, result.len(), start.elapsed().as_millis()); + ([("content-type", "text/plain")], result).into_response() +} \ No newline at end of file diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 91d0f4c..efef3fe 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -22,7 +22,13 @@ } ], "security": { - "csp": null + "csp": null, + "assetProtocol": { + "enable": true, + "scope": [ + "**" + ] + } } }, "bundle": { diff --git a/src/App.vue b/src/App.vue index 61ad3ff..3124bd3 100644 --- a/src/App.vue +++ b/src/App.vue @@ -66,21 +66,21 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown)) diff --git a/src/api/config.ts b/src/api/config.ts new file mode 100644 index 0000000..c294430 --- /dev/null +++ b/src/api/config.ts @@ -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 +} \ No newline at end of file diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..371e17d --- /dev/null +++ b/src/api/index.ts @@ -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): Promise { + 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, retries = 3): Promise { + 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((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): { 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 + } +} \ No newline at end of file diff --git a/src/components/VideoPlayer.vue b/src/components/VideoPlayer.vue index 9e0aac9..aa2c42f 100644 --- a/src/components/VideoPlayer.vue +++ b/src/components/VideoPlayer.vue @@ -1,46 +1,71 @@ +.ms-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.3); z-index: 999; display: grid; place-items: center; } +.ms-modal { background: #fff; border-radius: 20px; max-width: 380px; width: 90%; box-shadow: 0 20px 60px rgba(0,0,0,.18); } +.ms-modal-video { max-width: 720px; width: 95%; padding: 20px 24px 24px; text-align: left; background: #1a1a1a; } +.ms-modal-video-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; } +.ms-modal-video-title { font-size: 14px; font-weight: 600; color: #e8eaed; margin: 0; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ms-modal-video-close { border: none; background: transparent; font-size: 18px; color: #9aa0a6; cursor: pointer; padding: 0; line-height: 1; flex-shrink: 0; } +.ms-modal-video-close:hover { color: #fff; } +.video { max-width: 100%; max-height: 60vh; border-radius: 8px; display: block; width: 100%; } +.ms-video-loading { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 20px; color: #5f6368; font-size: 13px; } +.ms-video-loading-spinner { width: 18px; height: 18px; border: 2px solid #e8eaed; border-top-color: #1a56db; border-radius: 50%; animation: ms-spin .7s linear infinite; } +@keyframes ms-spin { to { transform: rotate(360deg); } } +.ms-video-timeline-wrap { padding: 14px 0 6px; } +.ms-video-tl-bar { position: relative; width: 100%; height: 4px; background: rgba(255,255,255,0.15); border-radius: 999px; margin-bottom: 8px; overflow: visible; } +.ms-video-tl-dot { position: absolute; top: 50%; transform: translate(0, -50%); height: 8px; border-radius: 4px; background: #9aa0a6; cursor: pointer; transition: height .12s, background .12s; z-index: 2; } +.ms-video-tl-dot:hover { height: 12px; background: #fff; } +.ms-video-tl-dot.active { background: #fff; box-shadow: 0 0 0 2.5px rgba(255,255,255,0.35); } +.ms-video-tl-labels { display: flex; justify-content: space-between; font-size: 10px; color: rgba(255,255,255,0.45); } +.ms-video-tl-tip { display: none; position: absolute; bottom: 16px; transform: translateX(-50%); background: rgba(0,0,0,.75); color: #fff; font-size: 10px; padding: 2px 7px; border-radius: 4px; white-space: nowrap; pointer-events: none; z-index: 10; } +.ms-video-tl-dot:hover .ms-video-tl-tip { display: block; } +.ms-video-nav { display: flex; align-items: center; justify-content: space-between; margin-top: 14px; gap: 10px; } +.ms-video-seg-info { font-size: 12px; color: rgba(255,255,255,0.6); flex-shrink: 0; } +.ms-video-seg-info2 { font-size: 12px; color: #9aa0a6; flex: 1; text-align: center; } +.ms-video-nav-btn { background: #2c2c2c; border-color: #3c3c3c; color: #e8eaed; } +.ms-video-nav-btn:hover { background: #3c3c3c; } +.ms-video-nav-btn:disabled { opacity: 0.35; cursor: default; } + \ No newline at end of file diff --git a/src/composables/useSearchHistory.ts b/src/composables/useSearchHistory.ts new file mode 100644 index 0000000..d9b3587 --- /dev/null +++ b/src/composables/useSearchHistory.ts @@ -0,0 +1,138 @@ +import { ref } from 'vue' +import { apiCall } from '@/api' + +export interface HistoryItem { + id: string + query: string + title: string + chat_state: string | null + mode: string | null + pinned: boolean + created_at: string | null + updated_at: string | null +} + +export function useSearchHistory() { + const history = ref([]) + const loading = ref(false) + + async function loadHistory() { + loading.value = true + try { + const result = await apiCall('get_search_history', { limit: 30 }) + history.value = Array.isArray(result) ? result : [] + } catch (e) { + console.error('Failed to load history:', e) + } finally { + loading.value = false + } + } + + async function saveToHistory(query: string, chatState: string, mode: string) { + const id = `h_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + try { + await apiCall('save_search_history', { + id, + query, + title: query, + chatState, + mode, + }) + await loadHistory() + } catch (e) { + console.error('Failed to save history:', e) + } + } + + async function renameHistory(id: string, title: string) { + try { + await apiCall('rename_search_history', { id, title }) + await loadHistory() + } catch (e) { + console.error('Failed to rename history:', e) + } + } + + async function pinHistory(id: string, pinned: boolean) { + try { + await apiCall('pin_search_history', { id, pinned }) + await loadHistory() + } catch (e) { + console.error('Failed to pin history:', e) + } + } + + async function deleteHistory(id: string) { + try { + await apiCall('delete_search_history', { id }) + await loadHistory() + } catch (e) { + console.error('Failed to delete history:', e) + } + } + + function restoreFromHistory(item: HistoryItem): any[] | null { + if (!item.chat_state) return null + try { + return JSON.parse(item.chat_state) + } catch { + return null + } + } + + return { + history, + loading, + loadHistory, + saveToHistory, + renameHistory, + pinHistory, + deleteHistory, + restoreFromHistory, + } +} + +export interface BookmarkItem { + id: number + label: string + history_id: string | null + created_at: string | null +} + +export function useBookmarks() { + const bookmarks = ref([]) + + async function loadBookmarks() { + try { + const result = await apiCall('get_bookmarks', {}) + bookmarks.value = Array.isArray(result) ? result : [] + } catch (e) { + console.error('Failed to load bookmarks:', e) + } + } + + async function saveBookmark(label: string, historyId?: string) { + try { + await apiCall('save_bookmark', { label, historyId: historyId || null }) + await loadBookmarks() + } catch (e) { + console.error('Failed to save bookmark:', e) + } + } + + async function deleteBookmark(id: number) { + try { + await apiCall('delete_bookmark', { id }) + await loadBookmarks() + } catch (e) { + console.error('Failed to delete bookmark:', e) + } + } + + return { + bookmarks, + loadBookmarks, + saveBookmark, + deleteBookmark, + } +} \ No newline at end of file diff --git a/src/composables/useUndoRedo.ts b/src/composables/useUndoRedo.ts new file mode 100644 index 0000000..6089bf0 --- /dev/null +++ b/src/composables/useUndoRedo.ts @@ -0,0 +1,253 @@ +import { reactive } from 'vue' +import { apiCall } from '@/api' +import { invalidatePeople, ensurePeople } from '@/store' + +interface HistoryCounts { + patchUndo: number + patchRedo: number + bindUndo: number + bindRedo: number +} + +interface MergeHistoryItem { + mergeId: string + sourceUuid: string + targetUuid: string + sourceName: string + targetName: string + undone: boolean + createdAt: string +} + +interface ActionHistoryItem { + type: 'patch' | 'bind' | 'merge' | 'delete' + label: string + uuid: string + timestamp: string + canUndo: boolean + canRedo: boolean +} + +export function useUndoRedo() { + const counts = reactive>({}) + const mergeHistory = reactive([]) + const recentActions = reactive([]) + + async function refreshCounts(uuid: string) { + try { + const [patchHist, bindHist] = await Promise.all([ + apiCall('identity_history', { uuid, page: 1, pageSize: 1 }), + apiCall('identity_bind_history', { uuid, page: 1, pageSize: 1 }), + ]) + if (!counts[uuid]) counts[uuid] = { patchUndo: 0, patchRedo: 0, bindUndo: 0, bindRedo: 0 } + counts[uuid].patchUndo = patchHist?.undo_stack_count ?? patchHist?.undoStackCount ?? 0 + counts[uuid].patchRedo = patchHist?.redo_stack_count ?? patchHist?.redoStackCount ?? 0 + counts[uuid].bindUndo = bindHist?.undo_stack_count ?? bindHist?.undoStackCount ?? 0 + counts[uuid].bindRedo = bindHist?.redo_stack_count ?? bindHist?.redoStackCount ?? 0 + } catch (e) { + console.error('Failed to refresh undo counts:', e) + } + } + + async function refreshMergeHistory(uuid?: string) { + try { + const params: Record = { pageSize: 10 } + if (uuid) params.sourceUuid = uuid + const data = await apiCall('merge_history', params) + const items = data?.history || data?.merges || data || [] + mergeHistory.splice(0, mergeHistory.length) + for (const m of Array.isArray(items) ? items : []) { + mergeHistory.push({ + mergeId: m.merge_id ?? m.mergeId ?? '', + sourceUuid: m.source_uuid ?? m.sourceUuid ?? '', + targetUuid: m.target_uuid ?? m.targetUuid ?? '', + sourceName: m.source_name ?? m.sourceName ?? '', + targetName: m.target_name ?? m.targetName ?? '', + undone: m.is_undone ?? m.isUndone ?? false, + createdAt: m.created_at ?? m.createdAt ?? '', + }) + } + } catch (e) { + console.error('Failed to refresh merge history:', e) + } + } + + async function refreshActionHistory(uuid: string) { + try { + const [patchHist, bindHist] = await Promise.all([ + apiCall('identity_history', { uuid, page: 1, pageSize: 10 }), + apiCall('identity_bind_history', { uuid, page: 1, pageSize: 10 }), + ]) + const actions: ActionHistoryItem[] = [] + const patchItems = patchHist?.history || patchHist?.records || [] + for (const r of Array.isArray(patchItems) ? patchItems : []) { + const op = r.operation ?? r.op ?? '' + const label = op === 'delete' ? '刪除人物' : op === 'update' ? '更新人物' : op + actions.push({ + type: op === 'delete' ? 'delete' : 'patch', + label, + uuid, + timestamp: r.created_at ?? r.createdAt ?? r.timestamp ?? '', + canUndo: !r.is_undone && !r.isUndone, + canRedo: !!(r.is_undone || r.isUndone), + }) + } + const bindItems = bindHist?.history || bindHist?.records || [] + for (const r of Array.isArray(bindItems) ? bindItems : []) { + const op = r.operation ?? r.op ?? '' + const label = op === 'bind' ? '綁定面部' : op === 'unbind' ? '解綁面部' : op === 'bind_trace' ? '綁定軌跡' : op + actions.push({ + type: 'bind', + label, + uuid, + timestamp: r.created_at ?? r.createdAt ?? r.timestamp ?? '', + canUndo: !(r.is_undone ?? r.isUndone), + canRedo: !!(r.is_undone ?? r.isUndone), + }) + } + actions.sort((a, b) => b.timestamp.localeCompare(a.timestamp)) + recentActions.splice(0, recentActions.length, ...actions.slice(0, 20)) + } catch (e) { + console.error('Failed to refresh action history:', e) + } + } + + async function undo(uuid: string, steps = 1) { + try { + await apiCall('identity_undo', { uuid, steps }) + await refreshCounts(uuid) + await refreshActionHistory(uuid) + invalidatePeople() + await ensurePeople() + } catch (e) { + console.error('Undo failed:', e) + throw e + } + } + + async function redo(uuid: string, steps = 1) { + try { + await apiCall('identity_redo', { uuid, steps }) + await refreshCounts(uuid) + await refreshActionHistory(uuid) + invalidatePeople() + await ensurePeople() + } catch (e) { + console.error('Redo failed:', e) + throw e + } + } + + async function bindUndo(uuid: string, steps = 1) { + try { + await apiCall('identity_bind_undo', { uuid, steps }) + await refreshCounts(uuid) + await refreshActionHistory(uuid) + invalidatePeople() + await ensurePeople() + } catch (e) { + console.error('Bind undo failed:', e) + throw e + } + } + + async function bindRedo(uuid: string, steps = 1) { + try { + await apiCall('identity_bind_redo', { uuid, steps }) + await refreshCounts(uuid) + await refreshActionHistory(uuid) + invalidatePeople() + await ensurePeople() + } catch (e) { + console.error('Bind redo failed:', e) + throw e + } + } + + async function mergeUndo(mergeId: string) { + try { + await apiCall('merge_undo', { mergeId }) + invalidatePeople() + await ensurePeople() + } catch (e) { + console.error('Merge undo failed:', e) + throw e + } + } + + async function mergeRedo(mergeId: string) { + try { + await apiCall('merge_redo', { mergeId }) + invalidatePeople() + await ensurePeople() + } catch (e) { + console.error('Merge redo failed:', e) + throw e + } + } + + function canUndo(uuid: string): boolean { + const c = counts[uuid] + if (!c) return false + return c.patchUndo > 0 || c.bindUndo > 0 + } + + function canRedo(uuid: string): boolean { + const c = counts[uuid] + if (!c) return false + return c.patchRedo > 0 || c.bindRedo > 0 + } + + async function undoAction(uuid: string, type: 'patch' | 'bind') { + try { + if (type === 'bind') { + await apiCall('identity_bind_undo', { uuid, steps: 1 }) + } else { + await apiCall('identity_undo', { uuid, steps: 1 }) + } + await refreshCounts(uuid) + await refreshActionHistory(uuid) + invalidatePeople() + await ensurePeople() + } catch (e) { + console.error('Undo action failed:', e) + throw e + } + } + + async function redoAction(uuid: string, type: 'patch' | 'bind') { + try { + if (type === 'bind') { + await apiCall('identity_bind_redo', { uuid, steps: 1 }) + } else { + await apiCall('identity_redo', { uuid, steps: 1 }) + } + await refreshCounts(uuid) + await refreshActionHistory(uuid) + invalidatePeople() + await ensurePeople() + } catch (e) { + console.error('Redo action failed:', e) + throw e + } + } + + return { + counts, + mergeHistory, + recentActions, + refreshCounts, + refreshMergeHistory, + refreshActionHistory, + undo, + redo, + bindUndo, + bindRedo, + mergeUndo, + mergeRedo, + canUndo, + canRedo, + undoAction, + redoAction, + } +} \ No newline at end of file diff --git a/src/directives/vObserve.ts b/src/directives/vObserve.ts new file mode 100644 index 0000000..1dd8765 --- /dev/null +++ b/src/directives/vObserve.ts @@ -0,0 +1,44 @@ +import { Directive } from 'vue' + +const vObserve: Directive = { + mounted(el, binding) { + if (typeof binding.value !== 'function') return + + const callback = binding.value + + const observer = new IntersectionObserver( + (entries) => { + if (!entries.length) return + if (entries[0].isIntersecting) { + callback() + observer.disconnect() + } + }, + { rootMargin: '200px 0px' } + ) + + observer.observe(el) + ;(el as any)._io = observer + + requestAnimationFrame(() => { + const rect = el.getBoundingClientRect() + if ( + rect.top < window.innerHeight + 200 && + rect.bottom > -200 && + rect.width > 0 + ) { + callback() + observer.disconnect() + ;(el as any)._io = undefined + } + }) + }, + unmounted(el) { + if ((el as any)._io) { + ;(el as any)._io.disconnect() + ;(el as any)._io = undefined + } + }, +} + +export default vObserve \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index efe493a..ef35487 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,9 @@ import { createApp } from 'vue' import App from './App.vue' import router from './router' +import vObserve from './directives/vObserve' const app = createApp(App) app.use(router) +app.directive('observe', vObserve) app.mount('#app') diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..9f6fdee --- /dev/null +++ b/src/store.ts @@ -0,0 +1,221 @@ +import { ref } from 'vue' +import { apiCall } from '@/api' +import { isTauri } from './api/config' + +export const filesCache = ref([]) +export const filesLoaded = ref(false) + +export const peopleCache = ref([]) +export const peopleLoaded = ref(false) + +export const faceCandidatesCache = ref([]) +export const faceCandidatesLoaded = ref(false) + +export const thumbnailsCache = ref>({}) +export const profilesCache = ref>({}) +export const faceThumbsCache = ref>({}) + +const thumbQueue: (() => Promise)[] = [] +let activeThumbLoads = 0 +const MAX_CONCURRENT = 16 + +const profileQueue: (() => Promise)[] = [] +let activeProfileLoads = 0 +const MAX_PROFILE_CONCURRENT = 4 + +const loadingThumbs = new Set() +const loadingProfiles = new Set() +const loadingFaceThumbs = new Set() + +function drainThumbQueue() { + if (activeThumbLoads >= MAX_CONCURRENT || thumbQueue.length === 0) return + activeThumbLoads++ + const task = thumbQueue.shift()! + task().then(() => { + activeThumbLoads-- + drainThumbQueue() + }).catch(() => { + activeThumbLoads-- + drainThumbQueue() + }) +} + +function drainProfileQueue() { + if (activeProfileLoads >= MAX_PROFILE_CONCURRENT || profileQueue.length === 0) return + activeProfileLoads++ + const task = profileQueue.shift()! + task().then(() => { + activeProfileLoads-- + drainProfileQueue() + }).catch(() => { + activeProfileLoads-- + drainProfileQueue() + }) +} + +function queueThumb(fn: () => Promise) { + thumbQueue.push(fn) + drainThumbQueue() +} + +function queueProfile(fn: () => Promise) { + profileQueue.push(fn) + drainProfileQueue() +} + +let _peopleLoading = false +let _filesLoading = false +let _faceCandidatesLoading = false + +export async function ensureFiles() { + if (filesLoaded.value) return + if (_filesLoading) { + await new Promise(r => { const check = setInterval(() => { if (filesLoaded.value || !_filesLoading) { clearInterval(check); r() } }, 100) }) + return + } + _filesLoading = true + try { + const result = await apiCall('get_files', { args: { pageSize: 500 } }) + filesCache.value = Array.isArray(result) ? result : [] + } catch (e) { + console.error('Failed to load files:', e) + } finally { + _filesLoading = false + filesLoaded.value = true + } +} + +export async function ensurePeople() { + if (peopleLoaded.value) return + if (_peopleLoading) { + await new Promise(r => { const check = setInterval(() => { if (peopleLoaded.value || !_peopleLoading) { clearInterval(check); r() } }, 100) }) + return + } + _peopleLoading = true + try { + if (isTauri) { + const result: any = await apiCall('get_people', { page: 1, perPage: 100 }) + peopleCache.value = Array.isArray(result) ? result : [] + } else { + const allPeople: any[] = [] + for (let page = 1; page <= 5; page++) { + const batch: any = await apiCall('get_people', { page, perPage: 20 }) + const arr = Array.isArray(batch) ? batch : [] + if (!arr.length) break + allPeople.push(...arr) + if (arr.length < 20) break + } + peopleCache.value = allPeople + } + } catch (e) { + console.error('Failed to load people:', e) + } finally { + _peopleLoading = false + peopleLoaded.value = true + } +} + +export async function ensureFaceCandidates() { + if (faceCandidatesLoaded.value) return + if (_faceCandidatesLoading) { + await new Promise(r => { const check = setInterval(() => { if (faceCandidatesLoaded.value || !_faceCandidatesLoading) { clearInterval(check); r() } }, 100) }) + return + } + _faceCandidatesLoading = true + try { + if (isTauri) { + const fc: any = await apiCall('get_face_candidates', { page: 1, perPage: 100 }) + faceCandidatesCache.value = Array.isArray(fc) ? fc : [] + } else { + const all: any[] = [] + for (let page = 1; page <= 5; page++) { + const batch: any = await apiCall('get_face_candidates', { page, perPage: 20 }) + const arr = Array.isArray(batch) ? batch : [] + if (!arr.length) break + all.push(...arr) + if (arr.length < 20) break + } + faceCandidatesCache.value = all + } + } catch (e) { + console.error('Failed to load face candidates:', e) + } finally { + _faceCandidatesLoading = false + faceCandidatesLoaded.value = true + } +} + +export function loadThumbnail(uuid: string, frame = 30) { + const key = `${uuid}:${frame}` + if (!uuid || thumbnailsCache.value[key] || loadingThumbs.has(key)) return + loadingThumbs.add(key) + queueThumb(async () => { + try { + const result = await apiCall('get_thumbnail', { uuid, frame }) + if (result) thumbnailsCache.value[key] = result + } catch (e) { + console.error('loadThumbnail failed:', uuid, frame, e) + } finally { + loadingThumbs.delete(key) + } + }) +} + +export function loadProfile(uuid: string) { + if (!uuid) { console.error('[loadProfile] called with empty uuid'); return } + if (profilesCache.value[uuid]) return + if (loadingProfiles.has(uuid)) return + loadingProfiles.add(uuid) + console.error('[loadProfile] requesting', uuid) + queueProfile(async () => { + try { + const result = await apiCall('get_identity_profile', { uuid }) + console.error('[loadProfile] got result for', uuid, typeof result, result ? (result as string).substring(0, 30) : 'null') + if (result) profilesCache.value[uuid] = result + else console.error('[loadProfile] empty result for', uuid) + } catch (e) { + console.error('loadProfile failed:', uuid, e) + } finally { + loadingProfiles.delete(uuid) + } + }) +} + +export function loadFaceThumb(key: string, uuid: string, frame: number, bbox?: any) { + if (!uuid || faceThumbsCache.value[key] || loadingFaceThumbs.has(key)) return + loadingFaceThumbs.add(key) + queueThumb(async () => { + try { + const args: any = { uuid, frame } + if (bbox) { + args.bboxX = Math.round(bbox.x) + args.bboxY = Math.round(bbox.y) + args.bboxW = Math.round(bbox.width) + args.bboxH = Math.round(bbox.height) + } + const result = await apiCall('get_face_thumbnail', args) + if (result) faceThumbsCache.value[key] = result + } catch (e) { + console.error('loadFaceThumb failed:', key, uuid, e) + } finally { + loadingFaceThumbs.delete(key) + } + }) +} + +export function invalidateFiles() { + filesLoaded.value = false + filesCache.value = [] +} + +export function invalidatePeople() { + peopleLoaded.value = false + peopleCache.value = [] + faceCandidatesLoaded.value = false + faceCandidatesCache.value = [] +} + +export function invalidateProfile(uuid: string) { + delete profilesCache.value[uuid] + loadingProfiles.delete(uuid) +} \ No newline at end of file diff --git a/src/views/LibraryView.vue b/src/views/LibraryView.vue index e351a19..2a94aec 100644 --- a/src/views/LibraryView.vue +++ b/src/views/LibraryView.vue @@ -1,97 +1,131 @@ diff --git a/src/views/PersonDetailView.vue b/src/views/PersonDetailView.vue index 7550fd0..f60c2d9 100644 --- a/src/views/PersonDetailView.vue +++ b/src/views/PersonDetailView.vue @@ -7,6 +7,13 @@ 返回 +
@@ -16,18 +23,26 @@
@@ -102,7 +197,7 @@
- +
{{ Math.round(c.confidence * 100) }}%
{{ c.file_uuid?.slice(0, 8) }}... #{{ c.frame_number }} @@ -112,63 +207,101 @@
-
+
-

Merge Identity

- -
- +

⇄ 合併到其他人物

+
+ 🔍 + +
+
+
+
+ + + + +
+ {{ r.name }} +
+
+ No results found +
+
+
- + + + +
+ +
+.ms-ppl-actions-section { display: flex; flex-direction: column; gap: 8px; } +.ms-ppl-actions-status { display: flex; align-items: center; gap: 10px; padding: 12px; background: #f8f9fa; border-radius: 10px; font-size: 13px; } +.ms-ppl-actions-label { color: #5f6368; font-weight: 500; } +.ms-ppl-actions-value { color: #202124; font-weight: 600; text-transform: capitalize; } +.ms-ppl-media-badge-conf { position: absolute; top: 5px; left: 7px; background: rgba(0,0,0,.5); color: #fff; font-size: 10px; padding: 1px 5px; border-radius: 3px; } +.segment-card { max-width: 320px; margin-bottom: 20px; } + +/* Context menu */ +.ms-ctx-menu { position: fixed; z-index: 99999; background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.15); padding: 6px; min-width: 140px; font-size: 13px; color: #222; } +.ms-ctx-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; border-radius: 8px; border: none; background: transparent; width: 100%; text-align: left; font-size: 13px; color: #222; font-family: inherit; } +.ms-ctx-item:hover { background: #f3f4f6; } +.ms-ctx-item.ms-ctx-danger { color: #d93025; } +.ms-ctx-item.ms-ctx-danger:hover { background: #fce8e6; } +.ms-ctx-item-disabled { padding: 8px 12px; font-size: 12px; color: #9aa0a6; cursor: default; } + +/* Remove button for face strip */ +.ms-ppl-strip-remove-btn { display: none; position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%; background: #fff; border: 1.5px solid #d1d5db; font-size: 11px; color: #5f6368; line-height: 1; cursor: pointer; outline: none; place-items: center; } +.ms-ppl-edit-mode .ms-ppl-strip-remove-btn { display: grid; } + \ No newline at end of file diff --git a/src/views/SearchView.vue b/src/views/SearchView.vue index c1984aa..c67415d 100644 --- a/src/views/SearchView.vue +++ b/src/views/SearchView.vue @@ -1,121 +1,587 @@