diff --git a/.gitignore b/.gitignore
index 5c61326..5874c93 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,6 +36,7 @@ __pycache__/
venv/
# Runtime data (monitor)
+monitor/ocr_data.db
monitor/chroma_db/
monitor/screenshots/
monitor/monitor_pipe_name.txt
diff --git a/ai_embedding/skill.md b/ai_embedding/skill.md
deleted file mode 100644
index 4b6a6e9..0000000
--- a/ai_embedding/skill.md
+++ /dev/null
@@ -1,244 +0,0 @@
-# CarbonPaper Memory Retrieval Skill
-
-You have access to the user's **CarbonPaper** screenshot history — a continuous, text-searchable archive of everything they've seen on screen. Use the MCP tools below to help them recall information, find past activities, and answer questions about their computer usage history.
-
-## Core Concepts
-
-- **Snapshot**: A screenshot captured at a specific moment, with metadata (process name, window title, timestamp, category) and OCR-extracted text.
-- **OCR results**: Text blocks recognized from screenshots, each with bounding-box coordinates and confidence scores.
-- **Task cluster**: A group of related snapshots automatically clustered by activity patterns (e.g. "Writing report in Word", "Browsing GitHub issues"). Tasks have labels (auto-generated or user-renamed), a dominant process/category, and a time span.
-- **Timestamps**: All timestamps are **milliseconds since Unix epoch**. To convert a human date: `new Date("2025-06-15T10:00:00").getTime()` → `1750006800000`.
-
-## Available Tools
-
-| Tool | Purpose | Key params |
-|------|---------|------------|
-| `search_ocr_text` | Full-text search on OCR text (exact/fuzzy keyword match) | `query`, `limit`, `offset`, `fuzzy`, `process_names`, `start_time`, `end_time`, `categories` |
-| `search_nl` | Natural language semantic search on screenshot images (requires Python service running) | `query`, `limit`, `offset`, `process_names`, `start_time`, `end_time` |
-| `get_snapshots_by_time_range` | List snapshots within a time window (metadata only, no OCR) | `start_time`, `end_time`, `max_records` |
-| `get_snapshot_details` | Full details of one snapshot: metadata + OCR text + task cluster membership | `id`, `include_coords` (default false) |
-| `get_task_clusters` | List task clusters with metadata and relevance scores | `layer`, `start_time`, `end_time`, `hide_inactive` |
-| `get_task_screenshots` | List snapshots in a task cluster (paginated) | `task_id`, `page`, `page_size` |
-| `rename_task` | Rename a task cluster | `task_id`, `label` |
-| `get_smart_clusters` | List smart clusters, assignment counts, and stored AI summaries | none |
-| `get_smart_cluster_ocr_corpus` | Get assigned smart-cluster snapshots with joined OCR text for summarization | `cluster_id`, `page`, `page_size`, `include_empty_ocr` |
-| `get_smart_cluster_summary` | Read the stored AI summary for a smart cluster | `cluster_id` |
-| `upsert_smart_cluster_summary` | Create or replace an AI-generated smart-cluster title, overview, OCR summary, key points, evidence, and model metadata | `cluster_id`, `title`, `summary`, `ocr_summary`, `key_points`, `evidence`, `source_snapshot_count`, `source_hash`, `model_provider`, `model_name`, `prompt_version` |
-| `delete_smart_cluster_summary` | Delete an existing smart-cluster summary without deleting the cluster or snapshots | `cluster_id` |
-
-## Search Strategy Guide
-
-### When to use which search tool
-
-- **`search_ocr_text`** — Best for: specific text the user remembers seeing (error messages, code snippets, names, URLs, numbers). Supports CJK and English. Use `fuzzy: true` (default) for lenient matching, `fuzzy: false` for exact phrases.
-- **`search_nl`** — Best for: describing what was on screen visually ("a chart showing sales data", "a video call with 4 people", "a dark-themed code editor"). This searches by image embedding similarity, not text.
-- **`get_snapshots_by_time_range`** — Best for: browsing what happened during a known time period ("what was I doing yesterday afternoon").
-- **`get_task_clusters`** — Best for: high-level overview of activities over a time span ("what projects was I working on last week").
-- **`get_smart_clusters`** — Best for: listing user-defined natural-language clusters and checking whether an AI summary already exists.
-- **`get_smart_cluster_ocr_corpus`** — Best for: gathering OCR evidence before creating or refreshing a smart-cluster summary.
-
-### Retrieval patterns
-
-**1. Keyword recall** — User remembers specific text they saw:
-```
-search_ocr_text(query="connection refused", limit=10)
-→ find matching snapshots
-→ get_snapshot_details(id=...) for the most relevant one
-```
-
-**2. Time-based recall** — User knows approximately when:
-```
-get_snapshots_by_time_range(start_time=..., end_time=..., max_records=50)
-→ scan metadata to find relevant entries
-→ get_snapshot_details(id=...) for details
-```
-
-**3. Activity recall** — User wants to know what they were working on:
-```
-get_task_clusters(start_time=..., end_time=..., hide_inactive=true)
-→ present task list with labels, dominant processes, and time spans
-→ get_task_screenshots(task_id=...) to drill into a specific task
-```
-
-**4. Combined search** — Start broad, then narrow:
-```
-search_ocr_text(query="API key", process_names=["chrome.exe"], start_time=..., end_time=...)
-→ found in a browser window
-→ get_snapshot_details(id=...) to see full OCR text + task context
-→ get_task_screenshots(task_id=...) to see related activities
-```
-
-**5. Visual recall** — User describes what it looked like:
-```
-search_nl(query="presentation slides with a blue bar chart")
-→ find visually matching screenshots
-→ get_snapshot_details(id=...) for OCR text and task info
-```
-
-**6. Smart-cluster summary creation** — User asks to summarize a smart cluster:
-```
-get_smart_clusters()
-→ identify the target cluster_id and current summary state
-→ get_smart_cluster_ocr_corpus(cluster_id=..., page=0, page_size=50)
-→ synthesize title, summary, OCR summary, key points, and evidence from the returned OCR
-→ upsert_smart_cluster_summary(cluster_id=..., title=..., summary=..., ocr_summary=..., key_points=[...], evidence=[...])
-→ get_smart_cluster_summary(cluster_id=...) to verify the saved summary
-```
-
-**7. Smart-cluster summary removal** — User asks to delete a generated summary:
-```
-delete_smart_cluster_summary(cluster_id=...)
-→ confirm that only the stored summary was removed; the smart cluster and snapshots remain
-```
-
-## Response Data Schemas
-
-### Snapshot record
-```json
-{
- "id": 12345,
- "image_path": "screenshots/2025/06/...",
- "process_name": "chrome.exe",
- "window_title": "GitHub - Pull Request #42",
- "category": "Development",
- "timestamp": 1750006800000,
- "created_at": "2025-06-15 10:00:00",
- "page_url": "https://github.com/...",
- "visible_links": [{"text": "Files changed", "url": "..."}]
-}
-```
-
-### OCR result block (default, without coordinates)
-```json
-{
- "id": 67890,
- "screenshot_id": 12345,
- "text": "def hello_world():",
- "confidence": 0.97
-}
-```
-When `include_coords: true` is passed, each block also includes `"box_coords": [[x1,y1],[x2,y2],[x3,y3],[x4,y4]]`.
-
-### Task cluster
-```json
-{
- "id": 5,
- "label": "Coding",
- "auto_label": "GitHub / chrome.exe",
- "dominant_process": "chrome.exe",
- "dominant_category": "Development",
- "start_time": 1750000000000,
- "end_time": 1750010000000,
- "snapshot_count": 47,
- "layer": "hot"
-}
-```
-
-### Smart cluster
-```json
-{
- "id": 12,
- "anchor_text": "CarbonPaper MCP development",
- "threshold": 0.62,
- "enabled": true,
- "dominant_color": "#4f46e5",
- "assignment_count": 38,
- "summary": {
- "smart_cluster_id": 12,
- "title": "MCP summary workflow",
- "summary": "This cluster collects screenshots about adding smart-cluster summaries.",
- "ocr_summary": "OCR repeatedly mentions MCP tools, smart_cluster_summaries, and frontend display changes.",
- "key_points": ["Schema update", "MCP write/delete tools", "Frontend summary panel"],
- "evidence": [{"screenshot_id": 123, "excerpt": "upsert_smart_cluster_summary"}],
- "source_snapshot_count": 38,
- "model_name": "gpt-5",
- "updated_at": "2026-06-21 12:00:00"
- }
-}
-```
-
-### Smart-cluster OCR corpus item
-```json
-{
- "screenshot_id": 123,
- "rerank_score": 0.83,
- "process_name": "Code.exe",
- "window_title": "mcp_server.rs",
- "created_at": "2026-06-21 11:50:00",
- "assigned_at": "2026-06-21 11:55:00",
- "ocr_text": "upsert_smart_cluster_summary..."
-}
-```
-
-### Snapshot detail (with task)
-```json
-{
- "record": { ... },
- "ocr_results": [ ... ],
- "task": {
- "task_id": 5,
- "task_label": "Coding"
- }
-}
-```
-`task` is `null` if the snapshot has not been assigned to any cluster.
-
-## Best Practices
-
-1. **Start with the cheapest query.** `search_ocr_text` is fast and runs locally; `search_nl` requires the Python service and is slower. Try OCR search first when the user's query contains specific text.
-
-2. **Use filters to narrow results.** All search tools support `start_time`/`end_time` and `process_names` filters. Use them when the user provides temporal or application context — this dramatically improves relevance.
-
-3. **Combine OCR text from multiple blocks.** A single screenshot may have many OCR blocks. When presenting content, concatenate the text fields from `ocr_results` to reconstruct the full visible text.
-
-4. **Use task clusters for context.** When you find a relevant snapshot, check its `task` field. If it belongs to a cluster, mention the task label to give the user higher-level context about what they were doing.
-
-5. **Paginate large result sets.** Use `offset`/`limit` for search results and `page`/`page_size` for task screenshots. Don't request more data than needed.
-
-6. **Create smart-cluster summaries from evidence.** Before writing a summary, inspect the OCR corpus and include concise evidence entries with snapshot IDs and short excerpts. Do not overwrite an existing summary unless the user asked to create, update, or refresh it.
-
-7. **Delete only what was requested.** `delete_smart_cluster_summary` removes the generated summary only. It does not delete the smart cluster, assignments, or screenshots.
-
-8. **Present timestamps in human-readable form.** Convert millisecond timestamps to the user's local time format when displaying results.
-
-9. **Respect privacy.** The data may contain sensitive information (passwords, private messages, financial data visible on screen). Present information factually and don't draw unnecessary attention to sensitive content. If the user asks about sensitive data, just help them find it without editorializing.
-
-## Example Conversations
-
-### "What was the error message I saw earlier today?"
-
-```
-1. search_ocr_text(query="error", start_time=, end_time=, limit=10)
-2. Review results — pick the one that looks like an error message by checking process_name and text
-3. get_snapshot_details(id=) for full OCR text
-4. Present: "At 2:34 PM in Terminal, you saw: 'ConnectionRefusedError: [Errno 111] Connection refused' — this was while you were working on [task_label]."
-```
-
-### "What was I working on last Tuesday?"
-
-```
-1. get_task_clusters(start_time=, end_time=, hide_inactive=true)
-2. Present task list with time spans:
- - "9:00–12:30: Writing CarbonPaper docs (VS Code) — 45 snapshots"
- - "13:00–14:20: Reviewing PRs on GitHub (Chrome) — 23 snapshots"
- - "14:30–17:00: Debugging MCP server (VS Code + Terminal) — 67 snapshots"
-3. If user wants details, use get_task_screenshots(task_id=...) to drill in
-```
-
-### "Find that API documentation page I was reading"
-
-```
-1. search_ocr_text(query="API documentation", process_names=["chrome.exe", "msedge.exe"], limit=10)
-2. If no good results, try: search_nl(query="API documentation webpage")
-3. get_snapshot_details(id=) — check page_url, window_title, and OCR text
-4. Present: "Found it — you were reading 'Stripe API Reference' at https://docs.stripe.com/api at 3:15 PM on June 12th."
-```
-
-### "How much time did I spend on the report this week?"
-
-```
-1. get_task_clusters(start_time=, end_time=)
-2. Filter tasks where label/auto_label/dominant_process relate to report writing
-3. Sum up (end_time - start_time) for matching tasks
-4. Present: "You have a task cluster 'Q2 Report — Word' spanning Mon 9am to Wed 2pm, with 156 snapshots. Total active time window: ~29 hours across 3 days."
-```
diff --git a/monitor/monitor/__init__.py b/monitor/monitor/__init__.py
index 3b77b31..e0e9222 100644
--- a/monitor/monitor/__init__.py
+++ b/monitor/monitor/__init__.py
@@ -289,6 +289,17 @@ def _handle_command(req: dict):
return result
+def _storage_ipc_health_snapshot():
+ try:
+ from storage_client import get_storage_client
+ sc = get_storage_client()
+ if sc and hasattr(sc, 'ipc_health_snapshot'):
+ return sc.ipc_health_snapshot()
+ except Exception as exc:
+ return {'error': str(exc)}
+ return None
+
+
def _handle_command_impl(req: dict):
"""Actual command dispatch logic."""
global _last_seq_no
@@ -341,6 +352,46 @@ def _handle_command_impl(req: dict):
status['ocr_stats'] = _ocr_worker.get_stats()
return status
+ if cmd == 'index_health':
+ storage_ipc = _storage_ipc_health_snapshot()
+ if not _ocr_worker:
+ return {
+ 'status': 'success',
+ 'worker_available': False,
+ 'worker_started': False,
+ 'stats': {},
+ 'postprocess': None,
+ 'storage_ipc': storage_ipc,
+ }
+ try:
+ refresh = bool(req.get('refresh', False))
+ if hasattr(_ocr_worker, 'get_index_health'):
+ result = _ocr_worker.get_index_health(refresh=refresh)
+ if isinstance(result, dict):
+ result['storage_ipc'] = storage_ipc
+ return result
+ return {
+ 'status': 'success',
+ 'worker_available': True,
+ 'worker_started': None,
+ 'stats': _ocr_worker.get_stats() if hasattr(_ocr_worker, 'get_stats') else {},
+ 'postprocess': None,
+ 'storage_ipc': storage_ipc,
+ }
+ except Exception as e:
+ logger.warning('Index health query failed: %s', e)
+ return {'status': 'error', 'error': str(e)}
+
+ if cmd == 'retry_vector_indexing':
+ if not _ocr_worker or not hasattr(_ocr_worker, 'retry_vector_indexing'):
+ return {'status': 'error', 'error': 'Vector indexing retry is not available'}
+ try:
+ limit = int(req.get('limit', 32) or 32)
+ return _ocr_worker.retry_vector_indexing(limit=limit)
+ except Exception as e:
+ logger.warning('Vector indexing retry failed: %s', e)
+ return {'status': 'error', 'error': str(e)}
+
# ----- Configuration commands -----
if cmd == 'update_filters':
filters = req.get('filters', {}) if isinstance(req, dict) else {}
diff --git a/monitor/monitor/worker_process.py b/monitor/monitor/worker_process.py
index 29e3ea8..b2e3c32 100644
--- a/monitor/monitor/worker_process.py
+++ b/monitor/monitor/worker_process.py
@@ -15,6 +15,7 @@
import threading
import time
import traceback
+from collections import OrderedDict
from typing import Any, Dict, Optional
from .worker_supervisor import WorkerSupervisor, attach_response_metadata
@@ -42,6 +43,16 @@ def __init__(self, ocr_worker, classifier, maxsize: Optional[int] = None):
self.dropped = 0
self.processed = 0
self.failed = 0
+ self.vector_failed = 0
+ self.vector_retry_enqueued = 0
+ self.last_indexing_error: Optional[str] = None
+ self.last_indexing_error_at: Optional[float] = None
+ self.max_vector_retry_backlog = max(
+ 1,
+ int(os.environ.get("CARBONPAPER_VECTOR_RETRY_BACKLOG_MAX", "32") or "32"),
+ )
+ self._vector_retry_backlog = OrderedDict()
+ self._stats_lock = threading.Lock()
def start(self):
if self._thread and self._thread.is_alive():
@@ -98,24 +109,43 @@ def _run(self):
finally:
self._queue.task_done()
- def _handle_job(self, job: Dict[str, Any]):
+ def _record_vector_failure(self, job: Dict[str, Any], reason: str):
+ screenshot_id = job.get("screenshot_id")
+ key = str(screenshot_id or job.get("image_hash") or time.time())
+ retry_job = dict(job)
+ retry_job["_vector_retry_only"] = True
+ with self._stats_lock:
+ self.vector_failed += 1
+ self.last_indexing_error = reason
+ self.last_indexing_error_at = time.time()
+ if key in self._vector_retry_backlog:
+ self._vector_retry_backlog.move_to_end(key)
+ self._vector_retry_backlog[key] = retry_job
+ while len(self._vector_retry_backlog) > self.max_vector_retry_backlog:
+ self._vector_retry_backlog.popitem(last=False)
+
+ def _clear_vector_failure(self, job: Dict[str, Any]):
+ screenshot_id = job.get("screenshot_id")
+ key = str(screenshot_id or job.get("image_hash") or "")
+ if not key:
+ return
+ with self._stats_lock:
+ self._vector_retry_backlog.pop(key, None)
+
+ def _handle_vector_indexing(self, job: Dict[str, Any]) -> bool:
from PIL import Image
- from storage_client import get_storage_client
- from . import config
screenshot_id = job.get("screenshot_id")
image_hash = job.get("image_hash", "")
ocr_text = job.get("ocr_text", "")
image_bytes = job.get("image_bytes") or b""
- started = time.perf_counter()
- image_pil = None
if self.ocr_worker.enable_vector_store and self.ocr_worker.vector_store and ocr_text.strip():
try:
image_pil = Image.open(io.BytesIO(image_bytes))
image_pil.load()
t_vector = time.perf_counter()
- self.ocr_worker.vector_store.add_image(
+ result = self.ocr_worker.vector_store.add_image(
image_path=f"memory://{image_hash}",
image=image_pil,
metadata={
@@ -125,13 +155,46 @@ def _handle_job(self, job: Dict[str, Any]):
},
ocr_text=ocr_text,
)
+ if not isinstance(result, dict) or result.get("status") != "success":
+ reason = result.get("error") if isinstance(result, dict) else str(result)
+ self._record_vector_failure(job, reason or "Vector index write failed")
+ logger.warning(
+ "[DIAG:ocr_postprocess] vector add failed screenshot_id=%s reason=%s",
+ screenshot_id,
+ reason,
+ )
+ return False
+ self._clear_vector_failure(job)
logger.debug(
"[DIAG:ocr_postprocess] vector add done screenshot_id=%s elapsed=%.3fs",
screenshot_id,
time.perf_counter() - t_vector,
)
+ return True
except Exception as exc:
+ self._record_vector_failure(job, str(exc))
logger.warning("Vector store add failed: %s", exc)
+ return False
+ return True
+
+ def _handle_job(self, job: Dict[str, Any]):
+ from storage_client import get_storage_client
+ from . import config
+
+ screenshot_id = job.get("screenshot_id")
+ ocr_text = job.get("ocr_text", "")
+ started = time.perf_counter()
+
+ self._handle_vector_indexing(job)
+
+ if job.get("_vector_retry_only"):
+ logger.info(
+ "[DIAG:ocr_postprocess] vector retry done screenshot_id=%s total=%.3fs queue_size=%s",
+ screenshot_id,
+ time.perf_counter() - started,
+ self._queue.qsize(),
+ )
+ return
if self.classifier and config.CLASSIFICATION_ENABLED:
try:
@@ -173,6 +236,49 @@ def _handle_job(self, job: Dict[str, Any]):
self._queue.qsize(),
)
+ def retry_failed_vector_indexing(self, limit: int = 32) -> Dict[str, Any]:
+ limit = max(1, min(int(limit or 32), self.max_vector_retry_backlog))
+ enqueued = 0
+ with self._stats_lock:
+ retry_items = list(self._vector_retry_backlog.items())[:limit]
+ for _key, job in retry_items:
+ try:
+ self._queue.put_nowait(dict(job))
+ enqueued += 1
+ except queue.Full:
+ break
+ with self._stats_lock:
+ self.vector_retry_enqueued += enqueued
+ return {
+ "status": "success",
+ "requested": len(retry_items),
+ "enqueued": enqueued,
+ "queue_full": enqueued < len(retry_items),
+ "backlog_count": self.vector_retry_backlog_count(),
+ }
+
+ def vector_retry_backlog_count(self) -> int:
+ with self._stats_lock:
+ return len(self._vector_retry_backlog)
+
+ def status_snapshot(self) -> Dict[str, Any]:
+ with self._stats_lock:
+ last_error = self.last_indexing_error
+ last_error_at = self.last_indexing_error_at
+ backlog_count = len(self._vector_retry_backlog)
+ return {
+ "queue_size": self._queue.qsize(),
+ "queue_max_size": self._queue.maxsize,
+ "dropped": self.dropped,
+ "processed": self.processed,
+ "failed": self.failed,
+ "vector_failed": self.vector_failed,
+ "vector_retry_enqueued": self.vector_retry_enqueued,
+ "vector_retry_backlog_count": backlog_count,
+ "last_indexing_error": last_error,
+ "last_indexing_error_at": last_error_at,
+ }
+
def _json_safe(obj):
if isinstance(obj, (datetime.datetime, datetime.date)):
@@ -342,6 +448,24 @@ def send_response(response: Dict[str, Any]):
send_response(result)
elif command == "get_stats":
send_response({"status": "success", "stats": ocr_worker.get_stats()})
+ elif command == "get_index_health":
+ storage_ipc = None
+ try:
+ from storage_client import get_storage_client
+ sc = get_storage_client()
+ if sc and hasattr(sc, "ipc_health_snapshot"):
+ storage_ipc = sc.ipc_health_snapshot()
+ except Exception as exc:
+ storage_ipc = {"error": str(exc)}
+ send_response({
+ "status": "success",
+ "stats": ocr_worker.get_stats(),
+ "postprocess": postprocess_queue.status_snapshot(),
+ "worker_storage_ipc": storage_ipc,
+ })
+ elif command == "retry_vector_indexing":
+ limit = int(msg.get("limit", 32) or 32)
+ send_response(postprocess_queue.retry_failed_vector_indexing(limit=limit))
elif command == "search_by_natural_language":
args = msg.get("args", {})
send_response({
@@ -447,6 +571,30 @@ def get_stats(self):
stats["watchdog"] = self.status_snapshot()
return stats
+ def get_index_health(self, refresh: bool = False):
+ snapshot = self.status_snapshot()
+ if not refresh and not snapshot.get("alive"):
+ return {
+ "status": "success",
+ "worker_available": True,
+ "worker_started": False,
+ "stats": self.get_stats(),
+ "postprocess": None,
+ }
+
+ result = self.request("get_index_health", timeout=30)
+ if result.get("status") == "success":
+ result["worker_available"] = True
+ result["worker_started"] = True
+ return result
+ raise RuntimeError(result.get("error", "Model worker index health failed"))
+
+ def retry_vector_indexing(self, limit: int = 32):
+ result = self.request("retry_vector_indexing", {"limit": int(limit or 32)}, timeout=30)
+ if result.get("status") == "success":
+ return result
+ raise RuntimeError(result.get("error", "Model worker vector retry failed"))
+
def pause(self):
logger.info("Model worker proxy paused")
diff --git a/monitor/ocr_data.db b/monitor/ocr_data.db
deleted file mode 100644
index 3070193..0000000
Binary files a/monitor/ocr_data.db and /dev/null differ
diff --git a/monitor/smart_cluster_worker.py b/monitor/smart_cluster_worker.py
index 5e2afd4..39268c5 100644
--- a/monitor/smart_cluster_worker.py
+++ b/monitor/smart_cluster_worker.py
@@ -176,6 +176,9 @@ def _tick(self, force: bool = False) -> bool:
if not force:
idle = self._storage_client.get_idle_state()
+ if not isinstance(idle, dict):
+ logger.warning("[smart_cluster_worker] skipping tick — malformed idle state: %r", idle)
+ return False
if not idle.get("is_idle", False):
logger.debug(
"[smart_cluster_worker] skipping tick — system not idle (idle_secs=%s fullscreen=%s)",
@@ -427,7 +430,7 @@ def _still_idle(self) -> bool:
"""Cheap idle re-check used to abort heavy work mid-batch."""
try:
idle = self._storage_client.get_idle_state()
- return bool(idle.get("is_idle", False))
+ return isinstance(idle, dict) and bool(idle.get("is_idle", False))
except Exception:
return False
diff --git a/monitor/storage_client.py b/monitor/storage_client.py
index 3f8dcb3..12b9fc1 100644
--- a/monitor/storage_client.py
+++ b/monitor/storage_client.py
@@ -7,6 +7,7 @@
import threading
import struct
import os
+import random
from collections import OrderedDict
logger = logging.getLogger(__name__)
@@ -22,6 +23,96 @@
PIPE_CLOSED_WINERRORS = (109, 232)
+def _default_reverse_ipc_timeout_secs() -> float:
+ raw = os.environ.get("CARBONPAPER_REVERSE_IPC_TIMEOUT_SECS", "15") or "15"
+ try:
+ return max(1.0, float(raw))
+ except (TypeError, ValueError):
+ logger.warning("Invalid CARBONPAPER_REVERSE_IPC_TIMEOUT_SECS=%r; using 15s", raw)
+ return 15.0
+
+
+def _env_float(name: str, default: float, minimum: float) -> float:
+ raw = os.environ.get(name, str(default)) or str(default)
+ try:
+ return max(minimum, float(raw))
+ except (TypeError, ValueError):
+ logger.warning("Invalid %s=%r; using %s", name, raw, default)
+ return float(default)
+
+
+def _env_int(name: str, default: int, minimum: int) -> int:
+ raw = os.environ.get(name, str(default)) or str(default)
+ try:
+ return max(minimum, int(raw))
+ except (TypeError, ValueError):
+ logger.warning("Invalid %s=%r; using %s", name, raw, default)
+ return int(default)
+
+
+DEFAULT_REVERSE_IPC_TIMEOUT_SECS = _default_reverse_ipc_timeout_secs()
+DEFAULT_REVERSE_IPC_CIRCUIT_FAILURE_THRESHOLD = _env_int(
+ "CARBONPAPER_REVERSE_IPC_CIRCUIT_FAILURE_THRESHOLD",
+ 3,
+ 1,
+)
+DEFAULT_REVERSE_IPC_CIRCUIT_COOLDOWN_SECS = _env_float(
+ "CARBONPAPER_REVERSE_IPC_CIRCUIT_COOLDOWN_SECS",
+ 15.0,
+ 1.0,
+)
+RETRY_BACKOFF_BASE_SECS = _env_float(
+ "CARBONPAPER_REVERSE_IPC_RETRY_BACKOFF_BASE_SECS",
+ 0.05,
+ 0.01,
+)
+
+READ_RETRY_COMMANDS = {
+ 'get_public_key',
+ 'encrypt_for_chromadb',
+ 'decrypt_from_chromadb',
+ 'decrypt_many_from_chromadb',
+ 'list_screenshots_for_clustering',
+ 'get_screenshots_with_ocr_by_ids',
+ 'get_idle_state',
+ 'smart_cluster_list_enabled',
+ 'smart_cluster_peek_pending',
+ 'smart_cluster_count_pending',
+ 'get_auth_status',
+ 'get_temp_image',
+ 'screenshot_exists',
+}
+
+IDEMPOTENT_RETRY_COMMANDS = {
+ 'update_screenshot_category',
+ 'smart_cluster_enqueue_pending',
+ 'smart_cluster_delete_pending',
+ 'smart_cluster_record_assignment',
+ 'abort_screenshot',
+}
+
+UNSAFE_AFTER_SEND_COMMANDS = {
+ 'save_screenshot',
+ 'save_screenshot_temp',
+ 'commit_screenshot',
+}
+
+SAFE_RETRY_AFTER_SEND_COMMANDS = READ_RETRY_COMMANDS | IDEMPOTENT_RETRY_COMMANDS
+
+
+class ReverseIpcTimeoutError(TimeoutError):
+ """Raised when a reverse IPC storage request exceeds its deadline."""
+
+ def __init__(self, command: str, timeout_secs: float, phase: str = "request"):
+ self.command = command or ""
+ self.timeout_secs = float(timeout_secs)
+ self.phase = phase
+ super().__init__(
+ f"Reverse IPC {phase} timed out after {self.timeout_secs:.1f}s "
+ f"(command={self.command})"
+ )
+
+
def _write_framed_json(handle, payload: bytes) -> None:
if len(payload) > MAX_PIPE_MESSAGE_BYTES:
raise ValueError(f"Request too large (max {MAX_PIPE_MESSAGE_BYTES} bytes)")
@@ -116,6 +207,157 @@ def __init__(self, pipe_name: str):
self._cache_limit = 512
self._auth_token = os.environ.get("CARBONPAPER_REVERSE_IPC_TOKEN", "")
self._seq_no = 0
+ self._last_timeout_at: Optional[float] = None
+ self._last_timeout_command: Optional[str] = None
+ self._circuit_lock = threading.Lock()
+ self._circuit_failure_threshold = DEFAULT_REVERSE_IPC_CIRCUIT_FAILURE_THRESHOLD
+ self._circuit_cooldown_secs = DEFAULT_REVERSE_IPC_CIRCUIT_COOLDOWN_SECS
+ self._circuit_failure_count = 0
+ self._circuit_open_until = 0.0
+ self._circuit_opened_at: Optional[float] = None
+ self._circuit_half_open_probe = False
+ self._circuit_last_failure_at: Optional[float] = None
+ self._circuit_last_error: Optional[str] = None
+ self._circuit_last_command: Optional[str] = None
+
+ def _timeout_response(self, exc: ReverseIpcTimeoutError) -> Dict[str, Any]:
+ self._last_timeout_at = time.time()
+ self._last_timeout_command = exc.command
+ return {
+ 'status': 'error',
+ 'code': 'ipc_timeout',
+ 'command': exc.command,
+ 'phase': exc.phase,
+ 'timeout_secs': exc.timeout_secs,
+ 'error': str(exc),
+ }
+
+ def _build_request_bytes(self, request: Dict[str, Any]) -> bytes:
+ framed_request = dict(request)
+ framed_request['_ipc_keepalive'] = True
+ framed_request['_auth_token'] = self._auth_token
+ self._seq_no += 1
+ framed_request['_seq_no'] = self._seq_no
+ return json.dumps(framed_request).encode('utf-8')
+
+ def _can_retry_after_send(self, command: str) -> bool:
+ return command in SAFE_RETRY_AFTER_SEND_COMMANDS
+
+ def _sleep_before_retry(self, attempt: int, command: str) -> None:
+ delay = RETRY_BACKOFF_BASE_SECS * (2 ** max(0, attempt))
+ delay += random.uniform(0.0, delay * 0.2)
+ logger.debug(
+ "[storage_client] reverse IPC retry backoff command=%s attempt=%s delay=%.3fs",
+ command,
+ attempt + 1,
+ delay,
+ )
+ time.sleep(delay)
+
+ def _circuit_state_locked(self, now: Optional[float] = None) -> str:
+ now = time.monotonic() if now is None else now
+ if self._circuit_open_until > now:
+ return "open"
+ if self._circuit_half_open_probe or self._circuit_failure_count >= self._circuit_failure_threshold:
+ return "half_open"
+ return "closed"
+
+ def _circuit_block_response(self, command: str, state: str, retry_after_secs: float) -> Dict[str, Any]:
+ return {
+ 'status': 'error',
+ 'code': 'ipc_circuit_open',
+ 'command': command,
+ 'circuit_state': state,
+ 'retry_after_secs': max(0.0, retry_after_secs),
+ 'failure_count': self._circuit_failure_count,
+ 'failure_threshold': self._circuit_failure_threshold,
+ 'error': (
+ "Reverse IPC circuit breaker is open after repeated transport failures; "
+ "skipping reconnect attempt"
+ ),
+ }
+
+ def _check_circuit_before_request(self, command: str) -> Optional[Dict[str, Any]]:
+ with self._circuit_lock:
+ now = time.monotonic()
+ if self._circuit_open_until > now:
+ return self._circuit_block_response(
+ command,
+ "open",
+ self._circuit_open_until - now,
+ )
+ if self._circuit_failure_count >= self._circuit_failure_threshold:
+ if self._circuit_half_open_probe:
+ return self._circuit_block_response(command, "half_open", 0.0)
+ self._circuit_half_open_probe = True
+ return None
+ return None
+
+ def _record_ipc_success(self) -> None:
+ with self._circuit_lock:
+ self._circuit_failure_count = 0
+ self._circuit_open_until = 0.0
+ self._circuit_opened_at = None
+ self._circuit_half_open_probe = False
+ self._circuit_last_error = None
+ self._circuit_last_command = None
+
+ def _record_ipc_failure(self, command: str, error: str) -> None:
+ with self._circuit_lock:
+ now = time.monotonic()
+ self._circuit_failure_count += 1
+ self._circuit_half_open_probe = False
+ self._circuit_last_failure_at = time.time()
+ self._circuit_last_error = error
+ self._circuit_last_command = command
+ if self._circuit_failure_count >= self._circuit_failure_threshold:
+ self._circuit_open_until = now + self._circuit_cooldown_secs
+ self._circuit_opened_at = time.time()
+ logger.error(
+ "[storage_client] reverse IPC circuit opened command=%s failures=%s cooldown=%.1fs last_error=%s",
+ command,
+ self._circuit_failure_count,
+ self._circuit_cooldown_secs,
+ error,
+ )
+
+ def ipc_health_snapshot(self) -> Dict[str, Any]:
+ with self._circuit_lock:
+ now = time.monotonic()
+ retry_after = max(0.0, self._circuit_open_until - now)
+ return {
+ 'circuit_state': self._circuit_state_locked(now),
+ 'failure_count': self._circuit_failure_count,
+ 'failure_threshold': self._circuit_failure_threshold,
+ 'cooldown_secs': self._circuit_cooldown_secs,
+ 'retry_after_secs': retry_after,
+ 'opened_at': self._circuit_opened_at,
+ 'last_failure_at': self._circuit_last_failure_at,
+ 'last_error': self._circuit_last_error,
+ 'last_command': self._circuit_last_command,
+ 'last_timeout_at': self._last_timeout_at,
+ 'last_timeout_command': self._last_timeout_command,
+ }
+
+ def _remaining_deadline(self, deadline: float, command: str, timeout_secs: float, phase: str) -> float:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise ReverseIpcTimeoutError(command, timeout_secs, phase=phase)
+ return remaining
+
+ def _trip_request_watchdog(
+ self,
+ timeout_event: threading.Event,
+ command: str,
+ timeout_secs: float,
+ ) -> None:
+ timeout_event.set()
+ logger.error(
+ "[storage_client] reverse IPC watchdog fired command=%s timeout=%.1fs; closing persistent pipe",
+ command,
+ timeout_secs,
+ )
+ self._close_persistent_handle()
def _close_persistent_handle(self) -> None:
handle = self._persistent_handle
@@ -179,7 +421,7 @@ def _cache_set(self, cache: OrderedDict, key: str, value: str) -> None:
if len(cache) > self._cache_limit:
cache.popitem(last=False)
- def _send_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
+ def _send_request(self, request: Dict[str, Any], timeout: Optional[float] = None) -> Dict[str, Any]:
"""
Send a request to the Rust storage service.
@@ -189,63 +431,123 @@ def _send_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
Returns:
Response data.
"""
+ command = str(request.get('command') or '')
+ timeout_secs = max(
+ 0.1,
+ float(timeout if timeout is not None else DEFAULT_REVERSE_IPC_TIMEOUT_SECS),
+ )
+ deadline = time.monotonic() + timeout_secs
+ semaphore_acquired = False
+ lock_acquired = False
+ watchdog_timer = None
+ timeout_event = threading.Event()
+
try:
- self._semaphore.acquire()
- with self._request_lock:
- framed_request = dict(request)
- framed_request['_ipc_keepalive'] = True
- framed_request['_auth_token'] = self._auth_token
- self._seq_no += 1
- framed_request['_seq_no'] = self._seq_no
- request_bytes = json.dumps(framed_request).encode('utf-8')
-
- for attempt in range(2):
- handle = self._connect_persistent_handle()
+ circuit_response = self._check_circuit_before_request(command)
+ if circuit_response is not None:
+ return circuit_response
+
+ remaining = self._remaining_deadline(deadline, command, timeout_secs, "semaphore")
+ semaphore_acquired = self._semaphore.acquire(timeout=remaining)
+ if not semaphore_acquired:
+ raise ReverseIpcTimeoutError(command, timeout_secs, phase="semaphore")
+
+ remaining = self._remaining_deadline(deadline, command, timeout_secs, "request_lock")
+ lock_acquired = self._request_lock.acquire(timeout=remaining)
+ if not lock_acquired:
+ raise ReverseIpcTimeoutError(command, timeout_secs, phase="request_lock")
+
+ remaining = self._remaining_deadline(deadline, command, timeout_secs, "request")
+ watchdog_timer = threading.Timer(
+ remaining,
+ self._trip_request_watchdog,
+ args=(timeout_event, command, timeout_secs),
+ )
+ watchdog_timer.daemon = True
+ watchdog_timer.start()
+
+ for attempt in range(2):
+ self._remaining_deadline(deadline, command, timeout_secs, "connect")
+ handle = self._connect_persistent_handle()
+ request_bytes = self._build_request_bytes(request)
+ try:
+ self._remaining_deadline(deadline, command, timeout_secs, "write")
+ _write_framed_json(handle, request_bytes)
+
+ # Flush pipe to ensure all data has been sent.
try:
- _write_framed_json(handle, request_bytes)
-
- # Flush pipe to ensure all data has been sent.
- try:
- win32file.FlushFileBuffers(handle)
- except pywintypes.error as e:
- if e.winerror not in PIPE_CLOSED_WINERRORS:
- raise
- logger.debug(
- "[storage_client] FlushFileBuffers returned %s; continue reading response",
- e.winerror,
- )
- response = _read_framed_json(handle)
+ self._remaining_deadline(deadline, command, timeout_secs, "flush")
+ win32file.FlushFileBuffers(handle)
except pywintypes.error as e:
- if e.winerror not in PIPE_CLOSED_WINERRORS or attempt >= 1:
+ if e.winerror not in PIPE_CLOSED_WINERRORS:
raise
+ logger.debug(
+ "[storage_client] FlushFileBuffers returned %s; continue reading response",
+ e.winerror,
+ )
+
+ self._remaining_deadline(deadline, command, timeout_secs, "read")
+ response = _read_framed_json(handle)
+ if timeout_event.is_set():
+ raise ReverseIpcTimeoutError(command, timeout_secs, phase="watchdog")
+ except pywintypes.error as e:
+ if timeout_event.is_set():
+ raise ReverseIpcTimeoutError(command, timeout_secs, phase="watchdog")
+ can_retry = (
+ e.winerror in PIPE_CLOSED_WINERRORS
+ and attempt < 1
+ and self._can_retry_after_send(command)
+ )
+ if not can_retry:
+ raise
+ logger.warning(
+ "[storage_client] persistent pipe closed during request command=%s winerror=%s; reconnecting and retrying once",
+ request.get('command'),
+ e.winerror,
+ )
+ self._close_persistent_handle()
+ self._sleep_before_retry(attempt, command)
+ continue
+
+ if response == {'status': 'error', 'error': 'Empty response'}:
+ self._close_persistent_handle()
+ if attempt < 1 and self._can_retry_after_send(command):
logger.warning(
- "[storage_client] persistent pipe closed during request command=%s winerror=%s; reconnecting and retrying once",
+ "[storage_client] persistent pipe returned empty response command=%s; reconnecting and retrying once",
request.get('command'),
- e.winerror,
)
- self._close_persistent_handle()
+ self._sleep_before_retry(attempt, command)
continue
-
- if response == {'status': 'error', 'error': 'Empty response'}:
- self._close_persistent_handle()
- if attempt < 1:
- logger.warning(
- "[storage_client] persistent pipe returned empty response command=%s; reconnecting and retrying once",
- request.get('command'),
- )
- continue
+ self._record_ipc_failure(command, "Empty response")
return response
+ self._record_ipc_success()
+ return response
- raise RuntimeError("Failed to send request to pipe")
+ raise RuntimeError("Failed to send request to pipe")
+ except ReverseIpcTimeoutError as e:
+ self._close_persistent_handle()
+ if e.phase not in ("semaphore", "request_lock"):
+ self._record_ipc_failure(command, str(e))
+ return self._timeout_response(e)
except pywintypes.error as e:
self._close_persistent_handle()
+ self._record_ipc_failure(command, f"IPC error: {e}")
return {'status': 'error', 'error': f'IPC error: {e}'}
except Exception as e:
self._close_persistent_handle()
+ self._record_ipc_failure(command, f"Error: {e}")
return {'status': 'error', 'error': f'Error: {e}'}
finally:
- self._semaphore.release()
+ if watchdog_timer is not None:
+ watchdog_timer.cancel()
+ if lock_acquired:
+ try:
+ self._request_lock.release()
+ except RuntimeError:
+ pass
+ if semaphore_acquired:
+ self._semaphore.release()
def get_public_key(self) -> Optional[bytes]:
"""
@@ -449,7 +751,9 @@ def get_idle_state(self) -> Dict[str, Any]:
"""
response = self._send_request({'command': 'get_idle_state'})
if response.get('status') == 'success':
- return response.get('data', {'is_idle': False, 'idle_secs': 0, 'fullscreen_exclusive': True})
+ data = response.get('data')
+ if isinstance(data, dict):
+ return data
return {'is_idle': False, 'idle_secs': 0, 'fullscreen_exclusive': True}
def smart_cluster_list_enabled(self) -> List[Dict[str, Any]]:
diff --git a/monitor/task_clustering.py b/monitor/task_clustering.py
index 0cf1e01..acb95e4 100644
--- a/monitor/task_clustering.py
+++ b/monitor/task_clustering.py
@@ -1334,6 +1334,23 @@ def _do_run(self) -> bool:
return False
if self._running:
return False
+ storage_client = self._storage_client or getattr(self._manager, "_storage_client", None)
+ if storage_client:
+ try:
+ idle = storage_client.get_idle_state()
+ except Exception as e:
+ logger.warning("Skipping scheduled clustering: idle state unavailable: %s", e)
+ return False
+ if not isinstance(idle, dict):
+ logger.warning("Skipping scheduled clustering: idle state malformed: %r", idle)
+ return False
+ if not idle.get("is_idle", False):
+ logger.debug(
+ "Skipping scheduled clustering: system not idle (idle_secs=%s fullscreen=%s)",
+ idle.get("idle_secs"),
+ idle.get("fullscreen_exclusive"),
+ )
+ return False
if not TaskEmbedder.is_model_available():
logger.debug("Skipping scheduled clustering: MiniLM model not downloaded")
return False
diff --git a/monitor/tests/test_monitor_command_dispatch.py b/monitor/tests/test_monitor_command_dispatch.py
index 15fd14a..a4602c6 100644
--- a/monitor/tests/test_monitor_command_dispatch.py
+++ b/monitor/tests/test_monitor_command_dispatch.py
@@ -6,6 +6,8 @@ def __init__(self, enabled=True, should_raise=False):
self.enable_vector_store = enabled
self.should_raise = should_raise
self.calls = []
+ self.index_health_calls = []
+ self.retry_calls = []
def search_by_natural_language(self, query, n_results, offset, process_names, start_time, end_time):
self.calls.append(
@@ -22,6 +24,23 @@ def search_by_natural_language(self, query, n_results, offset, process_names, st
raise RuntimeError("search failed")
return [{"id": "doc-1", "metadata": {"process_name": "chrome.exe"}}]
+ def get_stats(self):
+ return {"processed_count": 1}
+
+ def get_index_health(self, refresh=False):
+ self.index_health_calls.append(refresh)
+ return {
+ "status": "success",
+ "worker_available": True,
+ "worker_started": bool(refresh),
+ "stats": {"vector_stats": {"count": 7}},
+ "postprocess": {"vector_retry_backlog_count": 2},
+ }
+
+ def retry_vector_indexing(self, limit=32):
+ self.retry_calls.append(limit)
+ return {"status": "success", "enqueued": min(limit, 2)}
+
class DummyScheduler:
def __init__(self):
@@ -209,3 +228,56 @@ def fail_if_called(force=False):
assert result["clustering_scheduler_active"] is True
finally:
_restore_globals(snapshot)
+
+
+def test_index_health_dispatches_to_worker_with_refresh_flag():
+ snapshot = _snapshot_globals()
+ worker = DummyOcrWorker(enabled=True)
+
+ try:
+ mm._auth_token = None
+ mm._last_seq_no = -1
+ mm._ocr_worker = worker
+
+ result = mm._handle_command_impl({"command": "index_health", "refresh": True})
+
+ assert result["status"] == "success"
+ assert result["worker_started"] is True
+ assert result["stats"]["vector_stats"]["count"] == 7
+ assert result["postprocess"]["vector_retry_backlog_count"] == 2
+ assert worker.index_health_calls == [True]
+ finally:
+ _restore_globals(snapshot)
+
+
+def test_index_health_reports_unavailable_without_worker():
+ snapshot = _snapshot_globals()
+ try:
+ mm._auth_token = None
+ mm._last_seq_no = -1
+ mm._ocr_worker = None
+
+ result = mm._handle_command_impl({"command": "index_health"})
+
+ assert result["status"] == "success"
+ assert result["worker_available"] is False
+ assert result["worker_started"] is False
+ finally:
+ _restore_globals(snapshot)
+
+
+def test_retry_vector_indexing_dispatches_to_worker():
+ snapshot = _snapshot_globals()
+ worker = DummyOcrWorker(enabled=True)
+
+ try:
+ mm._auth_token = None
+ mm._last_seq_no = -1
+ mm._ocr_worker = worker
+
+ result = mm._handle_command_impl({"command": "retry_vector_indexing", "limit": 5})
+
+ assert result == {"status": "success", "enqueued": 2}
+ assert worker.retry_calls == [5]
+ finally:
+ _restore_globals(snapshot)
diff --git a/monitor/tests/test_monitor_worker_contracts.py b/monitor/tests/test_monitor_worker_contracts.py
index 04b99c1..3cf3fbf 100644
--- a/monitor/tests/test_monitor_worker_contracts.py
+++ b/monitor/tests/test_monitor_worker_contracts.py
@@ -224,3 +224,53 @@ def fake_request(command, payload=None, timeout=120.0):
"timeout": 30,
},
]
+
+
+def test_model_worker_index_health_does_not_cold_start_by_default(monkeypatch):
+ worker = RestartableModelWorker(storage_pipe=None, data_dir="unused", env={})
+ calls = []
+
+ monkeypatch.setattr(worker, "status_snapshot", lambda: {"alive": False, "state": "stopped"})
+ monkeypatch.setattr(
+ worker,
+ "request",
+ lambda *args, **kwargs: calls.append((args, kwargs)) or {"status": "success"},
+ )
+
+ result = worker.get_index_health(refresh=False)
+
+ assert result["status"] == "success"
+ assert result["worker_available"] is True
+ assert result["worker_started"] is False
+ assert result["stats"]["watchdog"]["alive"] is False
+ assert calls == []
+
+
+def test_model_worker_index_health_and_retry_payload_contract(monkeypatch):
+ worker = RestartableModelWorker(storage_pipe=None, data_dir="unused", env={})
+ calls = []
+ responses = {
+ "get_index_health": {
+ "status": "success",
+ "stats": {"vector_stats": {"count": 3}},
+ "postprocess": {"vector_retry_backlog_count": 1},
+ },
+ "retry_vector_indexing": {"status": "success", "enqueued": 1},
+ }
+
+ def fake_request(command, payload=None, timeout=120.0):
+ calls.append({"command": command, "payload": payload, "timeout": timeout})
+ return responses[command]
+
+ monkeypatch.setattr(worker, "request", fake_request)
+
+ health = worker.get_index_health(refresh=True)
+ retry = worker.retry_vector_indexing(limit=5)
+
+ assert health["worker_available"] is True
+ assert health["worker_started"] is True
+ assert retry == {"status": "success", "enqueued": 1}
+ assert calls == [
+ {"command": "get_index_health", "payload": None, "timeout": 30},
+ {"command": "retry_vector_indexing", "payload": {"limit": 5}, "timeout": 30},
+ ]
diff --git a/monitor/tests/test_ocr_postprocess_queue.py b/monitor/tests/test_ocr_postprocess_queue.py
index 1899f26..6d72cb0 100644
--- a/monitor/tests/test_ocr_postprocess_queue.py
+++ b/monitor/tests/test_ocr_postprocess_queue.py
@@ -7,6 +7,27 @@ class DummyOcrWorker:
vector_store = None
+class DummyVectorWorker:
+ enable_vector_store = True
+
+ def __init__(self, vector_store):
+ self.vector_store = vector_store
+
+
+class FailingVectorStore:
+ def add_image(self, **_kwargs):
+ return {"status": "error", "stage": "add", "error": "chroma down"}
+
+
+class SuccessfulVectorStore:
+ def __init__(self):
+ self.calls = []
+
+ def add_image(self, **kwargs):
+ self.calls.append(kwargs)
+ return {"status": "success", "id": "doc-1", "skipped": False}
+
+
class DummyClassifier:
def __init__(self):
self.calls = []
@@ -60,6 +81,16 @@ def enqueue(self, job):
return True
+def _png_bytes():
+ from PIL import Image
+ import io
+
+ image = Image.new("RGB", (2, 2), color="white")
+ buf = io.BytesIO()
+ image.save(buf, format="PNG")
+ return buf.getvalue()
+
+
def test_ocr_postprocess_queue_drops_when_full():
queue = OcrPostprocessQueue(DummyOcrWorker(), None, maxsize=1)
@@ -112,3 +143,57 @@ def test_process_ocr_enqueues_postprocess_even_when_ocr_text_is_empty(monkeypatc
assert len(postprocess.jobs) == 1
assert postprocess.jobs[0]["ocr_text"] == ""
assert postprocess.jobs[0]["window_title"] == "Editor"
+
+
+def test_vector_indexing_failure_records_retry_backlog():
+ queue = OcrPostprocessQueue(DummyVectorWorker(FailingVectorStore()), None, maxsize=4)
+
+ ok = queue._handle_vector_indexing({
+ "screenshot_id": 42,
+ "image_hash": "hash-42",
+ "window_title": "Editor",
+ "process_name": "code.exe",
+ "timestamp": 123,
+ "ocr_text": "indexed text",
+ "image_bytes": _png_bytes(),
+ })
+
+ snapshot = queue.status_snapshot()
+ assert ok is False
+ assert snapshot["vector_failed"] == 1
+ assert snapshot["vector_retry_backlog_count"] == 1
+ assert snapshot["last_indexing_error"] == "chroma down"
+ assert snapshot["last_indexing_error_at"] is not None
+
+
+def test_vector_retry_enqueues_retry_only_job_without_classification(monkeypatch):
+ vector_store = SuccessfulVectorStore()
+ classifier = DummyClassifier()
+ storage = DummyStorageClient()
+ queue = OcrPostprocessQueue(DummyVectorWorker(vector_store), classifier, maxsize=4)
+ retry_job = {
+ "screenshot_id": 42,
+ "image_hash": "hash-42",
+ "window_title": "Editor",
+ "process_name": "code.exe",
+ "timestamp": 123,
+ "ocr_text": "indexed text",
+ "image_bytes": _png_bytes(),
+ }
+ queue._record_vector_failure(retry_job, "previous failure")
+
+ result = queue.retry_failed_vector_indexing(limit=1)
+ queued = queue._queue.get_nowait()
+ queue._queue.task_done()
+
+ monkeypatch.setattr(config, "CLASSIFICATION_ENABLED", True)
+ monkeypatch.setattr("storage_client.get_storage_client", lambda: storage)
+ queue._handle_job(queued)
+
+ assert result["status"] == "success"
+ assert result["enqueued"] == 1
+ assert queued["_vector_retry_only"] is True
+ assert len(vector_store.calls) == 1
+ assert classifier.calls == []
+ assert storage.updates == []
+ assert queue.vector_retry_backlog_count() == 0
diff --git a/monitor/tests/test_smart_cluster_worker_idle.py b/monitor/tests/test_smart_cluster_worker_idle.py
new file mode 100644
index 0000000..54678e3
--- /dev/null
+++ b/monitor/tests/test_smart_cluster_worker_idle.py
@@ -0,0 +1,27 @@
+import smart_cluster_worker as scw
+
+
+def test_tick_skips_malformed_idle_state_without_crashing():
+ class StorageClient:
+ def __init__(self):
+ self.list_calls = 0
+
+ def smart_cluster_count_pending(self):
+ return 1
+
+ def get_idle_state(self):
+ return None
+
+ def smart_cluster_list_enabled(self):
+ self.list_calls += 1
+ return []
+
+ storage = StorageClient()
+ worker = scw.SmartClusterWorker()
+ worker._storage_client = storage
+
+ try:
+ assert worker._tick(force=False) is False
+ assert storage.list_calls == 0
+ finally:
+ worker._storage_client = None
diff --git a/monitor/tests/test_storage_client_error_paths.py b/monitor/tests/test_storage_client_error_paths.py
index 363fd3e..07bf88b 100644
--- a/monitor/tests/test_storage_client_error_paths.py
+++ b/monitor/tests/test_storage_client_error_paths.py
@@ -1,5 +1,7 @@
import storage_client as sc
import struct
+import threading
+import time
class FakePyWinError(Exception):
@@ -130,3 +132,169 @@ def test_send_request_non_benign_flush_error_becomes_ipc_error(monkeypatch):
assert result["status"] == "error"
assert "IPC error" in result["error"]
+
+
+def test_send_request_times_out_waiting_for_semaphore():
+ client = sc.StorageClient("pipe")
+ assert client._semaphore.acquire(blocking=False)
+ assert client._semaphore.acquire(blocking=False)
+ try:
+ result = client._send_request({"command": "status"}, timeout=0.01)
+ finally:
+ client._semaphore.release()
+ client._semaphore.release()
+
+ assert result["status"] == "error"
+ assert result["code"] == "ipc_timeout"
+ assert result["phase"] == "semaphore"
+ assert result["command"] == "status"
+
+
+def test_send_request_times_out_waiting_for_request_lock():
+ client = sc.StorageClient("pipe")
+ ready = threading.Event()
+ release = threading.Event()
+
+ def hold_lock():
+ client._request_lock.acquire()
+ ready.set()
+ release.wait(timeout=1.0)
+ client._request_lock.release()
+
+ holder = threading.Thread(target=hold_lock, daemon=True)
+ holder.start()
+ assert ready.wait(timeout=1.0)
+ try:
+ result = client._send_request({"command": "status"}, timeout=0.01)
+ finally:
+ release.set()
+ holder.join(timeout=1.0)
+
+ assert result["status"] == "error"
+ assert result["code"] == "ipc_timeout"
+ assert result["phase"] == "request_lock"
+
+
+def test_send_request_watchdog_closes_handle_during_blocking_read(monkeypatch):
+ client = sc.StorageClient("pipe")
+ close_calls = []
+ fake_handle = object()
+
+ monkeypatch.setattr(client, "_connect_persistent_handle", lambda: fake_handle)
+ monkeypatch.setattr(sc, "_write_framed_json", lambda _handle, _payload: None)
+ monkeypatch.setattr(sc.win32file, "FlushFileBuffers", lambda _handle: None)
+
+ def slow_read(_handle):
+ time.sleep(0.15)
+ return {"status": "success"}
+
+ monkeypatch.setattr(sc, "_read_framed_json", slow_read)
+ monkeypatch.setattr(client, "_close_persistent_handle", lambda: close_calls.append(True))
+
+ result = client._send_request({"command": "slow_read"}, timeout=0.1)
+
+ assert result["status"] == "error"
+ assert result["code"] == "ipc_timeout"
+ assert result["phase"] == "watchdog"
+ assert close_calls
+
+
+def test_unsafe_write_command_is_not_retried_after_pipe_close(monkeypatch):
+ state = {"create_calls": 0, "write_calls": 0}
+
+ def create_file(*_args, **_kwargs):
+ state["create_calls"] += 1
+ return object()
+
+ def write_file(_h, _payload):
+ state["write_calls"] += 1
+ raise FakePyWinError(232, "pipe is being closed")
+
+ _install_win32(
+ monkeypatch,
+ create_file=create_file,
+ write_file=write_file,
+ read_file=lambda _h, _s: (0, b""),
+ flush_file=lambda _h: None,
+ )
+
+ client = sc.StorageClient("pipe")
+ result = client._send_request({"command": "save_screenshot_temp"})
+
+ assert result["status"] == "error"
+ assert "IPC error" in result["error"]
+ assert state["create_calls"] == 1
+ assert state["write_calls"] == 1
+
+
+def test_circuit_breaker_opens_after_repeated_transport_failures(monkeypatch):
+ attempts = {"create": 0}
+
+ def create_file(*_args, **_kwargs):
+ attempts["create"] += 1
+ raise FakePyWinError(2, "file not found")
+
+ _install_win32(
+ monkeypatch,
+ create_file=create_file,
+ write_file=lambda _h, payload: (0, len(payload)),
+ read_file=lambda _h, _s: (0, b""),
+ flush_file=lambda _h: None,
+ )
+
+ client = sc.StorageClient("pipe")
+ client._circuit_failure_threshold = 2
+ client._circuit_cooldown_secs = 30.0
+
+ first = client._send_request({"command": "get_auth_status"})
+ second = client._send_request({"command": "get_auth_status"})
+ third = client._send_request({"command": "get_auth_status"})
+
+ assert first["status"] == "error"
+ assert second["status"] == "error"
+ assert third["status"] == "error"
+ assert third["code"] == "ipc_circuit_open"
+ assert attempts["create"] == 2
+
+ snapshot = client.ipc_health_snapshot()
+ assert snapshot["circuit_state"] == "open"
+ assert snapshot["failure_count"] == 2
+ assert snapshot["last_command"] == "get_auth_status"
+
+
+def test_circuit_breaker_snapshot_reports_half_open_after_cooldown():
+ client = sc.StorageClient("pipe")
+ client._circuit_failure_threshold = 2
+ client._circuit_cooldown_secs = 1.0
+ client._record_ipc_failure("get_auth_status", "first")
+ client._record_ipc_failure("get_auth_status", "second")
+ client._circuit_open_until = time.monotonic() - 0.01
+
+ snapshot = client.ipc_health_snapshot()
+
+ assert snapshot["circuit_state"] == "half_open"
+ assert snapshot["retry_after_secs"] == 0.0
+
+
+def test_circuit_breaker_half_open_probe_resets_after_success(monkeypatch):
+ _install_win32(
+ monkeypatch,
+ create_file=lambda *_a, **_k: object(),
+ write_file=lambda _h, payload: (0, len(payload)),
+ read_file=_framed_reader(b'{"status":"success"}'),
+ flush_file=lambda _h: None,
+ )
+
+ client = sc.StorageClient("pipe")
+ client._circuit_failure_threshold = 2
+ client._circuit_cooldown_secs = 1.0
+ client._record_ipc_failure("get_auth_status", "first")
+ client._record_ipc_failure("get_auth_status", "second")
+ client._circuit_open_until = time.monotonic() - 0.01
+
+ result = client._send_request({"command": "get_auth_status"})
+
+ assert result["status"] == "success"
+ snapshot = client.ipc_health_snapshot()
+ assert snapshot["circuit_state"] == "closed"
+ assert snapshot["failure_count"] == 0
diff --git a/monitor/tests/test_storage_client_ipc.py b/monitor/tests/test_storage_client_ipc.py
index c5956a1..5a65a5b 100644
--- a/monitor/tests/test_storage_client_ipc.py
+++ b/monitor/tests/test_storage_client_ipc.py
@@ -188,7 +188,7 @@ def fake_read_file(_handle, size):
monkeypatch.setattr(sc.win32file, "ReadFile", fake_read_file)
monkeypatch.setattr(sc.win32file, "CloseHandle", lambda _h: state.__setitem__("close_calls", state["close_calls"] + 1))
- result = sc.StorageClient("test-pipe")._send_request({"command": "after_idle"})
+ result = sc.StorageClient("test-pipe")._send_request({"command": "get_idle_state"})
assert result == response_obj
assert state["create_calls"] == 2
@@ -251,6 +251,21 @@ def test_is_session_valid_reads_response_flag(monkeypatch):
assert client.is_session_valid() is False
+def test_get_idle_state_treats_success_null_data_as_not_idle(monkeypatch):
+ client = sc.StorageClient("test-pipe")
+ monkeypatch.setattr(
+ client,
+ "_send_request",
+ lambda _req: {"status": "success", "data": None},
+ )
+
+ assert client.get_idle_state() == {
+ "is_idle": False,
+ "idle_secs": 0,
+ "fullscreen_exclusive": True,
+ }
+
+
def test_list_screenshots_for_clustering_uses_expected_payload(monkeypatch):
client = sc.StorageClient("test-pipe")
captured = {}
diff --git a/monitor/tests/test_task_clustering_scheduler_paths.py b/monitor/tests/test_task_clustering_scheduler_paths.py
index 2899136..9040eaa 100644
--- a/monitor/tests/test_task_clustering_scheduler_paths.py
+++ b/monitor/tests/test_task_clustering_scheduler_paths.py
@@ -29,6 +29,80 @@ def run_clustering(
assert scheduler.get_config()["running"] is False
+def test_scheduler_skips_when_system_not_idle_before_model_check(monkeypatch):
+ class Manager:
+ def __init__(self):
+ self.calls = 0
+
+ def run_clustering(
+ self,
+ auto_compress=True,
+ clustering_mode="auto",
+ manual=False,
+ allow_full_low_memory=False,
+ ):
+ self.calls += 1
+ return {"status": "success"}
+
+ class StorageClient:
+ def __init__(self):
+ self.calls = 0
+
+ def get_idle_state(self):
+ self.calls += 1
+ return {"is_idle": False, "idle_secs": 2, "fullscreen_exclusive": False}
+
+ manager = Manager()
+ storage_client = StorageClient()
+ scheduler = tc.ClusteringScheduler(manager, storage_client=storage_client)
+ monkeypatch.setattr(
+ tc.TaskEmbedder,
+ "is_model_available",
+ staticmethod(lambda: (_ for _ in ()).throw(AssertionError("model check should wait for idle"))),
+ )
+
+ result = scheduler._do_run()
+
+ assert result is False
+ assert storage_client.calls == 1
+ assert manager.calls == 0
+ assert scheduler.get_config()["running"] is False
+
+
+def test_scheduler_skips_when_idle_state_is_malformed(monkeypatch):
+ class Manager:
+ def __init__(self):
+ self.calls = 0
+
+ def run_clustering(
+ self,
+ auto_compress=True,
+ clustering_mode="auto",
+ manual=False,
+ allow_full_low_memory=False,
+ ):
+ self.calls += 1
+ return {"status": "success"}
+
+ class StorageClient:
+ def get_idle_state(self):
+ return None
+
+ manager = Manager()
+ scheduler = tc.ClusteringScheduler(manager, storage_client=StorageClient())
+ monkeypatch.setattr(
+ tc.TaskEmbedder,
+ "is_model_available",
+ staticmethod(lambda: (_ for _ in ()).throw(AssertionError("model check should wait for valid idle state"))),
+ )
+
+ result = scheduler._do_run()
+
+ assert result is False
+ assert manager.calls == 0
+ assert scheduler.get_config()["running"] is False
+
+
def test_run_now_returns_already_running():
class Manager:
def run_clustering(
diff --git a/monitor/vector_store.py b/monitor/vector_store.py
index 1743753..28ff270 100644
--- a/monitor/vector_store.py
+++ b/monitor/vector_store.py
@@ -866,7 +866,7 @@ def add_image(
image: Optional[Union[Image.Image, np.ndarray]] = None,
metadata: Optional[Dict[str, Any]] = None,
ocr_text: Optional[str] = None
- ) -> str:
+ ) -> Dict[str, Any]:
"""
Add an image to the vector store.
@@ -877,7 +877,7 @@ def add_image(
ocr_text: OCR-recognised text (stored as searchable document).
Returns:
- Stored document ID.
+ {'status': 'success', 'id': doc_id} or {'status': 'error', ...}.
"""
doc_id = self._compute_id(image_path)
@@ -899,7 +899,7 @@ def add_image(
if existing and existing.get('ids'):
logger.info("[vector_store] add_image skipped, already exists: %s", doc_id)
- return doc_id
+ return {'status': 'success', 'id': doc_id, 'skipped': True}
# Encode image
if image is None:
@@ -958,16 +958,33 @@ def add_image(
logger.info("[vector_store] client.persist() called")
except Exception as e:
logger.error("[vector_store] client.persist() call failed: %s", e)
+ return {
+ 'status': 'error',
+ 'id': doc_id,
+ 'stage': 'persist',
+ 'error': str(e),
+ }
try:
after = self.collection.count()
except Exception:
after = None
logger.info("[vector_store] add_image success id=%s before=%s after=%s", doc_id, before, after)
+ return {
+ 'status': 'success',
+ 'id': doc_id,
+ 'skipped': False,
+ 'before': before,
+ 'after': after,
+ }
except Exception as e:
logger.error("[vector_store] add_image failed id=%s error=%s", doc_id, e)
-
- return doc_id
+ return {
+ 'status': 'error',
+ 'id': doc_id,
+ 'stage': 'add',
+ 'error': str(e),
+ }
def add_images_batch(
self,
diff --git a/scripts/security-guards.cjs b/scripts/security-guards.cjs
index bd4d581..d910a87 100644
--- a/scripts/security-guards.cjs
+++ b/scripts/security-guards.cjs
@@ -49,6 +49,8 @@ const COMMAND_TIERS = {
'commands::storage::storage_soft_delete': 'session_required',
'commands::storage::storage_soft_delete_screenshots': 'session_required',
'commands::storage::storage_get_delete_queue_status': 'session_required',
+ 'commands::storage::storage_get_index_health': 'session_required',
+ 'commands::storage::storage_retry_vector_indexing': 'session_required',
'commands::storage::storage_save_screenshot': 'session_required',
'commands::storage::storage_set_policy': 'session_required',
'commands::storage::storage_get_policy': 'session_required',
diff --git a/src-tauri/src/commands/storage.rs b/src-tauri/src/commands/storage.rs
index 5162b19..6b93068 100644
--- a/src-tauri/src/commands/storage.rs
+++ b/src-tauri/src/commands/storage.rs
@@ -63,9 +63,95 @@ fn redact_policy_for_frontend(policy: &mut serde_json::Value) {
}
}
+fn value_as_i64(value: Option<&serde_json::Value>) -> Option {
+ value.and_then(|v| {
+ v.as_i64()
+ .or_else(|| v.as_u64().and_then(|n| i64::try_from(n).ok()))
+ })
+}
+
+fn compose_index_health_response(
+ storage_stats: storage::IndexStorageStats,
+ monitor_health: Result,
+) -> serde_json::Value {
+ let (monitor_available, python, transport_error) = match monitor_health {
+ Ok(value) => (true, Some(value), None),
+ Err(error) => (false, None, Some(error)),
+ };
+
+ let vector_stats = python
+ .as_ref()
+ .and_then(|value| value.get("stats"))
+ .and_then(|stats| stats.get("vector_stats"))
+ .cloned();
+ let postprocess = python
+ .as_ref()
+ .and_then(|value| value.get("postprocess"))
+ .cloned();
+ let storage_ipc = python
+ .as_ref()
+ .and_then(|value| value.get("storage_ipc"))
+ .cloned();
+ let worker_storage_ipc = python
+ .as_ref()
+ .and_then(|value| value.get("worker_storage_ipc"))
+ .cloned();
+ let vector_rows_count = vector_stats
+ .as_ref()
+ .and_then(|stats| value_as_i64(stats.get("count")));
+ let pending_retry_queue_count = postprocess
+ .as_ref()
+ .and_then(|stats| value_as_i64(stats.get("vector_retry_backlog_count")));
+ let last_indexing_error = postprocess
+ .as_ref()
+ .and_then(|stats| stats.get("last_indexing_error"))
+ .cloned()
+ .unwrap_or(serde_json::Value::Null);
+ let last_indexing_error_at = postprocess
+ .as_ref()
+ .and_then(|stats| stats.get("last_indexing_error_at"))
+ .cloned()
+ .unwrap_or(serde_json::Value::Null);
+ let monitor_error = transport_error
+ .map(serde_json::Value::String)
+ .or_else(|| {
+ python
+ .as_ref()
+ .and_then(|value| value.get("error"))
+ .and_then(|value| value.as_str())
+ .map(|s| serde_json::Value::String(s.to_string()))
+ })
+ .unwrap_or(serde_json::Value::Null);
+
+ serde_json::json!({
+ "status": "success",
+ "screenshots_count": storage_stats.screenshots_count,
+ "ocr_rows_count": storage_stats.ocr_rows_count,
+ "vector_rows_count": vector_rows_count,
+ "pending_retry_queue_count": pending_retry_queue_count,
+ "smart_cluster_pending_count": storage_stats.smart_cluster_pending_count,
+ "delete_queue": storage_stats.delete_queue,
+ "last_indexing_error": last_indexing_error,
+ "last_indexing_error_at": last_indexing_error_at,
+ "monitor_available": monitor_available,
+ "monitor_error": monitor_error,
+ "worker_started": python
+ .as_ref()
+ .and_then(|value| value.get("worker_started"))
+ .cloned()
+ .unwrap_or(serde_json::Value::Null),
+ "storage_ipc": storage_ipc.unwrap_or(serde_json::Value::Null),
+ "worker_storage_ipc": worker_storage_ipc.unwrap_or(serde_json::Value::Null),
+ "vector_status": vector_stats.unwrap_or(serde_json::Value::Null),
+ "postprocess": postprocess.unwrap_or(serde_json::Value::Null),
+ "python": python.unwrap_or(serde_json::Value::Null),
+ })
+}
+
#[cfg(test)]
mod tests {
- use super::{merge_policy_update, redact_policy_for_frontend};
+ use super::{compose_index_health_response, merge_policy_update, redact_policy_for_frontend};
+ use crate::storage::{DeleteQueueStatus, IndexStorageStats};
use serde_json::json;
#[test]
@@ -110,6 +196,61 @@ mod tests {
assert_eq!(policy["mcp_enabled"], true);
assert!(policy.get("mcp_token_encrypted").is_none());
}
+
+ #[test]
+ fn compose_index_health_response_merges_storage_and_monitor_counts() {
+ let storage_stats = IndexStorageStats {
+ screenshots_count: 10,
+ ocr_rows_count: 12,
+ smart_cluster_pending_count: 3,
+ delete_queue: DeleteQueueStatus {
+ pending_screenshots: 1,
+ pending_ocr: 2,
+ running: false,
+ },
+ };
+ let monitor_health = Ok(json!({
+ "status": "success",
+ "worker_started": true,
+ "stats": { "vector_stats": { "count": 9 } },
+ "postprocess": {
+ "vector_retry_backlog_count": 4,
+ "last_indexing_error": "chroma down",
+ "last_indexing_error_at": 123.0
+ }
+ }));
+
+ let response = compose_index_health_response(storage_stats, monitor_health);
+
+ assert_eq!(response["screenshots_count"], 10);
+ assert_eq!(response["ocr_rows_count"], 12);
+ assert_eq!(response["vector_rows_count"], 9);
+ assert_eq!(response["pending_retry_queue_count"], 4);
+ assert_eq!(response["monitor_available"], true);
+ assert_eq!(response["last_indexing_error"], "chroma down");
+ }
+
+ #[test]
+ fn compose_index_health_response_keeps_storage_counts_when_monitor_unavailable() {
+ let storage_stats = IndexStorageStats {
+ screenshots_count: 10,
+ ocr_rows_count: 12,
+ smart_cluster_pending_count: 3,
+ delete_queue: DeleteQueueStatus {
+ pending_screenshots: 1,
+ pending_ocr: 2,
+ running: false,
+ },
+ };
+
+ let response =
+ compose_index_health_response(storage_stats, Err("Monitor not started".to_string()));
+
+ assert_eq!(response["screenshots_count"], 10);
+ assert_eq!(response["vector_rows_count"], serde_json::Value::Null);
+ assert_eq!(response["monitor_available"], false);
+ assert_eq!(response["monitor_error"], "Monitor not started");
+ }
}
#[tauri::command]
@@ -710,6 +851,51 @@ pub async fn storage_get_delete_queue_status(
.map_err(|e| format!("Task join error: {:?}", e))?
}
+#[tauri::command]
+pub async fn storage_get_index_health(
+ credential_state: tauri::State<'_, Arc>,
+ state: tauri::State<'_, Arc>,
+ monitor_state: tauri::State<'_, MonitorState>,
+ refresh_vector: Option,
+) -> Result {
+ check_auth_required(&credential_state)?;
+
+ let storage_state = state.inner().clone();
+ let storage_stats =
+ tokio::task::spawn_blocking(move || storage_state.get_index_storage_stats())
+ .await
+ .map_err(|e| format!("Task join error: {:?}", e))??;
+
+ let monitor_health = monitor::forward_command_to_python(
+ &monitor_state,
+ serde_json::json!({
+ "command": "index_health",
+ "refresh": refresh_vector.unwrap_or(false),
+ }),
+ )
+ .await;
+
+ Ok(compose_index_health_response(storage_stats, monitor_health))
+}
+
+#[tauri::command]
+pub async fn storage_retry_vector_indexing(
+ credential_state: tauri::State<'_, Arc>,
+ monitor_state: tauri::State<'_, MonitorState>,
+ limit: Option,
+) -> Result {
+ check_auth_required(&credential_state)?;
+
+ monitor::forward_command_to_python(
+ &monitor_state,
+ serde_json::json!({
+ "command": "retry_vector_indexing",
+ "limit": limit.unwrap_or(32).clamp(1, 256),
+ }),
+ )
+ .await
+}
+
#[tauri::command]
pub async fn storage_save_screenshot(
credential_state: tauri::State<'_, Arc>,
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 1d01275..4c0347b 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -986,6 +986,8 @@ pub fn run() {
commands::storage::storage_soft_delete,
commands::storage::storage_soft_delete_screenshots,
commands::storage::storage_get_delete_queue_status,
+ commands::storage::storage_get_index_health,
+ commands::storage::storage_retry_vector_indexing,
commands::storage::storage_save_screenshot,
commands::storage::storage_set_policy,
commands::storage::storage_get_policy,
diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs
index 36bf830..21f93a5 100644
--- a/src-tauri/src/mcp_server.rs
+++ b/src-tauri/src/mcp_server.rs
@@ -30,8 +30,6 @@ use tauri::{Emitter, Manager};
const DEFAULT_MCP_PORT: u16 = 23816;
const MCP_PROTOCOL_VERSION: &str = "2025-03-26";
-/// Fallback instructions embedded at compile time.
-const SKILL_INSTRUCTIONS_FALLBACK: &str = include_str!("../../ai_embedding/skill.md");
// ==================== Runtime state ====================
@@ -100,7 +98,6 @@ impl McpRuntimeState {
struct McpServerInner {
app_handle: tauri::AppHandle,
token_hash: [u8; 32],
- skill_instructions: String,
}
fn require_authenticated_session(app_handle: &tauri::AppHandle) -> Result<(), String> {
@@ -223,7 +220,7 @@ async fn handle_mcp(
tracing::info!("MCP request: method={}", req.method);
let resp = match req.method.as_str() {
- "initialize" => handle_initialize(req.id, &state.skill_instructions),
+ "initialize" => handle_initialize(req.id),
"notifications/initialized" => JsonRpcResponse::success(req.id, serde_json::json!({})),
"ping" => JsonRpcResponse::success(req.id, serde_json::json!({})),
"tools/list" => handle_tools_list(req.id),
@@ -237,7 +234,7 @@ async fn handle_mcp(
(StatusCode::OK, headers, Json(resp))
}
-fn handle_initialize(id: Option, instructions: &str) -> JsonRpcResponse {
+fn handle_initialize(id: Option) -> JsonRpcResponse {
JsonRpcResponse::success(
id,
serde_json::json!({
@@ -248,8 +245,7 @@ fn handle_initialize(id: Option, instructions: &str) -> JsonRpcResponse {
"serverInfo": {
"name": "CarbonPaper",
"version": env!("CARGO_PKG_VERSION")
- },
- "instructions": instructions
+ }
}),
)
}
@@ -2061,24 +2057,9 @@ pub async fn start_server(
stop_server(&mcp_runtime).await;
}
- // Load skill instructions from bundled resource, fall back to compile-time embedded version.
- let skill_instructions = app_handle
- .path()
- .resource_dir()
- .ok()
- .map(|dir| dir.join("ai_embedding").join("skill.md"))
- .and_then(|path| std::fs::read_to_string(&path).ok())
- .unwrap_or_else(|| {
- tracing::warn!(
- "Failed to load ai_embedding/skill.md from resource dir, using embedded fallback"
- );
- SKILL_INSTRUCTIONS_FALLBACK.to_string()
- });
-
let inner = Arc::new(McpServerInner {
app_handle: app_handle.clone(),
token_hash,
- skill_instructions,
});
let app = Router::new()
diff --git a/src-tauri/src/monitor.rs b/src-tauri/src/monitor.rs
index 55949fb..f91761e 100644
--- a/src-tauri/src/monitor.rs
+++ b/src-tauri/src/monitor.rs
@@ -32,6 +32,45 @@ use windows::Win32::System::JobObjects::{
};
use windows::Win32::System::Performance::*;
+#[derive(Clone, Debug)]
+struct MonitorRecoveryState {
+ state: String,
+ policy: String,
+ restart_available: bool,
+ last_exit_code: Option,
+ last_error: Option,
+ last_crashed_at_ms: Option,
+ crash_count: u64,
+}
+
+impl Default for MonitorRecoveryState {
+ fn default() -> Self {
+ Self {
+ state: "stopped".to_string(),
+ policy: "manual_restart".to_string(),
+ restart_available: true,
+ last_exit_code: None,
+ last_error: None,
+ last_crashed_at_ms: None,
+ crash_count: 0,
+ }
+ }
+}
+
+impl MonitorRecoveryState {
+ fn to_json(&self) -> serde_json::Value {
+ serde_json::json!({
+ "state": self.state,
+ "policy": self.policy,
+ "restart_available": self.restart_available,
+ "last_exit_code": self.last_exit_code,
+ "last_error": self.last_error,
+ "last_crashed_at_ms": self.last_crashed_at_ms,
+ "crash_count": self.crash_count,
+ })
+ }
+}
+
pub struct MonitorState {
pub process: Mutex
-
-
-
+
{piiEnabled && (
diff --git a/src/components/settings/BrowserExtensionSection.jsx b/src/components/settings/BrowserExtensionSection.jsx
index 3769b32..e4bc337 100644
--- a/src/components/settings/BrowserExtensionSection.jsx
+++ b/src/components/settings/BrowserExtensionSection.jsx
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { invoke } from '@tauri-apps/api/core';
import { Globe } from 'lucide-react';
import { withAuth } from '../../lib/auth_api';
+import { SettingsSwitch } from './SettingsControls';
export default function BrowserExtensionSection() {
const { t } = useTranslation();
@@ -94,18 +95,11 @@ export default function BrowserExtensionSection() {
{t(`settings.extension.enhance.${browser}`)}
- handleEnhanceToggle(browser, !enhance[browser])}
- className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${enhance[browser] ? 'bg-ide-accent' : 'bg-ide-border'
- }`}
+ handleEnhanceToggle(browser, next)}
title={t(`settings.extension.enhance.${browser}`)}
- >
-
-
+ />
)}
diff --git a/src/components/settings/CaptureFiltersSection.jsx b/src/components/settings/CaptureFiltersSection.jsx
index bf16757..42c1ccc 100644
--- a/src/components/settings/CaptureFiltersSection.jsx
+++ b/src/components/settings/CaptureFiltersSection.jsx
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Loader2, Trash2, AlertTriangle, Clock } from 'lucide-react';
+import { SettingsSwitch } from './SettingsControls';
export default function CaptureFiltersSection({
filterSettings,
@@ -132,18 +133,10 @@ export default function CaptureFiltersSection({
{t('settings.captureFilters.ignoreProtected.description')}
-
-
-
+
diff --git a/src/components/settings/FeaturesSection.jsx b/src/components/settings/FeaturesSection.jsx
index 68d9f6c..131da92 100644
--- a/src/components/settings/FeaturesSection.jsx
+++ b/src/components/settings/FeaturesSection.jsx
@@ -1,319 +1,98 @@
-import React, { useState, useEffect, useRef } from 'react';
+import React from 'react';
import { useTranslation } from 'react-i18next';
import { Layers, Database, ChevronDown, RefreshCw, ExternalLink, Sparkles, Download, Zap, RotateCcw, Loader2, AlertTriangle, Play, X } from 'lucide-react';
-import { invoke } from '@tauri-apps/api/core';
-import { listen } from '@tauri-apps/api/event';
-import { withAuth } from '../../lib/auth_api';
-import { getClusteringStatus, runClustering, saveClusteringResults } from '../../lib/task_api';
+import { SettingsButton, SettingsSegmentedControl, SettingsSwitch } from './SettingsControls';
+import { useFeaturesController } from './useFeaturesController';
+
+const FEATURE_MODE_OPTIONS = [
+ {
+ value: 'minimal',
+ config: {
+ classification_enabled: false,
+ clustering_enabled: false,
+ smart_cluster_enabled: false,
+ },
+ },
+ {
+ value: 'basic',
+ config: {
+ classification_enabled: true,
+ clustering_enabled: false,
+ smart_cluster_enabled: false,
+ },
+ },
+ {
+ value: 'organized',
+ config: {
+ classification_enabled: true,
+ clustering_enabled: true,
+ smart_cluster_enabled: false,
+ },
+ },
+ {
+ value: 'smart',
+ config: {
+ classification_enabled: true,
+ clustering_enabled: true,
+ smart_cluster_enabled: true,
+ },
+ },
+];
+
+function getFeatureMode(config) {
+ const match = FEATURE_MODE_OPTIONS.find((option) => (
+ Boolean(config.classification_enabled) === option.config.classification_enabled
+ && Boolean(config.clustering_enabled) === option.config.clustering_enabled
+ && Boolean(config.smart_cluster_enabled) === option.config.smart_cluster_enabled
+ ));
+ return match?.value || 'custom';
+}
export default function FeaturesSection({ monitorStatus }) {
const { t } = useTranslation();
- const [config, setConfig] = useState(null);
- const [loading, setLoading] = useState(true);
- const [models, setModels] = useState([]);
- const [modelsLoading, setModelsLoading] = useState(false);
- const [clusteringDropdownOpen, setClusteringDropdownOpen] = useState(false);
- const [clusteringAdvancedOpen, setClusteringAdvancedOpen] = useState(false);
- const [clusteringRunning, setClusteringRunning] = useState(false);
- const [clusteringError, setClusteringError] = useState(null);
- const [clusteringNotice, setClusteringNotice] = useState(null);
- const [clusteringStatus, setClusteringStatus] = useState(null);
- const [rangeStart, setRangeStart] = useState('');
- const [rangeEnd, setRangeEnd] = useState('');
-
- // Smart Cluster state
- const [scModelAvailable, setScModelAvailable] = useState(false);
- const [scStatus, setScStatus] = useState(null); // { pending_count, enabled_cluster_count, total_cluster_count }
- const [scDownloading, setScDownloading] = useState(false);
- const [scDownloadLog, setScDownloadLog] = useState([]);
- const [scDownloadError, setScDownloadError] = useState(null);
- const scDownloadStartedRef = useRef(false);
-
- const loadConfig = async () => {
- try {
- const result = await invoke('get_advanced_config');
- // Default to true if not present in older configs
- if (result.clustering_enabled === undefined) result.clustering_enabled = true;
- if (result.classification_enabled === undefined) result.classification_enabled = true;
- setConfig(result);
- } catch (err) {
- console.error('Failed to load advanced config:', err);
- } finally {
- setLoading(false);
- }
- };
-
- const loadModels = async () => {
- console.log('[FeaturesSection] loadModels called. monitorStatus:', monitorStatus);
- if (monitorStatus !== 'running') {
- console.log('[FeaturesSection] Aborting loadModels because monitorStatus is not running.');
- return;
- }
- setModelsLoading(true);
- try {
- console.log('[FeaturesSection] Invoking monitor_get_all_models');
- const res = await withAuth(() => invoke('monitor_get_all_models'));
- console.log('[FeaturesSection] Received raw response from backend:', res);
- const parsedRes = typeof res === 'string' ? JSON.parse(res) : res;
- console.log('[FeaturesSection] Parsed response:', parsedRes);
- if (parsedRes && parsedRes.status === 'success' && parsedRes.models) {
- console.log('[FeaturesSection] Successfully setting models state with', parsedRes.models.length, 'items');
- setModels(parsedRes.models);
- } else {
- console.warn('[FeaturesSection] Response format unexpected or not successful:', parsedRes);
- }
- } catch (err) {
- console.error('[FeaturesSection] Failed to fetch models:', err);
- } finally {
- setModelsLoading(false);
- console.log('[FeaturesSection] loadModels completed.');
- }
- };
-
- useEffect(() => {
- loadConfig();
- }, []);
-
- useEffect(() => {
- if (monitorStatus === 'running') {
- loadModels();
- }
- }, [monitorStatus]);
-
- const refreshClusteringStatus = async () => {
- if (monitorStatus !== 'running') return;
- try {
- const result = await getClusteringStatus();
- if (result?.status === 'success') {
- setClusteringStatus(result);
- }
- } catch { /* ignore */ }
- };
-
- useEffect(() => {
- refreshClusteringStatus();
- }, [monitorStatus]);
-
- // Smart Cluster: check model availability + poll status
- const refreshSmartClusterModel = async () => {
- try {
- const modelStatus = await invoke('check_model_files');
- const reranker = modelStatus?.['bge-reranker-v2-m3'];
- setScModelAvailable(reranker?.complete === true);
- } catch (err) {
- console.warn('Failed to check reranker model:', err);
- }
- };
-
- const refreshSmartClusterStatus = async () => {
- try {
- const s = await withAuth(() => invoke('smart_cluster_status'));
- setScStatus(s);
- } catch { /* ignore */ }
- };
-
- useEffect(() => {
- refreshSmartClusterModel();
- refreshSmartClusterStatus();
- const interval = setInterval(() => {
- refreshSmartClusterStatus();
- }, 10000);
- return () => clearInterval(interval);
- }, []);
-
- // Forward install-log to local panel while downloading
- useEffect(() => {
- if (!scDownloading) return;
- let mounted = true;
- let unlisten;
- (async () => {
- try {
- unlisten = await listen('install-log', (event) => {
- if (!mounted) return;
- const line = event?.payload?.line || JSON.stringify(event?.payload || {});
- const ts = new Date().toLocaleTimeString();
- setScDownloadLog((prev) => [...prev, `[${ts}] ${line}`]);
- });
- } catch { /* ignore */ }
- })();
- return () => {
- mounted = false;
- if (unlisten) unlisten();
- };
- }, [scDownloading]);
-
- // Close dropdown on outside click
- useEffect(() => {
- const handler = () => {
- setClusteringDropdownOpen(false);
- };
- if (clusteringDropdownOpen) {
- document.addEventListener('click', handler);
- return () => document.removeEventListener('click', handler);
- }
- }, [clusteringDropdownOpen]);
-
- const saveConfig = async (newConfig) => {
- setConfig(newConfig);
- try {
- await withAuth(() => invoke('set_advanced_config', { config: newConfig }), { autoPrompt: true });
- // Notify python backend
- await withAuth(() => invoke('monitor_update_feature_config', {
- clusteringEnabled: newConfig.clustering_enabled,
- classificationEnabled: newConfig.classification_enabled,
- }), { autoPrompt: true });
- } catch (err) {
- console.error('Failed to save advanced config:', err);
- }
- };
-
- const handleOpenLocation = async (path) => {
- try {
- await invoke('open_path', { path });
- } catch (err) {
- console.error('Failed to open location:', err);
- }
- };
-
- const handleToggle = async (key) => {
- if (!config) return;
- const newConfig = { ...config, [key]: !config[key] };
- await saveConfig(newConfig);
- };
-
- const handleRunClustering = async () => {
- setClusteringRunning(true);
- setClusteringError(null);
- setClusteringNotice(null);
- try {
- const options = { manual: true };
- if (rangeStart) options.startTime = new Date(rangeStart).getTime() / 1000;
- if (rangeEnd) options.endTime = new Date(rangeEnd).getTime() / 1000;
-
- let result = await runClustering(options);
- if (result?.status === 'needs_user_choice') {
- const hasCompleteRange = Boolean(rangeStart && rangeEnd);
- const count = result?.estimate?.count ?? result?.n_total ?? 0;
- const memory = result?.estimate?.memory || {};
- const scope = hasCompleteRange
- ? t('tasks.clusteringRangeScope')
- : t('tasks.clusteringAllScope');
- const reason = result.reason === 'low_memory'
- ? t('tasks.clusteringLowMemoryReason')
- : t('tasks.clusteringLargeRangeReason');
- const useBatched = window.confirm(t('tasks.clusteringDegradePrompt', {
- scope,
- count,
- reason,
- estimatedGb: memory.estimated_peak_bytes
- ? (memory.estimated_peak_bytes / (1024 ** 3)).toFixed(1)
- : '-',
- }));
- result = await runClustering({
- ...options,
- clusteringMode: useBatched ? 'batched' : 'full',
- });
- }
-
- if (result?.status === 'empty') {
- setClusteringError(t('tasks.noData'));
- }
-
- if (result?.clusters?.length) {
- const taskRequests = result.clusters.map((cl) => ({
- auto_label: cl.dominant_process || null,
- dominant_process: cl.dominant_process || null,
- dominant_category: cl.dominant_category || null,
- start_time: cl.start_time || null,
- end_time: cl.end_time || null,
- snapshot_count: cl.snapshot_count || 0,
- layer: 'hot',
- screenshot_ids: (cl.snapshot_ids || []).map((id) => Number(id)),
- confidences: null,
- }));
- await saveClusteringResults(taskRequests);
- setClusteringNotice(t('settings.features.management.clustering.completed', {
- count: taskRequests.length,
- }));
- }
-
- if (result?.degraded) {
- setClusteringNotice(t('tasks.clusteringDegradedNotice', {
- sampleSize: result.sample_size ?? 0,
- assignedCount: result.assigned_count ?? 0,
- }));
- }
-
- await refreshClusteringStatus();
- } catch (err) {
- const msg = String(err?.message || err);
- if (msg.includes('not found') || msg.includes('ModelNotAvailable') || msg.includes('not downloaded')) {
- setClusteringError(t('tasks.modelMissing'));
- } else {
- setClusteringError(msg);
- }
- console.error('Clustering failed:', err);
- } finally {
- setClusteringRunning(false);
- }
- };
-
- const handleDownloadReranker = async () => {
- if (scDownloadStartedRef.current) return;
- scDownloadStartedRef.current = true;
- setScDownloading(true);
- setScDownloadLog([]);
- setScDownloadError(null);
- try {
- setScDownloadLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] Downloading bge-reranker-v2-m3 (uint8, ~570MB)…`]);
- await invoke('download_model', {
- repo: 'onnx-community/bge-reranker-v2-m3-ONNX',
- subdir: 'bge-reranker-v2-m3',
- files: [
- 'config.json',
- 'tokenizer.json',
- 'tokenizer_config.json',
- 'special_tokens_map.json',
- 'onnx/model_uint8.onnx',
- ],
- });
- await invoke('mark_smart_cluster_setup_done', { dismissedPermanently: false });
- await refreshSmartClusterModel();
- } catch (err) {
- setScDownloadError(err?.message || String(err));
- scDownloadStartedRef.current = false;
- } finally {
- setScDownloading(false);
- }
- };
-
- const handleDrainNow = async () => {
- try {
- await withAuth(() => invoke('monitor_smart_cluster_drain_now'), { autoPrompt: true });
- // Refresh status soon after triggering.
- setTimeout(refreshSmartClusterStatus, 500);
- } catch (err) {
- console.warn('Failed to trigger drain_now:', err);
- }
- };
-
- const handleRescanAll = async () => {
- try {
- await withAuth(() => invoke('smart_cluster_rescan_all'), { autoPrompt: true });
- setTimeout(refreshSmartClusterStatus, 500);
- } catch (err) {
- console.warn('Failed to trigger rescan:', err);
- }
- };
-
- const formatSize = (sizeStr) => {
- if (!sizeStr) return '-';
- return sizeStr;
- };
-
- const lastClusteringRunLabel = clusteringStatus?.config?.last_run
- ? new Date(clusteringStatus.config.last_run * 1000).toLocaleString()
- : t('tasks.never');
+ const {
+ config,
+ loading,
+ models,
+ modelsLoading,
+ clusteringDropdownOpen,
+ setClusteringDropdownOpen,
+ clusteringAdvancedOpen,
+ setClusteringAdvancedOpen,
+ clusteringRunning,
+ clusteringError,
+ clusteringNotice,
+ rangeStart,
+ setRangeStart,
+ rangeEnd,
+ setRangeEnd,
+ customControlsOpen,
+ setCustomControlsOpen,
+ scModelAvailable,
+ scStatus,
+ scDownloading,
+ scDownloadLog,
+ scDownloadError,
+ handleOpenLocation,
+ handleFeatureModeChange,
+ handleCustomFeatureToggle,
+ handleClusteringIntervalChange,
+ handleRunClustering,
+ handleDownloadReranker,
+ handleDrainNow,
+ handleRescanAll,
+ formatSize,
+ lastClusteringRunLabel,
+ featureMode,
+ featureModeOptions,
+ selectedFeatureMode,
+ loadModels,
+ } = useFeaturesController({
+ monitorStatus,
+ t,
+ featureModeDefinitions: FEATURE_MODE_OPTIONS,
+ getFeatureMode,
+ });
if (loading || !config) {
return (
@@ -333,152 +112,192 @@ export default function FeaturesSection({ monitorStatus }) {
- {/* 任务聚类 */}
-
-
-
-
{t('settings.features.management.clustering.label', '任务聚类')}
-
{t('settings.features.management.clustering.description', '使用 MiniLM 模型将相似活动分组为长期任务')}
-
+
+
+
{t('settings.features.management.featureMode.label', '功能等级')}
+
{t('settings.features.management.featureMode.description', '选择截图语义功能的启用深度')}
+
+
+
+
+ {featureMode !== 'custom' && (
+
{selectedFeatureMode.description}
+ )}
+
+
handleToggle('clustering_enabled')}
- className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${config.clustering_enabled ? 'bg-ide-accent' : 'bg-ide-border'}`}
+ type="button"
+ onClick={() => setCustomControlsOpen((open) => !open)}
+ className={`flex w-full items-center justify-between gap-3 text-left rounded-lg px-2 py-1.5 transition-colors ${
+ customControlsOpen
+ ? 'bg-ide-panel/70 text-ide-text'
+ : 'text-ide-muted hover:bg-ide-hover hover:text-ide-text'
+ }`}
>
-
+ {t('settings.features.management.featureMode.customControls.label')}
+
-
-
- {/* 聚类间隔设置 - 仅在启用时显示 */}
- {config.clustering_enabled && (
- <>
-
-
-
{t('settings.features.management.clustering.interval_label', '自动聚类间隔')}
-
-
-
{
- e.stopPropagation();
- setClusteringDropdownOpen(!clusteringDropdownOpen);
- }}
- className="flex items-center gap-2 px-4 py-2 bg-ide-panel border border-ide-border rounded-lg text-sm text-ide-text hover:bg-ide-hover transition-colors min-w-[120px]"
- >
- {t(`settings.advanced.clustering.intervals.${config.clustering_interval || '1w'}`)}
-
-
- {clusteringDropdownOpen && (
-
e.stopPropagation()}
- >
- {['1d', '1w', '1m', '6m'].map((interval) => (
-
{
- setClusteringDropdownOpen(false);
- const newConfig = { ...config, clustering_interval: interval };
- await saveConfig(newConfig);
- try {
- await withAuth(() => invoke('monitor_set_clustering_interval', { interval }), { autoPrompt: true });
- } catch { /* best-effort */ }
- }}
- className={`w-full px-4 py-2.5 text-left hover:bg-ide-hover transition-colors flex items-center justify-between ${interval === (config.clustering_interval || '1w') ? 'bg-ide-accent/10' : ''}`}
- >
- {t(`settings.advanced.clustering.intervals.${interval}`)}
- {interval === (config.clustering_interval || '1w') && (
-
- )}
-
- ))}
+
+ {customControlsOpen && (
+
+ {[
+ {
+ key: 'classification_enabled',
+ label: t('settings.features.management.classification.label', '内容分类'),
+ description: t('settings.features.management.classification.description', '使用 BGE 模型自动分类截图内容'),
+ },
+ {
+ key: 'clustering_enabled',
+ label: t('settings.features.management.clustering.label', '任务聚类'),
+ description: t('settings.features.management.clustering.description', '使用 MiniLM 模型将相似活动分组为长期任务'),
+ },
+ {
+ key: 'smart_cluster_enabled',
+ label: t('settings.features.management.smartCluster.label', '智能聚类(按描述自动归档)'),
+ description: scModelAvailable
+ ? t('settings.features.management.smartCluster.description', '输入一句话描述(如 "对加利福尼亚山脉的研究"),自动归档相关快照。仅在系统空闲时计算。')
+ : t('settings.features.management.smartCluster.modelMissing', '请先下载模型'),
+ disabled: !scModelAvailable && !config.smart_cluster_enabled,
+ },
+ ].map((item, index) => (
+
0 ? 'border-t border-ide-border/50 pt-3' : ''}>
+
+
+
{item.label}
+
{item.description}
+
+
handleCustomFeatureToggle(item.key)}
+ disabled={item.disabled}
+ title={item.disabled ? t('settings.features.management.smartCluster.modelMissing', '请先下载模型') : item.label}
+ />
- )}
-
+
+ ))}
+
+ )}
+
+
+
+ {config.clustering_enabled && (
+
+
+
+
{t('settings.features.management.clustering.label', '任务聚类')}
+
{t('settings.features.management.clustering.description', '使用 MiniLM 模型将相似活动分组为长期任务')}
+
-
+
+
+
{t('settings.features.management.clustering.interval_label', '自动聚类间隔')}
+
+
setClusteringAdvancedOpen((v) => !v)}
- className="flex w-full items-center justify-between gap-3 text-left"
+ onClick={(e) => {
+ e.stopPropagation();
+ setClusteringDropdownOpen(!clusteringDropdownOpen);
+ }}
+ className="flex items-center gap-2 px-4 py-2 bg-ide-panel border border-ide-border rounded-lg text-sm text-ide-text hover:bg-ide-hover transition-colors min-w-[120px]"
>
- {t('settings.features.management.clustering.advanced_label', '高级')}
-
+ {t(`settings.advanced.clustering.intervals.${config.clustering_interval || '1w'}`)}
+
-
- {clusteringAdvancedOpen && (
-
-
- setRangeStart(e.target.value)}
- className="px-3 py-2 text-xs bg-ide-panel border border-ide-border rounded-lg text-ide-text focus:outline-none focus:border-ide-accent"
- />
- -
- setRangeEnd(e.target.value)}
- className="px-3 py-2 text-xs bg-ide-panel border border-ide-border rounded-lg text-ide-text focus:outline-none focus:border-ide-accent"
- />
-
-
-
+ {clusteringDropdownOpen && (
+
e.stopPropagation()}
+ >
+ {['1d', '1w', '1m', '6m'].map((interval) => (
{
+ await handleClusteringIntervalChange(interval);
+ }}
+ className={`w-full px-4 py-2.5 text-left hover:bg-ide-hover transition-colors flex items-center justify-between ${interval === (config.clustering_interval || '1w') ? 'bg-ide-accent/10' : ''}`}
>
- {clusteringRunning ? : }
- {t('settings.features.management.clustering.run_now', '立即运行聚类')}
+ {t(`settings.advanced.clustering.intervals.${interval}`)}
+ {interval === (config.clustering_interval || '1w') && (
+
+ )}
-
- {t('tasks.lastRun')}: {lastClusteringRunLabel}
-
-
-
- {clusteringError && (
-
- setClusteringError(null)} />
- {clusteringError}
-
- )}
- {clusteringNotice && (
-
- setClusteringNotice(null)} />
- {clusteringNotice}
-
- )}
+ ))}
)}
- >
- )}
-
-
- {/* 分类 */}
-
-
-
-
{t('settings.features.management.classification.label', '内容分类')}
-
{t('settings.features.management.classification.description', '使用 BGE 模型自动分类截图内容')}
-
handleToggle('classification_enabled')}
- className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${config.classification_enabled ? 'bg-ide-accent' : 'bg-ide-border'}`}
- >
-
-
+
+
+
setClusteringAdvancedOpen((v) => !v)}
+ className="flex w-full items-center justify-between gap-3 text-left"
+ >
+ {t('settings.features.management.clustering.advanced_label', '高级')}
+
+
+
+ {clusteringAdvancedOpen && (
+
+
+ setRangeStart(e.target.value)}
+ className="px-3 py-2 text-xs bg-ide-panel border border-ide-border rounded-lg text-ide-text focus:outline-none focus:border-ide-accent"
+ />
+ -
+ setRangeEnd(e.target.value)}
+ className="px-3 py-2 text-xs bg-ide-panel border border-ide-border rounded-lg text-ide-text focus:outline-none focus:border-ide-accent"
+ />
+
+
+
+ : Play}
+ >
+ {t('settings.features.management.clustering.run_now', '立即运行聚类')}
+
+
+ {t('tasks.lastRun')}: {lastClusteringRunLabel}
+
+
+
+ {clusteringError && (
+
+ setClusteringError(null)} />
+ {clusteringError}
+
+ )}
+ {clusteringNotice && (
+
+ setClusteringNotice(null)} />
+ {clusteringNotice}
+
+ )}
+
+ )}
+
-
+ )}
{/* 智能聚类 (Smart Cluster) */}
+ {(config.smart_cluster_enabled || !scModelAvailable || scDownloading || scDownloadError) && (
-
+
@@ -488,25 +307,9 @@ export default function FeaturesSection({ monitorStatus }) {
{t('settings.features.management.smartCluster.description', '输入一句话描述(如 "对加利福尼亚山脉的研究"),自动归档相关快照。仅在系统空闲时计算。')}
-
scModelAvailable && handleToggle('smart_cluster_enabled')}
- disabled={!scModelAvailable}
- className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${
- !scModelAvailable
- ? 'bg-ide-border opacity-40 cursor-not-allowed'
- : config.smart_cluster_enabled
- ? 'bg-ide-accent'
- : 'bg-ide-border'
- }`}
- title={!scModelAvailable ? t('settings.features.management.smartCluster.modelMissing', '请先下载模型') : undefined}
- >
-
-
- {!config.clustering_enabled && (
+ {config.smart_cluster_enabled && !config.clustering_enabled && (
@@ -521,13 +324,13 @@ export default function FeaturesSection({ monitorStatus }) {
{t('settings.features.management.smartCluster.modelNotDownloaded', 'bge-reranker-v2-m3 (uint8, ~570MB) 尚未下载')}
-
-
{t('settings.features.management.smartCluster.downloadModel', '下载模型')}
-
+
)}
@@ -550,13 +353,14 @@ export default function FeaturesSection({ monitorStatus }) {
{scDownloadError && (
{scDownloadError}
-
-
{t('settings.features.management.smartCluster.retry', '重试')}
-
+
)}
@@ -574,24 +378,23 @@ export default function FeaturesSection({ monitorStatus }) {
-
-
{t('settings.features.management.smartCluster.drainNow', '立即处理待处理队列')}
-
-
+
-
{t('settings.features.management.smartCluster.rescanAll', '全部重新匹配 hot 层')}
-
+
)}
+ )}
diff --git a/src/components/settings/GeneralOptionsSection.jsx b/src/components/settings/GeneralOptionsSection.jsx
index 471c31a..6a706bf 100644
--- a/src/components/settings/GeneralOptionsSection.jsx
+++ b/src/components/settings/GeneralOptionsSection.jsx
@@ -1,10 +1,8 @@
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
-import { invoke } from '@tauri-apps/api/core';
-import { listen } from '@tauri-apps/api/event';
-import { Circle, ChevronDown } from 'lucide-react';
-import { getLightweightConfig, setLightweightConfig, switchToLightweightMode } from '../../lib/lightweight_api';
-import { withAuth } from '../../lib/auth_api';
+import { ChevronDown, Minimize2 } from 'lucide-react';
+import { SettingsButton, SettingsSegmentedControl, SettingsSwitch } from './SettingsControls';
+import { useGeneralOptionsController } from './useGeneralOptionsController';
function DropdownSelect({ value, onChange, options }) {
const [open, setOpen] = useState(false);
@@ -67,122 +65,29 @@ export default function GeneralOptionsSection({
onTogglePowerSaving,
}) {
const { t } = useTranslation();
- const [powerSavingMode, setPowerSavingMode] = useState(externalPowerSavingMode !== false);
- const [gameModeEnabled, setGameModeEnabled] = useState(false);
- const [gameModeActive, setGameModeActive] = useState(false);
- const [gameModePermanent, setGameModePermanent] = useState(false);
- const [fullscreenPaused, setFullscreenPaused] = useState(false);
- const [useDml, setUseDml] = useState(false);
- const [gameModeLoading, setGameModeLoading] = useState(true);
-
- // 轻量模式状态
- const [lightweightConfig, setLightweightConfigState] = useState({
- start_with_window_hidden: false,
- auto_lightweight_enabled: false,
- auto_lightweight_delay_minutes: 5,
- });
-
- const [cardClickBehaviorSearch, setCardClickBehaviorSearch] = useState(() => localStorage.getItem('cardClickBehavior_search') || 'preview');
- const [cardClickBehaviorClusters, setCardClickBehaviorClusters] = useState(() => localStorage.getItem('cardClickBehavior_clusters') || 'standalone');
- const [cardClickBehaviorActivityContext, setCardClickBehaviorActivityContext] = useState(() => localStorage.getItem('cardClickBehavior_activityContext') || 'preview');
-
- useEffect(() => {
- getLightweightConfig().then(setLightweightConfigState).catch(console.error);
- }, []);
-
- // Sync with external power saving mode
- useEffect(() => {
- setPowerSavingMode(externalPowerSavingMode !== false);
- }, [externalPowerSavingMode]);
-
- // Listen for power-saving-changed events from Rust
- useEffect(() => {
- const unlisten = listen('power-saving-changed', (event) => {
- const payload = event.payload || {};
- setPowerSavingMode(payload.enabled !== false);
- });
- return () => {
- unlisten.then((fn) => fn());
- };
- }, []);
-
- const handleTogglePowerSaving = async () => {
- const next = !powerSavingMode;
- // Optimistic update
- setPowerSavingMode(next);
- onTogglePowerSaving(next);
- try {
- await invoke('set_power_saving_enabled', { enabled: next });
- } catch (err) {
- console.error('Failed to toggle power saving mode:', err);
- // Revert on error
- setPowerSavingMode(!next);
- onTogglePowerSaving(!next);
- }
- };
-
- useEffect(() => {
- (async () => {
- try {
- const config = await invoke('get_advanced_config');
- setUseDml(config.use_dml || false);
- setGameModeEnabled(config.game_mode_enabled || false);
-
- // get initial game mode status
- const status = await invoke('get_game_mode_status');
- setGameModeActive(status.active || false);
- setGameModePermanent(status.permanent || false);
- setFullscreenPaused(status.fullscreen_paused || false);
- } catch (err) {
- console.error('Failed to load config for game mode:', err);
- } finally {
- setGameModeLoading(false);
- }
- })();
- }, []);
-
- useEffect(() => {
- const unlisten = listen('game-mode-status', (event) => {
- setGameModeActive(event.payload?.active || false);
- setGameModePermanent(event.payload?.permanent || false);
- if (event.payload?.fullscreen_paused !== undefined) {
- setFullscreenPaused(event.payload.fullscreen_paused);
- }
- });
- return () => {
- unlisten.then((fn) => fn());
- };
- }, []);
-
- const handleToggleGameMode = async () => {
- const next = !gameModeEnabled;
- setGameModeEnabled(next);
- try {
- await withAuth(() => invoke('toggle_game_mode', { enabled: next }), { autoPrompt: true });
- } catch (err) {
- console.error('Failed to toggle game mode:', err);
- setGameModeEnabled(!next);
- }
- };
-
- const handleLightweightConfigChange = async (key, value) => {
- const newConfig = { ...lightweightConfig, [key]: value };
- setLightweightConfigState(newConfig);
- try {
- await setLightweightConfig(newConfig);
- } catch (error) {
- console.error('Failed to save lightweight config:', error);
- }
- };
-
- const handleSwitchToLightweight = async () => {
- try {
- await switchToLightweightMode();
- // 窗口将被销毁,此代码不会执行
- } catch (error) {
- console.error('Failed to switch to lightweight mode:', error);
- }
- };
+ const {
+ powerSavingMode,
+ gameModeEnabled,
+ gameModeActive,
+ gameModePermanent,
+ fullscreenPaused,
+ useDml,
+ gameModeLoading,
+ resourcePolicyLoading,
+ lightweightConfig,
+ cardClickBehaviorSearch,
+ cardClickBehaviorClusters,
+ cardClickBehaviorActivityContext,
+ resourcePolicy,
+ resourcePolicyOptions,
+ selectedResourcePolicy,
+ handleSetPowerSaving,
+ handleSetGameMode,
+ handleResourcePolicyChange,
+ handleLightweightConfigChange,
+ handleSwitchToLightweight,
+ setCardClickBehavior,
+ } = useGeneralOptionsController({ externalPowerSavingMode, onTogglePowerSaving, t });
return (
@@ -195,53 +100,69 @@ export default function GeneralOptionsSection({
{t('settings.general.telemetry.description')}
-
-
-
+ />
- {/* Power saving */}
+ {/* Resource policy */}
-
+
-
-
- {t('settings.general.powerSaving.description')}
-
+
+
{t('settings.general.resourcePolicy.description')}
-
-
-
-
- {/* Game Mode */}
-
+
+
+
{selectedResourcePolicy.description}
+
+ {(resourcePolicy === 'custom') && (
+
+
+
+
+
+ {t('settings.general.powerSaving.description')}
+
+
+
handleSetPowerSaving(next)}
+ disabled={resourcePolicyLoading}
+ title={t('settings.general.powerSaving.label')}
+ />
+
-
-
-
-
+
+
+
+
+
+
+ {t('settings.general.gameMode.description')}
+
+
+
handleSetGameMode(next)}
+ disabled={gameModeLoading || resourcePolicyLoading}
+ title={t('settings.general.gameMode.label')}
+ />
+
-
- {t('settings.general.gameMode.description')}
-
+ )}
+
+
{gameModeEnabled && useDml && (
{gameModePermanent
@@ -258,108 +179,111 @@ export default function GeneralOptionsSection({
)}
-
-
-
- {/* 轻量模式 */}
+ {/* Window and background behavior */}
-
+
-
+
- {t('settings.general.lightweightMode.startHidden.description')}
+ {t('settings.general.windowBehavior.description')}
-
handleLightweightConfigChange('start_with_window_hidden', !lightweightConfig.start_with_window_hidden)}
- className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${lightweightConfig.start_with_window_hidden ? 'bg-ide-accent' : 'bg-ide-border'}`}
- >
-
-
-
-
+
+
+
+
handleLightweightConfigChange('start_with_window_hidden', next)}
+ columns={2}
+ />
+
+ {lightweightConfig.start_with_window_hidden
+ ? t('settings.general.windowBehavior.startup.background.description')
+ : t('settings.general.windowBehavior.startup.showWindow.description')}
+
+
-
-
-
-
- {t('settings.general.lightweightMode.autoSwitch.description')}
-
- {lightweightConfig.auto_lightweight_enabled && (
-
- {t('settings.general.lightweightMode.autoSwitch.delayLabel')}
- {
- const val = parseInt(e.target.value, 10);
- // 只有当值是有效数字时才更新,否则使用默认值 5
- if (!isNaN(val) && val >= 1 && val <= 60) {
- handleLightweightConfigChange('auto_lightweight_delay_minutes', val);
- } else if (e.target.value === '') {
- // 如果用户清空输入框,暂时不更新状态,等待用户输入
- // 但为了避免显示 NaN,我们保持当前值
- }
- }}
- onBlur={(e) => {
- // 失焦时,如果值无效,恢复为默认值 5
- const val = parseInt(e.target.value, 10);
- if (isNaN(val) || val < 1 || val > 60) {
- handleLightweightConfigChange('auto_lightweight_delay_minutes', 5);
- }
- }}
- className="w-16 px-2 py-1 bg-ide-panel border border-ide-border rounded text-ide-text text-xs"
- />
- {t('settings.general.lightweightMode.autoSwitch.delayUnit')}
-
- )}
+
+
+
handleLightweightConfigChange('auto_lightweight_enabled', next)}
+ columns={2}
+ />
+
+ {lightweightConfig.auto_lightweight_enabled
+ ? t('settings.general.windowBehavior.closeWindow.background.description')
+ : t('settings.general.windowBehavior.closeWindow.current.description')}
+
+ {lightweightConfig.auto_lightweight_enabled && (
+
+ {t('settings.general.windowBehavior.delay.label')}
+ {
+ const val = parseInt(e.target.value, 10);
+ if (!isNaN(val) && val >= 1 && val <= 60) {
+ handleLightweightConfigChange('auto_lightweight_delay_minutes', val);
+ }
+ }}
+ onBlur={(e) => {
+ const val = parseInt(e.target.value, 10);
+ if (isNaN(val) || val < 1 || val > 60) {
+ handleLightweightConfigChange('auto_lightweight_delay_minutes', 5);
+ }
+ }}
+ className="w-16 px-2 py-1 bg-ide-panel border border-ide-border rounded text-ide-text text-xs"
+ />
+ {t('settings.general.windowBehavior.delay.unit')}
+
+ )}
+
-
handleLightweightConfigChange('auto_lightweight_enabled', !lightweightConfig.auto_lightweight_enabled)}
- className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${lightweightConfig.auto_lightweight_enabled ? 'bg-ide-accent' : 'bg-ide-border'}`}
- >
-
-
-
-
-
-
-
-
-
- {t('settings.general.lightweightMode.switchNow.description')}
-
+
+
+
+
{t('settings.general.windowBehavior.switchNow.description')}
+
+
+ {t('settings.general.windowBehavior.switchNow.button')}
+
-
- {t('settings.general.lightweightMode.switchNow.button')}
-
{/* Card Click Behavior */}
@@ -379,10 +303,7 @@ export default function GeneralOptionsSection({
{
- setCardClickBehaviorSearch(val);
- localStorage.setItem('cardClickBehavior_search', val);
- }}
+ onChange={(val) => setCardClickBehavior('search', val)}
options={[
{ value: 'preview', label: t('settings.general.cardClickBehavior.preview') },
{ value: 'standalone', label: t('settings.general.cardClickBehavior.standalone') },
@@ -395,10 +316,7 @@ export default function GeneralOptionsSection({
{
- setCardClickBehaviorClusters(val);
- localStorage.setItem('cardClickBehavior_clusters', val);
- }}
+ onChange={(val) => setCardClickBehavior('clusters', val)}
options={[
{ value: 'preview', label: t('settings.general.cardClickBehavior.preview') },
{ value: 'standalone', label: t('settings.general.cardClickBehavior.standalone') },
@@ -411,10 +329,7 @@ export default function GeneralOptionsSection({
{
- setCardClickBehaviorActivityContext(val);
- localStorage.setItem('cardClickBehavior_activityContext', val);
- }}
+ onChange={(val) => setCardClickBehavior('activityContext', val)}
options={[
{ value: 'preview', label: t('settings.general.cardClickBehavior.preview') },
{ value: 'standalone', label: t('settings.general.cardClickBehavior.standalone') },
diff --git a/src/components/settings/MonitorServiceSection.jsx b/src/components/settings/MonitorServiceSection.jsx
index 951bf4b..b5708d2 100644
--- a/src/components/settings/MonitorServiceSection.jsx
+++ b/src/components/settings/MonitorServiceSection.jsx
@@ -1,6 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
-import { Play, Pause, Square as StopSquare, Loader2, RotateCw, HelpCircle, Squirrel, Circle } from 'lucide-react';
+import { Play, Pause, Square as StopSquare, Loader2, RotateCw, Squirrel, Circle } from 'lucide-react';
+import SettingsHelpTooltip from './SettingsHelpTooltip';
+import { SettingsSwitch } from './SettingsControls';
export default function MonitorServiceSection({
monitorStatus,
@@ -23,12 +25,7 @@ export default function MonitorServiceSection({
-
-
-
- {t('settings.general.monitor.tooltip')}
-
-
+
{t('settings.general.monitor.tooltip')}
@@ -114,19 +111,11 @@ export default function MonitorServiceSection({
{t('settings.general.monitor.autoStart.description')}
-
onAutoStartMonitorChange?.(!autoStartMonitor)}
- className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${
- autoStartMonitor ? 'bg-ide-accent' : 'bg-ide-border'
- }`}
+ onAutoStartMonitorChange?.(next)}
title={t('settings.general.monitor.autoStart.tooltip')}
- >
-
-
+ />
diff --git a/src/components/settings/SettingsControls.jsx b/src/components/settings/SettingsControls.jsx
new file mode 100644
index 0000000..794c93c
--- /dev/null
+++ b/src/components/settings/SettingsControls.jsx
@@ -0,0 +1,148 @@
+import React from 'react';
+
+function cx(...classes) {
+ return classes.filter(Boolean).join(' ');
+}
+
+export function SettingsSwitch({
+ checked,
+ onChange,
+ disabled = false,
+ title,
+ className = '',
+}) {
+ return (
+
onChange?.(!checked)}
+ className={cx(
+ 'relative w-10 h-5 shrink-0 rounded-full transition-colors',
+ disabled
+ ? 'bg-ide-border opacity-50 cursor-not-allowed'
+ : checked
+ ? 'bg-ide-accent'
+ : 'bg-ide-border',
+ className,
+ )}
+ >
+
+
+ );
+}
+
+const BUTTON_VARIANTS = {
+ primary: 'bg-ide-accent hover:bg-ide-accent/90 text-white border-transparent',
+ secondary: 'bg-ide-panel border-ide-border hover:bg-ide-hover/40 text-ide-text',
+ ghost: 'border-transparent text-ide-muted hover:text-ide-text hover:bg-ide-hover',
+ danger: 'text-red-400 hover:text-red-300 hover:bg-red-500/10 border-red-500/30',
+};
+
+const BUTTON_SIZES = {
+ xs: 'px-2 py-1 text-xs',
+ sm: 'px-3 py-1.5 text-xs',
+ md: 'px-4 py-2 text-sm',
+};
+
+export function SettingsButton({
+ children,
+ icon: Icon,
+ variant = 'secondary',
+ size = 'sm',
+ className = '',
+ disabled = false,
+ ...props
+}) {
+ return (
+
+ {Icon && (React.isValidElement(Icon) ? Icon : )}
+ {children}
+
+ );
+}
+
+export function SettingsSegmentedControl({
+ value,
+ options,
+ onChange,
+ columns,
+ density = 'compact',
+ disabled = false,
+ className = '',
+}) {
+ const isCard = density === 'card';
+
+ return (
+
+ {options.map((option) => {
+ const selected = option.value === value;
+ const optionDisabled = disabled || option.disabled;
+ const selectedClass = option.selectedClassName
+ || (isCard
+ ? 'border-ide-accent bg-ide-accent/15 text-ide-text'
+ : 'bg-ide-accent text-white');
+ const idleClass = option.idleClassName
+ || (isCard
+ ? 'border-ide-border bg-ide-panel/40 text-ide-muted hover:border-ide-accent/50 hover:bg-ide-hover hover:text-ide-text'
+ : 'text-ide-muted hover:bg-ide-hover hover:text-ide-text');
+
+ return (
+ onChange?.(option.value)}
+ className={cx(
+ isCard
+ ? 'min-h-[74px] rounded-lg border px-3 py-2 text-left transition-colors'
+ : 'min-h-[34px] rounded-md px-2 py-1 text-xs font-medium transition-colors',
+ selected ? selectedClass : idleClass,
+ optionDisabled && !selected
+ ? isCard
+ ? 'opacity-50 cursor-not-allowed hover:border-ide-border hover:bg-ide-panel/40 hover:text-ide-muted'
+ : 'opacity-60 cursor-default hover:bg-transparent hover:text-ide-muted'
+ : '',
+ option.className,
+ )}
+ >
+
+ {option.label}
+
+ {isCard && option.description && (
+
+ {option.description}
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/settings/SettingsDialog.jsx b/src/components/settings/SettingsDialog.jsx
index bdc6e67..aa74291 100644
--- a/src/components/settings/SettingsDialog.jsx
+++ b/src/components/settings/SettingsDialog.jsx
@@ -1,10 +1,7 @@
-import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { invoke } from '@tauri-apps/api/core';
import { Settings as SettingsIcon, Shield, Info, Activity, Image as ImageIcon, Database, HardDrive, Wrench, Languages, Globe, Plug, Sparkles } from 'lucide-react';
import { Dialog } from '../Dialog';
-import { updateMonitorFilters, deleteRecordsByTimeRange } from '../../lib/monitor_api';
-import { getAnalysisOverview } from '../../lib/analysis_api';
import MonitorServiceSection from './MonitorServiceSection';
import GeneralOptionsSection from './GeneralOptionsSection';
import CaptureFiltersSection from './CaptureFiltersSection';
@@ -16,10 +13,7 @@ import FeaturesSection from './FeaturesSection';
import LanguageSection from './LanguageSection';
import BrowserExtensionSection from './BrowserExtensionSection';
import AiEmbeddingSection from './AiEmbeddingSection';
-import { defaultFilterSettings, formatInvokeError, normalizeList } from './filterUtils';
-import { REFRESH_INTERVAL_MS } from './analysisUtils';
-import { checkForUpdate, downloadAndInstallUpdate } from '../../lib/update_api';
-import { withAuth } from '../../lib/auth_api';
+import { useSettingsDialogController } from './useSettingsDialogController';
function SettingsDialog({
isOpen,
@@ -37,345 +31,61 @@ function SettingsDialog({
powerSavingMode,
onPowerSavingModeChange,
}) {
+ const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('general');
- const [lowResolutionAnalysis, setLowResolutionAnalysis] = useState(() => localStorage.getItem('lowResolutionAnalysis') === 'true');
- const [sendTelemetryDiagnostics, setSendTelemetryDiagnostics] = useState(() => localStorage.getItem('sendTelemetryDiagnostics') === 'true');
- const [monitorStatus, setMonitorStatus] = useState('stopped');
- const monitorStatusRef = useRef('stopped');
- const [filterSettings, setFilterSettings] = useState(() => {
- try {
- const saved = JSON.parse(localStorage.getItem('monitorFilters') || 'null');
- if (saved && typeof saved === 'object') {
- return {
- ...defaultFilterSettings,
- ...saved,
- processes: Array.isArray(saved.processes) ? saved.processes : [],
- titles: Array.isArray(saved.titles) ? saved.titles : [],
- ignoreProtected: typeof saved.ignoreProtected === 'boolean' ? saved.ignoreProtected : true,
- };
- }
- } catch (e) {
- console.warn('Failed to read saved filters', e);
- }
- return defaultFilterSettings;
+ const {
+ lowResolutionAnalysis,
+ toggleLowResolutionAnalysis,
+ sendTelemetryDiagnostics,
+ toggleTelemetryDiagnostics,
+ monitorStatus,
+ filterSettings,
+ processInput,
+ setProcessInput,
+ titleInput,
+ setTitleInput,
+ filtersDirty,
+ savingFilters,
+ saveFiltersMessage,
+ autoLaunchEnabled,
+ autoLaunchLoading,
+ autoLaunchMessage,
+ storage,
+ analysisLoading,
+ analysisRefreshing,
+ analysisError,
+ checkingUpdate,
+ upToDate,
+ updateInfo,
+ updateError,
+ downloading,
+ downloadProgress,
+ isDeleting,
+ deleteMessage,
+ addProcessTags,
+ addTitleTags,
+ removeProcessTag,
+ removeTitleTag,
+ handleToggleProtected,
+ handleQuickDelete,
+ handleSaveFilters,
+ handleStartMonitor,
+ handleStopMonitor,
+ handleRestartMonitor,
+ handlePauseMonitor,
+ handleResumeMonitor,
+ handleToggleAutoLaunch,
+ handleRefreshAnalysis,
+ handleCheckUpdate,
+ handleDownloadUpdate,
+ } = useSettingsDialogController({
+ isOpen,
+ activeTab,
+ onManualStartMonitor,
+ onManualStopMonitor,
+ onRecordsDeleted,
+ t,
});
- const [processInput, setProcessInput] = useState('');
- const [titleInput, setTitleInput] = useState('');
- const [filtersDirty, setFiltersDirty] = useState(false);
- const [savingFilters, setSavingFilters] = useState(false);
- const [saveFiltersMessage, setSaveFiltersMessage] = useState('');
- const [autoLaunchEnabled, setAutoLaunchEnabled] = useState(null);
- const [autoLaunchLoading, setAutoLaunchLoading] = useState(false);
- const [autoLaunchMessage, setAutoLaunchMessage] = useState('');
-
- const [memorySeries, setMemorySeries] = useState([]);
- const [storage, setStorage] = useState(null);
- const [analysisLoading, setAnalysisLoading] = useState(true);
- const [analysisRefreshing, setAnalysisRefreshing] = useState(false);
- const [analysisError, setAnalysisError] = useState('');
- const [checkingUpdate, setCheckingUpdate] = useState(false);
- const [upToDate, setUpToDate] = useState(false);
- const [updateInfo, setUpdateInfo] = useState(null); // { version, body }
- const [updateError, setUpdateError] = useState('');
- const [downloading, setDownloading] = useState(false);
- const [downloadProgress, setDownloadProgress] = useState({ downloaded: 0, contentLength: 0 });
- const [isDeleting, setIsDeleting] = useState(false);
- const [deleteMessage, setDeleteMessage] = useState('');
-
- const checkMonitorStatus = async () => {
- try {
- const resString = await invoke('get_monitor_status');
- try {
- const res = JSON.parse(resString);
- if (res.stopped) {
- setMonitorStatus('stopped');
- monitorStatusRef.current = 'stopped';
- } else if (res.paused) {
- setMonitorStatus('paused');
- monitorStatusRef.current = 'paused';
- } else {
- setMonitorStatus('running');
- monitorStatusRef.current = 'running';
- }
- } catch (parseError) {
- setMonitorStatus('running');
- monitorStatusRef.current = 'running';
- }
- } catch (e) {
- if (monitorStatusRef.current === 'waiting') {
- return;
- }
- setMonitorStatus('stopped');
- monitorStatusRef.current = 'stopped';
- }
- };
-
- const addProcessTags = () => {
- const items = normalizeList(processInput);
- if (!items.length) return;
- setFilterSettings((prev) => {
- const merged = Array.from(new Set([...(prev.processes || []), ...items]));
- return { ...prev, processes: merged };
- });
- setProcessInput('');
- setFiltersDirty(true);
- setSaveFiltersMessage('');
- };
-
- const addTitleTags = () => {
- const items = normalizeList(titleInput);
- if (!items.length) return;
- setFilterSettings((prev) => {
- const merged = Array.from(new Set([...(prev.titles || []), ...items]));
- return { ...prev, titles: merged };
- });
- setTitleInput('');
- setFiltersDirty(true);
- setSaveFiltersMessage('');
- };
-
- const removeProcessTag = (tag) => {
- setFilterSettings((prev) => ({
- ...prev,
- processes: (prev.processes || []).filter((p) => p !== tag),
- }));
- setFiltersDirty(true);
- setSaveFiltersMessage('');
- };
-
- const removeTitleTag = (tag) => {
- setFilterSettings((prev) => ({
- ...prev,
- titles: (prev.titles || []).filter((t) => t !== tag),
- }));
- setFiltersDirty(true);
- setSaveFiltersMessage('');
- };
-
- const syncFiltersToMonitor = async (filtersPayload = filterSettings) => {
- if (monitorStatus !== 'running') {
- return { ok: false, reason: 'not_running' };
- }
- try {
- await updateMonitorFilters({
- processes: filtersPayload.processes,
- titles: filtersPayload.titles,
- ignore_protected: filtersPayload.ignoreProtected,
- });
- return { ok: true };
- } catch (e) {
- if (e?.code === 'unsupported') {
- return { ok: false, reason: 'unsupported' };
- }
- return { ok: false, reason: 'error', error: e };
- }
- };
-
- const handleQuickDelete = async (minutes) => {
- setIsDeleting(true);
- setDeleteMessage('');
- try {
- const result = await deleteRecordsByTimeRange(minutes);
- if (result.error) {
- setDeleteMessage(t('settings.delete.failure', { error: result.error }));
- } else {
- const count = result.deleted_count || 0;
- setDeleteMessage(t('settings.delete.success', { count }));
- onRecordsDeleted?.();
- }
- } catch (e) {
- setDeleteMessage(t('settings.delete.failure', { error: e?.message || e }));
- } finally {
- setIsDeleting(false);
- }
- };
-
- const handleSaveFilters = async () => {
- setSavingFilters(true);
- setSaveFiltersMessage('');
-
- const nextFilters = { ...filterSettings };
-
- setFilterSettings(nextFilters);
- setFiltersDirty(false);
-
- const result = await syncFiltersToMonitor(nextFilters);
- setSavingFilters(false);
- if (result.ok) {
- setSaveFiltersMessage(t('settings.save_filters.synced'));
- } else if (result.reason === 'not_running') {
- setSaveFiltersMessage(t('settings.save_filters.saved_local_not_running'));
- } else if (result.reason === 'unsupported') {
- setSaveFiltersMessage(t('settings.save_filters.saved_local_unsupported'));
- } else {
- setSaveFiltersMessage(t('settings.save_filters.saved_local_sync_failed', { error: result.error?.message || result.error || 'Unknown error' }));
- }
- };
-
- const handleStartMonitor = async () => {
- setMonitorStatus('waiting');
- monitorStatusRef.current = 'waiting';
- onManualStartMonitor?.();
- try {
- await withAuth(() => invoke('start_monitor'), { autoPrompt: true });
- } catch (e) {
- console.error('Failed to start monitor', e);
- setMonitorStatus('stopped');
- monitorStatusRef.current = 'stopped';
- }
- };
-
- const handleStopMonitor = async () => {
- setMonitorStatus('loading');
- monitorStatusRef.current = 'loading';
- try {
- await withAuth(() => invoke('stop_monitor'), { autoPrompt: true });
- } catch (e) {
- console.error('Failed to stop monitor', e);
- } finally {
- onManualStopMonitor?.();
- setMonitorStatus('stopped');
- monitorStatusRef.current = 'stopped';
- }
- };
-
- const handleRestartMonitor = async () => {
- setMonitorStatus('loading');
- monitorStatusRef.current = 'loading';
- try {
- await withAuth(() => invoke('stop_monitor'), { autoPrompt: true });
- setMonitorStatus('waiting');
- monitorStatusRef.current = 'waiting';
- await withAuth(() => invoke('start_monitor'), { autoPrompt: true });
- await checkMonitorStatus();
- } catch (e) {
- console.error('Failed to restart monitor', e);
- setMonitorStatus('stopped');
- monitorStatusRef.current = 'stopped';
- await checkMonitorStatus();
- }
- };
-
- const handlePauseMonitor = async () => {
- try {
- await withAuth(() => invoke('pause_monitor'), { autoPrompt: true });
- await checkMonitorStatus();
- } catch (e) {
- console.error(e);
- }
- };
-
- const handleResumeMonitor = async () => {
- try {
- await withAuth(() => invoke('resume_monitor'), { autoPrompt: true });
- await checkMonitorStatus();
- } catch (e) {
- console.error(e);
- }
- };
-
- const refreshAutoLaunchStatus = async () => {
- setAutoLaunchLoading(true);
- setAutoLaunchMessage('');
- try {
- const enabled = await invoke('get_autostart_status');
- setAutoLaunchEnabled(Boolean(enabled));
- } catch (e) {
- setAutoLaunchMessage(e?.message || t('settings.autolaunch.read_error'));
- setAutoLaunchEnabled(null);
- } finally {
- setAutoLaunchLoading(false);
- }
- };
-
- const handleToggleAutoLaunch = async () => {
- setAutoLaunchLoading(true);
- setAutoLaunchMessage('');
- try {
- const next = !(autoLaunchEnabled ?? false);
- const result = await invoke('set_autostart', { enabled: next });
- setAutoLaunchEnabled(Boolean(result));
- setAutoLaunchMessage(Boolean(result) ? t('settings.autolaunch.enabled') : t('settings.autolaunch.disabled'));
- } catch (e) {
- setAutoLaunchMessage(t('settings.autolaunch.action_failed', { error: formatInvokeError(e) }));
- } finally {
- setAutoLaunchLoading(false);
- }
- };
-
- const loadAnalysisOverview = useCallback(
- async (forceStorage = false) => {
- try {
- setAnalysisError('');
- if (!analysisRefreshing) {
- setAnalysisLoading(true);
- }
- const result = await getAnalysisOverview(forceStorage);
- setMemorySeries(result?.memory || []);
- setStorage(result?.storage || null);
- } catch (err) {
- setAnalysisError(err?.message || t('settings.analysis.load_failed', { error: '' }));
- } finally {
- setAnalysisLoading(false);
- setAnalysisRefreshing(false);
- }
- },
- [analysisRefreshing],
- );
-
- const handleRefreshAnalysis = () => {
- setAnalysisRefreshing(true);
- loadAnalysisOverview(true);
- };
-
- useEffect(() => {
- let interval;
- if (isOpen) {
- checkMonitorStatus();
- refreshAutoLaunchStatus();
- interval = setInterval(checkMonitorStatus, 2000);
- }
- return () => clearInterval(interval);
- }, [isOpen]);
-
- useEffect(() => {
- localStorage.setItem('monitorFilters', JSON.stringify(filterSettings));
- }, [filterSettings]);
-
- useEffect(() => {
- if (monitorStatus === 'running') {
- syncFiltersToMonitor();
- }
- }, [monitorStatus]);
-
- useEffect(() => {
- monitorStatusRef.current = monitorStatus;
- }, [monitorStatus]);
-
- useEffect(() => {
- try {
- localStorage.setItem('lowResolutionAnalysis', lowResolutionAnalysis ? 'true' : 'false');
- } catch (e) {
- /* ignore */
- }
- }, [lowResolutionAnalysis]);
-
- useEffect(() => {
- try {
- localStorage.setItem('sendTelemetryDiagnostics', sendTelemetryDiagnostics ? 'true' : 'false');
- } catch (e) {
- /* ignore */
- }
- }, [sendTelemetryDiagnostics]);
-
- useEffect(() => {
- if (!isOpen || activeTab !== 'analysis') return undefined;
- loadAnalysisOverview(false);
- const timer = setInterval(() => loadAnalysisOverview(false), REFRESH_INTERVAL_MS);
- return () => clearInterval(timer);
- }, [isOpen, activeTab, loadAnalysisOverview]);
-
- const { t } = useTranslation();
const storageSegments = useMemo(() => {
if (!storage) return [];
@@ -385,44 +95,10 @@ function SettingsDialog({
{ key: 'database', label: t('settings.storage.database'), bytes: storage.database_bytes, icon: Database, color: 'bg-emerald-500/70' },
{ key: 'other', label: t('settings.storage.other'), bytes: storage.other_bytes, icon: HardDrive, color: 'bg-amber-500/70' },
];
- }, [storage]);
+ }, [storage, t]);
const totalStorage = storage?.total_bytes || 0;
- const handleCheckUpdate = async () => {
- setCheckingUpdate(true);
- setUpToDate(false);
- setUpdateInfo(null);
- setUpdateError('');
- try {
- const result = await checkForUpdate();
- if (result.available) {
- setUpdateInfo({ version: result.version, body: result.body });
- } else {
- setUpToDate(true);
- }
- } catch (err) {
- setUpdateError(err?.message || String(err));
- } finally {
- setCheckingUpdate(false);
- }
- };
-
- const handleDownloadUpdate = async () => {
- if (!updateInfo) return;
- setDownloading(true);
- setDownloadProgress({ phase: 'downloading', downloaded: 0, contentLength: 0 });
- try {
- await downloadAndInstallUpdate((progress) => {
- setDownloadProgress(progress);
- });
- } catch (err) {
- setUpdateError(err?.message || String(err));
- } finally {
- setDownloading(false);
- }
- };
-
const tabs = [
{ id: 'general', label: t('settings.tabs.general'), icon: SettingsIcon },
{ id: 'language', label: t('settings.tabs.language'), icon: Languages },
@@ -481,11 +157,11 @@ function SettingsDialog({
setLowResolutionAnalysis((v) => !v)}
+ onToggleLowRes={toggleLowResolutionAnalysis}
sendTelemetryDiagnostics={sendTelemetryDiagnostics}
- onToggleTelemetry={() => setSendTelemetryDiagnostics((v) => !v)}
+ onToggleTelemetry={toggleTelemetryDiagnostics}
powerSavingMode={powerSavingMode}
- onTogglePowerSaving={() => onPowerSavingModeChange?.(!powerSavingMode)}
+ onTogglePowerSaving={(next) => onPowerSavingModeChange?.(next)}
/>
)}
@@ -496,7 +172,6 @@ function SettingsDialog({
{activeTab === 'security' && (
- {/* Windows Hello 安全设置 */}
- {/* 捕获过滤器设置 */}
{
- setFilterSettings((prev) => ({ ...prev, ignoreProtected: !prev.ignoreProtected }));
- setFiltersDirty(true);
- setSaveFiltersMessage('');
- }}
+ onToggleProtected={handleToggleProtected}
onSave={handleSaveFilters}
filtersDirty={filtersDirty}
savingFilters={savingFilters}
@@ -556,6 +226,7 @@ function SettingsDialog({
refreshing={analysisRefreshing}
error={analysisError}
onRefresh={handleRefreshAnalysis}
+ monitorStatus={monitorStatus}
/>
)}
diff --git a/src/components/settings/SettingsHelpTooltip.jsx b/src/components/settings/SettingsHelpTooltip.jsx
new file mode 100644
index 0000000..a0cb5d4
--- /dev/null
+++ b/src/components/settings/SettingsHelpTooltip.jsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import { HelpCircle } from 'lucide-react';
+
+export default function SettingsHelpTooltip({ children, variant = 'section', className = '' }) {
+ const isTerm = variant === 'term';
+
+ return (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/settings/StorageManagementSection.jsx b/src/components/settings/StorageManagementSection.jsx
index c19de0c..8b7f062 100644
--- a/src/components/settings/StorageManagementSection.jsx
+++ b/src/components/settings/StorageManagementSection.jsx
@@ -1,24 +1,14 @@
-import React, { useState, useEffect, useMemo, useCallback } from 'react';
+import React from 'react';
import { useTranslation } from 'react-i18next';
-import { invoke } from '@tauri-apps/api/core';
-import { open } from '@tauri-apps/plugin-dialog';
-import { listen } from '@tauri-apps/api/event';
import { Dialog } from '../Dialog';
import { ConfirmDialog } from '../ConfirmDialog';
import MigrationProgressDialog from './MigrationProgressDialog';
-import { HardDrive, RefreshCw, Clock, Database, Activity, FolderOpen, AlertTriangle, PieChart, ArrowLeft, Trash2, ChevronLeft, ChevronRight, FileUp, FileDown } from 'lucide-react';
+import { HardDrive, RefreshCw, Clock, Database, Activity, FolderOpen, AlertTriangle, PieChart, ArrowLeft, Trash2, ChevronLeft, ChevronRight, FileUp, FileDown, RotateCcw, Loader2 } from 'lucide-react';
import { formatBytes, formatTimestamp } from './analysisUtils';
import { ThumbnailCard } from '../ThumbnailCard';
import BackupMigrationDialog from '../BackupMigrationDialog';
-import {
- fetchThumbnailBatch,
- getProcessStorageStats,
- getProcessMonthlyThumbnails,
- getSoftDeleteQueueStatus,
- softDeleteScreenshots,
- softDeleteProcessMonth,
-} from '../../lib/monitor_api';
-import { withAuth } from '../../lib/auth_api';
+import { SettingsButton } from './SettingsControls';
+import { useStorageManagementController } from './useStorageManagementController';
const PROCESS_PALETTE = ['#0ea5e9', '#22c55e', '#f59e0b', '#ef4444', '#06b6d4', '#84cc16', '#8b5cf6', '#f97316'];
@@ -250,6 +240,111 @@ function StoragePathOption({
);
}
+function IndexHealthCard({
+ indexHealth,
+ indexHealthLoading,
+ indexHealthError,
+ vectorRetrying,
+ vectorRetryBacklog,
+ deleteQueuePending,
+ lastIndexingError,
+ lastIndexingErrorAt,
+ storageIpcLabel,
+ storageIpcRetryAfter,
+ monitorStatus,
+ onRefresh,
+ onRetryVectorIndexing,
+ formatIndexCount,
+}) {
+ const { t } = useTranslation();
+ const canUseMonitor = monitorStatus === 'running' || indexHealth?.monitor_available;
+
+ return (
+
+
+
+
+
+
+
+
{t('settings.features.management.indexHealth.label', '索引健康')}
+
+ {t('settings.features.management.indexHealth.description', '截图、OCR、向量索引和后台重试队列的当前状态')}
+
+
+
+
+ onRefresh({ refreshVector: canUseMonitor })}
+ disabled={indexHealthLoading}
+ className="p-1.5 text-ide-muted hover:text-ide-text hover:bg-ide-hover rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ title={t('settings.features.management.indexHealth.refresh', '刷新')}
+ >
+
+
+ : RotateCcw}
+ >
+ {t('settings.features.management.indexHealth.retry', '重试失败向量')}
+
+
+
+
+
+
+
{t('settings.features.management.indexHealth.screenshots', '截图')}
+
{formatIndexCount(indexHealth?.screenshots_count)}
+
+
+
{t('settings.features.management.indexHealth.ocrRows', 'OCR 行')}
+
{formatIndexCount(indexHealth?.ocr_rows_count)}
+
+
+
{t('settings.features.management.indexHealth.vectorRows', '向量')}
+
+ {formatIndexCount(indexHealth?.vector_rows_count, indexHealth?.worker_started === false ? t('settings.features.management.indexHealth.notLoaded', '未加载') : '—')}
+
+
+
+
{t('settings.features.management.indexHealth.vectorRetry', '向量重试')}
+
{formatIndexCount(vectorRetryBacklog)}
+
+
+
{t('settings.features.management.indexHealth.deleteQueue', '删除队列')}
+
{formatIndexCount(deleteQueuePending)}
+
+
+
{t('settings.features.management.indexHealth.smartPending', '智能聚类待处理')}
+
{formatIndexCount(indexHealth?.smart_cluster_pending_count)}
+
+
+
{t('settings.features.management.indexHealth.storageIpc', '存储 IPC')}
+
+ {storageIpcLabel}
+ {storageIpcRetryAfter ? ` ${storageIpcRetryAfter}s` : ''}
+
+
+
+
+ {(indexHealthError || indexHealth?.monitor_error || lastIndexingError) && (
+
+
+
+
{indexHealthError || indexHealth?.monitor_error || lastIndexingError}
+ {lastIndexingErrorAt && (
+
{lastIndexingErrorAt}
+ )}
+
+
+ )}
+
+ );
+}
+
export default function StorageManagementSection({
storageSegments,
totalStorage,
@@ -258,405 +353,69 @@ export default function StorageManagementSection({
refreshing,
error,
onRefresh,
+ monitorStatus,
}) {
- // Storage settings from localStorage
- const [storageLimit, setStorageLimit] = useState(() => {
- return localStorage.getItem('snapshotStorageLimit') || 'unlimited';
- });
-
- const [retentionPeriod, setRetentionPeriod] = useState(() => {
- return localStorage.getItem('snapshotRetentionPeriod') || 'permanent';
- });
-
const { t } = useTranslation();
-
- // Migration state
- const [isMigrating, setIsMigrating] = useState(false);
- const [migrationProgress, setMigrationProgress] = useState({ total_files: 0, copied_files: 0, current_file: '' });
- const [migrationError, setMigrationError] = useState('');
- const [isUpdatingStoragePath, setIsUpdatingStoragePath] = useState(false);
- const [isMigrationChoiceDialogOpen, setIsMigrationChoiceDialogOpen] = useState(false);
- const [pendingTargetPath, setPendingTargetPath] = useState('');
- const [panelView, setPanelView] = useState('overview');
- const [processStats, setProcessStats] = useState([]);
- const [processStatsLoading, setProcessStatsLoading] = useState(false);
- const [processStatsError, setProcessStatsError] = useState('');
- const [selectedProcess, setSelectedProcess] = useState('');
- const [processPage, setProcessPage] = useState(0);
- const [processMonthData, setProcessMonthData] = useState(null);
- const [processMonthLoading, setProcessMonthLoading] = useState(false);
- const [processMonthError, setProcessMonthError] = useState('');
- const [processThumbMap, setProcessThumbMap] = useState({});
- const [selectedScreenshotIds, setSelectedScreenshotIds] = useState(() => new Set());
- const [deletingTarget, setDeletingTarget] = useState('');
- const [pendingDeleteIntent, setPendingDeleteIntent] = useState(null);
- const [isBackupDialogOpen, setIsBackupDialogOpen] = useState(false);
- const [backupMode, setBackupMode] = useState('export'); // 'export' or 'import'
- const [deleteQueueStatus, setDeleteQueueStatus] = useState({
- pending_screenshots: 0,
- pending_ocr: 0,
- running: false,
- });
-
- // Save settings to localStorage
- useEffect(() => {
- localStorage.setItem('snapshotStorageLimit', storageLimit);
- // try to persist to backend; if backend not available, fall back to localStorage
- (async () => {
- try {
- await withAuth(() => invoke('storage_set_policy', { policy: { storage_limit: storageLimit, retention_period: retentionPeriod } }));
- } catch (e) {
- // backend may be unavailable in dev; ignore and rely on localStorage
- }
- })();
- }, [storageLimit]);
-
- useEffect(() => {
- localStorage.setItem('snapshotRetentionPeriod', retentionPeriod);
- // persist to backend as well
- (async () => {
- try {
- await withAuth(() => invoke('storage_set_policy', { policy: { storage_limit: storageLimit, retention_period: retentionPeriod } }));
- } catch (e) {
- // ignore
- }
- })();
- }, [retentionPeriod]);
-
- // On mount try to read policy from backend and sync UI
- useEffect(() => {
- (async () => {
- try {
- const resp = await withAuth(() => invoke('storage_get_policy'));
- if (resp && typeof resp === 'object') {
- const backendPolicy = resp;
- if (backendPolicy.storage_limit) setStorageLimit(String(backendPolicy.storage_limit));
- if (backendPolicy.retention_period) setRetentionPeriod(String(backendPolicy.retention_period));
- }
- } catch (e) {
- // backend not available — keep localStorage values
- }
- })();
- }, []);
-
- const loadDeleteQueueStatus = useCallback(async () => {
- const status = await getSoftDeleteQueueStatus();
- setDeleteQueueStatus(status || { pending_screenshots: 0, pending_ocr: 0, running: false });
- }, []);
-
- const loadProcessStats = useCallback(async () => {
- setProcessStatsLoading(true);
- setProcessStatsError('');
- try {
- const stats = await getProcessStorageStats();
- setProcessStats(Array.isArray(stats) ? stats : []);
- } catch (e) {
- setProcessStats([]);
- setProcessStatsError(String(e));
- } finally {
- setProcessStatsLoading(false);
- }
- }, []);
-
- const loadProcessMonthPage = useCallback(async (processName, page = 0) => {
- if (!processName) return;
- setProcessMonthLoading(true);
- setProcessMonthError('');
- try {
- const data = await getProcessMonthlyThumbnails(processName, page, 60);
- setProcessMonthData(data);
- setProcessPage(page);
- setSelectedScreenshotIds(new Set());
- } catch (e) {
- setProcessMonthData(null);
- setProcessMonthError(String(e));
- } finally {
- setProcessMonthLoading(false);
- }
- }, []);
-
- const openProcessDetail = useCallback((processName) => {
- setSelectedProcess(processName);
- setPanelView('process-detail');
- setProcessThumbMap({});
- setSelectedScreenshotIds(new Set());
- loadProcessMonthPage(processName, 0);
- }, [loadProcessMonthPage]);
-
- const toggleScreenshotSelection = useCallback((screenshotId) => {
- if (typeof screenshotId !== 'number' || screenshotId <= 0) return;
- setSelectedScreenshotIds((prev) => {
- const next = new Set(prev);
- if (next.has(screenshotId)) {
- next.delete(screenshotId);
- } else {
- next.add(screenshotId);
- }
- return next;
- });
- }, []);
-
- const requestSoftDelete = useCallback((processName, month = null, screenshotIds = []) => {
- if (!processName) return;
- const selectedIds = [...new Set((screenshotIds || []).filter((id) => typeof id === 'number' && id > 0))];
- const hasSelectedIds = selectedIds.length > 0;
-
- setPendingDeleteIntent({
- processName,
- month,
- screenshotIds: selectedIds,
- hasSelectedIds,
- targetKey: `${processName}::${month || 'all'}`,
- title: t('settings.storageManagement.deleteConfirm.title'),
- message: hasSelectedIds
- ? t('settings.storageManagement.deleteConfirm.messageSelected', { count: selectedIds.length })
- : month
- ? t('settings.storageManagement.deleteConfirm.messageMonth', { processName, month })
- : t('settings.storageManagement.deleteConfirm.messageProcess', { processName }),
- confirmLabel: hasSelectedIds
- ? t('settings.storageManagement.processDetails.deleteSelected', { count: selectedIds.length })
- : month
- ? t('settings.storageManagement.processDetails.deleteMonth')
- : t('settings.storageManagement.processDetails.deleteProcess'),
- });
- }, [t]);
-
- const executeSoftDelete = useCallback(async (intent) => {
- if (!intent?.processName) return;
- const {
- processName,
- month,
- screenshotIds,
- hasSelectedIds,
- targetKey,
- } = intent;
-
- setDeletingTarget(targetKey);
- try {
- if (hasSelectedIds) {
- await softDeleteScreenshots(screenshotIds);
- setSelectedScreenshotIds((prev) => {
- const next = new Set(prev);
- screenshotIds.forEach((id) => next.delete(id));
- return next;
- });
- } else {
- await softDeleteProcessMonth(processName, month);
- }
- await loadDeleteQueueStatus();
- await loadProcessStats();
- if (selectedProcess && selectedProcess === processName) {
- await loadProcessMonthPage(processName, processPage);
- }
- onRefresh?.();
- } catch (e) {
- setProcessMonthError(String(e));
- } finally {
- setDeletingTarget('');
- }
- }, [loadDeleteQueueStatus, loadProcessMonthPage, loadProcessStats, onRefresh, processPage, selectedProcess]);
-
- const handleConfirmSoftDelete = useCallback(async () => {
- if (!pendingDeleteIntent) return;
- await executeSoftDelete(pendingDeleteIntent);
- setPendingDeleteIntent(null);
- }, [executeSoftDelete, pendingDeleteIntent]);
-
- const handleCancelSoftDelete = useCallback(() => {
- if (deletingTarget) return;
- setPendingDeleteIntent(null);
- }, [deletingTarget]);
-
- useEffect(() => {
- loadDeleteQueueStatus();
- const timer = setInterval(loadDeleteQueueStatus, 5000);
- return () => clearInterval(timer);
- }, [loadDeleteQueueStatus]);
-
- useEffect(() => {
- if (panelView === 'overview') {
- loadProcessStats();
- }
- }, [panelView, loadProcessStats]);
-
- useEffect(() => {
- const items = processMonthData?.items || [];
- if (!items.length) {
- setProcessThumbMap({});
- return;
- }
-
- const ids = items.map((item) => item.screenshot_id).filter((id) => typeof id === 'number');
- fetchThumbnailBatch(ids)
- .then((batch) => setProcessThumbMap(batch || {}))
- .catch(() => setProcessThumbMap({}));
- }, [processMonthData]);
-
- const groupedMonthItems = useMemo(() => {
- const grouped = {};
- for (const item of processMonthData?.items || []) {
- const key = item.month || 'unknown';
- if (!grouped[key]) grouped[key] = [];
- grouped[key].push(item);
- }
- return Object.entries(grouped);
- }, [processMonthData]);
-
- const selectedCountByMonth = useMemo(() => {
- const counts = {};
- for (const item of processMonthData?.items || []) {
- if (!selectedScreenshotIds.has(item.screenshot_id)) continue;
- const month = item.month || 'unknown';
- counts[month] = (counts[month] || 0) + 1;
- }
- return counts;
- }, [processMonthData, selectedScreenshotIds]);
-
- const handleRefresh = useCallback(() => {
- onRefresh?.();
- loadDeleteQueueStatus();
- if (panelView === 'overview') {
- loadProcessStats();
- }
- if (panelView === 'process-detail' && selectedProcess) {
- loadProcessMonthPage(selectedProcess, processPage);
- }
- }, [loadDeleteQueueStatus, loadProcessMonthPage, loadProcessStats, onRefresh, panelView, processPage, selectedProcess]);
-
- // Storage limit options
- const storageLimitOptions = [
- { value: '10', label: t('settings.storageManagement.storageLimit.options.10') },
- { value: '20', label: t('settings.storageManagement.storageLimit.options.20') },
- { value: '50', label: t('settings.storageManagement.storageLimit.options.50') },
- { value: '120', label: t('settings.storageManagement.storageLimit.options.120') },
- { value: 'unlimited', label: t('settings.storageManagement.storageLimit.options.unlimited') },
- ];
-
- // Retention period options
- const retentionOptions = [
- { value: '1month', label: t('settings.storageManagement.retention.options.1month') },
- { value: '6months', label: t('settings.storageManagement.retention.options.6months') },
- { value: '1year', label: t('settings.storageManagement.retention.options.1year') },
- { value: '2years', label: t('settings.storageManagement.retention.options.2years') },
- { value: 'permanent', label: t('settings.storageManagement.retention.options.permanent') },
- ];
-
- // Mock disk info - in real implementation this would come from backend
- const diskInfo = useMemo(() => {
- // Extract disk path from storage root path
- const rootPath = storage?.root_path || '';
- const driveLetter = rootPath.charAt(0);
-
- // For demo purposes, estimate disk usage
- // In production, this would come from a Rust backend call
- return {
- driveLetter: driveLetter || 'C',
- totalSize: 500 * 1024 * 1024 * 1024, // 500GB placeholder
- usedSize: 320 * 1024 * 1024 * 1024, // 320GB placeholder
- };
- }, [storage]);
-
- const currentStoragePath = storage?.root_path || 'LocalAppData/CarbonPaper';
-
- const executeStoragePathChange = async (targetPath, shouldMigrateData) => {
- let unlistenProgress = null;
- let unlistenError = null;
- let shouldRestartMonitor = true;
-
- try {
- if (!targetPath) return;
-
- setMigrationError('');
- setIsUpdatingStoragePath(true);
- try {
- const monitorStatusRaw = await invoke('get_monitor_status');
- const monitorStatus = typeof monitorStatusRaw === 'string' ? JSON.parse(monitorStatusRaw) : monitorStatusRaw;
- shouldRestartMonitor = !monitorStatus?.stopped;
- } catch (_) {
- shouldRestartMonitor = true;
- }
-
- if (shouldRestartMonitor) {
- await withAuth(() => invoke('stop_monitor'), { autoPrompt: true });
- }
-
- if (shouldMigrateData) {
- setIsMigrating(true);
- setMigrationProgress({ total_files: 0, copied_files: 0, current_file: '' });
-
- unlistenProgress = await listen('storage-migration-progress', (evt) => {
- const p = evt.payload;
- setMigrationProgress(p);
- });
-
- unlistenError = await listen('storage-migration-error', (evt) => {
- setMigrationError(evt.payload?.message || t('settings.storageManagement.migration.error_default'));
- });
- }
-
- await withAuth(() => invoke('storage_migrate_data_dir', {
- target: targetPath,
- migrateDataFiles: shouldMigrateData,
- }), { autoPrompt: true });
-
- if (shouldMigrateData) {
- setMigrationProgress((s) => ({ ...s, current_file: t('settings.storageManagement.migration.completed') }));
- await new Promise((resolve) => setTimeout(resolve, 600));
- }
-
- onRefresh?.();
- } catch (e) {
- console.error('change storage path failed', e);
- setMigrationError(String(e));
- } finally {
- if (unlistenProgress) {
- try { await unlistenProgress(); } catch (_) { }
- }
- if (unlistenError) {
- try { await unlistenError(); } catch (_) { }
- }
-
- setIsMigrating(false);
- setIsUpdatingStoragePath(false);
- if (shouldRestartMonitor) {
- try { await withAuth(() => invoke('start_monitor'), { autoPrompt: true }); } catch (_) { }
- }
- }
- };
-
- const handleChangeStoragePath = async () => {
- try {
- const selected = await open({ directory: true });
- if (!selected) return;
-
- const targetPath = Array.isArray(selected) ? selected[0] : selected;
- if (!targetPath) return;
-
- const normalizedCurrent = currentStoragePath.replace(/[\\/]+$/, '');
- const normalizedTarget = targetPath.replace(/[\\/]+$/, '');
- if (normalizedCurrent && normalizedCurrent === normalizedTarget) {
- return;
- }
-
- setPendingTargetPath(targetPath);
- setIsMigrationChoiceDialogOpen(true);
- } catch (e) {
- console.error('select storage path failed', e);
- setMigrationError(String(e));
- }
- };
-
- const handleCancelMigrationChoice = () => {
- setIsMigrationChoiceDialogOpen(false);
- setPendingTargetPath('');
- };
-
- const handleApplyStoragePath = async (shouldMigrateData) => {
- const targetPath = pendingTargetPath;
- setIsMigrationChoiceDialogOpen(false);
- setPendingTargetPath('');
- await executeStoragePathChange(targetPath, shouldMigrateData);
- };
+ const {
+ storageLimit,
+ setStorageLimit,
+ retentionPeriod,
+ setRetentionPeriod,
+ isMigrating,
+ migrationProgress,
+ migrationError,
+ isUpdatingStoragePath,
+ isMigrationChoiceDialogOpen,
+ pendingTargetPath,
+ panelView,
+ setPanelView,
+ processStats,
+ processStatsLoading,
+ processStatsError,
+ selectedProcess,
+ processPage,
+ processMonthData,
+ processMonthLoading,
+ processMonthError,
+ processThumbMap,
+ selectedScreenshotIds,
+ deletingTarget,
+ pendingDeleteIntent,
+ isBackupDialogOpen,
+ setIsBackupDialogOpen,
+ backupMode,
+ setBackupMode,
+ deleteQueueStatus,
+ indexHealth,
+ indexHealthLoading,
+ indexHealthError,
+ vectorRetrying,
+ groupedMonthItems,
+ selectedCountByMonth,
+ storageLimitOptions,
+ retentionOptions,
+ diskInfo,
+ currentStoragePath,
+ vectorRetryBacklog,
+ indexHealthDeleteQueuePending,
+ lastIndexingError,
+ lastIndexingErrorAt,
+ storageIpcLabel,
+ storageIpcRetryAfter,
+ handleRefresh,
+ loadIndexHealth,
+ handleRetryVectorIndexing,
+ formatIndexCount,
+ openProcessDetail,
+ toggleScreenshotSelection,
+ requestSoftDelete,
+ handleConfirmSoftDelete,
+ handleCancelSoftDelete,
+ loadProcessMonthPage,
+ handleChangeStoragePath,
+ handleCancelMigrationChoice,
+ handleApplyStoragePath,
+ } = useStorageManagementController({ storage, onRefresh, t, monitorStatus });
return (
@@ -759,6 +518,23 @@ export default function StorageManagementSection({
+
+
{
+ setConfig(newConfig);
+ try {
+ await withAuth(() => invoke('set_advanced_config', { config: newConfig }), { autoPrompt: true });
+ } catch (err) {
+ console.error('Failed to save advanced config:', err);
+ }
+ };
+
+ const syncOcrConfigToMonitor = async (newConfig) => {
+ if (monitorStatus !== 'running') return;
+ try {
+ await withAuth(() => invoke('monitor_update_advanced_config', {
+ captureOnOcrBusy: newConfig.capture_on_ocr_busy,
+ ocrQueueMaxSize: newConfig.ocr_queue_limit_enabled
+ ? newConfig.ocr_queue_max_size
+ : 999999,
+ ocrTimeoutSecs: newConfig.ocr_timeout_secs || 120,
+ clusteringAllowFullLowMemory: Boolean(newConfig.clustering_allow_full_low_memory),
+ }), { autoPrompt: true });
+ } catch (err) {
+ console.error('Failed to sync OCR config to monitor:', err);
+ }
+ };
+
+ const loadConfig = async () => {
+ try {
+ const result = await invoke('get_advanced_config');
+ setConfig(result);
+ } catch (err) {
+ console.error('Failed to load advanced config:', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const loadGpus = async () => {
+ setGpuLoading(true);
+ try {
+ const result = await invoke('enumerate_gpus');
+ const gpuList = result || [];
+ setGpus(gpuList);
+ if (config && gpuList.length > 0 && !gpuList.some((gpu) => gpu.id === config.dml_device_id)) {
+ await saveConfig({ ...config, dml_device_id: gpuList[0].id });
+ }
+ } catch (err) {
+ console.error('Failed to enumerate GPUs:', err);
+ setGpus([]);
+ } finally {
+ setGpuLoading(false);
+ }
+ };
+
+ const refreshVacuumRunningStatus = async () => {
+ try {
+ const status = await invoke('storage_get_startup_vacuum_status');
+ setVacuumRunning(Boolean(status?.in_progress));
+ } catch {
+ setVacuumRunning(false);
+ }
+ };
+
+ useEffect(() => {
+ loadConfig();
+ }, []);
+
+ useEffect(() => {
+ if (config?.use_dml) {
+ loadGpus();
+ }
+ }, [config?.use_dml]);
+
+ useEffect(() => {
+ const handler = () => {
+ setCpuDropdownOpen(false);
+ setQueueDropdownOpen(false);
+ setGpuDropdownOpen(false);
+ setClusteringDropdownOpen(false);
+ };
+ if (cpuDropdownOpen || queueDropdownOpen || gpuDropdownOpen || clusteringDropdownOpen) {
+ document.addEventListener('click', handler);
+ return () => document.removeEventListener('click', handler);
+ }
+ return undefined;
+ }, [cpuDropdownOpen, queueDropdownOpen, gpuDropdownOpen, clusteringDropdownOpen]);
+
+ useEffect(() => {
+ refreshVacuumRunningStatus();
+ }, []);
+
+ const handleToggle = async (key) => {
+ const newConfig = { ...config, [key]: !config[key] };
+ await saveConfig(newConfig);
+ if (key === 'cpu_limit_enabled') setCpuChanged(true);
+ if (key === 'use_dml') setDmlChanged(true);
+ if (key === 'use_onnx') setOnnxChanged(true);
+ if (key === 'capture_on_ocr_busy' || key === 'ocr_queue_limit_enabled' || key === 'clustering_allow_full_low_memory') {
+ await syncOcrConfigToMonitor(newConfig);
+ }
+ };
+
+ const handleCpuPercentChange = async (value) => {
+ setCpuDropdownOpen(false);
+ await saveConfig({ ...config, cpu_limit_percent: value });
+ setCpuChanged(true);
+ };
+
+ const handleQueueSizeChange = async (value) => {
+ setQueueDropdownOpen(false);
+ const newConfig = { ...config, ocr_queue_max_size: value };
+ await saveConfig(newConfig);
+ await syncOcrConfigToMonitor(newConfig);
+ };
+
+ const handleOcrTimeoutDraftChange = (value) => {
+ setConfig({ ...config, ocr_timeout_secs: value });
+ };
+
+ const handleOcrTimeoutChange = async (value) => {
+ const parsed = Number.parseInt(value, 10);
+ const next = Number.isFinite(parsed) ? Math.min(600, Math.max(30, parsed)) : 120;
+ const newConfig = { ...config, ocr_timeout_secs: next };
+ await saveConfig(newConfig);
+ await syncOcrConfigToMonitor(newConfig);
+ };
+
+ const handleGpuChange = async (deviceId) => {
+ setGpuDropdownOpen(false);
+ await saveConfig({ ...config, dml_device_id: deviceId });
+ setDmlChanged(true);
+ };
+
+ const handleClusteringIntervalChange = async (interval) => {
+ setClusteringDropdownOpen(false);
+ const newConfig = { ...config, clustering_interval: interval };
+ await saveConfig(newConfig);
+ try {
+ await withAuth(() => invoke('monitor_set_clustering_interval', { interval }), { autoPrompt: true });
+ } catch {
+ // Best effort; persisted config will be applied on the next monitor refresh.
+ }
+ };
+
+ const handleManualVacuum = async () => {
+ setVacuumMessage('');
+ setVacuumRunning(true);
+ try {
+ const result = await withAuth(() => invoke('storage_run_manual_vacuum'), { autoPrompt: true });
+ if (result?.already_running) {
+ setVacuumMessage(t('settings.advanced.vacuum.already_running', '已有数据库优化任务正在执行,请稍候。'));
+ } else {
+ setVacuumMessage(t('settings.advanced.vacuum.success', '数据库优化已完成。'));
+ }
+ } catch (err) {
+ const msg = err?.message || err?.toString() || t('settings.advanced.vacuum.error', '数据库优化失败');
+ setVacuumMessage(t('settings.advanced.vacuum.error_with_detail', '数据库优化失败:{{error}}', { error: msg }));
+ } finally {
+ await refreshVacuumRunningStatus();
+ }
+ };
+
+ const selectedGpu = config ? (gpus.find((gpu) => gpu.id === config.dml_device_id) || gpus[0]) : null;
+
+ return {
+ config,
+ loading,
+ cpuDropdownOpen,
+ queueDropdownOpen,
+ gpuDropdownOpen,
+ clusteringDropdownOpen,
+ cpuChanged,
+ dmlChanged,
+ onnxChanged,
+ gpus,
+ gpuLoading,
+ vacuumRunning,
+ vacuumMessage,
+ selectedGpu,
+ setCpuDropdownOpen,
+ setQueueDropdownOpen,
+ setGpuDropdownOpen,
+ setClusteringDropdownOpen,
+ clearCpuChanged: () => setCpuChanged(false),
+ clearDmlChanged: () => setDmlChanged(false),
+ clearOnnxChanged: () => setOnnxChanged(false),
+ handleToggle,
+ handleCpuPercentChange,
+ handleQueueSizeChange,
+ handleOcrTimeoutDraftChange,
+ handleOcrTimeoutChange,
+ handleGpuChange,
+ handleClusteringIntervalChange,
+ handleManualVacuum,
+ };
+}
diff --git a/src/components/settings/useAiEmbeddingController.js b/src/components/settings/useAiEmbeddingController.js
new file mode 100644
index 0000000..e3581c0
--- /dev/null
+++ b/src/components/settings/useAiEmbeddingController.js
@@ -0,0 +1,462 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
+import { withAuth } from '../../lib/auth_api';
+import { useTauriEventListener } from '../../hooks/useTauriEventListener';
+
+export function useAiEmbeddingController({ t, agentSkillName, agentSkillRepo }) {
+ const [enabled, setEnabled] = useState(() => localStorage.getItem('mcpEnabled') === 'true');
+ const [port, setPort] = useState(() => {
+ const saved = parseInt(localStorage.getItem('mcpPort'), 10);
+ return saved > 0 ? saved : 23816;
+ });
+ const [running, setRunning] = useState(false);
+ const [serviceState, setServiceState] = useState(() => (
+ localStorage.getItem('mcpEnabled') === 'true' ? 'pending_auth' : 'disabled'
+ ));
+ const [statusError, setStatusError] = useState('');
+ const hasCachedState = localStorage.getItem('mcpEnabled') !== null;
+ const [loading, setLoading] = useState(!hasCachedState);
+ const [actionLoading, setActionLoading] = useState(false);
+ const [restoreLoading, setRestoreLoading] = useState(false);
+ const [error, setError] = useState('');
+ const restoreAttemptRef = useRef('');
+ const [privacyAcknowledged, setPrivacyAcknowledged] = useState(false);
+ const [showPrivacyDialog, setShowPrivacyDialog] = useState(false);
+ const [confirmText, setConfirmText] = useState('');
+ const [tokenCopied, setTokenCopied] = useState(false);
+ const [agentPromptCopied, setAgentPromptCopied] = useState(false);
+ const [showResetConfirm, setShowResetConfirm] = useState(false);
+ const [filterEnabled, setFilterEnabled] = useState(true);
+ const [filterCategories, setFilterCategories] = useState({
+ cat_01: true, cat_02: true, cat_03: true, cat_04: true, cat_05: true,
+ });
+ const [filterMode, setFilterMode] = useState('reject');
+ const [showAdvanced, setShowAdvanced] = useState(false);
+ const [piiEnabled, setPiiEnabled] = useState(true);
+ const [piiEntities, setPiiEntities] = useState({
+ PHONE_NUMBER: true, CN_ID_CARD: true,
+ EMAIL_ADDRESS: true, CN_BANK_CARD: true, ADDRESS: true,
+ });
+ const [spacyModels, setSpacyModels] = useState({
+ zh_core_web_sm: { installed: false },
+ en_core_web_sm: { installed: false },
+ });
+ const [downloadingModel, setDownloadingModel] = useState(null);
+ const [recheckLoading, setRecheckLoading] = useState(false);
+ const [showPiiAdvanced, setShowPiiAdvanced] = useState(false);
+
+ const CONFIRM_TEXT = t('settings.ai_embedding.privacy_warning.confirm_text');
+
+ const loadStatus = useCallback(async (retryCount = 0) => {
+ try {
+ const status = await invoke('mcp_get_status');
+ setEnabled(status.enabled);
+ setPort(status.port);
+ setRunning(status.running);
+ setServiceState(status.state || (status.enabled ? (status.running ? 'running' : 'pending_auth') : 'disabled'));
+ setStatusError(status.error || '');
+ setPrivacyAcknowledged(Boolean(status.privacy_acknowledged));
+
+ localStorage.setItem('mcpEnabled', status.enabled ? 'true' : 'false');
+ if (status.port) localStorage.setItem('mcpPort', String(status.port));
+
+ try {
+ const filterConfig = await withAuth(() => invoke('mcp_get_sensitive_filter_config'));
+ setFilterEnabled(filterConfig.enabled);
+ setFilterCategories(filterConfig.categories);
+ if (filterConfig.mode) setFilterMode(filterConfig.mode);
+ if (filterConfig.presidio_enabled !== undefined) setPiiEnabled(filterConfig.presidio_enabled);
+ if (filterConfig.presidio_entities && filterConfig.presidio_entities.length > 0) {
+ const entityMap = {};
+ for (const e of filterConfig.presidio_entities) entityMap[e] = true;
+ setPiiEntities((prev) => {
+ const merged = { ...prev };
+ for (const key of Object.keys(merged)) merged[key] = !!entityMap[key];
+ return merged;
+ });
+ }
+ } catch (e) {
+ console.error('Failed to load filter config:', e);
+ }
+
+ try {
+ const models = await invoke('check_spacy_models');
+ setSpacyModels(models);
+ } catch (e) {
+ console.error('Failed to check spaCy models:', e);
+ }
+ } catch (e) {
+ console.error('Failed to load MCP status:', e);
+ if (retryCount < 1) {
+ setTimeout(() => loadStatus(retryCount + 1), 500);
+ return;
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadStatus();
+ }, [loadStatus]);
+
+ useTauriEventListener('mcp-status-changed', () => {
+ loadStatus();
+ }, [loadStatus]);
+
+ useTauriEventListener('spacy-model-status', (event) => {
+ const { model, status } = event.payload;
+ if (status === 'installing') {
+ setDownloadingModel(model);
+ } else if (status === 'installed') {
+ setSpacyModels((prev) => ({ ...prev, [model]: { installed: true } }));
+ setDownloadingModel((prev) => prev === model ? null : prev);
+ } else if (status === 'failed') {
+ setDownloadingModel((prev) => prev === model ? null : prev);
+ }
+ });
+
+ const startMcpService = useCallback(async ({ auto = false } = {}) => {
+ const setBusy = auto ? setRestoreLoading : setActionLoading;
+ setBusy(true);
+ if (!auto) setError('');
+ try {
+ const result = await withAuth(
+ () => invoke('mcp_set_enabled', { enabled: true }),
+ { autoPrompt: !auto },
+ );
+ setEnabled(true);
+ setRunning(true);
+ setServiceState('running');
+ setStatusError('');
+ localStorage.setItem('mcpEnabled', 'true');
+ if (result.port) {
+ setPort(result.port);
+ localStorage.setItem('mcpPort', String(result.port));
+ }
+ setTokenCopied(false);
+ return true;
+ } catch (e) {
+ const message = String(e);
+ if (!auto) setError(message);
+ setRunning(false);
+ if (message.includes('AUTH_REQUIRED')) {
+ setServiceState('pending_auth');
+ } else {
+ setServiceState('error');
+ setStatusError(message);
+ }
+ return false;
+ } finally {
+ setBusy(false);
+ }
+ }, []);
+
+ const handleToggle = async () => {
+ if (!enabled) {
+ if (privacyAcknowledged) {
+ await startMcpService({ auto: false });
+ } else {
+ setShowPrivacyDialog(true);
+ setConfirmText('');
+ }
+ } else {
+ setActionLoading(true);
+ setError('');
+ try {
+ await withAuth(() => invoke('mcp_set_enabled', { enabled: false }), { autoPrompt: true });
+ setEnabled(false);
+ setRunning(false);
+ setServiceState('disabled');
+ setStatusError('');
+ localStorage.setItem('mcpEnabled', 'false');
+ } catch (e) {
+ setError(String(e));
+ } finally {
+ setActionLoading(false);
+ }
+ }
+ };
+
+ const handleConfirmEnable = async () => {
+ setShowPrivacyDialog(false);
+ setActionLoading(true);
+ setError('');
+ try {
+ await invoke('mcp_ack_privacy_warning');
+ setPrivacyAcknowledged(true);
+ } catch (e) {
+ setError(String(e));
+ setActionLoading(false);
+ return;
+ }
+ setActionLoading(false);
+ await startMcpService({ auto: false });
+ };
+
+ const handleResetToken = async () => {
+ setShowResetConfirm(false);
+ setActionLoading(true);
+ setError('');
+ try {
+ const result = await withAuth(() => invoke('mcp_reset_token'), { autoPrompt: true });
+ setTokenCopied(Boolean(result?.copied_to_clipboard));
+ } catch (e) {
+ setError(String(e));
+ } finally {
+ setActionLoading(false);
+ }
+ };
+
+ const filterLevel = (() => {
+ if (!filterEnabled) return 'off';
+ const { cat_01, cat_02, cat_03, cat_04, cat_05 } = filterCategories;
+ if (cat_01 && cat_02 && cat_03 && cat_04 && cat_05) return 'standard';
+ if (cat_02 && cat_05 && !cat_01 && !cat_03 && !cat_04) return 'minimal';
+ return 'custom';
+ })();
+
+ const handleLevelChange = async (level) => {
+ let newEnabled = filterEnabled;
+ let newCategories = { ...filterCategories };
+ if (level === 'standard') {
+ newEnabled = true;
+ newCategories = { cat_01: true, cat_02: true, cat_03: true, cat_04: true, cat_05: true };
+ } else if (level === 'minimal') {
+ newEnabled = true;
+ newCategories = { cat_01: false, cat_02: true, cat_03: false, cat_04: false, cat_05: true };
+ } else if (level === 'off') {
+ newEnabled = false;
+ }
+ setFilterEnabled(newEnabled);
+ setFilterCategories(newCategories);
+ try {
+ await withAuth(() => invoke('mcp_set_sensitive_filter_config', {
+ config: {
+ enabled: newEnabled,
+ categories: newCategories,
+ mode: filterMode,
+ presidio_enabled: piiEnabled,
+ presidio_entities: Object.keys(piiEntities).filter((k) => piiEntities[k]),
+ },
+ }), { autoPrompt: true });
+ } catch (e) {
+ setFilterEnabled(filterEnabled);
+ setFilterCategories(filterCategories);
+ console.error('Failed to save filter config:', e);
+ }
+ };
+
+ const handleCategoryToggle = async (category) => {
+ const newCategories = { ...filterCategories, [category]: !filterCategories[category] };
+ setFilterCategories(newCategories);
+ try {
+ await withAuth(() => invoke('mcp_set_sensitive_filter_config', {
+ config: {
+ enabled: filterEnabled,
+ categories: newCategories,
+ mode: filterMode,
+ presidio_enabled: piiEnabled,
+ presidio_entities: Object.keys(piiEntities).filter((k) => piiEntities[k]),
+ },
+ }), { autoPrompt: true });
+ } catch (e) {
+ setFilterCategories(filterCategories);
+ console.error('Failed to save filter config:', e);
+ }
+ };
+
+ const handleFilterModeChange = async (newMode) => {
+ const prevMode = filterMode;
+ setFilterMode(newMode);
+ try {
+ await withAuth(() => invoke('mcp_set_sensitive_filter_config', {
+ config: {
+ enabled: filterEnabled,
+ categories: filterCategories,
+ mode: newMode,
+ presidio_enabled: piiEnabled,
+ presidio_entities: Object.keys(piiEntities).filter((k) => piiEntities[k]),
+ },
+ }), { autoPrompt: true });
+ } catch (e) {
+ setFilterMode(prevMode);
+ console.error('Failed to save filter config:', e);
+ }
+ };
+
+ const savePiiConfig = async (newEnabled, newEntities) => {
+ try {
+ const entityList = Object.keys(newEntities).filter((k) => newEntities[k]);
+ await withAuth(() => invoke('mcp_set_sensitive_filter_config', {
+ config: {
+ enabled: filterEnabled,
+ categories: filterCategories,
+ mode: filterMode,
+ presidio_enabled: newEnabled,
+ presidio_entities: entityList,
+ },
+ }), { autoPrompt: true });
+ } catch (e) {
+ console.error('Failed to save PII config:', e);
+ }
+ };
+
+ const handlePiiToggle = async () => {
+ const newVal = !piiEnabled;
+ setPiiEnabled(newVal);
+ await savePiiConfig(newVal, piiEntities);
+ };
+
+ const handlePiiEntityToggle = async (entityType) => {
+ const newEntities = { ...piiEntities, [entityType]: !piiEntities[entityType] };
+ setPiiEntities(newEntities);
+ await savePiiConfig(piiEnabled, newEntities);
+ };
+
+ const handleDownloadModel = async (modelName) => {
+ setDownloadingModel(modelName);
+ try {
+ await invoke('install_spacy_model', { modelName });
+ const models = await invoke('check_spacy_models');
+ setSpacyModels(models);
+ } catch (e) {
+ setError(String(e));
+ } finally {
+ setDownloadingModel(null);
+ }
+ };
+
+ const handleForceRecheck = async () => {
+ setRecheckLoading(true);
+ try {
+ const models = await invoke('force_recheck_spacy_models');
+ setSpacyModels(models);
+ } catch (e) {
+ setError(String(e));
+ } finally {
+ setRecheckLoading(false);
+ }
+ };
+
+ const handleCopyCurrentToken = async () => {
+ try {
+ await withAuth(() => invoke('mcp_copy_token_to_clipboard'), { autoPrompt: true });
+ setTokenCopied(true);
+ } catch (e) {
+ setError(String(e));
+ }
+ };
+
+ const handleCopyAgentSetupPrompt = async () => {
+ const endpoint = `http://localhost:${port}/mcp`;
+ const prompt = t('settings.ai_embedding.agent_setup.prompt', {
+ skillName: agentSkillName,
+ repo: agentSkillRepo,
+ endpoint,
+ });
+ try {
+ await navigator.clipboard.writeText(prompt);
+ setAgentPromptCopied(true);
+ } catch (e) {
+ setError(String(e));
+ }
+ };
+
+ const normalizedServiceState = enabled
+ ? (running ? 'running' : serviceState || 'pending_auth')
+ : 'disabled';
+ const shouldShowStartButton = enabled && normalizedServiceState !== 'running';
+ const statusBadge = {
+ running: { label: 'RUNNING', className: 'text-green-500' },
+ pending_auth: { label: 'WAITING', className: 'text-amber-400' },
+ error: { label: 'ERROR', className: 'text-red-500' },
+ stopped: { label: 'STOPPED', className: 'text-red-500' },
+ }[normalizedServiceState] || { label: 'STOPPED', className: 'text-red-500' };
+ const statusMessage = (() => {
+ if (restoreLoading) return t('settings.ai_embedding.status.starting');
+ if (!enabled) return t('settings.ai_embedding.status.stopped');
+ if (normalizedServiceState === 'running') {
+ return `${t('settings.ai_embedding.status.port_label')}: ${port}`;
+ }
+ if (normalizedServiceState === 'pending_auth') {
+ return t('settings.ai_embedding.status.pending_auth');
+ }
+ if (normalizedServiceState === 'error') {
+ return statusError || t('settings.ai_embedding.status.error');
+ }
+ return t('settings.ai_embedding.status.stopped');
+ })();
+
+ useEffect(() => {
+ if (!enabled || running) {
+ restoreAttemptRef.current = '';
+ return;
+ }
+ if (normalizedServiceState !== 'stopped' || actionLoading || restoreLoading) return;
+
+ const attemptKey = String(port || 23816);
+ if (restoreAttemptRef.current === attemptKey) return;
+
+ restoreAttemptRef.current = attemptKey;
+ startMcpService({ auto: true });
+ }, [enabled, running, normalizedServiceState, actionLoading, restoreLoading, port, startMcpService]);
+
+ useEffect(() => {
+ if (!enabled || running) return undefined;
+
+ const timer = window.setInterval(() => {
+ loadStatus();
+ }, 5000);
+
+ return () => window.clearInterval(timer);
+ }, [enabled, running, loadStatus]);
+
+ return {
+ enabled,
+ port,
+ running,
+ loading,
+ actionLoading,
+ restoreLoading,
+ error,
+ showPrivacyDialog,
+ setShowPrivacyDialog,
+ confirmText,
+ setConfirmText,
+ tokenCopied,
+ agentPromptCopied,
+ showResetConfirm,
+ setShowResetConfirm,
+ filterEnabled,
+ filterCategories,
+ filterMode,
+ showAdvanced,
+ setShowAdvanced,
+ piiEnabled,
+ piiEntities,
+ spacyModels,
+ downloadingModel,
+ recheckLoading,
+ showPiiAdvanced,
+ setShowPiiAdvanced,
+ CONFIRM_TEXT,
+ startMcpService,
+ handleToggle,
+ handleConfirmEnable,
+ handleResetToken,
+ filterLevel,
+ handleLevelChange,
+ handleCategoryToggle,
+ handleFilterModeChange,
+ handlePiiToggle,
+ handlePiiEntityToggle,
+ handleDownloadModel,
+ handleForceRecheck,
+ handleCopyCurrentToken,
+ handleCopyAgentSetupPrompt,
+ shouldShowStartButton,
+ statusBadge,
+ statusMessage,
+ };
+}
diff --git a/src/components/settings/useFeaturesController.js b/src/components/settings/useFeaturesController.js
new file mode 100644
index 0000000..98feeae
--- /dev/null
+++ b/src/components/settings/useFeaturesController.js
@@ -0,0 +1,377 @@
+import { useEffect, useRef, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
+import { withAuth } from '../../lib/auth_api';
+import { getClusteringStatus, runClustering, saveClusteringResults } from '../../lib/task_api';
+import { useTauriEventListener } from '../../hooks/useTauriEventListener';
+
+export function useFeaturesController({
+ monitorStatus,
+ t,
+ featureModeDefinitions,
+ getFeatureMode,
+}) {
+ const [config, setConfig] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [models, setModels] = useState([]);
+ const [modelsLoading, setModelsLoading] = useState(false);
+ const [clusteringDropdownOpen, setClusteringDropdownOpen] = useState(false);
+ const [clusteringAdvancedOpen, setClusteringAdvancedOpen] = useState(false);
+ const [clusteringRunning, setClusteringRunning] = useState(false);
+ const [clusteringError, setClusteringError] = useState(null);
+ const [clusteringNotice, setClusteringNotice] = useState(null);
+ const [clusteringStatus, setClusteringStatus] = useState(null);
+ const [rangeStart, setRangeStart] = useState('');
+ const [rangeEnd, setRangeEnd] = useState('');
+ const [customControlsOpen, setCustomControlsOpen] = useState(false);
+ const [scModelAvailable, setScModelAvailable] = useState(false);
+ const [scStatus, setScStatus] = useState(null);
+ const [scDownloading, setScDownloading] = useState(false);
+ const [scDownloadLog, setScDownloadLog] = useState([]);
+ const [scDownloadError, setScDownloadError] = useState(null);
+ const scDownloadStartedRef = useRef(false);
+
+ const loadConfig = async () => {
+ try {
+ const result = await invoke('get_advanced_config');
+ if (result.clustering_enabled === undefined) result.clustering_enabled = true;
+ if (result.classification_enabled === undefined) result.classification_enabled = true;
+ setConfig(result);
+ } catch (err) {
+ console.error('Failed to load advanced config:', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const loadModels = async () => {
+ if (monitorStatus !== 'running') {
+ return;
+ }
+ setModelsLoading(true);
+ try {
+ const res = await withAuth(() => invoke('monitor_get_all_models'));
+ const parsedRes = typeof res === 'string' ? JSON.parse(res) : res;
+ if (parsedRes && parsedRes.status === 'success' && parsedRes.models) {
+ setModels(parsedRes.models);
+ } else {
+ console.warn('[FeaturesSection] Response format unexpected or not successful:', parsedRes);
+ }
+ } catch (err) {
+ console.error('[FeaturesSection] Failed to fetch models:', err);
+ } finally {
+ setModelsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadConfig();
+ }, []);
+
+ useEffect(() => {
+ if (monitorStatus === 'running') {
+ loadModels();
+ }
+ }, [monitorStatus]);
+
+ const refreshClusteringStatus = async () => {
+ if (monitorStatus !== 'running') return;
+ try {
+ const result = await getClusteringStatus();
+ if (result?.status === 'success') {
+ setClusteringStatus(result);
+ }
+ } catch { /* ignore */ }
+ };
+
+ useEffect(() => {
+ refreshClusteringStatus();
+ }, [monitorStatus]);
+
+ const refreshSmartClusterModel = async () => {
+ try {
+ const modelStatus = await invoke('check_model_files');
+ const reranker = modelStatus?.['bge-reranker-v2-m3'];
+ setScModelAvailable(reranker?.complete === true);
+ } catch (err) {
+ console.warn('Failed to check reranker model:', err);
+ }
+ };
+
+ const refreshSmartClusterStatus = async () => {
+ try {
+ const s = await withAuth(() => invoke('smart_cluster_status'));
+ setScStatus(s);
+ } catch { /* ignore */ }
+ };
+
+ useEffect(() => {
+ refreshSmartClusterModel();
+ refreshSmartClusterStatus();
+ const interval = setInterval(() => {
+ refreshSmartClusterStatus();
+ }, 10000);
+ return () => clearInterval(interval);
+ }, []);
+
+ useTauriEventListener('install-log', (event) => {
+ const line = event?.payload?.line || JSON.stringify(event?.payload || {});
+ const ts = new Date().toLocaleTimeString();
+ setScDownloadLog((prev) => [...prev, `[${ts}] ${line}`]);
+ }, [scDownloading], scDownloading);
+
+ useEffect(() => {
+ const handler = () => {
+ setClusteringDropdownOpen(false);
+ };
+ if (clusteringDropdownOpen) {
+ document.addEventListener('click', handler);
+ return () => document.removeEventListener('click', handler);
+ }
+ }, [clusteringDropdownOpen]);
+
+ const saveConfig = async (newConfig) => {
+ setConfig(newConfig);
+ try {
+ await withAuth(() => invoke('set_advanced_config', { config: newConfig }), { autoPrompt: true });
+ await withAuth(() => invoke('monitor_update_feature_config', {
+ clusteringEnabled: newConfig.clustering_enabled,
+ classificationEnabled: newConfig.classification_enabled,
+ }), { autoPrompt: true });
+ } catch (err) {
+ console.error('Failed to save advanced config:', err);
+ }
+ };
+
+ const handleOpenLocation = async (path) => {
+ try {
+ await invoke('open_path', { path });
+ } catch (err) {
+ console.error('Failed to open location:', err);
+ }
+ };
+
+ const handleFeatureModeChange = async (mode) => {
+ if (!config) return;
+ if (mode === 'smart' && !scModelAvailable) return;
+
+ const option = featureModeDefinitions.find((item) => item.value === mode);
+ if (!option) return;
+
+ setCustomControlsOpen(false);
+ await saveConfig({
+ ...config,
+ ...option.config,
+ });
+ };
+
+ const handleCustomFeatureToggle = async (key) => {
+ if (!config) return;
+ if (key === 'smart_cluster_enabled' && !config.smart_cluster_enabled && !scModelAvailable) return;
+
+ await saveConfig({
+ ...config,
+ [key]: !config[key],
+ });
+ };
+
+ const handleClusteringIntervalChange = async (interval) => {
+ if (!config) return;
+ setClusteringDropdownOpen(false);
+ const newConfig = { ...config, clustering_interval: interval };
+ await saveConfig(newConfig);
+ try {
+ await withAuth(() => invoke('monitor_set_clustering_interval', { interval }), { autoPrompt: true });
+ } catch {
+ // Best-effort runtime update; persisted config still applies next run.
+ }
+ };
+
+ const handleRunClustering = async () => {
+ setClusteringRunning(true);
+ setClusteringError(null);
+ setClusteringNotice(null);
+ try {
+ const options = { manual: true };
+ if (rangeStart) options.startTime = new Date(rangeStart).getTime() / 1000;
+ if (rangeEnd) options.endTime = new Date(rangeEnd).getTime() / 1000;
+
+ let result = await runClustering(options);
+ if (result?.status === 'needs_user_choice') {
+ const hasCompleteRange = Boolean(rangeStart && rangeEnd);
+ const count = result?.estimate?.count ?? result?.n_total ?? 0;
+ const memory = result?.estimate?.memory || {};
+ const scope = hasCompleteRange
+ ? t('tasks.clusteringRangeScope')
+ : t('tasks.clusteringAllScope');
+ const reason = result.reason === 'low_memory'
+ ? t('tasks.clusteringLowMemoryReason')
+ : t('tasks.clusteringLargeRangeReason');
+ const useBatched = window.confirm(t('tasks.clusteringDegradePrompt', {
+ scope,
+ count,
+ reason,
+ estimatedGb: memory.estimated_peak_bytes
+ ? (memory.estimated_peak_bytes / (1024 ** 3)).toFixed(1)
+ : '-',
+ }));
+ result = await runClustering({
+ ...options,
+ clusteringMode: useBatched ? 'batched' : 'full',
+ });
+ }
+
+ if (result?.status === 'empty') {
+ setClusteringError(t('tasks.noData'));
+ }
+
+ if (result?.clusters?.length) {
+ const taskRequests = result.clusters.map((cl) => ({
+ auto_label: cl.dominant_process || null,
+ dominant_process: cl.dominant_process || null,
+ dominant_category: cl.dominant_category || null,
+ start_time: cl.start_time || null,
+ end_time: cl.end_time || null,
+ snapshot_count: cl.snapshot_count || 0,
+ layer: 'hot',
+ screenshot_ids: (cl.snapshot_ids || []).map((id) => Number(id)),
+ confidences: null,
+ }));
+ await saveClusteringResults(taskRequests);
+ setClusteringNotice(t('settings.features.management.clustering.completed', {
+ count: taskRequests.length,
+ }));
+ }
+
+ if (result?.degraded) {
+ setClusteringNotice(t('tasks.clusteringDegradedNotice', {
+ sampleSize: result.sample_size ?? 0,
+ assignedCount: result.assigned_count ?? 0,
+ }));
+ }
+
+ await refreshClusteringStatus();
+ } catch (err) {
+ const msg = String(err?.message || err);
+ if (msg.includes('not found') || msg.includes('ModelNotAvailable') || msg.includes('not downloaded')) {
+ setClusteringError(t('tasks.modelMissing'));
+ } else {
+ setClusteringError(msg);
+ }
+ console.error('Clustering failed:', err);
+ } finally {
+ setClusteringRunning(false);
+ }
+ };
+
+ const handleDownloadReranker = async () => {
+ if (scDownloadStartedRef.current) return;
+ scDownloadStartedRef.current = true;
+ setScDownloading(true);
+ setScDownloadLog([]);
+ setScDownloadError(null);
+ try {
+ setScDownloadLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] Downloading bge-reranker-v2-m3 (uint8, ~570MB)...`]);
+ await invoke('download_model', {
+ repo: 'onnx-community/bge-reranker-v2-m3-ONNX',
+ subdir: 'bge-reranker-v2-m3',
+ files: [
+ 'config.json',
+ 'tokenizer.json',
+ 'tokenizer_config.json',
+ 'special_tokens_map.json',
+ 'onnx/model_uint8.onnx',
+ ],
+ });
+ await invoke('mark_smart_cluster_setup_done', { dismissedPermanently: false });
+ await refreshSmartClusterModel();
+ } catch (err) {
+ setScDownloadError(err?.message || String(err));
+ scDownloadStartedRef.current = false;
+ } finally {
+ setScDownloading(false);
+ }
+ };
+
+ const handleDrainNow = async () => {
+ try {
+ await withAuth(() => invoke('monitor_smart_cluster_drain_now'), { autoPrompt: true });
+ setTimeout(refreshSmartClusterStatus, 500);
+ } catch (err) {
+ console.warn('Failed to trigger drain_now:', err);
+ }
+ };
+
+ const handleRescanAll = async () => {
+ try {
+ await withAuth(() => invoke('smart_cluster_rescan_all'), { autoPrompt: true });
+ setTimeout(refreshSmartClusterStatus, 500);
+ } catch (err) {
+ console.warn('Failed to trigger rescan:', err);
+ }
+ };
+
+ const formatSize = (sizeStr) => {
+ if (!sizeStr) return '-';
+ return sizeStr;
+ };
+
+ const lastClusteringRunLabel = clusteringStatus?.config?.last_run
+ ? new Date(clusteringStatus.config.last_run * 1000).toLocaleString()
+ : t('tasks.never');
+ const featureMode = config ? getFeatureMode(config) : 'minimal';
+ const featureModeOptions = featureModeDefinitions.map((option) => ({
+ ...option,
+ label: t(`settings.features.management.featureMode.options.${option.value}.label`),
+ description: t(`settings.features.management.featureMode.options.${option.value}.description`),
+ disabled: option.value === 'smart' && !scModelAvailable,
+ title: option.value === 'smart' && !scModelAvailable
+ ? t('settings.features.management.smartCluster.modelMissing', '请先下载模型')
+ : t(`settings.features.management.featureMode.options.${option.value}.description`),
+ }));
+ const selectedFeatureMode = featureModeOptions.find((option) => option.value === featureMode) || featureModeOptions[0];
+
+ useEffect(() => {
+ if (featureMode === 'custom') {
+ setCustomControlsOpen(true);
+ }
+ }, [featureMode]);
+
+ return {
+ config,
+ loading,
+ models,
+ modelsLoading,
+ clusteringDropdownOpen,
+ setClusteringDropdownOpen,
+ clusteringAdvancedOpen,
+ setClusteringAdvancedOpen,
+ clusteringRunning,
+ clusteringError,
+ clusteringNotice,
+ rangeStart,
+ setRangeStart,
+ rangeEnd,
+ setRangeEnd,
+ customControlsOpen,
+ setCustomControlsOpen,
+ scModelAvailable,
+ scStatus,
+ scDownloading,
+ scDownloadLog,
+ scDownloadError,
+ handleOpenLocation,
+ handleFeatureModeChange,
+ handleCustomFeatureToggle,
+ handleClusteringIntervalChange,
+ handleRunClustering,
+ handleDownloadReranker,
+ handleDrainNow,
+ handleRescanAll,
+ formatSize,
+ lastClusteringRunLabel,
+ featureMode,
+ featureModeOptions,
+ selectedFeatureMode,
+ loadModels,
+ };
+}
diff --git a/src/components/settings/useGeneralOptionsController.js b/src/components/settings/useGeneralOptionsController.js
new file mode 100644
index 0000000..136ed82
--- /dev/null
+++ b/src/components/settings/useGeneralOptionsController.js
@@ -0,0 +1,255 @@
+import { useEffect, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
+import { getLightweightConfig, setLightweightConfig, switchToLightweightMode } from '../../lib/lightweight_api';
+import { withAuth } from '../../lib/auth_api';
+import { useTauriEventListener } from '../../hooks/useTauriEventListener';
+
+const RESOURCE_POLICY_STORAGE_KEY = 'settings.resourcePolicy';
+
+const RESOURCE_POLICY_OPTIONS = [
+ {
+ value: 'complete',
+ powerSaving: false,
+ gameMode: false,
+ colorClass: {
+ selected: 'bg-amber-500/20 text-amber-200 border-amber-400/50',
+ idle: 'text-amber-300/90 hover:bg-amber-500/10 hover:text-amber-200',
+ },
+ },
+ {
+ value: 'balanced',
+ powerSaving: true,
+ gameMode: false,
+ colorClass: {
+ selected: 'bg-sky-500/20 text-sky-200 border-sky-400/50',
+ idle: 'text-sky-300/90 hover:bg-sky-500/10 hover:text-sky-200',
+ },
+ },
+ {
+ value: 'performance',
+ powerSaving: true,
+ gameMode: true,
+ colorClass: {
+ selected: 'bg-emerald-500/20 text-emerald-200 border-emerald-400/50',
+ idle: 'text-emerald-300/90 hover:bg-emerald-500/10 hover:text-emerald-200',
+ },
+ },
+ {
+ value: 'custom',
+ colorClass: {
+ selected: 'bg-ide-accent/20 text-ide-text border-ide-accent/50',
+ idle: 'text-ide-muted hover:bg-ide-hover hover:text-ide-text',
+ },
+ },
+];
+
+function getResourcePolicy(powerSaving, gameMode) {
+ if (!powerSaving && !gameMode) return 'complete';
+ if (powerSaving && !gameMode) return 'balanced';
+ if (powerSaving && gameMode) return 'performance';
+ return 'custom';
+}
+
+export function useGeneralOptionsController({ externalPowerSavingMode, onTogglePowerSaving, t }) {
+ const [powerSavingMode, setPowerSavingMode] = useState(externalPowerSavingMode !== false);
+ const [gameModeEnabled, setGameModeEnabled] = useState(false);
+ const [gameModeActive, setGameModeActive] = useState(false);
+ const [gameModePermanent, setGameModePermanent] = useState(false);
+ const [fullscreenPaused, setFullscreenPaused] = useState(false);
+ const [useDml, setUseDml] = useState(false);
+ const [gameModeLoading, setGameModeLoading] = useState(true);
+ const [resourcePolicyLoading, setResourcePolicyLoading] = useState(false);
+ const [manualResourcePolicy, setManualResourcePolicy] = useState(() => {
+ if (typeof window === 'undefined') return null;
+ return localStorage.getItem(RESOURCE_POLICY_STORAGE_KEY) === 'custom' ? 'custom' : null;
+ });
+ const [lightweightConfig, setLightweightConfigState] = useState({
+ start_with_window_hidden: false,
+ auto_lightweight_enabled: false,
+ auto_lightweight_delay_minutes: 5,
+ });
+ const [cardClickBehaviorSearch, setCardClickBehaviorSearch] = useState(() => localStorage.getItem('cardClickBehavior_search') || 'preview');
+ const [cardClickBehaviorClusters, setCardClickBehaviorClusters] = useState(() => localStorage.getItem('cardClickBehavior_clusters') || 'standalone');
+ const [cardClickBehaviorActivityContext, setCardClickBehaviorActivityContext] = useState(() => localStorage.getItem('cardClickBehavior_activityContext') || 'preview');
+
+ useEffect(() => {
+ getLightweightConfig().then(setLightweightConfigState).catch(console.error);
+ }, []);
+
+ useEffect(() => {
+ setPowerSavingMode(externalPowerSavingMode !== false);
+ }, [externalPowerSavingMode]);
+
+ useTauriEventListener('power-saving-changed', (event) => {
+ const payload = event.payload || {};
+ setPowerSavingMode(payload.enabled !== false);
+ });
+
+ const handleSetPowerSaving = async (next) => {
+ const previous = powerSavingMode;
+ setPowerSavingMode(next);
+ onTogglePowerSaving?.(next);
+ try {
+ await invoke('set_power_saving_enabled', { enabled: next });
+ } catch (err) {
+ console.error('Failed to set power saving mode:', err);
+ setPowerSavingMode(previous);
+ onTogglePowerSaving?.(previous);
+ throw err;
+ }
+ };
+
+ useEffect(() => {
+ (async () => {
+ try {
+ const config = await invoke('get_advanced_config');
+ setUseDml(config.use_dml || false);
+ setGameModeEnabled(config.game_mode_enabled || false);
+
+ const status = await invoke('get_game_mode_status');
+ setGameModeActive(status.active || false);
+ setGameModePermanent(status.permanent || false);
+ setFullscreenPaused(status.fullscreen_paused || false);
+ } catch (err) {
+ console.error('Failed to load config for game mode:', err);
+ } finally {
+ setGameModeLoading(false);
+ }
+ })();
+ }, []);
+
+ useTauriEventListener('game-mode-status', (event) => {
+ setGameModeActive(event.payload?.active || false);
+ setGameModePermanent(event.payload?.permanent || false);
+ if (event.payload?.fullscreen_paused !== undefined) {
+ setFullscreenPaused(event.payload.fullscreen_paused);
+ }
+ });
+
+ const handleSetGameMode = async (next) => {
+ const previous = gameModeEnabled;
+ setGameModeEnabled(next);
+ try {
+ await withAuth(() => invoke('toggle_game_mode', { enabled: next }), { autoPrompt: true });
+ } catch (err) {
+ console.error('Failed to set game mode:', err);
+ setGameModeEnabled(previous);
+ throw err;
+ }
+ };
+
+ const handleResourcePolicyChange = async (nextPolicy) => {
+ if (nextPolicy === 'custom') {
+ setManualResourcePolicy('custom');
+ localStorage.setItem(RESOURCE_POLICY_STORAGE_KEY, 'custom');
+ return;
+ }
+ const option = RESOURCE_POLICY_OPTIONS.find((item) => item.value === nextPolicy);
+ if (!option || resourcePolicyLoading) return;
+
+ const previousPowerSaving = powerSavingMode;
+ const previousGameMode = gameModeEnabled;
+ const previousManualResourcePolicy = manualResourcePolicy;
+ setResourcePolicyLoading(true);
+ setManualResourcePolicy(null);
+ localStorage.removeItem(RESOURCE_POLICY_STORAGE_KEY);
+ setPowerSavingMode(option.powerSaving);
+ setGameModeEnabled(option.gameMode);
+ onTogglePowerSaving?.(option.powerSaving);
+ try {
+ if (previousGameMode !== option.gameMode) {
+ await withAuth(() => invoke('toggle_game_mode', { enabled: option.gameMode }), { autoPrompt: true });
+ }
+ if (previousPowerSaving !== option.powerSaving) {
+ await invoke('set_power_saving_enabled', { enabled: option.powerSaving });
+ }
+ } catch (err) {
+ console.error('Failed to change resource policy:', err);
+ if (previousGameMode !== option.gameMode) {
+ try {
+ await withAuth(() => invoke('toggle_game_mode', { enabled: previousGameMode }), { autoPrompt: true });
+ } catch (rollbackErr) {
+ console.error('Failed to roll back game mode:', rollbackErr);
+ }
+ }
+ if (previousPowerSaving !== option.powerSaving) {
+ try {
+ await invoke('set_power_saving_enabled', { enabled: previousPowerSaving });
+ } catch (rollbackErr) {
+ console.error('Failed to roll back power saving mode:', rollbackErr);
+ }
+ }
+ setPowerSavingMode(previousPowerSaving);
+ setGameModeEnabled(previousGameMode);
+ setManualResourcePolicy(previousManualResourcePolicy);
+ if (previousManualResourcePolicy === 'custom') {
+ localStorage.setItem(RESOURCE_POLICY_STORAGE_KEY, 'custom');
+ } else {
+ localStorage.removeItem(RESOURCE_POLICY_STORAGE_KEY);
+ }
+ onTogglePowerSaving?.(previousPowerSaving);
+ } finally {
+ setResourcePolicyLoading(false);
+ }
+ };
+
+ const handleLightweightConfigChange = async (key, value) => {
+ const newConfig = { ...lightweightConfig, [key]: value };
+ setLightweightConfigState(newConfig);
+ try {
+ await setLightweightConfig(newConfig);
+ } catch (error) {
+ console.error('Failed to save lightweight config:', error);
+ }
+ };
+
+ const handleSwitchToLightweight = async () => {
+ try {
+ await switchToLightweightMode();
+ } catch (error) {
+ console.error('Failed to switch to lightweight mode:', error);
+ }
+ };
+
+ const setCardClickBehavior = (scope, value) => {
+ localStorage.setItem(`cardClickBehavior_${scope}`, value);
+ if (scope === 'search') setCardClickBehaviorSearch(value);
+ if (scope === 'clusters') setCardClickBehaviorClusters(value);
+ if (scope === 'activityContext') setCardClickBehaviorActivityContext(value);
+ };
+
+ const derivedResourcePolicy = getResourcePolicy(powerSavingMode, gameModeEnabled);
+ const resourcePolicy = manualResourcePolicy || derivedResourcePolicy;
+ const resourcePolicyOptions = RESOURCE_POLICY_OPTIONS.map((option) => ({
+ ...option,
+ label: t(`settings.general.resourcePolicy.options.${option.value}.label`),
+ description: t(`settings.general.resourcePolicy.options.${option.value}.description`),
+ selectedClassName: option.colorClass.selected,
+ idleClassName: `border-transparent ${option.colorClass.idle}`,
+ }));
+ const selectedResourcePolicy = resourcePolicyOptions.find((option) => option.value === resourcePolicy) || resourcePolicyOptions[2];
+
+ return {
+ powerSavingMode,
+ gameModeEnabled,
+ gameModeActive,
+ gameModePermanent,
+ fullscreenPaused,
+ useDml,
+ gameModeLoading,
+ resourcePolicyLoading,
+ lightweightConfig,
+ cardClickBehaviorSearch,
+ cardClickBehaviorClusters,
+ cardClickBehaviorActivityContext,
+ resourcePolicy,
+ resourcePolicyOptions,
+ selectedResourcePolicy,
+ handleSetPowerSaving,
+ handleSetGameMode,
+ handleResourcePolicyChange,
+ handleLightweightConfigChange,
+ handleSwitchToLightweight,
+ setCardClickBehavior,
+ };
+}
diff --git a/src/components/settings/useSettingsDialogController.js b/src/components/settings/useSettingsDialogController.js
new file mode 100644
index 0000000..2b17ba8
--- /dev/null
+++ b/src/components/settings/useSettingsDialogController.js
@@ -0,0 +1,442 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
+import { updateMonitorFilters, deleteRecordsByTimeRange } from '../../lib/monitor_api';
+import { getAnalysisOverview } from '../../lib/analysis_api';
+import { defaultFilterSettings, formatInvokeError, normalizeList } from './filterUtils';
+import { REFRESH_INTERVAL_MS } from './analysisUtils';
+import { checkForUpdate, downloadAndInstallUpdate } from '../../lib/update_api';
+import { withAuth } from '../../lib/auth_api';
+
+function readInitialFilterSettings() {
+ try {
+ const saved = JSON.parse(localStorage.getItem('monitorFilters') || 'null');
+ if (saved && typeof saved === 'object') {
+ return {
+ ...defaultFilterSettings,
+ ...saved,
+ processes: Array.isArray(saved.processes) ? saved.processes : [],
+ titles: Array.isArray(saved.titles) ? saved.titles : [],
+ ignoreProtected: typeof saved.ignoreProtected === 'boolean' ? saved.ignoreProtected : true,
+ };
+ }
+ } catch (e) {
+ console.warn('Failed to read saved filters', e);
+ }
+ return defaultFilterSettings;
+}
+
+export function useSettingsDialogController({
+ isOpen,
+ activeTab,
+ onManualStartMonitor,
+ onManualStopMonitor,
+ onRecordsDeleted,
+ t,
+}) {
+ const [lowResolutionAnalysis, setLowResolutionAnalysis] = useState(() => localStorage.getItem('lowResolutionAnalysis') === 'true');
+ const [sendTelemetryDiagnostics, setSendTelemetryDiagnostics] = useState(() => localStorage.getItem('sendTelemetryDiagnostics') === 'true');
+ const [monitorStatus, setMonitorStatus] = useState('stopped');
+ const monitorStatusRef = useRef('stopped');
+ const [filterSettings, setFilterSettings] = useState(readInitialFilterSettings);
+ const [processInput, setProcessInput] = useState('');
+ const [titleInput, setTitleInput] = useState('');
+ const [filtersDirty, setFiltersDirty] = useState(false);
+ const [savingFilters, setSavingFilters] = useState(false);
+ const [saveFiltersMessage, setSaveFiltersMessage] = useState('');
+ const [autoLaunchEnabled, setAutoLaunchEnabled] = useState(null);
+ const [autoLaunchLoading, setAutoLaunchLoading] = useState(false);
+ const [autoLaunchMessage, setAutoLaunchMessage] = useState('');
+ const [memorySeries, setMemorySeries] = useState([]);
+ const [storage, setStorage] = useState(null);
+ const [analysisLoading, setAnalysisLoading] = useState(true);
+ const [analysisRefreshing, setAnalysisRefreshing] = useState(false);
+ const [analysisError, setAnalysisError] = useState('');
+ const [checkingUpdate, setCheckingUpdate] = useState(false);
+ const [upToDate, setUpToDate] = useState(false);
+ const [updateInfo, setUpdateInfo] = useState(null);
+ const [updateError, setUpdateError] = useState('');
+ const [downloading, setDownloading] = useState(false);
+ const [downloadProgress, setDownloadProgress] = useState({ downloaded: 0, contentLength: 0 });
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [deleteMessage, setDeleteMessage] = useState('');
+
+ const checkMonitorStatus = async () => {
+ try {
+ const resString = await invoke('get_monitor_status');
+ try {
+ const res = JSON.parse(resString);
+ if (res.stopped) {
+ setMonitorStatus('stopped');
+ monitorStatusRef.current = 'stopped';
+ } else if (res.paused) {
+ setMonitorStatus('paused');
+ monitorStatusRef.current = 'paused';
+ } else {
+ setMonitorStatus('running');
+ monitorStatusRef.current = 'running';
+ }
+ } catch {
+ setMonitorStatus('running');
+ monitorStatusRef.current = 'running';
+ }
+ } catch {
+ if (monitorStatusRef.current === 'waiting') {
+ return;
+ }
+ setMonitorStatus('stopped');
+ monitorStatusRef.current = 'stopped';
+ }
+ };
+
+ const addProcessTags = () => {
+ const items = normalizeList(processInput);
+ if (!items.length) return;
+ setFilterSettings((prev) => {
+ const merged = Array.from(new Set([...(prev.processes || []), ...items]));
+ return { ...prev, processes: merged };
+ });
+ setProcessInput('');
+ setFiltersDirty(true);
+ setSaveFiltersMessage('');
+ };
+
+ const addTitleTags = () => {
+ const items = normalizeList(titleInput);
+ if (!items.length) return;
+ setFilterSettings((prev) => {
+ const merged = Array.from(new Set([...(prev.titles || []), ...items]));
+ return { ...prev, titles: merged };
+ });
+ setTitleInput('');
+ setFiltersDirty(true);
+ setSaveFiltersMessage('');
+ };
+
+ const removeProcessTag = (tag) => {
+ setFilterSettings((prev) => ({
+ ...prev,
+ processes: (prev.processes || []).filter((p) => p !== tag),
+ }));
+ setFiltersDirty(true);
+ setSaveFiltersMessage('');
+ };
+
+ const removeTitleTag = (tag) => {
+ setFilterSettings((prev) => ({
+ ...prev,
+ titles: (prev.titles || []).filter((item) => item !== tag),
+ }));
+ setFiltersDirty(true);
+ setSaveFiltersMessage('');
+ };
+
+ const handleToggleProtected = () => {
+ setFilterSettings((prev) => ({ ...prev, ignoreProtected: !prev.ignoreProtected }));
+ setFiltersDirty(true);
+ setSaveFiltersMessage('');
+ };
+
+ const syncFiltersToMonitor = async (filtersPayload = filterSettings) => {
+ if (monitorStatus !== 'running') {
+ return { ok: false, reason: 'not_running' };
+ }
+ try {
+ await updateMonitorFilters({
+ processes: filtersPayload.processes,
+ titles: filtersPayload.titles,
+ ignore_protected: filtersPayload.ignoreProtected,
+ });
+ return { ok: true };
+ } catch (e) {
+ if (e?.code === 'unsupported') {
+ return { ok: false, reason: 'unsupported' };
+ }
+ return { ok: false, reason: 'error', error: e };
+ }
+ };
+
+ const handleQuickDelete = async (minutes) => {
+ setIsDeleting(true);
+ setDeleteMessage('');
+ try {
+ const result = await deleteRecordsByTimeRange(minutes);
+ if (result.error) {
+ setDeleteMessage(t('settings.delete.failure', { error: result.error }));
+ } else {
+ const count = result.deleted_count || 0;
+ setDeleteMessage(t('settings.delete.success', { count }));
+ onRecordsDeleted?.();
+ }
+ } catch (e) {
+ setDeleteMessage(t('settings.delete.failure', { error: e?.message || e }));
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ const handleSaveFilters = async () => {
+ setSavingFilters(true);
+ setSaveFiltersMessage('');
+
+ const nextFilters = { ...filterSettings };
+
+ setFilterSettings(nextFilters);
+ setFiltersDirty(false);
+
+ const result = await syncFiltersToMonitor(nextFilters);
+ setSavingFilters(false);
+ if (result.ok) {
+ setSaveFiltersMessage(t('settings.save_filters.synced'));
+ } else if (result.reason === 'not_running') {
+ setSaveFiltersMessage(t('settings.save_filters.saved_local_not_running'));
+ } else if (result.reason === 'unsupported') {
+ setSaveFiltersMessage(t('settings.save_filters.saved_local_unsupported'));
+ } else {
+ setSaveFiltersMessage(t('settings.save_filters.saved_local_sync_failed', { error: result.error?.message || result.error || 'Unknown error' }));
+ }
+ };
+
+ const handleStartMonitor = async () => {
+ setMonitorStatus('waiting');
+ monitorStatusRef.current = 'waiting';
+ onManualStartMonitor?.();
+ try {
+ await withAuth(() => invoke('start_monitor'), { autoPrompt: true });
+ } catch (e) {
+ console.error('Failed to start monitor', e);
+ setMonitorStatus('stopped');
+ monitorStatusRef.current = 'stopped';
+ }
+ };
+
+ const handleStopMonitor = async () => {
+ setMonitorStatus('loading');
+ monitorStatusRef.current = 'loading';
+ try {
+ await withAuth(() => invoke('stop_monitor'), { autoPrompt: true });
+ } catch (e) {
+ console.error('Failed to stop monitor', e);
+ } finally {
+ onManualStopMonitor?.();
+ setMonitorStatus('stopped');
+ monitorStatusRef.current = 'stopped';
+ }
+ };
+
+ const handleRestartMonitor = async () => {
+ setMonitorStatus('loading');
+ monitorStatusRef.current = 'loading';
+ try {
+ await withAuth(() => invoke('stop_monitor'), { autoPrompt: true });
+ setMonitorStatus('waiting');
+ monitorStatusRef.current = 'waiting';
+ await withAuth(() => invoke('start_monitor'), { autoPrompt: true });
+ await checkMonitorStatus();
+ } catch (e) {
+ console.error('Failed to restart monitor', e);
+ setMonitorStatus('stopped');
+ monitorStatusRef.current = 'stopped';
+ await checkMonitorStatus();
+ }
+ };
+
+ const handlePauseMonitor = async () => {
+ try {
+ await withAuth(() => invoke('pause_monitor'), { autoPrompt: true });
+ await checkMonitorStatus();
+ } catch (e) {
+ console.error(e);
+ }
+ };
+
+ const handleResumeMonitor = async () => {
+ try {
+ await withAuth(() => invoke('resume_monitor'), { autoPrompt: true });
+ await checkMonitorStatus();
+ } catch (e) {
+ console.error(e);
+ }
+ };
+
+ const refreshAutoLaunchStatus = async () => {
+ setAutoLaunchLoading(true);
+ setAutoLaunchMessage('');
+ try {
+ const enabled = await invoke('get_autostart_status');
+ setAutoLaunchEnabled(Boolean(enabled));
+ } catch (e) {
+ setAutoLaunchMessage(e?.message || t('settings.autolaunch.read_error'));
+ setAutoLaunchEnabled(null);
+ } finally {
+ setAutoLaunchLoading(false);
+ }
+ };
+
+ const handleToggleAutoLaunch = async () => {
+ setAutoLaunchLoading(true);
+ setAutoLaunchMessage('');
+ try {
+ const next = !(autoLaunchEnabled ?? false);
+ const result = await invoke('set_autostart', { enabled: next });
+ setAutoLaunchEnabled(Boolean(result));
+ setAutoLaunchMessage(Boolean(result) ? t('settings.autolaunch.enabled') : t('settings.autolaunch.disabled'));
+ } catch (e) {
+ setAutoLaunchMessage(t('settings.autolaunch.action_failed', { error: formatInvokeError(e) }));
+ } finally {
+ setAutoLaunchLoading(false);
+ }
+ };
+
+ const loadAnalysisOverview = useCallback(
+ async (forceStorage = false) => {
+ try {
+ setAnalysisError('');
+ if (!analysisRefreshing) {
+ setAnalysisLoading(true);
+ }
+ const result = await getAnalysisOverview(forceStorage);
+ setMemorySeries(result?.memory || []);
+ setStorage(result?.storage || null);
+ } catch (err) {
+ setAnalysisError(err?.message || t('settings.analysis.load_failed', { error: '' }));
+ } finally {
+ setAnalysisLoading(false);
+ setAnalysisRefreshing(false);
+ }
+ },
+ [analysisRefreshing, t],
+ );
+
+ const handleRefreshAnalysis = () => {
+ setAnalysisRefreshing(true);
+ loadAnalysisOverview(true);
+ };
+
+ useEffect(() => {
+ let interval;
+ if (isOpen) {
+ checkMonitorStatus();
+ refreshAutoLaunchStatus();
+ interval = setInterval(checkMonitorStatus, 2000);
+ }
+ return () => clearInterval(interval);
+ }, [isOpen]);
+
+ useEffect(() => {
+ localStorage.setItem('monitorFilters', JSON.stringify(filterSettings));
+ }, [filterSettings]);
+
+ useEffect(() => {
+ if (monitorStatus === 'running') {
+ syncFiltersToMonitor();
+ }
+ }, [monitorStatus]);
+
+ useEffect(() => {
+ monitorStatusRef.current = monitorStatus;
+ }, [monitorStatus]);
+
+ useEffect(() => {
+ try {
+ localStorage.setItem('lowResolutionAnalysis', lowResolutionAnalysis ? 'true' : 'false');
+ } catch {
+ // ignore
+ }
+ }, [lowResolutionAnalysis]);
+
+ useEffect(() => {
+ try {
+ localStorage.setItem('sendTelemetryDiagnostics', sendTelemetryDiagnostics ? 'true' : 'false');
+ } catch {
+ // ignore
+ }
+ }, [sendTelemetryDiagnostics]);
+
+ useEffect(() => {
+ if (!isOpen || activeTab !== 'analysis') return undefined;
+ loadAnalysisOverview(false);
+ const timer = setInterval(() => loadAnalysisOverview(false), REFRESH_INTERVAL_MS);
+ return () => clearInterval(timer);
+ }, [isOpen, activeTab, loadAnalysisOverview]);
+
+ const handleCheckUpdate = async () => {
+ setCheckingUpdate(true);
+ setUpToDate(false);
+ setUpdateInfo(null);
+ setUpdateError('');
+ try {
+ const result = await checkForUpdate();
+ if (result.available) {
+ setUpdateInfo({ version: result.version, body: result.body });
+ } else {
+ setUpToDate(true);
+ }
+ } catch (err) {
+ setUpdateError(err?.message || String(err));
+ } finally {
+ setCheckingUpdate(false);
+ }
+ };
+
+ const handleDownloadUpdate = async () => {
+ if (!updateInfo) return;
+ setDownloading(true);
+ setDownloadProgress({ phase: 'downloading', downloaded: 0, contentLength: 0 });
+ try {
+ await downloadAndInstallUpdate((progress) => {
+ setDownloadProgress(progress);
+ });
+ } catch (err) {
+ setUpdateError(err?.message || String(err));
+ } finally {
+ setDownloading(false);
+ }
+ };
+
+ return {
+ lowResolutionAnalysis,
+ toggleLowResolutionAnalysis: () => setLowResolutionAnalysis((value) => !value),
+ sendTelemetryDiagnostics,
+ toggleTelemetryDiagnostics: () => setSendTelemetryDiagnostics((value) => !value),
+ monitorStatus,
+ filterSettings,
+ processInput,
+ setProcessInput,
+ titleInput,
+ setTitleInput,
+ filtersDirty,
+ savingFilters,
+ saveFiltersMessage,
+ autoLaunchEnabled,
+ autoLaunchLoading,
+ autoLaunchMessage,
+ storage,
+ analysisLoading,
+ analysisRefreshing,
+ analysisError,
+ checkingUpdate,
+ upToDate,
+ updateInfo,
+ updateError,
+ downloading,
+ downloadProgress,
+ isDeleting,
+ deleteMessage,
+ addProcessTags,
+ addTitleTags,
+ removeProcessTag,
+ removeTitleTag,
+ handleToggleProtected,
+ handleQuickDelete,
+ handleSaveFilters,
+ handleStartMonitor,
+ handleStopMonitor,
+ handleRestartMonitor,
+ handlePauseMonitor,
+ handleResumeMonitor,
+ handleToggleAutoLaunch,
+ handleRefreshAnalysis,
+ handleCheckUpdate,
+ handleDownloadUpdate,
+ };
+}
diff --git a/src/components/settings/useStorageManagementController.js b/src/components/settings/useStorageManagementController.js
new file mode 100644
index 0000000..bc741fc
--- /dev/null
+++ b/src/components/settings/useStorageManagementController.js
@@ -0,0 +1,556 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
+import { listen } from '@tauri-apps/api/event';
+import { open } from '@tauri-apps/plugin-dialog';
+import {
+ fetchThumbnailBatch,
+ getIndexHealth,
+ getProcessMonthlyThumbnails,
+ getProcessStorageStats,
+ getSoftDeleteQueueStatus,
+ retryVectorIndexing,
+ softDeleteProcessMonth,
+ softDeleteScreenshots,
+} from '../../lib/monitor_api';
+import { withAuth } from '../../lib/auth_api';
+
+export function useStorageManagementController({ storage, onRefresh, t, monitorStatus }) {
+ const [storageLimit, setStorageLimit] = useState(() => {
+ return localStorage.getItem('snapshotStorageLimit') || 'unlimited';
+ });
+ const [retentionPeriod, setRetentionPeriod] = useState(() => {
+ return localStorage.getItem('snapshotRetentionPeriod') || 'permanent';
+ });
+ const [isMigrating, setIsMigrating] = useState(false);
+ const [migrationProgress, setMigrationProgress] = useState({ total_files: 0, copied_files: 0, current_file: '' });
+ const [migrationError, setMigrationError] = useState('');
+ const [isUpdatingStoragePath, setIsUpdatingStoragePath] = useState(false);
+ const [isMigrationChoiceDialogOpen, setIsMigrationChoiceDialogOpen] = useState(false);
+ const [pendingTargetPath, setPendingTargetPath] = useState('');
+ const [panelView, setPanelView] = useState('overview');
+ const [processStats, setProcessStats] = useState([]);
+ const [processStatsLoading, setProcessStatsLoading] = useState(false);
+ const [processStatsError, setProcessStatsError] = useState('');
+ const [selectedProcess, setSelectedProcess] = useState('');
+ const [processPage, setProcessPage] = useState(0);
+ const [processMonthData, setProcessMonthData] = useState(null);
+ const [processMonthLoading, setProcessMonthLoading] = useState(false);
+ const [processMonthError, setProcessMonthError] = useState('');
+ const [processThumbMap, setProcessThumbMap] = useState({});
+ const [selectedScreenshotIds, setSelectedScreenshotIds] = useState(() => new Set());
+ const [deletingTarget, setDeletingTarget] = useState('');
+ const [pendingDeleteIntent, setPendingDeleteIntent] = useState(null);
+ const [isBackupDialogOpen, setIsBackupDialogOpen] = useState(false);
+ const [backupMode, setBackupMode] = useState('export');
+ const [deleteQueueStatus, setDeleteQueueStatus] = useState({
+ pending_screenshots: 0,
+ pending_ocr: 0,
+ running: false,
+ });
+ const [indexHealth, setIndexHealth] = useState(null);
+ const [indexHealthLoading, setIndexHealthLoading] = useState(false);
+ const [indexHealthError, setIndexHealthError] = useState(null);
+ const [vectorRetrying, setVectorRetrying] = useState(false);
+ const mountedRef = useRef(true);
+ const migrationUnlistenersRef = useRef([]);
+
+ useEffect(() => {
+ return () => {
+ mountedRef.current = false;
+ migrationUnlistenersRef.current.forEach((unlisten) => {
+ try { unlisten(); } catch { }
+ });
+ migrationUnlistenersRef.current = [];
+ };
+ }, []);
+
+ useEffect(() => {
+ localStorage.setItem('snapshotStorageLimit', storageLimit);
+ (async () => {
+ try {
+ await withAuth(() => invoke('storage_set_policy', { policy: { storage_limit: storageLimit, retention_period: retentionPeriod } }));
+ } catch {
+ // Backend may be unavailable in dev; localStorage remains the fallback.
+ }
+ })();
+ }, [storageLimit]);
+
+ useEffect(() => {
+ localStorage.setItem('snapshotRetentionPeriod', retentionPeriod);
+ (async () => {
+ try {
+ await withAuth(() => invoke('storage_set_policy', { policy: { storage_limit: storageLimit, retention_period: retentionPeriod } }));
+ } catch {
+ // Ignore backend sync failures here; the settings remain locally persisted.
+ }
+ })();
+ }, [retentionPeriod]);
+
+ useEffect(() => {
+ (async () => {
+ try {
+ const resp = await withAuth(() => invoke('storage_get_policy'));
+ if (resp && typeof resp === 'object') {
+ if (resp.storage_limit) setStorageLimit(String(resp.storage_limit));
+ if (resp.retention_period) setRetentionPeriod(String(resp.retention_period));
+ }
+ } catch {
+ // Keep localStorage values when the backend is unavailable.
+ }
+ })();
+ }, []);
+
+ const loadDeleteQueueStatus = useCallback(async () => {
+ const status = await getSoftDeleteQueueStatus();
+ setDeleteQueueStatus(status || { pending_screenshots: 0, pending_ocr: 0, running: false });
+ }, []);
+
+ const loadIndexHealth = useCallback(async ({ refreshVector = false } = {}) => {
+ setIndexHealthLoading(true);
+ setIndexHealthError(null);
+ try {
+ const result = await getIndexHealth({ refreshVector });
+ setIndexHealth(result);
+ } catch (err) {
+ const message = err?.message || String(err);
+ setIndexHealthError(message);
+ } finally {
+ setIndexHealthLoading(false);
+ }
+ }, []);
+
+ const loadProcessStats = useCallback(async () => {
+ setProcessStatsLoading(true);
+ setProcessStatsError('');
+ try {
+ const stats = await getProcessStorageStats();
+ setProcessStats(Array.isArray(stats) ? stats : []);
+ } catch (e) {
+ setProcessStats([]);
+ setProcessStatsError(String(e));
+ } finally {
+ setProcessStatsLoading(false);
+ }
+ }, []);
+
+ const loadProcessMonthPage = useCallback(async (processName, page = 0) => {
+ if (!processName) return;
+ setProcessMonthLoading(true);
+ setProcessMonthError('');
+ try {
+ const data = await getProcessMonthlyThumbnails(processName, page, 60);
+ setProcessMonthData(data);
+ setProcessPage(page);
+ setSelectedScreenshotIds(new Set());
+ } catch (e) {
+ setProcessMonthData(null);
+ setProcessMonthError(String(e));
+ } finally {
+ setProcessMonthLoading(false);
+ }
+ }, []);
+
+ const openProcessDetail = useCallback((processName) => {
+ setSelectedProcess(processName);
+ setPanelView('process-detail');
+ setProcessThumbMap({});
+ setSelectedScreenshotIds(new Set());
+ loadProcessMonthPage(processName, 0);
+ }, [loadProcessMonthPage]);
+
+ const toggleScreenshotSelection = useCallback((screenshotId) => {
+ if (typeof screenshotId !== 'number' || screenshotId <= 0) return;
+ setSelectedScreenshotIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(screenshotId)) {
+ next.delete(screenshotId);
+ } else {
+ next.add(screenshotId);
+ }
+ return next;
+ });
+ }, []);
+
+ const requestSoftDelete = useCallback((processName, month = null, screenshotIds = []) => {
+ if (!processName) return;
+ const selectedIds = [...new Set((screenshotIds || []).filter((id) => typeof id === 'number' && id > 0))];
+ const hasSelectedIds = selectedIds.length > 0;
+
+ setPendingDeleteIntent({
+ processName,
+ month,
+ screenshotIds: selectedIds,
+ hasSelectedIds,
+ targetKey: `${processName}::${month || 'all'}`,
+ title: t('settings.storageManagement.deleteConfirm.title'),
+ message: hasSelectedIds
+ ? t('settings.storageManagement.deleteConfirm.messageSelected', { count: selectedIds.length })
+ : month
+ ? t('settings.storageManagement.deleteConfirm.messageMonth', { processName, month })
+ : t('settings.storageManagement.deleteConfirm.messageProcess', { processName }),
+ confirmLabel: hasSelectedIds
+ ? t('settings.storageManagement.processDetails.deleteSelected', { count: selectedIds.length })
+ : month
+ ? t('settings.storageManagement.processDetails.deleteMonth')
+ : t('settings.storageManagement.processDetails.deleteProcess'),
+ });
+ }, [t]);
+
+ const executeSoftDelete = useCallback(async (intent) => {
+ if (!intent?.processName) return;
+ const {
+ processName,
+ month,
+ screenshotIds,
+ hasSelectedIds,
+ targetKey,
+ } = intent;
+
+ setDeletingTarget(targetKey);
+ try {
+ if (hasSelectedIds) {
+ await softDeleteScreenshots(screenshotIds);
+ setSelectedScreenshotIds((prev) => {
+ const next = new Set(prev);
+ screenshotIds.forEach((id) => next.delete(id));
+ return next;
+ });
+ } else {
+ await softDeleteProcessMonth(processName, month);
+ }
+ await loadDeleteQueueStatus();
+ await loadProcessStats();
+ if (selectedProcess && selectedProcess === processName) {
+ await loadProcessMonthPage(processName, processPage);
+ }
+ onRefresh?.();
+ } catch (e) {
+ setProcessMonthError(String(e));
+ } finally {
+ setDeletingTarget('');
+ }
+ }, [loadDeleteQueueStatus, loadProcessMonthPage, loadProcessStats, onRefresh, processPage, selectedProcess]);
+
+ const handleConfirmSoftDelete = useCallback(async () => {
+ if (!pendingDeleteIntent) return;
+ await executeSoftDelete(pendingDeleteIntent);
+ setPendingDeleteIntent(null);
+ }, [executeSoftDelete, pendingDeleteIntent]);
+
+ const handleCancelSoftDelete = useCallback(() => {
+ if (deletingTarget) return;
+ setPendingDeleteIntent(null);
+ }, [deletingTarget]);
+
+ useEffect(() => {
+ loadDeleteQueueStatus();
+ const timer = setInterval(loadDeleteQueueStatus, 5000);
+ return () => clearInterval(timer);
+ }, [loadDeleteQueueStatus]);
+
+ useEffect(() => {
+ loadIndexHealth({ refreshVector: false });
+ }, [monitorStatus, loadIndexHealth]);
+
+ useEffect(() => {
+ if (panelView === 'overview') {
+ loadProcessStats();
+ }
+ }, [panelView, loadProcessStats]);
+
+ useEffect(() => {
+ const items = processMonthData?.items || [];
+ if (!items.length) {
+ setProcessThumbMap({});
+ return;
+ }
+
+ const ids = items.map((item) => item.screenshot_id).filter((id) => typeof id === 'number');
+ fetchThumbnailBatch(ids)
+ .then((batch) => setProcessThumbMap(batch || {}))
+ .catch(() => setProcessThumbMap({}));
+ }, [processMonthData]);
+
+ const groupedMonthItems = useMemo(() => {
+ const grouped = {};
+ for (const item of processMonthData?.items || []) {
+ const key = item.month || 'unknown';
+ if (!grouped[key]) grouped[key] = [];
+ grouped[key].push(item);
+ }
+ return Object.entries(grouped);
+ }, [processMonthData]);
+
+ const selectedCountByMonth = useMemo(() => {
+ const counts = {};
+ for (const item of processMonthData?.items || []) {
+ if (!selectedScreenshotIds.has(item.screenshot_id)) continue;
+ const month = item.month || 'unknown';
+ counts[month] = (counts[month] || 0) + 1;
+ }
+ return counts;
+ }, [processMonthData, selectedScreenshotIds]);
+
+ const handleRefresh = useCallback(() => {
+ onRefresh?.();
+ loadDeleteQueueStatus();
+ loadIndexHealth({ refreshVector: monitorStatus === 'running' });
+ if (panelView === 'overview') {
+ loadProcessStats();
+ }
+ if (panelView === 'process-detail' && selectedProcess) {
+ loadProcessMonthPage(selectedProcess, processPage);
+ }
+ }, [loadDeleteQueueStatus, loadIndexHealth, loadProcessMonthPage, loadProcessStats, monitorStatus, onRefresh, panelView, processPage, selectedProcess]);
+
+ const storageLimitOptions = [
+ { value: '10', label: t('settings.storageManagement.storageLimit.options.10') },
+ { value: '20', label: t('settings.storageManagement.storageLimit.options.20') },
+ { value: '50', label: t('settings.storageManagement.storageLimit.options.50') },
+ { value: '120', label: t('settings.storageManagement.storageLimit.options.120') },
+ { value: 'unlimited', label: t('settings.storageManagement.storageLimit.options.unlimited') },
+ ];
+
+ const retentionOptions = [
+ { value: '1month', label: t('settings.storageManagement.retention.options.1month') },
+ { value: '6months', label: t('settings.storageManagement.retention.options.6months') },
+ { value: '1year', label: t('settings.storageManagement.retention.options.1year') },
+ { value: '2years', label: t('settings.storageManagement.retention.options.2years') },
+ { value: 'permanent', label: t('settings.storageManagement.retention.options.permanent') },
+ ];
+
+ const diskInfo = useMemo(() => {
+ const rootPath = storage?.root_path || '';
+ const driveLetter = rootPath.charAt(0);
+
+ return {
+ driveLetter: driveLetter || 'C',
+ totalSize: 500 * 1024 * 1024 * 1024,
+ usedSize: 320 * 1024 * 1024 * 1024,
+ };
+ }, [storage]);
+
+ const currentStoragePath = storage?.root_path || 'LocalAppData/CarbonPaper';
+
+ const handleRetryVectorIndexing = useCallback(async () => {
+ setVectorRetrying(true);
+ setIndexHealthError(null);
+ try {
+ await retryVectorIndexing(32);
+ await loadIndexHealth({ refreshVector: true });
+ } catch (err) {
+ const message = err?.message || String(err);
+ setIndexHealthError(message);
+ } finally {
+ setVectorRetrying(false);
+ }
+ }, [loadIndexHealth]);
+
+ const formatIndexCount = useCallback((value, fallback = '—') => {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value.toLocaleString();
+ }
+ return fallback;
+ }, []);
+
+ const vectorRetryBacklog = indexHealth?.pending_retry_queue_count;
+ const indexHealthDeleteQueuePending = indexHealth
+ ? (indexHealth.delete_queue?.pending_screenshots ?? 0) + (indexHealth.delete_queue?.pending_ocr ?? 0)
+ : null;
+ const lastIndexingError = indexHealth?.last_indexing_error;
+ const lastIndexingErrorAt = indexHealth?.last_indexing_error_at
+ ? new Date(indexHealth.last_indexing_error_at * 1000).toLocaleString()
+ : null;
+ const storageIpc = indexHealth?.storage_ipc || indexHealth?.python?.storage_ipc || null;
+ const storageIpcState = storageIpc?.circuit_state;
+ const storageIpcLabel = storageIpcState === 'open'
+ ? t('settings.features.management.indexHealth.ipcOpen', '熔断')
+ : storageIpcState === 'half_open'
+ ? t('settings.features.management.indexHealth.ipcHalfOpen', '探测')
+ : storageIpcState === 'closed'
+ ? t('settings.features.management.indexHealth.ipcClosed', '正常')
+ : '—';
+ const storageIpcRetryAfter = typeof storageIpc?.retry_after_secs === 'number'
+ ? Math.ceil(storageIpc.retry_after_secs)
+ : null;
+
+ const executeStoragePathChange = async (targetPath, shouldMigrateData) => {
+ let unlistenProgress = null;
+ let unlistenError = null;
+ let shouldRestartMonitor = true;
+ const registerMigrationListener = async (eventName, handler) => {
+ const unlisten = await listen(eventName, (evt) => {
+ if (mountedRef.current) {
+ handler(evt);
+ }
+ });
+ if (!mountedRef.current) {
+ try { unlisten(); } catch { }
+ return null;
+ }
+ migrationUnlistenersRef.current.push(unlisten);
+ return unlisten;
+ };
+ const removeMigrationListener = async (unlisten) => {
+ if (!unlisten) return;
+ migrationUnlistenersRef.current = migrationUnlistenersRef.current.filter((fn) => fn !== unlisten);
+ try { await unlisten(); } catch { }
+ };
+
+ try {
+ if (!targetPath) return;
+
+ setMigrationError('');
+ setIsUpdatingStoragePath(true);
+ try {
+ const monitorStatusRaw = await invoke('get_monitor_status');
+ const monitorStatus = typeof monitorStatusRaw === 'string' ? JSON.parse(monitorStatusRaw) : monitorStatusRaw;
+ shouldRestartMonitor = !monitorStatus?.stopped;
+ } catch {
+ shouldRestartMonitor = true;
+ }
+
+ if (shouldRestartMonitor) {
+ await withAuth(() => invoke('stop_monitor'), { autoPrompt: true });
+ }
+
+ if (shouldMigrateData) {
+ setIsMigrating(true);
+ setMigrationProgress({ total_files: 0, copied_files: 0, current_file: '' });
+
+ unlistenProgress = await registerMigrationListener('storage-migration-progress', (evt) => {
+ setMigrationProgress(evt.payload);
+ });
+
+ unlistenError = await registerMigrationListener('storage-migration-error', (evt) => {
+ setMigrationError(evt.payload?.message || t('settings.storageManagement.migration.error_default'));
+ });
+ }
+
+ await withAuth(() => invoke('storage_migrate_data_dir', {
+ target: targetPath,
+ migrateDataFiles: shouldMigrateData,
+ }), { autoPrompt: true });
+
+ if (shouldMigrateData) {
+ if (mountedRef.current) {
+ setMigrationProgress((s) => ({ ...s, current_file: t('settings.storageManagement.migration.completed') }));
+ }
+ await new Promise((resolve) => setTimeout(resolve, 600));
+ }
+
+ onRefresh?.();
+ } catch (e) {
+ console.error('change storage path failed', e);
+ if (mountedRef.current) {
+ setMigrationError(String(e));
+ }
+ } finally {
+ await removeMigrationListener(unlistenProgress);
+ await removeMigrationListener(unlistenError);
+
+ if (mountedRef.current) {
+ setIsMigrating(false);
+ setIsUpdatingStoragePath(false);
+ }
+ if (shouldRestartMonitor) {
+ try { await withAuth(() => invoke('start_monitor'), { autoPrompt: true }); } catch { }
+ }
+ }
+ };
+
+ const handleChangeStoragePath = async () => {
+ try {
+ const selected = await open({ directory: true });
+ if (!selected) return;
+
+ const targetPath = Array.isArray(selected) ? selected[0] : selected;
+ if (!targetPath) return;
+
+ const normalizedCurrent = currentStoragePath.replace(/[\\/]+$/, '');
+ const normalizedTarget = targetPath.replace(/[\\/]+$/, '');
+ if (normalizedCurrent && normalizedCurrent === normalizedTarget) {
+ return;
+ }
+
+ setPendingTargetPath(targetPath);
+ setIsMigrationChoiceDialogOpen(true);
+ } catch (e) {
+ console.error('select storage path failed', e);
+ setMigrationError(String(e));
+ }
+ };
+
+ const handleCancelMigrationChoice = () => {
+ setIsMigrationChoiceDialogOpen(false);
+ setPendingTargetPath('');
+ };
+
+ const handleApplyStoragePath = async (shouldMigrateData) => {
+ const targetPath = pendingTargetPath;
+ setIsMigrationChoiceDialogOpen(false);
+ setPendingTargetPath('');
+ await executeStoragePathChange(targetPath, shouldMigrateData);
+ };
+
+ return {
+ storageLimit,
+ setStorageLimit,
+ retentionPeriod,
+ setRetentionPeriod,
+ isMigrating,
+ migrationProgress,
+ migrationError,
+ isUpdatingStoragePath,
+ isMigrationChoiceDialogOpen,
+ pendingTargetPath,
+ panelView,
+ setPanelView,
+ processStats,
+ processStatsLoading,
+ processStatsError,
+ selectedProcess,
+ processPage,
+ processMonthData,
+ processMonthLoading,
+ processMonthError,
+ processThumbMap,
+ selectedScreenshotIds,
+ deletingTarget,
+ pendingDeleteIntent,
+ isBackupDialogOpen,
+ setIsBackupDialogOpen,
+ backupMode,
+ setBackupMode,
+ deleteQueueStatus,
+ indexHealth,
+ indexHealthLoading,
+ indexHealthError,
+ vectorRetrying,
+ groupedMonthItems,
+ selectedCountByMonth,
+ storageLimitOptions,
+ retentionOptions,
+ diskInfo,
+ currentStoragePath,
+ vectorRetryBacklog,
+ indexHealthDeleteQueuePending,
+ lastIndexingError,
+ lastIndexingErrorAt,
+ storageIpcLabel,
+ storageIpcRetryAfter,
+ handleRefresh,
+ loadIndexHealth,
+ handleRetryVectorIndexing,
+ formatIndexCount,
+ openProcessDetail,
+ toggleScreenshotSelection,
+ requestSoftDelete,
+ handleConfirmSoftDelete,
+ handleCancelSoftDelete,
+ loadProcessMonthPage,
+ handleChangeStoragePath,
+ handleCancelMigrationChoice,
+ handleApplyStoragePath,
+ };
+}
diff --git a/src/components/settings/useStorageManagementController.test.js b/src/components/settings/useStorageManagementController.test.js
new file mode 100644
index 0000000..97c5e09
--- /dev/null
+++ b/src/components/settings/useStorageManagementController.test.js
@@ -0,0 +1,88 @@
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ fetchThumbnailBatch,
+ getIndexHealth,
+ getProcessStorageStats,
+ getSoftDeleteQueueStatus,
+} from '../../lib/monitor_api';
+import { useStorageManagementController } from './useStorageManagementController';
+
+vi.mock('../../lib/auth_api', () => ({
+ withAuth: vi.fn((fn) => fn()),
+}));
+
+vi.mock('@tauri-apps/api/event', () => ({
+ listen: vi.fn(async () => vi.fn()),
+}));
+
+vi.mock('@tauri-apps/plugin-dialog', () => ({
+ open: vi.fn(),
+}));
+
+vi.mock('../../lib/monitor_api', () => ({
+ fetchThumbnailBatch: vi.fn(async () => ({})),
+ getIndexHealth: vi.fn(async () => ({ status: 'success', monitor_available: false })),
+ getProcessMonthlyThumbnails: vi.fn(),
+ getProcessStorageStats: vi.fn(async () => []),
+ getSoftDeleteQueueStatus: vi.fn(async () => ({ pending_screenshots: 0, pending_ocr: 0, running: false })),
+ retryVectorIndexing: vi.fn(),
+ softDeleteProcessMonth: vi.fn(),
+ softDeleteScreenshots: vi.fn(),
+}));
+
+const t = (_key, fallback) => fallback || _key;
+
+describe('useStorageManagementController', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ fetchThumbnailBatch.mockClear();
+ getIndexHealth.mockClear();
+ getProcessStorageStats.mockClear();
+ getSoftDeleteQueueStatus.mockClear();
+ });
+
+ it('loads index health diagnostics even when the monitor is stopped', async () => {
+ getIndexHealth.mockResolvedValueOnce({
+ status: 'success',
+ monitor_available: false,
+ screenshots_count: 12,
+ ocr_rows_count: 34,
+ });
+
+ const { result } = renderHook(() => useStorageManagementController({
+ storage: { root_path: 'C:/CarbonPaper' },
+ onRefresh: vi.fn(),
+ t,
+ monitorStatus: 'stopped',
+ }));
+
+ await waitFor(() => expect(getIndexHealth).toHaveBeenCalledWith({ refreshVector: false }));
+ expect(result.current.indexHealth).toMatchObject({
+ monitor_available: false,
+ screenshots_count: 12,
+ ocr_rows_count: 34,
+ });
+ });
+
+ it('does not request vector refresh from the overview refresh when the monitor is stopped', async () => {
+ const onRefresh = vi.fn();
+ const { result } = renderHook(() => useStorageManagementController({
+ storage: { root_path: 'C:/CarbonPaper' },
+ onRefresh,
+ t,
+ monitorStatus: 'stopped',
+ }));
+
+ await waitFor(() => expect(getIndexHealth).toHaveBeenCalledWith({ refreshVector: false }));
+ getIndexHealth.mockClear();
+
+ act(() => {
+ result.current.handleRefresh();
+ });
+
+ await waitFor(() => expect(getIndexHealth).toHaveBeenCalledWith({ refreshVector: false }));
+ expect(onRefresh).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/hooks/useAdvancedSearchController.js b/src/hooks/useAdvancedSearchController.js
new file mode 100644
index 0000000..45d8b92
--- /dev/null
+++ b/src/hooks/useAdvancedSearchController.js
@@ -0,0 +1,344 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ batchGetCategories,
+ fetchThumbnailBatch,
+ getCategoriesFromDb,
+ listProcesses,
+ searchScreenshots,
+} from '../lib/monitor_api';
+
+const PAGE_SIZE = 40;
+const NL_PAGE_SIZE = 100;
+
+function useDebouncedValue(value, delay = 300) {
+ const [debounced, setDebounced] = useState(value);
+ useEffect(() => {
+ const handle = setTimeout(() => setDebounced(value), delay);
+ return () => clearTimeout(handle);
+ }, [value, delay]);
+ return debounced;
+}
+
+export function useAdvancedSearchController({
+ active,
+ searchParams,
+ searchMode,
+ onSearchModeChange,
+ backendOnline,
+ t,
+}) {
+ const [query, setQuery] = useState(searchParams?.query || '');
+ const [mode, setMode] = useState(searchMode ?? (searchParams?.mode || 'ocr'));
+ const [results, setResults] = useState([]);
+ const [thumbnailCache, setThumbnailCache] = useState({});
+ const [loading, setLoading] = useState(false);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [hasMore, setHasMore] = useState(false);
+ const [error, setError] = useState(null);
+ const [selectedProcesses, setSelectedProcesses] = useState([]);
+ const [processOptions, setProcessOptions] = useState([]);
+ const [selectedCategories, setSelectedCategories] = useState([]);
+ const [categoryOptions, setCategoryOptions] = useState([]);
+ const [startDate, setStartDate] = useState('');
+ const [endDate, setEndDate] = useState('');
+ const offsetRef = useRef(0);
+ const observerRef = useRef(null);
+ const sentinelRef = useRef(null);
+ const lastParamsRef = useRef({ query: '', mode: 'ocr' });
+ const searchIdRef = useRef(0);
+
+ const debouncedQuery = useDebouncedValue(query, 400);
+ const rotatingMessages = useMemo(() => t('advancedSearch.rotating', { returnObjects: true }) || [], [t]);
+ const [rotatingMessage, setRotatingMessage] = useState(() => {
+ return rotatingMessages.length > 0 ? rotatingMessages[Math.floor(Math.random() * rotatingMessages.length)] : '';
+ });
+
+ useEffect(() => {
+ const id = setInterval(() => {
+ setRotatingMessage((prev) => {
+ if (rotatingMessages.length <= 1) return prev;
+ let next = prev;
+ while (next === prev) {
+ next = rotatingMessages[Math.floor(Math.random() * rotatingMessages.length)];
+ }
+ return next;
+ });
+ }, 5000);
+ return () => clearInterval(id);
+ }, [rotatingMessages]);
+
+ useEffect(() => {
+ if (!active) return;
+ let mounted = true;
+ (async () => {
+ const [data, cats] = await Promise.all([listProcesses(), getCategoriesFromDb()]);
+ if (mounted) {
+ setProcessOptions(data);
+ setCategoryOptions(cats);
+ }
+ })();
+ return () => {
+ mounted = false;
+ };
+ }, [active]);
+
+ const handleModeChange = useCallback((nextMode) => {
+ if (nextMode === mode) return;
+ searchIdRef.current += 1;
+ setResults([]);
+ setThumbnailCache({});
+ setHasMore(false);
+ setLoading(false);
+ setLoadingMore(false);
+ setError(null);
+ offsetRef.current = 0;
+ if (searchMode === undefined) {
+ setMode(nextMode);
+ }
+ onSearchModeChange?.(nextMode);
+ }, [onSearchModeChange, mode, searchMode]);
+
+ useEffect(() => {
+ if (searchMode === undefined || searchMode === mode) return;
+ setMode(searchMode);
+ }, [searchMode, mode]);
+
+ useEffect(() => {
+ if (backendOnline === false && mode === 'nl') {
+ handleModeChange('ocr');
+ }
+ }, [backendOnline, mode, handleModeChange]);
+
+ useEffect(() => {
+ if (!searchParams) return;
+ const { query: nextQuery = '', mode: nextMode = 'ocr' } = searchParams;
+ const prev = lastParamsRef.current;
+ if (prev.query !== nextQuery) {
+ setQuery(nextQuery);
+ }
+ if (prev.mode !== nextMode) {
+ handleModeChange(nextMode);
+ }
+ lastParamsRef.current = { query: nextQuery, mode: nextMode };
+ }, [searchParams, handleModeChange]);
+
+ const queryTokens = useMemo(() => {
+ return debouncedQuery.trim()
+ .split(/\s+/)
+ .map((token) => token.trim())
+ .filter(Boolean);
+ }, [debouncedQuery]);
+
+ const computeTimestamp = useCallback((value) => {
+ if (!value) return null;
+ const parsed = Date.parse(value);
+ if (Number.isNaN(parsed)) return null;
+ return Math.floor(parsed / 1000);
+ }, []);
+
+ const enrichNlCategories = async (fetched, currentSearchId) => {
+ if (mode !== 'nl' || fetched.length === 0) return;
+ const hashes = fetched
+ .map((item) => (item.image_path || '').replace('memory://', ''))
+ .filter(Boolean);
+ if (hashes.length === 0) return;
+ const categoryMap = await batchGetCategories(hashes);
+ if (searchIdRef.current === currentSearchId) {
+ for (const item of fetched) {
+ const hash = (item.image_path || '').replace('memory://', '');
+ if (hash && categoryMap[hash] !== undefined) {
+ item.category = categoryMap[hash];
+ }
+ }
+ }
+ };
+
+ const resetAndFetch = useCallback(async () => {
+ if (!active) return;
+ const normalizedQuery = debouncedQuery.trim();
+ const hasFilters = selectedProcesses.length > 0 || selectedCategories.length > 0 || startDate || endDate;
+ if (!normalizedQuery && !hasFilters) {
+ searchIdRef.current += 1;
+ setResults([]);
+ setThumbnailCache({});
+ setHasMore(false);
+ setLoading(false);
+ setLoadingMore(false);
+ setError(null);
+ offsetRef.current = 0;
+ return;
+ }
+
+ const currentSearchId = ++searchIdRef.current;
+ setLoading(true);
+ setLoadingMore(false);
+ setError(null);
+ setThumbnailCache({});
+ offsetRef.current = 0;
+ const pageSize = mode === 'nl' ? NL_PAGE_SIZE : PAGE_SIZE;
+ try {
+ const fetched = await searchScreenshots(normalizedQuery, mode, {
+ limit: pageSize,
+ offset: 0,
+ processNames: selectedProcesses,
+ categories: mode === 'ocr' ? selectedCategories : [],
+ startTime: computeTimestamp(startDate),
+ endTime: computeTimestamp(endDate),
+ fuzzy: mode !== 'nl',
+ });
+ if (searchIdRef.current !== currentSearchId) return;
+
+ await enrichNlCategories(fetched, currentSearchId);
+
+ if (searchIdRef.current !== currentSearchId) return;
+ setResults(fetched);
+ setHasMore(fetched.length === pageSize);
+ offsetRef.current = fetched.length;
+ } catch (e) {
+ console.error('Advanced search resetAndFetch failed:', e);
+ if (searchIdRef.current === currentSearchId) {
+ setError(e.message || String(e));
+ setResults([]);
+ setHasMore(false);
+ }
+ } finally {
+ if (searchIdRef.current === currentSearchId) {
+ setLoading(false);
+ }
+ }
+ }, [active, debouncedQuery, mode, selectedProcesses, selectedCategories, startDate, endDate, computeTimestamp]);
+
+ useEffect(() => {
+ resetAndFetch();
+ }, [resetAndFetch]);
+
+ useEffect(() => {
+ if (!searchParams) return;
+ if (!active) return;
+ if (searchParams.refreshKey === undefined) return;
+ resetAndFetch();
+ }, [searchParams?.refreshKey, active, resetAndFetch]);
+
+ useEffect(() => {
+ if (results.length === 0) return;
+ let activeBatch = true;
+ (async () => {
+ const ids = results
+ .map((item) => item.screenshot_id ?? item.metadata?.screenshot_id)
+ .filter((id) => typeof id === 'number' && id > 0);
+ const uniqueIds = [...new Set(ids)].filter((id) => !thumbnailCache[id]);
+ if (uniqueIds.length === 0) return;
+ const batch = await fetchThumbnailBatch(uniqueIds);
+ if (activeBatch && batch) {
+ setThumbnailCache((prev) => ({ ...prev, ...batch }));
+ }
+ })();
+ return () => { activeBatch = false; };
+ }, [results]);
+
+ const loadMore = useCallback(async () => {
+ if (!hasMore || loadingMore || loading) return;
+ const currentSearchId = searchIdRef.current;
+ setLoadingMore(true);
+ setError(null);
+ const pageSize = mode === 'nl' ? NL_PAGE_SIZE : PAGE_SIZE;
+ try {
+ const fetched = await searchScreenshots(debouncedQuery.trim(), mode, {
+ limit: pageSize,
+ offset: offsetRef.current,
+ processNames: selectedProcesses,
+ categories: mode === 'ocr' ? selectedCategories : [],
+ startTime: computeTimestamp(startDate),
+ endTime: computeTimestamp(endDate),
+ fuzzy: mode !== 'nl',
+ });
+ if (searchIdRef.current !== currentSearchId) return;
+
+ await enrichNlCategories(fetched, currentSearchId);
+
+ if (searchIdRef.current !== currentSearchId) return;
+ setResults((prev) => [...prev, ...fetched]);
+ setHasMore(fetched.length === pageSize);
+ offsetRef.current += fetched.length;
+ } catch (e) {
+ console.error('Advanced search loadMore failed:', e);
+ if (searchIdRef.current === currentSearchId) {
+ setError(e.message || String(e));
+ setHasMore(false);
+ }
+ } finally {
+ if (searchIdRef.current === currentSearchId) {
+ setLoadingMore(false);
+ }
+ }
+ }, [debouncedQuery, mode, selectedProcesses, selectedCategories, startDate, endDate, computeTimestamp, hasMore, loadingMore, loading]);
+
+ useEffect(() => {
+ if (!active) return;
+ const node = sentinelRef.current;
+ if (!node) return;
+ if (observerRef.current) {
+ observerRef.current.disconnect();
+ }
+ observerRef.current = new IntersectionObserver((entries) => {
+ const [entry] = entries;
+ if (entry?.isIntersecting) {
+ loadMore();
+ }
+ }, { threshold: 0.6 });
+
+ observerRef.current.observe(node);
+ return () => {
+ if (observerRef.current) {
+ observerRef.current.disconnect();
+ }
+ };
+ }, [active, loadMore, results]);
+
+ const handleSubmit = (event) => {
+ event.preventDefault();
+ resetAndFetch();
+ };
+
+ const clearFilters = () => {
+ setSelectedProcesses([]);
+ setSelectedCategories([]);
+ setStartDate('');
+ setEndDate('');
+ };
+
+ const searchSourceDetail = useMemo(() => {
+ const modeLabel = mode === 'nl' ? t('advancedSearch.modes.nl') : t('advancedSearch.modes.ocr');
+ const trimmed = query.trim();
+ return trimmed ? `${modeLabel} · ${trimmed}` : modeLabel;
+ }, [mode, query, t]);
+
+ return {
+ query,
+ setQuery,
+ mode,
+ results,
+ thumbnailCache,
+ loading,
+ loadingMore,
+ hasMore,
+ error,
+ selectedProcesses,
+ setSelectedProcesses,
+ processOptions,
+ selectedCategories,
+ setSelectedCategories,
+ categoryOptions,
+ startDate,
+ setStartDate,
+ endDate,
+ setEndDate,
+ sentinelRef,
+ queryTokens,
+ rotatingMessage,
+ handleModeChange,
+ handleSubmit,
+ clearFilters,
+ searchSourceDetail,
+ };
+}
diff --git a/src/hooks/useAppNotifications.js b/src/hooks/useAppNotifications.js
new file mode 100644
index 0000000..1cc42c7
--- /dev/null
+++ b/src/hooks/useAppNotifications.js
@@ -0,0 +1,146 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useTauriEventListener } from './useTauriEventListener';
+
+export function useAppNotifications() {
+ const [showNotifications, setShowNotifications] = useState(false);
+ const [notifications, setNotifications] = useState([]);
+ const [hiddenToastIds, setHiddenToastIds] = useState(() => new Set());
+ const [securityAlert, setSecurityAlert] = useState(null);
+ const lastBackendErrorRef = useRef('');
+
+ const pushNotification = useCallback((notification) => {
+ if (notification?.id) {
+ setHiddenToastIds((prev) => {
+ if (!prev.has(notification.id)) return prev;
+ const next = new Set(prev);
+ next.delete(notification.id);
+ return next;
+ });
+ }
+ setNotifications((prev) => [notification, ...prev].slice(0, 200));
+ }, []);
+
+ useTauriEventListener('security-alert', (event) => {
+ const payload = event.payload || {};
+ setSecurityAlert({
+ code: payload.code,
+ message: payload.message,
+ detail: payload.detail,
+ });
+ });
+
+ useTauriEventListener('app-toast', (event) => {
+ const payload = event.payload || {};
+ pushNotification({
+ id: payload.id || `toast-${Date.now()}-${Math.random().toString(16).slice(2)}`,
+ type: payload.type || 'info',
+ title: payload.title || 'CarbonPaper',
+ message: payload.message || '',
+ details: payload.details || '',
+ timestamp: payload.timestamp || Date.now(),
+ });
+ }, [pushNotification]);
+
+ const dismissNotification = useCallback((id) => {
+ setNotifications((prev) => prev.filter((n) => n.id !== id));
+ setHiddenToastIds((prev) => {
+ if (!prev.has(id)) return prev;
+ const next = new Set(prev);
+ next.delete(id);
+ return next;
+ });
+ }, []);
+
+ const dismissToast = useCallback((id) => {
+ setHiddenToastIds((prev) => {
+ if (prev.has(id)) return prev;
+ const next = new Set(prev);
+ next.add(id);
+ return next;
+ });
+ }, []);
+
+ const handleToastClose = useCallback((id, reason = 'manual') => {
+ if (reason === 'timeout') {
+ dismissToast(id);
+ return;
+ }
+ dismissNotification(id);
+ }, [dismissNotification, dismissToast]);
+
+ const clearNotifications = useCallback(() => {
+ setNotifications([]);
+ setHiddenToastIds(new Set());
+ }, []);
+
+ const toastNotifications = useMemo(() => {
+ return notifications
+ .filter((notification) => notification.showToast !== false && !hiddenToastIds.has(notification.id))
+ .slice(0, 3);
+ }, [hiddenToastIds, notifications]);
+
+ useEffect(() => {
+ setHiddenToastIds((prev) => {
+ if (prev.size === 0) return prev;
+ const currentIds = new Set(notifications.map((notification) => notification.id));
+ let changed = false;
+ const next = new Set();
+ prev.forEach((id) => {
+ if (currentIds.has(id)) {
+ next.add(id);
+ } else {
+ changed = true;
+ }
+ });
+ return changed ? next : prev;
+ });
+ }, [notifications]);
+
+ const formatErrorDetails = useCallback((err) => {
+ if (!err) return '';
+ if (typeof err === 'string') return err;
+ if (err instanceof Error) {
+ return err.stack || err.message || String(err);
+ }
+ try {
+ return JSON.stringify(err, null, 2);
+ } catch {
+ return String(err);
+ }
+ }, []);
+
+ const reportBackendError = useCallback((title, message, details = '') => {
+ if (!message) return;
+ const dedupeKey = `${message}::${details}`;
+ if (lastBackendErrorRef.current === dedupeKey) return;
+ lastBackendErrorRef.current = dedupeKey;
+ pushNotification({
+ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
+ type: 'error',
+ title,
+ message,
+ details,
+ timestamp: Date.now(),
+ });
+ }, [pushNotification]);
+
+ const resetBackendErrorDedupe = useCallback(() => {
+ lastBackendErrorRef.current = '';
+ }, []);
+
+ return {
+ showNotifications,
+ setShowNotifications,
+ notifications,
+ toastNotifications,
+ pushNotification,
+ dismissNotification,
+ handleToastClose,
+ clearNotifications,
+ securityAlert,
+ setSecurityAlert,
+ formatErrorDetails,
+ reportBackendError,
+ resetBackendErrorDedupe,
+ };
+}
diff --git a/src/hooks/useAppTheme.js b/src/hooks/useAppTheme.js
new file mode 100644
index 0000000..a9d0714
--- /dev/null
+++ b/src/hooks/useAppTheme.js
@@ -0,0 +1,23 @@
+import { useEffect, useState } from 'react';
+
+export function useAppTheme() {
+ const [darkMode, setDarkMode] = useState(() => {
+ if (typeof window !== 'undefined') {
+ return localStorage.getItem('theme') === 'dark' ||
+ (!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
+ }
+ return true;
+ });
+
+ useEffect(() => {
+ if (darkMode) {
+ document.documentElement.classList.add('dark');
+ localStorage.setItem('theme', 'dark');
+ } else {
+ document.documentElement.classList.remove('dark');
+ localStorage.setItem('theme', 'light');
+ }
+ }, [darkMode]);
+
+ return { darkMode, setDarkMode };
+}
diff --git a/src/hooks/useAppWindowState.js b/src/hooks/useAppWindowState.js
new file mode 100644
index 0000000..e7dedd7
--- /dev/null
+++ b/src/hooks/useAppWindowState.js
@@ -0,0 +1,147 @@
+import { useEffect, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
+import { getCurrentWindow } from '@tauri-apps/api/window';
+import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
+import { useTauriEventListener } from './useTauriEventListener';
+
+export function usePowerSavingState() {
+ const [powerSavingMode, setPowerSavingMode] = useState(() => {
+ if (typeof window === 'undefined') return true;
+ const saved = localStorage.getItem('powerSavingMode');
+ return saved === null ? true : saved === 'true';
+ });
+ const [powerSavingSuppressed, setPowerSavingSuppressed] = useState(false);
+ const [windowFocused, setWindowFocused] = useState(true);
+
+ useEffect(() => {
+ localStorage.setItem('powerSavingMode', powerSavingMode ? 'true' : 'false');
+ }, [powerSavingMode]);
+
+ useTauriEventListener('power-saving-changed', (event) => {
+ const payload = event.payload || {};
+ setPowerSavingSuppressed(payload.active === true);
+ });
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const status = await invoke('get_power_saving_status');
+ if (!cancelled) {
+ setPowerSavingMode(status.enabled !== false);
+ setPowerSavingSuppressed(status.active === true);
+ }
+ } catch (err) {
+ if (!cancelled) {
+ console.warn('Failed to get initial power saving status:', err);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ const appWindow = getCurrentWindow();
+ let active = true;
+ let unlistenFocus = null;
+ let unlistenBlur = null;
+
+ appWindow.listen('tauri://focus', () => {
+ if (active) setWindowFocused(true);
+ }).then((fn) => {
+ if (active) {
+ unlistenFocus = fn;
+ } else {
+ fn();
+ }
+ });
+ appWindow.listen('tauri://blur', () => {
+ if (active) setWindowFocused(false);
+ }).then((fn) => {
+ if (active) {
+ unlistenBlur = fn;
+ } else {
+ fn();
+ }
+ });
+
+ return () => {
+ active = false;
+ if (unlistenFocus) unlistenFocus();
+ if (unlistenBlur) unlistenBlur();
+ };
+ }, []);
+
+ return {
+ powerSavingMode,
+ setPowerSavingMode,
+ powerSavingSuppressed,
+ windowFocused,
+ };
+}
+
+export function useWindowMaximizedState() {
+ const [isMaximized, setIsMaximized] = useState(false);
+
+ useEffect(() => {
+ const appWindow = getCurrentWindow();
+ let active = true;
+ let unlistenResize = null;
+ const updateState = async () => {
+ const maximized = await appWindow.isMaximized();
+ if (active) {
+ setIsMaximized(maximized);
+ }
+ };
+ updateState();
+
+ appWindow.listen('tauri://resize', updateState).then((fn) => {
+ if (active) {
+ unlistenResize = fn;
+ } else {
+ fn();
+ }
+ });
+
+ return () => {
+ active = false;
+ if (unlistenResize) unlistenResize();
+ };
+ }, []);
+
+ return isMaximized;
+}
+
+export function useAppWindowActions() {
+ const minimize = () => getCurrentWindow().minimize();
+ const toggleMaximize = () => getCurrentWindow().toggleMaximize();
+
+ const hideToTray = async () => {
+ await getCurrentWindow().hide();
+
+ let permissionGranted = await isPermissionGranted();
+ if (!permissionGranted) {
+ const permission = await requestPermission();
+ permissionGranted = permission === 'granted';
+ }
+ if (permissionGranted) {
+ sendNotification({
+ title: 'Carbonpaper',
+ body: '程序已最小化到系统托盘,点击托盘图标可恢复窗口',
+ });
+ }
+ };
+
+ const restartApp = () => invoke('restart_app').catch(() => {});
+ const exitApp = () => invoke('exit_app').catch(() => {});
+
+ return {
+ minimize,
+ toggleMaximize,
+ hideToTray,
+ restartApp,
+ exitApp,
+ };
+}
diff --git a/src/hooks/useAuthSession.js b/src/hooks/useAuthSession.js
new file mode 100644
index 0000000..1fe24b0
--- /dev/null
+++ b/src/hooks/useAuthSession.js
@@ -0,0 +1,85 @@
+import { useCallback, useEffect, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
+import { withAuth } from '../lib/auth_api';
+
+export function useAuthSession() {
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+ const [authError, setAuthError] = useState(null);
+ const [sessionTimeout, setSessionTimeout] = useState(() => {
+ const saved = localStorage.getItem('sessionTimeout');
+ return saved ? parseInt(saved, 10) : 900;
+ });
+
+ const checkAuthStatus = useCallback(async () => {
+ try {
+ const isValid = await invoke('credential_check_session');
+ setIsAuthenticated(isValid);
+ } catch (err) {
+ console.warn('Failed to check auth status:', err);
+ setIsAuthenticated(false);
+ }
+ }, []);
+
+ const handleAuthSuccess = useCallback(() => {
+ setIsAuthenticated(true);
+ setAuthError(null);
+ }, []);
+
+ const handleLockSession = useCallback(() => {
+ setIsAuthenticated(false);
+ }, []);
+
+ useEffect(() => {
+ checkAuthStatus();
+ const interval = setInterval(checkAuthStatus, 10000);
+ return () => clearInterval(interval);
+ }, [checkAuthStatus]);
+
+ useEffect(() => {
+ let mounted = true;
+ const syncSessionTimeout = async () => {
+ try {
+ const res = await invoke('credential_get_session_timeout');
+ const backendTimeout = Number(res);
+ if (!Number.isNaN(backendTimeout) && mounted) {
+ setSessionTimeout(backendTimeout);
+ try {
+ localStorage.setItem('sessionTimeout', String(backendTimeout));
+ } catch { }
+ }
+ } catch {
+ const saved = localStorage.getItem('sessionTimeout');
+ if (saved) {
+ const v = parseInt(saved, 10);
+ if (!Number.isNaN(v)) {
+ try {
+ await withAuth(() => invoke('credential_set_session_timeout', { timeout: v }));
+ } catch (e) {
+ console.warn('Failed to migrate session timeout to backend', e);
+ }
+ }
+ }
+ }
+ };
+ syncSessionTimeout();
+ return () => { mounted = false; };
+ }, []);
+
+ useEffect(() => {
+ const handleAuthRequired = () => {
+ setIsAuthenticated(false);
+ };
+ window.addEventListener('cp-auth-required', handleAuthRequired);
+ return () => window.removeEventListener('cp-auth-required', handleAuthRequired);
+ }, []);
+
+ return {
+ isAuthenticated,
+ authError,
+ setAuthError,
+ sessionTimeout,
+ setSessionTimeout,
+ handleAuthSuccess,
+ handleLockSession,
+ };
+}
diff --git a/src/hooks/useCriticalErrors.js b/src/hooks/useCriticalErrors.js
new file mode 100644
index 0000000..446cd49
--- /dev/null
+++ b/src/hooks/useCriticalErrors.js
@@ -0,0 +1,16 @@
+import { useEffect, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
+import { useTauriEventListener } from './useTauriEventListener';
+
+export function useCriticalErrors() {
+ const [criticalErrors, setCriticalErrors] = useState([]);
+ const [criticalErrorLogPath, setCriticalErrorLogPath] = useState('');
+
+ useTauriEventListener('critical-error', (event) => {
+ const msg = event.payload?.message || event.payload || 'Unknown error';
+ setCriticalErrors((prev) => [...prev, msg]);
+ invoke('get_log_dir').then(setCriticalErrorLogPath).catch(() => { });
+ });
+
+ return { criticalErrors, criticalErrorLogPath };
+}
diff --git a/src/hooks/useDepsSyncOverlay.js b/src/hooks/useDepsSyncOverlay.js
new file mode 100644
index 0000000..3c77261
--- /dev/null
+++ b/src/hooks/useDepsSyncOverlay.js
@@ -0,0 +1,71 @@
+import { useEffect, useRef, useState } from 'react';
+import { useTauriEventListener } from './useTauriEventListener';
+
+export function useDepsSyncOverlay({
+ depsNeedUpdate,
+ pythonVersion,
+ renderVenvInstallStep,
+ depsSyncing,
+ onDepsSync,
+}) {
+ const [depsSyncLog, setDepsSyncLog] = useState([]);
+ const [depsSyncError, setDepsSyncError] = useState(null);
+ const [depsSyncStarted, setDepsSyncStarted] = useState(false);
+ const [retryNonce, setRetryNonce] = useState(0);
+ const depsSyncLogRef = useRef(null);
+ const prevDepsNeedUpdate = useRef(false);
+
+ useEffect(() => {
+ if (depsSyncLogRef?.current) {
+ depsSyncLogRef.current.scrollTop = depsSyncLogRef.current.scrollHeight;
+ }
+ }, [depsSyncLog]);
+
+ useEffect(() => {
+ if (!depsNeedUpdate || !pythonVersion || renderVenvInstallStep != null) return;
+ if (depsSyncStarted || depsSyncing) return;
+ if (depsSyncError) return;
+
+ setDepsSyncStarted(true);
+ setDepsSyncLog([]);
+ setDepsSyncError(null);
+
+ (async () => {
+ try {
+ await onDepsSync();
+ setDepsSyncStarted(false);
+ } catch (err) {
+ setDepsSyncError(err?.message || String(err));
+ setDepsSyncStarted(false);
+ }
+ })();
+ }, [depsNeedUpdate, pythonVersion, renderVenvInstallStep, depsSyncStarted, depsSyncing, depsSyncError, retryNonce, onDepsSync]);
+
+ useEffect(() => {
+ if (depsNeedUpdate && !prevDepsNeedUpdate.current) {
+ setDepsSyncLog([]);
+ setDepsSyncError(null);
+ }
+ prevDepsNeedUpdate.current = depsNeedUpdate;
+ }, [depsNeedUpdate]);
+
+ useTauriEventListener('install-log', (event) => {
+ const payload = event?.payload || {};
+ const line = payload.line || JSON.stringify(payload);
+ const ts = new Date().toLocaleTimeString();
+ setDepsSyncLog((prev) => [...prev, `[${ts}] ${line}`]);
+ }, [depsNeedUpdate, depsSyncing], depsNeedUpdate || depsSyncing);
+
+ const retryDepsSync = () => {
+ setDepsSyncError(null);
+ setDepsSyncStarted(false);
+ setRetryNonce((value) => value + 1);
+ };
+
+ return {
+ depsSyncLog,
+ depsSyncError,
+ depsSyncLogRef,
+ retryDepsSync,
+ };
+}
diff --git a/src/hooks/useHmacMigrationStatus.js b/src/hooks/useHmacMigrationStatus.js
new file mode 100644
index 0000000..6f30f61
--- /dev/null
+++ b/src/hooks/useHmacMigrationStatus.js
@@ -0,0 +1,36 @@
+import { useEffect, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
+import { useTauriEventListener } from './useTauriEventListener';
+
+export function useHmacMigrationStatus() {
+ const [isMigrating, setIsMigrating] = useState(false);
+
+ useEffect(() => {
+ let mounted = true;
+ const check = async () => {
+ try {
+ const status = await invoke('storage_check_hmac_migration_status');
+ if (mounted && (status.needs_migration || status.is_running)) {
+ setIsMigrating(true);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ };
+ check();
+
+ return () => {
+ mounted = false;
+ };
+ }, []);
+
+ useTauriEventListener('hmac-migration-progress', () => {
+ setIsMigrating(true);
+ });
+
+ useTauriEventListener('hmac-migration-complete', () => {
+ setIsMigrating(false);
+ });
+
+ return isMigrating;
+}
diff --git a/src/hooks/useMonitorLifecycle.js b/src/hooks/useMonitorLifecycle.js
new file mode 100644
index 0000000..1f76a7d
--- /dev/null
+++ b/src/hooks/useMonitorLifecycle.js
@@ -0,0 +1,207 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
+import { withAuth } from '../lib/auth_api';
+import { useTauriEventListener } from './useTauriEventListener';
+
+export function useMonitorLifecycle({
+ pythonVersion,
+ depsNeedUpdate,
+ depsSyncing,
+ depsCheckDone,
+ modelsNeedDownload,
+ powerSavingSuppressed,
+ formatErrorDetails,
+ reportBackendError,
+ resetBackendErrorDedupe,
+}) {
+ const [autoStartMonitor, setAutoStartMonitorState] = useState(() => {
+ if (typeof window === 'undefined') return true;
+ const saved = localStorage.getItem('autoStartMonitor');
+ return saved === null ? true : saved === 'true';
+ });
+ const [autoStartSuppressed, setAutoStartSuppressed] = useState(false);
+ const [backendStatus, setBackendStatus] = useState('unknown');
+ const [monitorPaused, setMonitorPaused] = useState(false);
+ const [backendError, setBackendError] = useState('');
+ const backendStatusRef = useRef('unknown');
+ const backendStartAtRef = useRef(null);
+
+ useEffect(() => {
+ backendStatusRef.current = backendStatus;
+ }, [backendStatus]);
+
+ useEffect(() => {
+ localStorage.setItem('autoStartMonitor', autoStartMonitor ? 'true' : 'false');
+ }, [autoStartMonitor]);
+
+ const setAutoStartMonitor = useCallback((next) => {
+ setAutoStartMonitorState(next);
+ if (next) {
+ setAutoStartSuppressed(false);
+ }
+ }, []);
+
+ const handleManualStartMonitor = useCallback(() => {
+ setAutoStartSuppressed(false);
+ }, []);
+
+ const handleManualStopMonitor = useCallback(() => {
+ setAutoStartSuppressed(true);
+ }, []);
+
+ const handleStartBackend = useCallback(async () => {
+ setAutoStartSuppressed(false);
+ setBackendError('');
+ setBackendStatus('waiting');
+ backendStatusRef.current = 'waiting';
+ backendStartAtRef.current = Date.now();
+ try {
+ await invoke('start_monitor');
+ } catch (err) {
+ setBackendStatus('offline');
+ backendStatusRef.current = 'offline';
+ const message = err?.message || 'Failed to start backend';
+ const details = formatErrorDetails(err);
+ setBackendError(message);
+ setAutoStartSuppressed(true);
+ reportBackendError('Python 子服务启动失败', message, details);
+ }
+ }, [formatErrorDetails, reportBackendError]);
+
+ const handlePauseMonitor = useCallback(async () => {
+ try {
+ await withAuth(() => invoke('pause_monitor'), { autoPrompt: true });
+ setMonitorPaused(true);
+ } catch (err) {
+ console.warn('Failed to pause monitor:', err);
+ }
+ }, []);
+
+ const handleResumeMonitor = useCallback(async () => {
+ try {
+ await withAuth(() => invoke('resume_monitor'), { autoPrompt: true });
+ setMonitorPaused(false);
+ } catch (err) {
+ console.warn('Failed to resume monitor:', err);
+ }
+ }, []);
+
+ const checkBackendStatus = useCallback(async () => {
+ const t0 = performance.now();
+ try {
+ const resString = await invoke('get_monitor_status');
+ const elapsed = performance.now() - t0;
+ if (elapsed > 5000) {
+ console.warn(`[DIAG:STATUS] get_monitor_status took ${elapsed.toFixed(0)}ms`);
+ }
+ let res = null;
+ try {
+ res = JSON.parse(resString);
+ } catch {
+ res = null;
+ }
+
+ if (res?.stopped) {
+ setBackendStatus('offline');
+ backendStatusRef.current = 'offline';
+ setMonitorPaused(false);
+ setBackendError('');
+ resetBackendErrorDedupe();
+ backendStartAtRef.current = null;
+ return;
+ }
+
+ setBackendStatus('online');
+ backendStatusRef.current = 'online';
+ setMonitorPaused(!!res?.paused);
+ setBackendError('');
+ resetBackendErrorDedupe();
+ backendStartAtRef.current = null;
+ } catch (err) {
+ const elapsed = performance.now() - t0;
+ if (elapsed > 5000) {
+ console.warn(`[DIAG:STATUS] get_monitor_status FAILED after ${elapsed.toFixed(0)}ms:`, err);
+ }
+ if (backendStatusRef.current === 'waiting') {
+ const startAt = backendStartAtRef.current;
+ if (startAt && Date.now() - startAt < 15000) {
+ return;
+ }
+ }
+ setBackendStatus('offline');
+ backendStatusRef.current = 'offline';
+ const message = err?.message || 'Backend offline';
+ const details = formatErrorDetails(err);
+ setBackendError(message);
+ reportBackendError('Python 子服务不可用', message, details);
+ }
+ }, [formatErrorDetails, reportBackendError, resetBackendErrorDedupe]);
+
+ useEffect(() => {
+ checkBackendStatus();
+ const interval = setInterval(checkBackendStatus, 3000);
+ return () => clearInterval(interval);
+ }, [checkBackendStatus]);
+
+ useTauriEventListener('monitor-exited', (event) => {
+ const payload = event?.payload || {};
+ const code = payload.code || 'unknown';
+ const errMsg = payload.error ? `; ${payload.error}` : '';
+ const recovery = payload.recovery || {};
+ const recoveryMsg = recovery.policy === 'manual_restart'
+ ? ';恢复策略:手动重启,旧 IPC 状态已清理'
+ : '';
+ const message = `子服务已退出(code: ${code}${errMsg})${recoveryMsg}`;
+ const details = formatErrorDetails(payload);
+ setBackendStatus('offline');
+ backendStatusRef.current = 'offline';
+ setBackendError(message);
+ reportBackendError('Python 子服务异常退出', message, details);
+ }, [formatErrorDetails, reportBackendError]);
+
+ useTauriEventListener('monitor-stopped', () => {
+ setBackendStatus('offline');
+ backendStatusRef.current = 'offline';
+ setMonitorPaused(false);
+ setBackendError('');
+ resetBackendErrorDedupe();
+ backendStartAtRef.current = null;
+ }, [resetBackendErrorDedupe]);
+
+ useEffect(() => {
+ if (!autoStartMonitor) return;
+ if (autoStartSuppressed) return;
+ if (powerSavingSuppressed) return;
+ if (!pythonVersion) return;
+ if (!depsCheckDone) return;
+ if (depsNeedUpdate || depsSyncing) return;
+ if (modelsNeedDownload) return;
+ if (backendStatus === 'offline' && backendStatusRef.current !== 'waiting') {
+ handleStartBackend();
+ }
+ }, [
+ autoStartMonitor,
+ autoStartSuppressed,
+ backendStatus,
+ depsCheckDone,
+ depsNeedUpdate,
+ depsSyncing,
+ handleStartBackend,
+ modelsNeedDownload,
+ powerSavingSuppressed,
+ pythonVersion,
+ ]);
+
+ return {
+ autoStartMonitor,
+ setAutoStartMonitor,
+ handleManualStartMonitor,
+ handleManualStopMonitor,
+ backendStatus,
+ monitorPaused,
+ backendError,
+ handleStartBackend,
+ handlePauseMonitor,
+ handleResumeMonitor,
+ };
+}
diff --git a/src/hooks/usePythonEnvironment.js b/src/hooks/usePythonEnvironment.js
new file mode 100644
index 0000000..0a04b14
--- /dev/null
+++ b/src/hooks/usePythonEnvironment.js
@@ -0,0 +1,84 @@
+import { useCallback, useEffect, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
+
+export function usePythonEnvironment() {
+ const [pythonVersion, setPythonVersion] = useState(null);
+ const [depsNeedUpdate, setDepsNeedUpdate] = useState(false);
+ const [depsSyncing, setDepsSyncing] = useState(false);
+ const [depsCheckDone, setDepsCheckDone] = useState(false);
+ const [modelsNeedDownload, setModelsNeedDownload] = useState(false);
+ const [missingModels, setMissingModels] = useState(null);
+
+ const refreshPythonVersion = useCallback(async () => {
+ try {
+ const version = await invoke('check_python_venv');
+ setPythonVersion(version);
+
+ if (version) {
+ try {
+ const result = await invoke('check_deps_freshness');
+ if (result?.needs_update) {
+ setDepsNeedUpdate(true);
+ } else {
+ setDepsNeedUpdate(false);
+ }
+ } catch (err) {
+ console.warn('Failed to check deps freshness:', err);
+ setDepsNeedUpdate(false);
+ }
+
+ try {
+ const modelStatus = await invoke('check_model_files');
+ const hasIncomplete = Object.values(modelStatus).some((m) => !m.complete && m.required !== false);
+ if (hasIncomplete) {
+ setModelsNeedDownload(true);
+ setMissingModels(modelStatus);
+ } else {
+ setModelsNeedDownload(false);
+ setMissingModels(null);
+ }
+ } catch (err) {
+ console.warn('Failed to check model files:', err);
+ setModelsNeedDownload(false);
+ }
+ }
+ } catch (error) {
+ console.error('Error fetching Python version:', error);
+ } finally {
+ setDepsCheckDone(true);
+ }
+ }, []);
+
+ useEffect(() => {
+ refreshPythonVersion();
+ }, [refreshPythonVersion]);
+
+ const handleDepsSync = useCallback(async () => {
+ setDepsSyncing(true);
+ try {
+ await invoke('sync_python_deps');
+ setDepsNeedUpdate(false);
+ } catch (err) {
+ throw err;
+ } finally {
+ setDepsSyncing(false);
+ }
+ }, []);
+
+ const handleModelsDownloadComplete = useCallback(() => {
+ setModelsNeedDownload(false);
+ setMissingModels(null);
+ }, []);
+
+ return {
+ pythonVersion,
+ depsNeedUpdate,
+ depsSyncing,
+ depsCheckDone,
+ modelsNeedDownload,
+ missingModels,
+ refreshPythonVersion,
+ handleDepsSync,
+ handleModelsDownloadComplete,
+ };
+}
diff --git a/src/hooks/useRequiredModelDownload.js b/src/hooks/useRequiredModelDownload.js
new file mode 100644
index 0000000..5429d19
--- /dev/null
+++ b/src/hooks/useRequiredModelDownload.js
@@ -0,0 +1,186 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
+import { useTauriEventListener } from './useTauriEventListener';
+
+export function useRequiredModelDownload({
+ modelsNeedDownload,
+ missingModels,
+ renderVenvInstallStep,
+ depsNeedUpdate,
+ onModelsDownloadComplete,
+ t,
+}) {
+ const [modelDownloadLog, setModelDownloadLog] = useState([]);
+ const [modelDownloadError, setModelDownloadError] = useState(null);
+ const [modelDownloading, setModelDownloading] = useState(false);
+ const modelDownloadLogRef = useRef(null);
+ const modelDownloadStartedRef = useRef(false);
+ const [retryNonce, setRetryNonce] = useState(0);
+ const [isClosedByUser, setIsClosedByUser] = useState(false);
+ const [downloadProgressState, setDownloadProgressState] = useState({
+ keys: [],
+ currentIdx: 0,
+ currentFileProgress: {},
+ });
+
+ useEffect(() => {
+ if (modelsNeedDownload) {
+ setIsClosedByUser(false);
+ }
+ }, [modelsNeedDownload]);
+
+ const overallProgress = useMemo(() => {
+ const { keys, currentIdx, currentFileProgress } = downloadProgressState;
+ if (!keys || keys.length === 0) return 0;
+ const progressValues = Object.values(currentFileProgress);
+ const currentModelProgress = progressValues.length > 0
+ ? progressValues.reduce((sum, val) => sum + val, 0) / Math.max(progressValues.length, 5)
+ : 0;
+ const progress = (currentIdx * 100 + currentModelProgress) / keys.length;
+ return Math.max(0, Math.min(100, progress));
+ }, [downloadProgressState]);
+
+ useEffect(() => {
+ window.dispatchEvent(
+ new CustomEvent('model-download-progress', {
+ detail: {
+ active: modelDownloading && isClosedByUser,
+ progress: Math.round(overallProgress),
+ },
+ }),
+ );
+ }, [modelDownloading, isClosedByUser, overallProgress]);
+
+ useEffect(() => {
+ if (modelDownloadLogRef?.current) {
+ modelDownloadLogRef.current.scrollTop = modelDownloadLogRef.current.scrollHeight;
+ }
+ }, [modelDownloadLog]);
+
+ useTauriEventListener('install-log', (event) => {
+ const payload = event?.payload || {};
+ const line = payload.line || JSON.stringify(payload);
+ const ts = new Date().toLocaleTimeString();
+ setModelDownloadLog((prev) => [...prev, `[${ts}] ${line}`]);
+
+ if (payload.source === 'aria2' && payload.file) {
+ const match = line.match(/\((\d+)%\)/);
+ if (match) {
+ const percent = parseInt(match[1], 10);
+ setDownloadProgressState((prev) => {
+ const newProgress = { ...prev.currentFileProgress, [payload.file]: percent };
+ return {
+ ...prev,
+ currentFileProgress: newProgress,
+ };
+ });
+ }
+ }
+ }, [modelDownloading], modelDownloading);
+
+ useEffect(() => {
+ if (!modelsNeedDownload || !missingModels) return;
+ if (modelDownloadStartedRef.current || modelDownloading) return;
+ if (modelDownloadError) return;
+ if (renderVenvInstallStep != null || depsNeedUpdate) return;
+
+ modelDownloadStartedRef.current = true;
+ setModelDownloading(true);
+ setModelDownloadLog([]);
+ setModelDownloadError(null);
+
+ (async () => {
+ try {
+ const config = await invoke('get_advanced_config');
+ const useOnnx = config?.use_onnx || false;
+
+ const keysToDownload = [];
+ if (missingModels['chinese-clip'] && !missingModels['chinese-clip'].complete) {
+ keysToDownload.push('chinese-clip');
+ }
+ if (missingModels['bge-small-zh'] && !missingModels['bge-small-zh'].complete) {
+ keysToDownload.push('bge-small-zh');
+ }
+ if (missingModels['minilm-l12'] && !missingModels['minilm-l12'].complete) {
+ keysToDownload.push('minilm-l12');
+ }
+
+ setDownloadProgressState({
+ keys: keysToDownload,
+ currentIdx: 0,
+ currentFileProgress: {},
+ });
+
+ for (let i = 0; i < keysToDownload.length; i++) {
+ const key = keysToDownload[i];
+ setDownloadProgressState((prev) => ({
+ ...prev,
+ currentIdx: i,
+ currentFileProgress: {},
+ }));
+
+ if (key === 'chinese-clip') {
+ setModelDownloadLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${t('mask.model_download.downloading_clip')}`]);
+ await invoke('download_model');
+ } else if (key === 'bge-small-zh') {
+ setModelDownloadLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${t('mask.model_download.downloading_bge')}`]);
+ if (useOnnx) {
+ await invoke('download_model', {
+ repo: 'Xenova/bge-small-zh-v1.5',
+ subdir: 'bge-small-zh-v1.5',
+ files: ['config.json', 'tokenizer.json', 'tokenizer_config.json', 'special_tokens_map.json', 'onnx/model_quantized.onnx'],
+ modelRuntime: 'onnx',
+ });
+ } else {
+ await invoke('download_model', {
+ repo: 'BAAI/bge-small-zh-v1.5',
+ subdir: 'bge-small-zh-v1.5',
+ files: ['config.json', 'pytorch_model.bin', 'tokenizer.json', 'tokenizer_config.json', 'vocab.txt', 'special_tokens_map.json'],
+ });
+ }
+ } else if (key === 'minilm-l12') {
+ setModelDownloadLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${t('mask.model_download.downloading_minilm')}`]);
+ if (useOnnx) {
+ await invoke('download_model', {
+ repo: 'Xenova/paraphrase-multilingual-MiniLM-L12-v2',
+ subdir: 'paraphrase-multilingual-MiniLM-L12-v2',
+ files: ['config.json', 'tokenizer.json', 'tokenizer_config.json', 'special_tokens_map.json', 'onnx/model_quantized.onnx'],
+ modelRuntime: 'onnx',
+ });
+ } else {
+ await invoke('download_model', {
+ repo: 'sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2',
+ subdir: 'paraphrase-multilingual-MiniLM-L12-v2',
+ files: ['config.json', 'pytorch_model.bin', 'tokenizer.json', 'tokenizer_config.json', 'special_tokens_map.json', 'sentencepiece.bpe.model'],
+ });
+ }
+ }
+ }
+
+ setModelDownloadLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${t('mask.model_download.complete')}`]);
+ onModelsDownloadComplete?.();
+ } catch (err) {
+ setModelDownloadError(err?.message || String(err));
+ } finally {
+ setModelDownloading(false);
+ modelDownloadStartedRef.current = false;
+ }
+ })();
+ }, [modelsNeedDownload, missingModels, modelDownloading, modelDownloadError, renderVenvInstallStep, depsNeedUpdate, onModelsDownloadComplete, retryNonce, t]);
+
+ const retryModelDownload = () => {
+ setModelDownloadError(null);
+ modelDownloadStartedRef.current = false;
+ setRetryNonce((value) => value + 1);
+ };
+
+ return {
+ modelDownloadLog,
+ modelDownloadError,
+ modelDownloading,
+ modelDownloadLogRef,
+ isClosedByUser,
+ setIsClosedByUser,
+ retryModelDownload,
+ };
+}
diff --git a/src/hooks/useSearchBoxController.js b/src/hooks/useSearchBoxController.js
new file mode 100644
index 0000000..4f1463d
--- /dev/null
+++ b/src/hooks/useSearchBoxController.js
@@ -0,0 +1,400 @@
+import { useEffect, useRef, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
+import {
+ searchScreenshots,
+ fetchThumbnailBatch,
+ getSoftDeleteQueueStatus,
+ getSmartClusterWorkerStatus,
+} from '../lib/monitor_api';
+import { smartClusterStopDrain } from '../lib/task_api';
+import { useHmacMigrationStatus } from './useHmacMigrationStatus';
+
+function useDebounce(value, delay) {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
+
+const EMPTY_DELETE_QUEUE_STATUS = {
+ pending_screenshots: 0,
+ pending_ocr: 0,
+ running: false,
+};
+
+const EMPTY_CLUSTER_QUEUE_STATUS = {
+ pending_count: 0,
+ running: false,
+};
+
+export function useSearchBoxController({
+ onSelectResult,
+ onSubmit,
+ controlledMode,
+ onModeChange,
+ backendOnline,
+ monitorPaused,
+ handlePauseMonitor,
+ handleResumeMonitor,
+ t,
+}) {
+ const [query, setQuery] = useState('');
+ const [localMode, setLocalMode] = useState('ocr');
+ const [showModeMenu, setShowModeMenu] = useState(false);
+ const [results, setResults] = useState([]);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [showResults, setShowResults] = useState(false);
+ const [thumbCache, setThumbCache] = useState({});
+ const [deleteQueueStatus, setDeleteQueueStatus] = useState(EMPTY_DELETE_QUEUE_STATUS);
+ const [deleteQueuePeak, setDeleteQueuePeak] = useState(0);
+ const [smartClusterQueueStatus, setSmartClusterQueueStatus] = useState(EMPTY_CLUSTER_QUEUE_STATUS);
+ const [smartClusterQueuePeak, setSmartClusterQueuePeak] = useState(0);
+ const [downloadProgress, setDownloadProgress] = useState(0);
+ const [isDownloadingModels, setIsDownloadingModels] = useState(false);
+
+ const debouncedQuery = useDebounce(query, 500);
+ const wrapperRef = useRef(null);
+ const inputRef = useRef(null);
+ const userInteractionRef = useRef(false);
+ const wasPausedRef = useRef(null);
+ const handleResumeMonitorRef = useRef(handleResumeMonitor);
+ const mode = controlledMode ?? localMode;
+ const setMode = onModeChange ?? setLocalMode;
+ const isMigrating = useHmacMigrationStatus();
+
+ useEffect(() => {
+ if (backendOnline === false && mode === 'nl') {
+ setMode('ocr');
+ }
+ }, [backendOnline, mode, setMode]);
+
+ useEffect(() => {
+ function handleClickOutside(event) {
+ if (wrapperRef.current && !wrapperRef.current.contains(event.target)) {
+ setShowModeMenu(false);
+ setShowResults(false);
+ }
+ }
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (debouncedQuery.trim().length === 0) {
+ setResults([]);
+ setError(null);
+ setLoading(false);
+ setShowResults(false);
+ return;
+ }
+
+ let active = true;
+ const doSearch = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await searchScreenshots(debouncedQuery, mode);
+ if (!active) return;
+ setResults(res);
+ const isFocused = document.activeElement === inputRef.current;
+ if (isFocused || userInteractionRef.current) {
+ setShowResults(true);
+ }
+ userInteractionRef.current = false;
+ } catch (e) {
+ if (!active) return;
+ console.error('Search failed:', e);
+ setError(e.message || String(e));
+ setResults([]);
+ setShowResults(true);
+ } finally {
+ if (active) setLoading(false);
+ }
+ };
+
+ doSearch();
+ return () => {
+ active = false;
+ };
+ }, [debouncedQuery, mode]);
+
+ useEffect(() => {
+ if (!results.length) {
+ setThumbCache({});
+ return undefined;
+ }
+ let active = true;
+ const ids = results
+ .map((item) => {
+ const sid = mode === 'nl' ? item.metadata?.screenshot_id : item.screenshot_id;
+ return typeof sid === 'number' && sid > 0 ? sid : null;
+ })
+ .filter(Boolean);
+
+ if (ids.length === 0) return undefined;
+ fetchThumbnailBatch([...new Set(ids)])
+ .then((batch) => {
+ if (active && batch) setThumbCache(batch);
+ })
+ .catch(() => {});
+
+ return () => {
+ active = false;
+ };
+ }, [results, mode]);
+
+ useEffect(() => {
+ const handleProgress = (event) => {
+ if (event.detail) {
+ setDownloadProgress(event.detail.progress ?? 0);
+ setIsDownloadingModels(event.detail.active ?? false);
+ }
+ };
+ window.addEventListener('model-download-progress', handleProgress);
+ return () => {
+ window.removeEventListener('model-download-progress', handleProgress);
+ };
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+ const loadQueueStatus = async () => {
+ try {
+ const status = await getSoftDeleteQueueStatus();
+ if (!cancelled) {
+ setDeleteQueueStatus(status || EMPTY_DELETE_QUEUE_STATUS);
+ }
+ } catch {
+ if (!cancelled) {
+ setDeleteQueueStatus(EMPTY_DELETE_QUEUE_STATUS);
+ }
+ }
+ };
+
+ loadQueueStatus();
+ const timer = setInterval(loadQueueStatus, 4000);
+ return () => {
+ cancelled = true;
+ clearInterval(timer);
+ };
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+ const loadClusterStatus = async () => {
+ try {
+ const config = await invoke('get_advanced_config');
+ if (cancelled) return;
+ if (config && config.smart_cluster_enabled) {
+ const status = await getSmartClusterWorkerStatus();
+ if (!cancelled) {
+ setSmartClusterQueueStatus(status || EMPTY_CLUSTER_QUEUE_STATUS);
+ }
+ } else {
+ setSmartClusterQueueStatus(EMPTY_CLUSTER_QUEUE_STATUS);
+ }
+ } catch {
+ if (!cancelled) {
+ setSmartClusterQueueStatus(EMPTY_CLUSTER_QUEUE_STATUS);
+ }
+ }
+ };
+
+ loadClusterStatus();
+ const timer = setInterval(loadClusterStatus, 4000);
+ return () => {
+ cancelled = true;
+ clearInterval(timer);
+ };
+ }, []);
+
+ const pendingDeleteTotal = Number(deleteQueueStatus?.pending_ocr || 0)
+ + Number(deleteQueueStatus?.pending_screenshots || 0);
+ const hasDeleteTask = Boolean(deleteQueueStatus?.running) || pendingDeleteTotal > 120;
+ const deleteProgress = (() => {
+ if (!hasDeleteTask) return 0;
+ if (pendingDeleteTotal <= 0) return 100;
+ if (deleteQueuePeak <= 0) return 0;
+ const ratio = ((deleteQueuePeak - pendingDeleteTotal) / deleteQueuePeak) * 100;
+ return Math.max(0, Math.min(100, ratio));
+ })();
+
+ const hasClusterTask = Boolean(smartClusterQueueStatus?.running)
+ && Number(smartClusterQueueStatus?.pending_count || 0) > 0;
+ const canCancelClusterTask = hasClusterTask && Boolean(smartClusterQueueStatus?.forceRunning);
+ const clusterProgress = (() => {
+ if (!hasClusterTask) return 0;
+ const pending = Number(smartClusterQueueStatus.pending_count || 0);
+ if (pending <= 0) return 100;
+ if (smartClusterQueuePeak <= 0) return 0;
+ const ratio = ((smartClusterQueuePeak - pending) / smartClusterQueuePeak) * 100;
+ return Math.max(0, Math.min(100, ratio));
+ })();
+
+ const showProgressBar = hasDeleteTask || hasClusterTask || isDownloadingModels;
+ const progressFillPercent = (() => {
+ if (hasDeleteTask) return deleteProgress <= 0 ? 8 : Math.min(100, deleteProgress);
+ if (hasClusterTask) return clusterProgress <= 0 ? 8 : Math.min(100, clusterProgress);
+ if (isDownloadingModels) return downloadProgress <= 0 ? 8 : Math.min(100, downloadProgress);
+ return 0;
+ })();
+
+ const taskSummaryPlaceholder = (() => {
+ if (hasDeleteTask) {
+ return t('search.task.summaryPlaceholder', { progress: Math.round(deleteProgress) });
+ }
+ if (hasClusterTask) {
+ return t('search.task.smartClusterSummaryPlaceholder', { progress: Math.round(clusterProgress) });
+ }
+ if (isDownloadingModels) {
+ return t('search.task.modelDownloadSummaryPlaceholder', { progress: Math.round(downloadProgress) });
+ }
+ return '';
+ })();
+
+ useEffect(() => {
+ if (!hasDeleteTask) {
+ setDeleteQueuePeak(0);
+ return;
+ }
+ if (pendingDeleteTotal > 0) {
+ setDeleteQueuePeak((prev) => Math.max(prev, pendingDeleteTotal));
+ }
+ }, [hasDeleteTask, pendingDeleteTotal]);
+
+ useEffect(() => {
+ const running = Boolean(smartClusterQueueStatus?.running);
+ const pending = Number(smartClusterQueueStatus?.pending_count || 0);
+ if (!running || pending <= 0) {
+ setSmartClusterQueuePeak(0);
+ return;
+ }
+ setSmartClusterQueuePeak((prev) => Math.max(prev, pending));
+ }, [smartClusterQueueStatus?.running, smartClusterQueueStatus?.pending_count]);
+
+ useEffect(() => {
+ handleResumeMonitorRef.current = handleResumeMonitor;
+ }, [handleResumeMonitor]);
+
+ useEffect(() => {
+ if (hasClusterTask) {
+ if (wasPausedRef.current === null) {
+ wasPausedRef.current = !!monitorPaused;
+ if (!monitorPaused && handlePauseMonitor) {
+ handlePauseMonitor();
+ }
+ }
+ } else if (wasPausedRef.current !== null) {
+ const wasPaused = wasPausedRef.current;
+ wasPausedRef.current = null;
+ if (!wasPaused && handleResumeMonitor) {
+ handleResumeMonitor();
+ }
+ }
+ }, [hasClusterTask, monitorPaused, handlePauseMonitor, handleResumeMonitor]);
+
+ useEffect(() => {
+ return () => {
+ if (wasPausedRef.current === false && handleResumeMonitorRef.current) {
+ try {
+ handleResumeMonitorRef.current();
+ } catch (e) {
+ console.warn('[SmartCluster] resume on unmount failed', e);
+ }
+ }
+ wasPausedRef.current = null;
+ };
+ }, []);
+
+ const switchMode = (nextMode) => {
+ if (nextMode === 'nl' && backendOnline === false) return;
+ userInteractionRef.current = true;
+ setMode(nextMode);
+ };
+
+ const toggleMode = () => {
+ if (backendOnline === false && mode === 'ocr') return;
+ switchMode(mode === 'ocr' ? 'nl' : 'ocr');
+ };
+
+ const selectMode = (nextMode) => {
+ switchMode(nextMode);
+ if (nextMode !== 'nl' || backendOnline !== false) {
+ setShowModeMenu(false);
+ }
+ };
+
+ const handleSelect = (item) => {
+ const id = mode === 'ocr' ? item.screenshot_id : item.metadata?.screenshot_id;
+ onSelectResult({
+ id,
+ ...item,
+ path: item.image_path || item.path,
+ });
+ setShowResults(false);
+ };
+
+ const handleSubmit = (event) => {
+ if (event.key !== 'Enter') return;
+ event.preventDefault();
+ setShowResults(false);
+ onSubmit?.({ query, mode });
+ };
+
+ const handleCancelCluster = async (event) => {
+ event?.stopPropagation();
+ try {
+ await smartClusterStopDrain();
+ const status = await getSmartClusterWorkerStatus();
+ setSmartClusterQueueStatus(status || EMPTY_CLUSTER_QUEUE_STATUS);
+ } catch (err) {
+ console.error('Failed to cancel cluster process:', err);
+ }
+ };
+
+ return {
+ query,
+ setQuery,
+ mode,
+ showModeMenu,
+ setShowModeMenu,
+ results,
+ error,
+ loading,
+ showResults,
+ setShowResults,
+ debouncedQuery,
+ wrapperRef,
+ inputRef,
+ thumbCache,
+ isMigrating,
+ deleteQueueStatus,
+ smartClusterQueueStatus,
+ downloadProgress,
+ hasDeleteTask,
+ deleteProgress,
+ hasClusterTask,
+ canCancelClusterTask,
+ clusterProgress,
+ showProgressBar,
+ progressFillPercent,
+ taskSummaryPlaceholder,
+ isDownloadingModels,
+ toggleMode,
+ selectMode,
+ handleSelect,
+ handleSubmit,
+ handleCancelCluster,
+ };
+}
diff --git a/src/hooks/useSelectedSnapshot.js b/src/hooks/useSelectedSnapshot.js
new file mode 100644
index 0000000..eb3934b
--- /dev/null
+++ b/src/hooks/useSelectedSnapshot.js
@@ -0,0 +1,166 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { fetchImage, getScreenshotDetails } from '../lib/monitor_api';
+
+export function normalizeTimestampToMs(value, options = {}) {
+ const { assumeUtc = false } = options;
+ if (value === null || value === undefined || value === '') return null;
+
+ if (typeof value === 'number' && !Number.isNaN(value)) {
+ if (value > 1e12) return value;
+ if (value > 1e10) return value;
+ return value * 1000;
+ }
+
+ const raw = typeof value === 'string' ? value.trim() : String(value);
+ if (!raw) return null;
+
+ const numeric = Number(raw);
+ if (!Number.isNaN(numeric)) {
+ if (numeric > 1e12) return numeric;
+ if (numeric > 1e10) return numeric;
+ return numeric * 1000;
+ }
+
+ let iso = raw;
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(raw)) {
+ iso = raw.replace(' ', 'T');
+ }
+ if (assumeUtc && !/[zZ]|[+\-]\d{2}:\d{2}$/.test(iso)) {
+ iso = `${iso}Z`;
+ }
+ const parsed = new Date(iso);
+ if (!Number.isNaN(parsed.getTime())) return parsed.getTime();
+
+ return null;
+}
+
+export function useSelectedSnapshot() {
+ const [selectedEvent, setSelectedEvent] = useState(null);
+ const [selectedDetails, setSelectedDetails] = useState(null);
+ const [selectedImageSrc, setSelectedImageSrc] = useState(null);
+ const [isLoadingDetails, setIsLoadingDetails] = useState(false);
+ const [lastError, setLastError] = useState(null);
+ const [highlightedEventId, setHighlightedEventId] = useState(null);
+ const [timelineJump, setTimelineJump] = useState(null);
+ const [timelineRefreshKey, setTimelineRefreshKey] = useState(0);
+
+ useEffect(() => {
+ if (!selectedEvent) {
+ setSelectedDetails(null);
+ setSelectedImageSrc(null);
+ setLastError(null);
+ return;
+ }
+
+ setIsLoadingDetails(true);
+ setLastError(null);
+ setSelectedImageSrc(null);
+
+ let cancelled = false;
+
+ const loadData = async () => {
+ try {
+ const targetId = selectedEvent.id === -1 ? null : selectedEvent.id;
+ const targetPath = selectedEvent.path || selectedEvent.image_path;
+
+
+ const [det, img] = await Promise.all([
+ getScreenshotDetails(targetId, targetPath),
+ fetchImage(targetId, targetPath),
+ ]);
+
+ if (cancelled) return;
+
+
+ if (det && det.error) {
+ throw new Error(det.error);
+ }
+ setSelectedDetails(det);
+
+ if (selectedEvent._fromNlSearch) {
+ const recordCreatedAt = det?.record?.created_at;
+ if (recordCreatedAt) {
+ const dbTimestampMs = normalizeTimestampToMs(recordCreatedAt, { assumeUtc: true });
+ if (dbTimestampMs && Math.abs((selectedEvent.timestamp || 0) - dbTimestampMs) > 5000) {
+ setTimelineJump({ time: dbTimestampMs, ts: Date.now() });
+ }
+ }
+ }
+
+
+ if (!img) {
+ console.warn('Image fetch returned null for ID:', selectedEvent.id);
+ }
+ setSelectedImageSrc(img);
+ setIsLoadingDetails(false);
+ } catch (err) {
+ if (cancelled) return;
+ console.error('Failed to load details', err);
+ setLastError(err.message || 'Failed to load image details');
+ setIsLoadingDetails(false);
+ }
+ };
+
+ loadData();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [selectedEvent]);
+
+ const ocrBoxes = useMemo(() => {
+ return (selectedDetails?.ocr_results || []).map((item, index) => {
+ const points = item.box_coords || item.box;
+ if (!points || !Array.isArray(points) || points.length === 0) {
+ return null;
+ }
+ const xs = points.map((p) => p[0]);
+ const ys = points.map((p) => p[1]);
+ const minX = Math.min(...xs);
+ const maxX = Math.max(...xs);
+ const minY = Math.min(...ys);
+ const maxY = Math.max(...ys);
+
+ return {
+ id: String(item.id ?? index),
+ label: item.text,
+ type: 'text',
+ box: {
+ x: minX,
+ y: minY,
+ width: maxX - minX,
+ height: maxY - minY,
+ unit: 'pixel',
+ },
+ isSensitive: false,
+ };
+ }).filter(Boolean);
+ }, [selectedDetails]);
+
+ const clearSelection = useCallback(() => {
+ setSelectedEvent(null);
+ setSelectedDetails(null);
+ setSelectedImageSrc(null);
+ }, []);
+
+ const bumpTimelineRefresh = useCallback(() => {
+ setTimelineRefreshKey((prev) => prev + 1);
+ }, []);
+
+ return {
+ selectedEvent,
+ setSelectedEvent,
+ selectedDetails,
+ selectedImageSrc,
+ isLoadingDetails,
+ lastError,
+ highlightedEventId,
+ setHighlightedEventId,
+ timelineJump,
+ setTimelineJump,
+ timelineRefreshKey,
+ ocrBoxes,
+ clearSelection,
+ bumpTimelineRefresh,
+ };
+}
diff --git a/src/hooks/useStartupRecoveryHooks.test.js b/src/hooks/useStartupRecoveryHooks.test.js
new file mode 100644
index 0000000..5fb3b0a
--- /dev/null
+++ b/src/hooks/useStartupRecoveryHooks.test.js
@@ -0,0 +1,164 @@
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { invoke } from '@tauri-apps/api/core';
+import { listen } from '@tauri-apps/api/event';
+
+import {
+ getSmartClusterWorkerStatus,
+ getSoftDeleteQueueStatus,
+ searchScreenshots,
+} from '../lib/monitor_api';
+import { useDepsSyncOverlay } from './useDepsSyncOverlay';
+import { useRequiredModelDownload } from './useRequiredModelDownload';
+import { useSearchBoxController } from './useSearchBoxController';
+import { useTauriEventListener } from './useTauriEventListener';
+
+vi.mock('@tauri-apps/api/event', () => ({
+ listen: vi.fn(async () => vi.fn()),
+}));
+
+vi.mock('../lib/monitor_api', () => ({
+ fetchThumbnailBatch: vi.fn(async () => ({})),
+ getSmartClusterWorkerStatus: vi.fn(async () => ({ pending_count: 0, running: false })),
+ getSoftDeleteQueueStatus: vi.fn(async () => ({ pending_screenshots: 0, pending_ocr: 0, running: false })),
+ searchScreenshots: vi.fn(async () => []),
+}));
+
+vi.mock('../lib/task_api', () => ({
+ smartClusterStopDrain: vi.fn(async () => ({})),
+}));
+
+const t = (key, values) => (values ? `${key}:${JSON.stringify(values)}` : key);
+
+describe('startup and search recovery hooks', () => {
+ beforeEach(() => {
+ invoke.mockImplementation(async (command) => {
+ if (command === 'get_advanced_config') return { smart_cluster_enabled: false, use_onnx: false };
+ if (command === 'storage_check_hmac_migration_status') return { needs_migration: false, is_running: false };
+ return null;
+ });
+ listen.mockResolvedValue(vi.fn());
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('clears SearchBox loading when the query is emptied while a request is pending', async () => {
+ vi.useFakeTimers();
+ searchScreenshots.mockReturnValue(new Promise(() => {}));
+
+ const { result, unmount } = renderHook(() => useSearchBoxController({
+ onSelectResult: vi.fn(),
+ onSubmit: vi.fn(),
+ backendOnline: true,
+ monitorPaused: false,
+ handlePauseMonitor: vi.fn(),
+ handleResumeMonitor: vi.fn(),
+ t,
+ }));
+
+ act(() => {
+ result.current.setQuery('pending');
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ await Promise.resolve();
+ });
+
+ expect(result.current.loading).toBe(true);
+
+ act(() => {
+ result.current.setQuery('');
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ await Promise.resolve();
+ });
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.results).toEqual([]);
+ unmount();
+ });
+
+ it('does not auto-retry dependency sync after a failure until retry is requested', async () => {
+ const onDepsSync = vi.fn(async () => {
+ throw new Error('sync failed');
+ });
+
+ const { result } = renderHook(() => useDepsSyncOverlay({
+ depsNeedUpdate: true,
+ pythonVersion: '3.12.10',
+ renderVenvInstallStep: null,
+ depsSyncing: false,
+ onDepsSync,
+ }));
+
+ await waitFor(() => expect(result.current.depsSyncError).toBe('sync failed'));
+ expect(onDepsSync).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(onDepsSync).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ result.current.retryDepsSync();
+ });
+
+ await waitFor(() => expect(onDepsSync).toHaveBeenCalledTimes(2));
+ });
+
+ it('retries required model download only when retry is requested after a failure', async () => {
+ invoke.mockImplementation(async (command) => {
+ if (command === 'get_advanced_config') return { use_onnx: false };
+ if (command === 'download_model') throw new Error('download failed');
+ return null;
+ });
+
+ const { result } = renderHook(() => useRequiredModelDownload({
+ modelsNeedDownload: true,
+ missingModels: {
+ 'chinese-clip': { complete: false },
+ },
+ renderVenvInstallStep: null,
+ depsNeedUpdate: false,
+ onModelsDownloadComplete: vi.fn(),
+ t,
+ }));
+
+ await waitFor(() => expect(result.current.modelDownloadError).toBe('download failed'));
+ expect(invoke.mock.calls.filter(([command]) => command === 'download_model')).toHaveLength(1);
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(invoke.mock.calls.filter(([command]) => command === 'download_model')).toHaveLength(1);
+
+ act(() => {
+ result.current.retryModelDownload();
+ });
+
+ await waitFor(() => {
+ expect(invoke.mock.calls.filter(([command]) => command === 'download_model')).toHaveLength(2);
+ });
+ });
+
+ it('unsubscribes a Tauri listener that resolves after unmount', async () => {
+ let resolveListen;
+ const unlisten = vi.fn();
+ listen.mockImplementationOnce(() => new Promise((resolve) => {
+ resolveListen = resolve;
+ }));
+
+ const { unmount } = renderHook(() => useTauriEventListener('late-event', vi.fn()));
+ unmount();
+
+ await act(async () => {
+ resolveListen(unlisten);
+ await Promise.resolve();
+ });
+
+ expect(unlisten).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/hooks/useStartupWizards.js b/src/hooks/useStartupWizards.js
new file mode 100644
index 0000000..da3537a
--- /dev/null
+++ b/src/hooks/useStartupWizards.js
@@ -0,0 +1,131 @@
+import { useCallback, useEffect, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
+import { withAuth } from '../lib/auth_api';
+import { useDelayedClusteringSetupRunner } from './useDelayedClusteringSetupRunner';
+
+export function useStartupWizards({ backendStatus, isAuthenticated, setActiveTab, pushNotification }) {
+ const [showExtensionSetup, setShowExtensionSetup] = useState(false);
+ const [showClusteringSetup, setShowClusteringSetup] = useState(false);
+ const [showSmartClusterSetup, setShowSmartClusterSetup] = useState(false);
+
+ useEffect(() => {
+ if (backendStatus !== 'online' || !isAuthenticated) return;
+ let cancelled = false;
+ (async () => {
+ try {
+ const needed = await invoke('check_extension_setup_needed');
+ if (!cancelled && needed) {
+ setShowExtensionSetup(true);
+ }
+ } catch (err) {
+ console.warn('Failed to check extension setup status:', err);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [backendStatus, isAuthenticated]);
+
+ useEffect(() => {
+ if (backendStatus !== 'online' || !isAuthenticated || showExtensionSetup) return;
+ let cancelled = false;
+ (async () => {
+ try {
+ const needed = await invoke('check_clustering_setup_needed');
+ if (!cancelled && needed) {
+ setShowClusteringSetup(true);
+ }
+ } catch (err) {
+ console.warn('Failed to check clustering setup status:', err);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [backendStatus, isAuthenticated, showExtensionSetup]);
+
+ useEffect(() => {
+ if (backendStatus !== 'online' || !isAuthenticated || showExtensionSetup || showClusteringSetup) return;
+ let cancelled = false;
+ (async () => {
+ try {
+ const needed = await invoke('check_smart_cluster_setup_needed');
+ if (!cancelled && needed) {
+ setShowSmartClusterSetup(true);
+ }
+ } catch (err) {
+ console.warn('Failed to check smart cluster setup status:', err);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [backendStatus, isAuthenticated, showExtensionSetup, showClusteringSetup]);
+
+ useEffect(() => {
+ if (backendStatus !== 'online' || !isAuthenticated) return;
+ let cancelled = false;
+ withAuth(() => invoke('storage_warmup_thumbnails'))
+ .then((result) => {
+ if (!cancelled) {
+ const progress = result?.progress || {};
+ if (result?.started || result?.running) {
+ } else {
+ }
+ }
+ })
+ .catch((err) => console.warn('[Warmup] Thumbnail warmup failed:', err));
+ return () => { cancelled = true; };
+ }, [backendStatus, isAuthenticated]);
+
+ useEffect(() => {
+ const showExtension = () => {
+ setShowClusteringSetup(false);
+ setShowSmartClusterSetup(false);
+ setShowExtensionSetup(true);
+ };
+ const showClustering = () => {
+ setShowExtensionSetup(false);
+ setShowSmartClusterSetup(false);
+ setShowClusteringSetup(true);
+ };
+ const showSmartCluster = () => {
+ setShowExtensionSetup(false);
+ setShowClusteringSetup(false);
+ setShowSmartClusterSetup(true);
+ };
+
+ window.addEventListener('debug-show-extension-wizard', showExtension);
+ window.addEventListener('debug-show-clustering-wizard', showClustering);
+ window.addEventListener('debug-show-smart-cluster-wizard', showSmartCluster);
+
+ return () => {
+ window.removeEventListener('debug-show-extension-wizard', showExtension);
+ window.removeEventListener('debug-show-clustering-wizard', showClustering);
+ window.removeEventListener('debug-show-smart-cluster-wizard', showSmartCluster);
+ };
+ }, []);
+
+ const handleExtensionSetupComplete = useCallback(() => {
+ setShowExtensionSetup(false);
+ }, []);
+
+ const closeClusteringSetup = useCallback(() => {
+ setShowClusteringSetup(false);
+ }, []);
+
+ const handleClusteringSetupComplete = useDelayedClusteringSetupRunner({
+ onClose: closeClusteringSetup,
+ pushNotification,
+ });
+
+ const handleSmartClusterSetupComplete = useCallback((enabled) => {
+ setShowSmartClusterSetup(false);
+ if (enabled) {
+ setActiveTab('smart-cluster');
+ }
+ }, [setActiveTab]);
+
+ return {
+ showExtensionSetup,
+ showClusteringSetup,
+ showSmartClusterSetup,
+ handleExtensionSetupComplete,
+ handleClusteringSetupComplete,
+ handleSmartClusterSetupComplete,
+ };
+}
diff --git a/src/hooks/useTauriEventListener.js b/src/hooks/useTauriEventListener.js
new file mode 100644
index 0000000..593e8ad
--- /dev/null
+++ b/src/hooks/useTauriEventListener.js
@@ -0,0 +1,45 @@
+import { useEffect, useRef } from 'react';
+import { listen } from '@tauri-apps/api/event';
+
+export function useTauriEventListener(eventName, handler, deps = [], enabled = true) {
+ const handlerRef = useRef(handler);
+
+ useEffect(() => {
+ handlerRef.current = handler;
+ });
+
+ useEffect(() => {
+ if (!enabled || !eventName) return undefined;
+
+ let active = true;
+ let unlistenFn = null;
+
+ (async () => {
+ try {
+ const resolvedUnlisten = await listen(eventName, (event) => {
+ if (active) {
+ handlerRef.current?.(event);
+ }
+ });
+
+ if (!active) {
+ resolvedUnlisten();
+ return;
+ }
+
+ unlistenFn = resolvedUnlisten;
+ } catch (error) {
+ if (active) {
+ console.warn(`Failed to register ${eventName} listener`, error);
+ }
+ }
+ })();
+
+ return () => {
+ active = false;
+ if (unlistenFn) {
+ unlistenFn();
+ }
+ };
+ }, [eventName, enabled, ...deps]);
+}
diff --git a/src/hooks/useUpdateManager.js b/src/hooks/useUpdateManager.js
new file mode 100644
index 0000000..6ef7542
--- /dev/null
+++ b/src/hooks/useUpdateManager.js
@@ -0,0 +1,73 @@
+import { useEffect, useState } from 'react';
+import { checkForUpdate, downloadAndInstallUpdate } from '../lib/update_api';
+
+export function useUpdateManager() {
+ const [updateModalVisible, setUpdateModalVisible] = useState(false);
+ const [updateInfo, setUpdateInfo] = useState(null);
+ const [updateDownloading, setUpdateDownloading] = useState(false);
+ const [updateDownloadProgress, setUpdateDownloadProgress] = useState(null);
+ const [updateDownloadError, setUpdateDownloadError] = useState(null);
+
+ useEffect(() => {
+ const timer = setTimeout(async () => {
+ try {
+ const result = await checkForUpdate();
+ if (result.available) {
+ const dismissedVersion = localStorage.getItem('updateDismissed');
+ if (result.critical || dismissedVersion !== result.version) {
+ setUpdateInfo(result);
+ setUpdateModalVisible(true);
+ }
+ }
+ } catch {
+ // Network failure is non-fatal at startup.
+ }
+ }, 5000);
+ return () => clearTimeout(timer);
+ }, []);
+
+ useEffect(() => {
+ const handler = (e) => {
+ setUpdateInfo({
+ version: '9.9.9-debug',
+ body: 'This is a debug update payload.\n- It supports multiline text.\n- And lists.\n\nEnjoy testing the update modal!',
+ critical: e.detail?.critical || false,
+ });
+ setUpdateModalVisible(true);
+ };
+ window.addEventListener('debug-update-modal', handler);
+ return () => window.removeEventListener('debug-update-modal', handler);
+ }, []);
+
+ const handleDownloadUpdate = async () => {
+ setUpdateDownloading(true);
+ setUpdateDownloadError(null);
+ setUpdateDownloadProgress({ phase: 'downloading', downloaded: 0, contentLength: 0 });
+ try {
+ await downloadAndInstallUpdate((progress) => {
+ setUpdateDownloadProgress(progress);
+ });
+ } catch (err) {
+ setUpdateDownloadError(err.message || String(err));
+ setUpdateDownloading(false);
+ }
+ };
+
+ const handleLater = () => {
+ setUpdateModalVisible(false);
+ if (updateInfo) {
+ localStorage.setItem('updateDismissed', updateInfo.version);
+ }
+ };
+
+ return {
+ updateModalVisible,
+ updateInfo,
+ updateDownloading,
+ updateDownloadProgress,
+ updateDownloadError,
+ setUpdateModalVisible,
+ handleDownloadUpdate,
+ handleLater,
+ };
+}
diff --git a/src/hooks/useVenvInstallController.js b/src/hooks/useVenvInstallController.js
new file mode 100644
index 0000000..a8cd3b9
--- /dev/null
+++ b/src/hooks/useVenvInstallController.js
@@ -0,0 +1,299 @@
+import { useEffect, useRef, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
+import { open } from '@tauri-apps/plugin-dialog';
+import { useTauriEventListener } from './useTauriEventListener';
+
+function normalizeDiscoveredPythonVersions(result) {
+ const versions = Array.isArray(result) ? result : [result].filter(Boolean);
+
+ return versions.map((pv, idx) => {
+ const isPath = typeof pv === 'string' && (pv.includes('\\') || pv.includes('/') || String(pv).toLowerCase().endsWith('.exe'));
+ if (isPath) {
+ const path = pv;
+ const filename = path.split(/[\\/]/).pop();
+ return {
+ id: `py${path.replace(/[^a-zA-Z0-9]/g, '') || idx}`,
+ display: `${filename} - ${path}`,
+ disabled: false,
+ path,
+ };
+ }
+
+ const verDigits = pv ? pv.replace(/\./g, '') : `${idx}`;
+ const path = `C:\\Python${verDigits}\\python.exe`;
+ return {
+ id: `py${verDigits}`,
+ display: `Python ${pv} - ${path}`,
+ disabled: false,
+ path,
+ };
+ });
+}
+
+export function useVenvInstallController({
+ onRefreshPythonVersion,
+ handleStartBackend,
+ t,
+}) {
+ const [venvInstallStep, setVenvInstallStep] = useState(null);
+ const [pythonPath, setPythonPath] = useState('');
+ const [discoveredOptions, setDiscoveredOptions] = useState([]);
+ const [selectedVersions, setSelectedVersions] = useState([]);
+ const [versionErrorState, setVersionErrorState] = useState(null);
+ const [installing, setInstalling] = useState(false);
+ const [installError, setInstallError] = useState(null);
+ const [installLogs, setInstallLogs] = useState([]);
+ const [depsInstalling, setDepsInstalling] = useState(false);
+ const [depsInstallLog, setDepsInstallLog] = useState([]);
+ const [depsError, setDepsError] = useState(null);
+ const [depsInstallSuccess, setDepsInstallSuccess] = useState(false);
+ const [chosenPythonForInstall, setChosenPythonForInstall] = useState(null);
+
+ const installLogRef = useRef(null);
+ const depsLogRef = useRef(null);
+ const installStartedRef = useRef(false);
+ const pythonPathRef = useRef(pythonPath);
+ const inputRef = useRef(null);
+ const autoPostInstallRef = useRef(false);
+
+ const appendInstallLog = (msg) => {
+ setInstallLogs((prev) => [...prev, msg]);
+ };
+
+ const appendDepsLog = (msg) => {
+ const ts = new Date().toLocaleTimeString();
+ setDepsInstallLog((prev) => [...prev, `[${ts}] ${msg}`]);
+ };
+
+ useTauriEventListener('install-log', (event) => {
+ const payload = event?.payload || {};
+ const line = payload.line || JSON.stringify(payload);
+ const source = payload.source || 'installer';
+ if (source === 'pip' || source === 'aria2') {
+ appendDepsLog(line);
+ } else {
+ appendInstallLog(line);
+ }
+ });
+
+ useEffect(() => {
+ if (venvInstallStep === 1) {
+ let cancelled = false;
+ setVersionErrorState(null);
+ (async () => {
+ try {
+ setDiscoveredOptions([]);
+ const result = await invoke('check_python_status');
+ const opts = normalizeDiscoveredPythonVersions(result);
+ if (!cancelled) setDiscoveredOptions(opts);
+ } catch (error) {
+ if (!cancelled) setVersionErrorState(error?.message || String(error));
+ }
+ })();
+ return () => { cancelled = true; };
+ }
+ }, [venvInstallStep]);
+
+ useEffect(() => {
+ pythonPathRef.current = pythonPath;
+ }, [pythonPath]);
+
+ useEffect(() => {
+ if (venvInstallStep === 2) {
+ setDepsInstallLog([]);
+ setDepsError(null);
+ setDepsInstallSuccess(false);
+ autoPostInstallRef.current = false;
+
+ if (window.__cp_install_started) {
+ appendDepsLog('安装已在进行中(忽略重复触发)');
+ return;
+ }
+ window.__cp_install_started = true;
+ installStartedRef.current = true;
+
+ setDepsInstalling(true);
+
+ const currentPythonPath = (chosenPythonForInstall !== null && chosenPythonForInstall !== undefined)
+ ? chosenPythonForInstall
+ : (inputRef.current?.value || pythonPathRef.current);
+ const processedPythonPath = currentPythonPath ? currentPythonPath.replace(/\\/g, '\\\\') : null;
+
+ appendDepsLog(t('mask.venv.step2.log_start'));
+ appendDepsLog(t('mask.venv.step2.using_python', { path: processedPythonPath || t('mask.venv.step2.using_python_default') }));
+
+ (async () => {
+ try {
+ const res = await invoke('install_python_venv', { python_path: processedPythonPath });
+ appendDepsLog(res);
+ const config = await invoke('get_advanced_config');
+ const useOnnx = config?.use_onnx || false;
+
+ appendDepsLog(t('mask.venv.step2.download_models'));
+ const modelRes = await invoke('download_model');
+ appendDepsLog(modelRes);
+
+ appendDepsLog(t('mask.venv.step2.download_bge'));
+ const bgeRes = useOnnx
+ ? await invoke('download_model', {
+ repo: 'Xenova/bge-small-zh-v1.5',
+ subdir: 'bge-small-zh-v1.5',
+ files: ['config.json', 'tokenizer.json', 'tokenizer_config.json', 'special_tokens_map.json', 'onnx/model_quantized.onnx'],
+ modelRuntime: 'onnx',
+ })
+ : await invoke('download_model', {
+ repo: 'BAAI/bge-small-zh-v1.5',
+ subdir: 'bge-small-zh-v1.5',
+ files: ['config.json', 'pytorch_model.bin', 'tokenizer.json', 'tokenizer_config.json', 'vocab.txt', 'special_tokens_map.json'],
+ });
+ appendDepsLog(bgeRes);
+
+ appendDepsLog(t('mask.venv.step2.download_minilm'));
+ const minilmRes = useOnnx
+ ? await invoke('download_model', {
+ repo: 'Xenova/paraphrase-multilingual-MiniLM-L12-v2',
+ subdir: 'paraphrase-multilingual-MiniLM-L12-v2',
+ files: ['config.json', 'tokenizer.json', 'tokenizer_config.json', 'special_tokens_map.json', 'onnx/model_quantized.onnx'],
+ modelRuntime: 'onnx',
+ })
+ : await invoke('download_model', {
+ repo: 'sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2',
+ subdir: 'paraphrase-multilingual-MiniLM-L12-v2',
+ files: ['config.json', 'pytorch_model.bin', 'tokenizer.json', 'tokenizer_config.json', 'special_tokens_map.json', 'sentencepiece.bpe.model'],
+ });
+ appendDepsLog(minilmRes);
+ appendDepsLog(t('mask.venv.step2.deps_complete'));
+ setDepsInstallSuccess(true);
+ } catch (err) {
+ appendDepsLog(t('mask.venv.step2.deps_failed', { error: err?.message || String(err) }));
+ setDepsError(err?.message || String(err));
+ setDepsInstallSuccess(false);
+ } finally {
+ setDepsInstalling(false);
+ window.__cp_install_started = false;
+ installStartedRef.current = false;
+ }
+ })();
+ } else {
+ installStartedRef.current = false;
+ if (window.__cp_install_started) window.__cp_install_started = false;
+ }
+ }, [venvInstallStep, chosenPythonForInstall, t]);
+
+ useEffect(() => {
+ if (depsInstalling || depsError || !depsInstallSuccess) return;
+ if (autoPostInstallRef.current) return;
+ autoPostInstallRef.current = true;
+
+ (async () => {
+ try {
+ if (typeof onRefreshPythonVersion === 'function') {
+ await onRefreshPythonVersion();
+ }
+ } catch (e) {
+ console.warn('Failed to refresh Python version after install', e);
+ } finally {
+ setVenvInstallStep(null);
+ if (typeof handleStartBackend === 'function') {
+ handleStartBackend();
+ }
+ }
+ })();
+ }, [depsInstalling, depsError, depsInstallSuccess, handleStartBackend, onRefreshPythonVersion]);
+
+ useEffect(() => {
+ if (depsLogRef?.current) {
+ depsLogRef.current.scrollTop = depsLogRef.current.scrollHeight;
+ }
+ }, [depsInstallLog]);
+
+ useEffect(() => {
+ if (installLogRef?.current) {
+ installLogRef.current.scrollTop = installLogRef.current.scrollHeight;
+ }
+ }, [installLogs]);
+
+ const toggleVersion = (id) => {
+ const opt = discoveredOptions.find((o) => o.id === id);
+ if (!opt || opt.disabled) return;
+ if (selectedVersions.includes(id)) {
+ setSelectedVersions(selectedVersions.filter((item) => item !== id));
+ } else {
+ setSelectedVersions([...selectedVersions, id]);
+ setPythonPath(opt.path || '');
+ }
+ };
+
+ const installPython = async () => {
+ setInstallError(null);
+ setInstalling(true);
+ setInstallLogs([]);
+ appendInstallLog(t('mask.venv.auto_install.log_start'));
+
+ try {
+ await invoke('request_install_python');
+ appendInstallLog(t('mask.venv.auto_install.success'));
+ appendInstallLog(t('mask.venv.auto_install.finished'));
+ setVenvInstallStep(null);
+ await invoke('close_process');
+ } catch (err) {
+ setInstallError(t('mask.venv.install_failed', { error: err?.message || String(err) }));
+ appendInstallLog(t('mask.venv.install_failed', { error: err?.message || String(err) }));
+ } finally {
+ setInstalling(false);
+ }
+ };
+
+ const openFileDialog = async () => {
+ try {
+ const selected = await open({
+ multiple: false,
+ filters: [{ name: 'Executable', extensions: ['exe'] }],
+ });
+ if (!selected) return;
+ const chosen = Array.isArray(selected) ? selected[0] : selected;
+ setPythonPath(chosen);
+ } catch (err) {
+ console.error('Failed to open file dialog', err);
+ }
+ };
+
+ const beginDependencyInstall = () => {
+ setChosenPythonForInstall(inputRef.current?.value || pythonPath);
+ setVenvInstallStep(2);
+ };
+
+ const retryDependencyInstall = () => {
+ setDepsError(null);
+ setDepsInstallLog([]);
+ setDepsInstallSuccess(false);
+ window.__cp_install_started = false;
+ installStartedRef.current = false;
+ setVenvInstallStep(null);
+ setTimeout(() => setVenvInstallStep(2), 0);
+ };
+
+ return {
+ venvInstallStep,
+ setVenvInstallStep,
+ pythonPath,
+ setPythonPath,
+ discoveredOptions,
+ selectedVersions,
+ versionErrorState,
+ installing,
+ installError,
+ installLogs,
+ installLogRef,
+ depsInstalling,
+ depsInstallLog,
+ depsError,
+ depsLogRef,
+ inputRef,
+ toggleVersion,
+ installPython,
+ openFileDialog,
+ beginDependencyInstall,
+ retryDependencyInstall,
+ };
+}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 1928524..e689d2e 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -23,6 +23,28 @@
"permanent": "DirectML disabled (too many switches, will restore on restart)",
"fullscreen_paused": "Active: Non-browser fullscreen app detected, capture paused"
},
+ "resourcePolicy": {
+ "label": "Resource Usage",
+ "description": "Choose how aggressively CarbonPaper should protect battery life and foreground performance.",
+ "options": {
+ "complete": {
+ "label": "Complete",
+ "description": "Keep capture and analysis running whenever possible. Best for a complete history, but may affect games and battery life."
+ },
+ "balanced": {
+ "label": "Balanced",
+ "description": "Pause heavier background services on battery power, then resume when plugged in."
+ },
+ "performance": {
+ "label": "Performance",
+ "description": "Default. Protect battery life and reduce load during games or fullscreen apps."
+ },
+ "custom": {
+ "label": "Custom",
+ "description": "Tune battery saving and game protection separately."
+ }
+ }
+ },
"lightweightMode": {
"startHidden": {
"label": "Start with Hidden Window (Lightweight Mode)",
@@ -40,6 +62,41 @@
"button": "Switch"
}
},
+ "windowBehavior": {
+ "label": "Window and Background",
+ "description": "Control whether CarbonPaper shows a window on startup and keeps running in the background after closing.",
+ "startup": {
+ "label": "Startup",
+ "showWindow": {
+ "label": "Show window",
+ "description": "Open the main window when CarbonPaper starts."
+ },
+ "background": {
+ "label": "Background",
+ "description": "Start hidden in the tray and run in the background."
+ }
+ },
+ "closeWindow": {
+ "label": "When closing the window",
+ "current": {
+ "label": "Just hide window",
+ "description": "Hide the UI to the tray and keep current background services as they are."
+ },
+ "background": {
+ "label": "Run in background",
+ "description": "Hide the UI immediately, then switch to background running after the delay."
+ }
+ },
+ "delay": {
+ "label": "Delay before background running:",
+ "unit": "minutes"
+ },
+ "switchNow": {
+ "label": "Run in background now",
+ "description": "Hide the main window immediately and switch to background running.",
+ "button": "Run in background"
+ }
+ },
"cardClickBehavior": {
"label": "Card Click Default Behavior",
"description": "Configure the default action when clicking on snapshot cards under different functional views.",
@@ -135,6 +192,12 @@
"loading": "Loading...",
"warning": "These settings may affect monitor service performance and system stability. Adjust carefully.",
"quick_restart": "Quick Restart",
+ "terms": {
+ "directml": "DirectML is Microsoft's GPU inference interface. It can accelerate OCR on compatible GPUs, but may compete with games or graphics-heavy apps.",
+ "onnx": "ONNX is a portable model format/runtime. It usually uses less memory than PyTorch and starts faster.",
+ "pacmap_hdbscan": "PaCMAP reduces high-dimensional text vectors into a shape that is easier to cluster; HDBSCAN then groups dense regions without needing a fixed cluster count.",
+ "minilm": "MiniLM-L12 is the compact language model used to encode OCR text before task clustering."
+ },
"cpu": {
"title": "Python subprocess CPU limits",
"label": "Limit CPU usage",
@@ -419,6 +482,16 @@
"endpoint": "Endpoint",
"auth_header": "Authorization Header"
},
+ "agent_setup": {
+ "title": "Agent Setup",
+ "description": "Copy setup instructions so an Agent can fetch the Skill and connect to the local MCP server.",
+ "skill": "Skill",
+ "source": "Source",
+ "endpoint": "Endpoint",
+ "copy": "Copy setup prompt",
+ "copied": "Setup prompt copied",
+ "prompt": "Configure CarbonPaper MCP access.\n\n1. Fetch the Skill from the {{skillName}} directory in GitHub repository {{repo}}, then install it into your Agent/Skill environment.\n2. Use that Skill for CarbonPaper screenshot history, OCR, task cluster, and smart-cluster summary workflows.\n3. MCP endpoint: {{endpoint}}, using Streamable HTTP/HTTP POST.\n4. Authorization header: Authorization: Bearer . Do not ask me to paste the token into this long setup prompt; ask me to click Copy token in CarbonPaper and configure it separately in your MCP client.\n5. If a tool returns AUTH_REQUIRED, tell me to unlock CarbonPaper with Windows Hello before retrying.\n6. This MCP server may expose local screenshot history, OCR text, window titles, URLs, task clusters, and smart-cluster data. Read only the content needed for my explicit request."
+ },
"content_filter": {
"title": "Content Filter",
"description": "Filter flagged content from MCP server responses based on compliance rules.",
@@ -509,6 +582,35 @@
"title": "Features",
"management": {
"title": "Feature Management",
+ "featureMode": {
+ "label": "Feature Level",
+ "description": "Choose how much semantic functionality CarbonPaper should enable. Higher levels provide more automation and may use more background resources.",
+ "customControls": {
+ "label": "Adjust individually"
+ },
+ "options": {
+ "minimal": {
+ "label": "Minimal",
+ "description": "Keep basic screenshots and OCR search without semantic classification or clustering."
+ },
+ "basic": {
+ "label": "Basic",
+ "description": "Enable content classification for filtering, statistics, and later organization."
+ },
+ "organized": {
+ "label": "Organized",
+ "description": "Enable content classification and task clustering to organize long-running activity."
+ },
+ "smart": {
+ "label": "Smart",
+ "description": "Enable all semantic features, including automatic archive by description."
+ },
+ "custom": {
+ "label": "Custom",
+ "description": "The current feature combination came from individual settings or an older configuration."
+ }
+ }
+ },
"clustering": {
"label": "Task Clustering",
"description": "Group similar activities into long-term tasks using MiniLM model",
diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json
index be8dd79..7f93742 100644
--- a/src/i18n/locales/zh-CN.json
+++ b/src/i18n/locales/zh-CN.json
@@ -23,6 +23,28 @@
"permanent": "DirectML 已关闭(频繁切换,将在重启后恢复)",
"fullscreen_paused": "已激活:检测到全屏非浏览器程序,截图已暂停"
},
+ "resourcePolicy": {
+ "label": "资源占用策略",
+ "description": "选择 CarbonPaper 在电量和前台性能之间的取舍方式。",
+ "options": {
+ "complete": {
+ "label": "完整记录",
+ "description": "尽量保持截图和分析持续运行,适合优先保证历史完整性,但可能影响游戏性能和电池续航。"
+ },
+ "balanced": {
+ "label": "均衡",
+ "description": "使用电池供电时暂停较重的后台服务,接入电源后自动恢复。"
+ },
+ "performance": {
+ "label": "性能保护",
+ "description": "默认选项。推荐。保护电池续航,并在游戏或全屏应用运行时降低负载。"
+ },
+ "custom": {
+ "label": "自定义",
+ "description": "分别调整电池节能和游戏性能保护。"
+ }
+ }
+ },
"lightweightMode": {
"startHidden": {
"label": "启动时隐藏窗口(轻量模式启动)",
@@ -40,6 +62,41 @@
"button": "切换"
}
},
+ "windowBehavior": {
+ "label": "窗口与后台运行",
+ "description": "控制 CarbonPaper 启动时是否显示窗口,以及关闭窗口后是否在后台运行。",
+ "startup": {
+ "label": "启动方式",
+ "showWindow": {
+ "label": "显示窗口",
+ "description": "CarbonPaper 启动时打开主窗口。"
+ },
+ "background": {
+ "label": "后台启动",
+ "description": "启动后直接隐藏到托盘,在后台运行。"
+ }
+ },
+ "closeWindow": {
+ "label": "关闭窗口时",
+ "current": {
+ "label": "仅隐藏窗口",
+ "description": "将界面隐藏到托盘,后台服务保持当前状态。"
+ },
+ "background": {
+ "label": "在后台运行",
+ "description": "界面会立即隐藏到托盘,并在延迟结束后转为后台运行。"
+ }
+ },
+ "delay": {
+ "label": "转为后台运行前延迟:",
+ "unit": "分钟"
+ },
+ "switchNow": {
+ "label": "立即在后台运行",
+ "description": "马上隐藏主窗口,并转为后台运行。",
+ "button": "在后台运行"
+ }
+ },
"cardClickBehavior": {
"label": "卡片点击默认行为",
"description": "配置在不同功能视图下点击快照卡片时的默认响应操作。",
@@ -135,6 +192,12 @@
"loading": "加载中...",
"warning": "这些设置可能会影响监控服务的性能和稳定性,甚至影响操作系统整体性能。请谨慎调整。",
"quick_restart": "快速重启",
+ "terms": {
+ "directml": "DirectML 是微软的 GPU 推理接口。它可以在兼容显卡上加速 OCR,但可能与游戏或图形密集应用争用 GPU。",
+ "onnx": "ONNX 是一种通用模型格式和运行时。相比 PyTorch,它通常占用更少内存,启动也更快。",
+ "pacmap_hdbscan": "PaCMAP 会把高维文本向量压缩成更容易聚类的形状;HDBSCAN 再按密度自动分组,不需要预先指定分组数量。",
+ "minilm": "MiniLM-L12 是用于把 OCR 文本编码成向量的轻量语言模型,供任务聚类使用。"
+ },
"cpu": {
"title": "Python 子进程 CPU 限制",
"label": "限制 CPU 占用率",
@@ -419,6 +482,16 @@
"endpoint": "端点",
"auth_header": "认证头"
},
+ "agent_setup": {
+ "title": "Agent 配置",
+ "description": "复制一段配置提示词,让 Agent 自行获取 Skill 并连接本地 MCP 服务。",
+ "skill": "Skill",
+ "source": "来源",
+ "endpoint": "端点",
+ "copy": "复制配置提示词",
+ "copied": "配置提示词已复制",
+ "prompt": "请配置 CarbonPaper MCP 访问。\n\n1. 从 GitHub 仓库 {{repo}} 的 {{skillName}} 目录获取 Skill,并安装到你的 Agent/Skill 环境中。\n2. 使用该 Skill 处理 CarbonPaper 截图历史、OCR、任务聚类和智能聚类摘要相关请求。\n3. MCP endpoint:{{endpoint}},使用 Streamable HTTP/HTTP POST。\n4. 认证头:Authorization: Bearer 。不要让我把 token 写进长提示词;请让我在 CarbonPaper 中单独点击“复制令牌”,然后将其配置到你的 MCP 客户端。\n5. 如果工具返回 AUTH_REQUIRED,请提示我先在 CarbonPaper 应用中完成 Windows Hello 解锁。\n6. 该 MCP 可能暴露本机截图历史、OCR 文本、窗口标题、URL、任务聚类和智能聚类数据;只在我明确要求时读取相关内容。"
+ },
"content_filter": {
"title": "内容过滤",
"description": "基于合规规则过滤 MCP 服务返回结果中的标记内容。",
@@ -509,6 +582,35 @@
"title": "功能",
"management": {
"title": "功能管理",
+ "featureMode": {
+ "label": "功能等级",
+ "description": "选择截图语义功能的启用深度。等级越高,自动整理能力越强,后台资源占用也可能更高。",
+ "customControls": {
+ "label": "单独调整"
+ },
+ "options": {
+ "minimal": {
+ "label": "最小功能",
+ "description": "保留基础截图与 OCR 搜索,不启用语义分类和聚类。"
+ },
+ "basic": {
+ "label": "基础功能",
+ "description": "启用内容分类,帮助筛选、统计和后续整理。"
+ },
+ "organized": {
+ "label": "整理功能",
+ "description": "启用内容分类和任务聚类,自动整理长期活动。"
+ },
+ "smart": {
+ "label": "智能功能",
+ "description": "启用全部语义功能,支持按描述自动归档快照。"
+ },
+ "custom": {
+ "label": "自定义",
+ "description": "当前功能组合来自单独设置或旧版本配置。"
+ }
+ }
+ },
"clustering": {
"label": "任务聚类",
"description": "使用 MiniLM 模型将相似活动分组为长期任务",
diff --git a/src/lib/api_contracts.test.js b/src/lib/api_contracts.test.js
index b2728a4..871c3eb 100644
--- a/src/lib/api_contracts.test.js
+++ b/src/lib/api_contracts.test.js
@@ -2,19 +2,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { invoke } from '@tauri-apps/api/core';
vi.mock('./auth_api', () => ({
- withAuth: async (fn) => fn(),
+ withAuth: vi.fn(async (fn) => fn()),
requestAuth: vi.fn(),
checkAuthSession: vi.fn(),
initAuthListeners: vi.fn(),
lockSession: vi.fn(),
}));
+import { withAuth } from './auth_api';
import {
classifyDebug,
deleteRecordsByTimeRange,
deleteScreenshot,
+ getIndexHealth,
getSmartClusterWorkerStatus,
removeLocalAnchorsByProcess,
+ retryVectorIndexing,
} from './monitor_api';
import {
createSmartCluster,
@@ -32,15 +35,28 @@ import {
describe('API contract payloads', () => {
beforeEach(() => {
invoke.mockReset();
+ withAuth.mockClear();
});
+ const expectWithAuth = (callNumber, options) => {
+ const call = withAuth.mock.calls[callNumber - 1];
+ expect(call?.[0]).toEqual(expect.any(Function));
+ if (options === undefined) {
+ expect(call).toHaveLength(1);
+ } else {
+ expect(call?.[1]).toEqual(options);
+ }
+ };
+
it('sends monitor classification and maintenance payloads', async () => {
invoke
.mockResolvedValueOnce({ category: 'Development' })
.mockResolvedValueOnce({ status: 'success', removed_count: 2 })
.mockResolvedValueOnce({ status: 'success', deleted: true })
.mockResolvedValueOnce({ status: 'success', deleted_count: 3 })
- .mockResolvedValueOnce({ pending_count: 4, is_running: true, is_force_running: false });
+ .mockResolvedValueOnce({ pending_count: 4, is_running: true, is_force_running: false })
+ .mockResolvedValueOnce({ status: 'success', screenshots_count: 10 })
+ .mockResolvedValueOnce({ status: 'success', enqueued: 2 });
await classifyDebug({ title: 'Editor', ocrText: 'text', processName: 'code.exe' });
await removeLocalAnchorsByProcess('Development', 'code.exe');
@@ -51,6 +67,8 @@ describe('API contract payloads', () => {
running: true,
forceRunning: false,
});
+ await getIndexHealth({ refreshVector: true });
+ await retryVectorIndexing(12);
expect(invoke).toHaveBeenNthCalledWith(1, 'monitor_classify_debug', {
title: 'Editor',
@@ -69,6 +87,21 @@ describe('API contract payloads', () => {
endTime: 1000000,
});
expect(invoke).toHaveBeenNthCalledWith(5, 'monitor_smart_cluster_worker_status');
+ expect(invoke).toHaveBeenNthCalledWith(6, 'storage_get_index_health', {
+ refreshVector: true,
+ });
+ expect(invoke).toHaveBeenNthCalledWith(7, 'storage_retry_vector_indexing', {
+ limit: 12,
+ });
+
+ expect(withAuth).toHaveBeenCalledTimes(7);
+ expectWithAuth(1, { autoPrompt: true });
+ expectWithAuth(2, { autoPrompt: true });
+ expectWithAuth(3, { autoPrompt: true });
+ expectWithAuth(4, { autoPrompt: true });
+ expectWithAuth(5);
+ expectWithAuth(6, { autoPrompt: true });
+ expectWithAuth(7, { autoPrompt: true });
});
it('sends task and natural-language clustering payloads', async () => {
@@ -109,6 +142,13 @@ describe('API contract payloads', () => {
expect(invoke).toHaveBeenNthCalledWith(5, 'storage_save_clustering_results', {
tasks: [{ label: 'Work', screenshot_ids: [42] }],
});
+
+ expect(withAuth).toHaveBeenCalledTimes(5);
+ expectWithAuth(1);
+ expectWithAuth(2, { autoPrompt: true });
+ expectWithAuth(3);
+ expectWithAuth(4, { autoPrompt: true });
+ expectWithAuth(5, { autoPrompt: true });
});
it('sends smart cluster CRUD payloads', async () => {
@@ -146,5 +186,12 @@ describe('API contract payloads', () => {
page: 2,
pageSize: 20,
});
+
+ expect(withAuth).toHaveBeenCalledTimes(5);
+ expectWithAuth(1, { autoPrompt: true });
+ expectWithAuth(2, { autoPrompt: true });
+ expectWithAuth(3, { autoPrompt: true });
+ expectWithAuth(4, { autoPrompt: true });
+ expectWithAuth(5);
});
});
diff --git a/src/lib/categories.test.js b/src/lib/categories.test.js
index 1abaccb..dd31e89 100644
--- a/src/lib/categories.test.js
+++ b/src/lib/categories.test.js
@@ -13,18 +13,28 @@ describe('categories constants', () => {
expect(CATEGORY_LIST).toContain('未分类');
});
- it('provides colors for primary categories', () => {
- expect(CATEGORY_COLORS['编程开发']).toBe('#3b82f6');
- expect(CATEGORY_COLORS['办公文档']).toBe('#f59e0b');
- expect(CATEGORY_COLORS['阅读资讯']).toBe('#f97316');
+ it('keeps display colors aligned with selectable categories', () => {
+ const categoriesWithDedicatedColors = CATEGORY_LIST.filter((category) => category !== '未分类');
+
+ expect(Object.keys(CATEGORY_COLORS).sort()).toEqual([...categoriesWithDedicatedColors].sort());
+ expect(CATEGORY_COLORS['未分类']).toBeUndefined();
+
+ for (const color of Object.values(CATEGORY_COLORS)) {
+ expect(color).toMatch(/^#[0-9a-f]{6}$/i);
+ }
});
- it('marks entertainment and social groups', () => {
- expect(ENTERTAINMENT_CATEGORIES.has('影音娱乐')).toBe(true);
- expect(ENTERTAINMENT_CATEGORIES.has('游戏')).toBe(true);
- expect(ENTERTAINMENT_CATEGORIES.has('编程开发')).toBe(false);
+ it('keeps task filter groups known and non-overlapping', () => {
+ const knownCategories = new Set(CATEGORY_LIST);
+
+ for (const category of ENTERTAINMENT_CATEGORIES) {
+ expect(knownCategories.has(category)).toBe(true);
+ expect(SOCIAL_CATEGORIES.has(category)).toBe(false);
+ }
- expect(SOCIAL_CATEGORIES.has('社交通讯')).toBe(true);
- expect(SOCIAL_CATEGORIES.has('办公文档')).toBe(false);
+ for (const category of SOCIAL_CATEGORIES) {
+ expect(knownCategories.has(category)).toBe(true);
+ expect(ENTERTAINMENT_CATEGORIES.has(category)).toBe(false);
+ }
});
});
diff --git a/src/lib/monitor_api.api.test.js b/src/lib/monitor_api.api.test.js
index cf37e67..bc8bd4a 100644
--- a/src/lib/monitor_api.api.test.js
+++ b/src/lib/monitor_api.api.test.js
@@ -2,16 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { invoke } from '@tauri-apps/api/core';
vi.mock('./auth_api', () => ({
- withAuth: async (fn) => fn(),
+ withAuth: vi.fn(async (fn) => fn()),
requestAuth: vi.fn(),
checkAuthSession: vi.fn(),
initAuthListeners: vi.fn(),
lockSession: vi.fn(),
}));
+import { withAuth } from './auth_api';
import {
fetchImage,
fetchThumbnail,
+ fetchTimelineImage,
+ REQUEST_DEADLINES,
searchScreenshots,
listProcesses,
getScreenshotDetails,
@@ -25,13 +28,25 @@ describe('monitor_api command wrappers', () => {
beforeEach(() => {
invoke.mockReset();
+ withAuth.mockClear();
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
+ vi.useRealTimers();
consoleErrorSpy.mockRestore();
});
+ const expectWithAuth = (callNumber, options) => {
+ const call = withAuth.mock.calls[callNumber - 1];
+ expect(call?.[0]).toEqual(expect.any(Function));
+ if (options === undefined) {
+ expect(call).toHaveLength(1);
+ } else {
+ expect(call?.[1]).toEqual(options);
+ }
+ };
+
it('calls nl search with monitor command payload', async () => {
invoke.mockResolvedValue({ results: [{ id: 1 }] });
@@ -54,12 +69,14 @@ describe('monitor_api command wrappers', () => {
endTime: 20,
fuzzy: false,
});
+ expectWithAuth(1);
});
it('throws for nl search when backend returns error', async () => {
invoke.mockResolvedValue({ error: 'bad_request' });
await expect(searchScreenshots('x', 'nl')).rejects.toThrow('bad_request');
+ expectWithAuth(1);
});
it('calls ocr search with normalized filters', async () => {
@@ -81,24 +98,28 @@ describe('monitor_api command wrappers', () => {
startTime: null,
endTime: null,
});
+ expectWithAuth(1);
});
it('returns empty array when listProcesses invoke fails', async () => {
invoke.mockRejectedValue(new Error('pipe error'));
await expect(listProcesses()).resolves.toEqual([]);
+ expectWithAuth(1);
});
it('returns normalized error object when getScreenshotDetails throws', async () => {
invoke.mockRejectedValue(new Error('boom'));
await expect(getScreenshotDetails(1)).resolves.toEqual({ error: 'Error: boom' });
+ expectWithAuth(1);
});
it('maps unknown command to unsupported code in updateMonitorFilters', async () => {
invoke.mockResolvedValue({ error: 'unknown command' });
await expect(updateMonitorFilters({})).rejects.toMatchObject({ code: 'unsupported' });
+ expectWithAuth(1, { autoPrompt: true });
});
it('dedupes concurrent full image requests by id', async () => {
@@ -113,6 +134,65 @@ describe('monitor_api command wrappers', () => {
expect(second).toBe(first);
expect(invoke).toHaveBeenCalledTimes(1);
expect(invoke).toHaveBeenCalledWith('storage_get_image', { id: 42, path: null });
+ expectWithAuth(1);
+ });
+
+ it('rejects full image requests that exceed the queue deadline', async () => {
+ vi.useFakeTimers();
+ invoke.mockImplementation(() => new Promise(() => {}));
+
+ const request = fetchImage(9001);
+ const assertion = expect(request).rejects.toMatchObject({ code: 'deadline_exceeded' });
+
+ await vi.advanceTimersByTimeAsync(REQUEST_DEADLINES.imageMs + 1);
+
+ await assertion;
+ expect(invoke).toHaveBeenCalledWith('storage_get_image', { id: 9001, path: null });
+ expectWithAuth(1);
+ });
+
+ it('rejects thumbnail requests that exceed the queue deadline', async () => {
+ vi.useFakeTimers();
+ invoke.mockImplementation(() => new Promise(() => {}));
+
+ const request = fetchThumbnail(9003);
+ const assertion = expect(request).rejects.toMatchObject({ code: 'deadline_exceeded' });
+
+ await vi.advanceTimersByTimeAsync(REQUEST_DEADLINES.thumbnailMs + 1);
+
+ await assertion;
+ expect(invoke).toHaveBeenCalledWith('storage_get_thumbnail', { id: 9003, path: null });
+ expectWithAuth(1);
+ });
+
+ it('rejects timeline image requests that exceed the queue deadline', async () => {
+ vi.useFakeTimers();
+ invoke.mockImplementation(() => new Promise(() => {}));
+
+ const request = fetchTimelineImage(9004);
+ const assertion = expect(request).rejects.toMatchObject({ code: 'deadline_exceeded' });
+
+ await vi.advanceTimersByTimeAsync(REQUEST_DEADLINES.timelineImageMs + 1);
+
+ await assertion;
+ expect(invoke).toHaveBeenCalledWith('storage_get_thumbnail', { id: 9004, path: null });
+ expectWithAuth(1);
+ });
+
+ it('returns an error object when screenshot details exceed the queue deadline', async () => {
+ vi.useFakeTimers();
+ invoke.mockImplementation(() => new Promise(() => {}));
+
+ const request = getScreenshotDetails(9002);
+ const assertion = expect(request).resolves.toEqual({
+ error: `Error: deadline exceeded after ${REQUEST_DEADLINES.detailMs}ms`,
+ });
+
+ await vi.advanceTimersByTimeAsync(REQUEST_DEADLINES.detailMs + 1);
+
+ await assertion;
+ expect(invoke).toHaveBeenCalledWith('storage_get_screenshot_details', { id: 9002, path: null });
+ expectWithAuth(1);
});
it('dedupes concurrent thumbnail requests by path', async () => {
@@ -130,5 +210,6 @@ describe('monitor_api command wrappers', () => {
expect(second).toBe(first);
expect(invoke).toHaveBeenCalledTimes(1);
expect(invoke).toHaveBeenCalledWith('storage_get_thumbnail', { id: null, path: 'D:/shots/a.jpg' });
+ expectWithAuth(1);
});
});
diff --git a/src/lib/monitor_api.js b/src/lib/monitor_api.js
index da59cba..746c3c8 100644
--- a/src/lib/monitor_api.js
+++ b/src/lib/monitor_api.js
@@ -5,6 +5,19 @@ import { withAuth, requestAuth, checkAuthSession } from './auth_api';
export { requestAuth, checkAuthSession };
export { initAuthListeners, lockSession } from './auth_api';
+export const REQUEST_DEADLINES = Object.freeze({
+ imageMs: 15_000,
+ thumbnailMs: 15_000,
+ timelineImageMs: 15_000,
+ detailMs: 15_000,
+});
+
+const createDeadlineError = (deadlineMs) => {
+ const err = new Error(`deadline exceeded after ${deadlineMs}ms`);
+ err.code = 'deadline_exceeded';
+ return err;
+};
+
// Simple request queue to limit concurrent pipe connections
export class RequestQueue {
constructor(maxConcurrent = 3, maxPending = 200) {
@@ -17,7 +30,7 @@ export class RequestQueue {
}
async enqueue(fn, options = {}) {
- const { priority = 'normal', key = null, dedupe = true } = options;
+ const { priority = 'normal', key = null, dedupe = true, deadlineMs = null } = options;
if (dedupe && key !== null && key !== undefined) {
const existing = this.pendingByKey.get(key) || this.runningByKey.get(key);
@@ -34,6 +47,8 @@ export class RequestQueue {
}
let settled = false;
+ let state = 'pending';
+ let deadlineTimer = null;
let resolveRef;
let rejectRef;
const promise = new Promise((resolve, reject) => {
@@ -41,15 +56,34 @@ export class RequestQueue {
rejectRef = reject;
});
- const safeResolve = (value) => {
+ const settle = (kind, value) => {
if (settled) return;
settled = true;
- resolveRef(value);
- };
- const safeReject = (error) => {
- if (settled) return;
- settled = true;
- rejectRef(error);
+ if (deadlineTimer) {
+ clearTimeout(deadlineTimer);
+ deadlineTimer = null;
+ }
+
+ const wasRunning = state === 'running';
+ state = 'settled';
+ this.queue = this.queue.filter((item) => item !== entry);
+ if (entry.key !== null && entry.key !== undefined) {
+ this.pendingByKey.delete(entry.key);
+ this.runningByKey.delete(entry.key);
+ }
+ if (wasRunning) {
+ this.running = Math.max(0, this.running - 1);
+ }
+
+ if (kind === 'resolve') {
+ resolveRef(value);
+ } else {
+ rejectRef(value);
+ }
+
+ if (wasRunning) {
+ this.processNext();
+ }
};
const entry = {
@@ -58,37 +92,36 @@ export class RequestQueue {
cancelled: false,
promise,
run: async () => {
+ if (settled) return;
+ state = 'running';
if (entry.key !== null && entry.key !== undefined) {
this.pendingByKey.delete(entry.key);
this.runningByKey.set(entry.key, entry);
}
if (entry.cancelled) {
- safeReject(new Error('cancelled'));
+ settle('reject', new Error('cancelled'));
return;
}
try {
const result = await fn();
- safeResolve(result);
+ settle('resolve', result);
} catch (e) {
- safeReject(e);
- } finally {
- this.running--;
- if (entry.key !== null && entry.key !== undefined) {
- this.runningByKey.delete(entry.key);
- }
- this.processNext();
+ settle('reject', e);
}
},
cancel: () => {
entry.cancelled = true;
- if (entry.key !== null && entry.key !== undefined) {
- this.pendingByKey.delete(entry.key);
- this.runningByKey.delete(entry.key);
- }
- safeReject(new Error('cancelled'));
+ settle('reject', new Error('cancelled'));
}
};
+ if (Number.isFinite(deadlineMs) && deadlineMs > 0) {
+ deadlineTimer = setTimeout(() => {
+ entry.cancelled = true;
+ settle('reject', createDeadlineError(deadlineMs));
+ }, deadlineMs);
+ }
+
if (this.queue.length >= this.maxPending) {
const dropped = priority === 'high' ? this.queue.pop() : this.queue.shift();
if (dropped) dropped.cancel();
@@ -136,6 +169,7 @@ const imageQueue = new RequestQueue(3, 100);
const thumbnailQueue = new RequestQueue(6, 100);
// Timeline thumbnails should load in parallel to avoid long UI delays after pan/zoom
const timelineImageQueue = new RequestQueue(20, 800);
+const detailQueue = new RequestQueue(3, 100);
const imageRequestKey = (prefix, id, path) => {
if (id !== null && id !== undefined && id !== '') return `${prefix}:id:${id}`;
@@ -179,7 +213,6 @@ export const getTimeline = async (startTime, endTime, maxRecords = null) => {
params.maxRecords = maxRecords;
}
const records = await invoke('storage_get_timeline', params);
- console.log('[Timeline] Fetched records:', records?.length || 0, 'range:', new Date(startTime).toLocaleString(), '-', new Date(endTime).toLocaleString());
return records || [];
});
};
@@ -215,14 +248,14 @@ export const fetchImage = async (id, path = null) => {
}
return null;
});
- }, { priority: 'high', key });
+ }, { priority: 'high', key, deadlineMs: REQUEST_DEADLINES.imageMs });
};
/**
* 时间线缩略图专用获取(低优先级,使用 thumbnail API)
*/
export const fetchTimelineImage = async (id, path = null, options = {}) => {
- const { priority = 'normal', key = null } = options || {};
+ const { priority = 'normal', key = null, deadlineMs = REQUEST_DEADLINES.timelineImageMs } = options || {};
return timelineImageQueue.enqueue(async () => {
return withAuth(async () => {
try {
@@ -241,7 +274,7 @@ export const fetchTimelineImage = async (id, path = null, options = {}) => {
throw err;
}
});
- }, { priority, key });
+ }, { priority, key, deadlineMs });
};
export const clearTimelineImageQueue = () => {
@@ -261,7 +294,7 @@ export const fetchThumbnail = async (id, path = null) => {
}
return null;
});
- }, { priority: 'normal', key });
+ }, { priority: 'normal', key, deadlineMs: REQUEST_DEADLINES.thumbnailMs });
};
/**
@@ -405,9 +438,13 @@ export const getSoftDeleteQueueStatus = async () => {
};
export const getScreenshotDetails = async (id, path = null) => {
+ const key = imageRequestKey('detail', id, path);
try {
- const response = await withAuth(() => invoke('storage_get_screenshot_details', { id, path }));
- if (response.error) {
+ const response = await detailQueue.enqueue(
+ () => withAuth(() => invoke('storage_get_screenshot_details', { id, path })),
+ { priority: 'high', key, deadlineMs: REQUEST_DEADLINES.detailMs }
+ );
+ if (response?.error) {
console.error("Details error:", response.error);
return { error: response.error };
}
@@ -603,3 +640,17 @@ export const getSmartClusterWorkerStatus = async () => {
}
});
};
+
+export const getIndexHealth = async ({ refreshVector = false } = {}) => {
+ return withAuth(
+ () => invoke('storage_get_index_health', { refreshVector }),
+ { autoPrompt: true },
+ );
+};
+
+export const retryVectorIndexing = async (limit = 32) => {
+ return withAuth(
+ () => invoke('storage_retry_vector_indexing', { limit }),
+ { autoPrompt: true },
+ );
+};
diff --git a/src/lib/monitor_api.test.js b/src/lib/monitor_api.test.js
index 16eaf6f..826d31d 100644
--- a/src/lib/monitor_api.test.js
+++ b/src/lib/monitor_api.test.js
@@ -1,9 +1,13 @@
-import { describe, expect, it, vi } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { RequestQueue } from './monitor_api';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
describe('RequestQueue', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
it('limits concurrent task execution', async () => {
const queue = new RequestQueue(2, 20);
let running = 0;
@@ -65,4 +69,42 @@ describe('RequestQueue', () => {
releaseFirst('first');
await expect(first).resolves.toBe('first');
});
+
+ it('rejects pending tasks that exceed their deadline before running', async () => {
+ vi.useFakeTimers();
+ const queue = new RequestQueue(1, 10);
+ let releaseFirst;
+ const secondFn = vi.fn(() => Promise.resolve('second'));
+
+ const first = queue.enqueue(
+ () =>
+ new Promise((resolve) => {
+ releaseFirst = resolve;
+ }),
+ { deadlineMs: 1000 }
+ );
+ const second = queue.enqueue(secondFn, { deadlineMs: 50 });
+ const secondAssertion = expect(second).rejects.toMatchObject({ code: 'deadline_exceeded' });
+
+ await vi.advanceTimersByTimeAsync(51);
+ await secondAssertion;
+ expect(secondFn).not.toHaveBeenCalled();
+
+ releaseFirst('first');
+ await expect(first).resolves.toBe('first');
+ });
+
+ it('releases a running queue slot when a task exceeds its deadline', async () => {
+ vi.useFakeTimers();
+ const queue = new RequestQueue(1, 10);
+
+ const hung = queue.enqueue(() => new Promise(() => {}), { deadlineMs: 50 });
+ const hungAssertion = expect(hung).rejects.toMatchObject({ code: 'deadline_exceeded' });
+ const second = queue.enqueue(() => Promise.resolve('second'), { deadlineMs: 500 });
+
+ await vi.advanceTimersByTimeAsync(51);
+
+ await hungAssertion;
+ await expect(second).resolves.toBe('second');
+ });
});
diff --git a/src/lib/task_api.test.js b/src/lib/task_api.test.js
index 04b883f..fe19f5b 100644
--- a/src/lib/task_api.test.js
+++ b/src/lib/task_api.test.js
@@ -1,5 +1,15 @@
-import { describe, expect, it, vi } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
import { invoke } from '@tauri-apps/api/core';
+
+vi.mock('./auth_api', () => ({
+ withAuth: vi.fn(async (fn) => fn()),
+ requestAuth: vi.fn(),
+ checkAuthSession: vi.fn(),
+ initAuthListeners: vi.fn(),
+ lockSession: vi.fn(),
+}));
+
+import { withAuth } from './auth_api';
import {
getTasks,
getTaskScreenshots,
@@ -13,6 +23,21 @@ import {
} from './task_api';
describe('task_api', () => {
+ beforeEach(() => {
+ invoke.mockReset();
+ withAuth.mockClear();
+ });
+
+ const expectWithAuth = (callNumber, options) => {
+ const call = withAuth.mock.calls[callNumber - 1];
+ expect(call?.[0]).toEqual(expect.any(Function));
+ if (options === undefined) {
+ expect(call).toHaveLength(1);
+ } else {
+ expect(call?.[1]).toEqual(options);
+ }
+ };
+
it('calls getTasks with default payload', async () => {
invoke.mockResolvedValue([]);
@@ -26,6 +51,7 @@ describe('task_api', () => {
hideEntertainment: true,
hideSocial: true,
});
+ expectWithAuth(1);
});
it('calls getTaskScreenshots with defaults', async () => {
@@ -38,6 +64,7 @@ describe('task_api', () => {
page: 0,
pageSize: 50,
});
+ expectWithAuth(1);
});
it('calls removeTaskScreenshot with expected payload', async () => {
@@ -49,12 +76,14 @@ describe('task_api', () => {
taskId: 123,
screenshotId: 456,
});
+ expectWithAuth(1, { autoPrompt: true });
});
it('throws when runClustering returns error', async () => {
invoke.mockResolvedValue({ error: 'AUTH_REQUIRED' });
await expect(runClustering()).rejects.toThrow('AUTH_REQUIRED');
+ expectWithAuth(1, { autoPrompt: false });
});
it('sends clustering commands with provided params', async () => {
@@ -71,6 +100,8 @@ describe('task_api', () => {
});
expect(invoke).toHaveBeenNthCalledWith(2, 'monitor_set_clustering_interval', { interval: '1w' });
+ expectWithAuth(1, { autoPrompt: false });
+ expectWithAuth(2, { autoPrompt: true });
});
it('calls smart cluster summary commands with expected payloads', async () => {
@@ -96,5 +127,9 @@ describe('task_api', () => {
expect(invoke).toHaveBeenNthCalledWith(2, 'smart_cluster_get_summary', { clusterId: 7 });
expect(invoke).toHaveBeenNthCalledWith(3, 'smart_cluster_upsert_summary', { summary });
expect(invoke).toHaveBeenNthCalledWith(4, 'smart_cluster_delete_summary', { clusterId: 7 });
+ expectWithAuth(1);
+ expectWithAuth(2);
+ expectWithAuth(3, { autoPrompt: true });
+ expectWithAuth(4, { autoPrompt: true });
});
});