Compare commits
104 Commits
ab1399b853
...
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 | |||
| 69601fe512 | |||
| c0832f32f9 | |||
| e7f9740e72 | |||
| 70889b25d1 | |||
| 8cf7196c86 | |||
| ec67e749d7 | |||
| a096bb645c | |||
| 36074b1017 | |||
| c347c200b2 | |||
| 58e614cb5c | |||
| a3b45e12c5 | |||
| 9f667cf174 | |||
| cb7e1ba0ba | |||
| 48975ef247 | |||
| 16fb1fd1ed | |||
| 38b993938b | |||
| 6a34a862f4 | |||
| b1a9c44778 |
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`)
|
||||
280
CORE_TEAM_ACTION_ITEMS.md
Normal file
280
CORE_TEAM_ACTION_ITEMS.md
Normal file
@@ -0,0 +1,280 @@
|
||||
# Core Team Action Items - Pipeline Progress Issue
|
||||
|
||||
## ✅ Studio Team 已完成修复
|
||||
|
||||
**修复内容**:
|
||||
1. ✅ 权重配置已修正(基于 Core team 规格)
|
||||
2. ✅ 移除 identity_agent 过滤
|
||||
3. ✅ API 端点已验证正确
|
||||
|
||||
**Commit**: `待推送`
|
||||
|
||||
---
|
||||
|
||||
## 🚨 问题摘要(已部分解决)
|
||||
|
||||
**现象**:Library 卡片在 deep scan 时卡在 "Scanning..." 状态,没有进度百分比
|
||||
|
||||
**前端已修复**:
|
||||
- ✅ 权重配置已修正(基于 Core team 规格)
|
||||
- ✅ 移除 identity_agent 过滤
|
||||
- ✅ API 端点已验证正确(无需修改)
|
||||
- ✅ `syncFileStatus()` 现在会加载进度数据
|
||||
- ✅ LibraryView 会启动轮询即使 pipeline 数据缺失
|
||||
|
||||
**后端需要诊断**:3 项核心问题
|
||||
|
||||
---
|
||||
|
||||
## 📋 Core Team 需要检查的项目
|
||||
|
||||
### 1. 🟡 Pipeline 进度计算逻辑(中优先级)
|
||||
|
||||
**位置**: Core API pipeline 执行引擎
|
||||
|
||||
**需要检查**:
|
||||
- 各处理阶段的进度追踪是否准确?
|
||||
- `overall_progress` 权重计算是否正确?
|
||||
- 阶段状态转换是否及时?
|
||||
|
||||
**权重设置**(前端期望):
|
||||
```typescript
|
||||
const WEIGHT_MAP = {
|
||||
processors: 0.30,
|
||||
rule1_ingestion: 0.10,
|
||||
face_tracing: 0.10,
|
||||
tkg_nodes: 0.20,
|
||||
tkg_edges: 0.15,
|
||||
rule2_ingestion: 0.15
|
||||
}
|
||||
```
|
||||
|
||||
**需要验证**:
|
||||
- ✅ 后端权重是否与前端一致?
|
||||
- ✅ 各阶段进度是否正确追踪?
|
||||
- ✅ 阶段完成时是否立即更新状态?
|
||||
|
||||
---
|
||||
|
||||
### 4. 🟡 Pipeline stages 初始化(中优先级)
|
||||
|
||||
**需要检查**:
|
||||
- 文件开始处理时,是否立即创建 stages 数据?
|
||||
- 所有必要的阶段是否都被追踪?
|
||||
- 阶段顺序是否正确?
|
||||
|
||||
**可能的问题**:
|
||||
- 文件开始处理时,`stages` 数组为空
|
||||
- 导致前端无法判断是否有正在运行的阶段
|
||||
- `pollPipelineProgress()` 不会被启动
|
||||
|
||||
**需要验证**:
|
||||
- ✅ 文件注册后,立即创建初始 stages?
|
||||
- ✅ 所有阶段都被包含在返回数据中?
|
||||
- ✅ 阶段顺序是否符合 pipeline 流程?
|
||||
|
||||
---
|
||||
|
||||
### 5. 🟡 文件处理异常处理(中优先级)
|
||||
|
||||
**需要检查**:
|
||||
- 处理过程出错时,文件状态是否正确更新?
|
||||
- 是否有文件卡在 `processing` 状态无法完成?
|
||||
- 错误日志是否完整?
|
||||
|
||||
**可能的问题**:
|
||||
- 文件处理出错,但状态未更新为 `failed`
|
||||
- 文件永远卡在 `processing` 状态
|
||||
- 前端无法判断文件是否完成
|
||||
|
||||
**需要验证**:
|
||||
- ✅ 错误处理机制是否完善?
|
||||
- ✅ 失败文件是否有明确的 `failed` 状态?
|
||||
- ✅ 错误日志是否包含足够信息?
|
||||
|
||||
---
|
||||
|
||||
## 🧪 诊断步骤
|
||||
|
||||
### Step 1: 检查 processing 文件的 pipeline stats
|
||||
|
||||
```bash
|
||||
# 1. 获取 processing 文件列表
|
||||
curl -X GET "http://localhost:3002/api/v1/files?status=processing&per_page=10" \
|
||||
-H "Authorization: Bearer {API_KEY}"
|
||||
|
||||
# 2. 对每个 processing 文件,检查 pipeline stats
|
||||
for uuid in $(processing_files); do
|
||||
echo "=== File: $uuid ==="
|
||||
curl -X GET "http://localhost:3002/api/v1/pipeline-stats?file_uuid=$uuid" \
|
||||
-H "Authorization: Bearer {API_KEY}"
|
||||
echo ""
|
||||
done
|
||||
```
|
||||
|
||||
**观察重点**:
|
||||
- `overall_progress` 是否为 0 或 null?
|
||||
- `stages` 数组是否为空?
|
||||
- 是否有 stage 的 `status === 'running'`?
|
||||
|
||||
---
|
||||
|
||||
### Step 2: 检查文件状态同步
|
||||
|
||||
```bash
|
||||
# 对卡住的文件,手动调用 sync_file_status
|
||||
curl -X POST "http://localhost:3002/api/v1/sync-file-status" \
|
||||
-H "Authorization: Bearer {API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"file_uuid": "{STUCK_FILE_UUID}"}'
|
||||
```
|
||||
|
||||
**观察重点**:
|
||||
- API 是否返回正确的状态?
|
||||
- 状态是否从 `processing` 变为 `completed`?
|
||||
|
||||
---
|
||||
|
||||
### Step 3: 检查后端日志
|
||||
|
||||
```bash
|
||||
# 查看最近的错误日志
|
||||
tail -f /path/to/core-api/logs/error.log | grep -E "pipeline|processing|file_uuid"
|
||||
```
|
||||
|
||||
**观察重点**:
|
||||
- 是否有 pipeline 执行错误?
|
||||
- 是否有文件处理超时?
|
||||
- 是否有数据库连接错误?
|
||||
|
||||
---
|
||||
|
||||
## 📊 API 端点验证结果
|
||||
|
||||
### ✅ 已验证正确(无需修改)
|
||||
|
||||
**API 端点已是正确的**:
|
||||
- ✅ `get_pipeline_stats`: `/api/v1/stats/pipeline/:file_uuid` (src/api/index.ts:510)
|
||||
- ✅ `sync_file_status`: `/api/v1/file/:file_uuid/sync-status` (src/api/index.ts:582)
|
||||
|
||||
**权重配置已修正**:
|
||||
```typescript
|
||||
const WEIGHT_MAP = {
|
||||
processors: 0.30,
|
||||
rule1_ingestion: 0.05, // ✅ 已修正(was 0.10)
|
||||
face_tracing: 0.05, // ✅ 已修正(was 0.10)
|
||||
identity_agent: 0.10, // ✅ 已添加(was filtered out)
|
||||
tkg_nodes: 0.20,
|
||||
tkg_edges: 0.15,
|
||||
rule2_ingestion: 0.15
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 前端已修复的内容
|
||||
|
||||
### 修复 1: `syncFileStatus()` 添加进度加载
|
||||
|
||||
**文件**: `src/store.ts:888-891`
|
||||
|
||||
**改动**:
|
||||
```typescript
|
||||
// 在 syncFileStatus() 中,当状态为 processing 时,加载进度数据
|
||||
if (newStatus === 'processing') {
|
||||
await loadPipelineStats(fileUuid, true)
|
||||
}
|
||||
```
|
||||
|
||||
**效果**:每 10 秒轮询时,会更新进度数据
|
||||
|
||||
---
|
||||
|
||||
### 修复 2: LibraryView 启动轮询(处理边缘情况)
|
||||
|
||||
**文件**: `src/views/LibraryView.vue:789-790`
|
||||
|
||||
**改动**:
|
||||
```typescript
|
||||
// 即使没有 pipeline 数据,如果状态是 processing,也启动轮询
|
||||
else if (!pipeline && f.status === 'processing') {
|
||||
pollPipelineProgress(f.file_uuid)
|
||||
}
|
||||
```
|
||||
|
||||
**效果**:不会因为 pipeline 数据缺失而无法启动轮询
|
||||
|
||||
---
|
||||
|
||||
### 修复 3: 权重配置修正
|
||||
|
||||
**文件**: `src/store.ts:923-931`
|
||||
|
||||
**改动**:
|
||||
```typescript
|
||||
const WEIGHT_MAP: Record<string, number> = {
|
||||
processors: 0.30,
|
||||
rule1_ingestion: 0.05, // ✅ 修正(was 0.10)
|
||||
face_tracing: 0.05, // ✅ 修正(was 0.10)
|
||||
identity_agent: 0.10, // ✅ 添加(was filtered out)
|
||||
tkg_nodes: 0.20,
|
||||
tkg_edges: 0.15,
|
||||
rule2_ingestion: 0.15
|
||||
}
|
||||
```
|
||||
|
||||
**效果**:进度百分比计算准确
|
||||
|
||||
---
|
||||
|
||||
### 修复 4: 移除 identity_agent 过滤
|
||||
|
||||
**文件**: `src/store.ts:919`
|
||||
|
||||
**改动**:
|
||||
```typescript
|
||||
// 移除过滤,包含所有阶段
|
||||
const stages = data.stages || []
|
||||
```
|
||||
|
||||
**效果**:identity_agent 阶段被正确追踪
|
||||
|
||||
---
|
||||
|
||||
## ✅ 修复验证清单
|
||||
|
||||
修复后,前端期望看到:
|
||||
|
||||
1. **进度条显示百分比**
|
||||
- 不再只显示 "Scanning..."
|
||||
- 显示具体进度(如 "45%")
|
||||
|
||||
2. **进度数据定期更新**
|
||||
- 每 10 秒更新一次
|
||||
- 进度条实时变化
|
||||
|
||||
3. **文件状态正确转换**
|
||||
- `processing` → `completed`
|
||||
- 处理失败的文件显示 `failed`
|
||||
|
||||
4. **无卡住现象**
|
||||
- 文件不会永远卡在 `processing`
|
||||
- 有明确的完成时间
|
||||
|
||||
---
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
如有疑问,请联系 Studio team:
|
||||
|
||||
- **前端修复 Commit**: `{COMMIT_HASH}`
|
||||
- **修复文件**:
|
||||
- `src/store.ts:888-891`
|
||||
- `src/views/LibraryView.vue:789-790`
|
||||
- **测试环境**: `http://localhost:5173`
|
||||
- **API 文档**: `AGENTS.md`
|
||||
|
||||
---
|
||||
|
||||
**生成时间**: 2026-07-25
|
||||
**优先级**: 🔴 高 - 影响用户体验
|
||||
180
CORE_TEAM_RESPONSE.md
Normal file
180
CORE_TEAM_RESPONSE.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Core Team Response - Pipeline Progress Issue
|
||||
|
||||
**Date**: 2026-07-25
|
||||
**Author**: Core Team (OpenCode)
|
||||
**Status**: ✅ Implemented
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Core Team has implemented all 3 requested backend fixes:
|
||||
|
||||
| # | Item | Status |
|
||||
|---|------|--------|
|
||||
| 1 | Add "running" stage tracking | ✅ Completed |
|
||||
| 2 | Initialize stages at registration | ✅ Completed |
|
||||
| 3 | sync-status TKG check | ✅ Completed |
|
||||
|
||||
---
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. "Running" Stage Tracking
|
||||
|
||||
**Problem**: Stages only showed `pending` → `completed`, never `running`.
|
||||
|
||||
**Solution**: Added `publish_pipeline_progress` calls at the start of each stage.
|
||||
|
||||
**Files Modified**: `src/worker/job_worker.rs`
|
||||
|
||||
| Stage | Line | Trigger |
|
||||
|-------|------|---------|
|
||||
| processors | ~472 | Job picked up |
|
||||
| rule1_ingestion | ~1885 | ASRX completed |
|
||||
| face_tracing | ~1991 | Face completed |
|
||||
| identity_agent | ~2223 | Seeds exist |
|
||||
| tkg_nodes + tkg_edges | ~2372 / ~2486 | Face + ASRX completed |
|
||||
| rule2_ingestion | ~2432 | TKG completed |
|
||||
|
||||
**Example**:
|
||||
```rust
|
||||
// Mark face_tracing as running
|
||||
{
|
||||
let mut pp = PipelineProgress::new(&uuid_clone);
|
||||
pp.update_stage("face_tracing", 0.0, "running", None);
|
||||
publish_pipeline_progress(redis_clone.as_ref(), &uuid_clone, &pp).await;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Initialize Stages at Registration
|
||||
|
||||
**Problem**: Stages were empty when file started processing, frontend couldn't determine active stages.
|
||||
|
||||
**Solution**: Initialize `PipelineProgress` immediately after `create_monitor_job`.
|
||||
|
||||
**Files Modified**: `src/api/files.rs:866-874`
|
||||
|
||||
```rust
|
||||
if let Ok(job) = auto_state.db.create_monitor_job(&auto_uuid, Some(vp)).await {
|
||||
// Initialize pipeline progress with all stages pending
|
||||
if let Ok(redis) = crate::core::db::RedisClient::new() {
|
||||
let pp = PipelineProgress::new(&auto_uuid);
|
||||
publish_pipeline_progress(&redis, &auto_uuid, &pp).await;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. sync-status TKG Check
|
||||
|
||||
**Problem**: `sync_file_status` only checked processors, not TKG or chunks.
|
||||
|
||||
**Solution**: Check TKG nodes, edges, and sentence chunks before marking as `completed`.
|
||||
|
||||
**Files Modified**: `src/api/files.rs:1762-1856`
|
||||
|
||||
**New Response Format**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"file_uuid": "...",
|
||||
"status": "completed",
|
||||
"processors_complete": 5,
|
||||
"processors_total": 5,
|
||||
"tkg_nodes": 123,
|
||||
"tkg_edges": 45,
|
||||
"sentence_chunks": 67,
|
||||
"is_music_only": false
|
||||
}
|
||||
```
|
||||
|
||||
**Logic**:
|
||||
- `completed` = processors_done + tkg_nodes > 0 + sentence_chunks > 0
|
||||
- For music-only files: `tkg_edges == 0` is acceptable
|
||||
- `is_music_only` = processors_done + tkg_nodes > 0 + tkg_edges == 0
|
||||
|
||||
---
|
||||
|
||||
## API Changes
|
||||
|
||||
### `/api/v1/file/:file_uuid/sync-status` Response Changes
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `tkg_nodes` | i64 | TKG node count |
|
||||
| `tkg_edges` | i64 | TKG edge count |
|
||||
| `sentence_chunks` | i64 | Rule 1 sentence chunk count |
|
||||
| `is_music_only` | bool | True if music-only file (no faces) |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Commands
|
||||
|
||||
1. **Register new file and check initial stages**:
|
||||
```bash
|
||||
# After registration, immediately check
|
||||
curl http://localhost:3002/api/v1/stats/pipeline/{file_uuid}
|
||||
|
||||
# Expected: stages array has 7 elements, all "pending"
|
||||
```
|
||||
|
||||
2. **Check "running" status during processing**:
|
||||
```bash
|
||||
# During processing, check stages
|
||||
curl http://localhost:3002/api/v1/stats/pipeline/{file_uuid}
|
||||
|
||||
# Expected: at least one stage shows "running"
|
||||
```
|
||||
|
||||
3. **Check sync-status response**:
|
||||
```bash
|
||||
curl -X POST http://localhost:3002/api/v1/file/{file_uuid}/sync-status
|
||||
|
||||
# Expected: includes tkg_nodes, tkg_edges, sentence_chunks, is_music_only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
**Environment**: Production (3002)
|
||||
**Status**: ✅ Deployed, running
|
||||
|
||||
**Implemented Files**:
|
||||
- `src/worker/job_worker.rs`: 6 处 "running" 状态更新
|
||||
- `src/api/files.rs:866`: 注册时初始化 stages
|
||||
- `src/api/files.rs:1762`: sync-status 检查 TKG + chunks
|
||||
- `src/api/files.rs:16`: 新增 imports
|
||||
|
||||
**Build**: 2026-07-24T18:43:52Z
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
### Music-Only Files
|
||||
|
||||
For files with no faces (music-only):
|
||||
- `identity_agent`: marked as "completed (no faces)"
|
||||
- `tkg_edges`: marked as "completed (no faces)"
|
||||
- `rule2_ingestion`: marked as "completed (no faces)"
|
||||
|
||||
This is handled in `src/api/scan.rs:1210-1216` and now correctly detected in `sync_file_status`.
|
||||
|
||||
---
|
||||
|
||||
## Commit
|
||||
|
||||
Branch: main
|
||||
Commit: (pending - to be committed after verification)
|
||||
|
||||
---
|
||||
|
||||
**Contact**: Core Team (OpenCode)
|
||||
50
DEPLOYMENT_TEST_REPORT.md
Normal file
50
DEPLOYMENT_TEST_REPORT.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Admin + Client 部署到 201 测试报告
|
||||
|
||||
## 部署时间:2026-06-26 16:25
|
||||
|
||||
## 已部署文件
|
||||
|
||||
| 文件 | 状态 | 大小 | 说明 |
|
||||
|------|------|------|------|
|
||||
| **LoginView.vue** | ✅ 已复制 | 3761 bytes | 登录界面 ⭐⭐⭐⭐⭐ |
|
||||
| **ClientView.vue** | ✅ 已复制 | 17240 bytes | 文件管理界面 ⭐⭐⭐⭐⭐ |
|
||||
| **router/index.ts** | ✅ 已更新 | - | 添加 /login 路由 |
|
||||
| **App.vue** | ⏳ 待更新 | - | 需添加路由守卫 |
|
||||
|
||||
## 路由配置 ✅
|
||||
|
||||
- → LoginView.vue ✅
|
||||
- → AdminView.vue ✅
|
||||
- → ClientView.vue ✅
|
||||
|
||||
## Tauri Commands ✅
|
||||
|
||||
已存在:
|
||||
- ✅
|
||||
- ✅
|
||||
- ✅
|
||||
- ✅
|
||||
|
||||
## 测试结果 ⚠️
|
||||
|
||||
### Vite dev server
|
||||
- 状态:运行中
|
||||
- 问题:node 命令找不到
|
||||
- 解决方案:需要使用绝对路径
|
||||
|
||||
### Proxy (port 8888)
|
||||
- 状态:运行中
|
||||
- 问题:前端路由返回 "Not found"
|
||||
- 原因:Vite dev server 未正确启动
|
||||
|
||||
## 下一步 ⭐⭐⭐⭐⭐
|
||||
|
||||
1. **修复 Vite 启动**:使用绝对路径
|
||||
2. **添加 App.vue 路由守卫**:检查 token 并跳转
|
||||
3. **测试完整功能**:登录 + Client 文件管理
|
||||
4. **验证 Tauri commands**:文件列表/上传/下载
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-06-26 16:25
|
||||
**状态**: 文件已部署,等待 Vite 重启 ⭐⭐⭐⭐
|
||||
292
FACE_MOVE_DIAGNOSIS_REPORT.md
Normal file
292
FACE_MOVE_DIAGNOSIS_REPORT.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# Face Move/Merge 功能诊断报告
|
||||
|
||||
**日期**: 2026-07-25
|
||||
**状态**: 已完成显示文本改进,待验证数据流
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成:显示文本改进
|
||||
|
||||
### 改进内容
|
||||
|
||||
**修改前**: `F{trace_id}`(如 `F13`)
|
||||
**修改后**: `Face{trace_id}`(如 `Face13`)
|
||||
|
||||
### 修改位置
|
||||
|
||||
| 行号 | 位置 | 修改前 | 修改后 |
|
||||
|------|------|--------|--------|
|
||||
| 143 | Face Detail Modal | `file_uuid:F13` | `file_uuid:Face13` |
|
||||
| 262 | Cluster Strip Fallback | `F13` | `Face13` |
|
||||
| 264 | Cluster Strip Label | `F13 GroupName` | `Face13 GroupName` |
|
||||
|
||||
### 保留的位置
|
||||
|
||||
**Line 105**: Frame range 显示
|
||||
```vue
|
||||
<span>F{{ t.start_frame }}–{{ t.end_frame }}</span>
|
||||
```
|
||||
- 保持不变,因为这是 Frame 编号范围
|
||||
- 示例:`F2219–2225`
|
||||
|
||||
---
|
||||
|
||||
## 🔍 数据流分析
|
||||
|
||||
### 理想数据流
|
||||
|
||||
```
|
||||
Core API
|
||||
↓
|
||||
返回: trace_ids = [13, 14, 15] (i64[])
|
||||
↓
|
||||
前端存储
|
||||
↓
|
||||
clusterTracesMap["13"] = {trace_id: 13, ...}
|
||||
↓
|
||||
用户选择
|
||||
↓
|
||||
selectedFaces.push("file_uuid:13")
|
||||
↓
|
||||
API 调用
|
||||
↓
|
||||
merge_trace(file_uuid, source_id=13, target_id=...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 关键检查点
|
||||
|
||||
#### 检查点 1: 数据存储
|
||||
|
||||
**代码位置**: PeopleView.vue Line 914-920
|
||||
```typescript
|
||||
clusterTracesMap.value = {}
|
||||
for (const t of allTraces) {
|
||||
clusterTracesMap.value[String(t.trace_id)] = t
|
||||
}
|
||||
```
|
||||
|
||||
**验证要点**:
|
||||
- ✅ `t.trace_id` 应该是 `integer`(如 13)
|
||||
- ✅ `String(t.trace_id)` 应该是 `"13"`(不是 `"Face13"`)
|
||||
|
||||
---
|
||||
|
||||
#### 检查点 2: 用户选择
|
||||
|
||||
**代码位置**: PeopleView.vue Line 1507-1515
|
||||
```typescript
|
||||
function toggleFaceSelection(t: any) {
|
||||
const key = `${t.file_uuid}:${t.trace_id}`
|
||||
const idx = selectedFaces.value.indexOf(key)
|
||||
if (idx >= 0) {
|
||||
selectedFaces.value.splice(idx, 1)
|
||||
} else {
|
||||
selectedFaces.value.push(key)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**验证要点**:
|
||||
- ✅ `t.trace_id` 应该是 `integer`(如 13)
|
||||
- ✅ `key` 应该是 `"file_uuid:13"`(不是 `"file_uuid:Face13"`)
|
||||
|
||||
---
|
||||
|
||||
#### 检查点 3: API 调用
|
||||
|
||||
**代码位置**: PeopleView.vue Line 1810-1827
|
||||
```typescript
|
||||
const traceList: { file_uuid: string; trace_id: number }[] = []
|
||||
for (const key of selectedFaces.value) {
|
||||
const [file_uuid, trace_id] = key.split(':')
|
||||
traceList.push({ file_uuid, trace_id: parseInt(trace_id) })
|
||||
}
|
||||
|
||||
for (const t of traceList) {
|
||||
await apiCall('merge_trace', {
|
||||
file_uuid: t.file_uuid,
|
||||
source_id: t.trace_id,
|
||||
target_id: targetTraceId
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**验证要点**:
|
||||
- ✅ `key.split(':')` 应该得到 `["file_uuid", "13"]`
|
||||
- ✅ `parseInt("13")` 应该是 `13`(不是 `NaN`)
|
||||
- ✅ API 参数应该正确
|
||||
|
||||
---
|
||||
|
||||
## 🧪 浏览器验证方案
|
||||
|
||||
### 方案 1: 检查 selectedFaces 格式
|
||||
|
||||
```javascript
|
||||
// 1. 打开 PeopleView 页面
|
||||
// 2. 选择一个 Face(如 Face13)
|
||||
// 3. 打开浏览器控制台,执行:
|
||||
|
||||
const app = document.querySelector('#app').__vueParentComponent
|
||||
const peopleView = app.ctx.$root.$children[0]
|
||||
|
||||
// 检查 selectedFaces
|
||||
console.log('=== selectedFaces 内容 ===')
|
||||
console.log('selectedFaces:', peopleView.selectedFaces)
|
||||
console.log('First key:', peopleView.selectedFaces[0])
|
||||
|
||||
// 检查 key 格式
|
||||
const [file_uuid, trace_id_str] = peopleView.selectedFaces[0].split(':')
|
||||
console.log('file_uuid:', file_uuid)
|
||||
console.log('trace_id (string):', trace_id_str)
|
||||
console.log('trace_id (parsed):', parseInt(trace_id_str))
|
||||
|
||||
// 预期结果:
|
||||
// selectedFaces = ["84d838f260e1881a0daa55fabbc8e434:13"]
|
||||
// trace_id (string) = "13"
|
||||
// trace_id (parsed) = 13
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方案 2: 检查数据源
|
||||
|
||||
```javascript
|
||||
// 检查 clusterTracesMap
|
||||
console.log('=== clusterTracesMap ===')
|
||||
console.log('Keys:', Object.keys(peopleView.clusterTracesMap))
|
||||
const firstKey = Object.keys(peopleView.clusterTracesMap)[0]
|
||||
console.log('First key:', firstKey)
|
||||
console.log('Data:', peopleView.clusterTracesMap[firstKey])
|
||||
|
||||
// 检查 trace_id 类型
|
||||
const trace = peopleView.clusterTracesMap[firstKey]
|
||||
console.log('trace_id:', trace.trace_id, typeof trace.trace_id)
|
||||
|
||||
// 预期结果:
|
||||
// Keys = ["13", "14", "15"]
|
||||
// First key = "13"
|
||||
// trace_id = 13 (number)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方案 3: 测试 Move 功能
|
||||
|
||||
```javascript
|
||||
// 1. 选择几个 Faces
|
||||
// 2. 点击 Move 按钮
|
||||
// 3. 选择目标 Group
|
||||
// 4. 打开 Network 面板,观察请求
|
||||
|
||||
// 预期结果:
|
||||
// URL: POST /api/v1/file/84d8.../trace/13/merge/142
|
||||
// 参数正确,API 调用成功
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 潜在问题假设
|
||||
|
||||
### 假设 1: 数据源错误
|
||||
|
||||
**问题**: Core API 返回的 `trace_id` 格式不正确
|
||||
|
||||
**验证**: 检查 Core API 响应
|
||||
```bash
|
||||
curl "http://localhost:3002/api/v1/file/84d838f260e1881a0daa55fabbc8e434/face-groups"
|
||||
```
|
||||
|
||||
**预期**: `trace_ids` 应该是 `[13, 14, 15]`(integer array)
|
||||
|
||||
---
|
||||
|
||||
### 假设 2: 显示文本污染数据
|
||||
|
||||
**问题**: 显示文本 `"Face13"` 污染了数据层
|
||||
|
||||
**验证**: 检查 `selectedFaces` 内容
|
||||
- 如果发现 `"file_uuid:Face13"`,说明数据被污染
|
||||
- 应该改为 `"file_uuid:13"`
|
||||
|
||||
---
|
||||
|
||||
### 假设 3: 前端转换错误
|
||||
|
||||
**问题**: 前端某处错误地处理了 `trace_id`
|
||||
|
||||
**验证**: 检查所有 `trace_id` 相关代码
|
||||
- 数据加载处
|
||||
- 用户选择处
|
||||
- API 调用处
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已确认的正确实现
|
||||
|
||||
### Core API 规格
|
||||
|
||||
**Endpoint**: `POST /api/v1/file/:file_uuid/trace/:trace_id/merge/:target_trace_id`
|
||||
|
||||
**参数类型**:
|
||||
- `file_uuid`: `string`(无连字符)
|
||||
- `trace_id`: `integer`
|
||||
- `target_trace_id`: `integer`
|
||||
|
||||
**示例**:
|
||||
```bash
|
||||
POST /api/v1/file/c36f35685177c981aa139b66bbbccc5b/trace/142/merge/396
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Face Group 数据结构
|
||||
|
||||
**Endpoint**: `GET /api/v1/file/:file_uuid/face-groups`
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"face_groups": [{
|
||||
"group_id": 1,
|
||||
"trace_ids": [13, 14, 15], // i64[] - integer array
|
||||
"trace_count": 3,
|
||||
"representative_trace": 13
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 下一步行动
|
||||
|
||||
### 立即执行
|
||||
|
||||
1. **浏览器验证**: 使用上述验证方案检查 `selectedFaces` 格式
|
||||
2. **数据流追踪**: 如果发现问题,定位具体污染位置
|
||||
3. **API 测试**: 测试 Move 功能是否正常工作
|
||||
|
||||
---
|
||||
|
||||
### 如果发现问题
|
||||
|
||||
1. **数据污染**: 修复数据存储逻辑
|
||||
2. **API 参数错误**: 修正 API 调用参数
|
||||
3. **其他问题**: 根据验证结果修复
|
||||
|
||||
---
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
**Studio Team**: 已完成显示文本改进
|
||||
**Core Team**: 提供 API 规格确认
|
||||
|
||||
**测试环境**: `http://localhost:5173`
|
||||
**API 环境**: `http://localhost:3002`
|
||||
|
||||
---
|
||||
|
||||
**生成时间**: 2026-07-25
|
||||
**状态**: 显示改进已完成,等待数据流验证
|
||||
BIN
data/users/demo.sqlite
Normal file
BIN
data/users/demo.sqlite
Normal file
Binary file not shown.
BIN
data/users/demo.sqlite-shm
Normal file
BIN
data/users/demo.sqlite-shm
Normal file
Binary file not shown.
1
dist/assets/index-B7dM6B6s.css
vendored
1
dist/assets/index-B7dM6B6s.css
vendored
File diff suppressed because one or more lines are too long
25
dist/assets/index-BXXbgmgw.js
vendored
25
dist/assets/index-BXXbgmgw.js
vendored
File diff suppressed because one or more lines are too long
13
dist/index.html
vendored
13
dist/index.html
vendored
@@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Momentry Studio</title>
|
||||
<script type="module" crossorigin src="/assets/index-BXXbgmgw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B7dM6B6s.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
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
|
||||
109
node_modules/.package-lock.json
generated
vendored
109
node_modules/.package-lock.json
generated
vendored
@@ -67,6 +67,50 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/core-base": {
|
||||
"version": "9.14.5",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.5.tgz",
|
||||
"integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/message-compiler": "9.14.5",
|
||||
"@intlify/shared": "9.14.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/message-compiler": {
|
||||
"version": "9.14.5",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz",
|
||||
"integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/shared": "9.14.5",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/shared": {
|
||||
"version": "9.14.5",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.5.tgz",
|
||||
"integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -104,6 +148,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
||||
@@ -289,6 +339,44 @@
|
||||
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz",
|
||||
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "14.3.0",
|
||||
"@vueuse/shared": "14.3.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz",
|
||||
"integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz",
|
||||
"integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/alien-signals": {
|
||||
"version": "1.0.13",
|
||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
|
||||
@@ -721,6 +809,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-i18n": {
|
||||
"version": "9.14.5",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz",
|
||||
"integrity": "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==",
|
||||
"deprecated": "v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "9.14.5",
|
||||
"@intlify/shared": "9.14.5",
|
||||
"@vue/devtools-api": "^6.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.6.4",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
|
||||
29
node_modules/.vite/deps/_metadata.json
generated
vendored
29
node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -1,29 +1,44 @@
|
||||
{
|
||||
"hash": "4fedd820",
|
||||
"configHash": "912a7b44",
|
||||
"lockfileHash": "8417729c",
|
||||
"browserHash": "686c2332",
|
||||
"hash": "813ccdf0",
|
||||
"configHash": "5b9e4b03",
|
||||
"lockfileHash": "ac269d47",
|
||||
"browserHash": "2eabb9c1",
|
||||
"optimized": {
|
||||
"@tauri-apps/api/core": {
|
||||
"src": "../../@tauri-apps/api/core.js",
|
||||
"file": "@tauri-apps_api_core.js",
|
||||
"fileHash": "e45d7d78",
|
||||
"fileHash": "3969e853",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue": {
|
||||
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
|
||||
"file": "vue.js",
|
||||
"fileHash": "7f9396bc",
|
||||
"fileHash": "b283492a",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue-router": {
|
||||
"src": "../../vue-router/dist/vue-router.mjs",
|
||||
"file": "vue-router.js",
|
||||
"fileHash": "d8343f48",
|
||||
"fileHash": "0528c0df",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue-i18n": {
|
||||
"src": "../../vue-i18n/dist/vue-i18n.mjs",
|
||||
"file": "vue-i18n.js",
|
||||
"fileHash": "b2bd439f",
|
||||
"needsInterop": false
|
||||
},
|
||||
"@vueuse/core": {
|
||||
"src": "../../@vueuse/core/dist/index.js",
|
||||
"file": "@vueuse_core.js",
|
||||
"fileHash": "db16229f",
|
||||
"needsInterop": false
|
||||
}
|
||||
},
|
||||
"chunks": {
|
||||
"chunk-YFT6OQ5R": {
|
||||
"file": "chunk-YFT6OQ5R.js"
|
||||
},
|
||||
"chunk-5G6OTI3J": {
|
||||
"file": "chunk-5G6OTI3J.js"
|
||||
}
|
||||
|
||||
161
node_modules/.vite/deps/vue-router.js
generated
vendored
161
node_modules/.vite/deps/vue-router.js
generated
vendored
@@ -1,3 +1,6 @@
|
||||
import {
|
||||
setupDevtoolsPlugin
|
||||
} from "./chunk-YFT6OQ5R.js";
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
@@ -18,164 +21,6 @@ import {
|
||||
watchEffect
|
||||
} from "./chunk-5G6OTI3J.js";
|
||||
|
||||
// node_modules/@vue/devtools-api/lib/esm/env.js
|
||||
function getDevtoolsGlobalHook() {
|
||||
return getTarget().__VUE_DEVTOOLS_GLOBAL_HOOK__;
|
||||
}
|
||||
function getTarget() {
|
||||
return typeof navigator !== "undefined" && typeof window !== "undefined" ? window : typeof globalThis !== "undefined" ? globalThis : {};
|
||||
}
|
||||
var isProxyAvailable = typeof Proxy === "function";
|
||||
|
||||
// node_modules/@vue/devtools-api/lib/esm/const.js
|
||||
var HOOK_SETUP = "devtools-plugin:setup";
|
||||
var HOOK_PLUGIN_SETTINGS_SET = "plugin:settings:set";
|
||||
|
||||
// node_modules/@vue/devtools-api/lib/esm/time.js
|
||||
var supported;
|
||||
var perf;
|
||||
function isPerformanceSupported() {
|
||||
var _a;
|
||||
if (supported !== void 0) {
|
||||
return supported;
|
||||
}
|
||||
if (typeof window !== "undefined" && window.performance) {
|
||||
supported = true;
|
||||
perf = window.performance;
|
||||
} else if (typeof globalThis !== "undefined" && ((_a = globalThis.perf_hooks) === null || _a === void 0 ? void 0 : _a.performance)) {
|
||||
supported = true;
|
||||
perf = globalThis.perf_hooks.performance;
|
||||
} else {
|
||||
supported = false;
|
||||
}
|
||||
return supported;
|
||||
}
|
||||
function now() {
|
||||
return isPerformanceSupported() ? perf.now() : Date.now();
|
||||
}
|
||||
|
||||
// node_modules/@vue/devtools-api/lib/esm/proxy.js
|
||||
var ApiProxy = class {
|
||||
constructor(plugin, hook) {
|
||||
this.target = null;
|
||||
this.targetQueue = [];
|
||||
this.onQueue = [];
|
||||
this.plugin = plugin;
|
||||
this.hook = hook;
|
||||
const defaultSettings = {};
|
||||
if (plugin.settings) {
|
||||
for (const id in plugin.settings) {
|
||||
const item = plugin.settings[id];
|
||||
defaultSettings[id] = item.defaultValue;
|
||||
}
|
||||
}
|
||||
const localSettingsSaveId = `__vue-devtools-plugin-settings__${plugin.id}`;
|
||||
let currentSettings = Object.assign({}, defaultSettings);
|
||||
try {
|
||||
const raw = localStorage.getItem(localSettingsSaveId);
|
||||
const data = JSON.parse(raw);
|
||||
Object.assign(currentSettings, data);
|
||||
} catch (e) {
|
||||
}
|
||||
this.fallbacks = {
|
||||
getSettings() {
|
||||
return currentSettings;
|
||||
},
|
||||
setSettings(value) {
|
||||
try {
|
||||
localStorage.setItem(localSettingsSaveId, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
}
|
||||
currentSettings = value;
|
||||
},
|
||||
now() {
|
||||
return now();
|
||||
}
|
||||
};
|
||||
if (hook) {
|
||||
hook.on(HOOK_PLUGIN_SETTINGS_SET, (pluginId, value) => {
|
||||
if (pluginId === this.plugin.id) {
|
||||
this.fallbacks.setSettings(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.proxiedOn = new Proxy({}, {
|
||||
get: (_target, prop) => {
|
||||
if (this.target) {
|
||||
return this.target.on[prop];
|
||||
} else {
|
||||
return (...args) => {
|
||||
this.onQueue.push({
|
||||
method: prop,
|
||||
args
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
this.proxiedTarget = new Proxy({}, {
|
||||
get: (_target, prop) => {
|
||||
if (this.target) {
|
||||
return this.target[prop];
|
||||
} else if (prop === "on") {
|
||||
return this.proxiedOn;
|
||||
} else if (Object.keys(this.fallbacks).includes(prop)) {
|
||||
return (...args) => {
|
||||
this.targetQueue.push({
|
||||
method: prop,
|
||||
args,
|
||||
resolve: () => {
|
||||
}
|
||||
});
|
||||
return this.fallbacks[prop](...args);
|
||||
};
|
||||
} else {
|
||||
return (...args) => {
|
||||
return new Promise((resolve) => {
|
||||
this.targetQueue.push({
|
||||
method: prop,
|
||||
args,
|
||||
resolve
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
async setRealTarget(target) {
|
||||
this.target = target;
|
||||
for (const item of this.onQueue) {
|
||||
this.target.on[item.method](...item.args);
|
||||
}
|
||||
for (const item of this.targetQueue) {
|
||||
item.resolve(await this.target[item.method](...item.args));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// node_modules/@vue/devtools-api/lib/esm/index.js
|
||||
function setupDevtoolsPlugin(pluginDescriptor, setupFn) {
|
||||
const descriptor = pluginDescriptor;
|
||||
const target = getTarget();
|
||||
const hook = getDevtoolsGlobalHook();
|
||||
const enableProxy = isProxyAvailable && descriptor.enableEarlyProxy;
|
||||
if (hook && (target.__VUE_DEVTOOLS_PLUGIN_API_AVAILABLE__ || !enableProxy)) {
|
||||
hook.emit(HOOK_SETUP, pluginDescriptor, setupFn);
|
||||
} else {
|
||||
const proxy = enableProxy ? new ApiProxy(descriptor, hook) : null;
|
||||
const list = target.__VUE_DEVTOOLS_PLUGINS__ = target.__VUE_DEVTOOLS_PLUGINS__ || [];
|
||||
list.push({
|
||||
pluginDescriptor: descriptor,
|
||||
setupFn,
|
||||
proxy
|
||||
});
|
||||
if (proxy) {
|
||||
setupFn(proxy.proxiedTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// node_modules/vue-router/dist/devtools-EWN81iOl.mjs
|
||||
var isBrowser = typeof document !== "undefined";
|
||||
function isRouteComponent(component) {
|
||||
|
||||
6
node_modules/.vite/deps/vue-router.js.map
generated
vendored
6
node_modules/.vite/deps/vue-router.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@@ -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 ""
|
||||
|
||||
73
setup-gitea-ci.sh
Executable file
73
setup-gitea-ci.sh
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
GITEA_URL="http://localhost:3000"
|
||||
REPO_NAME="momentry_studio"
|
||||
|
||||
echo "========================================="
|
||||
echo " 設定 Gitea CI/CD"
|
||||
echo "========================================="
|
||||
|
||||
# 檢查 Token
|
||||
if [ -z "$LOCAL_GITEA_TOKEN" ]; then
|
||||
echo "❌ 請先設定 LOCAL_GITEA_TOKEN"
|
||||
echo " export LOCAL_GITEA_TOKEN=\"your-token\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Token 已設定"
|
||||
|
||||
# 1. 建立倉庫
|
||||
echo ""
|
||||
echo "📁 Step 1: 建立 Gitea 倉庫..."
|
||||
REPO_EXISTS=$(curl -s -H "Authorization: token $LOCAL_GITEA_TOKEN" "$GITEA_URL/api/v1/repos/admin/$REPO_NAME" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$REPO_EXISTS" ]; then
|
||||
curl -s -X POST "$GITEA_URL/api/v1/user/repos" \
|
||||
-H "Authorization: token $LOCAL_GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"$REPO_NAME\",\"private\":false,\"auto_init\":false}"
|
||||
echo "✅ 倉庫已建立"
|
||||
else
|
||||
echo "倉庫已存在"
|
||||
fi
|
||||
|
||||
# 2. 設定 Git 遠端
|
||||
echo ""
|
||||
echo "🔗 Step 2: 設定 Git 遠端..."
|
||||
git remote remove gitea 2>/dev/null || true
|
||||
git remote add gitea "$GITEA_URL/admin/$REPO_NAME.git"
|
||||
echo "✅ 遠端已設定"
|
||||
|
||||
# 3. 推送程式碼
|
||||
echo ""
|
||||
echo "📤 Step 3: 推送程式碼..."
|
||||
git push -u gitea main -f 2>&1 | tail -3
|
||||
echo "✅ 程式碼已推送"
|
||||
|
||||
# 4. 建立 Webhook
|
||||
echo ""
|
||||
echo "🔔 Step 4: 建立 Webhook..."
|
||||
curl -s -X POST "$GITEA_URL/api/v1/repos/admin/$REPO_NAME/hooks" \
|
||||
-H "Authorization: token $LOCAL_GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"type\": \"gitea\",
|
||||
\"active\": true,
|
||||
\"config\": {
|
||||
\"content_type\": \"json\",
|
||||
\"url\": \"http://localhost:8080/webhook\",
|
||||
\"is_insecure_ssl\": false
|
||||
},
|
||||
\"events\": [\"push\"]
|
||||
}" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Webhook ID: {d.get(\"id\",\"?\")}' 2>/dev/null || echo 'Webhook 建立失敗')" || echo "Webhook 需要 CI Server"
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo " ✅ Gitea CI/CD 設定完成"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo "倉庫網址: $GITEA_URL/admin/$REPO_NAME"
|
||||
echo ""
|
||||
echo "手動觸發 CI/CD:"
|
||||
echo " ./ci-cd.sh"
|
||||
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 = [] }
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
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>
|
||||
|
||||
99
src/App.vue.backup_final
Normal file
99
src/App.vue.backup_final
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div id="app" class="ms-app" :data-current-login="'guest'">
|
||||
<aside class="ms-side">
|
||||
<div class="gs-logo">Workspace</div>
|
||||
<nav class="gs-nav">
|
||||
<router-link to="/search" class="gs-nav-item" active-class="active">
|
||||
<img class="gs-nav-icon" src="/icons/Search.png" alt="">
|
||||
<span>Search</span>
|
||||
</router-link>
|
||||
<router-link to="/library" class="gs-nav-item" active-class="active">
|
||||
<img class="gs-nav-icon" src="/icons/Library.png" alt="">
|
||||
<span>Library</span>
|
||||
</router-link>
|
||||
<router-link to="/people" class="gs-nav-item" active-class="active">
|
||||
<img class="gs-nav-icon" src="/icons/People.png" alt="">
|
||||
<span>People</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>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>Client</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
<div class="gs-divider"></div>
|
||||
<div class="gs-footer">
|
||||
<div class="gs-theme-switcher">
|
||||
<button class="gs-theme-btn active">☀️</button>
|
||||
<button class="gs-theme-btn">🌙</button>
|
||||
<button class="gs-theme-btn">🌗</button>
|
||||
</div>
|
||||
<div class="gs-account"><span class="gs-account-name">Demo</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="ms-main">
|
||||
<div class="ms-content"><router-view /></div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const appZoom = ref(100)
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey)) {
|
||||
if (e.key === '=' || e.key === '+') {
|
||||
e.preventDefault()
|
||||
appZoom.value = Math.min(appZoom.value + 10, 300)
|
||||
document.documentElement.style.zoom = appZoom.value + '%'
|
||||
} else if (e.key === '-') {
|
||||
e.preventDefault()
|
||||
appZoom.value = Math.max(appZoom.value - 10, 50)
|
||||
document.documentElement.style.zoom = appZoom.value + '%'
|
||||
} else if (e.key === '0') {
|
||||
e.preventDefault()
|
||||
appZoom.value = 100
|
||||
document.documentElement.style.zoom = '100%'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', handleKeydown))
|
||||
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import './assets/wordpress-exact.css';
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'DM Sans', 'Noto Sans TC', -apple-system, BlinkMacSystemFont, sans-serif; background: #fff; color: #202124; }
|
||||
#app { display: flex; max-width: 1400px; margin: 0 auto; padding: 28px; gap: 28px; min-height: 100vh; box-sizing: border-box; align-items: stretch; }
|
||||
.ms-side { width: 240px; min-width: 240px; flex-shrink: 0; background: #fff; border-radius: 18px; padding: 18px 14px; box-shadow: 0 16px 38px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.06); display: flex; flex-direction: column; position: relative; height: calc(100vh - 125px); align-self: flex-start; overflow: visible; }
|
||||
.gs-logo { font-size: 15px; font-weight: 600; color: #202124; padding: 8px 12px 16px 12px; flex-shrink: 0; }
|
||||
.gs-nav { display: flex; flex-direction: column; gap: 4px; flex-shrink: 0; }
|
||||
.gs-nav-item { display: flex; align-items: center; gap: 12px; height: 48px; padding: 0 12px; border-radius: 10px; text-decoration: none; color: #3c4043; font-weight: 600; font-size: 18px; line-height: 1; background: transparent; box-sizing: border-box; }
|
||||
.gs-nav-item:hover { background: var(--ms-accent-soft); color: var(--ms-accent-text); }
|
||||
.gs-nav-item.active { background: var(--ms-accent-soft) !important; color: var(--ms-accent-text) !important; font-weight: 600; }
|
||||
.gs-nav-icon { width: 24px; height: 24px; object-fit: contain; flex-shrink: 0; }
|
||||
.gs-divider { height: 1px; background: #eee; margin: 14px 8px; flex-shrink: 0; }
|
||||
.gs-footer { margin-top: auto; padding: 10px 12px; border-radius: 12px; display: flex; align-items: center; gap: 10px; background: #f8f9fa; flex-shrink: 0; }
|
||||
.gs-theme-switcher { display: flex; gap: 5px; }
|
||||
.gs-theme-btn { width: 14px; height: 14px; border-radius: 50%; border: 1px solid rgba(0,0,0,.08); background: #f3f3f3; cursor: pointer; padding: 0; }
|
||||
.gs-theme-btn:hover { transform: scale(1.08); }
|
||||
.gs-theme-btn.active { box-shadow: 0 0 0 2px #fff, 0 0 0 3px rgba(0,0,0,.16); }
|
||||
.gs-account-name { font-size: 12px; color: #5f6368; }
|
||||
.ms-main { flex: 1; min-width: 0; background: #fff; border-radius: 18px; padding: 36px 42px; overflow-y: auto; overflow-x: hidden; }
|
||||
.ms-content { max-width: 1200px; }
|
||||
</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
|
||||
}
|
||||
|
||||
33
src/api/params.ts
Normal file
33
src/api/params.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const PARAM_MAP: Record<string, string> = {
|
||||
pageSize: 'page_size',
|
||||
perPage: 'per_page',
|
||||
fileUuid: 'file_uuid',
|
||||
startTime: 'start_time',
|
||||
endTime: 'end_time',
|
||||
startFrame: 'start_frame',
|
||||
endFrame: 'end_frame',
|
||||
bboxX: 'bbox_x',
|
||||
bboxY: 'bbox_y',
|
||||
bboxW: 'bbox_w',
|
||||
bboxH: 'bbox_h',
|
||||
conversationId: 'conversation_id',
|
||||
historyId: 'history_id',
|
||||
mergeId: 'merge_id',
|
||||
traceId: 'trace_id',
|
||||
faceId: 'face_id',
|
||||
}
|
||||
|
||||
export function toSnakeCase(key: string): string {
|
||||
if (PARAM_MAP[key]) return PARAM_MAP[key]
|
||||
return key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
|
||||
}
|
||||
|
||||
export function mapParams(params: Record<string, any>): Record<string, any> {
|
||||
const result: Record<string, any> = {}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
result[toSnakeCase(key)] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
59
src/api/types.ts
Normal file
59
src/api/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export interface PoseKeypoint {
|
||||
name: string
|
||||
x: number
|
||||
y: number
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
export type PoseClass = 'standing' | 'sitting' | 'kneeling' | 'lying' | 'prone' | 'unknown'
|
||||
|
||||
export interface PoseResponse {
|
||||
frame: number
|
||||
keypoints: PoseKeypoint[]
|
||||
pose_class: PoseClass
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
export interface DominantColor {
|
||||
rgb: [number, number, number]
|
||||
percentage?: number
|
||||
}
|
||||
|
||||
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; }
|
||||
|
||||
216
src/components/AgentTip.vue
Normal file
216
src/components/AgentTip.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<transition name="slide-up">
|
||||
<div class="agent-tip" v-if="showAgentTip">
|
||||
<div class="agent-avatar">🤖</div>
|
||||
<div class="agent-message">{{ agentTip }}</div>
|
||||
<button class="agent-close" @click="showAgentTip = false">×</button>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<transition name="fade">
|
||||
<div class="discovery-tip" v-if="discoveryTip">
|
||||
<span class="discovery-icon">✨</span>
|
||||
<span class="discovery-message">{{ discoveryTip }}</span>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { showAgentTip, agentTip, discoveryTip } from '@/stores/agentStore'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.agent-tip {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.3);
|
||||
z-index: 9999;
|
||||
animation: slideInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.agent-avatar {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.agent-message {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.agent-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.agent-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.discovery-tip {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #0ea5e9;
|
||||
color: #0c4a6e;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
z-index: 9998;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.discovery-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.welcome-guide {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.guide-content {
|
||||
background: white;
|
||||
padding: 32px 40px;
|
||||
border-radius: 16px;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.guide-content h2 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 24px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.guide-content > p {
|
||||
color: #6b7280;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.guide-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.guide-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.guide-icon {
|
||||
font-size: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.guide-content-text h4 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 16px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.guide-content-text p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.guide-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.guide-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 32px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.guide-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.slide-up-enter-active {
|
||||
animation: slideInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.slide-up-leave-active {
|
||||
animation: slideOutDown 0.3s ease-in;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutDown {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
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
|
||||
};
|
||||
}
|
||||
12
src/composables/useTheme.ts
Normal file
12
src/composables/useTheme.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useDark, useToggle } from '@vueuse/core'
|
||||
|
||||
export const isDark = useDark({
|
||||
selector: 'html',
|
||||
attribute: 'data-theme',
|
||||
valueDark: 'dark',
|
||||
valueLight: 'light',
|
||||
})
|
||||
|
||||
export const toggleDark = () => {
|
||||
isDark.value = !isDark.value
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
35
src/composables/useZIndex.ts
Normal file
35
src/composables/useZIndex.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
let baseZIndex = 10000
|
||||
const stack = ref<string[]>([])
|
||||
|
||||
export function useZIndex() {
|
||||
const push = (id: string): number => {
|
||||
if (!stack.value.includes(id)) {
|
||||
stack.value.push(id)
|
||||
}
|
||||
return baseZIndex + stack.value.indexOf(id) * 10
|
||||
}
|
||||
|
||||
const pop = (id: string) => {
|
||||
const idx = stack.value.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
stack.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const current = computed(() => baseZIndex + stack.value.length * 10)
|
||||
|
||||
return { push, pop, current }
|
||||
}
|
||||
|
||||
let globalZIndex = 10000
|
||||
|
||||
export function getNextZIndex(): number {
|
||||
globalZIndex += 10
|
||||
return globalZIndex
|
||||
}
|
||||
|
||||
export function resetZIndex() {
|
||||
globalZIndex = 10000
|
||||
}
|
||||
454
src/locales/en.json
Normal file
454
src/locales/en.json
Normal file
@@ -0,0 +1,454 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"delete": "Delete",
|
||||
"save": "Save",
|
||||
"edit": "Edit",
|
||||
"search": "Search",
|
||||
"back": "Back",
|
||||
"close": "Close",
|
||||
"play": "Play",
|
||||
"logout": "Logout",
|
||||
"login": "Login"
|
||||
},
|
||||
"library": {
|
||||
"sort": {
|
||||
"title": "Sort By",
|
||||
"time_desc": "Time (Newest First)",
|
||||
"time_asc": "Time (Oldest First)",
|
||||
"size_desc": "Size (Largest First)",
|
||||
"size_asc": "Size (Smallest First)",
|
||||
"name_asc": "Name (A to Z)",
|
||||
"name_desc": "Name (Z to A)"
|
||||
},
|
||||
"filter": {
|
||||
"title": "Filter By",
|
||||
"unregistered": "Unregistered",
|
||||
"registered": "Registered",
|
||||
"pending": "Pending",
|
||||
"completed": "Completed",
|
||||
"videos_only": "Videos Only",
|
||||
"photos_only": "Photos Only"
|
||||
},
|
||||
"size": {
|
||||
"title": "Size",
|
||||
"min_mb": "Min MB",
|
||||
"max_mb": "Max MB"
|
||||
},
|
||||
"duration": {
|
||||
"title": "Video Duration",
|
||||
"min_minutes": "Min Minutes",
|
||||
"max_minutes": "Max Minutes"
|
||||
},
|
||||
"status": {
|
||||
"files": "{count} files",
|
||||
"registered": "Registered",
|
||||
"unregistered": "Unregistered",
|
||||
"pending": "Pending",
|
||||
"preparing": "Preparing",
|
||||
"processing": "Processing",
|
||||
"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"
|
||||
},
|
||||
"context": {
|
||||
"register_file": "Register File",
|
||||
"process_file": "Process File",
|
||||
"reprocess": "Reprocess",
|
||||
"unregister": "Unregister",
|
||||
"play_video": "Play Video",
|
||||
"processor_selection": "Processor Selection",
|
||||
"overall_progress": "Overall Progress",
|
||||
"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",
|
||||
"face_deduplication": "Face Deduplication",
|
||||
"qc_check": "QC Check"
|
||||
},
|
||||
"pipeline": {
|
||||
"processors": "Processors",
|
||||
"rule1_ingestion": "Rule1 Ingestion",
|
||||
"face_tracing": "Face Tracing",
|
||||
"tkg_nodes": "TKG Nodes",
|
||||
"tkg_edges": "TKG Edges",
|
||||
"rule2_ingestion": "Rule2 Ingestion"
|
||||
},
|
||||
"phase": {
|
||||
"processor_output": "Rule1: Processor Output",
|
||||
"postgresql_ingestion": "Rule1: PostgreSQL Ingestion",
|
||||
"qdrant_ingestion": "Rule1: Qdrant Ingestion"
|
||||
},
|
||||
"action": {
|
||||
"registering": "Registering...",
|
||||
"registered": "Registered: {name}",
|
||||
"registration_failed": "Registration failed: {message}",
|
||||
"registration_error": "Registration error: {error}",
|
||||
"processing": "Processing...",
|
||||
"processing_started": "Processing started: {name}",
|
||||
"processing_failed": "Processing failed: {message}",
|
||||
"processing_error": "Processing 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",
|
||||
"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": {
|
||||
"all_files": "All Files",
|
||||
"view_all_people": "View All People",
|
||||
"view_starred_people": "View Starred People",
|
||||
"refresh": "Refresh",
|
||||
"pending_people": "People",
|
||||
"skipped_people": "Skipped People",
|
||||
"pending_faces": "Faces",
|
||||
"face_group": "Face Group"
|
||||
},
|
||||
"section": {
|
||||
"known_people": "Known People",
|
||||
"pending_people": "People",
|
||||
"skipped_people": "Skipped People",
|
||||
"pending_faces": "Faces",
|
||||
"face_group": "Face Group",
|
||||
"unassigned": "Unassigned"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Sort By",
|
||||
"recent": "Recently Viewed",
|
||||
"file_name": "File Name",
|
||||
"name_az": "Name (A to Z)",
|
||||
"name_za": "Name (Z to A)",
|
||||
"time_desc": "Time (Newest First)",
|
||||
"time_asc": "Time (Oldest First)"
|
||||
},
|
||||
"search": {
|
||||
"known_people": "Search known people",
|
||||
"pending_people": "Search pending people",
|
||||
"person_name": "Search person name..."
|
||||
},
|
||||
"action_history": {
|
||||
"title": "Action History · {name}",
|
||||
"undo_all": "Undo All",
|
||||
"redo_all": "Redo All",
|
||||
"delete_person": "Delete This Person",
|
||||
"no_history": "No action history"
|
||||
},
|
||||
"expand": {
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"view_faces": "View Faces",
|
||||
"appears_in": "Appears in:",
|
||||
"faces": "{count} faces",
|
||||
"set_as_profile": "Set as Profile",
|
||||
"no_face_data": "No face data"
|
||||
},
|
||||
"context": {
|
||||
"star_person": "★ Mark as Important",
|
||||
"unstar_person": "☆ Remove Important",
|
||||
"details": "📋 Details",
|
||||
"confirm_person": "✓ Confirm Person",
|
||||
"pending": "Pending",
|
||||
"edit_name": "✎ Edit Name",
|
||||
"merge_person": "⇄ Already Have This Person",
|
||||
"skip_person": "✕ Skip This Person",
|
||||
"undo": "↩ Undo",
|
||||
"redo": "↪ Redo",
|
||||
"delete_person": "🗑 Delete This Person"
|
||||
},
|
||||
"face_context": {
|
||||
"details": "📋 Details",
|
||||
"assign_existing": "⇄ Assign to Existing Person",
|
||||
"skip_face": "✕ Skip This Face"
|
||||
},
|
||||
"assign": {
|
||||
"title": "Assign to Existing Person",
|
||||
"search_placeholder": "Search person name...",
|
||||
"no_results": "No matching people found",
|
||||
"confirm_assign": "Confirm Assignment"
|
||||
},
|
||||
"face_detail": {
|
||||
"title": "Pending Face Details",
|
||||
"file": "File:",
|
||||
"trace_id": "Trace ID:",
|
||||
"frame_number": "Frame Number:",
|
||||
"confidence": "Confidence:",
|
||||
"face_id": "Face ID:",
|
||||
"play_segment": "Play Video Segment",
|
||||
"new_identity": "Create as New Person",
|
||||
"enter_name": "Enter person name...",
|
||||
"add": "Add",
|
||||
"close": "Close"
|
||||
},
|
||||
"detail": {
|
||||
"back": "Back",
|
||||
"edit": "Edit",
|
||||
"role": "Role",
|
||||
"description": "Description",
|
||||
"enter_name": "Enter name",
|
||||
"enter_alias": "Enter alias and press Enter",
|
||||
"add_alias": "+ Alias",
|
||||
"collapse": "Collapse",
|
||||
"role_name": "Role name",
|
||||
"custom_description": "Custom description",
|
||||
"saving": "Saving...",
|
||||
"save_changes": "✓ Save Changes",
|
||||
"cancel": "Cancel",
|
||||
"add_same_person": "Add same person",
|
||||
"play": "▶ Play",
|
||||
"unbind": "✕ Unbind",
|
||||
"segments": "{count} segments",
|
||||
"traces": "{count} traces",
|
||||
"all_segments": "All segments",
|
||||
"mode_trace": "Trace",
|
||||
"mode_segment": "Segment",
|
||||
"mode_continuous": "Continuous",
|
||||
"status": "Status:",
|
||||
"confirm": "✓ Confirm",
|
||||
"skip": "Skip",
|
||||
"bind_face": "+ Bind Face",
|
||||
"merge_other": "⇄ Merge to Other Person",
|
||||
"delete_person": "Delete This Person",
|
||||
"bind_face_title": "Bind Face",
|
||||
"merge_title": "⇄ Merge to Other Person",
|
||||
"unbind_trace": "Unbind trace T{trace_id} (face_id: {face_id})?",
|
||||
"no_face_id": "This trace has no face_id, cannot unbind",
|
||||
"unbind_failed": "Unbind failed: {error}",
|
||||
"merge_confirm": "Merge \"{source}\" into \"{target}\"?",
|
||||
"not_this_person": "Not This Person",
|
||||
"delete_confirm": "Delete \"{name}\"?",
|
||||
"delete_confirm_message": "Delete \"{name}\"? This action cannot be undone.",
|
||||
"delete_failed": "Delete failed: {error}",
|
||||
"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": {
|
||||
"welcome": {
|
||||
"title": "👋 Welcome to Momentry Studio",
|
||||
"intro": "I'll help you make better use of the system. Let me introduce the main features:",
|
||||
"smart_search": {
|
||||
"title": "Smart Search",
|
||||
"desc": "Keyword, semantic, and people search modes"
|
||||
},
|
||||
"people_management": {
|
||||
"title": "People Management",
|
||||
"desc": "Identify people in videos, build people database"
|
||||
},
|
||||
"file_management": {
|
||||
"title": "File Management",
|
||||
"desc": "Manage and process your video and photo files"
|
||||
},
|
||||
"got_it": "Got it, start using"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"title": "Momentry Studio",
|
||||
"subtitle": "Login to continue",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"username_placeholder": "Enter username",
|
||||
"password_placeholder": "Enter password",
|
||||
"login_button": "Login",
|
||||
"logging_in": "Logging in...",
|
||||
"login_success": "Login successful!",
|
||||
"default_credentials": "Default credentials: demo / demo123"
|
||||
},
|
||||
"nav": {
|
||||
"search": "Search",
|
||||
"library": "Library",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
79
src/locales/index.ts
Normal file
79
src/locales/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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 {
|
||||
const saved = localStorage.getItem('app_language')
|
||||
if (saved && saved in supportedLanguages) {
|
||||
return saved
|
||||
}
|
||||
|
||||
if (saved === 'auto') {
|
||||
return detectOSLanguage()
|
||||
}
|
||||
|
||||
return 'en'
|
||||
}
|
||||
|
||||
function detectOSLanguage(): string {
|
||||
let osLang = 'en'
|
||||
|
||||
try {
|
||||
if ((window as any).__TAURI__) {
|
||||
const locale = (window as any).__TAURI__?.os?.getLocale?.()
|
||||
if (locale) osLang = locale
|
||||
} else {
|
||||
osLang = navigator.language || 'en'
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Language detection failed:', e)
|
||||
}
|
||||
|
||||
if (osLang.startsWith('zh-TW') || osLang.startsWith('zh-Hant')) {
|
||||
return 'zh-TW'
|
||||
} else if (osLang.startsWith('zh-CN') || osLang.startsWith('zh-Hans')) {
|
||||
return 'zh-CN'
|
||||
} else if (osLang.startsWith('ja')) {
|
||||
return 'ja'
|
||||
} else if (osLang.startsWith('ko')) {
|
||||
return 'ko'
|
||||
}
|
||||
|
||||
return 'en'
|
||||
}
|
||||
|
||||
const i18n = createI18n<[MessageSchema], 'en' | 'zh-TW' | 'ja'>({
|
||||
legacy: false,
|
||||
locale: detectInitialLanguage(),
|
||||
fallbackLocale: 'en',
|
||||
messages: supportedLanguages as any,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
})
|
||||
|
||||
export function changeLanguage(lang: string) {
|
||||
if (lang === 'auto') {
|
||||
localStorage.setItem('app_language', 'auto')
|
||||
const detected = detectOSLanguage()
|
||||
i18n.global.locale = detected as any
|
||||
} else {
|
||||
localStorage.setItem('app_language', lang)
|
||||
i18n.global.locale = lang as any
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentLanguage(): string {
|
||||
return i18n.global.locale as string
|
||||
}
|
||||
|
||||
export { detectOSLanguage }
|
||||
export default i18n
|
||||
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"
|
||||
}
|
||||
}
|
||||
454
src/locales/zh-TW.json
Normal file
454
src/locales/zh-TW.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": "Agent 可用"
|
||||
},
|
||||
"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": "請選擇至少一個處理器",
|
||||
"file_must_be_completed": "檔案必須先完成處理",
|
||||
"face_deduplication": "人臉去重",
|
||||
"qc_check": "品質檢查"
|
||||
},
|
||||
"pipeline": {
|
||||
"processors": "處理器",
|
||||
"rule1_ingestion": "Rule1 入庫",
|
||||
"face_tracing": "Face Tracing",
|
||||
"tkg_nodes": "TKG Nodes",
|
||||
"tkg_edges": "TKG Edges",
|
||||
"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": "路徑一:處理器輸出",
|
||||
"track_ingestion": "路徑二:資料入庫",
|
||||
"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": "Trace ID:",
|
||||
"frame_number": "帧號:",
|
||||
"confidence": "置信度:",
|
||||
"face_id": "Face 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": "Bind Face",
|
||||
"merge_title": "⇄ 合併到其他人物",
|
||||
"unbind_trace": "解綁 trace T{trace_id}(face_id: {face_id})?",
|
||||
"no_face_id": "此 trace 沒有 face_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": "Login to continue",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"username_placeholder": "Enter username",
|
||||
"password_placeholder": "Enter password",
|
||||
"login_button": "Login",
|
||||
"logging_in": "Logging in...",
|
||||
"login_success": "Login successful!",
|
||||
"default_credentials": "Default credentials: demo / demo123"
|
||||
},
|
||||
"nav": {
|
||||
"search": "Search",
|
||||
"library": "Library",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
19
src/main.ts.backup_local
Normal file
19
src/main.ts.backup_local
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import App from './App.vue'
|
||||
import LoginView from './views/LoginView.vue'
|
||||
import ClientView from './views/ClientView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/login' },
|
||||
{ path: '/login', name: 'Login', component: LoginView },
|
||||
{ path: '/client', name: 'Client', component: ClientView },
|
||||
{ path: '/search', name: 'Search', component: { template: '<div>Search Page (Coming Soon)</div>' } },
|
||||
],
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
264
src/store.ts
264
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)
|
||||
@@ -886,6 +969,10 @@ export async function syncFileStatus(fileUuid: string): Promise<any> {
|
||||
delete fileProgressCache.value[fileUuid]
|
||||
}
|
||||
f.status = newStatus
|
||||
|
||||
if (newStatus === 'processing') {
|
||||
await loadPipelineStats(fileUuid, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -912,27 +999,27 @@ export async function loadPipelineStats(fileUuid: string, force = false): Promis
|
||||
try {
|
||||
const data: any = await apiCall('get_pipeline_stats', { fileUuid })
|
||||
|
||||
// 过滤掉identity_agent阶段(独立于pipeline)
|
||||
const filteredStages = (data.stages || []).filter((s: any) => s.name !== 'identity_agent')
|
||||
const stages = data.stages || []
|
||||
|
||||
// 覆盖权重为最终设计值(Core API返回的权重可能不匹配)
|
||||
const WEIGHT_MAP: Record<string, number> = {
|
||||
processors: 0.30,
|
||||
rule1_ingestion: 0.10,
|
||||
face_tracing: 0.10,
|
||||
rule1_ingestion: 0.05,
|
||||
face_tracing: 0.05,
|
||||
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 = filteredStages.map((s: any) => ({
|
||||
const mappedStages = stages.map((s: any) => ({
|
||||
name: s.name,
|
||||
weight: WEIGHT_MAP[s.name] ?? s.weight ?? 0,
|
||||
progress: s.progress || 0,
|
||||
status: s.status || 'pending'
|
||||
}))
|
||||
|
||||
// 用前端权重重新计算 overall_progress(不信任 API 返回的值)
|
||||
const calculatedProgress = mappedStages.reduce((sum, s) => sum + s.weight * s.progress, 0)
|
||||
|
||||
const pipelineStats: PipelineStats = {
|
||||
@@ -944,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) {
|
||||
@@ -959,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')
|
||||
|
||||
@@ -986,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]
|
||||
@@ -1058,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1088,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
|
||||
@@ -1130,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
|
||||
@@ -1208,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/advancedMode.ts
Normal file
28
src/stores/advancedMode.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Advanced Mode Store - controls visibility of advanced features
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const ADVANCED_KEY = 'momentry_advanced_mode'
|
||||
|
||||
// Load initial state
|
||||
const savedAdvanced = localStorage.getItem(ADVANCED_KEY)
|
||||
const isAdvancedMode = ref(savedAdvanced === 'true')
|
||||
|
||||
export function useAdvancedMode() {
|
||||
const isAdvanced = computed(() => isAdvancedMode.value)
|
||||
|
||||
function toggleAdvanced() {
|
||||
isAdvancedMode.value = !isAdvancedMode.value
|
||||
localStorage.setItem(ADVANCED_KEY, isAdvancedMode.value.toString())
|
||||
}
|
||||
|
||||
function setAdvanced(value: boolean) {
|
||||
isAdvancedMode.value = value
|
||||
localStorage.setItem(ADVANCED_KEY, value.toString())
|
||||
}
|
||||
|
||||
return {
|
||||
isAdvanced,
|
||||
toggleAdvanced,
|
||||
setAdvanced
|
||||
}
|
||||
}
|
||||
48
src/stores/agentStore.ts
Normal file
48
src/stores/agentStore.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const agentTip = ref<string>('')
|
||||
export const showAgentTip = ref(false)
|
||||
export const agentSuggestions = ref<string[]>([])
|
||||
export const discoveryTip = ref<string>('')
|
||||
export const userBehavior = ref({
|
||||
hasUsedKeywordSearch: false,
|
||||
hasUsedSemanticSearch: false,
|
||||
hasUsedPeopleSearch: false,
|
||||
hasUsedFileFilter: false,
|
||||
hasUsedContextMenu: false,
|
||||
hasViewedPersonDetails: false,
|
||||
hasBindedFace: false,
|
||||
hasUsedSearchHistory: false,
|
||||
hasUsedBookmark: false
|
||||
})
|
||||
|
||||
export function showTip(message: string, duration = 5000) {
|
||||
agentTip.value = message
|
||||
showAgentTip.value = true
|
||||
setTimeout(() => {
|
||||
showAgentTip.value = false
|
||||
}, duration)
|
||||
}
|
||||
|
||||
export function showDiscovery(message: string, duration = 8000) {
|
||||
discoveryTip.value = message
|
||||
setTimeout(() => {
|
||||
discoveryTip.value = ''
|
||||
}, duration)
|
||||
}
|
||||
|
||||
export function trackBehavior(action: keyof typeof userBehavior.value) {
|
||||
userBehavior.value[action] = true
|
||||
localStorage.setItem('momentry_user_behavior', JSON.stringify(userBehavior.value))
|
||||
}
|
||||
|
||||
export function loadUserBehavior() {
|
||||
const saved = localStorage.getItem('momentry_user_behavior')
|
||||
if (saved) {
|
||||
try {
|
||||
userBehavior.value = JSON.parse(saved)
|
||||
} catch (e) {
|
||||
console.error('Failed to load user behavior:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
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 }
|
||||
}
|
||||
60
src/utils/mockPoseData.ts
Normal file
60
src/utils/mockPoseData.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { PoseResponse, AppearanceResponse } from '@/api/types'
|
||||
|
||||
export const mockPose: PoseResponse = {
|
||||
frame: 100,
|
||||
keypoints: [
|
||||
{ name: 'nose', x: 100, y: 50, confidence: 0.9 },
|
||||
{ name: 'left_eye', x: 90, y: 45, confidence: 0.95 },
|
||||
{ name: 'right_eye', x: 110, y: 45, confidence: 0.95 },
|
||||
{ name: 'left_ear', x: 80, y: 50, confidence: 0.85 },
|
||||
{ name: 'right_ear', x: 120, y: 50, confidence: 0.85 },
|
||||
{ name: 'left_shoulder', x: 60, y: 100, confidence: 0.9 },
|
||||
{ name: 'right_shoulder', x: 140, y: 100, confidence: 0.9 },
|
||||
{ name: 'left_elbow', x: 50, y: 150, confidence: 0.85 },
|
||||
{ name: 'right_elbow', x: 150, y: 150, confidence: 0.85 },
|
||||
{ name: 'left_wrist', x: 45, y: 190, confidence: 0.8 },
|
||||
{ name: 'right_wrist', x: 155, y: 190, confidence: 0.8 },
|
||||
{ name: 'left_hip', x: 70, y: 200, confidence: 0.9 },
|
||||
{ name: 'right_hip', x: 130, y: 200, confidence: 0.9 },
|
||||
{ name: 'left_knee', x: 65, y: 280, confidence: 0.85 },
|
||||
{ name: 'right_knee', x: 135, y: 280, confidence: 0.85 },
|
||||
{ name: 'left_ankle', x: 60, y: 350, confidence: 0.8 },
|
||||
{ name: 'right_ankle', x: 140, y: 350, confidence: 0.8 },
|
||||
],
|
||||
pose_class: 'standing',
|
||||
confidence: 0.92
|
||||
}
|
||||
|
||||
export const mockAppearance: AppearanceResponse = {
|
||||
frame: 100,
|
||||
dominant_colors: [
|
||||
{ rgb: [255, 100, 50], percentage: 0.35 },
|
||||
{ rgb: [50, 150, 200], percentage: 0.25 },
|
||||
{ rgb: [100, 200, 100], percentage: 0.15 },
|
||||
]
|
||||
}
|
||||
|
||||
export const mockSittingPose: PoseResponse = {
|
||||
frame: 200,
|
||||
keypoints: [
|
||||
{ name: 'nose', x: 100, y: 60, confidence: 0.88 },
|
||||
{ name: 'left_eye', x: 90, y: 55, confidence: 0.92 },
|
||||
{ name: 'right_eye', x: 110, y: 55, confidence: 0.92 },
|
||||
{ name: 'left_ear', x: 80, y: 60, confidence: 0.82 },
|
||||
{ name: 'right_ear', x: 120, y: 60, confidence: 0.82 },
|
||||
{ name: 'left_shoulder', x: 60, y: 110, confidence: 0.88 },
|
||||
{ name: 'right_shoulder', x: 140, y: 110, confidence: 0.88 },
|
||||
{ name: 'left_elbow', x: 55, y: 140, confidence: 0.83 },
|
||||
{ name: 'right_elbow', x: 145, y: 140, confidence: 0.83 },
|
||||
{ name: 'left_wrist', x: 50, y: 160, confidence: 0.78 },
|
||||
{ name: 'right_wrist', x: 150, y: 160, confidence: 0.78 },
|
||||
{ name: 'left_hip', x: 70, y: 180, confidence: 0.88 },
|
||||
{ name: 'right_hip', x: 130, y: 180, confidence: 0.88 },
|
||||
{ name: 'left_knee', x: 90, y: 200, confidence: 0.83 },
|
||||
{ name: 'right_knee', x: 110, y: 200, confidence: 0.83 },
|
||||
{ name: 'left_ankle', x: 85, y: 240, confidence: 0.78 },
|
||||
{ name: 'right_ankle', x: 115, y: 240, confidence: 0.78 },
|
||||
],
|
||||
pose_class: 'sitting',
|
||||
confidence: 0.87
|
||||
}
|
||||
264
src/utils/poseRenderer.ts
Normal file
264
src/utils/poseRenderer.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { PoseResponse, AppearanceResponse } from '@/api/types'
|
||||
|
||||
const SKELETON_CONNECTIONS: [string, string][] = [
|
||||
['nose', 'left_eye'],
|
||||
['nose', 'right_eye'],
|
||||
['left_eye', 'left_ear'],
|
||||
['right_eye', 'right_ear'],
|
||||
['left_shoulder', 'right_shoulder'],
|
||||
['left_shoulder', 'left_elbow'],
|
||||
['left_elbow', 'left_wrist'],
|
||||
['right_shoulder', 'right_elbow'],
|
||||
['right_elbow', 'right_wrist'],
|
||||
['left_shoulder', 'left_hip'],
|
||||
['right_shoulder', 'right_hip'],
|
||||
['left_hip', 'right_hip'],
|
||||
['left_hip', 'left_knee'],
|
||||
['left_knee', 'left_ankle'],
|
||||
['right_hip', 'right_knee'],
|
||||
['right_knee', 'right_ankle'],
|
||||
]
|
||||
|
||||
function transformKeypoints(
|
||||
keypoints: Array<{ name: string; x: number; y: number; confidence?: number }>,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number
|
||||
): Map<string, { x: number; y: number; confidence: number }> {
|
||||
console.log('[transformKeypoints] input keypoints:', keypoints?.length)
|
||||
|
||||
const validKeypoints = keypoints.filter(kp => {
|
||||
const conf = kp.confidence ?? 0
|
||||
return conf > 0 && kp.x > 0 && kp.y > 0
|
||||
})
|
||||
|
||||
console.log('[transformKeypoints] valid keypoints:', validKeypoints.length)
|
||||
|
||||
if (validKeypoints.length === 0) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
let minX = Infinity, maxX = -Infinity
|
||||
let minY = Infinity, maxY = -Infinity
|
||||
for (const kp of validKeypoints) {
|
||||
minX = Math.min(minX, kp.x)
|
||||
maxX = Math.max(maxX, kp.x)
|
||||
minY = Math.min(minY, kp.y)
|
||||
maxY = Math.max(maxY, kp.y)
|
||||
}
|
||||
|
||||
const width = maxX - minX || 1
|
||||
const height = maxY - minY || 1
|
||||
|
||||
console.log('[transformKeypoints] bounding box:', minX, minY, 'to', maxX, maxY)
|
||||
console.log('[transformKeypoints] bbox size:', width, 'x', height)
|
||||
|
||||
const padding = 20
|
||||
const availableWidth = canvasWidth - 2 * padding
|
||||
const availableHeight = canvasHeight - 2 * padding
|
||||
|
||||
const scaleX = availableWidth / width
|
||||
const scaleY = availableHeight / height
|
||||
const scale = Math.min(scaleX, scaleY)
|
||||
|
||||
const scaledWidth = width * scale
|
||||
const scaledHeight = height * scale
|
||||
const offsetX = (canvasWidth - scaledWidth) / 2 - minX * scale
|
||||
const offsetY = (canvasHeight - scaledHeight) / 2 - minY * scale
|
||||
|
||||
console.log('[transformKeypoints] scale:', scale, 'offset:', offsetX, offsetY)
|
||||
|
||||
const keypointMap = new Map<string, { x: number; y: number; confidence: number }>()
|
||||
for (const kp of keypoints) {
|
||||
const conf = kp.confidence ?? 0
|
||||
keypointMap.set(kp.name, {
|
||||
x: kp.x * scale + offsetX,
|
||||
y: kp.y * scale + offsetY,
|
||||
confidence: conf
|
||||
})
|
||||
}
|
||||
|
||||
console.log('[transformKeypoints] First 3 transformed:',
|
||||
[...keypointMap.entries()].slice(0, 3).map(([k, v]) => ({ name: k, ...v })))
|
||||
|
||||
return keypointMap
|
||||
}
|
||||
|
||||
export function drawPoseSkeleton(
|
||||
canvas: HTMLCanvasElement,
|
||||
pose: PoseResponse,
|
||||
options?: {
|
||||
lineWidth?: number
|
||||
pointRadius?: number
|
||||
lineColor?: string
|
||||
pointColor?: string
|
||||
videoWidth?: number
|
||||
videoHeight?: number
|
||||
noTransform?: boolean
|
||||
}
|
||||
) {
|
||||
console.log('[drawPoseSkeleton] canvas size:', canvas.width, 'x', canvas.height)
|
||||
console.log('[drawPoseSkeleton] pose:', pose)
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
console.error('[drawPoseSkeleton] No context')
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
lineWidth = 2,
|
||||
pointRadius = 4,
|
||||
lineColor = '#00ff00',
|
||||
pointColor = '#ff0000',
|
||||
noTransform = false
|
||||
} = options || {}
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 不轉換,直接畫原始座標(用於 debug)
|
||||
if (noTransform) {
|
||||
console.log('[drawPoseSkeleton] Drawing raw keypoints (no transform)')
|
||||
|
||||
const validKeypoints = pose.keypoints.filter(kp => kp.confidence > 0 && kp.x > 0 && kp.y > 0)
|
||||
|
||||
if (validKeypoints.length === 0) {
|
||||
ctx.fillStyle = '#9aa0a6'
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('No valid keypoints', canvas.width / 2, canvas.height / 2)
|
||||
return
|
||||
}
|
||||
|
||||
// 找 bounding box
|
||||
let minX = Infinity, maxX = -Infinity
|
||||
let minY = Infinity, maxY = -Infinity
|
||||
for (const kp of validKeypoints) {
|
||||
minX = Math.min(minX, kp.x)
|
||||
maxX = Math.max(maxX, kp.x)
|
||||
minY = Math.min(minY, kp.y)
|
||||
maxY = Math.max(maxY, kp.y)
|
||||
}
|
||||
|
||||
console.log('[drawPoseSkeleton] Raw bbox:', minX, minY, 'to', maxX, maxY)
|
||||
|
||||
// 繪製
|
||||
ctx.fillStyle = pointColor
|
||||
for (const kp of validKeypoints) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(kp.x, kp.y, pointRadius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const keypointMap = transformKeypoints(pose.keypoints, canvas.width, canvas.height)
|
||||
|
||||
console.log('[drawPoseSkeleton] transformed keypoints:', keypointMap.size)
|
||||
|
||||
if (keypointMap.size === 0) {
|
||||
console.log('[drawPoseSkeleton] No valid keypoints')
|
||||
ctx.fillStyle = '#9aa0a6'
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('No pose detected', canvas.width / 2, canvas.height / 2)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.strokeStyle = lineColor
|
||||
ctx.lineWidth = lineWidth
|
||||
for (const [from, to] of SKELETON_CONNECTIONS) {
|
||||
const fromPt = keypointMap.get(from)
|
||||
const toPt = keypointMap.get(to)
|
||||
if (fromPt && toPt && fromPt.confidence > 0 && toPt.confidence > 0) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(fromPt.x, fromPt.y)
|
||||
ctx.lineTo(toPt.x, toPt.y)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = pointColor
|
||||
for (const kp of pose.keypoints) {
|
||||
const transformed = keypointMap.get(kp.name)
|
||||
if (transformed && transformed.confidence > 0) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(transformed.x, transformed.y, pointRadius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function drawAppearanceOverlay(
|
||||
canvas: HTMLCanvasElement,
|
||||
appearance: AppearanceResponse,
|
||||
pose?: PoseResponse
|
||||
) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
if (!appearance.dominant_colors?.length) {
|
||||
if (appearance.hsv_histogram && appearance.hsv_histogram.length > 0) {
|
||||
ctx.fillStyle = '#f8f9fa'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.fillStyle = '#5f6368'
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('HSV histogram', canvas.width / 2, canvas.height / 2 - 10)
|
||||
ctx.fillText(`(${appearance.hsv_histogram.length} channels)`, canvas.width / 2, canvas.height / 2 + 10)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!pose) {
|
||||
const swatchWidth = canvas.width / appearance.dominant_colors.length
|
||||
appearance.dominant_colors.forEach((color, i) => {
|
||||
ctx.fillStyle = `rgb(${color.rgb.join(',')})`
|
||||
ctx.fillRect(i * swatchWidth, 0, swatchWidth, canvas.height)
|
||||
if (color.percentage) {
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.fillText(`${Math.round(color.percentage * 100)}%`, i * swatchWidth + 4, canvas.height - 8)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const keypointMap = transformKeypoints(pose.keypoints, canvas.width, canvas.height)
|
||||
|
||||
if (keypointMap.size === 0) return
|
||||
|
||||
ctx.globalAlpha = 0.4
|
||||
|
||||
const upperBodyPts = ['left_shoulder', 'right_shoulder', 'left_elbow', 'right_elbow']
|
||||
const lowerBodyPts = ['left_hip', 'right_hip', 'left_knee', 'right_knee']
|
||||
|
||||
if (appearance.dominant_colors[0]) {
|
||||
const color = appearance.dominant_colors[0]
|
||||
ctx.fillStyle = `rgb(${color.rgb.join(',')})`
|
||||
for (const name of upperBodyPts) {
|
||||
const pt = keypointMap.get(name)
|
||||
if (pt && pt.confidence > 0) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(pt.x, pt.y, 15, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (appearance.dominant_colors[1]) {
|
||||
const color = appearance.dominant_colors[1]
|
||||
ctx.fillStyle = `rgb(${color.rgb.join(',')})`
|
||||
for (const name of lowerBodyPts) {
|
||||
const pt = keypointMap.get(name)
|
||||
if (pt && pt.confidence > 0) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(pt.x, pt.y, 15, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1.0
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,19 +2,19 @@
|
||||
<div id="ms-view-client" class="client-layout">
|
||||
<div class="client-header">
|
||||
<div class="header-top-row">
|
||||
<h1>File Manager</h1>
|
||||
<h1>{{ t('client.title') }}</h1>
|
||||
<div class="header-buttons">
|
||||
<button @click="goUp" :disabled="currentPath === '/'">Up</button>
|
||||
<button @click="showCreateFolderDialog">New Folder</button>
|
||||
<button @click="showUploadDialog">Upload</button>
|
||||
<button @click="confirmDelete" :disabled="!selectedFile">Delete</button>
|
||||
<button @click="showMoveDialog" :disabled="!selectedFile">Move</button>
|
||||
<button @click="refreshFiles">Refresh</button>
|
||||
<button @click="showCreateFolderDialog">{{ t('client.button.newFolder') }}</button>
|
||||
<button @click="showUploadDialog">{{ t('client.button.upload') }}</button>
|
||||
<button @click="confirmDelete" :disabled="!selectedFile">{{ t('client.button.delete') }}</button>
|
||||
<button @click="showMoveDialog" :disabled="!selectedFile">{{ t('client.button.move') }}</button>
|
||||
<button @click="refreshFiles">{{ t('client.button.refresh') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-path-row">Path: {{ currentPath }}</div>
|
||||
<div class="client-search">
|
||||
<input v-model="searchQuery" type="text" placeholder="Search files..." @input="onSearch" />
|
||||
<input v-model="searchQuery" type="text" :placeholder="t('client.search.placeholder')" @input="onSearch" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,10 +24,10 @@
|
||||
<table v-else class="file-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th @click="sortBy('name')" class="sortable">Name <span v-if="sortField==='name'">{{ sortAsc ? '▲' : '▼' }}</span></th>
|
||||
<th @click="sortBy('size')" class="sortable">Size <span v-if="sortField==='size'">{{ sortAsc ? '▲' : '▼' }}</span></th>
|
||||
<th>Type</th>
|
||||
<th>Modified</th>
|
||||
<th @click="sortBy('name')" class="sortable">{{ t('client.table.name') }} <span v-if="sortField==='name'">{{ sortAsc ? '▲' : '▼' }}</span></th>
|
||||
<th @click="sortBy('size')" class="sortable">{{ t('client.table.size') }} <span v-if="sortField==='size'">{{ sortAsc ? '▲' : '▼' }}</span></th>
|
||||
<th>{{ t('client.table.type') }}</th>
|
||||
<th>{{ t('client.table.modified') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -78,11 +78,11 @@
|
||||
<!-- Create Folder Dialog -->
|
||||
<div v-if="showCreateFolder" class="dialog-overlay" @click="hideCreateFolderDialog">
|
||||
<div class="dialog" @click.stop>
|
||||
<h3>Create New Folder</h3>
|
||||
<input v-model="newFolderName" type="text" placeholder="Folder name" @keyup.enter="createFolder" />
|
||||
<h3>{{ t('client.dialog.createFolder') }}</h3>
|
||||
<input v-model="newFolderName" type="text" :placeholder="t('client.dialog.folderName')" @keyup.enter="createFolder" />
|
||||
<div class="dialog-buttons">
|
||||
<button @click="createFolder">Create</button>
|
||||
<button @click="hideCreateFolderDialog">Cancel</button>
|
||||
<button @click="createFolder">{{ t('client.button.create') }}</button>
|
||||
<button @click="hideCreateFolderDialog">{{ t('client.button.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,14 +90,14 @@
|
||||
<!-- Delete Confirm Dialog -->
|
||||
<div v-if="showDeleteConfirm" class="dialog-overlay" @click="cancelDelete">
|
||||
<div class="dialog" @click.stop>
|
||||
<h3>Delete "{{ deleteTarget }}"</h3>
|
||||
<h3>{{ t('client.dialog.deleteTitle', { name: deleteTarget }) }}</h3>
|
||||
<p v-if="selectedFile?.isDir && selectedFile.fileCount">
|
||||
This folder contains <strong>{{ selectedFile.fileCount }}</strong> file(s). They will be permanently deleted.
|
||||
</p>
|
||||
<p v-else>Are you sure you want to delete this item?</p>
|
||||
<p v-else>{{ t('client.dialog.deleteMessage', { type: selectedFile?.isDir ? t('client.type.folder') : t('client.type.file') }) }}</p>
|
||||
<div class="dialog-buttons">
|
||||
<button @click="doDelete" class="danger-btn">Delete</button>
|
||||
<button @click="cancelDelete">Cancel</button>
|
||||
<button @click="doDelete" class="danger-btn">{{ t('client.button.delete') }}</button>
|
||||
<button @click="cancelDelete">{{ t('client.button.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,11 +105,11 @@
|
||||
<!-- Rename Dialog -->
|
||||
<div v-if="showRename" class="dialog-overlay" @click="hideRenameDialog">
|
||||
<div class="dialog" @click.stop>
|
||||
<h3>Rename "{{ renameOldName }}"</h3>
|
||||
<input v-model="renameNewName" type="text" placeholder="New name" @keyup.enter="doRename" autofocus />
|
||||
<h3>{{ t('client.dialog.renameTitle', { name: renameOldName }) }}</h3>
|
||||
<input v-model="renameNewName" type="text" :placeholder="t('client.dialog.newName')" @keyup.enter="doRename" autofocus />
|
||||
<div class="dialog-buttons">
|
||||
<button @click="doRename">Rename</button>
|
||||
<button @click="hideRenameDialog">Cancel</button>
|
||||
<button @click="doRename">{{ t('client.button.rename') }}</button>
|
||||
<button @click="hideRenameDialog">{{ t('client.button.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +117,7 @@
|
||||
<!-- File Preview Dialog -->
|
||||
<div v-if="showPreview" class="dialog-overlay" @click="hidePreview">
|
||||
<div class="dialog preview-dialog" @click.stop>
|
||||
<h3>File Preview: {{ selectedFile?.name }}</h3>
|
||||
<h3>{{ t('client.dialog.previewTitle', { name: selectedFile?.name }) }}</h3>
|
||||
<div v-if="previewLoading" class="loading">Loading preview...</div>
|
||||
<div v-else-if="previewContent" class="preview-content">
|
||||
<img v-if="isImage" :src="previewContent" alt="preview" />
|
||||
@@ -131,8 +131,8 @@
|
||||
<pre v-else>{{ previewContent }}</pre>
|
||||
</div>
|
||||
<div class="dialog-buttons">
|
||||
<button @click="downloadFile">Download</button>
|
||||
<button @click="hidePreview">Close</button>
|
||||
<button @click="downloadFile">{{ t('client.button.download') }}</button>
|
||||
<button @click="hidePreview">{{ t('common.close') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,7 +140,7 @@
|
||||
<!-- Upload Dialog -->
|
||||
<div v-if="showUpload" class="dialog-overlay" @click="hideUploadDialog">
|
||||
<div class="dialog upload-dialog" @click.stop>
|
||||
<h3>Upload File{{ uploadFiles.length > 1 ? 's' : '' }}</h3>
|
||||
<h3>{{ t('client.dialog.uploadTitle', { s: uploadFiles.length > 1 ? 's' : '' }) }}</h3>
|
||||
<input ref="fileInput" type="file" multiple @change="selectUploadFile" />
|
||||
<div v-if="uploadFiles.length > 0" class="upload-info">
|
||||
<p v-if="!uploading">{{ uploadFiles.length }} file(s) selected</p>
|
||||
@@ -155,9 +155,9 @@
|
||||
</div>
|
||||
<div class="dialog-buttons">
|
||||
<button @click="uploadFileToServer" :disabled="uploadFiles.length === 0 || uploading">
|
||||
{{ uploading ? 'Uploading...' : 'Upload' }}
|
||||
{{ uploading ? t('client.status.uploading') : t('client.button.upload') }}
|
||||
</button>
|
||||
<button @click="hideUploadDialog">Cancel</button>
|
||||
<button @click="hideUploadDialog">{{ t('client.button.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,7 +165,7 @@
|
||||
<!-- Move Dialog -->
|
||||
<div v-if="showMove" class="dialog-overlay" @click="showMove = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<h3>{{ selectedFile?.isDir ? 'Move Folder' : 'Move File' }} "{{ selectedFile?.name }}"</h3>
|
||||
<h3>{{ t('client.dialog.moveTitle', { type: selectedFile?.isDir ? t('client.type.folder') : t('client.type.file'), name: selectedFile?.name }) }}</h3>
|
||||
<select v-model="moveTarget" class="move-select">
|
||||
<option value="" disabled>Select a folder...</option>
|
||||
<option v-for="folder in availableFolders" :key="folder.name" :value="folder.name">
|
||||
@@ -173,8 +173,8 @@
|
||||
</option>
|
||||
</select>
|
||||
<div class="dialog-buttons">
|
||||
<button @click="doMove" :disabled="!moveTarget">Move</button>
|
||||
<button @click="showMove = false">Cancel</button>
|
||||
<button @click="doMove" :disabled="!moveTarget">{{ t('client.button.move') }}</button>
|
||||
<button @click="showMove = false">{{ t('client.button.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,6 +183,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentPath = ref('/')
|
||||
const files = ref<any[]>([])
|
||||
@@ -1305,4 +1308,9 @@ onMounted(() => {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
table { display: block; overflow-x: auto; white-space: nowrap; }
|
||||
.client-view { padding: 8px; }
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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'">
|
||||
@@ -270,7 +270,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-frame="currentVideo.startFrame" :end-frame="currentVideo.endFrame" :all-traces="allTraces" :merged-segments="mergedSegments" :initial-trace-idx="currentVideo.traceIdx" :title="currentVideo.title" @close="playing = false" />
|
||||
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-frame="currentVideo.startFrame" :end-frame="currentVideo.endFrame" :all-traces="allTraces" :merged-segments="mergedSegments" :initial-trace-idx="currentVideo.traceIdx" :title="currentVideo.title" :show-face="true" :show-asrx="false" :show-ocr="false" :show-pose="false" @close="playing = false" />
|
||||
|
||||
<!-- Trace strip context menu -->
|
||||
<div v-if="traceCtxMenu.show" class="ms-ctx-menu" :style="{ left: traceCtxMenu.x + 'px', top: traceCtxMenu.y + 'px', display: 'block' }" @click.stop @mousedown.stop @pointerdown.stop>
|
||||
@@ -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,12 +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"
|
||||
@close="playing = false"
|
||||
:show-asrx="true"
|
||||
:show-ocr="true"
|
||||
:show-face="clusterTraces.length > 0"
|
||||
:show-pose="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>
|
||||
@@ -218,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>
|
||||
@@ -230,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 {
|
||||
@@ -251,6 +300,7 @@ interface ChatMessage {
|
||||
answer?: string
|
||||
sources?: SourceInfo[]
|
||||
chips?: string[]
|
||||
mode?: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
@@ -258,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')
|
||||
@@ -283,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 }
|
||||
}
|
||||
@@ -313,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,
|
||||
@@ -332,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) => {
|
||||
@@ -345,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()
|
||||
@@ -354,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
|
||||
}
|
||||
@@ -376,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
|
||||
@@ -387,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],
|
||||
})
|
||||
@@ -424,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')
|
||||
|
||||
@@ -468,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))
|
||||
|
||||
@@ -598,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
|
||||
@@ -662,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')
|
||||
@@ -717,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 || [])
|
||||
@@ -743,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)) {
|
||||
@@ -757,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) {
|
||||
@@ -802,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 = {
|
||||
@@ -814,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
|
||||
@@ -997,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); }
|
||||
@@ -1172,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);
|
||||
@@ -1265,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%;
|
||||
@@ -1495,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;
|
||||
@@ -1599,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')
|
||||
|
||||
247
vlm_search.py
Normal file
247
vlm_search.py
Normal file
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
VLM-based semantic search for video keyframes.
|
||||
Architecture:
|
||||
1. Extract keyframes from video at configurable interval
|
||||
2. Send each keyframe to VLM (Ollama LLaVA) for description
|
||||
3. Store frame descriptions in SQLite with embedding
|
||||
4. Search via text similarity using embedding model
|
||||
|
||||
Usage:
|
||||
python3 vlm_search.py index <file_uuid> # Index a video
|
||||
python3 vlm_search.py search <query> # Search indexed frames
|
||||
python3 vlm_search.py status # Show index status
|
||||
python3 vlm_search.py benchmark <file_uuid> # Benchmark VLM speed
|
||||
"""
|
||||
import sys, os, json, time, sqlite3, argparse, base64, io, struct
|
||||
from pathlib import Path
|
||||
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
|
||||
# ─── Configuration ───
|
||||
CORE_API = "http://localhost:3002"
|
||||
PROXY = "http://localhost:8888"
|
||||
API_KEY = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69"
|
||||
DB_PATH = Path(__file__).parent / "data" / "vlm_index.sqlite"
|
||||
FRAME_INTERVAL = 120 # every N frames (default ~5s at 24fps)
|
||||
VLM_MODEL = "llava"
|
||||
EMBED_MODEL = "mxbai-embed-large"
|
||||
|
||||
def api_get(path):
|
||||
url = f"{CORE_API}{path}?api_key={API_KEY}"
|
||||
with urllib.request.urlopen(url) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
def api_post(path, body):
|
||||
url = f"{CORE_API}{path}?api_key={API_KEY}"
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
def get_thumbnail_proxy(file_uuid, frame):
|
||||
url = f"{PROXY}/api/v1/file/{file_uuid}/thumbnail?frame={frame}"
|
||||
with urllib.request.urlopen(url) as r:
|
||||
return r.read()
|
||||
|
||||
# ─── SQLite Index ───
|
||||
def init_db():
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS frames (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_uuid TEXT NOT NULL,
|
||||
frame INTEGER NOT NULL,
|
||||
time_sec REAL,
|
||||
description TEXT,
|
||||
embedding BLOB,
|
||||
created_at REAL DEFAULT (julianday('now'))
|
||||
)""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_frames_desc ON frames(description)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_frames_file ON frames(file_uuid)")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
def get_embedding(text):
|
||||
"""Use Ollama embedding model to get vector."""
|
||||
payload = json.dumps({"model": EMBED_MODEL, "prompt": text}).encode()
|
||||
req = urllib.request.Request(
|
||||
"http://localhost:11434/api/embeddings",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
data = json.loads(r.read())
|
||||
return data.get("embedding")
|
||||
except Exception as e:
|
||||
print(f" Embedding error: {e}")
|
||||
return None
|
||||
|
||||
def cosine_similarity(a, b):
|
||||
dot = sum(x*y for x,y in zip(a,b))
|
||||
na = sum(x*x for x in a)**0.5
|
||||
nb = sum(x*x for x in b)**0.5
|
||||
return dot / (na * nb) if na and nb else 0
|
||||
|
||||
# ─── VLM Inference ───
|
||||
def vlm_describe(image_bytes, prompt="Describe this image in 1-2 sentences. Focus on what is happening, people, objects, and scene."):
|
||||
b64 = base64.b64encode(image_bytes).decode()
|
||||
payload = json.dumps({
|
||||
"model": VLM_MODEL,
|
||||
"prompt": prompt,
|
||||
"images": [b64],
|
||||
"stream": False,
|
||||
"options": {"num_predict": 128}
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
"http://localhost:11434/api/generate",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
data = json.loads(r.read())
|
||||
return data.get("response", "").strip()
|
||||
|
||||
# ─── Commands ───
|
||||
def cmd_index(file_uuid, interval=FRAME_INTERVAL, limit=None):
|
||||
conn = init_db()
|
||||
info = api_get(f"/api/v1/file/{file_uuid}")
|
||||
if not info.get("file_uuid"):
|
||||
print(f"File not found: {file_uuid}")
|
||||
return
|
||||
file_name = info.get("file_name", "?")
|
||||
fps = info.get("fps", 24)
|
||||
duration = info.get("duration", 0)
|
||||
total = 0
|
||||
frame = 0
|
||||
times = []
|
||||
print(f"Indexing: {file_name} ({duration:.1f}s @ {fps:.2f}fps)")
|
||||
while True:
|
||||
if limit and total >= limit:
|
||||
break
|
||||
try:
|
||||
img = get_thumbnail_proxy(file_uuid, frame)
|
||||
except Exception as e:
|
||||
if "404" in str(e):
|
||||
break # no more frames
|
||||
print(f" Error frame {frame}: {e}")
|
||||
frame += interval
|
||||
continue
|
||||
t0 = time.time()
|
||||
try:
|
||||
desc = vlm_describe(img, "Describe this image briefly. What are people doing? What are they wearing? What objects/scene?")
|
||||
except Exception as e:
|
||||
print(f" VLM error frame {frame}: {e}")
|
||||
frame += interval
|
||||
continue
|
||||
elapsed = time.time() - t0
|
||||
times.append(elapsed)
|
||||
# Get embedding
|
||||
emb = get_embedding(desc)
|
||||
emb_blob = struct.pack(f"{len(emb)}d", *emb) if emb else None
|
||||
conn.execute(
|
||||
"INSERT INTO frames (file_uuid, frame, time_sec, description, embedding) VALUES (?,?,?,?,?)",
|
||||
(file_uuid, frame, frame/fps, desc, emb_blob)
|
||||
)
|
||||
conn.commit()
|
||||
total += 1
|
||||
print(f" F{frame} ({frame/fps:.1f}s) [{elapsed:.1f}s] {desc[:80]}")
|
||||
frame += interval
|
||||
avg = sum(times)/len(times) if times else 0
|
||||
print(f"\nDone: {total} frames indexed, avg {avg:.2f}s per VLM call")
|
||||
|
||||
def cmd_status():
|
||||
conn = init_db()
|
||||
rows = conn.execute("""
|
||||
SELECT file_uuid, COUNT(*), MIN(frame), MAX(frame), MIN(created_at)
|
||||
FROM frames GROUP BY file_uuid
|
||||
""").fetchall()
|
||||
total = conn.execute("SELECT COUNT(*) FROM frames").fetchone()[0]
|
||||
print(f"Total indexed frames: {total}")
|
||||
for r in rows:
|
||||
print(f" {r[0][:12]}: {r[1]} frames (F{r[2]}–F{r[3]})")
|
||||
|
||||
def cmd_search(query, top_k=10):
|
||||
conn = init_db()
|
||||
# Get query embedding
|
||||
q_emb = get_embedding(query)
|
||||
if not q_emb:
|
||||
# Fallback to keyword search
|
||||
print("Embedding failed, using keyword search fallback")
|
||||
rows = conn.execute(
|
||||
"SELECT file_uuid, frame, time_sec, description FROM frames WHERE description LIKE ? LIMIT ?",
|
||||
(f"%{query}%", top_k)
|
||||
).fetchall()
|
||||
if not rows:
|
||||
print("No results")
|
||||
return
|
||||
for r in rows:
|
||||
print(f" [{r[0][:12]}] F{r[1]} ({r[2]:.1f}s): {r[3][:100]}")
|
||||
return
|
||||
# Cosine similarity search
|
||||
rows = conn.execute("SELECT id, file_uuid, frame, time_sec, description, embedding FROM frames").fetchall()
|
||||
scored = []
|
||||
for r in rows:
|
||||
emb_blob = r[5]
|
||||
if not emb_blob or len(emb_blob) < 8:
|
||||
continue
|
||||
emb = struct.unpack(f"{len(emb_blob)//8}d", emb_blob)
|
||||
sim = cosine_similarity(q_emb, emb)
|
||||
scored.append((sim, r[1], r[2], r[3], r[4]))
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
print(f"Top {min(top_k, len(scored))} results for '{query}':")
|
||||
for sim, fu, fr, ts, desc in scored[:top_k]:
|
||||
print(f" [{sim:.3f}] [{fu[:12]}] F{fr} ({ts:.1f}s): {desc[:100]}")
|
||||
|
||||
def cmd_benchmark(file_uuid, count=5):
|
||||
"""Benchmark VLM speed by processing N frames."""
|
||||
times = []
|
||||
for i in range(count):
|
||||
frame = i * 120
|
||||
try:
|
||||
img = get_thumbnail_proxy(file_uuid, frame)
|
||||
except:
|
||||
print(f" Frame {frame}: not available")
|
||||
continue
|
||||
t0 = time.time()
|
||||
desc = vlm_describe(img)
|
||||
elapsed = time.time() - t0
|
||||
times.append(elapsed)
|
||||
print(f" F{frame}: {elapsed:.2f}s — {desc[:60]}")
|
||||
if times:
|
||||
print(f"\nAvg: {sum(times)/len(times):.2f}s, Min: {min(times):.2f}s, Max: {max(times):.2f}s, Total: {sum(times):.1f}s")
|
||||
|
||||
# ─── Main ───
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="VLM Semantic Search for Video Keyframes")
|
||||
sub = parser.add_subparsers(dest="cmd")
|
||||
p_idx = sub.add_parser("index", help="Index a video's keyframes")
|
||||
p_idx.add_argument("file_uuid")
|
||||
p_idx.add_argument("--interval", type=int, default=FRAME_INTERVAL)
|
||||
p_idx.add_argument("--limit", type=int)
|
||||
|
||||
p_search = sub.add_parser("search", help="Search indexed frames")
|
||||
p_search.add_argument("query")
|
||||
p_search.add_argument("--top-k", type=int, default=10)
|
||||
|
||||
sub.add_parser("status", help="Show index status")
|
||||
|
||||
p_bench = sub.add_parser("benchmark", help="Benchmark VLM speed")
|
||||
p_bench.add_argument("file_uuid")
|
||||
p_bench.add_argument("--count", type=int, default=5)
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.cmd == "index":
|
||||
cmd_index(args.file_uuid, args.interval, args.limit)
|
||||
elif args.cmd == "search":
|
||||
cmd_search(args.query, args.top_k)
|
||||
elif args.cmd == "status":
|
||||
cmd_status()
|
||||
elif args.cmd == "benchmark":
|
||||
cmd_benchmark(args.file_uuid, args.count)
|
||||
else:
|
||||
parser.print_help()
|
||||
Reference in New Issue
Block a user