Compare commits
86 Commits
69601fe512
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| bb374dd374 | |||
| 8e71996ab1 | |||
| af04d08458 | |||
| 2f9b44d4b3 | |||
| 4f2dc359fb | |||
| e83d5c7a8c | |||
| 4e1df67857 | |||
| 593b83d61a | |||
| d3df1e54d0 | |||
| a1e0519170 | |||
| c586b5f994 | |||
| 2daa49ce31 | |||
| 53e23037ec | |||
| 73532c2e3e | |||
| e65d5648b8 | |||
| 5928bc9404 | |||
| 407651eead | |||
| 22fd55b0ab | |||
| 7b83d987e4 | |||
| 8758255eee | |||
| ed971c5f98 | |||
| 0bd71ab3f0 | |||
| 40b804c0d1 | |||
| 26ac0e90df | |||
| 633406346b | |||
| f96ba33c7a | |||
| 1bc53ae584 | |||
| 4eb423e424 | |||
| 51ded78f90 | |||
| 28dfd3255d | |||
| fef3b892d6 | |||
| f3d75ebce8 | |||
| 7b60ca3d2d | |||
| 97a5fef0b2 | |||
| beea6b1c35 | |||
| 1c0f15664e | |||
| adc8d944c6 | |||
| 5be57aec7d | |||
| 26b94f7243 | |||
| 9d58fdb9d9 | |||
| 2b944cf9d1 | |||
| 453168f326 | |||
| bcffe62a15 | |||
| ee9a602f7f | |||
| 3aef7d115f | |||
| 19846a8773 | |||
| bb4ff9b007 | |||
| fcb612ff0e | |||
| 1a0b3f2c1f | |||
| 9386a8e8ef | |||
| fb9a55a64c | |||
| 9691e75e36 | |||
| 674126d687 | |||
| d37967d38e | |||
| a289346fdc | |||
| dd843c29ee | |||
| d7592c528c | |||
| f75eb44351 | |||
| 652ba77c77 | |||
| ca94259fba | |||
| 660f01c6c5 | |||
| cb7ebfb633 | |||
| 25f718c0d5 | |||
| 0b19a0af0d | |||
| cf397b268e | |||
| aa25600580 | |||
| 464ddd16a9 | |||
| 4463ff2f96 | |||
| 15647a47ac | |||
| 0a7902c2f5 | |||
| d3cd940818 | |||
| 7cc4708c66 | |||
| 5db1b68fa0 | |||
| ec8d8d9d1a | |||
| f727b21e99 | |||
| 8af2dc6128 | |||
| 042bb45574 | |||
| e6b9b7b4b8 | |||
| 98534bebfb | |||
| 389b3c8ba3 | |||
| 9de4910aa0 | |||
| baa42fa625 | |||
| 39ce37e69c | |||
| 7b5650d098 | |||
| c235d11d03 | |||
| 361a01a025 |
48
AGENTS.md
48
AGENTS.md
@@ -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`)
|
||||
Binary file not shown.
388
docs/DEEP_SCAN_QUEUE_FEATURE_REQUEST.md
Normal file
388
docs/DEEP_SCAN_QUEUE_FEATURE_REQUEST.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# Deep Scan 队列功能需求
|
||||
|
||||
## 背景
|
||||
|
||||
### 当前问题
|
||||
|
||||
**前端临时解决方案:**
|
||||
- 使用 p-limit 控制并发数(maxConcurrent = 2)
|
||||
- 每个文件启动扫描后等待 2 秒
|
||||
- 勉强限制同时扫描的文件数量
|
||||
|
||||
**存在的限制:**
|
||||
1. **不可靠**:前端无法准确控制后端资源使用
|
||||
2. **效率低**:固定延迟 2 秒可能不够或浪费时间
|
||||
3. **状态不同步**:前端和后端对"正在扫描"的理解不一致
|
||||
4. **无优先级**:所有文件按顺序处理,无法优先处理重要文件
|
||||
5. **无错误恢复**:如果某个文件处理失败,没有重试机制
|
||||
6. **无进度跟踪**:无法准确知道队列中每个文件的处理进度
|
||||
|
||||
---
|
||||
|
||||
## 需求说明
|
||||
|
||||
### 核心需求
|
||||
|
||||
**1. 后端队列管理**
|
||||
- 后端维护一个全局的扫描队列
|
||||
- 限制同时处理的文件数量(可配置,默认 2-4)
|
||||
- 自动排队等待,先进先出(FIFO)
|
||||
|
||||
**2. 队列状态 API**
|
||||
- `/api/v1/queue/status` - 获取队列状态
|
||||
```json
|
||||
{
|
||||
"active_count": 2,
|
||||
"max_concurrent": 2,
|
||||
"pending_count": 3,
|
||||
"total_processed": 10,
|
||||
"queue": [
|
||||
{
|
||||
"file_uuid": "xxx",
|
||||
"file_name": "video1.mp4",
|
||||
"status": "processing",
|
||||
"started_at": "2026-10-07T10:00:00Z",
|
||||
"progress": 45
|
||||
},
|
||||
{
|
||||
"file_uuid": "yyy",
|
||||
"file_name": "video2.mp4",
|
||||
"status": "pending",
|
||||
"queued_at": "2026-10-07T10:01:00Z",
|
||||
"position": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**3. 队列控制 API**
|
||||
- `/api/v1/queue/pause` - 暂停队列
|
||||
- `/api/v1/queue/resume` - 恢复队列
|
||||
- `/api/v1/queue/clear` - 清空队列
|
||||
- `/api/v1/queue/prioritize/{file_uuid}` - 提升优先级
|
||||
|
||||
**4. 动态并发控制**
|
||||
- 根据系统资源(CPU、内存、GPU)自动调整并发数
|
||||
- 或从配置文件/API 设置 `max_concurrent`
|
||||
|
||||
**5. 错误处理与重试**
|
||||
- 自动重试失败的任务(可配置重试次数)
|
||||
- 记录失败原因
|
||||
- 提供手动重试 API
|
||||
|
||||
---
|
||||
|
||||
## API 设计建议
|
||||
|
||||
### 1. 提交扫描任务
|
||||
|
||||
**请求:**
|
||||
```http
|
||||
POST /api/v1/file/{file_uuid}/deep-scan
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"processors": ["face", "asr", "ocr"],
|
||||
"priority": "normal", // low, normal, high
|
||||
"retry_on_failure": true,
|
||||
"callback_url": "http://..." // 可选,完成后通知
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"task_id": "task_xxx",
|
||||
"status": "queued",
|
||||
"position": 3,
|
||||
"estimated_start": "2026-10-07T10:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 查询队列状态
|
||||
|
||||
**请求:**
|
||||
```http
|
||||
GET /api/v1/queue/status
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"active_count": 2,
|
||||
"max_concurrent": 2,
|
||||
"pending_count": 3,
|
||||
"paused": false,
|
||||
"queue": [
|
||||
{
|
||||
"task_id": "task_xxx",
|
||||
"file_uuid": "xxx",
|
||||
"file_name": "video1.mp4",
|
||||
"status": "processing",
|
||||
"started_at": "2026-10-07T10:00:00Z",
|
||||
"progress": 45,
|
||||
"processors": ["face", "asr"]
|
||||
},
|
||||
{
|
||||
"task_id": "task_yyy",
|
||||
"file_uuid": "yyy",
|
||||
"file_name": "video2.mp4",
|
||||
"status": "pending",
|
||||
"queued_at": "2026-10-07T10:01:00Z",
|
||||
"position": 1,
|
||||
"priority": "normal"
|
||||
}
|
||||
],
|
||||
"statistics": {
|
||||
"total_processed": 10,
|
||||
"total_failed": 2,
|
||||
"average_time": 120.5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 任务优先级调整
|
||||
|
||||
**请求:**
|
||||
```http
|
||||
PUT /api/v1/queue/task/{task_id}/priority
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"priority": "high"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"position": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 取消任务
|
||||
|
||||
**请求:**
|
||||
```http
|
||||
DELETE /api/v1/queue/task/{task_id}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Task cancelled"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据库设计
|
||||
|
||||
### queue_tasks 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE queue_tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
file_uuid TEXT NOT NULL,
|
||||
file_name TEXT,
|
||||
status TEXT DEFAULT 'pending', -- pending, processing, completed, failed
|
||||
priority TEXT DEFAULT 'normal',
|
||||
processors JSONB,
|
||||
progress INTEGER DEFAULT 0,
|
||||
queued_at TIMESTAMP DEFAULT NOW(),
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
error_message TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
max_retries INTEGER DEFAULT 3,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_queue_status ON queue_tasks(status);
|
||||
CREATE INDEX idx_queue_priority ON queue_tasks(priority, queued_at);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实现建议
|
||||
|
||||
### 后端架构
|
||||
|
||||
```
|
||||
Queue Manager (Singleton)
|
||||
├── Task Queue (Priority Queue)
|
||||
├── Worker Pool (2-4 workers)
|
||||
├── Resource Monitor
|
||||
│ ├── CPU Usage
|
||||
│ ├── Memory Usage
|
||||
│ └── GPU Usage
|
||||
└── Scheduler
|
||||
├── Dynamic Concurrency Adjustment
|
||||
└── Task Retry Logic
|
||||
```
|
||||
|
||||
### 核心组件
|
||||
|
||||
**1. QueueManager**
|
||||
```rust
|
||||
pub struct QueueManager {
|
||||
tasks: Arc<Mutex<VecDeque<QueueTask>>>,
|
||||
active_tasks: Arc<Mutex<HashMap<TaskId, JoinHandle>>>,
|
||||
max_concurrent: Arc<AtomicU32>,
|
||||
worker_semaphore: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl QueueManager {
|
||||
pub async fn submit_task(&self, task: QueueTask) -> Result<TaskId>;
|
||||
pub async fn get_status(&self) -> QueueStatus;
|
||||
pub async fn cancel_task(&self, task_id: TaskId) -> Result<()>;
|
||||
pub async fn adjust_concurrency(&self, max: u32);
|
||||
}
|
||||
```
|
||||
|
||||
**2. ResourceMonitor**
|
||||
```rust
|
||||
pub struct ResourceMonitor {
|
||||
cpu_threshold: f64, // 80%
|
||||
memory_threshold: f64, // 80%
|
||||
gpu_threshold: f64, // 90%
|
||||
}
|
||||
|
||||
impl ResourceMonitor {
|
||||
pub fn check_resources(&self) -> ResourceStatus;
|
||||
pub fn suggest_concurrency(&self) -> u32;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端集成
|
||||
|
||||
### 简化后的前端代码
|
||||
|
||||
```typescript
|
||||
async function batchDeepScan() {
|
||||
const filesToProcess = selectedRegisteredFiles.value
|
||||
|
||||
// 提交所有任务到后端队列
|
||||
for (const f of filesToProcess) {
|
||||
await apiCall('deep_scan', {
|
||||
fileUuid: f.file_uuid,
|
||||
priority: 'normal'
|
||||
})
|
||||
}
|
||||
|
||||
// 轮询队列状态
|
||||
const interval = setInterval(async () => {
|
||||
const status = await apiCall('get_queue_status')
|
||||
actionMsg.value = `Processing: ${status.active_count} active, ${status.pending_count} queuing...`
|
||||
|
||||
if (status.pending_count === 0 && status.active_count === 0) {
|
||||
clearInterval(interval)
|
||||
actionMsg.value = 'All tasks completed'
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 预期效果
|
||||
|
||||
### 用户体验改进
|
||||
|
||||
**当前(前端队列):**
|
||||
- ❌ 不准确的并发控制
|
||||
- ❌ 固定延迟效率低
|
||||
- ❌ 无法查看队列状态
|
||||
- ❌ 无法调整优先级
|
||||
- ❌ 无法恢复失败任务
|
||||
|
||||
**改进后(后端队列):**
|
||||
- ✅ 准确的并发控制
|
||||
- ✅ 自动资源管理
|
||||
- ✅ 实时队列状态查看
|
||||
- ✅ 灵活的优先级调整
|
||||
- ✅ 自动重试机制
|
||||
- ✅ 错误恢复能力
|
||||
|
||||
---
|
||||
|
||||
## 配置选项
|
||||
|
||||
### momentry.toml
|
||||
|
||||
```toml
|
||||
[queue]
|
||||
# 最大并发处理数
|
||||
max_concurrent = 2
|
||||
|
||||
# 是否启用动态调整
|
||||
dynamic_concurrency = true
|
||||
|
||||
# 资源阈值
|
||||
cpu_threshold = 80.0
|
||||
memory_threshold = 80.0
|
||||
gpu_threshold = 90.0
|
||||
|
||||
# 重试配置
|
||||
max_retries = 3
|
||||
retry_delay = 10 # seconds
|
||||
|
||||
# 任务超时
|
||||
task_timeout = 3600 # seconds
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 里程碑
|
||||
|
||||
### Phase 1: 基础队列功能
|
||||
- 队列数据结构和 API
|
||||
- 基本的并发控制
|
||||
- 任务状态跟踪
|
||||
|
||||
### Phase 2: 高级功能
|
||||
- 优先级队列
|
||||
- 资源监控
|
||||
- 动态并发调整
|
||||
|
||||
### Phase 3: 错误处理
|
||||
- 自动重试
|
||||
- 错误日志
|
||||
- 手动干预 API
|
||||
|
||||
---
|
||||
|
||||
## 参考实现
|
||||
|
||||
**类似项目:**
|
||||
- Celery (Python)
|
||||
- Bull (Node.js)
|
||||
- Sidekiq (Ruby)
|
||||
|
||||
**关键特性:**
|
||||
- 持久化队列
|
||||
- 优先级支持
|
||||
- 进度跟踪
|
||||
- 错误处理
|
||||
|
||||
---
|
||||
|
||||
## 联系方式
|
||||
|
||||
**需求提出者:** Momentry Studio Team
|
||||
**日期:** 2026-10-07
|
||||
**优先级:** High
|
||||
**预计工期:** 2-3 周
|
||||
|
||||
543
docs/face_filter_integration_guide.md
Normal file
543
docs/face_filter_integration_guide.md
Normal file
@@ -0,0 +1,543 @@
|
||||
# Face Filter Integration Guide for PeopleView
|
||||
|
||||
**版本**: v1.0
|
||||
**日期**: 2026-08-09
|
||||
**狀態**: 可整合
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
本指南說明如何將 Face Filter 功能整合到 PeopleView 中。
|
||||
|
||||
---
|
||||
|
||||
## 已創建的檔案
|
||||
|
||||
### 1. Composable
|
||||
**位置**: `src/composables/useFaceFilter.ts`
|
||||
|
||||
**功能**:
|
||||
- 管理臉部過濾狀態
|
||||
- 提供三種過濾模式(instant, backend, hybrid)
|
||||
- 處理 API 呼叫
|
||||
- 儲存使用者偏好到 localStorage
|
||||
|
||||
**主要 exports**:
|
||||
```typescript
|
||||
export function useFaceFilter() {
|
||||
return {
|
||||
mode, // 'instant' | 'backend' | 'hybrid'
|
||||
rules, // QuickFilterRules
|
||||
allFaces, // FaceMetadata[]
|
||||
filteredFaces, // FaceMetadata[]
|
||||
filterResult, // FilterResult | null
|
||||
filterStats, // FilterStats | null
|
||||
loading, // boolean
|
||||
error, // string | null
|
||||
stats, // computed stats
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
updateRules,
|
||||
setMode,
|
||||
applyBackendFilter,
|
||||
getFaceReportData,
|
||||
refreshStats,
|
||||
resetToDefault,
|
||||
applyLocalFilter
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Components
|
||||
|
||||
#### FaceFilterPanel.vue
|
||||
**位置**: `src/components/FaceFilterPanel.vue`
|
||||
|
||||
**功能**:
|
||||
- 過濾面板 UI
|
||||
- 模式選擇(instant/backend/hybrid)
|
||||
- 過濾規則控制(confidence, bbox_size, source, backend_status)
|
||||
- 統計顯示
|
||||
|
||||
**Props**:
|
||||
```typescript
|
||||
{
|
||||
fileUuid: string // 必需的檔案 UUID
|
||||
}
|
||||
```
|
||||
|
||||
**使用範例**:
|
||||
```vue
|
||||
<FaceFilterPanel :fileUuid="selectedFileUuid" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### FaceCard.vue
|
||||
**位置**: `src/components/FaceCard.vue`
|
||||
|
||||
**功能**:
|
||||
- 臉部卡片顯示(純文字,無縮圖)
|
||||
- 狀態標示(accept/reject/review)
|
||||
- 來源徽章(AV/MP)
|
||||
- 選擇功能
|
||||
|
||||
**Props**:
|
||||
```typescript
|
||||
{
|
||||
face: FaceMetadata,
|
||||
selected?: boolean,
|
||||
showCheckbox?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
**Events**:
|
||||
```typescript
|
||||
{
|
||||
click: [face: FaceMetadata],
|
||||
select: [face: FaceMetadata]
|
||||
}
|
||||
```
|
||||
|
||||
**使用範例**:
|
||||
```vue
|
||||
<FaceCard
|
||||
:face="face"
|
||||
:selected="selectedFaces.includes(face)"
|
||||
@click="openFaceDetail(face)"
|
||||
@select="toggleFaceSelection(face)"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 整合步驟
|
||||
|
||||
### Step 1: 導入 Composable
|
||||
|
||||
在 PeopleView.vue 中導入 useFaceFilter:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useFaceFilter } from '../composables/useFaceFilter';
|
||||
|
||||
const {
|
||||
mode,
|
||||
rules,
|
||||
allFaces,
|
||||
filteredFaces,
|
||||
filterStats,
|
||||
loading: filterLoading,
|
||||
error: filterError,
|
||||
stats: filterStats,
|
||||
initialize,
|
||||
updateRules,
|
||||
setMode,
|
||||
applyBackendFilter,
|
||||
resetToDefault
|
||||
} = useFaceFilter();
|
||||
|
||||
// ... existing code ...
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 2: 添加 FaceFilterPanel 組件
|
||||
|
||||
在 toolbar 區域添加 FaceFilterPanel:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="people-view">
|
||||
<div class="ms-ppl-control-area">
|
||||
<!-- Existing toolbar -->
|
||||
<div class="ms-ppl-toolbar">
|
||||
<!-- ... existing controls ... -->
|
||||
</div>
|
||||
|
||||
<!-- NEW: Face Filter Panel -->
|
||||
<FaceFilterPanel
|
||||
v-if="selectedFileUuid"
|
||||
:fileUuid="selectedFileUuid"
|
||||
ref="faceFilterPanel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ... rest of the view ... -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FaceFilterPanel from '../components/FaceFilterPanel.vue';
|
||||
|
||||
const faceFilterPanel = ref();
|
||||
|
||||
// ... existing code ...
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 3: 初始化過濾
|
||||
|
||||
當選擇檔案時初始化過濾:
|
||||
|
||||
```typescript
|
||||
// 在 onFileFilterChange 或 watch(selectedFileUuid) 中
|
||||
watch(selectedFileUuid, async (newUuid) => {
|
||||
if (newUuid) {
|
||||
await initialize(newUuid);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 4: 使用過濾後的臉部
|
||||
|
||||
使用 `filteredFaces` 替代原始的臉部列表:
|
||||
|
||||
```vue
|
||||
<!-- 原本的臉部列表 -->
|
||||
<div v-for="face in allFaces" :key="face.face_id">
|
||||
<!-- ... -->
|
||||
</div>
|
||||
|
||||
<!-- 改為使用過濾後的臉部 -->
|
||||
<div v-for="face in filteredFaces" :key="face.face_id">
|
||||
<FaceCard
|
||||
:face="face"
|
||||
@click="openFaceDetail(face)"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 5: 整合到現有 UI
|
||||
|
||||
#### 選項 A: 獨立面板(推薦)
|
||||
|
||||
FaceFilterPanel 作為獨立面板,放在 toolbar 下方:
|
||||
|
||||
```vue
|
||||
<div class="ms-ppl-control-area">
|
||||
<div class="ms-ppl-toolbar">
|
||||
<!-- Existing controls -->
|
||||
</div>
|
||||
|
||||
<!-- Face Filter Panel -->
|
||||
<FaceFilterPanel :fileUuid="selectedFileUuid" />
|
||||
|
||||
<!-- Undo Banner -->
|
||||
<div v-if="showUndoBanner" class="ms-undo-banner">
|
||||
<!-- ... -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 選項 B: 整合到現有面板
|
||||
|
||||
將過濾控制整合到現有的 Pending Faces 面板:
|
||||
|
||||
```vue
|
||||
<div v-if="showUface" class="ms-ppl-section">
|
||||
<div class="ms-ppl-section-toolbar">
|
||||
<div class="ms-ppl-section-title">Pending Faces</div>
|
||||
|
||||
<!-- Add filter controls -->
|
||||
<div class="filter-controls-inline">
|
||||
<select v-model="rules.source">
|
||||
<option value="all">All</option>
|
||||
<option value="apple_vision">AV</option>
|
||||
<option value="mediapipe">MP</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="rules.min_confidence"
|
||||
placeholder="Min confidence"
|
||||
/>
|
||||
<button @click="applyBackendFilter(selectedFileUuid)">
|
||||
Apply Backend
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Face cards -->
|
||||
<div class="ms-ppl-face-grid">
|
||||
<FaceCard
|
||||
v-for="face in filteredFaces"
|
||||
:key="face.face_id"
|
||||
:face="face"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 端點
|
||||
|
||||
所有 API 呼叫都通過 composable 處理,使用 proxy 到 `http://localhost:8888/api/v1/...`
|
||||
|
||||
**實際端點**:
|
||||
1. `GET /api/v1/file/{uuid}/faces` - 獲取臉部元數據
|
||||
2. `POST /api/v1/file/{uuid}/faces/filter` - 套用後端過濾
|
||||
3. `GET /api/v1/file/{uuid}/face/{faceId}/filter-report` - 獲取單臉報告
|
||||
4. `GET /api/v1/file/{uuid}/filter-stats` - 獲取統計
|
||||
5. `GET/POST /api/v1/file/{uuid}/filter-rules` - 規則 CRUD
|
||||
|
||||
---
|
||||
|
||||
## 資料結構
|
||||
|
||||
### FaceMetadata
|
||||
|
||||
```typescript
|
||||
interface FaceMetadata {
|
||||
face_id: string;
|
||||
frame: number;
|
||||
primary_source: 'apple_vision' | 'mediapipe';
|
||||
primary_confidence: number;
|
||||
detection_sources: Array<{
|
||||
source_type: string;
|
||||
confidence: number;
|
||||
}>;
|
||||
filter_features: {
|
||||
face_id: string;
|
||||
frame: number;
|
||||
bbox_size: number; // min(width, height)
|
||||
apple_vision_confidence: number;
|
||||
mediapipe_confidence?: number;
|
||||
eye_ratio?: number;
|
||||
nose_ratio?: number;
|
||||
pose_score?: number;
|
||||
mediapipe_detected?: boolean;
|
||||
mediapipe_iou?: number;
|
||||
embedding_similarity?: number;
|
||||
consecutive_frames?: number;
|
||||
total_frames_detected?: number;
|
||||
backend_filter_status?: 'accept' | 'reject' | 'review';
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 過濾模式
|
||||
|
||||
### Instant Mode
|
||||
- **效能**: 5-10ms
|
||||
- **邏輯**: Client 端即時過濾
|
||||
- **適用**: 快速調整規則
|
||||
|
||||
### Backend Mode
|
||||
- **效能**: 100-200ms(API 呼叫)
|
||||
- **邏輯**: 後端複雜過濾(landmark, embedding, temporal)
|
||||
- **適用**: 高品質過濾
|
||||
|
||||
### Hybrid Mode
|
||||
- **效能**: 5-10ms(前端)+ 後端預先計算
|
||||
- **邏輯**: 後端標示 + 前端即時過濾(AND)
|
||||
- **適用**: 最佳使用者體驗
|
||||
|
||||
---
|
||||
|
||||
## 使用者偏好
|
||||
|
||||
偏好儲存在 `localStorage`:
|
||||
|
||||
**Key**: `momentry_face_filter_preferences`
|
||||
|
||||
**結構**:
|
||||
```json
|
||||
{
|
||||
"mode": "instant",
|
||||
"rules": {
|
||||
"min_confidence": 0.5,
|
||||
"min_bbox_size": 80,
|
||||
"source": "all",
|
||||
"backend_status": "accept"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 效能優化
|
||||
|
||||
### 分頁
|
||||
- `page_size ≤ 50`(防 hang)
|
||||
- 虛擬滾動(可選)
|
||||
|
||||
### 快取
|
||||
- 臉部元數據快取在 composable
|
||||
- 偏好儲存在 localStorage
|
||||
|
||||
### 懶載入
|
||||
- JPG 縮圖懶載入(未來擴展)
|
||||
- 當前使用純文字卡片
|
||||
|
||||
---
|
||||
|
||||
## 錯誤處理
|
||||
|
||||
Composable 提供 error 狀態:
|
||||
|
||||
```typescript
|
||||
if (error.value) {
|
||||
console.error('Face filter error:', error.value);
|
||||
// 顯示錯誤訊息給使用者
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 測試
|
||||
|
||||
### 單元測試
|
||||
```typescript
|
||||
import { useFaceFilter } from '../composables/useFaceFilter';
|
||||
|
||||
describe('useFaceFilter', () => {
|
||||
it('should filter faces by confidence', () => {
|
||||
const { updateRules, filteredFaces } = useFaceFilter();
|
||||
|
||||
// Setup test data
|
||||
// ...
|
||||
|
||||
updateRules({ min_confidence: 0.7 });
|
||||
expect(filteredFaces.value.length).toBeLessThan(allFaces.value.length);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 整合測試
|
||||
1. 選擇檔案
|
||||
2. 驗證臉部載入
|
||||
3. 調整過濾規則
|
||||
4. 驗證過濾結果
|
||||
5. 切換模式
|
||||
6. 驗證模式切換
|
||||
|
||||
---
|
||||
|
||||
## 未來擴展
|
||||
|
||||
### 1. 縮圖支援
|
||||
當 Core 實作 `/face/{id}/thumbnail` 端點後:
|
||||
|
||||
```vue
|
||||
<FaceCard :face="face" showThumbnail />
|
||||
```
|
||||
|
||||
### 2. 規則管理 UI
|
||||
當需要規則 CRUD 時:
|
||||
|
||||
```vue
|
||||
<RuleManager
|
||||
:fileUuid="selectedFileUuid"
|
||||
@rule-change="handleRuleChange"
|
||||
/>
|
||||
```
|
||||
|
||||
### 3. 批量操作
|
||||
```typescript
|
||||
const selectedFaces = ref<FaceMetadata[]>([]);
|
||||
|
||||
const batchAction = async (action: 'accept' | 'reject') => {
|
||||
await fetch('/api/v1/file/.../faces/batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
face_ids: selectedFaces.value.map(f => f.face_id),
|
||||
action
|
||||
})
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### 4. 匯出功能
|
||||
```typescript
|
||||
const exportFilteredFaces = () => {
|
||||
const csv = filteredFaces.value.map(face =>
|
||||
`${face.face_id},${face.frame},${face.primary_confidence},${face.filter_features.bbox_size}`
|
||||
).join('\n');
|
||||
|
||||
// Download CSV
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 疑難排解
|
||||
|
||||
### 問題 1: 臉部未載入
|
||||
**檢查**:
|
||||
- `selectedFileUuid` 是否正確
|
||||
- API 端點是否可達
|
||||
- 網路請求是否成功
|
||||
|
||||
**解決**:
|
||||
```typescript
|
||||
console.log('File UUID:', selectedFileUuid);
|
||||
console.log('API Response:', await fetchFaces(selectedFileUuid));
|
||||
```
|
||||
|
||||
### 問題 2: 過濾未生效
|
||||
**檢查**:
|
||||
- `mode` 是否正確
|
||||
- `rules` 是否正確
|
||||
- `filteredFaces` 是否更新
|
||||
|
||||
**解決**:
|
||||
```typescript
|
||||
console.log('Mode:', mode.value);
|
||||
console.log('Rules:', rules.value);
|
||||
console.log('Filtered:', filteredFaces.value.length);
|
||||
```
|
||||
|
||||
### 問題 3: 偏好未儲存
|
||||
**檢查**:
|
||||
- `localStorage` 是否可寫
|
||||
- Key 是否正確
|
||||
|
||||
**解決**:
|
||||
```typescript
|
||||
console.log('LocalStorage:', localStorage.getItem('momentry_face_filter_preferences'));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 總結
|
||||
|
||||
**整合步驟**:
|
||||
1. ✅ 導入 useFaceFilter composable
|
||||
2. ✅ 添加 FaceFilterPanel 組件
|
||||
3. ✅ 初始化過濾(watch selectedFileUuid)
|
||||
4. ✅ 使用 filteredFaces 顯示
|
||||
5. ✅ 處理錯誤和載入狀態
|
||||
|
||||
**預期效果**:
|
||||
- 即時過濾(5-10ms)
|
||||
- 三種模式切換
|
||||
- 使用者偏好儲存
|
||||
- 純文字卡片(無縮圖)
|
||||
|
||||
**未來擴展**:
|
||||
- 縮圖支援
|
||||
- 規則管理 UI
|
||||
- 批量操作
|
||||
- 匯出功能
|
||||
|
||||
---
|
||||
|
||||
**文件版本**: v1.0
|
||||
**最後更新**: 2026-08-09
|
||||
**相關文件**:
|
||||
- `/Users/accusys/momentry_core/docs_v1.0/STUDIO_TEAM/face_filter_usage_guide.md`
|
||||
- `/Users/accusys/momentry_core/docs_v1.0/STUDIO_TEAM/face_filter_hybrid_mode_guide.md`
|
||||
@@ -1,8 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" translate="no">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="google" content="notranslate">
|
||||
<title>Momentry Studio</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
1
momentry-studio-v0.1.0-macos-aarch64.tar.gz.sha256
Normal file
1
momentry-studio-v0.1.0-macos-aarch64.tar.gz.sha256
Normal file
@@ -0,0 +1 @@
|
||||
154ef0e02a6199278ea6bc6a5073d11c631527f2825ba78ceeafcb5f486cf969 momentry-studio-v0.1.0-macos-aarch64.tar.gz
|
||||
@@ -3,8 +3,8 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Momentry Studio - Video Analysis Platform",
|
||||
"scripts": {
|
||||
"proxy": "lsof -ti :8888 | xargs kill -9 2>/dev/null; MOMENTRY_DATA_DIR=$(pwd)/data/users ./src-tauri/target/debug/momentry-proxy",
|
||||
"dev": "lsof -ti :8888 | xargs kill -9 2>/dev/null; MOMENTRY_DATA_DIR=$(pwd)/data/users ./src-tauri/target/debug/momentry-proxy > /tmp/momentry_proxy.log 2>&1 & sleep 2 && vite",
|
||||
"proxy": "lsof -ti :8888 | xargs kill -9 2>/dev/null; MOMENTRY_API_KEY=muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69 MOMENTRY_DATA_DIR=$(pwd)/data/users ./src-tauri/target/debug/momentry-proxy",
|
||||
"dev": "lsof -ti :8888 | xargs kill -9 2>/dev/null; MOMENTRY_API_KEY=muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69 MOMENTRY_DATA_DIR=$(pwd)/data/users ./src-tauri/target/debug/momentry-proxy > /tmp/momentry_proxy.log 2>&1 & sleep 2 && vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "cargo tauri"
|
||||
|
||||
345
release/ADMIN_DEPLOYMENT_GUIDE.md
Normal file
345
release/ADMIN_DEPLOYMENT_GUIDE.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# Momentry Studio v0.1.0 - Admin Team 部署指南
|
||||
|
||||
**发布日期:** 2026-10-08
|
||||
**构建版本:** 8e71996
|
||||
**目标环境:** 生产环境
|
||||
|
||||
---
|
||||
|
||||
## 📦 发布包信息
|
||||
|
||||
**文件名:** `momentry-studio-v0.1.0-macos-aarch64.tar.gz`
|
||||
**文件大小:** 37MB
|
||||
**SHA256:**
|
||||
```
|
||||
154ef0e02a6199278ea6bc6a5073d11c631527f2825ba78ceeafcb5f486cf969
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 验证发布包
|
||||
|
||||
```bash
|
||||
# 下载发布包后验证校验和
|
||||
shasum -a 256 -c momentry-studio-v0.1.0-macos-aarch64.tar.gz.sha256
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 部署前检查清单
|
||||
|
||||
### 1. 系统要求
|
||||
- [ ] macOS 12.0+ (Monterey 或更高版本)
|
||||
- [ ] Apple Silicon (M1/M2/M3)
|
||||
- [ ] 4GB RAM minimum (推荐 8GB)
|
||||
- [ ] 1GB 可用磁盘空间
|
||||
|
||||
### 2. 依赖服务
|
||||
- [ ] PostgreSQL 14+ 已安装并运行
|
||||
- [ ] Qdrant 1.7+ 已安装并运行
|
||||
- [ ] MarkBase Core API 2.3+ 已配置
|
||||
- [ ] MarkBase Engine 1.0+ 已配置
|
||||
|
||||
### 3. 网络配置
|
||||
- [ ] 端口 3002 可用(Core API)
|
||||
- [ ] 端口 8888 可用(Proxy)
|
||||
- [ ] 端口 5432 可用(PostgreSQL)
|
||||
- [ ] 端口 6333 可用(Qdrant)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 部署步骤
|
||||
|
||||
### 步骤 1: 准备发布包
|
||||
|
||||
```bash
|
||||
# 1. 解压发布包
|
||||
tar -xzf momentry-studio-v0.1.0-macos-aarch64.tar.gz
|
||||
|
||||
# 2. 验证内容
|
||||
ls -l
|
||||
# 应该看到:
|
||||
# - Momentry Studio.app
|
||||
# - Momentry Studio_0.1.0_aarch64.dmg
|
||||
# - momentry-core
|
||||
# - momentry-proxy
|
||||
# - install.sh
|
||||
# - README.md
|
||||
# - RELEASE_NOTES.md
|
||||
# - VERSION.json
|
||||
```
|
||||
|
||||
### 步骤 2: 部署 Studio App
|
||||
|
||||
**选项 A: 使用 DMG(推荐)**
|
||||
```bash
|
||||
# 打开 DMG 文件
|
||||
open "Momentry Studio_0.1.0_aarch64.dmg"
|
||||
|
||||
# 在 Finder 中拖拽到 Applications
|
||||
# 或使用命令行:
|
||||
cp -r "/Volumes/Momentry Studio/Momentry Studio.app" /Applications/
|
||||
```
|
||||
|
||||
**选项 B: 使用 .app**
|
||||
```bash
|
||||
# 直接复制到 Applications
|
||||
cp -r "Momentry Studio.app" /Applications/
|
||||
|
||||
# 如果需要,授权运行
|
||||
xattr -d com.apple.quarantine "/Applications/Momentry Studio.app"
|
||||
```
|
||||
|
||||
**选项 C: 使用安装脚本**
|
||||
```bash
|
||||
# 运行安装脚本
|
||||
./install.sh
|
||||
```
|
||||
|
||||
### 步骤 3: 部署 Backend Services
|
||||
|
||||
**安装 binaries:**
|
||||
```bash
|
||||
# 复制到系统 bin 目录
|
||||
sudo cp momentry-core /usr/local/bin/
|
||||
sudo cp momentry-proxy /usr/local/bin/
|
||||
|
||||
# 设置权限
|
||||
sudo chmod +x /usr/local/bin/momentry-core
|
||||
sudo chmod +x /usr/local/bin/momentry-proxy
|
||||
```
|
||||
|
||||
### 步骤 4: 配置服务
|
||||
|
||||
**创建配置文件:**
|
||||
```bash
|
||||
# 创建配置目录
|
||||
mkdir -p /opt/momentry/config
|
||||
|
||||
# 创建配置文件
|
||||
cat > /opt/momentry/config/momentry.toml << 'TOML'
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 3002
|
||||
|
||||
[database]
|
||||
url = "postgres://momentry:password@localhost:5432/momentry"
|
||||
|
||||
[face_settings]
|
||||
min_confidence = 0.5
|
||||
min_bbox_size = 80
|
||||
TOML
|
||||
```
|
||||
|
||||
**设置环境变量:**
|
||||
```bash
|
||||
# 在 ~/.zshrc 或 ~/.bash_profile 中添加
|
||||
export MOMENTRY_API_KEY="your_production_api_key"
|
||||
export MOMENTRY_DATA_DIR="/opt/momentry/data"
|
||||
export MOMENTRY_CONFIG="/opt/momentry/config/momentry.toml"
|
||||
```
|
||||
|
||||
### 步骤 5: 启动服务
|
||||
|
||||
**手动启动:**
|
||||
```bash
|
||||
# 1. 启动 Core API
|
||||
momentry-core server --config /opt/momentry/config/momentry.toml
|
||||
|
||||
# 2. 在另一个终端启动 Studio
|
||||
open "/Applications/Momentry Studio.app"
|
||||
```
|
||||
|
||||
**创建 launchd 服务(可选):**
|
||||
```bash
|
||||
# 创建 Core API 服务
|
||||
sudo cat > /Library/LaunchDaemons/com.momentry.core.plist << 'PLIST'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.momentry.core</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/momentry-core</string>
|
||||
<string>server</string>
|
||||
<string>--config</string>
|
||||
<string>/opt/momentry/config/momentry.toml</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
# 加载服务
|
||||
sudo launchctl load /Library/LaunchDaemons/com.momentry.core.plist
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证部署
|
||||
|
||||
### 1. 验证 Core API
|
||||
|
||||
```bash
|
||||
# 检查服务状态
|
||||
curl http://localhost:3002/api/v1/health
|
||||
|
||||
# 预期响应:
|
||||
# {"status":"ok","version":"1.0.0",...}
|
||||
```
|
||||
|
||||
### 2. 验证 Proxy
|
||||
|
||||
```bash
|
||||
# 检查 proxy 状态
|
||||
curl http://localhost:8888/health
|
||||
|
||||
# 预期响应:
|
||||
# ok
|
||||
```
|
||||
|
||||
### 3. 验证 Studio App
|
||||
|
||||
```bash
|
||||
# 打开应用
|
||||
open "/Applications/Momentry Studio.app"
|
||||
|
||||
# 检查进程
|
||||
ps aux | grep "Momentry Studio"
|
||||
```
|
||||
|
||||
### 4. 功能测试
|
||||
|
||||
在 Studio 中测试以下功能:
|
||||
|
||||
- [ ] 登录功能
|
||||
- [ ] Library 文件浏览
|
||||
- [ ] Search 功能
|
||||
- [ ] People 页面
|
||||
- [ ] Admin 设置页面
|
||||
- [ ] MarkBase Engine 状态显示为 `online`
|
||||
- [ ] 批量 Deep Scan 功能
|
||||
- [ ] 批量 Unregister 功能
|
||||
|
||||
---
|
||||
|
||||
## 🔄 更新流程
|
||||
|
||||
### 从旧版本升级
|
||||
|
||||
```bash
|
||||
# 1. 停止旧服务
|
||||
sudo launchctl unload /Library/LaunchDaemons/com.momentry.core.plist 2>/dev/null || true
|
||||
pkill -f momentry-core
|
||||
pkill -f "Momentry Studio"
|
||||
|
||||
# 2. 备份数据(重要!)
|
||||
pg_dump momentry > momentry_backup_$(date +%Y%m%d).sql
|
||||
|
||||
# 3. 安装新版本
|
||||
# (按照上面的部署步骤)
|
||||
|
||||
# 4. 启动新服务
|
||||
sudo launchctl load /Library/LaunchDaemons/com.momentry.core.plist
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 故障排查
|
||||
|
||||
### 问题 1: 应用无法启动
|
||||
|
||||
**症状:** 双击 .app 后无反应
|
||||
|
||||
**解决方案:**
|
||||
```bash
|
||||
# 移除隔离属性
|
||||
xattr -d com.apple.quarantine "/Applications/Momentry Studio.app"
|
||||
|
||||
# 检查控制台日志
|
||||
log stream --predicate 'process == "momentry-studio"' --level debug
|
||||
```
|
||||
|
||||
### 问题 2: 无法连接到 Core API
|
||||
|
||||
**症状:** Studio 显示连接错误
|
||||
|
||||
**解决方案:**
|
||||
```bash
|
||||
# 检查 Core API 是否运行
|
||||
ps aux | grep momentry-core
|
||||
|
||||
# 检查端口是否监听
|
||||
lsof -i :3002
|
||||
|
||||
# 检查防火墙
|
||||
# 确保端口 3002 和 8888 已开放
|
||||
```
|
||||
|
||||
### 问题 3: MarkBase Engine 显示 offline
|
||||
|
||||
**症状:** Admin 页面显示 MarkBase Engine 为 offline
|
||||
|
||||
**解决方案:**
|
||||
```bash
|
||||
# 检查 MarkBase Engine 是否运行
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# 如果正常返回 {"status":"ok"},但 Studio 仍显示 offline
|
||||
# 确认使用的是最新版本的 Studio(v0.1.0+)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 监控和日志
|
||||
|
||||
### 日志位置
|
||||
|
||||
- **Core API:** `/opt/momentry/logs/core.log`
|
||||
- **Proxy:** `/opt/momentry/logs/proxy.log`
|
||||
- **Studio:** `~/Library/Logs/Momentry Studio/`
|
||||
|
||||
### 性能监控
|
||||
|
||||
```bash
|
||||
# 检查资源使用
|
||||
top -pid $(pgrep momentry-core)
|
||||
|
||||
# 检查内存使用
|
||||
ps aux | grep momentry-core | awk '{print $6/1024 "MB"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 支持联系
|
||||
|
||||
**技术支持:**
|
||||
- Email: tech@momentry.ai
|
||||
- Slack: #momentry-support
|
||||
|
||||
**紧急联系:**
|
||||
- On-call: +886-xxx-xxx-xxx
|
||||
|
||||
**文档:**
|
||||
- 详细发布说明: RELEASE_NOTES.md
|
||||
- 用户手册: https://docs.momentry.ai
|
||||
|
||||
---
|
||||
|
||||
## 📝 部署记录
|
||||
|
||||
**部署日期:** _________
|
||||
**部署人员:** _________
|
||||
**部署环境:** _________
|
||||
**验证结果:** [ ] 通过 [ ] 失败
|
||||
**备注:** _________
|
||||
|
||||
---
|
||||
|
||||
**部署完成后,请将此文档归档并通知开发团队。**
|
||||
|
||||
69
release/README.md
Normal file
69
release/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Momentry Studio v0.1.0
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 安装
|
||||
|
||||
**方法 1: DMG 安装(推荐)**
|
||||
```bash
|
||||
# 打开 DMG 文件
|
||||
open "Momentry Studio_0.1.0_aarch64.dmg"
|
||||
# 拖拽到 Applications 文件夹
|
||||
```
|
||||
|
||||
**方法 2: 使用安装脚本**
|
||||
```bash
|
||||
./install.sh
|
||||
```
|
||||
|
||||
**方法 3: 手动安装**
|
||||
```bash
|
||||
cp -r "Momentry Studio.app" /Applications/
|
||||
```
|
||||
|
||||
### 启动服务
|
||||
|
||||
1. **启动 Core API:**
|
||||
```bash
|
||||
export MOMENTRY_API_KEY=your_api_key
|
||||
./momentry-core server --host 0.0.0.0 --port 3002
|
||||
```
|
||||
|
||||
2. **启动 Studio:**
|
||||
```bash
|
||||
open "/Applications/Momentry Studio.app"
|
||||
```
|
||||
|
||||
## 包含内容
|
||||
|
||||
- `Momentry Studio.app` - macOS 桌面应用
|
||||
- `Momentry Studio_0.1.0_aarch64.dmg` - DMG 安装包
|
||||
- `momentry-core` - Core API server
|
||||
- `momentry-proxy` - HTTP proxy server
|
||||
- `install.sh` - 安装脚本
|
||||
- `RELEASE_NOTES.md` - 详细发布说明
|
||||
|
||||
## 系统要求
|
||||
|
||||
- macOS 12.0+ (Apple Silicon)
|
||||
- PostgreSQL 14+
|
||||
- 4GB RAM minimum
|
||||
|
||||
## 新功能
|
||||
|
||||
- ✅ 批量 Deep Scan(并发控制)
|
||||
- ✅ 批量 Unregister
|
||||
- ✅ Face Filter 自动保存
|
||||
- ✅ MarkBase Engine 健康检查修复
|
||||
- ✅ FS Name 唯一性修复
|
||||
|
||||
## 文档
|
||||
|
||||
- 详细发布说明:`RELEASE_NOTES.md`
|
||||
- 配置示例:运行 `install.sh` 后创建在 `~/.momentry/momentry.toml`
|
||||
|
||||
## 支持
|
||||
|
||||
- 技术支持:tech@momentry.ai
|
||||
- Bug 报告:https://github.com/momentry/studio/issues
|
||||
|
||||
250
release/RELEASE_NOTES.md
Normal file
250
release/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Momentry Studio v0.1.0 Release Notes
|
||||
|
||||
**发布日期:** 2026-10-08
|
||||
**构建类型:** Production Release
|
||||
**平台:** macOS (Apple Silicon)
|
||||
|
||||
---
|
||||
|
||||
## 📦 发布包内容
|
||||
|
||||
### 1. Tauri Desktop Application
|
||||
- **Momentry Studio.app** - macOS 原生桌面应用
|
||||
- **Momentry Studio_0.1.0_aarch64.dmg** - DMG 安装包
|
||||
|
||||
### 2. Backend Services
|
||||
- **momentry-core** - Core API server binary
|
||||
- **momentry-proxy** - HTTP proxy server binary
|
||||
|
||||
---
|
||||
|
||||
## ✨ 新功能
|
||||
|
||||
### 1. 批量操作增强
|
||||
|
||||
**批量 Deep Scan:**
|
||||
- 在 Registered 和 Completed section 添加批量 Deep Scan 按钮
|
||||
- 使用 p-limit 控制并发(maxConcurrent = 2)
|
||||
- 超过并发的文件自动排队
|
||||
- 实时显示队列状态:`Processing 1/5 (3 queuing, 2 active)...`
|
||||
|
||||
**批量 Unregister:**
|
||||
- 在 Registered 和 Completed section 添加批量 Unregister 按钮
|
||||
- 支持确认对话框
|
||||
- 显示成功/失败统计
|
||||
|
||||
### 2. Face Filter 自动保存
|
||||
|
||||
**实时自动保存(500ms 防抖):**
|
||||
- 修改后自动保存到后端数据库
|
||||
- 离开页面时自动保存未保存的修改
|
||||
- 组件卸载时清理定时器
|
||||
- 显示自动保存状态:`✓ Auto-saved` 或 `⏳ Saving...`
|
||||
|
||||
### 3. Registered 卡片 Checkbox 修复
|
||||
|
||||
**修复问题:**
|
||||
- toggleSelect 函数使用正确的 key(file_uuid)
|
||||
- 修复 HTML 缩进问题
|
||||
- 确保勾选功能正常工作
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 1. MarkBase Engine 健康检查修复
|
||||
|
||||
**问题:**
|
||||
- Admin 页面显示 MarkBase Engine 为 `offline`
|
||||
- 实际服务正在运行
|
||||
|
||||
**修复:**
|
||||
- 支持 JSON 和纯文本格式的健康检查响应
|
||||
- 根据 Content-Type 判断响应格式
|
||||
- 从 JSON 提取 version 和 uptime 信息
|
||||
|
||||
### 2. FS Name 唯一性问题修复
|
||||
|
||||
**问题:**
|
||||
- 两个自动命名源导致名称混乱
|
||||
- Person_X 和 Face Trace X 格式并存
|
||||
|
||||
**修复:**
|
||||
- 移除所有自动命名逻辑
|
||||
- 新 traces 默认为 "Unassigned"
|
||||
- 统一 trace_profiles.name 为唯一名称来源
|
||||
|
||||
### 3. 性能优化
|
||||
|
||||
**并发控制:**
|
||||
- thumbnail 并发数从 32 降到 10
|
||||
- search_agents API timeout 提升到 90s
|
||||
|
||||
**排序优化:**
|
||||
- scanning/preparing 文件优先显示
|
||||
- 改进用户体验
|
||||
|
||||
---
|
||||
|
||||
## 📋 系统要求
|
||||
|
||||
### macOS
|
||||
- macOS 12.0 (Monterey) 或更高版本
|
||||
- Apple Silicon (M1/M2/M3) 或 Intel (需单独构建)
|
||||
- 4GB RAM minimum, 8GB recommended
|
||||
- 1GB 可用磁盘空间
|
||||
|
||||
### 依赖服务
|
||||
- PostgreSQL 14+
|
||||
- Qdrant (v1.7+)
|
||||
- MarkBase Core API (v2.3+)
|
||||
- MarkBase Engine (v1.0+)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 安装指南
|
||||
|
||||
### 方法 1: DMG 安装(推荐)
|
||||
|
||||
1. 下载 `Momentry Studio_0.1.0_aarch64.dmg`
|
||||
2. 双击打开 DMG 文件
|
||||
3. 拖拽 "Momentry Studio.app" 到 Applications 文件夹
|
||||
4. 从 Applications 启动应用
|
||||
|
||||
### 方法 2: 直接使用 .app
|
||||
|
||||
1. 复制 "Momentry Studio.app" 到 Applications 文件夹
|
||||
2. 首次运行可能需要在"系统偏好设置 > 安全性与隐私"中允许运行
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置
|
||||
|
||||
### 环境变量
|
||||
|
||||
**Momentry Core API:**
|
||||
```bash
|
||||
export MOMENTRY_API_KEY=your_api_key_here
|
||||
export DATABASE_URL=postgres://user:password@localhost:5432/momentry
|
||||
```
|
||||
|
||||
**Momentry Proxy:**
|
||||
```bash
|
||||
export MOMENTRY_API_KEY=your_api_key_here
|
||||
export MOMENTRY_DATA_DIR=/path/to/data/users
|
||||
```
|
||||
|
||||
### 配置文件
|
||||
|
||||
**momentry.toml (Core API):**
|
||||
```toml
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 3002
|
||||
|
||||
[database]
|
||||
url = "postgres://user:password@localhost:5432/momentry"
|
||||
|
||||
[face_settings]
|
||||
min_confidence = 0.5
|
||||
min_bbox_size = 80
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 部署指南
|
||||
|
||||
### 1. 启动 Core API
|
||||
|
||||
```bash
|
||||
./momentry-core server --host 0.0.0.0 --port 3002
|
||||
```
|
||||
|
||||
### 2. 启动 Proxy(如果需要独立部署)
|
||||
|
||||
```bash
|
||||
./momentry-proxy
|
||||
```
|
||||
|
||||
### 3. 启动 Tauri 应用
|
||||
|
||||
- 双击 "Momentry Studio.app" 启动
|
||||
- 或从终端运行:`open "Momentry Studio.app"`
|
||||
|
||||
---
|
||||
|
||||
## 📊 服务架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Momentry Studio (Tauri App) │
|
||||
│ ┌─────────────┐ ┌────────────────┐ │
|
||||
│ │ Frontend │───▶│ Proxy (Rust) │ │
|
||||
│ │ (Vue 3) │ │ Port 8888 │ │
|
||||
│ └─────────────┘ └────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────┐
|
||||
│ Momentry Core API │
|
||||
│ Port 3002 │
|
||||
│ - Files │
|
||||
│ - Faces │
|
||||
│ - Search │
|
||||
└───────────────────────────┘
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────┐
|
||||
│PostgreSQL│ │ Qdrant │
|
||||
│ Port 5432│ │Port 6333 │
|
||||
└──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 已知问题
|
||||
|
||||
1. **批量 Deep Scan 队列**
|
||||
- 当前前端临时实现,未来需要后端队列支持
|
||||
- 参考:`docs/DEEP_SCAN_QUEUE_FEATURE_REQUEST.md`
|
||||
|
||||
2. **System Stats Validation**
|
||||
- 已暂时禁用,待后续修复
|
||||
|
||||
---
|
||||
|
||||
## 📝 版本历史
|
||||
|
||||
**v0.1.0 (2026-10-08)**
|
||||
- 初始正式发布版本
|
||||
- 包含所有核心功能
|
||||
- 批量操作增强
|
||||
- Face Filter 自动保存
|
||||
- 多项 Bug 修复
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **Frontend**: Vue 3 + TypeScript + Vite
|
||||
- **Desktop**: Tauri 2 (Rust backend)
|
||||
- **Backend**: Rust (Axum, SQLx)
|
||||
- **Database**: PostgreSQL 14+
|
||||
- **Search Engine**: Qdrant
|
||||
- **Build**: Cargo + npm
|
||||
|
||||
---
|
||||
|
||||
## 📞 支持联系
|
||||
|
||||
- **技术支持**: tech@momentry.ai
|
||||
- **Bug 报告**: https://github.com/momentry/studio/issues
|
||||
- **文档**: https://docs.momentry.ai
|
||||
|
||||
---
|
||||
|
||||
## 📜 许可证
|
||||
|
||||
Proprietary Software - All Rights Reserved
|
||||
|
||||
46
release/VERSION.json
Normal file
46
release/VERSION.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"build_date": "2026-10-08",
|
||||
"build_type": "release",
|
||||
"platform": "macos-aarch64",
|
||||
"git_commit": "8e71996",
|
||||
"components": {
|
||||
"studio_app": {
|
||||
"name": "Momentry Studio.app",
|
||||
"version": "0.1.0",
|
||||
"size": "28MB"
|
||||
},
|
||||
"dmg": {
|
||||
"name": "Momentry Studio_0.1.0_aarch64.dmg",
|
||||
"size": "9.4MB"
|
||||
},
|
||||
"core_api": {
|
||||
"name": "momentry-core",
|
||||
"version": "1.0.0",
|
||||
"size": "44MB"
|
||||
},
|
||||
"proxy": {
|
||||
"name": "momentry-proxy",
|
||||
"version": "0.1.0",
|
||||
"size": "7.6MB"
|
||||
}
|
||||
},
|
||||
"features": [
|
||||
"batch_deep_scan",
|
||||
"batch_unregister",
|
||||
"face_filter_autosave",
|
||||
"markbase_health_check_fix",
|
||||
"fs_name_uniqueness_fix"
|
||||
],
|
||||
"requirements": {
|
||||
"macos": "12.0+",
|
||||
"architecture": "arm64",
|
||||
"ram": "4GB minimum",
|
||||
"dependencies": [
|
||||
"PostgreSQL 14+",
|
||||
"Qdrant 1.7+",
|
||||
"MarkBase Core API 2.3+",
|
||||
"MarkBase Engine 1.0+"
|
||||
]
|
||||
}
|
||||
}
|
||||
107
release/install.sh
Executable file
107
release/install.sh
Executable file
@@ -0,0 +1,107 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Momentry Studio v0.1.0 Installation Script
|
||||
# Usage: ./install.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "========================================="
|
||||
echo " Momentry Studio v0.1.0 Installation "
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
# 检查操作系统
|
||||
if [[ "$(uname)" != "Darwin" ]]; then
|
||||
echo "❌ Error: This script only supports macOS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查架构
|
||||
ARCH=$(uname -m)
|
||||
if [[ "$ARCH" != "arm64" ]]; then
|
||||
echo "⚠️ Warning: This build is optimized for Apple Silicon (arm64)"
|
||||
echo " Current architecture: $ARCH"
|
||||
echo " Some features may not work correctly."
|
||||
fi
|
||||
|
||||
# 设置安装目录
|
||||
INSTALL_DIR="${INSTALL_DIR:-/Applications}"
|
||||
BIN_DIR="${BIN_DIR:-/usr/local/bin}"
|
||||
|
||||
echo "📋 Installation Settings:"
|
||||
echo " App Location: $INSTALL_DIR/Momentry Studio.app"
|
||||
echo " Binary Location: $BIN_DIR"
|
||||
echo ""
|
||||
|
||||
# 询问确认
|
||||
read -p "Continue with installation? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Installation cancelled."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 .app 是否存在
|
||||
if [[ ! -d "Momentry Studio.app" ]]; then
|
||||
echo "❌ Error: Momentry Studio.app not found in current directory"
|
||||
echo " Please run this script from the release directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 安装 .app
|
||||
echo ""
|
||||
echo "📦 Installing Momentry Studio.app..."
|
||||
cp -r "Momentry Studio.app" "$INSTALL_DIR/"
|
||||
echo "✅ App installed to $INSTALL_DIR/Momentry Studio.app"
|
||||
|
||||
# 安装 binary(可选)
|
||||
if [[ -f "momentry-core" ]] && [[ -f "momentry-proxy" ]]; then
|
||||
echo ""
|
||||
read -p "Install backend binaries to $BIN_DIR? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "📦 Installing binaries..."
|
||||
sudo cp momentry-core "$BIN_DIR/"
|
||||
sudo cp momentry-proxy "$BIN_DIR/"
|
||||
sudo chmod +x "$BIN_DIR/momentry-core"
|
||||
sudo chmod +x "$BIN_DIR/momentry-proxy"
|
||||
echo "✅ Binaries installed to $BIN_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 创建配置示例
|
||||
echo ""
|
||||
echo "📝 Creating example configuration..."
|
||||
mkdir -p ~/.momentry
|
||||
cat > ~/.momentry/momentry.toml << 'TOML'
|
||||
# Momentry Studio Configuration Example
|
||||
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 3002
|
||||
|
||||
[database]
|
||||
url = "postgres://user:password@localhost:5432/momentry"
|
||||
|
||||
[face_settings]
|
||||
min_confidence = 0.5
|
||||
min_bbox_size = 80
|
||||
TOML
|
||||
echo "✅ Configuration example created at ~/.momentry/momentry.toml"
|
||||
|
||||
# 完成
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo " ✅ Installation Complete! "
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo "🚀 Next Steps:"
|
||||
echo " 1. Configure database connection in ~/.momentry/momentry.toml"
|
||||
echo " 2. Set environment variable: export MOMENTRY_API_KEY=your_key"
|
||||
echo " 3. Start Core API: momentry-core server"
|
||||
echo " 4. Launch Studio: open \"$INSTALL_DIR/Momentry Studio.app\""
|
||||
echo ""
|
||||
echo "📖 Documentation: See RELEASE_NOTES.md"
|
||||
echo "📞 Support: tech@momentry.ai"
|
||||
echo ""
|
||||
|
||||
46
src-tauri/Cargo.lock
generated
46
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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 = [] }
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
use argon2::{Algorithm, Argon2, Params, PasswordHasher, Version};
|
||||
use argon2::password_hash::{SaltString, rand_core::OsRng};
|
||||
|
||||
fn main() {
|
||||
let password = std::env::args().nth(1).unwrap_or_else(|| "demo123".to_string());
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default());
|
||||
match argon2.hash_password(password.as_bytes(), &salt) {
|
||||
Ok(hash) => println!("{}", hash),
|
||||
Err(e) => eprintln!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ mod proxy;
|
||||
#[path = "../db.rs"]
|
||||
mod db;
|
||||
|
||||
pub use proxy::SystemStatsResponse;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let _ = db::init_db();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
@@ -759,13 +759,6 @@ async fn get_face_thumbnail(uuid: String, frame: u32, bbox_x: Option<f64>, bbox_
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
const PROFILE_DIRS: &[&str] = &[
|
||||
"/Users/accusys/momentry/output/identities",
|
||||
"/Users/accusys/momentry/output_dev/identities",
|
||||
"/Volumes/external/momentry/output/identities",
|
||||
"/Volumes/external/momentry/output_dev/identities",
|
||||
];
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn get_identity_profile(uuid: String) -> Result<String, String> {
|
||||
let uuid_display = uuid.clone();
|
||||
@@ -781,13 +774,13 @@ async fn get_identity_profile(uuid: String) -> Result<String, String> {
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let result = (|| -> Option<String> {
|
||||
for dir in PROFILE_DIRS {
|
||||
for candidate in [&no_dash, &uuid_display] {
|
||||
let path = format!("{}/{}/profile.jpg", dir, candidate);
|
||||
if std::path::Path::new(&path).exists() {
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
return Some(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes)));
|
||||
}
|
||||
let output_dir = crate::proxy::get_output_dir();
|
||||
let identities_dir = output_dir.join("identities");
|
||||
for candidate in [&no_dash, &uuid_display] {
|
||||
let path = identities_dir.join(candidate).join("profile.jpg");
|
||||
if path.exists() {
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
return Some(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes)));
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -999,14 +992,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 +1289,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 +1319,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 +1433,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 +1495,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 +1526,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 +1578,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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,68 @@
|
||||
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;
|
||||
|
||||
// Temporarily disabled due to import issues
|
||||
// #[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 API_KEY: &str = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69";
|
||||
fn get_api_key() -> String {
|
||||
std::env::var("MOMENTRY_API_KEY")
|
||||
.expect("MOMENTRY_API_KEY environment variable must be set")
|
||||
}
|
||||
|
||||
pub fn get_output_dir() -> std::path::PathBuf {
|
||||
std::env::var_os("MOMENTRY_OUTPUT_DIR")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join("output")))
|
||||
})
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("output"))
|
||||
}
|
||||
|
||||
fn get_data_dir() -> std::path::PathBuf {
|
||||
std::env::var_os("MOMENTRY_DATA_DIR")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join("data")))
|
||||
})
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("data"))
|
||||
}
|
||||
|
||||
fn get_admin_config_path() -> std::path::PathBuf {
|
||||
std::env::var_os("MOMENTRY_ADMIN_CONFIG")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join("config/admin_config.json")))
|
||||
})
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("config/admin_config.json"))
|
||||
}
|
||||
|
||||
const CORE_API: &str = "http://127.0.0.1:3002";
|
||||
const MARKBASE_API: &str = "http://127.0.0.1:11438";
|
||||
const DIST_DIR: &str = "../dist";
|
||||
const PROFILE_DIRS: [&str; 4] = [
|
||||
"/Users/accusys/momentry/output/identities",
|
||||
"/Users/accusys/momentry/output_dev/identities",
|
||||
"/Volumes/external/momentry/output/identities",
|
||||
"/Volumes/external/momentry/output_dev/identities",
|
||||
];
|
||||
|
||||
static FACE_THUMB_CACHE: Mutex<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 +118,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 +245,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 +259,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 +276,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 +313,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, get_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 +599,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('-', "");
|
||||
@@ -551,14 +652,14 @@ async fn get_identity_profile_handler_inner(uuid: String) -> Response {
|
||||
}
|
||||
}
|
||||
let result = (|| -> Option<String> {
|
||||
for dir in &PROFILE_DIRS {
|
||||
for candidate in [&no_dash, &uuid] {
|
||||
let path = format!("{}/{}/profile.jpg", dir, candidate);
|
||||
if std::path::Path::new(&path).exists() {
|
||||
eprintln!("[proxy] PROFILE FOUND: {}", path);
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
return Some(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes)));
|
||||
}
|
||||
let output_dir = get_output_dir();
|
||||
let identities_dir = output_dir.join("identities");
|
||||
for candidate in [&no_dash, &uuid] {
|
||||
let path = identities_dir.join(candidate).join("profile.jpg");
|
||||
if path.exists() {
|
||||
eprintln!("[proxy] PROFILE FOUND: {:?}", path);
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
return Some(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes)));
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -579,33 +680,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 {
|
||||
@@ -647,7 +721,7 @@ async fn get_face_thumbnail_handler(State(state): State<ProxyState>, axum::extra
|
||||
}
|
||||
}
|
||||
}
|
||||
let url = format!("{}/api/v1/file/{}/thumbnail?api_key={}&frame={}", CORE_API, uuid, API_KEY, frame);
|
||||
let url = format!("{}/api/v1/file/{}/thumbnail?api_key={}&frame={}", CORE_API, uuid, get_api_key(), frame);
|
||||
|
||||
let bytes = match state.client.get(&url).send().await {
|
||||
Ok(resp) => match resp.bytes().await {
|
||||
@@ -698,20 +772,13 @@ async fn get_identity_matches_handler(axum::extract::Query(params): axum::extrac
|
||||
Some(v) => v.clone(),
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing file_hash").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 match_path = None;
|
||||
for base in &output_bases {
|
||||
let path = format!("{}/{}/{}.identity_match_round1.json", base, file_hash, file_hash);
|
||||
if std::path::Path::new(&path).exists() {
|
||||
match_path = Some(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let output_base = get_output_dir();
|
||||
let match_path = format!("{}/{}.identity_match_round1.json", output_base.display(), file_hash);
|
||||
let match_path = if std::path::Path::new(&match_path).exists() {
|
||||
Some(match_path)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match match_path {
|
||||
Some(path) => match std::fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
@@ -729,6 +796,39 @@ 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_base = get_output_dir();
|
||||
let profile_path = format!("{}/trace_{}/trace_profile.json", output_base.display(), trace_id);
|
||||
let profile_path = if std::path::Path::new(&profile_path).exists() {
|
||||
Some(profile_path)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
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 +836,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", get_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", get_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(),
|
||||
};
|
||||
@@ -786,7 +903,7 @@ async fn get_processor_json_handler(
|
||||
let json_url = format!("{}/api/v1/file/{}/json/{}", CORE_API, file_hash, processor);
|
||||
let json_resp = match client
|
||||
.post(&json_url)
|
||||
.header("X-API-Key", API_KEY)
|
||||
.header("X-API-Key", get_api_key())
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
@@ -872,7 +989,7 @@ async fn get_frame_handler(State(state): State<ProxyState>, axum::extract::Query
|
||||
let frame: u32 = params.get("frame").and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
|
||||
// Get file path from Core API
|
||||
let url = format!("{}/api/v1/file/{}?api_key={}", CORE_API, file_uuid, API_KEY);
|
||||
let url = format!("{}/api/v1/file/{}?api_key={}", CORE_API, file_uuid, get_api_key());
|
||||
let file_info = match state.client.get(&url).send().await {
|
||||
Ok(resp) => match resp.json::<serde_json::Value>().await {
|
||||
Ok(json) => json,
|
||||
@@ -907,12 +1024,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",
|
||||
@@ -957,7 +1073,7 @@ async fn get_pose_handler(
|
||||
let client = reqwest::Client::new();
|
||||
let resp = match client
|
||||
.get(&url)
|
||||
.header("X-API-Key", API_KEY)
|
||||
.header("X-API-Key", get_api_key())
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
@@ -1000,7 +1116,7 @@ async fn get_appearance_handler(
|
||||
let client = reqwest::Client::new();
|
||||
let resp = match client
|
||||
.get(&url)
|
||||
.header("X-API-Key", API_KEY)
|
||||
.header("X-API-Key", get_api_key())
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
@@ -1015,4 +1131,319 @@ async fn get_appearance_handler(
|
||||
};
|
||||
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_admin_config_handler() -> Response {
|
||||
let config_path = get_admin_config_path();
|
||||
match std::fs::read_to_string(&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 {
|
||||
let config_path = get_admin_config_path();
|
||||
match serde_json::to_string_pretty(&payload) {
|
||||
Ok(formatted) => {
|
||||
match std::fs::write(&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 }
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
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", get_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 - temporarily disabled due to import issues
|
||||
// let result = super::validate::run_validation(&layers, &stats);
|
||||
|
||||
// Return stats directly without validation
|
||||
axum::Json(stats).into_response()
|
||||
}
|
||||
|
||||
449
src-tauri/src/validate.rs
Normal file
449
src-tauri/src/validate.rs
Normal file
@@ -0,0 +1,449 @@
|
||||
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};
|
||||
|
||||
use super::proxy::SystemStatsResponse;
|
||||
|
||||
#[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: &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
|
||||
}
|
||||
180
src/App.vue
180
src/App.vue
@@ -2,18 +2,45 @@
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AgentTip from './components/AgentTip.vue'
|
||||
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())
|
||||
|
||||
const sidebarWidth = ref(240)
|
||||
const isDragging = ref(false)
|
||||
const sidebarCollapsed = ref(true)
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
const mobileOpen = ref(false)
|
||||
|
||||
function toggleSidebar() {
|
||||
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() {
|
||||
const user = localStorage.getItem('username') || ''
|
||||
@@ -21,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')
|
||||
@@ -74,6 +102,7 @@ onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
document.addEventListener('mousemove', handleDrag)
|
||||
document.addEventListener('mouseup', stopDrag)
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
refreshUser()
|
||||
loadUserBehavior()
|
||||
@@ -86,25 +115,35 @@ 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>
|
||||
<div id="app" class="ms-app" :data-current-login="'guest'">
|
||||
<aside class="ms-side" :style="{ width: sidebarWidth + 'px', minWidth: sidebarWidth + 'px' }">
|
||||
<div class="gs-logo">Workspace</div>
|
||||
<BuildVersion />
|
||||
<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)' }">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="gs-logo" :class="{ 'gs-logo-collapsed': sidebarCollapsed }">{{ sidebarCollapsed ? 'WS' : 'Workspace' }}</div>
|
||||
<nav class="gs-nav">
|
||||
<router-link to="/search" 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="11" cy="11" r="8"/>
|
||||
<path d="M21 21l-4.35-4.35"/>
|
||||
</svg>
|
||||
<span>{{ t('nav.search') }}</span>
|
||||
<span class="gs-nav-label">{{ t('nav.search') }}</span>
|
||||
</router-link>
|
||||
<router-link to="/library" 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">
|
||||
@@ -113,38 +152,36 @@ onUnmounted(() => {
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||
</svg>
|
||||
<span>{{ 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>Face</span>
|
||||
<span class="gs-nav-label">{{ t('nav.library') }}</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"/>
|
||||
<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>{{ t('nav.admin') }}</span>
|
||||
<span class="gs-nav-label">{{ t('nav.admin') }}</span>
|
||||
</router-link>
|
||||
<router-link to="/client" 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">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<span>{{ t('nav.client') }}</span>
|
||||
<span class="gs-nav-label">{{ t('nav.client') }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
<div class="gs-divider"></div>
|
||||
<div class="gs-footer">
|
||||
<div class="gs-lang-switcher">
|
||||
<div class="gs-divider" :class="{ 'gs-hidden': sidebarCollapsed }"></div>
|
||||
<div class="gs-footer" :class="{ 'gs-hidden': sidebarCollapsed }">
|
||||
<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">
|
||||
@@ -156,24 +193,81 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gs-footer-compact">
|
||||
<template v-if="loggedInUser">
|
||||
<button class="gs-logout-btn-compact" @click="handleLogout" title="Logout">⏻</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link to="/login" class="gs-login-link-compact" title="Login">⏻</router-link>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="ms-divider" @mousedown="startDrag"></div>
|
||||
<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>
|
||||
|
||||
<style>
|
||||
@import './assets/wordpress-exact.css';
|
||||
html, body { height: 100%; margin: 0; }
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
#app, .ms-app { background: var(--background-color) !important; display: flex; max-width: 1400px; margin: 0 auto; padding: 28px; gap: 0; min-height: 100vh; box-sizing: border-box; align-items: stretch; }
|
||||
.ms-side { width: 240px; min-width: 240px; flex-shrink: 0; background: var(--card-background) !important; border-radius: 18px; padding: 18px 14px; box-shadow: var(--shadow); display: flex; flex-direction: column; position: relative; height: calc(100vh - 125px); align-self: flex-start; overflow: visible; }
|
||||
#app, .ms-app { background: var(--background-color) !important; display: flex; max-width: 1400px; margin: 0 auto; padding: 28px; gap: 0; height: 100vh; box-sizing: border-box; }
|
||||
.ms-side { width: 240px; min-width: 240px; background: var(--card-background) !important; border-radius: 18px; padding: 18px 14px; box-shadow: var(--shadow); display: flex; flex-direction: column; position: relative; overflow: visible; height: 100%; }
|
||||
.ms-side.collapsed { align-items: center; padding: 18px 8px; }
|
||||
.ms-side.collapsed .gs-nav { align-items: center; width: 100%; }
|
||||
.ms-side.collapsed .gs-nav-item { justify-content: center; padding: 0; width: 44px; }
|
||||
.ms-side.collapsed .gs-nav-label { display: none; }
|
||||
.ms-side.collapsed .gs-nav-icon { width: 22px; height: 22px; }
|
||||
.gs-footer-compact { display: none; }
|
||||
.ms-side.collapsed .gs-footer-compact { display: flex; flex-direction: column; align-items: center; margin-top: auto; padding: 10px 0; }
|
||||
.gs-logout-btn-compact, .gs-login-link-compact { background: none; border: 1px solid var(--border-color); cursor: pointer; font-size: 16px; color: var(--text-secondary); width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; text-decoration: none; transition: all .15s; }
|
||||
.gs-logout-btn-compact:hover { color: var(--danger-color); border-color: var(--danger-color); background: var(--danger-background); }
|
||||
.gs-login-link-compact:hover { color: var(--text-primary); border-color: var(--text-primary); }
|
||||
.gs-hidden { display: none !important; }
|
||||
.gs-sidebar-toggle { display: flex; align-items: center; justify-content: flex-end; padding: 0 4px 12px 4px; cursor: pointer; color: var(--text-secondary); flex-shrink: 0; }
|
||||
.gs-sidebar-toggle svg { transition: transform 0.2s; }
|
||||
.ms-side.collapsed .gs-sidebar-toggle { justify-content: center; width: 100%; }
|
||||
.gs-logo-collapsed { text-align: center; padding: 8px 4px 16px 4px; font-size: 14px; }
|
||||
.ms-divider { width: 28px; flex-shrink: 0; cursor: col-resize; display: flex; align-items: center; justify-content: center; }
|
||||
.ms-divider-hidden { cursor: default; pointer-events: none; opacity: 0; }
|
||||
.ms-divider::after { content: ''; width: 4px; height: 40px; background: var(--border-color); border-radius: 2px; transition: background 0.2s, height 0.2s; }
|
||||
.ms-divider:hover::after { background: var(--primary-color); height: 60px; }
|
||||
.gs-logo { font-size: 15px; font-weight: 600; color: var(--text-primary); padding: 8px 12px 16px 12px; flex-shrink: 0; }
|
||||
@@ -184,6 +278,10 @@ onUnmounted(() => {
|
||||
.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); }
|
||||
@@ -193,6 +291,38 @@ onUnmounted(() => {
|
||||
.gs-logout-btn:hover { color: var(--danger-color); border-color: var(--danger-color); background: var(--danger-background); }
|
||||
.gs-login-link { font-size: 12px; color: var(--text-secondary); text-decoration: none; }
|
||||
.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; }
|
||||
.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>
|
||||
|
||||
293
src/api/index.ts
293
src/api/index.ts
@@ -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') {
|
||||
@@ -41,16 +56,6 @@ export async function apiCall(cmd: string, args: Record<string, any>): Promise<a
|
||||
}
|
||||
return httpCall('update_identity', { uuid: args.uuid, metadataJson: JSON.stringify(metadata) })
|
||||
}
|
||||
// run_identity_agent 沒有 Tauri command,直接走 HTTP 避免 invoke error
|
||||
if (cmd === 'run_identity_agent') {
|
||||
const data = await httpCall(cmd, args)
|
||||
return transformResponse(cmd, data)
|
||||
}
|
||||
// run_identity_for_seed 也沒有 Tauri command
|
||||
if (cmd === 'run_identity_for_seed') {
|
||||
const data = await httpCall(cmd, args)
|
||||
return transformResponse(cmd, data)
|
||||
}
|
||||
// run_cluster_agent 也沒有 Tauri command
|
||||
if (cmd === 'run_cluster_agent') {
|
||||
const data = await httpCall(cmd, args)
|
||||
@@ -61,6 +66,11 @@ export async function apiCall(cmd: string, args: Record<string, any>): Promise<a
|
||||
const data = await httpCall(cmd, args)
|
||||
return transformResponse(cmd, data)
|
||||
}
|
||||
// get_similar_face_groups 也沒有 Tauri command
|
||||
if (cmd === 'get_similar_face_groups') {
|
||||
const data = await httpCall(cmd, args)
|
||||
return transformResponse(cmd, data)
|
||||
}
|
||||
// create_pending_identity 也沒有 Tauri command
|
||||
if (cmd === 'create_pending_identity') {
|
||||
const data = await httpCall(cmd, args)
|
||||
@@ -76,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)
|
||||
@@ -93,6 +128,7 @@ export async function apiCall(cmd: string, args: Record<string, any>): Promise<a
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT = 30000
|
||||
const UNREGISTER_TIMEOUT = 200000 // 200s for large file unregister (was timing out at 30s)
|
||||
|
||||
export enum ApiErrorType {
|
||||
NETWORK = 'network',
|
||||
@@ -114,11 +150,11 @@ export class ApiError extends Error {
|
||||
|
||||
async function httpCall(cmd: string, args: Record<string, any>, retries = 3): Promise<any> {
|
||||
const base = getApiBase()
|
||||
const { url, method, body } = buildHttpRequest(cmd, args)
|
||||
const { url, method, body, timeout: requestTimeout } = buildHttpRequest(cmd, args)
|
||||
const fullUrl = url.startsWith('http://') || url.startsWith('https://') ? url : `${base}${url}`
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
|
||||
const timeout = setTimeout(() => controller.abort(), requestTimeout ?? DEFAULT_TIMEOUT)
|
||||
|
||||
let response: Response | null = null
|
||||
try {
|
||||
@@ -202,7 +238,7 @@ async function httpCall(cmd: string, args: Record<string, any>, retries = 3): Pr
|
||||
}
|
||||
}
|
||||
|
||||
function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string; method: string; body?: any } {
|
||||
function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string; method: string; body?: any; timeout?: number } {
|
||||
const a = args
|
||||
|
||||
switch (cmd) {
|
||||
@@ -255,13 +291,17 @@ case 'search_keyword': {
|
||||
case 'search_llm_smart': {
|
||||
return { url: '/api/v1/search/llm-smart', method: 'POST', body: { query: a.query, limit: a.limit || 20 } }
|
||||
}
|
||||
case 'search_people': {
|
||||
const params = new URLSearchParams()
|
||||
if (a.query) params.set('query', a.query)
|
||||
if (a.fileUuid) params.set('file_uuid', a.fileUuid)
|
||||
if (a.limit) params.set('limit', String(a.limit))
|
||||
return { url: `/api/v1/search/people?${params.toString()}`, method: 'GET' }
|
||||
}
|
||||
case 'search_agents': {
|
||||
const body: any = { query: a.query }
|
||||
if (a.conversationId) body.conversation_id = a.conversationId
|
||||
return { url: '/api/v1/agents/search', method: 'POST', body }
|
||||
}
|
||||
case 'search_identities': {
|
||||
return { url: `/api/v1/identities/search?q=${encodeURIComponent(a.query)}&limit=${a.limit || 50}`, method: 'GET' }
|
||||
return { url: '/api/v1/agents/search', method: 'POST', body, timeout: 180000 }
|
||||
}
|
||||
|
||||
// --- Image APIs ---
|
||||
@@ -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,17 +383,51 @@ 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()
|
||||
if (a.file_uuid) params.set('file_uuid', a.file_uuid)
|
||||
if (a.trace_id) params.set('trace_id', String(a.trace_id))
|
||||
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?${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': {
|
||||
return { url: '/api/v1/trace-profile', method: 'PUT', body: a.body }
|
||||
const body = a.body ?? {
|
||||
file_uuid: a.file_uuid ?? a.fileUuid,
|
||||
trace_id: a.trace_id ?? a.traceId,
|
||||
...Object.fromEntries(Object.entries(a).filter(([k]) => !['fileUuid','traceId','file_uuid','trace_id','body'].includes(k)))
|
||||
}
|
||||
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': {
|
||||
@@ -359,6 +436,17 @@ case 'search_keyword': {
|
||||
case 'merge_trace': {
|
||||
return { url: `/api/v1/file/${a.file_uuid}/trace/${a.source_id}/merge/${a.target_id}`, method: 'POST' }
|
||||
}
|
||||
case 'merge_groups': {
|
||||
return {
|
||||
url: `/api/v1/file/${a.fileUuid}/groups/merge`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
file_uuid: a.fileUuid,
|
||||
source_groups: a.sourceGroups,
|
||||
target_group_name: a.targetGroupName
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'bind_face': {
|
||||
const bindBody: any = { file_uuid: a.fileUuid }
|
||||
if (a.faceId) bindBody.face_id = a.faceId
|
||||
@@ -442,29 +530,30 @@ 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 } }
|
||||
}
|
||||
case 'run_identity_agent': {
|
||||
return { url: `/api/v1/file/${a.fileUuid}/identity-agent`, method: 'POST' }
|
||||
}
|
||||
case 'run_identity_for_seed': {
|
||||
return { url: '/api/v1/agents/identity/run-for-seed', method: 'POST', body: { file_uuid: a.fileUuid, identity_uuid: a.identityUuid } }
|
||||
case 're_scan_file': {
|
||||
return { url: `/api/v1/re-scan`, method: 'POST', body: { file_uuid: a.fileUuid, auto_pipeline: a.autoPipeline ?? true } }
|
||||
}
|
||||
case 'get_identity_matches': {
|
||||
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' }
|
||||
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/file/${a.fileHash}/json/face_cluster`, method: 'POST', body: {} }
|
||||
}
|
||||
case 'get_trace_profile': {
|
||||
return { url: `/api/v1/trace-profile?file_uuid=${a.fileUuid}&trace_id=${a.traceId}`, method: 'GET' }
|
||||
}
|
||||
case 'update_trace_profile': {
|
||||
return { url: '/api/v1/trace-profile', method: 'PUT', body: { file_uuid: a.fileUuid, trace_id: a.traceId, label: a.label, key_frame: a.keyFrame, key_face: a.keyFace, aliases: a.aliases } }
|
||||
case 'get_similar_face_groups': {
|
||||
const params = new URLSearchParams()
|
||||
if (a.traceIds) params.set('trace_ids', String(a.traceIds))
|
||||
if (a.topK) params.set('top_k', String(a.topK))
|
||||
if (a.minScore) params.set('min_score', String(a.minScore))
|
||||
return { url: `/api/v1/file/${a.fileUuid}/face-groups/similar?${params.toString()}`, method: 'GET' }
|
||||
}
|
||||
case 'update_trace_profile_group': {
|
||||
return { url: '/api/v1/trace-profile/group', method: 'PUT', body: { file_uuid: a.fileUuid, trace_ids: a.traceIds, name: a.name, key_frame: a.keyFrame, key_face: a.keyFace } }
|
||||
@@ -473,11 +562,16 @@ 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' }
|
||||
}
|
||||
case 'get_file_processor_json': {
|
||||
return { url: `/api/v1/file/${a.fileUuid}/json/${a.processor}`, method: 'POST', body: {} }
|
||||
}
|
||||
case 'create_pending_identity': {
|
||||
return { url: '/api/v1/identities/pending', method: 'POST', body: { file_uuid: a.fileUuid, trace_id: a.traceId, face_id: a.faceId, cluster_id: a.clusterId, trace_count: a.traceCount } }
|
||||
}
|
||||
@@ -487,7 +581,7 @@ case 'search_keyword': {
|
||||
case 'unregister_file': {
|
||||
const body: any = { file_uuid: a.fileUuid }
|
||||
if (a.deleteOutputFiles != null) body.delete_output_files = a.deleteOutputFiles
|
||||
return { url: '/api/v1/unregister', method: 'POST', body }
|
||||
return { url: '/api/v1/unregister', method: 'POST', body, timeout: UNREGISTER_TIMEOUT }
|
||||
}
|
||||
case 'ingest_file': {
|
||||
return { url: `/api/v1/file/${a.fileUuid}/checkin`, method: 'POST' }
|
||||
@@ -542,6 +636,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()
|
||||
@@ -555,6 +660,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':
|
||||
@@ -577,6 +687,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' }
|
||||
@@ -589,7 +707,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 || []
|
||||
@@ -601,8 +719,9 @@ 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 ?? (f.status === 'completed'),
|
||||
ingested: f.ingested ?? ['completed','keyword_ready','semantic_ready','face_mgmt_ready','agent_ready'].includes(f.status),
|
||||
}))
|
||||
}
|
||||
case 'get_people': {
|
||||
@@ -661,7 +780,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,
|
||||
@@ -675,6 +794,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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -687,6 +810,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) => {
|
||||
@@ -696,19 +865,19 @@ case 'get_unassigned_traces': {
|
||||
const asrLangProb = asr?.language_probability || 0
|
||||
|
||||
let asrStatus: 'no_audio_track' | 'silent_audio' | 'has_transcript' | 'processing' = 'processing'
|
||||
let asrMessage = '處理中'
|
||||
let asrMessage = 'Processing'
|
||||
|
||||
if (asrSegments.length === 0) {
|
||||
if (asrLang === '' && asrLangProb === 0) {
|
||||
asrStatus = 'no_audio_track'
|
||||
asrMessage = '無音軌'
|
||||
asrMessage = 'No Audio Track'
|
||||
} else {
|
||||
asrStatus = 'silent_audio'
|
||||
asrMessage = asrLang ? `無語音 (${asrLang})` : '無語音'
|
||||
asrMessage = asrLang ? `No Speech (${asrLang})` : 'No Speech'
|
||||
}
|
||||
} else {
|
||||
asrStatus = 'has_transcript'
|
||||
asrMessage = `${asrSegments.length} 段語音 (${asrLang || 'unknown'})`
|
||||
asrMessage = `${asrSegments.length} segments (${asrLang || 'unknown'})`
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -755,6 +924,9 @@ case 'get_unassigned_traces': {
|
||||
source_type: r.source_type || null,
|
||||
}))
|
||||
}
|
||||
case 'search_people': {
|
||||
return data || { people: [] }
|
||||
}
|
||||
case 'search_identities': {
|
||||
const results = data.results || data.data || data || []
|
||||
return results.map((r: any) => ({
|
||||
@@ -773,19 +945,17 @@ 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 'process_file': {
|
||||
return {
|
||||
success: data.success ?? false,
|
||||
file_uuid: data.file_uuid ?? '',
|
||||
message: data.message ?? '',
|
||||
}
|
||||
case 'get_command_status': {
|
||||
return data || null
|
||||
}
|
||||
case 'run_identity_agent': {
|
||||
case 'process_file': {
|
||||
return {
|
||||
success: data.success ?? false,
|
||||
file_uuid: data.file_uuid ?? '',
|
||||
@@ -804,7 +974,11 @@ case 'get_unassigned_traces': {
|
||||
case 'get_cluster_results': {
|
||||
return data || null
|
||||
}
|
||||
case 'get_similar_face_groups': {
|
||||
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 }
|
||||
@@ -827,7 +1001,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 {
|
||||
@@ -873,7 +1054,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
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
70
src/components/BuildVersion.vue
Normal file
70
src/components/BuildVersion.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div v-if="isAdvanced" class="build-version" @click="copyInfo" :title="tooltip">{{ displayText }}</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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>
|
||||
.build-version {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary, #9aa0a6);
|
||||
background: var(--bg-secondary, rgba(0,0,0,0.1));
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
z-index: 9999;
|
||||
font-family: monospace;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
195
src/components/FaceCard.vue
Normal file
195
src/components/FaceCard.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div
|
||||
class="face-card"
|
||||
:class="{
|
||||
'face-card-selected': selected,
|
||||
'status-accept': face.filter_features.backend_filter_status === 'accept',
|
||||
'status-reject': face.filter_features.backend_filter_status === 'reject',
|
||||
'status-review': face.filter_features.backend_filter_status === 'review'
|
||||
}"
|
||||
@click="$emit('click', face)"
|
||||
>
|
||||
<!-- Status Badge -->
|
||||
<div v-if="face.filter_features.backend_filter_status" class="status-badge" :class="face.filter_features.backend_filter_status">
|
||||
{{ face.filter_features.backend_filter_status }}
|
||||
</div>
|
||||
|
||||
<!-- Face Info -->
|
||||
<div class="face-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Face ID:</span>
|
||||
<span class="info-value">{{ face.face_id }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">Frame:</span>
|
||||
<span class="info-value">{{ face.frame }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">Confidence:</span>
|
||||
<span class="info-value">{{ (face.primary_confidence * 100).toFixed(1) }}%</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">Size:</span>
|
||||
<span class="info-value">{{ face.filter_features.bbox_size }}px</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">Source:</span>
|
||||
<span class="info-value source-badge" :class="face.primary_source">
|
||||
{{ face.primary_source === 'apple_vision' ? 'AV' : 'MP' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checkbox for selection -->
|
||||
<div v-if="showCheckbox" class="face-checkbox" @click.stop="$emit('select', face)">
|
||||
<span v-if="selected">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FaceMetadata } from '../composables/useFaceFilter';
|
||||
|
||||
defineProps<{
|
||||
face: FaceMetadata;
|
||||
selected?: boolean;
|
||||
showCheckbox?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
click: [face: FaceMetadata];
|
||||
select: [face: FaceMetadata];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.face-card {
|
||||
position: relative;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
background: var(--bg-color, #fff);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.face-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
.face-card-selected {
|
||||
border-color: var(--primary-color, #007bff);
|
||||
background: var(--selected-bg, #e3f2fd);
|
||||
}
|
||||
|
||||
/* Status-based styling */
|
||||
.status-accept {
|
||||
border-left: 3px solid #4caf50;
|
||||
}
|
||||
|
||||
.status-reject {
|
||||
border-left: 3px solid #f44336;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.status-review {
|
||||
border-left: 3px solid #ff9800;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge.accept {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.status-badge.reject {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.status-badge.review {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.face-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.source-badge.apple_vision {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.source-badge.mediapipe {
|
||||
background: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
.face-checkbox {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-color, #ddd);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-color, #fff);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.face-checkbox:hover {
|
||||
border-color: var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
.face-checkbox span {
|
||||
color: var(--primary-color, #007bff);
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
575
src/components/FaceFilterPanel.vue
Normal file
575
src/components/FaceFilterPanel.vue
Normal file
@@ -0,0 +1,575 @@
|
||||
<template>
|
||||
<div class="face-filter-panel">
|
||||
<!-- Toolbar Toggle -->
|
||||
<div class="face-filter-toolbar">
|
||||
<button
|
||||
class="face-filter-toggle-btn"
|
||||
:class="{ active: showPanel }"
|
||||
@click="togglePanel"
|
||||
>
|
||||
🔍 Face Filter
|
||||
<span class="toggle-dot" :class="{ on: showPanel }"></span>
|
||||
</button>
|
||||
|
||||
<!-- Stats Summary -->
|
||||
<div v-if="stats" class="face-filter-stats-inline">
|
||||
<span>Total: {{ stats.total }}</span>
|
||||
<span>Filtered: {{ stats.filtered }}</span>
|
||||
<span v-if="stats.removed > 0" class="removed">Removed: {{ stats.removed }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Panel -->
|
||||
<div v-if="showPanel" class="face-filter-content">
|
||||
<!-- Mode Selection -->
|
||||
<div class="filter-row">
|
||||
<label>Mode:</label>
|
||||
<div class="mode-buttons">
|
||||
<button
|
||||
class="mode-btn"
|
||||
:class="{ active: mode === 'instant' }"
|
||||
@click="setMode('instant')"
|
||||
title="Instant client-side filtering (5-10ms)"
|
||||
>
|
||||
Instant
|
||||
</button>
|
||||
<button
|
||||
class="mode-btn"
|
||||
:class="{ active: mode === 'backend' }"
|
||||
@click="setMode('backend')"
|
||||
title="Backend complex filtering (landmark, embedding, temporal)"
|
||||
>
|
||||
Backend
|
||||
</button>
|
||||
<button
|
||||
class="mode-btn"
|
||||
:class="{ active: mode === 'hybrid' }"
|
||||
@click="setMode('hybrid')"
|
||||
title="Hybrid: backend + instant filtering"
|
||||
>
|
||||
Hybrid
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Controls -->
|
||||
<div class="filter-row">
|
||||
<label>Confidence:</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="rules.min_confidence"
|
||||
@change="onRuleChange"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
class="filter-input"
|
||||
placeholder="Min confidence"
|
||||
/>
|
||||
<span class="filter-hint">(0-1)</span>
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
<label>Bbox Size:</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="rules.min_bbox_size"
|
||||
@change="onRuleChange"
|
||||
min="0"
|
||||
step="10"
|
||||
class="filter-input"
|
||||
placeholder="Min size (px)"
|
||||
/>
|
||||
<span class="filter-hint">(px)</span>
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
<label>Source:</label>
|
||||
<select v-model="rules.source" @change="onRuleChange" class="filter-select">
|
||||
<option value="all">All</option>
|
||||
<option value="apple_vision">Apple Vision</option>
|
||||
<option value="mediapipe">MediaPipe</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
<label>Backend Status:</label>
|
||||
<select v-model="rules.backend_status" @change="onRuleChange" class="filter-select">
|
||||
<option value="all">All</option>
|
||||
<option value="accept">Accept</option>
|
||||
<option value="review">Review</option>
|
||||
<option value="reject">Reject</option>
|
||||
</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">
|
||||
<div class="auto-save-hint">
|
||||
{{ singleFrameSettingsLoading ? '⏳ Saving...' : '✓ Auto-saved' }}
|
||||
</div>
|
||||
<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
|
||||
class="action-btn action-btn-primary"
|
||||
@click="handleApplyBackend"
|
||||
:disabled="loading || mode === 'instant'"
|
||||
>
|
||||
{{ loading ? 'Applying...' : 'Apply Backend Rules' }}
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="handleReset"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" class="error-message">
|
||||
❌ {{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Filter Stats (Backend Mode) -->
|
||||
<div v-if="filterStats && (mode === 'backend' || mode === 'hybrid')" class="filter-stats">
|
||||
<h4>Filter Statistics</h4>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<label>Total Faces:</label>
|
||||
<span>{{ filterStats.total_faces }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<label>Apple Vision:</label>
|
||||
<span>{{ filterStats.by_category?.by_source?.apple_vision || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<label>MediaPipe:</label>
|
||||
<span>{{ filterStats.by_category?.by_source?.mediapipe || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<label>MediaPipe Verified:</label>
|
||||
<span>{{ filterStats.by_category?.by_quality?.mediapipe_verified || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onBeforeUnmount } from 'vue';
|
||||
import { useFaceFilter, type QuickFilterRules } from '../composables/useFaceFilter';
|
||||
|
||||
const props = defineProps<{
|
||||
fileUuid: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'settings-updated'): void;
|
||||
}>();
|
||||
|
||||
const {
|
||||
mode,
|
||||
rules,
|
||||
filterResult,
|
||||
filterStats,
|
||||
loading,
|
||||
error,
|
||||
stats,
|
||||
setMode,
|
||||
updateRules,
|
||||
applyBackendFilter,
|
||||
resetToDefault,
|
||||
singleFrameSettings,
|
||||
singleFrameSettingsLoading,
|
||||
singleFrameSettingsError,
|
||||
singleFrameSettingsModified,
|
||||
loadSingleFrameSettings,
|
||||
resetSingleFrameSettings,
|
||||
markSingleFrameSettingsModified,
|
||||
cleanupAutoSave
|
||||
} = useFaceFilter();
|
||||
|
||||
const showPanel = ref(false);
|
||||
|
||||
function togglePanel() {
|
||||
showPanel.value = !showPanel.value;
|
||||
}
|
||||
|
||||
function onRuleChange() {
|
||||
updateRules(rules.value);
|
||||
}
|
||||
|
||||
async function handleApplyBackend() {
|
||||
if (!props.fileUuid) return;
|
||||
await applyBackendFilter(props.fileUuid, null);
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
resetToDefault();
|
||||
}
|
||||
|
||||
function markModified() {
|
||||
markSingleFrameSettingsModified();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
watch(singleFrameSettingsModified, (newVal, oldVal) => {
|
||||
if (oldVal === true && newVal === false) {
|
||||
emit('settings-updated');
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupAutoSave();
|
||||
});
|
||||
|
||||
// Expose for parent
|
||||
defineExpose({
|
||||
showPanel,
|
||||
stats
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.face-filter-panel {
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-color, #fff);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.face-filter-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #ddd);
|
||||
}
|
||||
|
||||
.face-filter-toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-color, #fff);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.face-filter-toggle-btn:hover {
|
||||
background: var(--hover-bg, #f5f5f5);
|
||||
}
|
||||
|
||||
.face-filter-toggle-btn.active {
|
||||
background: var(--primary-color, #007bff);
|
||||
color: white;
|
||||
border-color: var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
.toggle-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ccc;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toggle-dot.on {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.face-filter-stats-inline {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.face-filter-stats-inline .removed {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.face-filter-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.filter-row label {
|
||||
min-width: 120px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mode-buttons {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-color, #fff);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mode-btn:hover {
|
||||
background: var(--hover-bg, #f5f5f5);
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
background: var(--primary-color, #007bff);
|
||||
color: white;
|
||||
border-color: var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
width: 100px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: var(--bg-color, #fff);
|
||||
}
|
||||
|
||||
.filter-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid var(--border-color, #ddd);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-color, #fff);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover:not(:disabled) {
|
||||
background: var(--hover-bg, #f5f5f5);
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn-primary {
|
||||
background: var(--primary-color, #007bff);
|
||||
color: white;
|
||||
border-color: var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
.action-btn-primary:hover:not(:disabled) {
|
||||
background: var(--primary-hover, #0056b3);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #ffebee;
|
||||
border: 1px solid #f44336;
|
||||
border-radius: 4px;
|
||||
color: #c62828;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filter-stats {
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid var(--border-color, #ddd);
|
||||
}
|
||||
|
||||
.filter-stats h4 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
background: var(--hover-bg, #f5f5f5);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stat-item label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.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;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.auto-save-hint {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted, #666);
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-color, #f9f9f9);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
149
src/composables/useDragSelect.ts
Normal file
149
src/composables/useDragSelect.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
527
src/composables/useFaceFilter.ts
Normal file
527
src/composables/useFaceFilter.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
// Types
|
||||
export interface FaceMetadata {
|
||||
face_id: string;
|
||||
frame: number;
|
||||
primary_source: 'apple_vision' | 'mediapipe';
|
||||
primary_confidence: number;
|
||||
detection_sources: Array<{
|
||||
source_type: string;
|
||||
confidence: number;
|
||||
}>;
|
||||
filter_features: {
|
||||
face_id: string;
|
||||
frame: number;
|
||||
bbox_size: number;
|
||||
apple_vision_confidence: number;
|
||||
mediapipe_confidence?: number;
|
||||
eye_ratio?: number;
|
||||
nose_ratio?: number;
|
||||
pose_score?: number;
|
||||
mediapipe_detected?: boolean;
|
||||
mediapipe_iou?: number;
|
||||
embedding_similarity?: number;
|
||||
consecutive_frames?: number;
|
||||
total_frames_detected?: number;
|
||||
backend_filter_status?: 'accept' | 'reject' | 'review';
|
||||
};
|
||||
}
|
||||
|
||||
export interface QuickFilterRules {
|
||||
min_confidence?: number;
|
||||
max_confidence?: number;
|
||||
min_bbox_size?: number;
|
||||
max_bbox_size?: number;
|
||||
source?: 'apple_vision' | 'mediapipe' | 'all';
|
||||
backend_status?: 'accept' | 'reject' | 'review' | 'all';
|
||||
}
|
||||
|
||||
export interface FilterResult {
|
||||
total_faces: number;
|
||||
passed: number;
|
||||
filtered: number;
|
||||
passed_faces: Array<{
|
||||
face_id: string;
|
||||
frame: number;
|
||||
filter_result: {
|
||||
overall_confidence: number;
|
||||
recommendation: 'accept' | 'review' | 'reject';
|
||||
reasons: string[];
|
||||
};
|
||||
}>;
|
||||
filtered_faces: Array<{
|
||||
face_id: string;
|
||||
frame: number;
|
||||
filter_result: {
|
||||
overall_confidence: number;
|
||||
recommendation: 'accept' | 'review' | 'reject';
|
||||
reasons: string[];
|
||||
};
|
||||
}>;
|
||||
filter_stats: {
|
||||
landmark?: number;
|
||||
mediapipe?: number;
|
||||
embedding?: number;
|
||||
temporal?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FilterStats {
|
||||
total_faces: number;
|
||||
by_category: {
|
||||
total_faces: number;
|
||||
by_source: {
|
||||
apple_vision: number;
|
||||
mediapipe: number;
|
||||
};
|
||||
by_quality: {
|
||||
avg_eye_ratio?: number;
|
||||
avg_nose_ratio?: number;
|
||||
avg_pose_score?: number;
|
||||
mediapipe_verified: number;
|
||||
avg_embedding_similarity?: number;
|
||||
avg_consecutive_frames?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
min_bbox_size: 80,
|
||||
source: 'all',
|
||||
backend_status: 'accept'
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'momentry_face_filter_preferences';
|
||||
|
||||
// API helpers
|
||||
const apiBase = '/api/v1';
|
||||
|
||||
async function fetchFaces(fileUuid: string, params?: { page?: number; page_size?: number; source?: string }) {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.page) queryParams.set('page', params.page.toString());
|
||||
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}/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}/face-filter/apply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rules: rules || null })
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function getFilterStats(fileUuid: string) {
|
||||
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-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 => {
|
||||
// 1. Confidence 過濾
|
||||
if (rules.min_confidence && face.primary_confidence < rules.min_confidence) {
|
||||
return false;
|
||||
}
|
||||
if (rules.max_confidence && face.primary_confidence > rules.max_confidence) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Bbox size 過濾
|
||||
if (rules.min_bbox_size && face.filter_features.bbox_size < rules.min_bbox_size) {
|
||||
return false;
|
||||
}
|
||||
if (rules.max_bbox_size && face.filter_features.bbox_size > rules.max_bbox_size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. Source 過濾
|
||||
if (rules.source && rules.source !== 'all' && face.primary_source !== rules.source) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. Backend status 過濾
|
||||
if (rules.backend_status && rules.backend_status !== 'all' &&
|
||||
face.filter_features.backend_filter_status !== rules.backend_status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Hybrid filter function (backend + frontend)
|
||||
function hybridFilter(faces: FaceMetadata[], rules: QuickFilterRules): FaceMetadata[] {
|
||||
// 1. 前端過濾
|
||||
const frontendFiltered = quickFilter(faces, rules);
|
||||
|
||||
// 2. 後端已標示 backend_filter_status
|
||||
// 3. 兩條件 AND
|
||||
return frontendFiltered.filter(face =>
|
||||
face.filter_features.backend_filter_status === 'accept'
|
||||
);
|
||||
}
|
||||
|
||||
// Main composable
|
||||
export function useFaceFilter() {
|
||||
const mode = ref<'instant' | 'backend' | 'hybrid'>('instant');
|
||||
const rules = ref<QuickFilterRules>({ ...defaultRules });
|
||||
const allFaces = ref<FaceMetadata[]>([]);
|
||||
const filteredFaces = ref<FaceMetadata[]>([]);
|
||||
const filterResult = ref<FilterResult | null>(null);
|
||||
const filterStats = ref<FilterStats | null>(null);
|
||||
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);
|
||||
|
||||
const currentFileUuid = ref<string | null>(null);
|
||||
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Computed stats
|
||||
const stats = computed(() => ({
|
||||
total: allFaces.value.length,
|
||||
filtered: filteredFaces.value.length,
|
||||
removed: allFaces.value.length - filteredFaces.value.length
|
||||
}));
|
||||
|
||||
// Load preferences from localStorage
|
||||
function loadPreferences() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const prefs = JSON.parse(stored);
|
||||
mode.value = prefs.mode || 'instant';
|
||||
rules.value = prefs.rules || { ...defaultRules };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load preferences:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Save preferences to localStorage
|
||||
function savePreferences() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
mode: mode.value,
|
||||
rules: rules.value
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Failed to save preferences:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize: load faces and apply filter
|
||||
async function initialize(fileUuid: string) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
// 1. 載入臉部元數據
|
||||
const data = await fetchFaces(fileUuid, { page: 1, page_size: 1000 });
|
||||
allFaces.value = data.data?.faces || data.faces || [];
|
||||
|
||||
// 2. 載入後端過濾結果(混合模式需要)
|
||||
if (mode.value === 'hybrid' || mode.value === 'backend') {
|
||||
const result = await applyFilter(fileUuid, null);
|
||||
filterResult.value = result;
|
||||
|
||||
// 合併後端過濾結果到元數據
|
||||
mergeBackendResults(result);
|
||||
}
|
||||
|
||||
// 3. 載入統計
|
||||
filterStats.value = await getFilterStats(fileUuid);
|
||||
|
||||
// 4. 套用過濾
|
||||
applyLocalFilter();
|
||||
|
||||
// 5. 載入偏好
|
||||
loadPreferences();
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to initialize';
|
||||
console.error('Failed to initialize face filter:', err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge backend filter results into metadata
|
||||
function mergeBackendResults(result: FilterResult) {
|
||||
result.passed_faces.forEach(face => {
|
||||
const metadata = allFaces.value.find(f => f.face_id === face.face_id);
|
||||
if (metadata) {
|
||||
metadata.filter_features.backend_filter_status = face.filter_result.recommendation as any;
|
||||
}
|
||||
});
|
||||
|
||||
result.filtered_faces.forEach(face => {
|
||||
const metadata = allFaces.value.find(f => f.face_id === face.face_id);
|
||||
if (metadata) {
|
||||
metadata.filter_features.backend_filter_status = face.filter_result.recommendation as any;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Apply local filter based on mode
|
||||
function applyLocalFilter() {
|
||||
switch (mode.value) {
|
||||
case 'instant':
|
||||
filteredFaces.value = quickFilter(allFaces.value, rules.value);
|
||||
break;
|
||||
case 'backend':
|
||||
filteredFaces.value = allFaces.value.filter(face =>
|
||||
face.filter_features.backend_filter_status === 'accept'
|
||||
);
|
||||
break;
|
||||
case 'hybrid':
|
||||
filteredFaces.value = hybridFilter(allFaces.value, rules.value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update rules and re-filter
|
||||
function updateRules(newRules: Partial<QuickFilterRules>) {
|
||||
rules.value = { ...rules.value, ...newRules };
|
||||
applyLocalFilter();
|
||||
savePreferences();
|
||||
}
|
||||
|
||||
// Change mode and re-filter
|
||||
function setMode(newMode: 'instant' | 'backend' | 'hybrid') {
|
||||
mode.value = newMode;
|
||||
applyLocalFilter();
|
||||
savePreferences();
|
||||
}
|
||||
|
||||
// Apply backend filter
|
||||
async function applyBackendFilter(fileUuid: string, customRules?: any) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const result = await applyFilter(fileUuid, customRules);
|
||||
filterResult.value = result;
|
||||
|
||||
// 合併後端過濾結果到元數據
|
||||
mergeBackendResults(result);
|
||||
|
||||
// 重新套用過濾
|
||||
applyLocalFilter();
|
||||
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to apply backend filter';
|
||||
console.error('Failed to apply backend filter:', err);
|
||||
return null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get face report
|
||||
async function getFaceReportData(fileUuid: string, faceId: string) {
|
||||
try {
|
||||
return await getFaceReport(fileUuid, faceId);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to get face report:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh stats
|
||||
async function refreshStats(fileUuid: string) {
|
||||
try {
|
||||
filterStats.value = await getFilterStats(fileUuid);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to refresh stats:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset to default
|
||||
function resetToDefault() {
|
||||
rules.value = { ...defaultRules };
|
||||
applyLocalFilter();
|
||||
savePreferences();
|
||||
}
|
||||
|
||||
async function loadSingleFrameSettings(fileUuid: string) {
|
||||
singleFrameSettingsLoading.value = true;
|
||||
singleFrameSettingsError.value = null;
|
||||
|
||||
try {
|
||||
currentFileUuid.value = fileUuid;
|
||||
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 autoSaveSettings() {
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer);
|
||||
}
|
||||
|
||||
autoSaveTimer = setTimeout(async () => {
|
||||
if (currentFileUuid.value && singleFrameSettingsModified.value) {
|
||||
const success = await saveSingleFrameSettings(currentFileUuid.value);
|
||||
if (!success) {
|
||||
console.error('Auto-save failed for single frame settings');
|
||||
}
|
||||
}
|
||||
autoSaveTimer = null;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function cleanupAutoSave() {
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer);
|
||||
autoSaveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
autoSaveSettings();
|
||||
}
|
||||
|
||||
function markSingleFrameSettingsModified() {
|
||||
singleFrameSettingsModified.value = true;
|
||||
autoSaveSettings();
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
mode,
|
||||
rules,
|
||||
allFaces,
|
||||
filteredFaces,
|
||||
filterResult,
|
||||
filterStats,
|
||||
loading,
|
||||
error,
|
||||
stats,
|
||||
singleFrameSettings,
|
||||
singleFrameSettingsLoading,
|
||||
singleFrameSettingsError,
|
||||
singleFrameSettingsModified,
|
||||
currentFileUuid,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
updateRules,
|
||||
setMode,
|
||||
applyBackendFilter,
|
||||
getFaceReportData,
|
||||
refreshStats,
|
||||
resetToDefault,
|
||||
applyLocalFilter,
|
||||
loadSingleFrameSettings,
|
||||
saveSingleFrameSettings,
|
||||
resetSingleFrameSettings,
|
||||
markSingleFrameSettingsModified,
|
||||
cleanupAutoSave,
|
||||
|
||||
// API helpers (exposed for direct use)
|
||||
fetchFaces,
|
||||
applyFilter,
|
||||
getFilterStats,
|
||||
getFaceReport,
|
||||
quickFilter,
|
||||
hybridFilter
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -44,13 +44,20 @@
|
||||
"min_minutes": "Min Minutes",
|
||||
"max_minutes": "Max Minutes"
|
||||
},
|
||||
"status": {
|
||||
"status": {
|
||||
"files": "{count} files",
|
||||
"registered": "Registered",
|
||||
"unregistered": "Unregistered",
|
||||
"pending": "Pending",
|
||||
"preparing": "Preparing",
|
||||
"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 +66,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 +73,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 +99,58 @@
|
||||
"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.",
|
||||
"batch_unregister_message": "Unregister {count} files?\n\nThis will remove the files and their 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 +168,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 +280,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 +326,129 @@
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"productName": "Momentry Studio",
|
||||
"tagline": "Turn Every Moment Into Intelligence"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
454
src/locales/ja.json
Normal file
454
src/locales/ja.json
Normal file
@@ -0,0 +1,454 @@
|
||||
{
|
||||
"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": "保留中",
|
||||
"preparing": "準備中",
|
||||
"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ファイルとその出力データが削除されます。",
|
||||
"batch_unregister_message": "{count} ファイルを登録解除しますか?\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": "共有"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"productName": "Momentry Studio",
|
||||
"tagline": "Turn Every Moment Into Intelligence"
|
||||
}
|
||||
}
|
||||
@@ -44,30 +44,38 @@
|
||||
"min_minutes": "最小分鐘",
|
||||
"max_minutes": "最大分鐘"
|
||||
},
|
||||
"status": {
|
||||
"status": {
|
||||
"files": "{count} 個檔案",
|
||||
"registered": "已註冊",
|
||||
"unregistered": "未註冊",
|
||||
"pending": "等待中",
|
||||
"preparing": "準備中",
|
||||
"processing": "處理中",
|
||||
"completed": "已完成"
|
||||
"completed": "已完成",
|
||||
"scanning": "掃描中",
|
||||
"ready": "就緒",
|
||||
"keyword_ready": "關鍵字可用",
|
||||
"semantic_ready": "語意可用",
|
||||
"face_mgmt_ready": "臉部管理可用",
|
||||
"agent_ready": "Agent 可用"
|
||||
},
|
||||
"thumbnail": {
|
||||
"register_to_preview": "註冊以預覽"
|
||||
},
|
||||
"context": {
|
||||
"register_file": "註冊此檔案",
|
||||
"process_file": "處理此檔案",
|
||||
"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 +99,58 @@
|
||||
"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這將移除檔案及其輸出資料。",
|
||||
"batch_unregister_message": "取消註冊 {count} 個檔案?\n\n這將移除檔案及其輸出資料。"
|
||||
},
|
||||
"processor": {
|
||||
"started": "{processor} 已開始",
|
||||
"failed": "失敗:{message}",
|
||||
"error": "錯誤:{message}",
|
||||
"no_file": "錯誤:未選擇檔案",
|
||||
"vlm_failed": "無法觸發 VLM 摘要:{message}",
|
||||
"pipeline_qc": "管道品質檢查"
|
||||
},
|
||||
"settings": {
|
||||
"auto_scan": "註冊後自動深度掃描"
|
||||
}
|
||||
},
|
||||
"people": {
|
||||
"toolbar": {
|
||||
@@ -119,7 +168,8 @@
|
||||
"pending_people": "人物",
|
||||
"skipped_people": "已略過",
|
||||
"pending_faces": "人臉",
|
||||
"face_group": "人臉群組"
|
||||
"face_group": "人臉群組",
|
||||
"unassigned": "未分組"
|
||||
},
|
||||
"sort": {
|
||||
"title": "排序方式",
|
||||
@@ -230,6 +280,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 +326,129 @@
|
||||
"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": "共享"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"productName": "Momentry Studio",
|
||||
"tagline": "Turn Every Moment Into Intelligence"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
251
src/store.ts
251
src/store.ts
@@ -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}`
|
||||
@@ -332,7 +425,7 @@ loadProgressCacheFromStorage()
|
||||
|
||||
const thumbQueue: (() => Promise<void>)[] = []
|
||||
let activeThumbLoads = 0
|
||||
const MAX_CONCURRENT = 16
|
||||
const MAX_CONCURRENT = 10
|
||||
|
||||
const profileQueue: (() => Promise<void>)[] = []
|
||||
let activeProfileLoads = 0
|
||||
@@ -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,21 +686,22 @@ 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 {
|
||||
const args: any = { uuid: t.file_uuid, frame: t.best_face_frame || t.start_frame || 0 }
|
||||
if (t.best_face_bbox) {
|
||||
args.bboxX = t.best_face_bbox.x
|
||||
args.bboxY = t.best_face_bbox.y
|
||||
args.bboxW = t.best_face_bbox.width
|
||||
args.bboxH = t.best_face_bbox.height
|
||||
// 改用自己的 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()
|
||||
faceThumbsCache.value[key] = URL.createObjectURL(blob)
|
||||
}
|
||||
const result = await apiCall('get_face_thumbnail', args)
|
||||
if (result) faceThumbsCache.value[key] = result
|
||||
} catch (e) {
|
||||
console.error('loadTraceThumb failed:', key, e)
|
||||
} finally {
|
||||
@@ -796,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
|
||||
@@ -839,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)
|
||||
@@ -922,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) => ({
|
||||
@@ -935,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 = {
|
||||
@@ -947,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) {
|
||||
@@ -962,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')
|
||||
|
||||
@@ -989,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]
|
||||
@@ -1061,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1091,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
|
||||
@@ -1133,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
|
||||
@@ -1211,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
28
src/stores/autoScan.ts
Normal 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
32
src/stores/themeStore.ts
Normal 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 }
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
122
src/utils/faceLandmarkScoring.ts
Normal file
122
src/utils/faceLandmarkScoring.ts
Normal 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
@@ -1,85 +0,0 @@
|
||||
<template>
|
||||
<div id="ms-view-admin">
|
||||
<div class="admin-header">
|
||||
<h1>Admin Panel</h1>
|
||||
<div class="admin-tabs">
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'dashboard' }" @click="activeTab = 'dashboard'">Dashboard</button>
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'users' }" @click="activeTab = 'users'">Users</button>
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'shares' }" @click="activeTab = 'shares'">Shares</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-content">
|
||||
<div v-if="activeTab === 'dashboard'" class="dashboard-panel">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><div class="stat-label">CPU</div><div class="stat-value">{{ stats.cpu }}%</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Memory</div><div class="stat-value">{{ stats.memory }}%</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Disk</div><div class="stat-value">{{ stats.disk }}%</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Users</div><div class="stat-value">{{ users.length }}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Shares</div><div class="stat-value">{{ shares.length }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'users'" class="users-panel">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Username</th><th>Home Dir</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.username">
|
||||
<td>{{ u.username }}</td><td>{{ u.homeDir }}</td><td>{{ u.status === 1 ? 'Active' : 'Disabled' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'shares'" class="shares-panel">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Name</th><th>Port</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="s in shares" :key="s.name">
|
||||
<td>{{ s.name }}</td><td>{{ s.port }}</td><td>{{ s.status }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { apiCall } from '../api'
|
||||
|
||||
const activeTab = ref('dashboard')
|
||||
const stats = ref({ cpu: 0, memory: 0, disk: 0 })
|
||||
const users = ref<any[]>([])
|
||||
const shares = ref<any[]>([])
|
||||
|
||||
async function loadDashboard() {
|
||||
try { stats.value = await apiCall('get_system_stats', {}) || { cpu: 0, memory: 0, disk: 0 } } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try { users.value = await apiCall('list_admin_users', {}) || [] } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadShares() {
|
||||
try { shares.value = await apiCall('list_shares', {}) || [] } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
onMounted(async () => { await loadDashboard(); await loadUsers(); await loadShares() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#ms-view-admin { padding: 20px; }
|
||||
.admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.admin-tabs { display: flex; gap: 10px; }
|
||||
.admin-tab { padding: 8px 16px; border: none; background: #f0f0f0; cursor: pointer; border-radius: 4px; }
|
||||
.admin-tab.active { background: #2563eb; color: white; }
|
||||
.admin-content { background: white; border-radius: 8px; padding: 20px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 15px; }
|
||||
.stat-card { background: #f8f9fa; padding: 15px; border-radius: 8px; text-align: center; }
|
||||
.stat-label { font-size: 12px; color: #666; }
|
||||
.stat-value { font-size: 24px; font-weight: bold; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; }
|
||||
.admin-table th, .admin-table td { padding: 10px; border: 1px solid #ddd; text-align: left; }
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -1,84 +0,0 @@
|
||||
<template>
|
||||
<div id="ms-view-client">
|
||||
<div class="client-header">
|
||||
<h1>File Manager</h1>
|
||||
<div class="client-toolbar">
|
||||
<button @click="goUp">Up</button>
|
||||
<button @click="createFolder">New Folder</button>
|
||||
<button @click="deleteSelected" :disabled="!selectedFile">Delete</button>
|
||||
<span class="current-path">{{ currentPath }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="client-content">
|
||||
<table class="file-table">
|
||||
<thead><tr><th>Name</th><th>Size</th><th>Type</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="f in files" :key="f.name" @click="selectFile(f)" :class="{ selected: selectedFile?.name === f.name }">
|
||||
<td><span :class="f.isDir ? 'folder-icon' : 'file-icon'">{{ f.isDir ? '📁' : '📄' }}</span> {{ f.name }}</td>
|
||||
<td>{{ f.isDir ? '-' : formatSize(f.size) }}</td>
|
||||
<td>{{ f.isDir ? 'Folder' : 'File' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { apiCall } from '../api'
|
||||
|
||||
const currentPath = ref('/')
|
||||
const files = ref<any[]>([])
|
||||
const selectedFile = ref<any>(null)
|
||||
|
||||
async function loadFiles() {
|
||||
try {
|
||||
const data = await apiCall('list_client_files', { path: currentPath.value })
|
||||
files.value = (data || []).sort((a: any, b: any) => a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1)
|
||||
} catch (e) { console.error(e); files.value = [] }
|
||||
}
|
||||
|
||||
function goUp() {
|
||||
const parts = currentPath.value.split('/').filter(p => p)
|
||||
parts.pop()
|
||||
currentPath.value = '/' + parts.join('/')
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
function selectFile(f: any) {
|
||||
if (f.isDir) { currentPath.value = currentPath.value === '/' ? '/' + f.name : currentPath.value + '/' + f.name; loadFiles() }
|
||||
else { selectedFile.value = f }
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
const name = prompt('Folder name:')
|
||||
if (!name) return
|
||||
const path = currentPath.value === '/' ? '/' + name : currentPath.value + '/' + name
|
||||
try { await apiCall('mkdir_client', { path }); loadFiles() } catch (e) { alert('Failed: ' + e) }
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
if (!selectedFile.value || !confirm('Delete ' + selectedFile.value.name + '?')) return
|
||||
const path = currentPath.value === '/' ? '/' + selectedFile.value.name : currentPath.value + '/' + selectedFile.value.name
|
||||
try { await apiCall('rm_client', { path }); selectedFile.value = null; loadFiles() } catch (e) { alert('Failed: ' + e) }
|
||||
}
|
||||
|
||||
function formatSize(n: number) { return n < 1024 ? n + 'B' : n < 1024*1024 ? (n/1024).toFixed(1) + 'KB' : (n/1024/1024).toFixed(1) + 'MB' }
|
||||
|
||||
onMounted(loadFiles)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#ms-view-client { padding: 20px; }
|
||||
.client-header { margin-bottom: 15px; }
|
||||
.client-toolbar { display: flex; gap: 10px; align-items: center; margin-top: 10px; }
|
||||
.client-toolbar button { padding: 6px 12px; border: 1px solid #ddd; background: white; cursor: pointer; border-radius: 4px; }
|
||||
.client-toolbar button:disabled { opacity: 0.5; }
|
||||
.current-path { margin-left: 20px; font-weight: bold; }
|
||||
.file-table { width: 100%; border-collapse: collapse; }
|
||||
.file-table th, .file-table td { padding: 8px; border: 1px solid #eee; }
|
||||
.file-table tr.selected { background: #e3f2fd; }
|
||||
.file-table tr:hover { background: #f5f5f5; cursor: pointer; }
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -64,8 +73,8 @@
|
||||
<div class="chat" id="msChat" ref="messagesEl" :class="{ 'chat--empty': !messages.length }" @mouseup="onChatMouseUp">
|
||||
<template v-if="!messages.length">
|
||||
<header class="ms-hero">
|
||||
<h1 class="ms-title">Momentry Studio</h1>
|
||||
<p class="ms-subtitle">Turn Every Moment Into Intelligence</p>
|
||||
<h1 class="ms-title">{{ $t('app.productName') }}</h1>
|
||||
<p class="ms-subtitle">{{ $t('app.tagline') }}</p>
|
||||
</header>
|
||||
<div class="ibar ibar--hero">
|
||||
<div class="iwrap">
|
||||
@@ -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,7 +308,9 @@ 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>' },
|
||||
]
|
||||
|
||||
const mode = ref(localStorage.getItem('ms_chat_mode_v1') || 'keyword')
|
||||
@@ -287,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 }
|
||||
}
|
||||
@@ -317,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,
|
||||
@@ -336,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) => {
|
||||
@@ -349,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()
|
||||
@@ -358,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
|
||||
}
|
||||
@@ -380,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
|
||||
@@ -391,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],
|
||||
})
|
||||
@@ -428,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')
|
||||
|
||||
@@ -472,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))
|
||||
|
||||
@@ -602,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
|
||||
@@ -666,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')
|
||||
@@ -721,23 +896,53 @@ async function sendMessage() {
|
||||
messages.value[msgIdx].loading = false
|
||||
if (res?.chips) messages.value[msgIdx].chips = res.chips
|
||||
} else if (mode.value === 'people') {
|
||||
const raw: any = await apiCall('search_identities', { query: q, limit: 50 })
|
||||
const raw: any = await apiCall('search_people', { query: q, limit: 50 })
|
||||
if (searchSeq !== seq) return
|
||||
const results = (Array.isArray(raw) ? raw : []).filter((p: any) => p.file_uuid).map((p: any) => ({
|
||||
file_uuid: p.file_uuid,
|
||||
file_name: p.name,
|
||||
summary: `Identity - ${p.source || 'unknown'}`,
|
||||
similarity: 1.0,
|
||||
start_frame: p.startFrame || p.start_frame,
|
||||
end_frame: p.endFrame || p.end_frame,
|
||||
search_query: q,
|
||||
}))
|
||||
const peopleList = raw?.people || []
|
||||
|
||||
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 || [])
|
||||
@@ -747,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)) {
|
||||
@@ -761,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) {
|
||||
@@ -806,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 = {
|
||||
@@ -818,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
|
||||
@@ -1001,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); }
|
||||
@@ -1176,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);
|
||||
@@ -1269,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%;
|
||||
@@ -1499,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;
|
||||
@@ -1603,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>
|
||||
|
||||
11
src/vite-env.d.ts
vendored
Normal file
11
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __BUILD_VERSION__: string
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
import { execSync } from 'child_process'
|
||||
|
||||
const gitHash = execSync('git log --format="%h" -1', { encoding: 'utf-8' }).trim()
|
||||
const buildTime = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, '')
|
||||
const buildVersion = `${buildTime}-${gitHash}`
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
define: {
|
||||
__BUILD_VERSION__: JSON.stringify(buildVersion)
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
|
||||
Reference in New Issue
Block a user