feat: Library 批量注册 + PeopleView 修复 + 国际化

Library:
- 添加 checkbox 和鼠标框选功能
- Unregistered 卡片添加快速注册按钮
- 状态标签移至左下方,人脸图标移至左侧中间
- Deep Scan 改为 unregister -> register -> process
- 完整国际化(日语、繁体中文、英语)

PeopleView:
- 修复卡片不显示问题(使用 get_file_traces API)
- 移除对 identity_id 字段的依赖

其他:
- 添加日语语言包
- 添加 useDragSelect composable
- 新增文档:Face Bbox Null Bug Report
This commit is contained in:
2026-10-01 00:42:13 +08:00
parent 7b60ca3d2d
commit f3d75ebce8
35 changed files with 8054 additions and 961 deletions

View File

@@ -10,6 +10,11 @@ Examples:
- ✅ `/Users/accusys/momentry_studio/src-tauri/src/db.rs`
- ❌ `src-tauri/src/db.rs`
## Workflow
**Before starting any task, always create a `todowrite` with all steps listed.**
Mark items `in_progress` while working and `completed` when done.
## Stack
- **Frontend**: Vue 3 + TypeScript + Vite (port 5173)
- **Desktop**: Tauri 2 (Rust backend in `src-tauri/`)
@@ -25,17 +30,31 @@ npm run tauri # Alias for `cargo tauri`
cargo tauri dev # Full Tauri desktop dev (builds frontend + opens native window)
cargo tauri build # Full native app build (outputs to src-tauri/target/release/bundle/)
```
- **Browser dev flow**: `npm run dev` → kills old proxy on 8888 → starts `momentry-proxy` → sleeps 2s → starts Vite. Vite proxy forwards `/api/v1/*` to Rust proxy at `localhost:8888`. Rust proxy handles face-crop, cluster-results, identity-matches, search-history, bookmarks, profile images locally; proxies rest to Core API at `localhost:3002`.
- **Browser dev flow**: `npm run dev` → kills old proxy on 8888 → starts `momentry-proxy` → sleeps 2s → starts Vite. Vite proxy forwards `/api/v1/*` to Rust proxy at `localhost:8888` and `/api/v2/*` directly to MarkBase at `localhost:11438`. Rust proxy handles face-crop, cluster-results, identity-matches, search-history, bookmarks, profile images locally; proxies rest to Core API at `localhost:3002`.
## Two-API Architecture (不可混淆)
**Two independent backend APIs. `/api/v1/*` → Momentry Core API ONLY; `/api/v2/*` → MarkBase Core API ONLY. Never cross.**
| API | Port | Namespace | Owner | Auth |
|---|---|---|---|---|
| **Momentry Core API** | `:3002` | `/api/v1/*` only (files, search, identities, faces, traces, agents, video-marks…) | `/Users/accusys/momentry_core` | `api_key` injected by Studio proxy |
| **MarkBase Core API** | `:11438` | `/api/v2/*` only (admin, myfiles, service, auth, config, tree, products…) | `/Users/accusys/markbase/markbase-core` | Bearer token (`/api/v2/admin/login`) |
**Routing rules** (`/Users/accusys/momentry_studio/src-tauri/src/proxy.rs`):
- Studio proxy `:8888` is a v1/v2 gateway: `fallback_handler` routes `/api/v2/*` → `proxy_to(MARKBASE_API, inject_api_key=false)` and any other `/api/*` → `proxy_to(CORE_API, inject_api_key=true)`. MarkBase does NOT get `api_key` injected.
- Vite dev proxy (`/Users/accusys/momentry_studio/vite.config.ts`): `/api/v1` → `localhost:8888`, `/api/v2` → `localhost:11438`.
- **Admin data (`get_system_stats`/`list_admin_users`/`list_shares`) always comes from MarkBase `/api/v2/admin/*` in ALL modes** — these are forced to `httpCall()` in `src/api/index.ts` (no Tauri commands exist for them).
- `ClientView` (`/Users/accusys/momentry_studio/src/views/ClientView.vue`) talks to MarkBase directly via `MARKBASE_URL + '/api/v2'` (bypasses Studio proxy). `start_service`/`stop_service` use `detectBaseUrl()` → MarkBase directly.
## Architecture
- **Dual-mode API**: Frontend uses `apiCall()` from `src/api/index.ts` which detects `window.__TAURI__` and dispatches to either Tauri IPC (`invoke`) or HTTP to proxy at `http://0.0.0.0:8888`.
- **Rust Tauri commands**: `src-tauri/src/main.rs` — 22 Tauri commands:
- **Rust Tauri commands**: `src-tauri/src/main.rs` — 19 Tauri commands:
- Core API: `search_llm_smart`, `search_agents`, `get_files`, `get_people`, `get_faces`, `get_traces`, `get_file_info`
- Media: `get_thumbnail`, `get_face_thumbnail`, `get_identity_profile`
- Identity: `update_identity_name`, `update_identity_status`, `update_identity_starred`, `delete_identity`, `search_identities`, `get_face_candidates`, `merge_identities`, `bind_face`, `unbind_face`
- File ops: `register_file`, `process_file`, `unregister_file`
- Local SQLite: `get_search_history`, `save_search_history`, `rename_search_history`, `pin_search_history`, `delete_search_history`, `get_bookmarks`, `save_bookmark`, `delete_bookmark`
- **Rust HTTP proxy** (`src-tauri/src/proxy.rs`): Axum on port 8888. Forwards `/api/v1/*` to Core API (localhost:3002) with API key injection. Handles `/api/v1/search-history` and `/api/v1/bookmarks` locally via `db::` functions. Handles `/api/v1/identity/{uuid}/profile` (reads local filesystem) and `/api/v1/face-thumbnail` (downloads frame + crops bbox) locally — these mirror the Tauri IPC commands that can't be proxied to Core API.
- **Rust HTTP proxy** (`src-tauri/src/proxy.rs`): Axum on port 8888. Routes `/api/v2/*` to MarkBase API (localhost:11438) and `/api/v1/*` to Core API (localhost:3002) with API key injection. Handles `/api/v1/search-history` and `/api/v1/bookmarks` locally via `db::` functions. Handles `/api/v1/identity/{uuid}/profile` (reads local filesystem) and `/api/v1/face-thumbnail` (downloads frame + crops bbox) locally — these mirror the Tauri IPC commands that can't be proxied to Core API.
- **Per-user SQLite**: `src-tauri/src/db.rs` — Project-root `data/users/demo.sqlite` (outside `src-tauri/` to avoid dev watcher rebuilds). WAL mode enabled. Tables: `search_history`, `bookmarks`, `app_users`. `init_db()` called on app setup.
- **Frontend routes**: `/search`, `/library`, `/people` (redirect `/` → `/search`)
- **Frontend entry**: `src/main.ts` → `App.vue` → `router/index.ts`
@@ -50,7 +69,7 @@ cargo tauri build # Full native app build (outputs to src-tauri/target/releas
- **Full native build order**: `npm install` → `npm run build` → `cargo tauri build`
- **Rust deps**: sqlx 0.8, rusqlite 0.32 (bundled), reqwest, tokio, tauri-plugin-shell, base64, chrono, uuid
- **Core API**: `http://localhost:3002`, API key hardcoded in Rust only.
- **Proxy**: `http://0.0.0.0:8888` — forwards `/api/v1/*` to Core API with auto-injected key; handles `/api/v1/search-history` and `/api/v1/bookmarks` locally.
- **Proxy**: `http://0.0.0.0:8888` — v1/v2 gateway: `/api/v2/*` → MarkBase (11438, no key), `/api/v1/*` → Core API with auto-injected key; handles `/api/v1/search-history` and `/api/v1/bookmarks` locally.
## Patterns
- **Search thumbnails**: Use `start_frame` for frame-accurate thumbnails. Cache key format: `${file_uuid}:${start_frame}`. Not a single `file_uuid` key because same file can appear in multiple search results at different time segments.
@@ -60,6 +79,8 @@ cargo tauri build # Full native app build (outputs to src-tauri/target/releas
- **v-observe directive**: Global directive (`v-observe`) registered in `main.ts`. Defined in `src/directives/vObserve.ts`. Uses `IntersectionObserver` with 200px rootMargin. Includes `requestAnimationFrame` fallback for elements already visible on mount.
- **SearchView empty state**: When `messages.length === 0`, shows `.ms-hero` with "Momentry Studio" + "Turn Every Moment Into Intelligence" slogan. Chat area gets `.chat--empty` class (flex-column, justify-end) to position hero near the search bar. Once messages exist, hero disappears and `.ibar` stays fixed at bottom.
- **No `console.log` in *.vue files**: All debug console.log statements have been removed. Use `console.error` only for actual errors.
- **No dev residue allowed (`以後不再有 dev 殘留`)**: Never commit or leave scratch/dev files in the repo — this includes tracked backup snapshots (`*.bak`, `*.bak3`, `*.base`, `*.clean`, `*.work`, `*.backup_*`), standalone dev-only binaries/scripts (e.g. `gen_password.rs`), and unused dead code. Keep backups out of git (personal temp or gitignored); delete them when done. New dev features must not introduce `console.log` (even temporary — use `console.error` or remove before commit).
- **Face settings owned by Core**: `src/api/index.ts` `register_file`/`process_file` and the Rust commands in `/Users/accusys/momentry_studio/src-tauri/src/main.rs` must NOT send or read `face_settings`/`FACE_MIN_*` env vars. Core API owns the face-detection defaults server-side (see Core guide `docs_v1.0/M4_workspace/2026-08-08_face_settings_studio_guide.md`).
- **Search history & bookmarks**: Stored in per-user SQLite (`data/users/demo.sqlite`), not localStorage. History items have `id`, `query`, `title`, `chat_state` (JSON-serialized Vue messages), `mode`, `pinned`, timestamps. Max 30 items; pinned sorted first. Composable `useSearchHistory()` provides `loadHistory`, `saveToHistory`, `renameHistory`, `pinHistory`, `deleteHistory`, `restoreFromHistory`. Composable `useBookmarks()` provides `loadBookmarks`, `saveBookmark`, `deleteBookmark`.
- **SearchView history panel**: Dropdown with ⋯ menu per item (Pin/Unpin, Rename, Delete). +New button clears chat. Click item → restores `chat_state` JSON or runs new search. Renames via modal dialog.
- **SearchView bookmark panel**: Dropdown with items from SQLite. Click → runs search. Hover shows x button to delete.
@@ -67,4 +88,21 @@ cargo tauri build # Full native app build (outputs to src-tauri/target/releas
- **HistoryItem ID format**: `h_{timestamp}_{random}` e.g. `h_1718000000_abc123`.
- **SQLite WAL mode + path outside src-tauri**: Database at project-root `data/users/demo.sqlite` (not inside `src-tauri/`). WAL journal mode prevents `-journal` file creation that triggers `cargo tauri dev` watcher rebuilds. Default path `../data/users/demo.sqlite` resolved from `src-tauri/` cwd.
- **Video streaming**: `get_video_stream` is NOT a Tauri command. Both Tauri and browser modes use the proxy URL directly (`http://localhost:8888/api/v1/file/{uuid}/video?...`). The `<video>` element streams from the proxy, never via IPC. Proxy streams `video/*` and `octet-stream` responses (doesn't buffer entire body).
- **Face thumbnail & identity profile in browser mode**: `/api/v1/face-thumbnail` and `/api/v1/identity/{uuid}/profile` are handled locally by the proxy (same logic as Tauri IPC commands). Core API doesn't support bbox cropping for face thumbnails or local filesystem for profiles.
- **Face thumbnail & identity profile in browser mode**: `/api/v1/face-thumbnail` and `/api/v1/identity/{uuid}/profile` are handled locally by the proxy (same logic as Tauri IPC commands). Core API doesn't support bbox cropping for face thumbnails or local filesystem for profiles.
- **Advanced Mode (🔒/🔓)**: Store at `/Users/accusys/momentry_studio/src/stores/advancedMode.ts`. Toggle button (🔒 closed / 🔓 open) in LibraryView toolbar persists to localStorage key `momentry_advanced_mode`. Controls visibility of advanced features:
- `LibraryView.vue:180` — Processor QC button `v-if="isAdvanced"`
- `VideoPlayer.vue:88,129,169,198` — Mark timelines, controls, panel, frame seek `v-if="isAdvanced"`
- `PeopleView.vue:19` — Data QC button `v-if="selectedFileUuid && isAdvanced"`
- `BuildVersion.vue:2` — Build version badge `v-if="isAdvanced"`
- Import: `import { useAdvancedMode } from '@/stores/advancedMode'`
- Usage: `const { isAdvanced } = useAdvancedMode()`
- **File Status (Core API 單一來源)**: 6 個狀態 values 由 Core API 回傳,前端只負責顯示不過濾:
- `registered` → 📋 已註冊(剛註冊)
- `scanning` → 🔍 掃描中(處理中,顯示 progress bar)
- `keyword_ready` → 🔎 關鍵字可用(sentence chunks 完成)
- `semantic_ready` → 🧠 語意可用(embedded chunks 完成)
- `face_mgmt_ready` → 🎭 臉部管理可用(trace_profiles + tkg_nodes 完成)
- `agent_ready` → 🤖 Agent 可用(tkg_edges 完成,終端狀態)
- 前端不做任何本地 status promotion(已移除 `pollProgress`、`loadPipelineStats`、`refreshFileStatus` 中的 `f.status =` 賦值)
- 唯一寫入點:`syncFileStatus()` 將 Core API 回傳值寫入 `fileStatusCache` + `f.status`
- 顯示函數:`LibraryView.vue` line 1274 (`statusFromDB`), 1283 (`statusBadgeClass`), 1291 (`statusBadgeIcon`), 1299 (`statusBadgeLabel`)

46
src-tauri/Cargo.lock generated
View File

@@ -3590,9 +3590,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.186"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libdbus-sys"
@@ -3731,6 +3731,18 @@ dependencies = [
"twox-hash",
]
[[package]]
name = "macmon"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5df04a421e292536cc4dde4c1b0d783084d7aed8e7151aacc9aadee629473556"
dependencies = [
"core-foundation 0.10.1",
"libc",
"serde",
"serde_json",
]
[[package]]
name = "markbase-core"
version = "0.2.0"
@@ -3964,6 +3976,7 @@ dependencies = [
"image",
"kamadak-exif",
"lru 0.12.5",
"macmon",
"markbase-core",
"rand 0.8.6",
"reqwest 0.11.27",
@@ -6142,9 +6155,9 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
@@ -6174,22 +6187,22 @@ dependencies = [
[[package]]
name = "serde_core"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn 3.0.4",
]
[[package]]
@@ -6205,9 +6218,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.150"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
@@ -7041,6 +7054,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "0.1.2"

View File

@@ -41,6 +41,9 @@ rand = "0.8"
# MarkBase Core for Admin/Client GUI
markbase-core = { path = "../../markbase/markbase-core" }
[target.'cfg(target_arch = "aarch64")'.dependencies]
macmon = { version = "0.8", default-features = false }
[build-dependencies]
tauri-build = { version = "2", features = [] }

View File

@@ -4,6 +4,40 @@ mod proxy;
#[path = "../db.rs"]
mod db;
#[path = "../validate.rs"]
mod validate;
#[derive(serde::Serialize, serde::Deserialize)]
pub struct SystemStatsResponse {
pub cpu_used_percent: f64,
pub memory_used_percent: f64,
pub memory_available_mb: u64,
pub memory_total_mb: u64,
pub swap_used_percent: f64,
pub swap_available_mb: u64,
pub swap_total_mb: u64,
pub gpu_available: bool,
pub gpu_utilization: Option<f64>,
pub gpu_memory_used_pct: Option<f64>,
pub ane_available: bool,
pub ane_utilization: Option<f64>,
pub source: String,
pub chip_name: Option<String>,
pub cpu_power: Option<f64>,
pub gpu_power: Option<f64>,
pub ane_power: Option<f64>,
pub sys_power: Option<f64>,
pub ram_power: Option<f64>,
pub cpu_temp: Option<f64>,
pub gpu_temp: Option<f64>,
pub ecpu_freq_mhz: Option<u32>,
pub pcpu_freq_mhz: Option<u32>,
pub gpu_freq_mhz: Option<u32>,
pub ecpu_cores: Option<u8>,
pub pcpu_cores: Option<u8>,
pub gpu_cores: Option<u8>,
}
#[tokio::main]
async fn main() {
let _ = db::init_db();

View File

@@ -24,6 +24,16 @@ CREATE TABLE IF NOT EXISTS bookmarks (
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS video_marks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_uuid TEXT NOT NULL,
tag TEXT NOT NULL,
start_frame INTEGER NOT NULL,
end_frame INTEGER,
note TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS app_users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
@@ -36,6 +46,7 @@ CREATE TABLE IF NOT EXISTS app_users (
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);
CREATE INDEX IF NOT EXISTS idx_video_marks_file ON video_marks(file_uuid);
";
static DB_CONN: Mutex<Option<Connection>> = Mutex::new(None);
@@ -281,6 +292,75 @@ pub fn delete_bookmark(id: i64) -> Result<(), String> {
Ok(())
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VideoMark {
pub id: i64,
pub file_uuid: String,
pub tag: String,
pub start_frame: i64,
pub end_frame: Option<i64>,
pub note: Option<String>,
pub created_at: Option<String>,
}
#[tauri::command(rename_all = "camelCase")]
pub fn get_video_marks(file_uuid: String) -> Result<Vec<VideoMark>, String> {
let conn = get_conn()?;
let mut stmt = conn
.prepare("SELECT id, file_uuid, tag, start_frame, end_frame, note, created_at FROM video_marks WHERE file_uuid = ?1 ORDER BY start_frame ASC")
.map_err(|e| format!("Prepare error: {}", e))?;
let items = stmt
.query_map([file_uuid], |row| {
Ok(VideoMark {
id: row.get(0)?,
file_uuid: row.get(1)?,
tag: row.get(2)?,
start_frame: row.get(3)?,
end_frame: row.get(4)?,
note: row.get(5)?,
created_at: row.get(6)?,
})
})
.map_err(|e| format!("Query error: {}", e))?
.filter_map(|r| r.ok())
.collect();
Ok(items)
}
#[tauri::command(rename_all = "camelCase")]
pub fn save_video_mark(
file_uuid: String,
tag: String,
start_frame: i64,
end_frame: Option<i64>,
note: Option<String>,
) -> Result<VideoMark, String> {
let conn = get_conn()?;
conn.execute(
"INSERT INTO video_marks (file_uuid, tag, start_frame, end_frame, note) VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![file_uuid, tag, start_frame, end_frame, note],
)
.map_err(|e| format!("Insert error: {}", e))?;
let id = conn.last_insert_rowid();
Ok(VideoMark {
id,
file_uuid,
tag,
start_frame,
end_frame,
note,
created_at: None,
})
}
#[tauri::command(rename_all = "camelCase")]
pub fn delete_video_mark(id: i64) -> Result<(), String> {
let conn = get_conn()?;
conn.execute("DELETE FROM video_marks WHERE id = ?1", [id])
.map_err(|e| format!("Delete error: {}", e))?;
Ok(())
}
pub fn verify_user(username: &str, password: &str) -> Result<Option<User>, String> {
let conn = get_conn()?;
let mut stmt = conn

View File

@@ -4,6 +4,7 @@ use dirs;
use markbase_core::vfs::VfsBackend;
mod proxy;
mod db;
mod validate;
use serde::Serialize;
use base64::{Engine as _, engine::general_purpose::STANDARD};
@@ -12,7 +13,6 @@ use std::sync::{Arc, Mutex};
use std::num::NonZeroUsize;
use lru::LruCache;
use image::GenericImageView;
use image::DynamicImage;
use tokio::sync::Semaphore;
static THUMB_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
@@ -242,7 +242,7 @@ async fn get_files(args: GetFilesArgs) -> Result<Vec<FileInfo>, String> {
modified_time: f["modified_time"].as_str().unwrap_or("").to_string(),
is_registered: is_reg,
registration_time: f["registration_time"].as_str().map(|s| s.to_string()),
ingested: status == "completed",
ingested: matches!(status.as_str(), "completed" | "keyword_ready" | "semantic_ready" | "face_mgmt_ready" | "agent_ready"),
status,
})
})
@@ -999,14 +999,17 @@ struct CreateIdentityFromFaceResult {
}
#[tauri::command(rename_all = "camelCase")]
async fn create_identity_from_face(name: String, _face_id: Option<String>, _face_row_id: Option<i64>, file_uuid: String) -> Result<CreateIdentityFromFaceResult, String> {
async fn create_identity_from_face(name: String, _face_id: Option<String>, _face_row_id: Option<i64>, file_uuid: String, trace_ids: Option<Vec<i64>>) -> Result<CreateIdentityFromFaceResult, String> {
let client = get_client();
// Use POST /api/v1/file/:file_uuid/pending-person to create pending identity
let url = format!("{}/api/v1/file/{}/pending-person?api_key={}", CORE_API, file_uuid, API_KEY);
let body = serde_json::json!({
let mut body = serde_json::json!({
"name": name
});
if let Some(tids) = &trace_ids {
body["trace_ids"] = serde_json::json!(tids);
}
let resp = client.post(&url)
.json(&body)
@@ -1293,6 +1296,16 @@ async fn merge_history(source_uuid: Option<String>, target_uuid: Option<String>,
#[tauri::command(rename_all = "camelCase")]
async fn stop_service(name: String) -> Result<String, String> {
if name == "Worker" || name == "worker" {
// Worker has no port — stop via pidfile / pkill
let output = std::process::Command::new("sh")
.arg("-c")
.arg("sleep 0.5; WPID=$(cat /Users/accusys/momentry_core/logs/worker_3002.pid 2>/dev/null); if [ -n \"$WPID\" ]; then pkill -P $WPID 2>/dev/null; kill -9 $WPID 2>/dev/null; fi; pkill -9 -f 'target/release/momentry worker' 2>/dev/null; rm -f /Users/accusys/momentry_core/logs/worker_3002.pid; echo 'stopped'")
.output()
.map_err(|e| format!("Failed to stop worker: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
return Ok(stdout.trim().to_string());
}
let port = match name.as_str() {
"Core API" | "core" => 3002,
"MarkBase" | "markbase" => 11438,
@@ -1313,9 +1326,31 @@ async fn stop_service(name: String) -> Result<String, String> {
#[tauri::command(rename_all = "camelCase")]
async fn start_service(name: String) -> Result<String, String> {
if name == "Worker" || name == "worker" {
// Worker has no port — verify via pidfile instead of lsof
let output = std::process::Command::new("sh")
.arg("-c")
.arg("cd /Users/accusys/momentry_core && nohup bash run-worker-3002.sh > /tmp/momentry_worker.log 2>&1 &")
.output()
.map_err(|e| format!("Failed to start worker: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Worker start failed: {}", stderr));
}
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let check = std::process::Command::new("sh")
.arg("-c")
.arg("WPID=$(cat /Users/accusys/momentry_core/logs/worker_3002.pid 2>/dev/null); [ -n \"$WPID\" ] && kill -0 $WPID 2>/dev/null && echo alive")
.output()
.map_err(|e| format!("Worker check failed: {}", e))?;
if String::from_utf8_lossy(&check.stdout).contains("alive") {
return Ok("started".to_string());
}
return Err("Worker failed to start".to_string());
}
let cmd = match name.as_str() {
"MarkBase" | "markbase" => {
"cd /Users/accusys/markbase && nohup cargo run --bin markbase-core -- web-start --port 11438 > /tmp/markbase_web.log 2>&1 &"
"mkdir -p /Users/accusys/markbase/data && cd /Users/accusys/markbase && nohup ./target/release/markbase-core web-start --port 11438 > /tmp/markbase_web.log 2>&1 &"
}
"Core API" | "core" => {
"cd /Users/accusys/momentry_core && nohup bash run-server-3002.sh > /tmp/momentry_start.log 2>&1 &"
@@ -1405,48 +1440,6 @@ async fn get_current_user(username: String) -> Result<Option<db::User>, String>
db::get_user_by_username(&username)
}
#[tauri::command(rename_all = "camelCase")]
async fn list_admin_users() -> Result<serde_json::Value, String> {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/Users/accusys"));
let db_path = home.join("momentry/data/auth.sqlite");
let conn = rusqlite::Connection::open(&db_path)
.map_err(|e| format!("Failed to open database: {}", e))?;
let mut stmt = conn.prepare("SELECT username, home_dir, status FROM sftpgo_users")
.map_err(|e| format!("Failed to prepare query: {}", e))?;
let users = stmt.query_map([], |row| {
Ok(serde_json::json!({
"username": row.get::<_, String>(0)?,
"homeDir": row.get::<_, String>(1)?,
"status": row.get::<_, i32>(2)?
}))
}).map_err(|e| format!("Failed to query: {}", e))?;
let result: Vec<serde_json::Value> = users.filter_map(|u| u.ok()).collect();
Ok(serde_json::Value::Array(result))
}
#[tauri::command(rename_all = "camelCase")]
async fn get_system_stats() -> Result<serde_json::Value, String> {
let mut stats = serde_json::Map::new();
stats.insert("cpu".to_string(), serde_json::Value::Number(25.into()));
stats.insert("memory".to_string(), serde_json::Value::Number(60.into()));
stats.insert("disk".to_string(), serde_json::Value::Number(45.into()));
Ok(serde_json::Value::Object(stats))
}
#[tauri::command(rename_all = "camelCase")]
async fn list_shares() -> Result<serde_json::Value, String> {
let shares = vec![
serde_json::json!({"name": "MarkBase Web", "port": 11438, "status": "running", "protocol": "HTTP/WebDAV", "url": "http://localhost:11438"}),
serde_json::json!({"name": "SFTP", "port": 2024, "status": "running", "protocol": "SSH/SFTP"}),
serde_json::json!({"name": "SMB", "port": 4445, "status": "running", "protocol": "SMB"}),
];
Ok(serde_json::Value::Array(shares))
}
// === Client Commands ===
#[tauri::command(rename_all = "camelCase")]
@@ -1509,6 +1502,29 @@ fn main() {
rt.block_on(proxy::start_proxy_server());
});
fn ensure_markbase_running() {
let check = std::process::Command::new("sh")
.arg("-c")
.arg("lsof -ti :11438")
.output();
if let Ok(output) = check {
if output.stdout.is_empty() {
let cmd = "mkdir -p /Users/accusys/markbase/data && \
cd /Users/accusys/markbase && \
nohup ./target/release/markbase-core web-start --port 11438 \
> /tmp/markbase_web.log 2>&1 &";
let _ = std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.spawn();
eprintln!("[startup] MarkBase started on port 11438");
} else {
eprintln!("[startup] MarkBase already running on port 11438");
}
}
}
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.setup(|app| {
@@ -1517,6 +1533,7 @@ fn main() {
if let Err(e) = db::init_db() {
eprintln!("Failed to init database: {}", e);
}
ensure_markbase_running();
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -1568,11 +1585,11 @@ fn main() {
db::get_bookmarks,
db::save_bookmark,
db::delete_bookmark,
db::get_video_marks,
db::save_video_mark,
db::delete_video_mark,
login_user,
get_current_user,
list_admin_users,
get_system_stats,
list_shares,
list_client_files,
mkdir_client,
rm_client,

View File

@@ -1,22 +1,25 @@
use axum::{
body::Body,
extract::State,
extract::{State, Query},
http::{HeaderValue, StatusCode},
response::{IntoResponse, Response},
Router,
};
use tower_http::cors::{Any, CorsLayer};
use crate::db;
#[path = "validate.rs"]
mod validate;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use image::GenericImageView;
use image::DynamicImage;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::num::NonZeroUsize;
use lru::LruCache;
use tokio::sync::Semaphore;
const CORE_API: &str = "http://localhost:3002";
const CORE_API: &str = "http://127.0.0.1:3002";
const MARKBASE_API: &str = "http://127.0.0.1:11438";
const API_KEY: &str = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69";
const DIST_DIR: &str = "../dist";
const PROFILE_DIRS: [&str; 4] = [
@@ -28,7 +31,6 @@ const PROFILE_DIRS: [&str; 4] = [
static FACE_THUMB_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
static PROFILE_PROXY_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
static RAW_DIM_PROXY_CACHE: Mutex<Option<HashMap<String, (u32, u32)>>> = Mutex::new(None);
static THUMB_SEM_MAP_PROXY: Mutex<Option<HashMap<String, Arc<Semaphore>>>> = Mutex::new(None);
fn acquire_thumb_semaphore(uuid: &str) -> Arc<Semaphore> {
@@ -84,11 +86,16 @@ pub async fn start_proxy_server() {
.route("/api/v1/face-thumbnail", axum::routing::get(get_face_thumbnail_handler))
.route("/api/v1/media/frame", axum::routing::get(get_frame_handler))
.route("/api/v1/identity-matches", axum::routing::get(get_identity_matches_handler))
.route("/api/v1/trace-profile-file", axum::routing::get(get_trace_profile_file_handler))
.route("/api/v1/cluster-results", axum::routing::get(get_cluster_results_handler))
.route("/api/v1/processor-json", axum::routing::get(get_processor_json_handler))
.route("/api/v1/file/thumbnail", axum::routing::get(get_file_thumbnail_by_path_handler))
.route("/api/v1/file/:file_uuid/pose", axum::routing::get(get_pose_handler))
.route("/api/v1/file/:file_uuid/appearance", axum::routing::get(get_appearance_handler))
.route("/api/v1/admin/config", axum::routing::get(get_admin_config_handler))
.route("/api/v1/admin/config", axum::routing::post(save_admin_config_handler))
.route("/api/v1/system-stats", axum::routing::get(get_system_stats_handler))
.route("/api/v1/system-stats/validate", axum::routing::get(validate_system_stats_handler))
.fallback(fallback_handler)
.layer(cors)
.with_state(state);
@@ -206,7 +213,7 @@ async fn fallback_handler(State(state): State<ProxyState>, req: axum::extract::R
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;
let response = proxy_to(state, req, CORE_API, true).await;
// Invalidate profile cache on successful upload
let status = response.status();
if status == StatusCode::OK || status == StatusCode::CREATED {
@@ -220,12 +227,14 @@ async fn fallback_handler(State(state): State<ProxyState>, req: axum::extract::R
}
// Handle search-history CRUD locally
if path.starts_with("/api/v1/search-history") || path.starts_with("/api/v1/bookmarks") {
if path.starts_with("/api/v1/search-history") || path.starts_with("/api/v1/bookmarks") || path.starts_with("/api/v1/video-marks") || path.starts_with("/api/v1/video-mark/") {
return handle_local_api(req).await;
}
if path.starts_with("/api/") {
return proxy_api(state, req).await;
if path.starts_with("/api/v2/") {
return proxy_to(state, req, MARKBASE_API, false).await;
} else if path.starts_with("/api/") {
return proxy_to(state, req, CORE_API, true).await;
} else if state.serve_static {
return serve_spa(&path).await;
} else {
@@ -235,6 +244,7 @@ async fn fallback_handler(State(state): State<ProxyState>, req: axum::extract::R
async fn handle_local_api(req: axum::extract::Request) -> Response {
let path = req.uri().path().to_string();
let query_str = req.uri().query().unwrap_or("").to_string();
let method = req.method().clone();
let body_bytes = axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await.unwrap_or_default();
@@ -271,26 +281,46 @@ async fn handle_local_api(req: axum::extract::Request) -> Response {
return save_bookmark_handler_inner(body_bytes);
}
// Video marks (local SQLite)
if method == axum::http::Method::GET && path == "/api/v1/video-marks" {
return get_video_marks_handler_inner(&query_str);
}
if method == axum::http::Method::POST && path == "/api/v1/video-marks" {
return save_video_mark_handler_inner(body_bytes);
}
if method == axum::http::Method::DELETE {
if let Some(id_str) = path.strip_prefix("/api/v1/video-mark/") {
if let Ok(id) = id_str.parse::<i64>() {
return delete_video_mark_handler_inner(id);
}
}
}
(StatusCode::NOT_FOUND, "Not found").into_response()
}
async fn proxy_api(state: ProxyState, req: axum::extract::Request) -> Response {
async fn proxy_to(state: ProxyState, req: axum::extract::Request, base: &str, inject_api_key: bool) -> 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);
let mut url = if inject_api_key {
format!("{}{}?api_key={}", base, path, API_KEY)
} else {
format!("{}{}", base, path)
};
for pair in query_str.split('&') {
if pair.is_empty() || pair.starts_with("api_key=") {
if pair.is_empty() || (inject_api_key && pair.starts_with("api_key=")) {
continue;
}
url.push_str(&format!("&{}", pair));
}
let target = if base == CORE_API { "core api" } else { "markbase" };
let range_hdr = headers.get("range").and_then(|v| v.to_str().ok()).unwrap_or("-");
eprintln!("[proxy] --> {} {} (core api) range={}", method, path, range_hdr);
eprintln!("[proxy] --> {} {} ({}) range={}", method, path, target, range_hdr);
let reqwest_method = match method.as_str() {
"POST" => reqwest::Method::POST,
@@ -537,6 +567,45 @@ fn delete_bookmark_handler_inner(id: i64) -> Response {
}
}
fn get_video_marks_handler_inner(query_str: &str) -> Response {
let file_uuid = query_str
.split('&')
.find_map(|pair| {
let mut it = pair.splitn(2, '=');
let k = it.next().unwrap_or("");
let v = it.next().unwrap_or("");
if k == "file_uuid" { Some(v.to_string()) } else { None }
})
.unwrap_or_default();
match db::get_video_marks(file_uuid) {
Ok(items) => axum::Json(items).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
fn save_video_mark_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 file_uuid = req.get("file_uuid").and_then(|v| v.as_str()).unwrap_or("").to_string();
let tag = req.get("tag").and_then(|v| v.as_str()).unwrap_or("default").to_string();
let start_frame = req.get("start_frame").and_then(|v| v.as_i64()).unwrap_or(0);
let end_frame = req.get("end_frame").and_then(|v| v.as_i64());
let note = req.get("note").and_then(|v| v.as_str()).map(|s| s.to_string());
match db::save_video_mark(file_uuid, tag, start_frame, end_frame, note) {
Ok(item) => axum::Json(item).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
fn delete_video_mark_handler_inner(id: i64) -> Response {
match db::delete_video_mark(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('-', "");
@@ -579,33 +648,6 @@ async fn get_identity_profile_handler_inner(uuid: String) -> Response {
}
}
/// Read EXIF orientation and physically rotate pixels, stripping orientation tag.
/// Fetch raw video dimensions from Core API with in-memory cache.
async fn fetch_raw_dimensions(uuid: &str) -> Option<(u32, u32)> {
{
let cache = RAW_DIM_PROXY_CACHE.lock().unwrap();
if let Some(cache) = cache.as_ref() {
if let Some(dims) = cache.get(uuid) {
return Some(*dims);
}
}
}
let url = format!("{}/api/v1/file/{}?api_key={}", CORE_API, uuid, API_KEY);
let result = reqwest::get(&url).await.ok()?
.json::<serde_json::Value>().await.ok()?;
let w = result["width"].as_u64().unwrap_or(0) as u32;
let h = result["height"].as_u64().unwrap_or(0) as u32;
if w == 0 || h == 0 {
return None;
}
{
let mut cache = RAW_DIM_PROXY_CACHE.lock().unwrap();
let cache = cache.get_or_insert_with(HashMap::new);
cache.insert(uuid.to_string(), (w, h));
}
Some((w, h))
}
async fn get_face_thumbnail_handler(State(state): State<ProxyState>, axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
@@ -729,6 +771,46 @@ async fn get_identity_matches_handler(axum::extract::Query(params): axum::extrac
}
}
async fn get_trace_profile_file_handler(axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
let file_uuid = match params.get("file_uuid") {
Some(v) => v.clone(),
None => return (StatusCode::BAD_REQUEST, "Missing file_uuid").into_response(),
};
let trace_id = match params.get("trace_id") {
Some(v) => v.clone(),
None => return (StatusCode::BAD_REQUEST, "Missing trace_id").into_response(),
};
let output_bases = [
"/Users/accusys/momentry/output",
"/Users/accusys/momentry/output_dev",
"/Volumes/external/momentry/output",
"/Volumes/external/momentry/output_dev",
];
let mut profile_path = None;
for base in &output_bases {
let path = format!("{}/{}/trace_{}/trace_profile.json", base, file_uuid, trace_id);
if std::path::Path::new(&path).exists() {
profile_path = Some(path);
break;
}
}
match profile_path {
Some(path) => match std::fs::read_to_string(&path) {
Ok(content) => {
let parsed: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Parse failed: {}", e)).into_response();
}
};
axum::Json(parsed).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)).into_response(),
},
None => (StatusCode::NOT_FOUND, "Trace profile file not found").into_response(),
}
}
async fn get_cluster_results_handler(axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
let file_hash = match params.get("file_hash") {
Some(v) => v.clone(),
@@ -736,29 +818,46 @@ async fn get_cluster_results_handler(axum::extract::Query(params): axum::extract
};
let client = reqwest::Client::new();
let url = format!("{}/api/v1/file/{}/face-groups", CORE_API, file_hash);
let resp = match client
.get(&url)
.header("X-API-Key", API_KEY)
.send()
.await
{
// Try new cluster-results endpoint first, fallback to face-groups
let url = format!("{}/api/v1/cluster-results?file_hash={}", CORE_API, file_hash);
let resp = client.get(&url).header("X-API-Key", API_KEY).send().await;
match resp {
Ok(r) => {
let status = r.status();
let b = r.text().await.unwrap_or_default();
// Check if response has data
if status.is_success() {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&b) {
let fg = v.get("face_groups").and_then(|x| x.as_array()).map(|x| x.len()).unwrap_or(0);
if fg > 0 {
return ([(axum::http::header::CONTENT_TYPE, "application/json")], b).into_response();
}
}
}
}
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Request failed: {}", e)).into_response(),
};
// Fallback to face-groups
let fg_url = format!("{}/api/v1/file/{}/face-groups", CORE_API, file_hash);
let fg_resp = match client.get(&fg_url).header("X-API-Key", API_KEY).send().await {
Ok(r) => r,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Face groups request failed: {}", e)).into_response(),
};
let status = resp.status();
let body = match resp.text().await {
let fg_status = fg_resp.status();
let fg_body = match fg_resp.text().await {
Ok(b) => b,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body read failed: {}", e)).into_response(),
};
let axum_status = axum::http::StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let axum_status = axum::http::StatusCode::from_u16(fg_status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
if !axum_status.is_success() {
return (axum_status, body).into_response();
return (axum_status, fg_body).into_response();
}
let result: serde_json::Value = match serde_json::from_str(&body) {
let result: serde_json::Value = match serde_json::from_str(&fg_body) {
Ok(v) => v,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("JSON parse failed: {}", e)).into_response(),
};
@@ -907,12 +1006,11 @@ async fn get_frame_handler(State(state): State<ProxyState>, axum::extract::Query
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)).into_response(),
}
} else {
let fps = file_info["fps"].as_f64().unwrap_or(24.0);
let time_sec = frame as f64 / fps;
// Use select filter for exact frame extraction instead of -ss seeking
let output = tokio::process::Command::new("ffmpeg")
.args([
"-ss", &time_sec.to_string(),
"-i", &file_path,
"-vf", &format!("select=eq(n\\,{frame})"),
"-vframes", "1",
"-q:v", "2",
"-f", "mjpeg",
@@ -1015,4 +1113,288 @@ async fn get_appearance_handler(
};
(status, body).into_response()
}
}
const ADMIN_CONFIG_PATH: &str = "/Users/accusys/momentry/config/admin_config.json";
async fn get_admin_config_handler() -> Response {
match std::fs::read_to_string(ADMIN_CONFIG_PATH) {
Ok(content) => {
match serde_json::from_str::<serde_json::Value>(&content) {
Ok(v) => axum::Json(v).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Parse failed: {}", e)).into_response(),
}
}
Err(e) => (StatusCode::NOT_FOUND, format!("Config file not found: {}", e)).into_response(),
}
}
async fn save_admin_config_handler(axum::extract::Json(payload): axum::extract::Json<serde_json::Value>) -> Response {
match serde_json::to_string_pretty(&payload) {
Ok(formatted) => {
match std::fs::write(ADMIN_CONFIG_PATH, formatted) {
Ok(()) => axum::Json(serde_json::json!({"success": true, "message": "Config saved"})).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Write failed: {}", e)).into_response(),
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Format failed: {}", e)).into_response(),
}
}
#[cfg(target_arch = "aarch64")]
mod macmon_sampler {
use macmon::Sampler;
pub struct MacmonData {
pub cpu_power: f32,
pub gpu_power: f32,
pub ane_power: f32,
pub sys_power: f32,
pub ram_power: f32,
pub cpu_temp: f32,
pub gpu_temp: f32,
pub ecpu_freq_mhz: u32,
pub pcpu_freq_mhz: u32,
pub gpu_freq_mhz: u32,
pub cpu_scaled_ratio: f32,
pub gpu_scaled_ratio: f32,
pub chip_name: String,
pub ecpu_cores: u8,
pub pcpu_cores: u8,
pub gpu_cores: u8,
pub ram_total: u64,
pub ram_usage: u64,
pub swap_total: u64,
pub swap_used: u64,
}
fn get_swap_info() -> (u64, u64) {
use std::process::Command;
let output = match Command::new("sysctl")
.args(["-n", "vm.swapusage"])
.output()
{
Ok(o) => o,
Err(_) => return (0, 0),
};
let s = String::from_utf8_lossy(&output.stdout);
let parts: Vec<&str> = s.split_whitespace().collect();
let total_mb = parts.get(2)
.and_then(|v| v.trim_end_matches('M').parse::<f64>().ok())
.unwrap_or(0.0) as u64;
let used_mb = parts.get(5)
.and_then(|v| v.trim_end_matches('M').parse::<f64>().ok())
.unwrap_or(0.0) as u64;
(total_mb, used_mb)
}
pub fn get_metrics() -> Option<MacmonData> {
let mut sampler = Sampler::new().ok()?;
let metrics = sampler.get_metrics(100).ok()?;
let soc = sampler.get_soc_info();
let (swap_total, swap_used) = get_swap_info();
Some(MacmonData {
cpu_power: metrics.cpu_power,
gpu_power: metrics.gpu_power,
ane_power: metrics.ane_power,
sys_power: metrics.sys_power,
ram_power: metrics.ram_power,
cpu_temp: metrics.temp.cpu_temp_avg,
gpu_temp: metrics.temp.gpu_temp_avg,
ecpu_freq_mhz: metrics.ecpu_freq_mhz,
pcpu_freq_mhz: metrics.pcpu_freq_mhz,
gpu_freq_mhz: metrics.gpu_freq_mhz,
cpu_scaled_ratio: metrics.cpu_scaled_ratio,
gpu_scaled_ratio: metrics.gpu_scaled_ratio,
chip_name: soc.chip_name.clone(),
ecpu_cores: soc.ecpu_cores,
pcpu_cores: soc.pcpu_cores,
gpu_cores: soc.gpu_cores,
ram_total: metrics.memory.ram_total,
ram_usage: metrics.memory.ram_usage,
swap_total,
swap_used,
})
}
}
#[cfg(not(target_arch = "aarch64"))]
mod macmon_sampler {
pub struct MacmonData;
pub fn get_metrics() -> Option<MacmonData> { None }
}
pub use super::SystemStatsResponse;
async fn get_system_stats_handler() -> Response {
// 1. Try macmon first (Apple Silicon only)
if let Some(m) = macmon_sampler::get_metrics() {
let ram_total_mb = m.ram_total / 1024 / 1024;
let ram_usage_mb = m.ram_usage / 1024 / 1024;
let ram_available_mb = ram_total_mb.saturating_sub(ram_usage_mb);
let memory_used_percent = if ram_total_mb > 0 {
(ram_usage_mb as f64 / ram_total_mb as f64) * 100.0
} else {
0.0
};
let swap_total_mb = m.swap_total;
let swap_used_mb = m.swap_used;
let swap_available_mb = swap_total_mb.saturating_sub(swap_used_mb);
let swap_used_percent = if swap_total_mb > 0 {
(swap_used_mb as f64 / swap_total_mb as f64) * 100.0
} else {
0.0
};
let stats = SystemStatsResponse {
cpu_used_percent: m.cpu_scaled_ratio as f64 * 100.0,
memory_used_percent,
memory_available_mb: ram_available_mb,
memory_total_mb: ram_total_mb,
swap_used_percent,
swap_available_mb,
swap_total_mb,
gpu_available: true,
gpu_utilization: Some(m.gpu_scaled_ratio as f64 * 100.0),
gpu_memory_used_pct: None,
ane_available: m.ane_power > 0.0,
ane_utilization: None,
source: "macmon".to_string(),
chip_name: Some(m.chip_name),
cpu_power: Some(m.cpu_power as f64),
gpu_power: Some(m.gpu_power as f64),
ane_power: Some(m.ane_power as f64),
sys_power: Some(m.sys_power as f64),
ram_power: Some(m.ram_power as f64),
cpu_temp: Some(m.cpu_temp as f64),
gpu_temp: Some(m.gpu_temp as f64),
ecpu_freq_mhz: Some(m.ecpu_freq_mhz),
pcpu_freq_mhz: Some(m.pcpu_freq_mhz),
gpu_freq_mhz: Some(m.gpu_freq_mhz),
ecpu_cores: Some(m.ecpu_cores),
pcpu_cores: Some(m.pcpu_cores),
gpu_cores: Some(m.gpu_cores),
};
return axum::Json(stats).into_response();
}
// 2. Fallback: Use Core API
let client = reqwest::Client::new();
let url = format!("{}/health/detailed", CORE_API);
match client.get(&url).header("api_key", API_KEY).send().await {
Ok(resp) => {
match resp.json::<serde_json::Value>().await {
Ok(data) => {
if let Some(resources) = data.get("resources") {
let stats = SystemStatsResponse {
cpu_used_percent: resources.get("cpu_used_percent").and_then(|v| v.as_f64()).unwrap_or(0.0),
memory_used_percent: resources.get("memory_used_percent").and_then(|v| v.as_f64()).unwrap_or(0.0),
memory_available_mb: resources.get("memory_available_mb").and_then(|v| v.as_u64()).unwrap_or(0),
memory_total_mb: resources.get("memory_total_mb").and_then(|v| v.as_u64()).unwrap_or(0),
swap_used_percent: 0.0,
swap_available_mb: 0,
swap_total_mb: 0,
gpu_available: resources.get("gpu_available").and_then(|v| v.as_bool()).unwrap_or(false),
gpu_utilization: resources.get("gpu_utilization").and_then(|v| v.as_f64()),
gpu_memory_used_pct: resources.get("gpu_memory_used_pct").and_then(|v| v.as_f64()),
ane_available: resources.get("ane_available").and_then(|v| v.as_bool()).unwrap_or(false),
ane_utilization: resources.get("ane_utilization").and_then(|v| v.as_f64()),
source: "fallback".to_string(),
chip_name: None,
cpu_power: None,
gpu_power: None,
ane_power: None,
sys_power: None,
ram_power: None,
cpu_temp: None,
gpu_temp: None,
ecpu_freq_mhz: None,
pcpu_freq_mhz: None,
gpu_freq_mhz: None,
ecpu_cores: None,
pcpu_cores: None,
gpu_cores: None,
};
axum::Json(stats).into_response()
} else {
(StatusCode::INTERNAL_SERVER_ERROR, "No resources in response").into_response()
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Parse failed: {}", e)).into_response(),
}
}
Err(e) => (StatusCode::BAD_GATEWAY, format!("Core API error: {}", e)).into_response(),
}
}
async fn validate_system_stats_handler(
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Response {
// Parse layer parameter
let layers: Vec<u32> = params
.get("layers")
.map(|s| s.split(',').filter_map(|n| n.trim().parse().ok()).collect())
.unwrap_or_else(|| vec![1, 2, 4]);
// Get current macmon stats
let stats = if let Some(m) = macmon_sampler::get_metrics() {
let ram_total_mb = m.ram_total / 1024 / 1024;
let ram_usage_mb = m.ram_usage / 1024 / 1024;
let ram_available_mb = ram_total_mb.saturating_sub(ram_usage_mb);
let memory_used_percent = if ram_total_mb > 0 {
(ram_usage_mb as f64 / ram_total_mb as f64) * 100.0
} else {
0.0
};
let swap_total_mb = m.swap_total;
let swap_used_mb = m.swap_used;
let swap_available_mb = swap_total_mb.saturating_sub(swap_used_mb);
let swap_used_percent = if swap_total_mb > 0 {
(swap_used_mb as f64 / swap_total_mb as f64) * 100.0
} else {
0.0
};
SystemStatsResponse {
cpu_used_percent: m.cpu_scaled_ratio as f64 * 100.0,
memory_used_percent,
memory_available_mb: ram_available_mb,
memory_total_mb: ram_total_mb,
swap_used_percent,
swap_available_mb,
swap_total_mb,
gpu_available: true,
gpu_utilization: Some(m.gpu_scaled_ratio as f64 * 100.0),
gpu_memory_used_pct: None,
ane_available: m.ane_power > 0.0,
ane_utilization: None,
source: "macmon".to_string(),
chip_name: Some(m.chip_name),
cpu_power: Some(m.cpu_power as f64),
gpu_power: Some(m.gpu_power as f64),
ane_power: Some(m.ane_power as f64),
sys_power: Some(m.sys_power as f64),
ram_power: Some(m.ram_power as f64),
cpu_temp: Some(m.cpu_temp as f64),
gpu_temp: Some(m.gpu_temp as f64),
ecpu_freq_mhz: Some(m.ecpu_freq_mhz),
pcpu_freq_mhz: Some(m.pcpu_freq_mhz),
gpu_freq_mhz: Some(m.gpu_freq_mhz),
ecpu_cores: Some(m.ecpu_cores),
pcpu_cores: Some(m.pcpu_cores),
gpu_cores: Some(m.gpu_cores),
}
} else {
return (StatusCode::INTERNAL_SERVER_ERROR, "macmon not available").into_response();
};
// Run validation
let result = super::validate::run_validation(&layers, &stats);
axum::Json(result).into_response()
}

447
src-tauri/src/validate.rs Normal file
View File

@@ -0,0 +1,447 @@
use serde::{Deserialize, Serialize};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::Path;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ValidationStatus {
Pass,
Warn,
Fail,
Unverified,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricValidation {
pub macmon_value: f64,
pub reference_value: f64,
pub diff_pct: f64,
pub status: ValidationStatus,
pub tolerance: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayerValidation {
pub name: String,
pub requires_sudo: bool,
pub metrics: std::collections::HashMap<String, MetricValidation>,
pub summary: ValidationSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationSummary {
pub total: u32,
pub passed: u32,
pub failed: u32,
pub unverified: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationResult {
pub timestamp: String,
pub layers: std::collections::HashMap<String, Option<LayerValidation>>,
pub overall_summary: ValidationSummary,
}
impl ValidationStatus {
fn from_diff(diff_pct: f64, tolerance: f64) -> Self {
if diff_pct <= tolerance {
ValidationStatus::Pass
} else if diff_pct <= tolerance * 2.0 {
ValidationStatus::Warn
} else {
ValidationStatus::Fail
}
}
}
fn get_timestamp() -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let dt = chrono::DateTime::from_timestamp(now as i64, 0).unwrap();
dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
fn write_log(message: &str) {
let log_dir = std::env::var("MOMENTRY_DATA_DIR")
.unwrap_or_else(|_| ".".to_string());
let log_path = Path::new(&log_dir).join("logs").join("validation.log");
if let Some(parent) = log_path.parent() {
let _ = fs::create_dir_all(parent);
}
let timestamp = get_timestamp();
let log_line = format!("{} {}\n", timestamp, message);
if let Ok(mut file) = OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
let _ = file.write_all(log_line.as_bytes());
}
}
// ==================== Layer 1: Activity Monitor Validation ====================
// CPU formula decision (2026-08-31):
// - macmon uses `cpu_scaled_ratio` (frequency-weighted: E-core work counts less due to lower freq)
// - `top` uses simple user+sys ratio (no frequency weighting)
// - These are intentionally different formulas; macmon's approach is more accurate for "actual work done"
// - Therefore, CPU validation is marked as Unverified (different formula, not comparable)
pub fn validate_cpu_usage_scaled_ratio() -> Option<MetricValidation> {
write_log("[INFO] Layer 1: CPU uses cpu_scaled_ratio (frequency-weighted) - not comparable to top");
let macmon_value = 0.0; // Will be filled by caller
write_log(&format!(
"[INFO] Layer 1 CPU: macmon cpu_scaled_ratio={:.1}% (frequency-weighted formula)",
macmon_value
));
Some(MetricValidation {
macmon_value,
reference_value: 0.0,
diff_pct: 0.0,
status: ValidationStatus::Unverified,
tolerance: 0.0,
})
}
// Memory formula decision (2026-08-31):
// - macmon uses its own memory calculation (ram_usage from crate)
// - Activity Monitor uses: wired + active + inactive + speculative - purgeable
// - These formulas are intentionally different; macmon's approach is chosen as the baseline
// - Therefore, Memory Used validation is marked as Unverified (different formula, not comparable)
pub fn validate_memory_used_macmon_formula() -> Option<MetricValidation> {
write_log("[INFO] Layer 1: Memory uses macmon formula - not comparable to Activity Monitor");
let macmon_value = 0.0; // Will be filled by caller
write_log(&format!(
"[INFO] Layer 1 Memory Used: macmon={:.1}% (macmon formula)",
macmon_value
));
Some(MetricValidation {
macmon_value,
reference_value: 0.0,
diff_pct: 0.0,
status: ValidationStatus::Unverified,
tolerance: 0.0,
})
}
pub fn validate_swap_via_sysctl() -> Option<MetricValidation> {
write_log("[INFO] Layer 1: Validating Swap via sysctl");
let output = Command::new("sysctl")
.args(["-n", "vm.swapusage"])
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
// Parse: total = 9216.00M used = 8420.00M free = 796.00M (encrypted)
let parts: Vec<&str> = stdout.split_whitespace().collect();
let total_mb: f64 = parts.get(2)
.and_then(|v| v.trim_end_matches('M').parse().ok())
.unwrap_or(0.0);
let used_mb: f64 = parts.get(5)
.and_then(|v| v.trim_end_matches('M').parse().ok())
.unwrap_or(0.0);
let used_pct = if total_mb > 0.0 {
(used_mb / total_mb) * 100.0
} else {
0.0
};
let macmon_value = 0.0; // Will be filled by caller
let tolerance = 1.0;
let diff_pct = (macmon_value - used_pct).abs();
write_log(&format!(
"[INFO] Layer 1 Swap: macmon={:.1}%, sysctl={:.1}%, diff={:.2}%",
macmon_value, used_pct, diff_pct
));
Some(MetricValidation {
macmon_value,
reference_value: used_pct,
diff_pct,
status: ValidationStatus::from_diff(diff_pct, tolerance),
tolerance,
})
}
pub fn validate_memory_total_via_sysctl() -> Option<MetricValidation> {
write_log("[INFO] Layer 1: Validating Memory Total via sysctl");
let output = Command::new("sysctl")
.args(["-n", "hw.memsize"])
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let total_bytes: u64 = stdout.trim().parse().ok()?;
let total_mb = total_bytes as f64 / (1024.0 * 1024.0);
let macmon_value = 0.0; // Will be filled by caller
let tolerance = 0.0; // Exact match expected
let diff_pct = (macmon_value - total_mb).abs();
write_log(&format!(
"[INFO] Layer 1 Memory Total: macmon={:.0}MB, sysctl={:.0}MB, diff={:.0}MB",
macmon_value, total_mb, diff_pct
));
Some(MetricValidation {
macmon_value,
reference_value: total_mb,
diff_pct,
status: ValidationStatus::from_diff(diff_pct, 0.1), // Very small tolerance
tolerance: 0.1,
})
}
// ==================== Layer 2: System API Validation ====================
pub fn validate_cpu_temp_via_ioreg() -> Option<MetricValidation> {
write_log("[INFO] Layer 2: Validating CPU Temp via ioreg");
let output = Command::new("ioreg")
.args(["-r", "-n", "AppleSMC"])
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
// Look for temperature values
let temp = stdout
.lines()
.find(|line| line.contains("temperature") || line.contains("TC0P") || line.contains("TC0H"))
.and_then(|line| {
// Extract numeric value
let num_str: String = line
.chars()
.filter(|c| c.is_numeric() || *c == '.' || *c == '-')
.collect();
num_str.parse::<f64>().ok()
})
.unwrap_or(0.0);
let macmon_value = 0.0; // Will be filled by caller
let tolerance = 1.0;
let diff_pct = (macmon_value - temp).abs();
write_log(&format!(
"[INFO] Layer 2 CPU Temp: macmon={:.1}°C, ioreg={:.1}°C, diff={:.1}°C",
macmon_value, temp, diff_pct
));
Some(MetricValidation {
macmon_value,
reference_value: temp,
diff_pct,
status: ValidationStatus::from_diff(diff_pct, tolerance),
tolerance,
})
}
pub fn validate_thermal_state_via_pmset() -> Option<MetricValidation> {
write_log("[INFO] Layer 2: Validating Thermal State via pmset");
let output = Command::new("pmset")
.args(["-g", "therm"])
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
// Parse thermal state
let state = if stdout.contains("CPU Power Load") {
"Nominal"
} else if stdout.contains("Warning") {
"Warning"
} else {
"Nominal"
};
// For thermal state, we just log the result (it's a string comparison)
write_log(&format!("[INFO] Layer 2 Thermal State: {}", state));
None // Return None for string-based metrics
}
// ==================== Main Validation Function ====================
pub fn run_validation(layers: &[u32], macmon_stats: &super::SystemStatsResponse) -> ValidationResult {
write_log(&format!("[INFO] Validation started: layers={:?}", layers));
let mut result = ValidationResult {
timestamp: get_timestamp(),
layers: std::collections::HashMap::new(),
overall_summary: ValidationSummary {
total: 0,
passed: 0,
failed: 0,
unverified: 0,
},
};
// Layer 1: Activity Monitor
if layers.contains(&1) {
let mut layer1 = LayerValidation {
name: "Activity Monitor".to_string(),
requires_sudo: false,
metrics: std::collections::HashMap::new(),
summary: ValidationSummary {
total: 0,
passed: 0,
failed: 0,
unverified: 0,
},
};
// CPU - Unverified (different formula)
if let Some(mut val) = validate_cpu_usage_scaled_ratio() {
val.macmon_value = macmon_stats.cpu_used_percent;
layer1.metrics.insert("cpu_usage".to_string(), val);
}
// Memory Used - Unverified (different formula)
if let Some(mut val) = validate_memory_used_macmon_formula() {
val.macmon_value = macmon_stats.memory_used_percent;
layer1.metrics.insert("memory_used_percent".to_string(), val);
}
// Memory Total
if let Some(mut val) = validate_memory_total_via_sysctl() {
val.macmon_value = macmon_stats.memory_total_mb as f64;
val.diff_pct = (val.macmon_value - val.reference_value).abs();
val.status = ValidationStatus::from_diff(val.diff_pct, val.tolerance);
layer1.metrics.insert("memory_total_mb".to_string(), val);
}
// Swap
if macmon_stats.swap_total_mb > 0 {
if let Some(mut val) = validate_swap_via_sysctl() {
val.macmon_value = macmon_stats.swap_used_percent;
val.diff_pct = (val.macmon_value - val.reference_value).abs();
val.status = ValidationStatus::from_diff(val.diff_pct, val.tolerance);
layer1.metrics.insert("swap_used_percent".to_string(), val);
}
}
// Calculate summary
for (_, metric) in &layer1.metrics {
layer1.summary.total += 1;
result.overall_summary.total += 1;
match metric.status {
ValidationStatus::Pass => {
layer1.summary.passed += 1;
result.overall_summary.passed += 1;
}
ValidationStatus::Warn | ValidationStatus::Fail => {
layer1.summary.failed += 1;
result.overall_summary.failed += 1;
}
ValidationStatus::Unverified => {
layer1.summary.unverified += 1;
result.overall_summary.unverified += 1;
}
}
}
result.layers.insert("layer1".to_string(), Some(layer1));
}
// Layer 2: System API
if layers.contains(&2) {
let mut layer2 = LayerValidation {
name: "System API".to_string(),
requires_sudo: false,
metrics: std::collections::HashMap::new(),
summary: ValidationSummary {
total: 0,
passed: 0,
failed: 0,
unverified: 0,
},
};
// CPU Temp
if let Some(temp) = macmon_stats.cpu_temp {
if let Some(mut val) = validate_cpu_temp_via_ioreg() {
val.macmon_value = temp;
val.diff_pct = (val.macmon_value - val.reference_value).abs();
val.status = ValidationStatus::from_diff(val.diff_pct, val.tolerance);
layer2.metrics.insert("cpu_temp".to_string(), val);
}
}
// Calculate summary
for (_, metric) in &layer2.metrics {
layer2.summary.total += 1;
result.overall_summary.total += 1;
match metric.status {
ValidationStatus::Pass => {
layer2.summary.passed += 1;
result.overall_summary.passed += 1;
}
ValidationStatus::Warn | ValidationStatus::Fail => {
layer2.summary.failed += 1;
result.overall_summary.failed += 1;
}
ValidationStatus::Unverified => {
layer2.summary.unverified += 1;
result.overall_summary.unverified += 1;
}
}
}
result.layers.insert("layer2".to_string(), Some(layer2));
}
// Layer 3: Requires sudo (not implemented in this phase)
if layers.contains(&3) {
result.layers.insert("layer3".to_string(), None);
write_log("[INFO] Layer 3 validation not implemented yet");
}
// Layer 4: Unverified
if layers.contains(&4) {
let layer4 = LayerValidation {
name: "Unverified".to_string(),
requires_sudo: false,
metrics: std::collections::HashMap::new(),
summary: ValidationSummary {
total: 3,
passed: 0,
failed: 0,
unverified: 3,
},
};
result.layers.insert("layer4".to_string(), Some(layer4));
result.overall_summary.total += 3;
result.overall_summary.unverified += 3;
}
write_log(&format!(
"[INFO] Validation completed: {}/{} passed, {} failed, {} unverified",
result.overall_summary.passed,
result.overall_summary.total,
result.overall_summary.failed,
result.overall_summary.unverified
));
result
}

View File

@@ -6,9 +6,14 @@ import BuildVersion from './components/BuildVersion.vue'
import { loadUserBehavior } from '@/stores/agentStore'
import { useI18n } from 'vue-i18n'
import { changeLanguage, getCurrentLanguage } from './locales'
import { useAdvancedMode } from '@/stores/advancedMode'
import { useTheme } from '@/stores/themeStore'
const { t } = useI18n()
const router = useRouter()
const { isAdvanced, toggleAdvanced } = useAdvancedMode()
const { theme, toggleTheme } = useTheme()
const appZoom = ref(100)
const loggedInUser = ref(localStorage.getItem('username') || '')
const currentLanguage = ref(getCurrentLanguage())
@@ -16,10 +21,25 @@ const currentLanguage = ref(getCurrentLanguage())
const sidebarWidth = ref(240)
const isDragging = ref(false)
const sidebarCollapsed = ref(true)
const isMobile = ref(window.innerWidth < 768)
const mobileOpen = ref(false)
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
localStorage.setItem('sidebar_collapsed', String(sidebarCollapsed.value))
if (isMobile.value) {
mobileOpen.value = !mobileOpen.value
} else {
sidebarCollapsed.value = !sidebarCollapsed.value
localStorage.setItem('sidebar_collapsed', String(sidebarCollapsed.value))
}
}
function closeMobileDrawer() {
mobileOpen.value = false
}
function handleResize() {
isMobile.value = window.innerWidth < 768
if (!isMobile.value) mobileOpen.value = false
}
function refreshUser() {
@@ -28,6 +48,7 @@ function refreshUser() {
}
function handleLogout() {
if (!confirm('Are you sure you want to logout?')) return
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('expires_at')
@@ -81,6 +102,7 @@ onMounted(() => {
document.addEventListener('keydown', handleKeydown)
document.addEventListener('mousemove', handleDrag)
document.addEventListener('mouseup', stopDrag)
window.addEventListener('resize', handleResize)
refreshUser()
loadUserBehavior()
@@ -93,18 +115,21 @@ onMounted(() => {
watch(() => router.currentRoute.value.path, () => {
refreshUser()
closeMobileDrawer()
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('mousemove', handleDrag)
document.removeEventListener('mouseup', stopDrag)
window.removeEventListener('resize', handleResize)
})
</script>
<template>
<BuildVersion />
<div id="app" class="ms-app" :data-current-login="'guest'">
<aside class="ms-side" :class="{ collapsed: sidebarCollapsed }" :style="{ width: sidebarCollapsed ? '62px' : sidebarWidth + 'px', minWidth: sidebarCollapsed ? '62px' : sidebarWidth + 'px' }">
<div id="app" class="ms-app" :class="{ 'ms-app-mobile': isMobile }" :data-current-login="'guest'">
<div v-if="isMobile && mobileOpen" class="ms-overlay" @click="closeMobileDrawer"></div>
<aside class="ms-side" :class="{ collapsed: !isMobile && sidebarCollapsed, 'mobile-open': isMobile && mobileOpen }" :style="isMobile ? {} : { width: sidebarCollapsed ? '62px' : sidebarWidth + 'px', minWidth: sidebarCollapsed ? '62px' : sidebarWidth + 'px' }">
<div class="gs-sidebar-toggle" @click="toggleSidebar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
:style="{ transform: sidebarCollapsed ? 'rotate(0deg)' : 'rotate(180deg)' }">
@@ -129,14 +154,6 @@ onUnmounted(() => {
</svg>
<span class="gs-nav-label">{{ t('nav.library') }}</span>
</router-link>
<router-link to="/face" class="gs-nav-item" active-class="active">
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="12" cy="10" r="4"/>
<path d="M6 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>
</svg>
<span class="gs-nav-label">Face</span>
</router-link>
<router-link to="/admin" class="gs-nav-item" active-class="active">
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
@@ -153,12 +170,18 @@ onUnmounted(() => {
</nav>
<div class="gs-divider" :class="{ 'gs-hidden': sidebarCollapsed }"></div>
<div class="gs-footer" :class="{ 'gs-hidden': sidebarCollapsed }">
<div class="gs-lang-switcher">
<div class="gs-controls">
<select v-model="currentLanguage" @change="onChangeLanguage" class="gs-lang-select">
<option value="en">English</option>
<option value="zh-TW">繁體中文</option>
<option value="auto">Auto Detect</option>
<option value="en">{{ $t('language.en') }}</option>
<option value="zh-TW">{{ $t('language.zh-TW') }}</option>
<option value="ja">{{ $t('language.ja') }}</option>
<option value="auto">{{ $t('language.auto') }}</option>
</select>
<button class="gs-control-btn" :class="{ 'gs-control-active': isAdvanced }" :title="isAdvanced ? 'Advanced Mode: ON' : 'Advanced Mode: OFF'" @click="toggleAdvanced">
<svg v-if="isAdvanced" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 7.9-2"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
</button>
<button class="gs-control-btn" :title="theme === 'dark' ? 'Switch to Light' : 'Switch to Dark'" @click="toggleTheme">{{ theme === 'dark' ? '☀️' : '🌙' }}</button>
</div>
<div class="gs-account-row">
<template v-if="loggedInUser">
@@ -181,8 +204,40 @@ onUnmounted(() => {
</aside>
<div class="ms-divider" :class="{ 'ms-divider-hidden': sidebarCollapsed }" @mousedown="startDrag"></div>
<main class="ms-main">
<div v-if="isMobile" class="ms-mobile-header">
<button class="ms-mobile-hamburger" @click="toggleSidebar" aria-label="Menu">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<span class="ms-mobile-title">Momentry Studio</span>
</div>
<router-view />
</main>
<nav v-if="isMobile" class="ms-bottom-nav">
<router-link to="/search" class="ms-bottom-nav-item" active-class="active" @click="closeMobileDrawer">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<span>Search</span>
</router-link>
<router-link to="/library" class="ms-bottom-nav-item" active-class="active" @click="closeMobileDrawer">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
<span>Library</span>
</router-link>
<router-link to="/admin" class="ms-bottom-nav-item" active-class="active" @click="closeMobileDrawer">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<span>Admin</span>
</router-link>
<router-link to="/client" class="ms-bottom-nav-item" active-class="active" @click="closeMobileDrawer">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>
<span>Client</span>
</router-link>
<router-link v-if="!loggedInUser" to="/login" class="ms-bottom-nav-item" active-class="active" @click="closeMobileDrawer">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
<span>Login</span>
</router-link>
<button v-else class="ms-bottom-nav-item ms-bottom-nav-btn-logout" @click="handleLogout">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
<span>Logout</span>
</button>
</nav>
<AgentTip v-if="loggedInUser" />
</div>
</template>
@@ -223,6 +278,10 @@ html, body { height: 100%; margin: 0; }
.gs-nav-icon { width: 22px; height: 22px; flex-shrink: 0; }
.gs-divider { height: 1px; background: var(--border-light); margin: 14px 8px; flex-shrink: 0; }
.gs-footer { margin-top: auto; padding: 10px 8px; border-radius: 12px; background: var(--hover-background); flex-shrink: 0; display: flex; flex-direction: column; gap: 8px; }
.gs-controls { display: flex; align-items: center; gap: 6px; }
.gs-control-btn { width: 28px; height: 28px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--card-background); cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; transition: all 0.15s; color: #888; flex-shrink: 0; filter: grayscale(1); }
.gs-control-btn:hover { background: var(--border-light); border-color: var(--text-secondary); filter: grayscale(0); }
.gs-control-active { color: var(--primary-color); border-color: var(--primary-color); background: var(--primary-color-light, rgba(66,133,244,0.1)); filter: grayscale(0); }
.gs-lang-switcher { width: 100%; }
.gs-lang-select { width: 100%; padding: 6px 8px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--card-background); font-size: 12px; font-family: inherit; color: var(--text-secondary); cursor: pointer; outline: none; }
.gs-lang-select:focus { border-color: var(--text-primary); }
@@ -234,4 +293,36 @@ html, body { height: 100%; margin: 0; }
.gs-login-link:hover { color: var(--text-primary); }
.ms-main { flex: 1; min-width: 0; background: var(--card-background) !important; border-radius: 18px; padding: 36px 42px; overflow-y: auto; overflow-x: hidden; height: 100%; }
.ms-content { max-width: 1200px; }
/* Mobile overlay */
.ms-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 999; }
/* Mobile header */
.ms-mobile-header { display: flex; align-items: center; gap: 10px; padding-bottom: 12px; }
.ms-mobile-hamburger { background: none; border: none; cursor: pointer; color: var(--text-primary); padding: 4px; }
.ms-mobile-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
/* Bottom nav */
.ms-bottom-nav { display: flex; position: fixed; bottom: 0; left: 0; right: 0; z-index: 998;
background: var(--card-background); border-top: 1px solid var(--border-light);
justify-content: space-around; padding: 6px 0 env(safe-area-inset-bottom, 6px) 0;
box-shadow: 0 -2px 8px rgba(0,0,0,0.06); }
.ms-bottom-nav-item { display: flex; flex-direction: column; align-items: center; gap: 2px;
text-decoration: none; color: var(--text-secondary); font-size: 10px; padding: 2px 8px; }
.ms-bottom-nav-item.active { color: var(--primary-color); }
.ms-bottom-nav-item svg { width: 20px; height: 20px; }
.ms-bottom-nav-btn-logout { background: none; border: none; cursor: pointer; }
@media (max-width: 767px) {
.ms-app { padding: 0; max-width: 100%; height: 100dvh; }
.ms-app.ms-app-mobile { padding: 0; }
.ms-side { position: fixed; left: 0; top: 0; bottom: 0; z-index: 1000;
transform: translateX(-100%); transition: transform 0.25s ease;
width: 260px !important; min-width: 260px !important; border-radius: 0 18px 18px 0; }
.ms-side.mobile-open { transform: translateX(0); }
.ms-side .gs-sidebar-toggle { display: none; }
.ms-divider { display: none; }
.ms-main { padding: 12px 12px 60px; border-radius: 0; height: 100dvh; }
.gs-footer-compact { display: none !important; }
}
</style>

View File

@@ -11,6 +11,21 @@ function detectBaseUrl(): string {
return localStorage.getItem('markbase_url') || 'http://localhost:11438'
}
// Poll command status until completed or failed
export async function pollCommandStatus(commandId: string, intervalMs: number = 2000, maxAttempts: number = 150): Promise<any> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const status = await apiCall('get_command_status', { commandId })
if (status?.status === 'completed') {
return status
}
if (status?.status === 'failed') {
throw new Error(status.last_error || 'Command failed')
}
await new Promise(resolve => setTimeout(resolve, intervalMs))
}
throw new Error('Command polling timeout')
}
// API proxy: Tauri IPC or HTTP fetch
export async function apiCall(cmd: string, args: Record<string, any>): Promise<any> {
if (cmd === 'get_video_stream') {
@@ -71,6 +86,31 @@ export async function apiCall(cmd: string, args: Record<string, any>): Promise<a
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
// get_trace_profile / get_trace_profile_file 也沒有 Tauri command
if (cmd === 'get_trace_profile' || cmd === 'get_trace_profile_file') {
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
// get_file_traces 也沒有 Tauri command
if (cmd === 'get_file_traces') {
const data = await httpCall(cmd, args)
return transformResponse(cmd, data, args)
}
// search_file_faces 也沒有 Tauri command
if (cmd === 'search_file_faces') {
const data = await httpCall(cmd, args)
return transformResponse(cmd, data, args)
}
// get_face_landmarks 也沒有 Tauri command
if (cmd === 'get_face_landmarks') {
const data = await httpCall(cmd, args)
return transformResponse(cmd, data, args)
}
// Admin data comes from MarkBase API (/api/v2/admin/*) in all modes — no Tauri command
if (cmd === 'get_system_stats' || cmd === 'list_admin_users' || cmd === 'list_shares' || cmd === 'validate_system_stats') {
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
if (isTauri) {
try {
const data = await invoke(cmd, args)
@@ -282,6 +322,9 @@ case 'search_keyword': {
case 'get_file_thumbnail_by_path': {
return { url: `/api/v1/file/thumbnail?path=${encodeURIComponent(a.path)}&frame=${a.frame || 30}`, method: 'GET' }
}
case 'regenerate_trace_thumbnail': {
return { url: `/api/v1/file/${a.fileUuid}/trace/${a.traceId}/regenerate-thumbnail`, method: 'POST' }
}
// --- Video API ---
case 'get_video_stream': {
@@ -340,6 +383,20 @@ case 'search_keyword': {
case 'list_traces': {
return { url: `/api/v1/file/${a.file_uuid}/traces`, method: 'POST' }
}
case 'get_file_traces': {
const fu = a.fileUuid ?? a.file_uuid
return { url: `/api/v1/file/${fu}/traces`, method: 'POST', body: { page: a.page || 1, page_size: a.page_size || 3000, limit: a.limit || 3000 } }
}
case 'search_file_faces': {
const fu = a.fileUuid ?? a.file_uuid
const params = new URLSearchParams()
if (a.q) params.set('q', a.q)
params.set('page', String(a.page || 1))
params.set('page_size', String(a.perPage || 20))
if (a.binding) params.set('binding', a.binding)
if (a.minConfidence != null) params.set('min_confidence', String(a.minConfidence))
return { url: `/api/v1/file/${fu}/faces/search?${params.toString()}`, method: 'GET' }
}
case 'get_trace_profile': {
const params = new URLSearchParams()
const fu = a.file_uuid ?? a.fileUuid
@@ -348,6 +405,14 @@ case 'search_keyword': {
if (tid != null) params.set('trace_id', String(tid))
return { url: `/api/v1/trace-profile?${params.toString()}`, method: 'GET' }
}
case 'get_trace_profile_file': {
const params = new URLSearchParams()
const fu = a.file_uuid ?? a.fileUuid
const tid = a.trace_id ?? a.traceId
if (fu) params.set('file_uuid', fu)
if (tid != null) params.set('trace_id', String(tid))
return { url: `/api/v1/trace-profile-file?${params.toString()}`, method: 'GET' }
}
case 'update_trace_profile': {
const body = a.body ?? {
file_uuid: a.file_uuid ?? a.fileUuid,
@@ -357,7 +422,12 @@ case 'search_keyword': {
return { url: '/api/v1/trace-profile', method: 'PUT', body }
}
case 'delete_trace': {
const body: any = { hard_delete: a.hard_delete ?? true }
const body: any = { mode: a.mode ?? 'ungroup' }
if (a.mode === 'delete') {
if (a.hard_delete !== undefined) body.hard_delete = a.hard_delete
} else {
body.hard_delete = a.hard_delete ?? true
}
return { url: `/api/v1/file/${a.file_uuid}/trace/${a.trace_id}`, method: 'DELETE', body }
}
case 'restore_trace': {
@@ -460,6 +530,9 @@ case 'search_keyword': {
case 'register_file': {
return { url: '/api/v1/files/register', method: 'POST', body: { file_path: a.filePath } }
}
case 'get_command_status': {
return { url: `/api/v1/command/${a.commandId}`, method: 'GET' }
}
case 'process_file': {
return { url: `/api/v1/file/${a.fileUuid}/process`, method: 'POST', body: { processors: a.processors } }
}
@@ -467,10 +540,10 @@ case 'search_keyword': {
return { url: `/api/v1/identity-matches?file_hash=${a.fileHash}`, method: 'GET' }
}
case 'run_cluster_agent': {
return { url: `/api/v1/file/${a.fileUuid}/cluster-agent`, method: 'POST', body: { confirm: true } }
return { url: `/api/v1/file/${a.fileUuid}/cluster-agent`, method: 'POST', body: { confirm: true }, timeout: 600000 }
}
case 'get_cluster_results': {
return { url: `/api/v1/cluster-results?file_hash=${a.fileHash}`, method: 'GET' }
return { url: `/api/v1/cluster-results?file_hash=${a.fileHash}&include_trace_ids=false`, method: 'GET' }
}
case 'get_similar_face_groups': {
const params = new URLSearchParams()
@@ -486,7 +559,9 @@ case 'search_keyword': {
return { url: `/api/v1/file-profile?file_uuid=${a.fileUuid}`, method: 'GET' }
}
case 'update_file_profile': {
return { url: '/api/v1/file-profile', method: 'PUT', body: { file_uuid: a.fileUuid, file_path: a.filePath, file_name: a.fileName } }
const body: any = { file_uuid: a.fileUuid, file_path: a.filePath, file_name: a.fileName }
if (a.faceFilter != null) body.face_filter = a.faceFilter
return { url: '/api/v1/file-profile', method: 'PUT', body }
}
case 'get_processor_json': {
return { url: `/api/v1/processor-json?file_hash=${a.fileHash}&processor=${a.processor}`, method: 'GET' }
@@ -555,6 +630,17 @@ case 'search_keyword': {
return { url: `/api/v1/bookmarks/${a.id}`, method: 'DELETE' }
}
// --- Video Marks ---
case 'get_video_marks': {
return { url: `/api/v1/video-marks?file_uuid=${encodeURIComponent(a.fileUuid)}`, method: 'GET' }
}
case 'save_video_mark': {
return { url: '/api/v1/video-marks', method: 'POST', body: { file_uuid: a.fileUuid, tag: a.tag, start_frame: a.startFrame, end_frame: a.endFrame, note: a.note } }
}
case 'delete_video_mark': {
return { url: `/api/v1/video-mark/${a.id}`, method: 'DELETE' }
}
// --- Service Management ---
case 'stop_service': {
const mbUrl = detectBaseUrl()
@@ -568,6 +654,11 @@ case 'search_keyword': {
// --- Admin Panel ---
case 'get_system_stats':
return { url: '/api/v2/admin/system-stats', method: 'GET' }
case 'validate_system_stats': {
let url = '/api/v1/system-stats/validate'
if (a.layers) url += `?layers=${a.layers}`
return { url, method: 'GET' }
}
case 'list_admin_users':
return { url: '/api/v2/admin/users', method: 'GET' }
case 'list_shares':
@@ -590,6 +681,14 @@ case 'search_keyword': {
if (a.bboxH != null) url += `&bbox_h=${a.bboxH}`
return { url, method: 'GET' }
}
case 'get_face_landmarks': {
let url = `/api/v1/file/${a.fileUuid}/face-landmarks?frame=${a.frame}`
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' }
}
case 'sync_file_status': {
return { url: `/api/v1/file/${a.fileUuid}/sync-status`, method: 'POST' }
@@ -602,7 +701,7 @@ case 'search_keyword': {
// 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 {
export function transformResponse(cmd: string, data: any, args?: Record<string, any>): any {
switch (cmd) {
case 'get_files': {
const files = data.files || data.data || data || []
@@ -614,6 +713,7 @@ export function transformResponse(cmd: string, data: any): any {
modified_time: f.modified_time || '',
isRegistered: f.isRegistered ?? f.is_registered ?? false,
status: f.status || '',
status_updated_at: f.status_updated_at || null,
registrationTime: f.registration_time || null,
ingested: f.ingested ?? ['completed','keyword_ready','semantic_ready','face_mgmt_ready','agent_ready'].includes(f.status),
}))
@@ -674,7 +774,7 @@ export function transformResponse(cmd: string, data: any): any {
bbox: c.bbox ? { x: c.bbox.x, y: c.bbox.y, width: c.bbox.width, height: c.bbox.height } : null,
}))
}
case 'get_unassigned_traces': {
case 'get_unassigned_traces': {
const traces = data.traces || data.data || []
return {
total: data.total || traces.length || 0,
@@ -688,6 +788,10 @@ case 'get_unassigned_traces': {
best_face_frame: t.best_face_frame || 1,
best_face_confidence: t.best_face_confidence || 1,
best_face_bbox: t.best_face_bbox ? { x: t.best_face_bbox.x, y: t.best_face_bbox.y, width: t.best_face_bbox.width, height: t.best_face_bbox.height } : null,
best_face_capture_quality: t.best_capture_quality ?? null,
best_face_eye_ratio: t.best_face_eye_ratio ?? null,
best_face_nose_ratio: t.best_face_nose_ratio ?? null,
best_face_pose_score: t.best_face_pose_score ?? null,
})),
}
}
@@ -700,6 +804,52 @@ case 'get_unassigned_traces': {
})),
}
}
case 'get_file_traces': {
const traces = data.traces || data.data || []
const fu = args?.fileUuid ?? args?.file_uuid ?? ''
return {
total: data.total_traces || traces.length || 0,
traces: traces.map((t: any) => ({
trace_id: t.trace_id,
file_uuid: t.file_uuid || fu,
face_count: t.face_count || 0,
frame_count: t.face_count || 0,
start_frame: t.start_frame || 0,
end_frame: t.end_frame || 1,
start_time: t.start_time || 0,
end_time: t.end_time || 0,
duration_sec: t.duration_sec || 0,
avg_confidence: t.avg_confidence || 0,
best_face_id: t.best_face_id ?? null,
best_face_frame: t.best_face_frame || 0,
best_face_confidence: t.best_face_confidence || 0,
best_face_bbox: t.best_face_bbox || null,
best_face_capture_quality: t.best_face_capture_quality ?? null,
avg_capture_quality: t.avg_capture_quality ?? null,
thumbnail_url: t.thumbnail_url || '',
})),
}
}
case 'search_file_faces': {
const items = data.data || data.traces || []
const fu = args?.fileUuid ?? args?.file_uuid ?? ''
return {
total: data.total || items.length || 0,
page: data.page || 1,
page_size: data.page_size || 20,
traces: items.map((t: any) => ({
trace_id: t.trace_id ?? 0,
file_uuid: t.file_uuid || fu,
name: t.name ?? null,
start_frame: t.start_frame || 0,
end_frame: t.end_frame || 1,
frame_count: t.frame_count || 0,
avg_confidence: t.avg_confidence || 0,
best_face_bbox: t.best_face_bbox ?? null,
thumbnail_url: t.thumbnail_url || '',
})),
}
}
case 'search_llm_smart': {
const results = data.results || data.data || data || []
return results.map((r: any) => {
@@ -789,11 +939,16 @@ case 'get_unassigned_traces': {
case 'register_file': {
return {
success: data.success ?? false,
command_id: data.command_id ?? '',
status: data.status ?? '',
file_uuid: data.file_uuid ?? '',
file_name: data.file_name ?? '',
message: data.message ?? '',
}
}
case 'get_command_status': {
return data || null
}
case 'process_file': {
return {
success: data.success ?? false,
@@ -817,6 +972,7 @@ case 'get_unassigned_traces': {
return data || null
}
case 'get_trace_profile': { return data || null }
case 'get_trace_profile_file': { return data || null }
case 'update_trace_profile': { return data || { success: true } }
case 'update_trace_profile_group': { return data || { success: true } }
case 'get_file_profile': { return data || null }
@@ -839,7 +995,14 @@ case 'get_unassigned_traces': {
message: data.message ?? '',
}
}
case 'unregister_file':
case 'unregister_file': {
return {
success: data.success ?? false,
command_id: data.command_id ?? '',
file_uuid: data.file_uuid ?? '',
message: data.message ?? '',
}
}
case 'ingest_file':
case 'checkout_file': {
return {
@@ -885,7 +1048,19 @@ case 'get_unassigned_traces': {
}
case 'get_pose':
case 'get_appearance':
case 'get_face_landmarks':
return data
case 'get_system_stats':
case 'list_shares':
return data
case 'list_admin_users': {
const users = data.users || data.data || data || []
return users.map((u: any) => ({
username: u.username || '',
homeDir: u.home_dir ?? u.homeDir ?? '',
status: u.status ?? 1,
}))
}
default:
return data
}

View File

@@ -23,4 +23,37 @@ export interface AppearanceResponse {
frame: number
dominant_colors: DominantColor[]
hsv_histogram?: number[][][]
}
export interface FaceBBox {
x: number
y: number
width: number
height: number
confidence: number
}
export interface FaceLandmarksResponse {
frame: number
x: number
y: number
width: number
height: number
confidence: number
pose_angle?: {
angle?: string
yaw?: number
pitch?: number
roll?: number
} | null
landmarks: {
left_eye?: number[][]
right_eye?: number[][]
nose?: number[][]
} | null
lips?: {
outer_lips?: number[][]
inner_lips?: number[][]
} | null
faces?: FaceBBox[]
}

View File

@@ -12,14 +12,14 @@ html, body {
--success-color: #22c55e;
--warning-color: #f59e0b;
--error-color: #ef4444;
--background-color: #f8fafc;
--card-background: rgba(255, 255, 255, 0.88);
--background-color: #e8eaed;
--card-background: rgba(255, 255, 255, 0.92);
--text-primary: #1e293b;
--text-secondary: #64748b;
--border-color: rgba(0, 0, 0, 0.08);
--border-light: rgba(0, 0, 0, 0.05);
--border-dark: rgba(0, 0, 0, 0.12);
--hover-background: rgba(0, 0, 0, 0.04);
--border-color: rgba(0, 0, 0, 0.12);
--border-light: rgba(0, 0, 0, 0.08);
--border-dark: rgba(0, 0, 0, 0.16);
--hover-background: rgba(0, 0, 0, 0.06);
--input-background: rgba(255, 255, 255, 0.95);
--input-border: rgba(0, 0, 0, 0.1);
--danger-color: #d93025;

View File

@@ -929,7 +929,7 @@ padding:0 14px 0 32px !important; margin:2px 0 !important;
.ms-ppl-strip-wrap { display: flex; align-items: center; gap: 10px; margin-bottom: 28px; }
.ms-ppl-strip-add-btn { width: 52px; height: 52px; border-radius: 12px; border: 1.5px dashed #bdc1c6; background: #fff; font-size: 20px; color: #bdc1c6; display: grid; place-items: center; cursor: pointer; outline: none; flex-shrink: 0; transition: border-color .15s, color .15s; }
.ms-ppl-strip-add-btn:hover { border-color: #202124; color: #202124; }
.ms-ppl-face-strip { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: thin; flex: 1; }
.ms-ppl-face-strip { display: flex; gap: 10px; padding-bottom: 4px; scrollbar-width: thin; flex: 1; min-width: 0; overflow-x: auto; }
.ms-ppl-strip-face { position: relative; flex-shrink: 0; cursor: pointer; }
.ms-ppl-strip-face-img { width: 52px; height: 52px; border-radius: 12px; border: 2px solid transparent; background: #e8eaed; overflow: hidden; transition: border-color .15s; position: relative; }
.ms-ppl-strip-face-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
@@ -984,7 +984,12 @@ padding:0 14px 0 32px !important; margin:2px 0 !important;
.ms-merge-footer { display: flex; justify-content: flex-end; gap: 10px; }
.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; justify-content: space-between; margin-bottom: 16px; }
.ms-modal-video-header { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
.ms-video-header-row { display: flex; align-items: center; justify-content: space-between; }
.ms-video-file-name { font-size: 12px; font-weight: 500; color: var(--text-secondary); margin: 0; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-video-frame-info { font-size: 12px; color: var(--text-secondary); white-space: nowrap; flex-shrink: 0; }
.ms-video-title-row { gap: 10px; }
.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-title { font-size: 14px; font-weight: 600; color: #e8eaed; }
.ms-modal-video-close { border: none; background: transparent; font-size: 18px; color: #9aa0a6; cursor: pointer; padding: 0; line-height: 1; }
.ms-modal-video-close:hover { color: #fff; }

View File

@@ -925,7 +925,11 @@ padding:0 14px 0 32px !important; margin:2px 0 !important;
.ms-ppl-strip-wrap { display: flex; align-items: center; gap: 10px; margin-bottom: 28px; }
.ms-ppl-strip-add-btn { width: 52px; height: 52px; border-radius: 12px; border: 1.5px dashed #bdc1c6; background: #fff; font-size: 20px; color: #bdc1c6; display: grid; place-items: center; cursor: pointer; outline: none; flex-shrink: 0; transition: border-color .15s, color .15s; }
.ms-ppl-strip-add-btn:hover { border-color: #202124; color: #202124; }
.ms-ppl-face-strip { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: thin; flex: 1; }
.ms-ppl-face-strip { display: flex; gap: 10px; padding-bottom: 8px; scrollbar-width: thin; scrollbar-color: #b8bec6 transparent; flex: 1; min-width: 0; overflow-x: auto; }
.ms-ppl-face-strip::-webkit-scrollbar { height: 8px; }
.ms-ppl-face-strip::-webkit-scrollbar-track { background: transparent; }
.ms-ppl-face-strip::-webkit-scrollbar-thumb { background: #b8bec6; border-radius: 999px; }
.ms-ppl-face-strip::-webkit-scrollbar-thumb:hover { background: #8a919b; }
.ms-ppl-strip-face { position: relative; flex-shrink: 0; cursor: pointer; }
.ms-ppl-strip-face-img { width: 52px; height: 52px; border-radius: 12px; border: 2px solid transparent; background: #e8eaed; overflow: hidden; transition: border-color .15s; position: relative; }
.ms-ppl-strip-face-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
@@ -980,7 +984,12 @@ padding:0 14px 0 32px !important; margin:2px 0 !important;
.ms-merge-footer { display: flex; justify-content: flex-end; gap: 10px; }
.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; justify-content: space-between; margin-bottom: 16px; }
.ms-modal-video-header { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
.ms-video-header-row { display: flex; align-items: center; justify-content: space-between; }
.ms-video-file-name { font-size: 12px; font-weight: 500; color: var(--text-secondary); margin: 0; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-video-frame-info { font-size: 12px; color: var(--text-secondary); white-space: nowrap; flex-shrink: 0; }
.ms-video-title-row { gap: 10px; }
.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-title { font-size: 14px; font-weight: 600; color: #e8eaed; }
.ms-modal-video-close { border: none; background: transparent; font-size: 18px; color: #9aa0a6; cursor: pointer; padding: 0; line-height: 1; }
.ms-modal-video-close:hover { color: #fff; }

View File

@@ -1,9 +1,55 @@
<template>
<div class="build-version">{{ version }}</div>
<div v-if="isAdvanced" class="build-version" @click="copyInfo" :title="tooltip">{{ displayText }}</div>
</template>
<script setup lang="ts">
const version = __BUILD_VERSION__
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAdvancedMode } from '@/stores/advancedMode'
import pkg from '../../package.json'
const router = useRouter()
const { isAdvanced } = useAdvancedMode()
const build = __BUILD_VERSION__
const shortDate = build.slice(0, 8)
const appVersion = pkg.version
const pageName = computed(() => {
const name = router.currentRoute.value.name
return typeof name === 'string' ? name : router.currentRoute.value.path
})
const displayText = computed(() => `v${appVersion}-${shortDate} | ${pageName.value}`)
const tooltip = computed(() =>
`App: ${appVersion}\nBuild: ${build}\nPage: ${pageName.value}`
)
function copyInfo() {
const lines = [
'=== MOMENTRY STUDIO ===',
`App Version: ${appVersion}`,
`Build: ${build}`,
`Page: ${pageName.value}`,
`Route: ${router.currentRoute.value.path}`,
`Timestamp: ${new Date().toISOString()}`
]
const text = lines.join('\n')
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '0'
ta.style.top = '0'
ta.style.width = '0'
ta.style.height = '0'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.focus()
ta.setSelectionRange(0, text.length)
const ok = document.execCommand('copy')
document.body.removeChild(ta)
if (!ok) window.prompt('Copy (Ctrl+C, Enter):', text)
}
</script>
<style scoped>
@@ -14,9 +60,11 @@ const version = __BUILD_VERSION__
font-size: 10px;
color: var(--text-secondary, #9aa0a6);
background: var(--bg-secondary, rgba(0,0,0,0.1));
padding: 2px 6px;
padding: 2px 8px;
border-radius: 3px;
z-index: 9999;
font-family: monospace;
cursor: pointer;
white-space: nowrap;
}
</style>

View File

@@ -101,6 +101,79 @@
</select>
</div>
<div class="filter-section">
<div class="section-title">Single Frame FS Settings</div>
<div class="filter-row checkbox-row">
<label class="checkbox-label">
<input
type="checkbox"
v-model="singleFrameSettings.single_frame_trace_vlm_enabled"
@change="markModified"
/>
VLM Processing
</label>
<div class="hint">Enable VLM for single-frame traces</div>
</div>
<div class="filter-row checkbox-row">
<label class="checkbox-label">
<input
type="checkbox"
v-model="singleFrameSettings.single_frame_trace_group_enabled"
@change="markModified"
/>
Face Groups
</label>
<div class="hint">Include single-frame traces in groups</div>
</div>
<div class="filter-row checkbox-row">
<label class="checkbox-label">
<input
type="checkbox"
v-model="singleFrameSettings.single_frame_trace_unassigned_enabled"
@change="markModified"
/>
Unassigned List
</label>
<div class="hint">Show single-frame traces in unassigned</div>
</div>
<div class="filter-row checkbox-row">
<label class="checkbox-label">
<input
type="checkbox"
v-model="singleFrameSettings.single_trace_group_filter_enabled"
@change="markModified"
/>
Single Trace Groups
</label>
<div class="hint">Filter auto-generated Person_N groups</div>
</div>
<div class="section-actions">
<button
class="action-btn action-btn-primary"
@click="applySingleFrameSettings"
:disabled="!singleFrameSettingsModified || singleFrameSettingsLoading"
>
{{ singleFrameSettingsLoading ? 'Applying...' : 'Apply Settings' }}
</button>
<button
class="action-btn"
@click="handleResetSingleFrame"
:disabled="singleFrameSettingsLoading"
>
Reset to Default
</button>
</div>
<div v-if="singleFrameSettingsError" class="error-message">
❌ {{ singleFrameSettingsError }}
</div>
</div>
<!-- Action Buttons -->
<div class="filter-actions">
<button
@@ -150,13 +223,17 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, watch } from 'vue';
import { useFaceFilter, type QuickFilterRules } from '../composables/useFaceFilter';
const props = defineProps<{
fileUuid: string;
}>();
const emit = defineEmits<{
(e: 'settings-updated'): void;
}>();
const {
mode,
rules,
@@ -168,7 +245,15 @@ const {
setMode,
updateRules,
applyBackendFilter,
resetToDefault
resetToDefault,
singleFrameSettings,
singleFrameSettingsLoading,
singleFrameSettingsError,
singleFrameSettingsModified,
loadSingleFrameSettings,
saveSingleFrameSettings,
resetSingleFrameSettings,
markSingleFrameSettingsModified
} = useFaceFilter();
const showPanel = ref(false);
@@ -190,6 +275,34 @@ function handleReset() {
resetToDefault();
}
function markModified() {
markSingleFrameSettingsModified();
}
async function applySingleFrameSettings() {
if (!props.fileUuid) return;
const success = await saveSingleFrameSettings(props.fileUuid);
if (success) {
emit('settings-updated');
}
}
function handleResetSingleFrame() {
resetSingleFrameSettings();
}
watch(() => props.fileUuid, (newUuid) => {
if (newUuid && showPanel.value) {
loadSingleFrameSettings(newUuid);
}
}, { immediate: true });
watch(showPanel, (show) => {
if (show && props.fileUuid) {
loadSingleFrameSettings(props.fileUuid);
}
});
// Expose for parent
defineExpose({
showPanel,
@@ -403,4 +516,51 @@ defineExpose({
.stat-item span {
font-weight: 600;
}
.filter-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color, #ddd);
}
.section-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text-primary, #333);
}
.checkbox-row {
flex-direction: column;
align-items: flex-start;
gap: 4px;
margin-bottom: 12px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.hint {
font-size: 12px;
color: #999;
margin-left: 24px;
}
.section-actions {
display: flex;
gap: 10px;
margin-top: 12px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
import { ref, type Ref } from 'vue'
export interface DragSelectBox {
x1: number
y1: number
x2: number
y2: number
}
export interface DragSelectOptions {
getSelected: () => string[]
setSelected: (keys: string[]) => void
keyOf?: (el: HTMLElement) => string
onBox?: (box: DragSelectBox | null) => void
}
const DRAG_THRESHOLD = 4
function overlaps(ax1: number, ay1: number, ax2: number, ay2: number, bx1: number, by1: number, bx2: number, by2: number) {
return ax1 < bx2 && ax2 > bx1 && ay1 < by2 && ay2 > by1
}
export function useDragSelect(containerRef: Ref<HTMLElement | null>, opts: DragSelectOptions) {
const box = ref<DragSelectBox | null>(null)
function setBox(b: DragSelectBox | null) {
box.value = b
opts.onBox?.(b)
}
let startX = 0
let startY = 0
let curX = 0
let curY = 0
let moved = false
let lastWasDrag = false
function onMousedown(e: MouseEvent) {
if (e.button !== 0) return
if (e.ctrlKey || e.metaKey || e.shiftKey) return
const target = e.target as HTMLElement
if (target.closest('button, a, input, textarea, select, .ms-ppl-card-checkbox, .ms-fm-play-btn')) return
startX = curX = e.clientX
startY = curY = e.clientY
moved = false
lastWasDrag = false
setBox({ x1: startX, y1: startY, x2: startX, y2: startY })
window.addEventListener('mousemove', onMousemove)
window.addEventListener('mouseup', onMouseup)
}
function onMousemove(e: MouseEvent) {
curX = e.clientX
curY = e.clientY
if (!moved && Math.abs(curX - startX) < DRAG_THRESHOLD && Math.abs(curY - startY) < DRAG_THRESHOLD) {
setBox({ x1: startX, y1: startY, x2: startX, y2: startY })
return
}
if (!moved) {
moved = true
document.body.style.userSelect = 'none'
}
setBox({
x1: Math.min(startX, curX),
y1: Math.min(startY, curY),
x2: Math.max(startX, curX),
y2: Math.max(startY, curY),
})
}
function onMouseup(e: MouseEvent) {
window.removeEventListener('mousemove', onMousemove)
window.removeEventListener('mouseup', onMouseup)
if (!moved) {
setBox(null)
return
}
lastWasDrag = true
const rect = box.value
setBox(null)
if (document.body.style.userSelect === 'none') {
document.body.style.userSelect = ''
}
const container = containerRef.value
if (!container || !rect) {
return
}
const bx1 = rect.x1
const by1 = rect.y1
const bx2 = rect.x2
const by2 = rect.y2
const keyOf = opts.keyOf ?? ((el: HTMLElement) => el.dataset.dragKey || '')
const intersecting = new Set<string>()
const cards = container.querySelectorAll<HTMLElement>('[data-drag-key]')
cards.forEach((card) => {
const r = card.getBoundingClientRect()
if (overlaps(r.left, r.top, r.right, r.bottom, bx1, by1, bx2, by2)) {
const key = keyOf(card)
if (key) intersecting.add(key)
}
})
const prev = opts.getSelected()
if (e.shiftKey) {
const next = new Set(prev)
intersecting.forEach((k) => next.add(k))
opts.setSelected(Array.from(next))
} else if (e.ctrlKey || e.metaKey) {
const next = new Set(prev)
intersecting.forEach((k) => {
if (next.has(k)) next.delete(k)
else next.add(k)
})
opts.setSelected(Array.from(next))
} else {
opts.setSelected(Array.from(intersecting))
}
}
function onClickCapture(e: MouseEvent) {
if (lastWasDrag) {
e.preventDefault()
e.stopPropagation()
lastWasDrag = false
}
}
return {
box,
onMousedown,
onClickCapture,
}
}
export function dragSelectRectStyle(b: DragSelectBox) {
return {
left: b.x1 + 'px',
top: b.y1 + 'px',
width: Math.max(0, b.x2 - b.x1) + 'px',
height: Math.max(0, b.y2 - b.y1) + 'px',
}
}

View File

@@ -86,6 +86,26 @@ export interface FilterStats {
};
}
export interface SingleFrameFSSettings {
single_frame_trace_vlm_enabled: boolean;
single_frame_trace_group_enabled: boolean;
single_frame_trace_unassigned_enabled: boolean;
single_trace_group_filter_enabled: boolean;
}
export interface FileProfileResponse {
file_uuid: string;
file_name: string;
file_path: string;
status: string;
duration: number;
width: number;
height: number;
fps: number;
total_frames: number;
face_filter: SingleFrameFSSettings;
}
// Default rules
const defaultRules: QuickFilterRules = {
min_confidence: 0.5,
@@ -105,13 +125,13 @@ async function fetchFaces(fileUuid: string, params?: { page?: number; page_size?
if (params?.page_size) queryParams.set('page_size', params.page_size.toString());
if (params?.source) queryParams.set('source', params.source);
const response = await fetch(`${apiBase}/file/${fileUuid}/faces?${queryParams}`);
const response = await fetch(`${apiBase}/file/${fileUuid}/face-filter/faces?${queryParams}`);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json();
}
async function applyFilter(fileUuid: string, rules?: any) {
const response = await fetch(`${apiBase}/file/${fileUuid}/faces/filter`, {
const response = await fetch(`${apiBase}/file/${fileUuid}/face-filter/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rules: rules || null })
@@ -121,17 +141,32 @@ async function applyFilter(fileUuid: string, rules?: any) {
}
async function getFilterStats(fileUuid: string) {
const response = await fetch(`${apiBase}/file/${fileUuid}/filter-stats`);
const response = await fetch(`${apiBase}/file/${fileUuid}/face-filter/stats`);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json();
}
async function getFaceReport(fileUuid: string, faceId: string) {
const response = await fetch(`${apiBase}/file/${fileUuid}/face/${faceId}/filter-report`);
const response = await fetch(`${apiBase}/file/${fileUuid}/face-filter/${faceId}/report`);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json();
}
async function getFileProfile(fileUuid: string): Promise<FileProfileResponse> {
const response = await fetch(`${apiBase}/file-profile?file_uuid=${fileUuid}`);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json();
}
async function updateFileProfile(fileUuid: string, faceFilter: Partial<SingleFrameFSSettings>): Promise<boolean> {
const response = await fetch(`${apiBase}/file-profile`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_uuid: fileUuid, face_filter: faceFilter })
});
return response.ok;
}
// Quick filter function (client-side)
function quickFilter(faces: FaceMetadata[], rules: QuickFilterRules): FaceMetadata[] {
return faces.filter(face => {
@@ -189,6 +224,16 @@ export function useFaceFilter() {
const loading = ref(false);
const error = ref<string | null>(null);
const singleFrameSettings = ref<SingleFrameFSSettings>({
single_frame_trace_vlm_enabled: true,
single_frame_trace_group_enabled: true,
single_frame_trace_unassigned_enabled: true,
single_trace_group_filter_enabled: true,
});
const singleFrameSettingsLoading = ref(false);
const singleFrameSettingsError = ref<string | null>(null);
const singleFrameSettingsModified = ref(false);
// Computed stats
const stats = computed(() => ({
total: allFaces.value.length,
@@ -356,6 +401,60 @@ export function useFaceFilter() {
savePreferences();
}
async function loadSingleFrameSettings(fileUuid: string) {
singleFrameSettingsLoading.value = true;
singleFrameSettingsError.value = null;
try {
const profile = await getFileProfile(fileUuid);
singleFrameSettings.value = {
single_frame_trace_vlm_enabled: profile.face_filter.single_frame_trace_vlm_enabled ?? true,
single_frame_trace_group_enabled: profile.face_filter.single_frame_trace_group_enabled ?? true,
single_frame_trace_unassigned_enabled: profile.face_filter.single_frame_trace_unassigned_enabled ?? true,
single_trace_group_filter_enabled: profile.face_filter.single_trace_group_filter_enabled ?? true,
};
singleFrameSettingsModified.value = false;
} catch (err: any) {
singleFrameSettingsError.value = err.message || 'Failed to load settings';
console.error('Failed to load single frame settings:', err);
} finally {
singleFrameSettingsLoading.value = false;
}
}
async function saveSingleFrameSettings(fileUuid: string): Promise<boolean> {
singleFrameSettingsLoading.value = true;
singleFrameSettingsError.value = null;
try {
const success = await updateFileProfile(fileUuid, singleFrameSettings.value);
if (success) {
singleFrameSettingsModified.value = false;
}
return success;
} catch (err: any) {
singleFrameSettingsError.value = err.message || 'Failed to save settings';
console.error('Failed to save single frame settings:', err);
return false;
} finally {
singleFrameSettingsLoading.value = false;
}
}
function resetSingleFrameSettings() {
singleFrameSettings.value = {
single_frame_trace_vlm_enabled: true,
single_frame_trace_group_enabled: true,
single_frame_trace_unassigned_enabled: true,
single_trace_group_filter_enabled: true,
};
singleFrameSettingsModified.value = true;
}
function markSingleFrameSettingsModified() {
singleFrameSettingsModified.value = true;
}
return {
// State
mode,
@@ -367,6 +466,10 @@ export function useFaceFilter() {
loading,
error,
stats,
singleFrameSettings,
singleFrameSettingsLoading,
singleFrameSettingsError,
singleFrameSettingsModified,
// Actions
initialize,
@@ -377,6 +480,10 @@ export function useFaceFilter() {
refreshStats,
resetToDefault,
applyLocalFilter,
loadSingleFrameSettings,
saveSingleFrameSettings,
resetSingleFrameSettings,
markSingleFrameSettingsModified,
// API helpers (exposed for direct use)
fetchFaces,

View File

@@ -1,4 +1,5 @@
import { reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { apiCall } from '@/api'
import { invalidatePeople, ensurePeople } from '@/store'
@@ -29,6 +30,7 @@ interface ActionHistoryItem {
}
export function useUndoRedo() {
const { t } = useI18n()
const counts = reactive<Record<string, HistoryCounts>>({})
const mergeHistory = reactive<MergeHistoryItem[]>([])
const recentActions = reactive<ActionHistoryItem[]>([])
@@ -82,7 +84,7 @@ export function useUndoRedo() {
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
const label = op === 'delete' ? t('people.action.delete_person') : op === 'update' ? t('people.action.update_person') : op
actions.push({
type: op === 'delete' ? 'delete' : 'patch',
label,
@@ -95,7 +97,7 @@ export function useUndoRedo() {
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
const label = op === 'bind' ? t('people.action.bind_face') : op === 'unbind' ? t('people.action.unbind_face') : op === 'bind_trace' ? t('people.action.bind_trace') : op
actions.push({
type: 'bind',
label,

View File

@@ -44,13 +44,19 @@
"min_minutes": "Min Minutes",
"max_minutes": "Max Minutes"
},
"status": {
"status": {
"files": "{count} files",
"registered": "Registered",
"unregistered": "Unregistered",
"pending": "Pending",
"processing": "Processing",
"completed": "Completed"
"completed": "Completed",
"scanning": "Scanning",
"ready": "Ready",
"keyword_ready": "Keyword Ready",
"semantic_ready": "Semantic Ready",
"face_mgmt_ready": "Face Mgmt Ready",
"agent_ready": "Agent Ready"
},
"thumbnail": {
"register_to_preview": "Register to see preview"
@@ -59,7 +65,6 @@
"register_file": "Register File",
"process_file": "Process File",
"reprocess": "Reprocess",
"run_identity_agent": "Run Identity Agent",
"unregister": "Unregister",
"play_video": "Play Video",
"processor_selection": "Processor Selection",
@@ -67,7 +72,9 @@
"missing_file_path": "Missing file path",
"file_not_registered": "File not registered",
"select_processor": "Please select at least one processor",
"file_must_be_completed": "File must be completed first"
"file_must_be_completed": "File must be completed first",
"face_deduplication": "Face Deduplication",
"qc_check": "QC Check"
},
"pipeline": {
"processors": "Processors",
@@ -91,17 +98,57 @@
"processing_started": "Processing started: {name}",
"processing_failed": "Processing failed: {message}",
"processing_error": "Processing error: {error}",
"identity_agent_started": "Identity Agent started: {name}",
"identity_agent_failed": "Identity Agent failed: {message}",
"identity_agent_error": "Identity Agent error: {error}",
"removing": "Removing...",
"removed": "Removed: {name}",
"removal_failed": "Removal failed: {message}",
"removal_error": "Removal error: {error}",
"clear_filters": "Clear Filters"
},
"qc": {
"audio_info": "Audio Info",
"audio_track": "Audio Track",
"has_audio": "✓ Has Audio",
"no_audio": "✗ No Audio",
"speech": "Speech",
"has_speech": "✓ Has Speech",
"no_speech": "🔇 No Speech",
"language": "Language",
"sample_rate": "Sample Rate",
"uuid_label": "UUID:",
"track_processor_output": "Phase 1: Processor Output",
"track_ingestion": "Phase 2: Data Ingestion",
"track_status_change": "Final: Status Change",
"output_ingestion_done": "📥 Output + Ingestion Complete",
"items": "{count} items"
},
"refresh": "Refresh",
"refresh_status": "Refresh Status"
"refresh_status": "Refresh Status",
"confirm": {
"title_register": "📥 Register",
"title_process": "🔍 Deep Scan",
"title_rescan": "🔄 Re-scan",
"message_register": "File will be registered and processed with default settings.",
"message_register_no_scan": "Register only, no automatic scan.",
"message_process": "Processing will run on the selected processors using default settings.",
"message_rescan": "This will unregister → register → deep scan the file using selected processors.",
"cancel": "Cancel",
"proceed": "Proceed",
"rerun_processor": "Re-run {processor} for this file?",
"run_processor": "Run {processor} on {filename}?",
"unregister_title": "Unregister",
"unregister_message": "Unregister \"{filename}\"?\n\nThis will remove the file and its output data."
},
"processor": {
"started": "{processor} started",
"failed": "Failed: {message}",
"error": "Error: {message}",
"no_file": "Error: No file selected",
"vlm_failed": "Failed to trigger VLM summary: {message}",
"pipeline_qc": "Pipeline QC"
},
"settings": {
"auto_scan": "Auto deep scan after registration"
}
},
"people": {
"toolbar": {
@@ -119,7 +166,8 @@
"pending_people": "People",
"skipped_people": "Skipped People",
"pending_faces": "Faces",
"face_group": "Face Group"
"face_group": "Face Group",
"unassigned": "Unassigned"
},
"sort": {
"title": "Sort By",
@@ -230,6 +278,13 @@
"create_identity_failed": "Failed to create person: API did not return identity_uuid",
"create_identity_error": "Failed to create person: {error}",
"assign_failed": "Assignment failed: {error}"
},
"action": {
"delete_person": "Delete Person",
"update_person": "Update Person",
"bind_face": "Bind Face",
"unbind_face": "Unbind Face",
"bind_trace": "Bind Trace"
}
},
"agent": {
@@ -269,5 +324,125 @@
"people": "People",
"admin": "Admin",
"client": "Client"
},
"time": {
"just_now": "Just now",
"minutes_ago": "{count} min ago",
"hours_ago": "{count} hr ago"
},
"video": {
"prev": "Previous",
"next": "Next",
"key_frame": "Key Frame"
},
"language": {
"en": "English",
"zh-TW": "Traditional Chinese",
"zh-CN": "Simplified Chinese",
"ja": "Japanese",
"ko": "Korean",
"auto": "Auto Detect"
},
"client": {
"title": "File Manager",
"search": {
"placeholder": "Search files..."
},
"table": {
"name": "Name",
"size": "Size",
"type": "Type",
"modified": "Modified"
},
"button": {
"upload": "Upload",
"newFolder": "New Folder",
"refresh": "Refresh",
"create": "Create",
"cancel": "Cancel",
"delete": "Delete",
"rename": "Rename",
"move": "Move",
"preview": "Preview",
"download": "Download"
},
"dialog": {
"createFolder": "Create New Folder",
"folderName": "Folder name",
"deleteTitle": "Delete \"{name}\"",
"deleteMessage": "Are you sure you want to delete this {type}? This action cannot be undone.",
"renameTitle": "Rename \"{name}\"",
"newName": "New name",
"previewTitle": "File Preview: {name}",
"uploadTitle": "Upload File{s}",
"moveTitle": "Move {type} \"{name}\"",
"selectFiles": "Select files"
},
"type": {
"file": "file",
"folder": "folder",
"files": "files"
},
"status": {
"uploading": "Uploading...",
"success": "Success",
"error": "Error"
}
},
"admin": {
"title": "Admin Panel",
"tab": {
"dashboard": "Dashboard",
"users": "Users",
"shares": "Shares",
"config": "Config",
"settings": "Settings"
},
"section": {
"systemResources": "System Resources",
"validation": "Validation",
"configuration": "Admin Configuration",
"vlmPrompts": "VLM Prompts",
"languagePacks": "Language Packs",
"llmConfig": "LLM Configuration",
"processorConfig": "Processor Configuration",
"filterConfig": "Filter Configuration",
"searchConfig": "Search Configuration",
"systemConfig": "System Configuration",
"uploadPath": "Upload Path",
"serviceUrls": "Service URLs"
},
"resource": {
"cpu": "CPU",
"memory": "Memory",
"swap": "Swap",
"gpu": "GPU",
"gpuFreq": "GPU Freq",
"systemPower": "System Power"
},
"table": {
"username": "Username",
"homeDir": "Home Dir",
"status": "Status",
"name": "Name",
"port": "Port",
"filter": "Filter",
"parameters": "Parameters"
},
"config": {
"systemPrompt": "System Prompt",
"prompt": "Prompt",
"userPrompt": "User Prompt",
"maxTokens": "Max Tokens",
"url": "URL",
"model": "Model",
"availableLanguages": "Available Languages",
"importLanguagePack": "Import Language Pack",
"tools": "Tools"
},
"stat": {
"users": "Users",
"shares": "Shares"
}
}
}

View File

@@ -1,12 +1,14 @@
import { createI18n } from 'vue-i18n'
import en from './en.json'
import zhTW from './zh-TW.json'
import ja from './ja.json'
export type MessageSchema = typeof en
const supportedLanguages = {
'en': en,
'zh-TW': zhTW,
'ja': ja,
}
function detectInitialLanguage(): string {
@@ -49,7 +51,7 @@ function detectOSLanguage(): string {
return 'en'
}
const i18n = createI18n<[MessageSchema], 'en' | 'zh-TW'>({
const i18n = createI18n<[MessageSchema], 'en' | 'zh-TW' | 'ja'>({
legacy: false,
locale: detectInitialLanguage(),
fallbackLocale: 'en',

448
src/locales/ja.json Normal file
View File

@@ -0,0 +1,448 @@
{
"common": {
"loading": "読み込み中...",
"error": "エラー",
"success": "成功",
"cancel": "キャンセル",
"confirm": "確認",
"delete": "削除",
"save": "保存",
"edit": "編集",
"search": "検索",
"back": "戻る",
"close": "閉じる",
"play": "再生",
"logout": "ログアウト",
"login": "ログイン"
},
"library": {
"sort": {
"title": "並べ替え",
"time_desc": "時間(新しい順)",
"time_asc": "時間(古い順)",
"size_desc": "サイズ(大きい順)",
"size_asc": "サイズ(小さい順)",
"name_asc": "名前(A-Z)",
"name_desc": "名前(Z-A)"
},
"filter": {
"title": "フィルター",
"unregistered": "未登録",
"registered": "登録済み",
"pending": "保留中",
"completed": "完了",
"videos_only": "動画のみ",
"photos_only": "写真のみ"
},
"size": {
"title": "サイズ",
"min_mb": "最小 MB",
"max_mb": "最大 MB"
},
"duration": {
"title": "動画の長さ",
"min_minutes": "最小 分",
"max_minutes": "最大 分"
},
"status": {
"files": "{count} ファイル",
"registered": "登録済み",
"unregistered": "未登録",
"pending": "保留中",
"processing": "処理中",
"completed": "完了",
"scanning": "スキャン中",
"ready": "準備完了",
"keyword_ready": "キーワード準備完了",
"semantic_ready": "意味検索準備完了",
"face_mgmt_ready": "顔管理準備完了",
"agent_ready": "エージェント準備完了"
},
"thumbnail": {
"register_to_preview": "プレビューを表示するには登録してください"
},
"context": {
"register_file": "ファイルを登録",
"process_file": "ファイルを処理",
"reprocess": "再処理",
"unregister": "登録解除",
"play_video": "ビデオを再生",
"processor_selection": "プロセッサー選択",
"overall_progress": "全体の進捗",
"missing_file_path": "ファイルパスがありません",
"file_not_registered": "ファイルが登録されていません",
"select_processor": "少なくとも1つのプロセッサーを選択してください",
"file_must_be_completed": "ファイルが完了している必要があります",
"face_deduplication": "顔の重複排除",
"qc_check": "品質チェック"
},
"pipeline": {
"processors": "プロセッサー",
"rule1_ingestion": "Rule1 取り込み",
"face_tracing": "顔追跡",
"tkg_nodes": "TKG ノード",
"tkg_edges": "TKG エッジ",
"rule2_ingestion": "Rule2 取り込み"
},
"phase": {
"processor_output": "Rule1: プロセッサー出力",
"postgresql_ingestion": "Rule1: PostgreSQL 取り込み",
"qdrant_ingestion": "Rule1: Qdrant 取り込み"
},
"action": {
"registering": "登録中...",
"registered": "登録済み: {name}",
"registration_failed": "登録失敗: {message}",
"registration_error": "登録エラー: {error}",
"processing": "処理中...",
"processing_started": "処理開始: {name}",
"processing_failed": "処理失敗: {message}",
"processing_error": "処理エラー: {error}",
"removing": "削除中...",
"removed": "削除済み: {name}",
"removal_failed": "削除失敗: {message}",
"removal_error": "削除エラー: {error}",
"clear_filters": "フィルターをクリア"
},
"qc": {
"audio_info": "オーディオ情報",
"audio_track": "オーディオトラック",
"has_audio": "✓ オーディオあり",
"no_audio": "✗ オーディオなし",
"speech": "音声",
"has_speech": "✓ 音声あり",
"no_speech": "🔇 音声なし",
"language": "言語",
"sample_rate": "サンプリングレート",
"uuid_label": "UUID:",
"track_processor_output": "フェーズ1: プロセッサー出力",
"track_ingestion": "フェーズ2: データ取り込み",
"track_status_change": "最終: ステータス変更",
"output_ingestion_done": "📥 出力 + 取り込み完了",
"items": "{count} アイテム"
},
"refresh": "更新",
"refresh_status": "ステータスを更新",
"confirm": {
"title_register": "📥 登録",
"title_process": "🔍 詳細スキャン",
"title_rescan": "🔄 再スキャン",
"message_register": "ファイルがデフォルト設定で登録・処理されます。",
"message_register_no_scan": "登録のみ、自動スキャンなし。",
"message_process": "選択したプロセッサーでデフォルト設定を使用して処理します。",
"message_rescan": "登録解除 → 登録 → ディープスキャンを実行します(選択したプロセッサを使用)。",
"cancel": "キャンセル",
"proceed": "続行",
"rerun_processor": "{processor} を再実行しますか?",
"run_processor": "{filename} で {processor} を実行しますか?",
"unregister_title": "登録解除",
"unregister_message": "「{filename}」を登録解除しますか?\n\nファイルとその出力データが削除されます。"
},
"processor": {
"started": "{processor} が開始されました",
"failed": "失敗:{message}",
"error": "エラー:{message}",
"no_file": "エラー:ファイルが選択されていません",
"vlm_failed": "VLM サマリーの開始に失敗:{message}",
"pipeline_qc": "パイプライン品質チェック"
},
"settings": {
"auto_scan": "登録後に自動的に詳細スキャン"
}
},
"people": {
"toolbar": {
"all_files": "全ファイル",
"view_all_people": "全人物を表示",
"view_starred_people": "スター付き人物を表示",
"refresh": "更新",
"pending_people": "人物",
"skipped_people": "スキップされた人物",
"pending_faces": "顔",
"face_group": "顔グループ"
},
"section": {
"known_people": "既知の人物",
"pending_people": "人物",
"skipped_people": "スキップされた人物",
"pending_faces": "顔",
"face_group": "顔グループ",
"unassigned": "未割り当て"
},
"sort": {
"title": "並べ替え",
"recent": "最近表示",
"file_name": "ファイル名",
"name_az": "名前(A-Z)",
"name_za": "名前(Z-A)",
"time_desc": "時間(新しい順)",
"time_asc": "時間(古い順)"
},
"search": {
"known_people": "既知の人物を検索",
"pending_people": "保留中の人物を検索",
"person_name": "人物名を検索..."
},
"action_history": {
"title": "操作履歴 · {name}",
"undo_all": "すべて元に戻す",
"redo_all": "すべてやり直し",
"delete_person": "この人物を削除",
"no_history": "操作履歴なし"
},
"expand": {
"collapse": "折りたたむ",
"expand": "展開",
"view_faces": "顔を表示",
"appears_in": "登場:",
"faces": "{count} 顔",
"set_as_profile": "プロフィールに設定",
"no_face_data": "顔データなし"
},
"context": {
"star_person": "★ 重要としてマーク",
"unstar_person": "☆ 重要マークを削除",
"details": "📋 詳細",
"confirm_person": "✓ 人物を確認",
"pending": "保留中",
"edit_name": "✎ 名前を編集",
"merge_person": "⇄ 既にこの人物が存在",
"skip_person": "✕ この人物をスキップ",
"undo": "↩ 元に戻す",
"redo": "↪ やり直し",
"delete_person": "🗑 この人物を削除"
},
"face_context": {
"details": "📋 詳細",
"assign_existing": "⇄ 既存の人物に割り当て",
"skip_face": "✕ この顔をスキップ"
},
"assign": {
"title": "既存の人物に割り当て",
"search_placeholder": "人物名を検索...",
"no_results": "一致する人物が見つかりません",
"confirm_assign": "割り当てを確認"
},
"face_detail": {
"title": "保留中の顔の詳細",
"file": "ファイル:",
"trace_id": "トレースID:",
"frame_number": "フレーム番号:",
"confidence": "信頼度:",
"face_id": "顔ID:",
"play_segment": "動画セグメントを再生",
"new_identity": "新しい人物として作成",
"enter_name": "人物名を入力...",
"add": "追加",
"close": "閉じる"
},
"detail": {
"back": "戻る",
"edit": "編集",
"role": "役割",
"description": "説明",
"enter_name": "名前を入力",
"enter_alias": "エイリアスを入力してEnter",
"add_alias": "+ エイリアス",
"collapse": "折りたたむ",
"role_name": "役割名",
"custom_description": "カスタム説明",
"saving": "保存中...",
"save_changes": "✓ 変更を保存",
"cancel": "キャンセル",
"add_same_person": "同じ人物を追加",
"play": "▶ 再生",
"unbind": "✕ バインド解除",
"segments": "{count} セグメント",
"traces": "{count} トレース",
"all_segments": "全セグメント",
"mode_trace": "トレース",
"mode_segment": "セグメント",
"mode_continuous": "連続",
"status": "ステータス:",
"confirm": "✓ 確認",
"skip": "スキップ",
"bind_face": "+ 顔をバインド",
"merge_other": "⇄ 他の人物にマージ",
"delete_person": "この人物を削除",
"bind_face_title": "顔をバインド",
"merge_title": "⇄ 他の人物にマージ",
"unbind_trace": "トレース T{trace_id} のバインドを解除(顔ID: {face_id})?",
"no_face_id": "このトレースには顔IDがありません。バインド解除できません",
"unbind_failed": "バインド解除失敗: {error}",
"merge_confirm": "「{source}」を「{target}」にマージしますか?",
"not_this_person": "この人物ではない",
"delete_confirm": "「{name}」を削除しますか?",
"delete_confirm_message": "「{name}」を削除しますか?この操作は元に戻せません。",
"delete_failed": "削除失敗: {error}",
"create_identity_failed": "人物の作成に失敗: APIがidentity_uuidを返しませんでした",
"create_identity_error": "人物の作成に失敗: {error}",
"assign_failed": "割り当て失敗: {error}"
},
"action": {
"delete_person": "人物を削除",
"update_person": "人物を更新",
"bind_face": "顔をバインド",
"unbind_face": "顔のバインド解除",
"bind_trace": "トレースをバインド"
}
},
"agent": {
"welcome": {
"title": "👋 Momentry Studioへようこそ",
"intro": "システムをより効果的に活用できるようサポートします。主な機能を紹介します:",
"smart_search": {
"title": "スマート検索",
"desc": "キーワード、意味、人物検索モード"
},
"people_management": {
"title": "人物管理",
"desc": "動画内の人物を識別し、人物データベースを構築"
},
"file_management": {
"title": "ファイル管理",
"desc": "動画・写真ファイルの管理と処理"
},
"got_it": "了解しました、使用開始"
}
},
"login": {
"title": "Momentry Studio",
"subtitle": "続行するにはログインしてください",
"username": "ユーザー名",
"password": "パスワード",
"username_placeholder": "ユーザー名を入力",
"password_placeholder": "パスワードを入力",
"login_button": "ログイン",
"logging_in": "ログイン中...",
"login_success": "ログイン成功!",
"default_credentials": "デフォルト認証情報: demo / demo123"
},
"nav": {
"search": "検索",
"library": "ライブラリ",
"people": "人物",
"admin": "管理",
"client": "クライアント"
},
"time": {
"just_now": "たった今",
"minutes_ago": "{count} 分前",
"hours_ago": "{count} 時間前"
},
"video": {
"prev": "前へ",
"next": "次へ",
"key_frame": "キーフレーム"
},
"language": {
"en": "英語",
"zh-TW": "繁体字中国語",
"zh-CN": "簡体字中国語",
"ja": "日本語",
"ko": "韓国語",
"auto": "自動検出"
},
"client": {
"title": "ファイルマネージャー",
"search": {
"placeholder": "ファイルを検索..."
},
"table": {
"name": "名前",
"size": "サイズ",
"type": "タイプ",
"modified": "更新日時"
},
"button": {
"upload": "アップロード",
"newFolder": "新しいフォルダ",
"refresh": "更新",
"create": "作成",
"cancel": "キャンセル",
"delete": "削除",
"rename": "名前変更",
"move": "移動",
"preview": "プレビュー",
"download": "ダウンロード"
},
"dialog": {
"createFolder": "新しいフォルダを作成",
"folderName": "フォルダ名",
"deleteTitle": "「{name}」を削除",
"deleteMessage": "この{type}を削除してもよろしいですか?この操作は取り消せません。",
"renameTitle": "「{name}」の名前を変更",
"newName": "新しい名前",
"previewTitle": "ファイルプレビュー:{name}",
"uploadTitle": "ファイルをアップロード{s}",
"moveTitle": "{type}「{name}」を移動",
"selectFiles": "ファイルを選択"
},
"type": {
"file": "ファイル",
"folder": "フォルダ",
"files": "ファイル"
},
"status": {
"uploading": "アップロード中...",
"success": "成功",
"error": "エラー"
}
},
"admin": {
"title": "管理パネル",
"tab": {
"dashboard": "ダッシュボード",
"users": "ユーザー",
"shares": "共有",
"config": "設定",
"settings": "設定"
},
"section": {
"systemResources": "システムリソース",
"validation": "検証",
"configuration": "管理設定",
"vlmPrompts": "VLM プロンプト",
"languagePacks": "言語パック",
"llmConfig": "LLM 設定",
"processorConfig": "プロセッサ設定",
"filterConfig": "フィルタ設定",
"searchConfig": "検索設定",
"systemConfig": "システム設定",
"uploadPath": "アップロードパス",
"serviceUrls": "サービス URL"
},
"resource": {
"cpu": "CPU",
"memory": "メモリ",
"swap": "スワップ",
"gpu": "GPU",
"gpuFreq": "GPU 周波数",
"systemPower": "システム電力"
},
"table": {
"username": "ユーザー名",
"homeDir": "ホームディレクトリ",
"status": "ステータス",
"name": "名前",
"port": "ポート",
"filter": "フィルタ",
"parameters": "パラメータ"
},
"config": {
"systemPrompt": "システムプロンプト",
"prompt": "プロンプト",
"userPrompt": "ユーザープロンプト",
"maxTokens": "最大トークン数",
"url": "URL",
"model": "モデル",
"availableLanguages": "利用可能な言語",
"importLanguagePack": "言語パックをインポート",
"tools": "ツール"
},
"stat": {
"users": "ユーザー",
"shares": "共有"
}
}
}

View File

@@ -44,30 +44,37 @@
"min_minutes": "最小分鐘",
"max_minutes": "最大分鐘"
},
"status": {
"status": {
"files": "{count} 個檔案",
"registered": "已註冊",
"unregistered": "未註冊",
"pending": "等待中",
"processing": "處理中",
"completed": "已完成"
"completed": "已完成",
"scanning": "掃描中",
"ready": "就緒",
"keyword_ready": "關鍵字可用",
"semantic_ready": "語意可用",
"face_mgmt_ready": "臉部管理可用",
"agent_ready": "Agent 可用"
},
"thumbnail": {
"register_to_preview": "註冊以預覽"
},
"context": {
"register_file": "註冊此檔案",
"process_file": "處理此檔案",
"context": {
"register_file": "註冊檔案",
"process_file": "處理檔案",
"reprocess": "重新處理",
"run_identity_agent": "執行 Identity Agent",
"unregister": "移除註冊",
"unregister": "取消註冊",
"play_video": "播放影片",
"processor_selection": "處理器選擇",
"overall_progress": "整體進度",
"missing_file_path": "缺少檔案路徑",
"file_not_registered": "檔案尚未註冊",
"select_processor": "請至少選擇一個處理器",
"file_must_be_completed": "檔案需先完成處理"
"file_not_registered": "檔案未註冊",
"select_processor": "請選擇至少一個處理器",
"file_must_be_completed": "檔案必須先完成處理",
"face_deduplication": "人臉去重",
"qc_check": "品質檢查"
},
"pipeline": {
"processors": "處理器",
@@ -91,17 +98,57 @@
"processing_started": "處理已觸發:{name}",
"processing_failed": "處理失敗:{message}",
"processing_error": "處理錯誤:{error}",
"identity_agent_started": "Identity Agent 已啟動:{name}",
"identity_agent_failed": "Identity Agent 失敗:{message}",
"identity_agent_error": "Identity Agent 錯誤:{error}",
"removing": "移除中...",
"removed": "移除成功:{name}",
"removal_failed": "移除失敗:{message}",
"removal_error": "移除錯誤:{error}",
"clear_filters": "清除篩選"
},
"qc": {
"audio_info": "音軌資訊",
"audio_track": "音軌",
"has_audio": "✓ 有音軌",
"no_audio": "✗ 無音軌",
"speech": "語音",
"has_speech": "✓ 有語音",
"no_speech": "🔇 無語音",
"language": "語言",
"sample_rate": "取樣率",
"uuid_label": "UUID:",
"track_processor_output": "路徑一:處理器輸出",
"track_ingestion": "路徑二:資料入庫",
"track_status_change": "終點:狀態改變",
"output_ingestion_done": "📥 輸出 + 入庫 完成",
"items": "{count} 筆"
},
"refresh": "重新整理",
"refresh_status": "重新整理狀態"
"refresh_status": "重新整理狀態",
"confirm": {
"title_register": "📥 註冊",
"title_process": "🔍 深度掃描",
"title_rescan": "🔄 重新掃描",
"message_register": "檔案將以預設設定註冊並處理。",
"message_register_no_scan": "僅註冊,不自動掃描。",
"message_process": "將使用預設設定對選定的處理器執行處理。",
"message_rescan": "將執行取消註冊 → 註冊 → 深度掃描(使用選擇的處理器)。",
"cancel": "取消",
"proceed": "開始",
"rerun_processor": "重新執行 {processor}?",
"run_processor": "在 {filename} 上執行 {processor}?",
"unregister_title": "取消註冊",
"unregister_message": "取消註冊「{filename}」?\n\n這將移除檔案及其輸出資料。"
},
"processor": {
"started": "{processor} 已開始",
"failed": "失敗:{message}",
"error": "錯誤:{message}",
"no_file": "錯誤:未選擇檔案",
"vlm_failed": "無法觸發 VLM 摘要:{message}",
"pipeline_qc": "管道品質檢查"
},
"settings": {
"auto_scan": "註冊後自動深度掃描"
}
},
"people": {
"toolbar": {
@@ -119,7 +166,8 @@
"pending_people": "人物",
"skipped_people": "已略過",
"pending_faces": "人臉",
"face_group": "人臉群組"
"face_group": "人臉群組",
"unassigned": "未分組"
},
"sort": {
"title": "排序方式",
@@ -230,6 +278,13 @@
"create_identity_failed": "新增人物失敗:API 未返回 identity_uuid",
"create_identity_error": "新增人物失敗:{error}",
"assign_failed": "指派失敗:{error}"
},
"action": {
"delete_person": "刪除人物",
"update_person": "更新人物",
"bind_face": "綁定面部",
"unbind_face": "解綁面部",
"bind_trace": "綁定軌跡"
}
},
"agent": {
@@ -269,5 +324,125 @@
"people": "People",
"admin": "Admin",
"client": "Client"
},
"time": {
"just_now": "剛剛",
"minutes_ago": "{count} 分鐘前",
"hours_ago": "{count} 小時前"
},
"video": {
"prev": "上一個",
"next": "下一個",
"key_frame": "定位關鍵影格"
},
"language": {
"en": "English",
"zh-TW": "繁體中文",
"zh-CN": "简体中文",
"ja": "日本語",
"ko": "한국어",
"auto": "自動偵測"
},
"client": {
"title": "檔案管理器",
"search": {
"placeholder": "搜尋檔案..."
},
"table": {
"name": "名稱",
"size": "大小",
"type": "類型",
"modified": "修改時間"
},
"button": {
"upload": "上傳",
"newFolder": "新建資料夾",
"refresh": "刷新",
"create": "創建",
"cancel": "取消",
"delete": "刪除",
"rename": "重命名",
"move": "移動",
"preview": "預覽",
"download": "下載"
},
"dialog": {
"createFolder": "創建新資料夾",
"folderName": "資料夾名稱",
"deleteTitle": "刪除「{name}」",
"deleteMessage": "確定要刪除此{type}嗎?此操作無法撤銷。",
"renameTitle": "重命名「{name}」",
"newName": "新名稱",
"previewTitle": "檔案預覽:{name}",
"uploadTitle": "上傳檔案{s}",
"moveTitle": "移動{type}「{name}」",
"selectFiles": "選擇檔案"
},
"type": {
"file": "檔案",
"folder": "資料夾",
"files": "個檔案"
},
"status": {
"uploading": "上傳中...",
"success": "成功",
"error": "錯誤"
}
},
"admin": {
"title": "管理面板",
"tab": {
"dashboard": "儀表板",
"users": "用戶",
"shares": "共享",
"config": "配置",
"settings": "設定"
},
"section": {
"systemResources": "系統資源",
"validation": "驗證",
"configuration": "管理配置",
"vlmPrompts": "VLM 提示詞",
"languagePacks": "語言包",
"llmConfig": "LLM 配置",
"processorConfig": "處理器配置",
"filterConfig": "過濾器配置",
"searchConfig": "搜索配置",
"systemConfig": "系統配置",
"uploadPath": "上傳路徑",
"serviceUrls": "服務 URL"
},
"resource": {
"cpu": "CPU",
"memory": "記憶體",
"swap": "交換空間",
"gpu": "GPU",
"gpuFreq": "GPU 頻率",
"systemPower": "系統功耗"
},
"table": {
"username": "用戶名",
"homeDir": "主目錄",
"status": "狀態",
"name": "名稱",
"port": "端口",
"filter": "過濾器",
"parameters": "參數"
},
"config": {
"systemPrompt": "系統提示詞",
"prompt": "提示詞",
"userPrompt": "用戶提示詞",
"maxTokens": "最大 Token 數",
"url": "URL",
"model": "模型",
"availableLanguages": "可用語言",
"importLanguagePack": "導入語言包",
"tools": "工具"
},
"stat": {
"users": "用戶",
"shares": "共享"
}
}
}

View File

@@ -1,7 +1,7 @@
import { ref } from 'vue'
import { ref, reactive } from 'vue'
import { apiCall } from '@/api'
import { isTauri } from './api/config'
import type { PoseResponse, AppearanceResponse } from '@/api/types'
import { isTauri, getApiBase } from './api/config'
import type { PoseResponse, AppearanceResponse, FaceLandmarksResponse } from '@/api/types'
export const filesCache = ref<any[]>([])
export const filesLoaded = ref(false)
@@ -16,13 +16,37 @@ export const unassignedTracesCache = ref<any[]>([])
export const unassignedTracesLoaded = ref(false)
export const unassignedTracesTotal = ref(0)
export const unassignedTracesPage = ref(1)
export const faceCountsCache = ref<Record<string, number>>({})
const UNASSIGNED_PER_PAGE = 20
export async function loadFaceCount(fileUuid: string) {
if (faceCountsCache.value[fileUuid] !== undefined) return
try {
const result = await apiCall('get_file_traces', { fileUuid, page: 1, page_size: 1, limit: 1 })
faceCountsCache.value[fileUuid] = result.total || 0
} catch (e) {
console.error('loadFaceCount error:', e)
}
}
export const thumbnailsCache = ref<Record<string, string>>({})
export const profilesCache = ref<Record<string, string>>({})
export const faceThumbsCache = ref<Record<string, string>>({})
export const poseCache = ref<Record<string, PoseResponse>>({})
export const appearanceCache = ref<Record<string, AppearanceResponse>>({})
export const faceLandmarksCache = ref<Record<string, FaceLandmarksResponse>>({})
let thumbCacheBust = 0
export function bumpThumbCacheBust() {
thumbCacheBust += 1
}
export function clearFaceThumbs() {
for (const k of Object.keys(faceThumbsCache.value)) {
const v = faceThumbsCache.value[k]
if (v && v.startsWith('blob:')) URL.revokeObjectURL(v)
}
faceThumbsCache.value = {}
}
export interface ProcessorOutputInfo {
has_json: boolean
@@ -63,6 +87,36 @@ export interface PipelineStats {
stages: PipelineStage[]
}
export interface SystemStats {
cpu_used_percent: number
memory_used_percent: number
memory_available_mb: number
memory_total_mb: number
swap_used_percent: number
swap_available_mb: number
swap_total_mb: number
gpu_available: boolean
gpu_utilization: number | null
gpu_memory_used_pct: number | null
ane_available: boolean
ane_utilization: number | null
source: string
chip_name?: string
cpu_power?: number
gpu_power?: number
ane_power?: number
sys_power?: number
ram_power?: number
cpu_temp?: number
gpu_temp?: number
ecpu_freq_mhz?: number
pcpu_freq_mhz?: number
gpu_freq_mhz?: number
ecpu_cores?: number
pcpu_cores?: number
gpu_cores?: number
}
// File详细统计接口
export interface JsonProcessorInfo {
status: string
@@ -131,13 +185,27 @@ export interface IdentityAgentStats {
confirmations: number
}
export interface FileStats {
json: JsonStats
postgresql: PostgresqlStats
qdrant: QdrantStats
tkg: TkgStats
identity_agent: IdentityAgentStats
}
export interface FileStats {
json: JsonStats
postgresql: PostgresqlStats
qdrant: QdrantStats
tkg: TkgStats
identity_agent: IdentityAgentStats
vlm_summary: VlmSummaryStats
scene_vlm: SceneVlmStats
}
export interface VlmSummaryStats {
total: number
done: number
}
export interface SceneVlmStats {
total_scenes: number
status: string
vlm_done: number
vlm_total: number
}
export const processorCountsCache = ref<Record<string, Record<string, ProcessorOutputInfo>>>({})
export const fileProgressCache = ref<Record<string, FileProgress>>({})
@@ -146,22 +214,47 @@ export const fileStatusCache = ref<Record<string, string>>({})
// Cluster agent state (persistent across navigation)
export const clusterResultsCache = ref<Record<string, any[]>>({})
export const clusterRunningState = ref<Record<string, boolean>>({})
const clusterRunningSince = ref<Record<string, number>>({})
const CLUSTER_RUNNING_STALE_MS = 5 * 60 * 1000
function loadClusterStateFromStorage() {
try {
const saved = localStorage.getItem('momentry_cluster_running')
if (saved) clusterRunningState.value = JSON.parse(saved)
} catch (e) {}
try {
const since = localStorage.getItem('momentry_cluster_running_since')
if (since) clusterRunningSince.value = JSON.parse(since)
} catch (e) {}
const now = Date.now()
let changed = false
for (const uuid of Object.keys(clusterRunningState.value)) {
const sinceTs = clusterRunningSince.value[uuid]
if (clusterRunningState.value[uuid] && (!sinceTs || now - sinceTs > CLUSTER_RUNNING_STALE_MS)) {
clusterRunningState.value[uuid] = false
delete clusterRunningSince.value[uuid]
changed = true
}
}
if (changed) saveClusterStateToStorage()
}
function saveClusterStateToStorage() {
try {
localStorage.setItem('momentry_cluster_running', JSON.stringify(clusterRunningState.value))
} catch (e) {}
try {
localStorage.setItem('momentry_cluster_running_since', JSON.stringify(clusterRunningSince.value))
} catch (e) {}
}
loadClusterStateFromStorage()
export function setClusterRunning(fileUuid: string, running: boolean) {
clusterRunningState.value[fileUuid] = running
if (running) {
clusterRunningSince.value[fileUuid] = Date.now()
} else {
delete clusterRunningSince.value[fileUuid]
}
saveClusterStateToStorage()
}
@@ -170,7 +263,7 @@ export function isClusterRunning(fileUuid: string): boolean {
}
export async function pollClusterResults(fileUuid: string, onDone?: (results: any[]) => void) {
if (!fileUuid || isClusterRunning(fileUuid)) return
if (!fileUuid) return
setClusterRunning(fileUuid, true)
try {
for (let i = 0; i < 120; i++) {
@@ -196,7 +289,7 @@ export async function pollClusterResults(fileUuid: string, onDone?: (results: an
export async function loadClusterNames(fileUuid: string, clusters: any[]) {
console.log('[loadClusterNames] loading names for', clusters.length, 'clusters')
const promises = clusters.map(async (c) => {
const repTid = c.representative_trace || c.trace_ids?.[0]
const repTid = c.representative_trace !== undefined && c.representative_trace !== null ? c.representative_trace : c.trace_ids?.[0]
if (repTid != null) {
try {
const key = `${fileUuid}:${repTid}`
@@ -340,13 +433,14 @@ const MAX_PROFILE_CONCURRENT = 4
const loadingThumbs = new Set<string>()
const loadingProfiles = new Set<string>()
const loadingFaceThumbs = new Set<string>()
export const loadingFaceThumbs = reactive(new Set<string>())
const loadingProcessorCounts = new Set<string>()
const pollingProgress = new Set<string>()
export const pollingProgressSet = pollingProgress
export const pipelineStatsCache = ref<Record<string, PipelineStats>>({})
export const fileStatsCache = ref<Record<string, FileStats>>({})
export const systemStatsCache = ref<SystemStats | null>(null)
const loadingPipelineStats = new Set<string>()
const loadingFileStats = new Set<string>()
@@ -410,6 +504,7 @@ export function startFilesWatch() {
}
// Check status changes for existing files
let hasStatusChange = false
for (const newFile of newFiles) {
if (!newFile.file_uuid) continue
const oldFile = filesCache.value.find((f: any) => f.file_uuid === newFile.file_uuid)
@@ -417,14 +512,19 @@ export function startFilesWatch() {
// Status changed, update cache
const idx = filesCache.value.findIndex((f: any) => f.file_uuid === newFile.file_uuid)
if (idx >= 0) {
filesCache.value[idx] = newFile
filesCache.value.splice(idx, 1, newFile)
hasStatusChange = true
}
}
}
if (hasStatusChange) {
console.log('[startFilesWatch] Status changes detected and updated')
}
} catch (e) {
console.error('[startFilesWatch] Error:', e)
}
}, 5000)
}, 3000)
}
export function stopFilesWatch() {
@@ -570,8 +670,8 @@ export async function ensureUnassignedTraces(fileUuid: string, page?: number) {
}
_unassignedTracesLoading = true
try {
const args: any = { page: targetPage, perPage: UNASSIGNED_PER_PAGE, fileUuid }
const batch: any = await apiCall('get_unassigned_traces', args)
const args: any = { page: 1, page_size: 3000, limit: 3000, fileUuid }
const batch: any = await apiCall('get_file_traces', args)
const traces = batch?.traces || []
unassignedTracesCache.value = traces
unassignedTracesTotal.value = batch?.total ?? traces.length
@@ -586,19 +686,21 @@ export async function ensureUnassignedTraces(fileUuid: string, page?: number) {
}
export function loadTraceThumb(t: any) {
if (!t.file_uuid || !t.trace_id) return
const key = `${t.trace_id}-${t.file_uuid}-${t.best_face_id || 0}`
if (!t.file_uuid || t.trace_id === undefined || t.trace_id === null) return
const key = `${t.file_uuid}-${t.trace_id}`
if (faceThumbsCache.value[key] || loadingFaceThumbs.has(key)) return
loadingFaceThumbs.add(key)
queueThumb(async () => {
try {
// Use direct trace-thumb endpoint for fast keyFace.jpg loading (~8ms)
const url = `/api/v1/trace-thumb/${t.file_uuid}/${t.trace_id}`
// 改用自己的 robust face-thumbnail 端點(三層 fallback:.faces cache →
// key_face.jpg → live 重切格),避免 trace-thumb 缺檔時 404 破圖。
const base = getApiBase()
const ver = thumbCacheBust > 0 ? `&t=${thumbCacheBust}` : ''
const url = `${base}/api/v1/file/${t.file_uuid}/thumbnail?trace_id=${t.trace_id}${ver}`
const resp = await fetch(url)
if (resp.ok) {
const blob = await resp.blob()
const dataUrl = URL.createObjectURL(blob)
faceThumbsCache.value[key] = dataUrl
faceThumbsCache.value[key] = URL.createObjectURL(blob)
}
} catch (e) {
console.error('loadTraceThumb failed:', key, e)
@@ -795,15 +897,11 @@ export async function pollProgress(fileUuid: string) {
gpu_percent: data.gpu_percent,
memory_percent: data.memory_percent,
}
fileStatusCache.value[fileUuid] = 'processing'
const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (f) f.status = 'processing'
// No local status promotion - status comes from Core API only
saveProgressCacheToStorage()
const allComplete = data.processors?.every((p: any) => p.status === 'complete' || p.status === 'failed')
if (allComplete || data.overall_progress >= 100) {
stopPolling(fileUuid)
fileStatusCache.value[fileUuid] = 'completed'
if (f) f.status = 'completed'
saveProgressCacheToStorage()
await loadProcessorCounts(fileUuid)
break
@@ -838,22 +936,8 @@ export async function refreshFileStatus(fileUuid: string) {
try {
const pipeline = await loadPipelineStats(fileUuid, true)
if (pipeline) {
const isComplete = pipeline.overall_progress >= 1.0 || pipeline.stages?.every((s: any) => s.status === 'completed' || s.status === 'failed')
const isRunning = pipeline.stages?.some((s: any) => s.status === 'running')
if (isComplete) {
fileStatusCache.value[fileUuid] = 'completed'
await loadFileStats(fileUuid, true)
} else if (isRunning) {
fileStatusCache.value[fileUuid] = 'processing'
}
// Also update the file object in filesCache
const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (f) {
if (isComplete) f.status = 'completed'
else if (isRunning) f.status = 'processing'
}
// No local status promotion - status comes from Core API only
await loadFileStats(fileUuid, true)
}
} catch (e) {
console.error('refreshFileStatus failed:', fileUuid, e)
@@ -921,10 +1005,12 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis
processors: 0.30,
rule1_ingestion: 0.05,
face_tracing: 0.05,
identity_agent: 0.10,
face_dedup: 0.10,
tkg_nodes: 0.20,
tkg_edges: 0.15,
rule2_ingestion: 0.15
rule2_ingestion: 0.10,
vlm_summary: 0.04,
moment_chunks: 0.01
}
const mappedStages = stages.map((s: any) => ({
@@ -934,7 +1020,6 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis
status: s.status || 'pending'
}))
// 用前端权重重新计算 overall_progress(不信任 API 返回的值)
const calculatedProgress = mappedStages.reduce((sum, s) => sum + s.weight * s.progress, 0)
const pipelineStats: PipelineStats = {
@@ -946,14 +1031,7 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis
pipelineStatsCache.value = { ...pipelineStatsCache.value, [fileUuid]: pipelineStats }
// If pipeline is complete, update file status
if (calculatedProgress >= 1.0) {
const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (f && f.status !== 'completed') {
f.status = 'completed'
fileStatusCache.value[fileUuid] = 'completed'
}
}
// No local status promotion - status comes from Core API only
return pipelineStats
} catch (e) {
@@ -961,11 +1039,9 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis
try {
const oldData: any = await apiCall('get_progress', { fileUuid })
// 检查所有处理器是否都完成
const allComplete = oldData.processors?.length > 0 && oldData.processors?.every((p: any) => p.status === 'complete' || p.status === 'failed')
const hasRunning = oldData.processors?.some((p: any) => p.status === 'running')
// 如果都完成,显示100%
const progress = allComplete ? 1.0 : (oldData.overall_progress || 0) / 100
const status = allComplete ? 'completed' : (hasRunning ? 'running' : 'pending')
@@ -988,6 +1064,19 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis
}
}
export async function loadSystemStats(): Promise<SystemStats | null> {
try {
const resp = await fetch('/api/v1/system-stats')
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data: SystemStats = await resp.json()
systemStatsCache.value = data
return data
} catch (e) {
console.error('loadSystemStats failed:', e)
return null
}
}
export async function loadFileStats(fileUuid: string, force = false): Promise<FileStats | null> {
if (!fileUuid) return null
if (!force && fileStatsCache.value[fileUuid]) return fileStatsCache.value[fileUuid]
@@ -1060,6 +1149,16 @@ export async function loadFileStats(fileUuid: string, force = false): Promise<Fi
tmdb_matches: data.identity_agent?.tmdb_matches || 0,
speaker_bindings: data.identity_agent?.speaker_bindings || 0,
confirmations: data.identity_agent?.confirmations || 0
},
vlm_summary: {
total: data.vlm_summary?.total || 0,
done: data.vlm_summary?.done || 0
},
scene_vlm: {
total_scenes: data.scene_vlm?.total_scenes || 0,
status: data.scene_vlm?.status || '',
vlm_done: data.scene_vlm?.vlm_done || 0,
vlm_total: data.scene_vlm?.vlm_total || 0
}
}
@@ -1090,7 +1189,9 @@ export async function loadFileStats(fileUuid: string, force = false): Promise<Fi
postgresql: { sentence_chunks: 0, trace_chunks: 0, relationship_chunks: 0, identities: 0, file_identities: 0 },
qdrant: { faces: 0, face_traces: 0, face_identities: 0, text_chunks: 0, speakers: 0 },
tkg: { nodes: { character: 0, object: 0, scene: 0, action: 0, location: 0, event: 0, dialogue: 0, emotion: 0, relation: 0 }, edges: { appear_in: 0, interact_with: 0, located_at: 0, perform_action: 0, cause_effect: 0, temporal_link: 0, speak_to: 0, feel: 0 }, total_nodes: 0, total_edges: 0 },
identity_agent: { clusters: 0, identities_created: 0, tmdb_matches: 0, speaker_bindings: 0, confirmations: 0 }
identity_agent: { clusters: 0, identities_created: 0, tmdb_matches: 0, speaker_bindings: 0, confirmations: 0 },
vlm_summary: { total: 0, done: 0 },
scene_vlm: { total_scenes: 0, status: '', vlm_done: 0, vlm_total: 0 }
}
// 从processorCounts填充json.processors
@@ -1132,15 +1233,12 @@ export async function pollPipelineProgress(fileUuid: string) {
const idx = filesCache.value.findIndex((x: any) => x.file_uuid === fileUuid)
if (idx === -1) continue
fileStatusCache.value[fileUuid] = 'processing'
filesCache.value.splice(idx, 1, { ...filesCache.value[idx], status: 'processing' })
// No local status promotion - status comes from Core API only
saveProgressCacheToStorage()
const allComplete = stats.stages.every(s => s.status === 'completed' || s.status === 'failed')
if (allComplete || stats.overall_progress >= 1.0) {
stopPolling(fileUuid)
fileStatusCache.value[fileUuid] = 'completed'
filesCache.value.splice(idx, 1, { ...filesCache.value[idx], status: 'completed' })
saveProgressCacheToStorage()
await loadFileStats(fileUuid, true)
break
@@ -1210,4 +1308,24 @@ export async function getAppearance(fileUuid: string, frame: number, bbox?: { x:
console.error('getAppearance failed:', fileUuid, frame, e)
return null
}
}
export async function getFaceLandmarks(fileUuid: string, frame: number, bbox?: { x: number; y: number; width: number; height: number } | null): Promise<FaceLandmarksResponse | null> {
const key = bbox ? `${fileUuid}:${frame}:${bbox.x}:${bbox.y}` : `${fileUuid}:${frame}`
if (faceLandmarksCache.value[key]) return faceLandmarksCache.value[key]
try {
const args: any = { fileUuid, frame }
if (bbox) {
args.bboxX = bbox.x
args.bboxY = bbox.y
args.bboxW = bbox.width
args.bboxH = bbox.height
}
const data = await apiCall('get_face_landmarks', args)
if (data) faceLandmarksCache.value[key] = data
return data
} catch (e) {
console.error('getFaceLandmarks failed:', fileUuid, frame, e)
return null
}
}

28
src/stores/autoScan.ts Normal file
View File

@@ -0,0 +1,28 @@
// Auto Scan Store - controls whether registering a file triggers an automatic deep scan
import { ref, computed } from 'vue'
const AUTO_SCAN_KEY = 'momentry_auto_scan_after_register'
// Load initial state (default OFF: pure registration, no auto deep scan)
const savedAutoScan = localStorage.getItem(AUTO_SCAN_KEY)
const autoScanEnabled = ref(savedAutoScan === 'true')
export function useAutoScan() {
const autoScanAfterRegister = computed(() => autoScanEnabled.value)
function toggleAutoScan() {
autoScanEnabled.value = !autoScanEnabled.value
localStorage.setItem(AUTO_SCAN_KEY, autoScanEnabled.value.toString())
}
function setAutoScan(value: boolean) {
autoScanEnabled.value = value
localStorage.setItem(AUTO_SCAN_KEY, value.toString())
}
return {
autoScanAfterRegister,
toggleAutoScan,
setAutoScan,
}
}

32
src/stores/themeStore.ts Normal file
View File

@@ -0,0 +1,32 @@
import { ref, watch, onMounted } from 'vue'
const THEME_KEY = 'momentry_theme'
const saved = localStorage.getItem(THEME_KEY) || 'auto'
const theme = ref(saved)
function applyTheme(t: string) {
if (t === 'auto') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light')
} else {
document.documentElement.setAttribute('data-theme', t)
}
}
watch(theme, (v) => {
localStorage.setItem(THEME_KEY, v)
applyTheme(v)
})
export function useTheme() {
onMounted(() => applyTheme(theme.value))
function toggleTheme() {
const current = theme.value === 'auto'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme.value
theme.value = current === 'dark' ? 'light' : 'dark'
}
return { theme, toggleTheme }
}

View File

@@ -29,10 +29,10 @@ export function getAsrStatus(asr: AsrData | null | undefined): AsrStatus {
export function getAsrStatusLabel(status: AsrStatus): string {
const labels: Record<AsrStatus, string> = {
'no_audio_track': '無音軌',
'silent_audio': '無語音',
'has_transcript': '有語音',
'processing': '處理中'
'no_audio_track': 'No Audio Track',
'silent_audio': 'No Speech',
'has_transcript': 'Has Speech',
'processing': 'Processing'
}
return labels[status]
}

View File

@@ -0,0 +1,122 @@
import type { FaceLandmarksResponse } from '@/api/types'
export type LandmarkVerdict = 'PASS' | 'REVIEW' | 'FAIL'
export interface LandmarkScoreResult {
score: number
verdict: LandmarkVerdict
reasons: string[]
}
function pts(arr?: number[][] | null): [number, number][] {
return (arr || []).map((p) => [p[0], p[1]] as [number, number])
}
function centroid(arr?: number[][] | null): [number, number] | null {
const a = pts(arr)
if (a.length === 0) return null
const cx = a.reduce((s, p) => s + p[0], 0) / a.length
const cy = a.reduce((s, p) => s + p[1], 0) / a.length
return [cx, cy]
}
export function scoreFaceLandmarks(fa: FaceLandmarksResponse): LandmarkScoreResult {
const lm = fa.landmarks || {}
const lips = fa.lips || {}
const reasons: string[] = []
// Dimension A: completeness (20)
let a = 20
for (const k of ['left_eye', 'right_eye', 'nose'] as const) {
const v = lm[k] || []
if (v.length === 0) {
a -= 20 / 3
reasons.push(`missing ${k}`)
} else if (![6, 8].includes(v.length)) {
reasons.push(`${k} pts=${v.length}`)
}
}
if (lips.outer_lips && ![0, 14].includes(lips.outer_lips.length)) {
reasons.push(`outer_lips pts=${lips.outer_lips.length}`)
}
if (lips.inner_lips && ![0, 6].includes(lips.inner_lips.length)) {
reasons.push(`inner_lips pts=${lips.inner_lips.length}`)
}
// Dimension B: geometry (50)
let b = 50
const faceSize = Math.max(fa.width, fa.height, 1)
const le = centroid(lm.left_eye)
const re = centroid(lm.right_eye)
const no = centroid(lm.nose)
if (le && re && faceSize > 0) {
const eyeDist = Math.hypot(le[0] - re[0], le[1] - re[1])
const eyeRatio = eyeDist / faceSize
if (eyeRatio < 0.25) {
const pen = Math.min(20, (0.25 - eyeRatio) * 200)
b -= pen
reasons.push(`eye_ratio=${eyeRatio.toFixed(2)} too small`)
} else if (eyeRatio > 0.55) {
const pen = Math.min(20, (eyeRatio - 0.55) * 100)
b -= pen
reasons.push(`eye_ratio=${eyeRatio.toFixed(2)} too large`)
}
const eyeDy = Math.abs(le[1] - re[1]) / faceSize
if (eyeDy > 0.15) {
const pen = Math.min(15, (eyeDy - 0.15) * 100)
b -= pen
reasons.push(`eye_dy=${eyeDy.toFixed(2)} asymmetric`)
}
if (no) {
const noseBelow = (no[1] - Math.max(le[1], re[1])) / faceSize
if (noseBelow < 0.03) {
const pen = Math.min(15, (0.03 - noseBelow) * 50)
b -= pen
reasons.push(`nose_below=${noseBelow.toFixed(2)}`)
} else if (noseBelow > 0.35) {
const pen = Math.min(15, (noseBelow - 0.35) * 50)
b -= pen
reasons.push(`nose_below=${noseBelow.toFixed(2)} too low`)
}
const off = eyeDist > 0 ? (no[0] - (le[0] + re[0]) / 2) / eyeDist : 0
if (Math.abs(off) > 0.4) {
const pen = Math.min(15, (Math.abs(off) - 0.4) * 20)
b -= pen
reasons.push(`nose_off_center=${off.toFixed(2)}`)
}
}
} else {
b -= 30
reasons.push('no eye centroids')
}
for (const k of ['left_eye', 'right_eye', 'nose'] as const) {
for (const p of pts(lm[k])) {
if (!(fa.x - 5 <= p[0] && p[0] <= fa.x + fa.width + 5 && fa.y - 5 <= p[1] && p[1] <= fa.y + fa.height + 5)) {
b -= 10
reasons.push(`${k} outside bbox`)
break
}
}
}
if (Math.min(fa.width, fa.height) < 64) {
b -= 10
reasons.push('face < 64px')
}
// Dimension C: confidence & pose (30)
let c = fa.confidence * 30
const pa = fa.pose_angle
const maxPose = Math.max(Math.abs(pa?.yaw || 0), Math.abs(pa?.roll || 0), Math.abs(pa?.pitch || 0))
if (maxPose > 1.5) {
reasons.push(`pose=${maxPose.toFixed(1)}`)
}
const score = Math.max(0, Math.min(100, a + b + c))
const verdict: LandmarkVerdict = score >= 70 ? 'PASS' : score >= 40 ? 'REVIEW' : 'FAIL'
return { score: Math.round(score * 10) / 10, verdict, reasons }
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,19 +2,19 @@
<div id="ms-view-client" class="client-layout">
<div class="client-header">
<div class="header-top-row">
<h1>File Manager</h1>
<h1>{{ t('client.title') }}</h1>
<div class="header-buttons">
<button @click="goUp" :disabled="currentPath === '/'">Up</button>
<button @click="showCreateFolderDialog">New Folder</button>
<button @click="showUploadDialog">Upload</button>
<button @click="confirmDelete" :disabled="!selectedFile">Delete</button>
<button @click="showMoveDialog" :disabled="!selectedFile">Move</button>
<button @click="refreshFiles">Refresh</button>
<button @click="showCreateFolderDialog">{{ t('client.button.newFolder') }}</button>
<button @click="showUploadDialog">{{ t('client.button.upload') }}</button>
<button @click="confirmDelete" :disabled="!selectedFile">{{ t('client.button.delete') }}</button>
<button @click="showMoveDialog" :disabled="!selectedFile">{{ t('client.button.move') }}</button>
<button @click="refreshFiles">{{ t('client.button.refresh') }}</button>
</div>
</div>
<div class="header-path-row">Path: {{ currentPath }}</div>
<div class="client-search">
<input v-model="searchQuery" type="text" placeholder="Search files..." @input="onSearch" />
<input v-model="searchQuery" type="text" :placeholder="t('client.search.placeholder')" @input="onSearch" />
</div>
</div>
@@ -24,10 +24,10 @@
<table v-else class="file-table">
<thead>
<tr>
<th @click="sortBy('name')" class="sortable">Name <span v-if="sortField==='name'">{{ sortAsc ? '▲' : '▼' }}</span></th>
<th @click="sortBy('size')" class="sortable">Size <span v-if="sortField==='size'">{{ sortAsc ? '▲' : '▼' }}</span></th>
<th>Type</th>
<th>Modified</th>
<th @click="sortBy('name')" class="sortable">{{ t('client.table.name') }} <span v-if="sortField==='name'">{{ sortAsc ? '▲' : '▼' }}</span></th>
<th @click="sortBy('size')" class="sortable">{{ t('client.table.size') }} <span v-if="sortField==='size'">{{ sortAsc ? '▲' : '▼' }}</span></th>
<th>{{ t('client.table.type') }}</th>
<th>{{ t('client.table.modified') }}</th>
</tr>
</thead>
<tbody>
@@ -78,11 +78,11 @@
<!-- Create Folder Dialog -->
<div v-if="showCreateFolder" class="dialog-overlay" @click="hideCreateFolderDialog">
<div class="dialog" @click.stop>
<h3>Create New Folder</h3>
<input v-model="newFolderName" type="text" placeholder="Folder name" @keyup.enter="createFolder" />
<h3>{{ t('client.dialog.createFolder') }}</h3>
<input v-model="newFolderName" type="text" :placeholder="t('client.dialog.folderName')" @keyup.enter="createFolder" />
<div class="dialog-buttons">
<button @click="createFolder">Create</button>
<button @click="hideCreateFolderDialog">Cancel</button>
<button @click="createFolder">{{ t('client.button.create') }}</button>
<button @click="hideCreateFolderDialog">{{ t('client.button.cancel') }}</button>
</div>
</div>
</div>
@@ -90,14 +90,14 @@
<!-- Delete Confirm Dialog -->
<div v-if="showDeleteConfirm" class="dialog-overlay" @click="cancelDelete">
<div class="dialog" @click.stop>
<h3>Delete "{{ deleteTarget }}"</h3>
<h3>{{ t('client.dialog.deleteTitle', { name: deleteTarget }) }}</h3>
<p v-if="selectedFile?.isDir && selectedFile.fileCount">
This folder contains <strong>{{ selectedFile.fileCount }}</strong> file(s). They will be permanently deleted.
</p>
<p v-else>Are you sure you want to delete this item?</p>
<p v-else>{{ t('client.dialog.deleteMessage', { type: selectedFile?.isDir ? t('client.type.folder') : t('client.type.file') }) }}</p>
<div class="dialog-buttons">
<button @click="doDelete" class="danger-btn">Delete</button>
<button @click="cancelDelete">Cancel</button>
<button @click="doDelete" class="danger-btn">{{ t('client.button.delete') }}</button>
<button @click="cancelDelete">{{ t('client.button.cancel') }}</button>
</div>
</div>
</div>
@@ -105,11 +105,11 @@
<!-- Rename Dialog -->
<div v-if="showRename" class="dialog-overlay" @click="hideRenameDialog">
<div class="dialog" @click.stop>
<h3>Rename "{{ renameOldName }}"</h3>
<input v-model="renameNewName" type="text" placeholder="New name" @keyup.enter="doRename" autofocus />
<h3>{{ t('client.dialog.renameTitle', { name: renameOldName }) }}</h3>
<input v-model="renameNewName" type="text" :placeholder="t('client.dialog.newName')" @keyup.enter="doRename" autofocus />
<div class="dialog-buttons">
<button @click="doRename">Rename</button>
<button @click="hideRenameDialog">Cancel</button>
<button @click="doRename">{{ t('client.button.rename') }}</button>
<button @click="hideRenameDialog">{{ t('client.button.cancel') }}</button>
</div>
</div>
</div>
@@ -117,7 +117,7 @@
<!-- File Preview Dialog -->
<div v-if="showPreview" class="dialog-overlay" @click="hidePreview">
<div class="dialog preview-dialog" @click.stop>
<h3>File Preview: {{ selectedFile?.name }}</h3>
<h3>{{ t('client.dialog.previewTitle', { name: selectedFile?.name }) }}</h3>
<div v-if="previewLoading" class="loading">Loading preview...</div>
<div v-else-if="previewContent" class="preview-content">
<img v-if="isImage" :src="previewContent" alt="preview" />
@@ -131,8 +131,8 @@
<pre v-else>{{ previewContent }}</pre>
</div>
<div class="dialog-buttons">
<button @click="downloadFile">Download</button>
<button @click="hidePreview">Close</button>
<button @click="downloadFile">{{ t('client.button.download') }}</button>
<button @click="hidePreview">{{ t('common.close') }}</button>
</div>
</div>
</div>
@@ -140,7 +140,7 @@
<!-- Upload Dialog -->
<div v-if="showUpload" class="dialog-overlay" @click="hideUploadDialog">
<div class="dialog upload-dialog" @click.stop>
<h3>Upload File{{ uploadFiles.length > 1 ? 's' : '' }}</h3>
<h3>{{ t('client.dialog.uploadTitle', { s: uploadFiles.length > 1 ? 's' : '' }) }}</h3>
<input ref="fileInput" type="file" multiple @change="selectUploadFile" />
<div v-if="uploadFiles.length > 0" class="upload-info">
<p v-if="!uploading">{{ uploadFiles.length }} file(s) selected</p>
@@ -155,9 +155,9 @@
</div>
<div class="dialog-buttons">
<button @click="uploadFileToServer" :disabled="uploadFiles.length === 0 || uploading">
{{ uploading ? 'Uploading...' : 'Upload' }}
{{ uploading ? t('client.status.uploading') : t('client.button.upload') }}
</button>
<button @click="hideUploadDialog">Cancel</button>
<button @click="hideUploadDialog">{{ t('client.button.cancel') }}</button>
</div>
</div>
</div>
@@ -165,7 +165,7 @@
<!-- Move Dialog -->
<div v-if="showMove" class="dialog-overlay" @click="showMove = false">
<div class="dialog" @click.stop>
<h3>{{ selectedFile?.isDir ? 'Move Folder' : 'Move File' }} "{{ selectedFile?.name }}"</h3>
<h3>{{ t('client.dialog.moveTitle', { type: selectedFile?.isDir ? t('client.type.folder') : t('client.type.file'), name: selectedFile?.name }) }}</h3>
<select v-model="moveTarget" class="move-select">
<option value="" disabled>Select a folder...</option>
<option v-for="folder in availableFolders" :key="folder.name" :value="folder.name">
@@ -173,8 +173,8 @@
</option>
</select>
<div class="dialog-buttons">
<button @click="doMove" :disabled="!moveTarget">Move</button>
<button @click="showMove = false">Cancel</button>
<button @click="doMove" :disabled="!moveTarget">{{ t('client.button.move') }}</button>
<button @click="showMove = false">{{ t('client.button.cancel') }}</button>
</div>
</div>
</div>
@@ -183,6 +183,9 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const currentPath = ref('/')
const files = ref<any[]>([])
@@ -1305,4 +1308,9 @@ onMounted(() => {
font-size: 12px;
color: var(--text-secondary);
}
@media (max-width: 767px) {
table { display: block; overflow-x: auto; white-space: nowrap; }
.client-view { padding: 8px; }
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -88,15 +88,15 @@
</div>
<template v-if="showAliasInput">
<select v-model="aliasLocale" class="ms-ppl-alias-locale-select">
<option value="en">English</option>
<option value="zh-TW">繁體中文</option>
<option value="zh-CN">简体中文</option>
<option value="ja">日本語</option>
<option value="ko">한국어</option>
<option value="en">{{ $t('language.en') }}</option>
<option value="zh-TW">{{ $t('language.zh-TW') }}</option>
<option value="zh-CN">{{ $t('language.zh-CN') }}</option>
<option value="ja">{{ $t('language.ja') }}</option>
<option value="ko">{{ $t('language.ko') }}</option>
</select>
<input v-model="aliasName" class="ms-ppl-alias-inline-input" placeholder="Enter alias and press Enter" @keyup.enter="addAlias">
</template>
<button v-if="!showAliasInput" class="ms-ppl-alias-add-btn" @click="showAliasInput = true; aliasName = ''">+ Alias</button>
<button v-if="!showAliasInput" class="ms-ppl-alias-add-btn" @click="showAliasInput = true; aliasName = ''">{{ $t('people.detail.add_alias') }}</button>
<button v-else class="ms-ppl-alias-add-btn" @click="showAliasInput = false">Collapse</button>
</div>
<div class="ms-ppl-edit-fields">
@@ -122,8 +122,8 @@
<button class="ms-ppl-strip-add-btn" @click="showCandidates = true" title="Add same person">+</button>
<button class="ms-ppl-strip-arrow" :disabled="faceStripPage === 1" @click="prevFacePage">‹</button>
<div class="ms-ppl-face-strip">
<div v-if="loadingTraces" style="padding:10px;color:#999;font-size:12px;">Loading traces...</div>
<div v-else-if="allTraces.length === 0" style="padding:10px;color:#999;font-size:12px;">No traces found</div>
<div v-if="loadingTraces" style="padding:10px;color:#999;font-size:12px;">Loading FS...</div>
<div v-else-if="allTraces.length === 0" style="padding:10px;color:#999;font-size:12px;">No FS found</div>
<div v-for="(t, i) in paginatedTraces" :key="t.trace_id" class="ms-ppl-strip-face ms-ppl-strip-face-clickable" :class="{ selected: selectedTrace?.trace_id === t.trace_id }" @click="selectTrace(t)" @contextmenu.prevent="showTraceCtxMenu($event, t)">
<div class="ms-ppl-strip-face-img">
<img v-if="traceThumbs[thumbKey(t.file_uuid, t.first_frame)]" :src="traceThumbs[thumbKey(t.file_uuid, t.first_frame)]" alt="" loading="lazy" style="width:100%;height:100%;object-fit:contain;border-radius:8px;">
@@ -185,11 +185,11 @@
<span v-if="allSegCard.avg_confidence" class="ms-ppl-media-badge-conf">{{ (allSegCard.avg_confidence * 100).toFixed(0) }}%</span>
</div>
<div class="ms-ppl-media-info">
<div class="ms-ppl-media-title">{{ allSegCard.count }} segments · {{ allTraces.length }} traces</div>
<div class="ms-ppl-media-title">{{ allSegCard.count }} segments · {{ allTraces.length }} FS</div>
<div class="ms-ppl-media-sub">All segments · {{ formatTime(allSegCard.start) }}</div>
</div>
</div>
<div v-else-if="!loadingTraces" class="ms-ppl-media-label" style="margin-bottom:20px;">No trace segments available for this person</div>
<div v-else-if="!loadingTraces" class="ms-ppl-media-label" style="margin-bottom:20px;">No FS segments available for this person</div>
<!-- Tabs -->
<div v-show="activeTab === 'actions'">
@@ -867,7 +867,11 @@ let mergeSearchTimer: any
.ms-ppl-strip-wrap { display: flex; align-items: center; gap: 10px; margin-bottom: 28px; }
.ms-ppl-strip-add-btn { width: 52px; height: 52px; border-radius: 12px; border: 1.5px dashed var(--border-color); background: var(--card-background); font-size: 20px; color: var(--text-secondary); display: grid; place-items: center; cursor: pointer; outline: none; flex-shrink: 0; transition: border-color .15s, color .15s; }
.ms-ppl-strip-add-btn:hover { border-color: var(--text-primary); color: var(--text-primary); }
.ms-ppl-face-strip { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: thin; flex: 1; }
.ms-ppl-face-strip { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 8px; scrollbar-width: thin; scrollbar-color: #b8bec6 transparent; flex: 1; min-width: 0; }
.ms-ppl-face-strip::-webkit-scrollbar { height: 8px; }
.ms-ppl-face-strip::-webkit-scrollbar-track { background: transparent; }
.ms-ppl-face-strip::-webkit-scrollbar-thumb { background: #b8bec6; border-radius: 999px; }
.ms-ppl-face-strip::-webkit-scrollbar-thumb:hover { background: #8a919b; }
.ms-ppl-strip-face { position: relative; flex-shrink: 0; cursor: pointer; }
.ms-ppl-strip-face-clickable.selected { outline: 2px solid var(--primary-color); outline-offset: 2px; border-radius: 14px; }
.ms-ppl-strip-face-img { width: 52px; height: 52px; border-radius: 12px; border: 2px solid transparent; background: var(--border-light); overflow: hidden; transition: border-color .15s; }
@@ -925,4 +929,12 @@ let mergeSearchTimer: any
/* 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; }
@media (max-width: 767px) {
.people-view { padding-top: 8px; }
.ms-ppl-strip-wrap { gap: 6px; margin-bottom: 16px; }
.ms-ppl-strip-face-img { width: 40px; height: 40px; }
.ms-ppl-detail-name { font-size: 16px; }
.ms-ppl-detail-info { flex-direction: column; align-items: flex-start; }
}
</style>

View File

@@ -38,7 +38,16 @@
<div id="msHSL">
<div v-if="!historyList.length" style="color:var(--muted-text);font-size:12px;padding:8px 0">No history yet</div>
<div v-for="h in historyList" :key="h.id" class="hs-item" :class="{ pinned: h.pinned }">
<div class="hs-title" @click="restoreHistory(h)">{{ h.title || h.query }}</div>
<div class="hs-main">
<div class="hs-row">
<span class="hs-mode">{{ formatMode(h.mode) }}</span>
<div class="hs-title" @click="restoreHistory(h)">{{ h.title || h.query }}</div>
</div>
<div class="hs-time">
<template v-if="h.updated_at && h.updated_at !== h.created_at">{{ formatHistoryTime(h.created_at) }} - {{ formatHistoryTime(h.updated_at) }}</template>
<template v-else>{{ formatHistoryTime(h.created_at || h.updated_at) }}</template>
</div>
</div>
<button class="hs-menu-btn" @click.stop="toggleHistoryMenu(h.id)">⋯</button>
<div v-if="activeHistoryMenu === h.id" class="hs-menu">
<button v-if="!h.pinned" @click="pinHistoryItem(h.id)">Pin</button>
@@ -95,6 +104,7 @@
<template v-else>
<div v-for="(msg, i) in messages" :key="i" class="msg" :class="msg.role">
<div v-if="msg.role === 'user'" class="msg-user">
<span class="msg-mode">{{ formatMode(msg.mode) }}</span>
{{ msg.query }}
</div>
<div v-else>
@@ -108,28 +118,54 @@
</div>
<div v-if="msg.results.length" class="card-grid">
<div v-for="(r, j) in msg.results" :key="j" class="result-card">
<div class="card-thumb" v-observe="() => loadThumb(r.file_uuid, r.start_frame)" @click="playVideo(r)">
<img v-if="thumbs[r.file_uuid + ':' + r.start_frame]" :src="thumbs[r.file_uuid + ':' + r.start_frame]" alt="" loading="lazy">
<div v-else class="thumb-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<div v-for="(r, j) in msg.results" :key="j" class="result-card" :class="{ 'ocr-grouped': r.ocr_frames && r.ocr_frames.length > 1 }">
<div class="card-thumb" @click="playVideo(r)">
<img v-if="r.thumbnail_url" :src="r.thumbnail_url" alt="" loading="lazy">
<img v-else-if="thumbs[r.file_uuid + ':' + r.start_frame]" :src="thumbs[r.file_uuid + ':' + r.start_frame]" alt="" loading="lazy">
<div v-else class="thumb-placeholder" v-observe="() => loadThumb(r.file_uuid, r.start_frame || 0)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</div>
<div v-if="r.ocr_frames && r.ocr_frames.length > 1" class="ocr-group-badge">{{ r.ocr_frames.length }}</div>
</div>
<div v-if="r.ocr_frames && r.ocr_frames.length > 1" class="ocr-frame-marks">
<span v-for="f in r.ocr_frames" :key="f" class="ocr-frame-mark"
:class="{ 'ocr-frame-variant': isVariantFrame(r, f) }"
@click.stop="playVideoAtFrame(r, f)"
:title="getFrameText(r, f)">
F{{ f }}
</span>
</div>
<div v-if="r.variants?.length" class="ocr-corrected-list">
<div v-for="(v, vi) in r.variants" :key="vi" class="ocr-corrected-item">
<span class="ocr-corrected-frames">F{{ v.frames.join(', F') }}</span>
<span class="ocr-corrected-arrow">→</span>
<span class="ocr-corrected-original">{{ v.text }}</span>
</div>
</div>
<div class="card-name" @click.stop="showTextDetail(r)">
<span v-if="getSourceType(r)" class="card-source-type" :class="getSourceType(r).toLowerCase()">{{ getSourceType(r) }}</span><span v-html="highlightMatch(parseSummary(r.summary).text, r.search_query)"></span>
<span v-if="r._isPerson" class="card-source-type person">
<svg class="card-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M6 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/></svg>
</span><span v-html="highlightMatch(r._isPerson ? r.file_name : parseSummary(r.summary).text, r.search_query)"></span>
</div>
<div v-if="r.text_content" class="card-text" v-html="highlightMatch(r.text_content.slice(0, 120), r.search_query)"></div>
<div class="card-file">{{ r.file_name || '' }}<br><span class="card-file-uuid">{{ r.file_uuid }}</span></div>
<div v-if="r._isPerson" class="card-text card-person-stats">{{ r.text_content }}</div>
<div v-if="r.text_content && !r._isPerson" class="card-text" v-html="highlightMatch(r.text_content.slice(0, 120), r.search_query)"></div>
<div class="card-file" v-if="!r._isPerson">{{ r.file_name || '' }}<br><span class="card-file-uuid">{{ r.file_uuid }}</span></div>
<div class="card-file card-person-file" v-if="r._isPerson">{{ r.display_file_name }}<br><span class="card-file-uuid">{{ r.file_uuid }}</span></div>
<div class="card-meta">
<span v-if="r.ocr_frames" class="card-frame">F{{ r.ocr_frames.join(', F') }}</span>
<span v-else-if="r.start_frame" class="card-frame">F{{ r.start_frame }}–{{ r.end_frame }}</span>
<span v-if="!r.ocr_frames && r.start_frame" class="card-frame">F{{ r.start_frame }}–{{ r.end_frame }}</span>
<span v-if="r.start_time != null" class="card-time">({{ formatTime(r.start_time) }}–{{ formatTime(r.end_time) }})</span>
<span v-if="r.similarity != null" class="card-score">{{ (r.similarity * 100).toFixed(0) }}%</span>
</div>
<div class="card-actions">
<button class="card-action-btn" @click="playVideo(r)" title="Play">▶</button>
<button class="card-action-btn" @click="expandRange(r)" title="Expand ±150 frames">⬌</button>
<button class="card-action-btn" :class="{ marked: isMarked(r) }" @click="toggleMark(r)" title="Mark for export">⭐</button>
<button class="card-action-btn" @click="playVideo(r)" title="Play">
<svg class="card-icon" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Play
</button>
<button class="card-action-btn" @click="expandRange(r)" title="Expand ±150 frames">
<svg class="card-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg> Expand
</button>
<button class="card-action-btn" :class="{ marked: isMarked(r) }" @click="toggleMark(r)" title="Mark for export">
<svg class="card-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> Mark
</button>
</div>
</div>
</div>
@@ -188,16 +224,18 @@
<VideoPlayer
v-if="playing"
simple
:simple="clusterTraces.length === 0"
:file-uuid="currentVideo.fileUuid"
:start-frame="currentVideo.startFrame"
:end-frame="currentVideo.endFrame"
:all-traces="clusterTraces.length ? clusterTraces : undefined"
:merged-segments="clusterSegments.length ? clusterSegments : undefined"
:title="currentVideo.title"
:show-asrx="true"
:show-ocr="true"
:show-face="false"
:show-face="clusterTraces.length > 0"
:show-pose="false"
@close="playing = false"
@close="playing = false; clusterTraces = []; clusterSegments = []"
/>
<div v-if="selectionPopup.show" class="sel-popup" :style="{ top: selectionPopup.y + 'px', left: selectionPopup.x + 'px' }">
<button class="sel-popup-btn" @click="addSelectionBookmark">Bookmark this</button>
@@ -222,9 +260,15 @@
<div class="text-detail-content" v-html="highlightMatch(textDetailModal.text, textDetailModal.query)"></div>
</div>
<div class="text-detail-actions">
<button class="card-action-btn" @click="playVideo(textDetailModal)">▶ Play</button>
<button class="card-action-btn" @click="expandRange(textDetailModal)">⬌ Expand</button>
<button class="card-action-btn" :class="{ marked: isMarked(textDetailModal) }" @click="toggleMark(textDetailModal)">⭐ Mark</button>
<button class="card-action-btn" @click="playVideo(textDetailModal)">
<svg class="card-icon" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Play
</button>
<button class="card-action-btn" @click="expandRange(textDetailModal)">
<svg class="card-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg> Expand
</button>
<button class="card-action-btn" :class="{ marked: isMarked(textDetailModal) }" @click="toggleMark(textDetailModal)">
<svg class="card-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> Mark
</button>
</div>
</div>
</div>
@@ -234,10 +278,11 @@
<script setup lang="ts">
import { ref, nextTick, computed, onMounted, onUnmounted } from 'vue'
import { apiCall } from '@/api'
import { getApiBase } from '@/api/config'
import VideoPlayer from '../components/VideoPlayer.vue'
import { useRouter } from 'vue-router'
import { useSearchHistory, useBookmarks } from '@/composables/useSearchHistory'
import { thumbnailsCache, loadThumbnail } from '@/store'
import { thumbnailsCache, loadThumbnail, filesCache, ensureFiles } from '@/store'
import type { HistoryItem } from '@/composables/useSearchHistory'
interface SourceInfo {
@@ -255,6 +300,7 @@ interface ChatMessage {
answer?: string
sources?: SourceInfo[]
chips?: string[]
mode?: string
}
const router = useRouter()
@@ -262,6 +308,7 @@ const router = useRouter()
const modes = [
{ label: 'Keyword', value: 'keyword', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>' },
{ label: 'Semantic', value: 'semantic', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>' },
{ label: 'Visual', value: 'visual', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>' },
{ label: 'Agent', value: 'agent', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>' },
{ label: 'People', value: 'people', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' },
]
@@ -288,14 +335,28 @@ const { history: historyList, loadHistory, saveToHistory, renameHistory, pinHist
const { bookmarks: bookmarkList, loadBookmarks, saveBookmark, deleteBookmark } = useBookmarks()
const markedResults = ref<any[]>([])
const searchConfig = ref({ vlm_min_score: 0.0, vlm_max_results: 5, vlm_output_language: 'en' })
const textDetailModal = ref<any>({ show: false, tag: '', text: '', fileName: '', startFrame: 0, endFrame: 0, startTime: 0, endTime: 0, similarity: 0, summary: '', file_uuid: '', start_frame: 0, end_frame: 0, start_time: 0, end_time: 0, file_name: '', query: '' })
const textDetailModal = ref<any>({ show: false, tag: '', text: '', fileName: '', startFrame: 0, endFrame: 0, startTime: 0, endTime: 0, similarity: 0, summary: '', file_uuid: '', start_frame: 0, end_frame: 0, start_time: 0, end_time: 0, file_name: '', query: '', _traces: null, _isPerson: false })
function parseSummary(summary: string): { tag: string; text: string } {
if (!summary) return { tag: '', text: '' }
const match = summary.match(/^\[(OCR|ASRX)\]\s*(.*)$/i)
const match = summary.match(/^\[(OCR|ASRX|SCENE)\]\s*(.*)$/i)
if (match) {
return { tag: match[1].toUpperCase(), text: match[2] }
let text = match[2]
if (match[1].toUpperCase() === 'SCENE') {
const draftMatch = text.match(/\* \*Draft[^*]*:\*\*\s*(.+?)(?:\n\n|\n\*|$)/s)
if (draftMatch) {
text = draftMatch[1].trim()
} else {
const envMatch = text.match(/\* \*Environment:\*\*\s*(.+?)(?:\n\n|\n\*|$)/s)
const objMatch = text.match(/\* \*Objects:\*\*\s*(.+?)(?:\n\n|\n\*|$)/s)
if (envMatch && objMatch) {
text = `${envMatch[1].trim()} ${objMatch[1].trim()}`
}
}
}
return { tag: match[1].toUpperCase(), text }
}
return { tag: '', text: summary }
}
@@ -318,14 +379,23 @@ function highlightMatch(text: string, searchQuery: string): string {
return escaped.replace(regex, '<mark class="highlight">$1</mark>')
}
function getFileNameFromCache(fileUuid: string): string {
if (!fileUuid) return ''
const clean = fileUuid.replace(/-/g, '')
const f = filesCache.value.find((f: any) => f.file_uuid?.replace(/-/g, '') === clean)
return f?.file_name || f?.file_path?.split('/').pop() || ''
}
function showTextDetail(r: any) {
const parsed = parseSummary(r.summary)
const sourceType = getSourceType(r)
// Use display_file_name for person results, or lookup from filesCache
const actualFileName = r.display_file_name || getFileNameFromCache(r.file_uuid) || r.file_name || ''
textDetailModal.value = {
show: true,
tag: parsed.tag || (sourceType ? `[${sourceType}]` : ''),
text: parsed.text,
fileName: r.file_name || '',
fileName: actualFileName,
startFrame: r.start_frame || 0,
endFrame: r.end_frame || 0,
startTime: r.start_time || 0,
@@ -337,11 +407,30 @@ function showTextDetail(r: any) {
end_frame: r.end_frame,
start_time: r.start_time,
end_time: r.end_time,
file_name: r.file_name,
file_name: actualFileName,
query: r.search_query || '',
_traces: r._traces || null,
_isPerson: r._isPerson || false,
}
}
function levenshteinDistance(a: string, b: string): number {
const m = a.length, n = b.length
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0))
for (let i = 0; i <= m; i++) dp[i][0] = i
for (let j = 0; j <= n; j++) dp[0][j] = j
for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++)
dp[i][j] = Math.min(dp[i-1][j] + 1, dp[i][j-1] + 1, dp[i-1][j-1] + (a[i-1] !== b[j-1] ? 1 : 0))
return dp[m][n]
}
function textSimilarity(a: string, b: string): number {
const maxLen = Math.max(a.length, b.length)
if (maxLen === 0) return 1
return 1 - levenshteinDistance(a, b) / maxLen
}
function mergeResults(results: any[]): any[] {
if (!results.length) return results
const sorted = [...results].sort((a, b) => {
@@ -350,7 +439,7 @@ function mergeResults(results: any[]): any[] {
return fa - fb
})
const merged: any[] = []
const ocrTextFrames: Map<string, { frames: number[]; results: any[] }> = new Map()
const ocrGroups: Array<{ canonicalText: string; frames: number[]; results: any[]; variants: { text: string; frames: number[] }[] }> = []
for (const r of sorted) {
const rawText = (r.summary || r.text_content || '').trim()
@@ -359,12 +448,24 @@ function mergeResults(results: any[]): any[] {
if (isOcr) {
const ocrText = parsed.tag === '[OCR]' ? parsed.text : rawText
if (ocrTextFrames.has(ocrText)) {
const data = ocrTextFrames.get(ocrText)!
data.frames.push(r.start_frame || 0)
data.results.push(r)
let bestGroup = null
let bestSim = 0
for (const g of ocrGroups) {
const sim = textSimilarity(ocrText, g.canonicalText)
if (sim > bestSim) { bestSim = sim; bestGroup = g }
}
if (bestSim > 0.85 && bestGroup) {
bestGroup.frames.push(r.start_frame || 0)
bestGroup.results.push(r)
if (ocrText !== bestGroup.canonicalText) {
let variant = bestGroup.variants.find(v => v.text === ocrText)
if (!variant) { variant = { text: ocrText, frames: [] }; bestGroup.variants.push(variant) }
variant.frames.push(r.start_frame || 0)
}
} else {
ocrTextFrames.set(ocrText, { frames: [r.start_frame || 0], results: [r] })
ocrGroups.push({ canonicalText: ocrText, frames: [r.start_frame || 0], results: [r], variants: [] })
}
continue
}
@@ -381,7 +482,7 @@ function mergeResults(results: any[]): any[] {
const lastEnd = last.end_frame || last.start_frame + 1
const rStart = r.start_frame || 0
const gap = rStart - lastEnd
if (gap <= 30) {
if (gap <= 100) {
last.end_frame = Math.max(lastEnd, r.end_frame || rStart + 1)
if (r.similarity > (last.similarity || 0)) {
last.summary = r.summary
@@ -392,13 +493,14 @@ function mergeResults(results: any[]): any[] {
}
}
for (const [ocrText, data] of ocrTextFrames) {
const uniqueFrames = [...new Set(data.frames)].sort((a, b) => a - b)
for (const group of ocrGroups) {
const uniqueFrames = [...new Set(group.frames)].sort((a, b) => a - b)
merged.push({
...data.results[0],
summary: `[OCR] ${ocrText}`,
...group.results[0],
summary: `[OCR] ${group.canonicalText}`,
ocr_frames: uniqueFrames,
ocr_details: data.results,
ocr_details: group.results,
variants: group.variants,
start_frame: uniqueFrames[0],
end_frame: uniqueFrames[uniqueFrames.length - 1],
})
@@ -429,6 +531,8 @@ function ensureMinDuration(r: any) {
const playing = ref(false)
const currentVideo = ref({ fileUuid: '', startFrame: 0, endFrame: 0, title: '' })
const clusterTraces = ref<any[]>([])
const clusterSegments = ref<any[]>([])
const currentModeLabel = computed(() => modes.find(m => m.value === mode.value)?.label || 'Keyword')
@@ -473,10 +577,28 @@ function closeDropdowns(e: MouseEvent) {
}
}
onMounted(() => {
onMounted(async () => {
document.addEventListener('click', closeDropdowns)
await ensureFiles()
loadHistory()
loadBookmarks()
// Load search config for visual search parameters
try {
const base = getApiBase()
const res = await fetch(`${base}/api/v1/admin/config`)
if (res.ok) {
const data = await res.json()
if (data.search) {
searchConfig.value = {
vlm_min_score: data.search.vlm_min_score ?? 0.0,
vlm_max_results: data.search.vlm_max_results ?? 5,
vlm_output_language: data.search.vlm_output_language ?? 'en'
}
}
}
} catch (e) {
console.error('[SearchView] Failed to load search config:', e)
}
})
onUnmounted(() => document.removeEventListener('click', closeDropdowns))
@@ -603,7 +725,7 @@ async function sendMessage() {
query.value = ''
const seq = searchSeq
messages.value.push({ role: 'user', query: q, results: [], loading: false, message: '' })
messages.value.push({ role: 'user', query: q, results: [], loading: false, message: '', mode: mode.value })
const msgIdx = messages.value.length
messages.value.push({ role: 'assistant', results: [], loading: true, message: '' })
sending.value = true
@@ -667,6 +789,58 @@ async function sendMessage() {
}
}
}
// Agent tools return "cards" array (e.g. semantic_search, keyword_search, vlm_search)
if (Array.isArray(src.parsed?.cards)) {
for (const card of src.parsed.cards) {
if (card.card_type === 'chunk' && card.file_uuid) {
results.push({
file_uuid: card.file_uuid,
file_name: card.file_name || '',
summary: card.text_preview || '',
text_content: card.text_preview || '',
similarity: card.score ?? null,
start_frame: card.start_frame ?? 0,
end_frame: card.end_frame ?? 0,
source_type: card.chunk_type === 'ocr' ? 'ocr' : 'asrx',
chunk_id: card.chunk_id || '',
})
}
if (card.card_type === 'scene' && card.file_uuid) {
const sceneIndex = card.scene_index ?? 0
results.push({
file_uuid: card.file_uuid,
file_name: card.file_name || '',
summary: card.vlm_description ? `[SCENE] ${card.vlm_description}` : `Scene ${sceneIndex}`,
text_content: card.vlm_description || '',
similarity: card.score ?? null,
start_frame: card.frame_number ?? Math.round((card.timestamp_sec || 0) * 30),
end_frame: (card.frame_number ?? Math.round((card.timestamp_sec || 0) * 30)) + 30,
start_time: card.timestamp_sec,
end_time: (card.timestamp_sec || 0) + 1,
source_type: 'scene',
thumbnail_url: card.thumbnail_url,
vlm_tags: card.vlm_tags || [],
})
}
if (card.card_type === 'face' && card.file_uuid) {
results.push({
file_uuid: card.file_uuid,
file_name: card.file_name || '',
summary: card.vlm_description || card.identity_name || `Trace ${card.trace_id}`,
text_content: card.vlm_description || '',
similarity: card.score ?? null,
start_frame: card.start_frame ?? 0,
end_frame: card.end_frame ?? 0,
source_type: 'trace',
trace_id: card.trace_id,
identity_name: card.identity_name,
vlm_clothing: card.vlm_clothing,
vlm_location: card.vlm_location,
vlm_tags: card.vlm_tags || [],
})
}
}
}
}
const smartSrc = sources.find(s => s.tool === 'smart_search')
@@ -725,20 +899,50 @@ async function sendMessage() {
const raw: any = await apiCall('search_people', { query: q, limit: 50 })
if (searchSeq !== seq) return
const peopleList = raw?.people || []
const results = peopleList.map((p: any) => ({
file_name: p.name || 'Unknown',
summary: `People · ${p.appearance_count || 0} appearances`,
similarity: 1.0,
start_frame: p.first_appearance_time,
end_frame: p.last_appearance_time,
search_query: q,
}))
const personMap = new Map<string, { traces: any[], minFrame: number, maxFrame: number }>()
for (const t of peopleList) {
const name = t.name || 'Unknown'
const key = `${t.file_uuid || ''}::${name}`
if (!personMap.has(key)) {
personMap.set(key, { traces: [], minFrame: Infinity, maxFrame: -Infinity })
}
const p = personMap.get(key)!
p.traces.push(t)
if (t.start_frame < p.minFrame) p.minFrame = t.start_frame
if (t.end_frame > p.maxFrame) p.maxFrame = t.end_frame
}
const fileUuidToName: Record<string, string> = {}
for (const f of filesCache.value) {
if (f.file_uuid) fileUuidToName[f.file_uuid] = f.file_name || f.file_path?.split('/').pop() || ''
}
const results = Array.from(personMap.entries()).map(([key, p]) => {
const firstTrace = p.traces[0]
const totalFrames = p.traces.reduce((sum, t) => sum + (t.frame_count || 0), 0)
const avgConfidence = p.traces.reduce((sum, t) => sum + (t.avg_confidence || 0), 0) / p.traces.length
const personName = key.split('::').pop() || 'Unknown'
const fname = fileUuidToName[firstTrace.file_uuid] || ''
return {
file_uuid: firstTrace.file_uuid,
file_name: personName,
display_file_name: fname,
summary: personName,
text_content: `${p.traces.length} FS · ${totalFrames}F · ${(avgConfidence * 100).toFixed(0)}%`,
similarity: 1.0,
start_frame: p.minFrame,
end_frame: p.maxFrame,
search_query: q,
_traces: p.traces,
_isPerson: true,
}
})
messages.value[msgIdx].results = results
messages.value[msgIdx].loading = false
} else if (mode.value === 'semantic') {
const [semanticRes, keywordRes] = await Promise.all([
apiCall('search_semantic', { query: q, limit: 20 }),
apiCall('search_keyword', { query: q, limit: 20 })
apiCall('search_semantic', { query: q, limit: 5 }),
apiCall('search_keyword', { query: q, limit: 5 })
])
if (searchSeq !== seq) return
const semanticResults = (semanticRes?.results || semanticRes || [])
@@ -748,6 +952,11 @@ async function sendMessage() {
const merged: any[] = []
const semanticMerged = mergeResults(semanticResults)
const keywordMerged = mergeResults(keywordResults)
// Rerank: prioritize results containing query text (case-insensitive)
const queryLower = q.toLowerCase()
const hasQueryText = (text: string) => text && text.toLowerCase().includes(queryLower)
for (const r of semanticMerged) {
const key = `${r.file_uuid}:${r.start_frame || 0}`
if (!seen.has(key)) {
@@ -762,15 +971,200 @@ async function sendMessage() {
merged.push({ ...r, search_query: q, source_type: r.source_type || 'ocr' })
}
}
// Rerank: put results with query text first
merged.sort((a, b) => {
const aHasText = hasQueryText(a.text_content || a.summary || '')
const bHasText = hasQueryText(b.text_content || b.summary || '')
if (aHasText && !bHasText) return -1
if (!aHasText && bHasText) return 1
return (b.similarity || 0) - (a.similarity || 0)
})
messages.value[msgIdx].results = merged.slice(0, 15)
messages.value[msgIdx].loading = false
} else if (mode.value === 'visual') {
// Visual search: Scene VLM + Trace VLM
// min_score is handled by API (uses admin config default)
// Bilingual output: English + preferred language (if not English)
await ensureFiles()
const registeredUuids = new Set(filesCache.value.map((f: any) => f.file_uuid || f.uuid))
const base = getApiBase()
const maxResults = searchConfig.value.vlm_max_results || 5
const outputLang = searchConfig.value.vlm_output_language || 'en'
const bilingual = outputLang !== 'en'
let scenes: any[] = []
let traces: any[] = []
try {
if (bilingual) {
// Bilingual mode: fetch both English and preferred language
const [scenesEn, scenesLang, tracesEn, tracesLang] = await Promise.all([
fetch(`${base}/api/v1/search/vlm/scenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: q, page_size: maxResults, min_score: 0.0, output_language: 'en' })
}).then(r => r.json()).catch(() => ({})),
fetch(`${base}/api/v1/search/vlm/scenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: q, page_size: maxResults, min_score: 0.0, output_language: outputLang })
}).then(r => r.json()).catch(() => ({})),
fetch(`${base}/api/v1/search/vlm/traces`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: q, page_size: maxResults, min_score: 0.0, output_language: 'en' })
}).then(r => r.json()).catch(() => ({})),
fetch(`${base}/api/v1/search/vlm/traces`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: q, page_size: maxResults, min_score: 0.0, output_language: outputLang })
}).then(r => r.json()).catch(() => ({}))
])
// Merge bilingual results
const scenesEnList = (scenesEn?.results || scenesEn || []) as any[]
const scenesLangList = (scenesLang?.results || scenesLang || []) as any[]
const tracesEnList = (tracesEn?.results || tracesEn || []) as any[]
const tracesLangList = (tracesLang?.results || tracesLang || []) as any[]
// Match results by file_uuid + scene_index
scenes = scenesEnList.map((sEn: any) => {
const sceneIndex = sEn.index ?? sEn.scene_index ?? 0
const sLang = scenesLangList.find((s: any) =>
s.file_uuid === sEn.file_uuid && (s.index ?? s.scene_index ?? 0) === sceneIndex
)
return {
...sEn,
vlm_description_en: sEn.vlm_description || '',
vlm_description_lang: sLang?.vlm_description || '',
vlm_description: sEn.vlm_description || ''
}
})
traces = tracesEnList.map((tEn: any) => {
const traceId = tEn.trace_id
const tLang = tracesLangList.find((t: any) => t.file_uuid === tEn.file_uuid && t.trace_id === traceId)
return {
...tEn,
vlm_description_en: tEn.vlm_description || '',
vlm_description_lang: tLang?.vlm_description || '',
vlm_description: tEn.vlm_description || ''
}
})
} else {
// English only
const [scenesRes, tracesRes] = await Promise.all([
fetch(`${base}/api/v1/search/vlm/scenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: q, page_size: maxResults, min_score: 0.0, output_language: 'en' })
}).then(r => r.json()).catch(() => ({})),
fetch(`${base}/api/v1/search/vlm/traces`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: q, page_size: maxResults, min_score: 0.0, output_language: 'en' })
}).then(r => r.json()).catch(() => ({}))
])
scenes = (scenesRes?.results || scenesRes || []) as any[]
traces = (tracesRes?.results || tracesRes || []) as any[]
}
} catch (e) {
console.error('Visual search API error:', e)
}
if (searchSeq !== seq) return
// Filter by registered files only (API handles min_score filtering)
const filteredScenes = scenes.filter((s: any) => registeredUuids.has(s.file_uuid))
const filteredTraces = traces.filter((t: any) => registeredUuids.has(t.file_uuid))
const sceneResults = filteredScenes.map((s: any) => {
const sceneIndex = s.index ?? s.scene_index ?? 0
const frameNumber = s.frame_number ?? Math.round((s.timestamp_sec || 0) * 30)
// Build bilingual description
let vlmDesc = s.vlm_description || ''
let textContent = vlmDesc
if (bilingual && s.vlm_description_en && s.vlm_description_lang) {
// Show both English and preferred language
textContent = `【English】\n${s.vlm_description_en}\n\n【${outputLang.toUpperCase()}】\n${s.vlm_description_lang}`
vlmDesc = textContent
} else if (bilingual && s.vlm_description_en) {
// Only English available
textContent = `【English】\n${s.vlm_description_en}`
vlmDesc = textContent
}
return {
...s,
search_query: q,
source_type: 'scene',
start_frame: frameNumber,
end_frame: frameNumber + 30,
start_time: s.timestamp_sec,
end_time: (s.timestamp_sec || 0) + 1,
summary: vlmDesc ? `[SCENE] ${vlmDesc}` : `Scene ${sceneIndex}`,
text_content: textContent,
similarity: s.score ?? null,
thumbnail_url: `${base}/api/v1/file/${s.file_uuid}/scenes/scene_${String(sceneIndex).padStart(3, '0')}.jpg`,
bilingual,
vlm_description_en: s.vlm_description_en,
vlm_description_lang: s.vlm_description_lang
}
})
const traceResults = filteredTraces.map((t: any) => {
const frame = t.start_frame ?? t.best_face_frame ?? 0
// Build bilingual description
let vlmDesc = t.vlm_description || ''
let textContent = vlmDesc
if (bilingual && t.vlm_description_en && t.vlm_description_lang) {
textContent = `【English】\n${t.vlm_description_en}\n\n【${outputLang.toUpperCase()}】\n${t.vlm_description_lang}`
vlmDesc = textContent
} else if (bilingual && t.vlm_description_en) {
textContent = `【English】\n${t.vlm_description_en}`
vlmDesc = textContent
}
return {
...t,
search_query: q,
source_type: 'trace',
start_frame: frame,
end_frame: frame + 30,
summary: vlmDesc || t.text_content || 'Person appearance',
text_content: textContent,
similarity: t.score ?? null,
bilingual,
vlm_description_en: t.vlm_description_en,
vlm_description_lang: t.vlm_description_lang
}
})
// Limit total results to maxResults
const allResults = [...sceneResults, ...traceResults]
messages.value[msgIdx].results = allResults.slice(0, maxResults)
messages.value[msgIdx].loading = false
} else {
const results = await apiCall('search_keyword', { query: q, limit: 30 })
const results = await apiCall('search_keyword', { query: q, limit: 50 })
if (searchSeq !== seq) return
const rawResults = (results?.results || results || []) as any[]
const merged = mergeResults(rawResults)
for (const r of merged) r.search_query = q
messages.value[msgIdx].results = merged.slice(0, 10)
// Keyword search: skip merge, just deduplicate by file_uuid:start_frame
const seen = new Set<string>()
const deduped = rawResults.filter(r => {
const key = `${r.file_uuid}:${r.start_frame || 0}`
if (seen.has(key)) return false
seen.add(key)
return true
})
for (const r of deduped) r.search_query = q
messages.value[msgIdx].results = deduped.slice(0, 20)
messages.value[msgIdx].loading = false
}
} catch (e: any) {
@@ -807,7 +1201,32 @@ function formatTime(sec: number): string {
return `${m}:${s.toString().padStart(2, '0')}`
}
function formatMode(mode: string | null): string {
const map: Record<string, string> = {
keyword: 'Keyword',
semantic: 'Semantic',
agent: 'Agent',
people: 'People',
}
return map[mode || ''] || '—'
}
function formatHistoryTime(ts: string | null): string {
if (!ts) return ''
// SQLite datetime('now') writes "YYYY-MM-DD HH:MM:SS" (no TZ) while Rust writes
// "YYYY-MM-DDTHH:MM:SSZ". Normalize the former to UTC before parsing.
const norm = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(ts) ? `${ts}Z` : ts
const d = new Date(norm)
if (isNaN(d.getTime())) return ts
const pad = (n: number) => n.toString().padStart(2, '0')
return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function playVideo(r: any) {
if (r._traces) {
playPersonGroup(r)
return
}
const sf = r.start_frame || 0
const ef = r.end_frame || sf + 1
currentVideo.value = {
@@ -819,6 +1238,84 @@ function playVideo(r: any) {
playing.value = true
}
function playVideoAtFrame(r: any, frame: number) {
const ef = r.end_frame || frame + 1
currentVideo.value = {
fileUuid: r.file_uuid,
startFrame: frame,
endFrame: ef,
title: r.summary?.slice(0, 60) || r.file_name || 'Video'
}
playing.value = true
}
function isVariantFrame(r: any, frame: number): boolean {
return r.variants?.some((v: any) => v.frames.includes(frame))
}
function getFrameText(r: any, frame: number): string {
const variant = r.variants?.find((v: any) => v.frames.includes(frame))
return variant?.text || (r.summary || '').replace(/^\[OCR\]\s*/, '')
}
async function playPersonGroup(person: any) {
const traces = person._traces || []
if (!traces.length) return
const fileUuid = traces[0].file_uuid
let fps = 24
try {
const fi: any = await apiCall('get_file_info', { uuid: fileUuid })
if (fi?.fps) fps = fi.fps
} catch {}
const all = traces.map((t: any) => {
const sf = t.start_frame || 0
const ef = t.end_frame || sf + (t.frame_count || 1)
return {
trace_id: t.trace_id,
start_frame: sf,
end_frame: ef,
frame_count: t.frame_count || 1,
first_frame: sf,
last_frame: ef,
first_sec: sf / fps,
last_sec: ef / fps,
file_uuid: t.file_uuid,
}
}).sort((a: any, b: any) => a.start_frame - b.start_frame)
const merged: any[] = []
let cur: any = null
all.forEach((item: any, idx: number) => {
const st = item.first_sec
const en = item.last_sec
const fu = item.file_uuid || ''
if (cur && fu === cur.file_uuid && (st - cur.end) < 10) {
cur.end = Math.max(cur.end, en)
cur.end_frame = Math.max(cur.end_frame, item.last_frame)
cur.count++
cur._endIdx = idx
} else {
if (cur) merged.push(cur)
cur = { file_uuid: fu, start_frame: item.first_frame, end_frame: item.last_frame, start: st, end: en, count: 1, _startIdx: idx, _endIdx: idx }
}
})
if (cur) merged.push(cur)
const first = all[0]
const last = all[all.length - 1]
currentVideo.value = {
fileUuid,
startFrame: first ? first.start_frame : 0,
endFrame: last ? last.end_frame : 30,
title: `${person.file_name} · ${traces.length} ${traces.length > 1 ? 'FS' : 'FS'}`,
}
clusterTraces.value = all
clusterSegments.value = merged
playing.value = true
}
function expandRange(r: any) {
const sf = r.start_frame || 0
const ef = r.end_frame || sf + 1
@@ -1002,6 +1499,27 @@ function openSource(src: SourceInfo) {
position: relative;
cursor: pointer;
}
.hs-row { display: flex; align-items: center; gap: 6px; min-width: 0; }
.hs-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.hs-time {
font-size: 10px;
color: var(--muted-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hs-item.pinned .hs-time { color: rgba(var(--primary-color-rgb), 0.8); }
.hs-mode {
font-size: 10px;
padding: 1px 5px;
border-radius: 4px;
background: var(--border-light);
color: var(--text-secondary);
white-space: nowrap;
flex-shrink: 0;
font-weight: 500;
}
.hs-item.pinned .hs-mode { background: rgba(var(--primary-color-rgb), 0.15); color: var(--primary-color); }
.hs-item:hover { background: var(--hover-background); }
.hs-item.pinned { background: rgba(var(--primary-color-rgb), 0.1); }
.hs-item.pinned .hs-title { font-weight: 500; color: var(--primary-color); }
@@ -1177,16 +1695,28 @@ function openSource(src: SourceInfo) {
text-align: right;
}
.msg-user {
display: inline-block;
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(var(--primary-color-rgb), 0.1);
color: var(--text-primary);
padding: 8px 14px;
border-radius: 12px;
border-bottom-right-radius: 4px;
font-size: 14px;
font-size: 15px;
max-width: 70%;
word-break: break-word;
}
.msg-mode {
font-size: 10px;
padding: 1px 5px;
border-radius: 4px;
background: rgba(var(--primary-color-rgb), 0.2);
color: var(--primary-color);
white-space: nowrap;
flex-shrink: 0;
font-weight: 500;
}
.msg.assistant {
background: transparent;
color: var(--text-primary);
@@ -1270,6 +1800,44 @@ function openSource(src: SourceInfo) {
position: relative;
}
.card-thumb img { width: 100%; height: 100%; object-fit: cover; }
.ocr-group-badge {
position: absolute; top: 6px; right: 6px;
background: rgba(var(--primary-color-rgb), 0.85); color: #fff;
font-size: 11px; font-weight: 600; padding: 2px 7px; border-radius: 10px;
}
.ocr-frame-marks {
display: flex; flex-wrap: wrap; gap: 3px; padding: 6px 8px 2px;
max-height: 48px; overflow: hidden;
}
.ocr-frame-mark {
font-size: 9px; padding: 1px 5px; border-radius: 4px;
background: var(--hover-background); color: var(--text-secondary);
cursor: pointer; transition: all .15s; white-space: nowrap;
}
.ocr-frame-mark:hover {
background: rgba(var(--primary-color-rgb), 0.2); color: var(--primary-color);
}
.ocr-frame-mark.ocr-frame-variant {
background: rgba(var(--warning-color-rgb), 0.2);
color: var(--warning-color);
border: 1px solid var(--warning-color);
}
.ocr-corrected-list {
padding: 4px 8px 6px;
border-top: 1px solid var(--border-light);
font-size: 10px;
}
.ocr-corrected-item {
display: flex; align-items: center; gap: 6px;
padding: 2px 0; color: var(--text-secondary);
}
.ocr-corrected-frames {
color: var(--warning-color); font-weight: 500; min-width: 80px;
}
.ocr-corrected-arrow { opacity: 0.5; }
.ocr-corrected-original {
font-style: italic; opacity: 0.7;
}
.thumb-placeholder {
width: 100%;
height: 100%;
@@ -1500,6 +2068,24 @@ mark.highlight {
background: rgba(var(--success-color), 0.2);
color: var(--success-color);
}
.card-source-type.person {
background: rgba(var(--primary-color-rgb), 0.2);
color: var(--primary-color);
display: inline-flex;
align-items: center;
}
.card-icon {
flex-shrink: 0;
vertical-align: middle;
}
.card-person-stats {
color: var(--text-secondary);
font-size: 11px;
font-weight: 500;
}
.card-person-file {
padding: 2px 8px 6px;
}
.card-tag {
font-size: 10px;
@@ -1604,4 +2190,13 @@ mark.highlight {
display: flex;
gap: 8px;
}
@media (max-width: 767px) {
.chat--empty { padding: 40px 16px; }
.ms-hero h1 { font-size: 24px; }
.ms-hero p { font-size: 13px; }
.ibar { padding: 8px; gap: 6px; }
.ibar input { font-size: 14px; }
.message { max-width: 100%; padding: 8px 10px; font-size: 13px; }
}
</style>