From 62cda6a5edbe3e5a414981bf740d918135e26513 Mon Sep 17 00:00:00 2001 From: White-NX <2454053557@qq.com> Date: Fri, 3 Jul 2026 21:39:45 +0800 Subject: [PATCH 1/9] frontend: add deadlines to image detail queues --- src/lib/monitor_api.api.test.js | 57 ++++++++++++++++++++ src/lib/monitor_api.js | 94 +++++++++++++++++++++++---------- src/lib/monitor_api.test.js | 44 ++++++++++++++- 3 files changed, 166 insertions(+), 29 deletions(-) diff --git a/src/lib/monitor_api.api.test.js b/src/lib/monitor_api.api.test.js index cf37e67..9d4b7a1 100644 --- a/src/lib/monitor_api.api.test.js +++ b/src/lib/monitor_api.api.test.js @@ -12,6 +12,8 @@ vi.mock('./auth_api', () => ({ import { fetchImage, fetchThumbnail, + fetchTimelineImage, + REQUEST_DEADLINES, searchScreenshots, listProcesses, getScreenshotDetails, @@ -29,6 +31,7 @@ describe('monitor_api command wrappers', () => { }); afterEach(() => { + vi.useRealTimers(); consoleErrorSpy.mockRestore(); }); @@ -115,6 +118,60 @@ describe('monitor_api command wrappers', () => { expect(invoke).toHaveBeenCalledWith('storage_get_image', { id: 42, path: null }); }); + 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 }); + }); + + 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 }); + }); + + 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 }); + }); + + 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 }); + }); + it('dedupes concurrent thumbnail requests by path', async () => { invoke.mockImplementation(async () => { await sleep(5); diff --git a/src/lib/monitor_api.js b/src/lib/monitor_api.js index da59cba..796c44b 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) => { - if (settled) return; - settled = true; - resolveRef(value); - }; - const safeReject = (error) => { + const settle = (kind, value) => { 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}`; @@ -215,14 +249,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 +275,7 @@ export const fetchTimelineImage = async (id, path = null, options = {}) => { throw err; } }); - }, { priority, key }); + }, { priority, key, deadlineMs }); }; export const clearTimelineImageQueue = () => { @@ -261,7 +295,7 @@ export const fetchThumbnail = async (id, path = null) => { } return null; }); - }, { priority: 'normal', key }); + }, { priority: 'normal', key, deadlineMs: REQUEST_DEADLINES.thumbnailMs }); }; /** @@ -405,9 +439,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 }; } 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'); + }); }); From dbf3d4127ced1b363ae03dbd9f320cc7641e0bbe Mon Sep 17 00:00:00 2001 From: White-NX <2454053557@qq.com> Date: Fri, 3 Jul 2026 22:17:25 +0800 Subject: [PATCH 2/9] reliability: add index health recovery safeguards --- monitor/monitor/__init__.py | 34 +++ monitor/monitor/worker_process.py | 151 +++++++++++- monitor/storage_client.py | 189 ++++++++++---- .../tests/test_monitor_command_dispatch.py | 72 ++++++ .../tests/test_monitor_worker_contracts.py | 50 ++++ monitor/tests/test_ocr_postprocess_queue.py | 85 +++++++ .../tests/test_storage_client_error_paths.py | 67 +++++ monitor/vector_store.py | 27 +- scripts/security-guards.cjs | 2 + src-tauri/src/commands/storage.rs | 178 +++++++++++++- src-tauri/src/lib.rs | 2 + src-tauri/src/monitor.rs | 230 ++++++++++++++++-- src-tauri/src/storage/screenshot.rs | 37 ++- src-tauri/src/storage/types.rs | 9 + src/App.jsx | 6 +- src/components/settings/FeaturesSection.jsx | 132 ++++++++++ src/lib/api_contracts.test.js | 14 +- src/lib/monitor_api.js | 14 ++ 18 files changed, 1221 insertions(+), 78 deletions(-) diff --git a/monitor/monitor/__init__.py b/monitor/monitor/__init__.py index 3b77b31..e1da58c 100644 --- a/monitor/monitor/__init__.py +++ b/monitor/monitor/__init__.py @@ -341,6 +341,40 @@ def _handle_command_impl(req: dict): status['ocr_stats'] = _ocr_worker.get_stats() return status + if cmd == 'index_health': + if not _ocr_worker: + return { + 'status': 'success', + 'worker_available': False, + 'worker_started': False, + 'stats': {}, + 'postprocess': None, + } + try: + refresh = bool(req.get('refresh', False)) + if hasattr(_ocr_worker, 'get_index_health'): + return _ocr_worker.get_index_health(refresh=refresh) + return { + 'status': 'success', + 'worker_available': True, + 'worker_started': None, + 'stats': _ocr_worker.get_stats() if hasattr(_ocr_worker, 'get_stats') else {}, + 'postprocess': None, + } + 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..2c00bb3 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,15 @@ 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": + send_response({ + "status": "success", + "stats": ocr_worker.get_stats(), + "postprocess": postprocess_queue.status_snapshot(), + }) + 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 +562,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/storage_client.py b/monitor/storage_client.py index 3f8dcb3..7497791 100644 --- a/monitor/storage_client.py +++ b/monitor/storage_client.py @@ -22,6 +22,31 @@ 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 + + +DEFAULT_REVERSE_IPC_TIMEOUT_SECS = _default_reverse_ipc_timeout_secs() + + +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 +141,40 @@ 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 + + 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 _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 +238,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,55 +248,95 @@ 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() + 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() + + 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): + self._remaining_deadline(deadline, command, timeout_secs, "connect") + handle = self._connect_persistent_handle() + 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") + if e.winerror not in PIPE_CLOSED_WINERRORS or attempt >= 1: + 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() + continue + + if response == {'status': 'error', 'error': 'Empty response'}: + self._close_persistent_handle() + if attempt < 1: 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() continue + return response - 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 - 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() + return self._timeout_response(e) except pywintypes.error as e: self._close_persistent_handle() return {'status': 'error', 'error': f'IPC error: {e}'} @@ -245,7 +344,15 @@ def _send_request(self, request: Dict[str, Any]) -> Dict[str, Any]: self._close_persistent_handle() 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]: """ 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_storage_client_error_paths.py b/monitor/tests/test_storage_client_error_paths.py index 363fd3e..d9392fb 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,68 @@ 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 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..7638231 100644 --- a/src-tauri/src/commands/storage.rs +++ b/src-tauri/src/commands/storage.rs @@ -63,9 +63,85 @@ 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 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), + "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 +186,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 +841,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/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>, pub pipe_name: Mutex>, @@ -53,6 +92,7 @@ pub struct MonitorState { pub stopping: AtomicBool, /// Prevents the monitor from restarting during migration tasks pub migration_lock: AtomicBool, + recovery: Mutex, python_ipc_client: AsyncMutex>, } @@ -77,6 +117,7 @@ impl MonitorState { game_mode_task: Mutex::new(None), stopping: AtomicBool::new(false), migration_lock: AtomicBool::new(false), + recovery: Mutex::new(MonitorRecoveryState::default()), python_ipc_client: AsyncMutex::new(None), } } @@ -119,6 +160,110 @@ use serde_json::Value; const MAX_MONITOR_COMMAND_PAYLOAD_BYTES: usize = 256 * 1024; +fn current_epoch_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +fn monitor_recovery_snapshot(state: &MonitorState) -> Value { + state + .recovery + .lock() + .unwrap_or_else(|e| e.into_inner()) + .to_json() +} + +fn stopped_monitor_status(state: &MonitorState) -> Value { + serde_json::json!({ + "paused": false, + "stopped": true, + "interval": 0, + "recovery": monitor_recovery_snapshot(state), + }) +} + +fn set_monitor_recovery_starting(state: &MonitorState) { + let mut recovery = state.recovery.lock().unwrap_or_else(|e| e.into_inner()); + recovery.state = "starting".to_string(); + recovery.restart_available = false; + recovery.last_error = None; +} + +fn set_monitor_recovery_running(state: &MonitorState) { + let mut recovery = state.recovery.lock().unwrap_or_else(|e| e.into_inner()); + recovery.state = "running".to_string(); + recovery.restart_available = false; + recovery.last_error = None; + recovery.last_exit_code = None; + recovery.last_crashed_at_ms = None; +} + +fn set_monitor_recovery_stopped(state: &MonitorState) { + let mut recovery = state.recovery.lock().unwrap_or_else(|e| e.into_inner()); + recovery.state = "stopped".to_string(); + recovery.restart_available = true; + recovery.last_error = None; + recovery.last_exit_code = None; + recovery.last_crashed_at_ms = None; +} + +fn set_monitor_recovery_failed(state: &MonitorState, error: String) -> Value { + let mut recovery = state.recovery.lock().unwrap_or_else(|e| e.into_inner()); + recovery.state = "failed".to_string(); + recovery.restart_available = true; + recovery.last_error = Some(error); + recovery.to_json() +} + +fn set_monitor_recovery_crashed( + state: &MonitorState, + exit_code: String, + error: Option, +) -> Value { + let mut recovery = state.recovery.lock().unwrap_or_else(|e| e.into_inner()); + recovery.state = "crashed".to_string(); + recovery.restart_available = true; + recovery.last_exit_code = Some(exit_code); + recovery.last_error = error; + recovery.last_crashed_at_ms = Some(current_epoch_ms()); + recovery.crash_count = recovery.crash_count.saturating_add(1); + recovery.to_json() +} + +fn cleanup_monitor_runtime_after_unexpected_exit(state: &MonitorState) { + { + let mut guard = state.reverse_ipc.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(ref mut server) = *guard { + server.stop(); + } + *guard = None; + } + { + let mut guard = state.pipe_name.lock().unwrap_or_else(|e| e.into_inner()); + *guard = None; + } + { + let mut guard = state.auth_token.lock().unwrap_or_else(|e| e.into_inner()); + *guard = None; + } + { + let mut guard = state + .reverse_pipe_name + .lock() + .unwrap_or_else(|e| e.into_inner()); + *guard = None; + } + { + let mut guard = state.job_handle.lock().unwrap_or_else(|e| e.into_inner()); + *guard = None; + } + if let Ok(mut guard) = state.python_ipc_client.try_lock() { + *guard = None; + } +} + // Windows error code for ERROR_PIPE_BUSY #[cfg(windows)] const ERROR_PIPE_BUSY: i32 = 231; @@ -994,6 +1139,7 @@ pub async fn start_monitor_impl( // Reset the stopping flag for a fresh start state.stopping.store(false, Ordering::SeqCst); + set_monitor_recovery_starting(&state); let stdout_cache: Arc>> = Arc::new(Mutex::new(Vec::new())); let stderr_cache: Arc>> = Arc::new(Mutex::new(Vec::new())); @@ -1490,8 +1636,14 @@ pub async fn start_monitor_impl( .code() .map(|c| c.to_string()) .unwrap_or_else(|| "unknown".to_string()); - let _ = - app_clone.emit("monitor-exited", serde_json::json!({"code": code})); + cleanup_monitor_runtime_after_unexpected_exit(&state); + let recovery = set_monitor_recovery_crashed(&state, code.clone(), None); + crate::refresh_tray_menu(&app_clone); + let _ = app_clone.emit("monitor-recovery", recovery.clone()); + let _ = app_clone.emit( + "monitor-exited", + serde_json::json!({"code": code, "recovery": recovery}), + ); } break; } @@ -1501,9 +1653,21 @@ pub async fn start_monitor_impl( drop(guard); // Don't emit monitor-exited during intentional stop if !state.stopping.load(Ordering::SeqCst) { + cleanup_monitor_runtime_after_unexpected_exit(&state); + let recovery = set_monitor_recovery_crashed( + &state, + "unknown".to_string(), + Some(e.to_string()), + ); + crate::refresh_tray_menu(&app_clone); + let _ = app_clone.emit("monitor-recovery", recovery.clone()); let _ = app_clone.emit( "monitor-exited", - serde_json::json!({"code": "unknown", "error": e.to_string()}), + serde_json::json!({ + "code": "unknown", + "error": e.to_string(), + "recovery": recovery, + }), ); } break; @@ -1522,6 +1686,7 @@ pub async fn start_monitor_impl( match connect_to_pipe(&pipe_name).await { Ok(_) => { // 管道可连接,说明服务已就绪 — 启动 Rust 截图循环 + set_monitor_recovery_running(&state); spawn_capture_loop(&app); crate::refresh_tray_menu(&app); return Ok("Monitor started".into()); @@ -1542,16 +1707,22 @@ pub async fn start_monitor_impl( .code() .map(|c| c.to_string()) .unwrap_or_else(|| "unknown".to_string()); - return Err(format!("Monitor exited during startup (code: {})", code)); + let message = format!("Monitor exited during startup (code: {})", code); + set_monitor_recovery_failed(&state, message.clone()); + return Err(message); } Ok(None) => {} Err(e) => { *guard = None; - return Err(format!("Monitor startup check failed: {}", e)); + let message = format!("Monitor startup check failed: {}", e); + set_monitor_recovery_failed(&state, message.clone()); + return Err(message); } } } else { - return Err("Monitor process handle missing during startup".into()); + let message = "Monitor process handle missing during startup".to_string(); + set_monitor_recovery_failed(&state, message.clone()); + return Err(message); } } @@ -1575,7 +1746,7 @@ pub async fn start_monitor_impl( } }; - Err(format!( + let message = format!( "Monitor startup timed out: {} | cwd: {} | script: {} | script_abs: {} | python: {} (exists: {}) | pipe: {} | stderr_tail: {}", last_error.unwrap_or_else(|| "pipe unavailable".to_string()), cwd, @@ -1585,7 +1756,9 @@ pub async fn start_monitor_impl( python_exists_for_error, pipe_name, stderr_tail - )) + ); + set_monitor_recovery_failed(&state, message.clone()); + Err(message) } #[tauri::command] @@ -1823,6 +1996,7 @@ pub async fn stop_monitor_impl( *guard = None; } + set_monitor_recovery_stopped(&state); crate::refresh_tray_menu(&app); let _ = app.emit("monitor-stopped", serde_json::json!({"intentional": true})); @@ -1893,16 +2067,16 @@ pub async fn resume_monitor( #[tauri::command] pub async fn get_monitor_status(state: State<'_, MonitorState>) -> Result { if state.stopping.load(Ordering::SeqCst) { - let stopped = serde_json::json!({ - "paused": false, - "stopped": true, - "interval": 0 - }); - return Ok(stopped.to_string()); + return Ok(stopped_monitor_status(&state).to_string()); } - match send_ipc_command_internal(&state, "status").await { - Ok(status) => Ok(status), + match forward_command_to_python(&state, serde_json::json!({ "command": "status" })).await { + Ok(mut status) => { + if let Some(obj) = status.as_object_mut() { + obj.insert("recovery".to_string(), monitor_recovery_snapshot(&state)); + } + Ok(status.to_string()) + } Err(e) => { // 如果进程未运行,则返回“stopped”而不是抛错,避免前端冷启动误报 let mut running = false; @@ -1924,12 +2098,7 @@ pub async fn get_monitor_status(state: State<'_, MonitorState>) -> Result 0); + } } diff --git a/src-tauri/src/storage/screenshot.rs b/src-tauri/src/storage/screenshot.rs index 0c8f743..6ad709e 100644 --- a/src-tauri/src/storage/screenshot.rs +++ b/src-tauri/src/storage/screenshot.rs @@ -9,7 +9,7 @@ use std::sync::atomic::Ordering; use super::types::RawScreenshotRow; use super::{ - DeleteQueueStatus, DensityBucket, OcrResultInput, QueueScreenshotCandidate, + DeleteQueueStatus, DensityBucket, IndexStorageStats, OcrResultInput, QueueScreenshotCandidate, SaveScreenshotRequest, SaveScreenshotResponse, ScreenshotRecord, SoftDeleteResult, SoftDeleteScreenshotsResult, StorageState, }; @@ -940,6 +940,41 @@ impl StorageState { .map_err(|e| format!("Failed to count screenshots: {}", e)) } + pub fn count_active_screenshots(&self) -> Result { + let guard = self.get_connection_named("count_active_screenshots")?; + let conn = guard + .as_ref() + .ok_or_else(|| "Database connection is None".to_string())?; + conn.query_row( + "SELECT COUNT(*) FROM screenshots WHERE is_deleted = 0", + [], + |row| row.get::<_, i64>(0), + ) + .map_err(|e| format!("Failed to count active screenshots: {}", e)) + } + + pub fn count_active_ocr_rows(&self) -> Result { + let guard = self.get_connection_named("count_active_ocr_rows")?; + let conn = guard + .as_ref() + .ok_or_else(|| "Database connection is None".to_string())?; + conn.query_row( + "SELECT COUNT(*) FROM ocr_results WHERE is_deleted = 0", + [], + |row| row.get::<_, i64>(0), + ) + .map_err(|e| format!("Failed to count active OCR rows: {}", e)) + } + + pub fn get_index_storage_stats(&self) -> Result { + Ok(IndexStorageStats { + screenshots_count: self.count_active_screenshots()?, + ocr_rows_count: self.count_active_ocr_rows()?, + smart_cluster_pending_count: self.count_smart_cluster_pending()?, + delete_queue: self.get_delete_queue_status()?, + }) + } + /// Get screenshot density (counts per time bucket) within a time range. /// No decryption or joins - extremely fast index-only scan. pub fn get_screenshot_density( diff --git a/src-tauri/src/storage/types.rs b/src-tauri/src/storage/types.rs index 83fe080..06fb0af 100644 --- a/src-tauri/src/storage/types.rs +++ b/src-tauri/src/storage/types.rs @@ -359,6 +359,15 @@ pub struct DeleteQueueStatus { pub running: bool, } +/// Counts used by the index health panel. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IndexStorageStats { + pub screenshots_count: i64, + pub ocr_rows_count: i64, + pub smart_cluster_pending_count: i64, + pub delete_queue: DeleteQueueStatus, +} + /// Internal batch row for screenshot queue processing. #[derive(Debug, Clone)] pub struct QueueScreenshotCandidate { diff --git a/src/App.jsx b/src/App.jsx index 7dc84b9..64390e6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -742,7 +742,11 @@ function App() { const payload = event?.payload || {}; const code = payload.code || 'unknown'; const errMsg = payload.error ? `; ${payload.error}` : ''; - const message = `子服务已退出(code: ${code}${errMsg})`; + 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'; diff --git a/src/components/settings/FeaturesSection.jsx b/src/components/settings/FeaturesSection.jsx index 68d9f6c..2313a82 100644 --- a/src/components/settings/FeaturesSection.jsx +++ b/src/components/settings/FeaturesSection.jsx @@ -5,6 +5,7 @@ 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 { getIndexHealth, retryVectorIndexing } from '../../lib/monitor_api'; export default function FeaturesSection({ monitorStatus }) { const { t } = useTranslation(); @@ -20,6 +21,10 @@ export default function FeaturesSection({ monitorStatus }) { const [clusteringStatus, setClusteringStatus] = useState(null); const [rangeStart, setRangeStart] = useState(''); const [rangeEnd, setRangeEnd] = useState(''); + const [indexHealth, setIndexHealth] = useState(null); + const [indexHealthLoading, setIndexHealthLoading] = useState(false); + const [indexHealthError, setIndexHealthError] = useState(null); + const [vectorRetrying, setVectorRetrying] = useState(false); // Smart Cluster state const [scModelAvailable, setScModelAvailable] = useState(false); @@ -94,6 +99,26 @@ export default function FeaturesSection({ monitorStatus }) { refreshClusteringStatus(); }, [monitorStatus]); + const loadIndexHealth = 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); + } + }; + + useEffect(() => { + if (monitorStatus === 'running') { + loadIndexHealth({ refreshVector: false }); + } + }, [monitorStatus]); + // Smart Cluster: check model availability + poll status const refreshSmartClusterModel = async () => { try { @@ -306,11 +331,41 @@ export default function FeaturesSection({ monitorStatus }) { } }; + const handleRetryVectorIndexing = 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); + } + }; + const formatSize = (sizeStr) => { if (!sizeStr) return '-'; return sizeStr; }; + const formatIndexCount = (value, fallback = '—') => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value.toLocaleString(); + } + return fallback; + }; + + const vectorRetryBacklog = indexHealth?.pending_retry_queue_count; + const deleteQueuePending = 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 lastClusteringRunLabel = clusteringStatus?.config?.last_run ? new Date(clusteringStatus.config.last_run * 1000).toLocaleString() : t('tasks.never'); @@ -476,6 +531,83 @@ export default function FeaturesSection({ monitorStatus }) { + {/* 索引健康 */} +
+
+
+

+ + {t('settings.features.management.indexHealth.label', '索引健康')} +

+

+ {t('settings.features.management.indexHealth.description', '截图、OCR、向量索引和后台重试队列的当前状态')} +

+
+
+ + +
+
+ +
+
+

{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)}

+
+
+ + {(indexHealthError || indexHealth?.monitor_error || lastIndexingError) && ( +
+ +
+

{indexHealthError || indexHealth?.monitor_error || lastIndexingError}

+ {lastIndexingErrorAt && ( +

{lastIndexingErrorAt}

+ )} +
+
+ )} +
+ {/* 智能聚类 (Smart Cluster) */}
diff --git a/src/lib/api_contracts.test.js b/src/lib/api_contracts.test.js index b2728a4..fd111b6 100644 --- a/src/lib/api_contracts.test.js +++ b/src/lib/api_contracts.test.js @@ -13,8 +13,10 @@ import { classifyDebug, deleteRecordsByTimeRange, deleteScreenshot, + getIndexHealth, getSmartClusterWorkerStatus, removeLocalAnchorsByProcess, + retryVectorIndexing, } from './monitor_api'; import { createSmartCluster, @@ -40,7 +42,9 @@ describe('API contract payloads', () => { .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 +55,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 +75,12 @@ 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, + }); }); it('sends task and natural-language clustering payloads', async () => { diff --git a/src/lib/monitor_api.js b/src/lib/monitor_api.js index 796c44b..9b406f9 100644 --- a/src/lib/monitor_api.js +++ b/src/lib/monitor_api.js @@ -641,3 +641,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 }, + ); +}; From c94b71a8402d45da131c1ecb2160c4d567f871cd Mon Sep 17 00:00:00 2001 From: White-NX <2454053557@qq.com> Date: Fri, 3 Jul 2026 22:48:01 +0800 Subject: [PATCH 3/9] reliability: guard reverse ipc reconnect storms --- monitor/monitor/__init__.py | 19 +- monitor/monitor/worker_process.py | 9 + monitor/storage_client.py | 213 +++++++++++++++++- .../tests/test_storage_client_error_paths.py | 101 +++++++++ monitor/tests/test_storage_client_ipc.py | 2 +- src-tauri/src/commands/storage.rs | 10 + src/components/settings/FeaturesSection.jsx | 19 ++ 7 files changed, 362 insertions(+), 11 deletions(-) diff --git a/monitor/monitor/__init__.py b/monitor/monitor/__init__.py index e1da58c..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 @@ -342,6 +353,7 @@ def _handle_command_impl(req: dict): return status if cmd == 'index_health': + storage_ipc = _storage_ipc_health_snapshot() if not _ocr_worker: return { 'status': 'success', @@ -349,17 +361,22 @@ def _handle_command_impl(req: dict): 'worker_started': False, 'stats': {}, 'postprocess': None, + 'storage_ipc': storage_ipc, } try: refresh = bool(req.get('refresh', False)) if hasattr(_ocr_worker, 'get_index_health'): - return _ocr_worker.get_index_health(refresh=refresh) + 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) diff --git a/monitor/monitor/worker_process.py b/monitor/monitor/worker_process.py index 2c00bb3..b2e3c32 100644 --- a/monitor/monitor/worker_process.py +++ b/monitor/monitor/worker_process.py @@ -449,10 +449,19 @@ def send_response(response: Dict[str, Any]): 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) diff --git a/monitor/storage_client.py b/monitor/storage_client.py index 7497791..a03c831 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__) @@ -31,7 +32,72 @@ def _default_reverse_ipc_timeout_secs() -> float: 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): @@ -143,6 +209,16 @@ def __init__(self, pipe_name: str): 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() @@ -156,6 +232,113 @@ def _timeout_response(self, exc: ReverseIpcTimeoutError) -> Dict[str, Any]: '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: @@ -260,6 +443,10 @@ def _send_request(self, request: Dict[str, Any], timeout: Optional[float] = None timeout_event = threading.Event() try: + 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: @@ -279,16 +466,10 @@ def _send_request(self, request: Dict[str, Any], timeout: Optional[float] = None watchdog_timer.daemon = True watchdog_timer.start() - 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): 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) @@ -312,7 +493,12 @@ def _send_request(self, request: Dict[str, Any], timeout: Optional[float] = None except pywintypes.error as e: if timeout_event.is_set(): raise ReverseIpcTimeoutError(command, timeout_secs, phase="watchdog") - if e.winerror not in PIPE_CLOSED_WINERRORS or attempt >= 1: + 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", @@ -320,28 +506,37 @@ def _send_request(self, request: Dict[str, Any], timeout: Optional[float] = None 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: + if attempt < 1 and self._can_retry_after_send(command): logger.warning( "[storage_client] persistent pipe returned empty response command=%s; reconnecting and retrying once", request.get('command'), ) + self._sleep_before_retry(attempt, 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") 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: if watchdog_timer is not None: diff --git a/monitor/tests/test_storage_client_error_paths.py b/monitor/tests/test_storage_client_error_paths.py index d9392fb..07bf88b 100644 --- a/monitor/tests/test_storage_client_error_paths.py +++ b/monitor/tests/test_storage_client_error_paths.py @@ -197,3 +197,104 @@ def slow_read(_handle): 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..95b6774 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 diff --git a/src-tauri/src/commands/storage.rs b/src-tauri/src/commands/storage.rs index 7638231..6b93068 100644 --- a/src-tauri/src/commands/storage.rs +++ b/src-tauri/src/commands/storage.rs @@ -88,6 +88,14 @@ fn compose_index_health_response( .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"))); @@ -132,6 +140,8 @@ fn compose_index_health_response( .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), diff --git a/src/components/settings/FeaturesSection.jsx b/src/components/settings/FeaturesSection.jsx index 2313a82..7351858 100644 --- a/src/components/settings/FeaturesSection.jsx +++ b/src/components/settings/FeaturesSection.jsx @@ -365,6 +365,18 @@ export default function FeaturesSection({ monitorStatus }) { 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 lastClusteringRunLabel = clusteringStatus?.config?.last_run ? new Date(clusteringStatus.config.last_run * 1000).toLocaleString() @@ -593,6 +605,13 @@ export default function FeaturesSection({ monitorStatus }) {

{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) && ( From 7b4423b1dd5e2dec0ab28b44544b940f9b5bfdaf Mon Sep 17 00:00:00 2001 From: White-NX <2454053557@qq.com> Date: Sat, 4 Jul 2026 00:12:08 +0800 Subject: [PATCH 4/9] frontend: add MCP agent skill setup --- .gitignore | 1 + ai_embedding/skill.md | 244 ------------------ monitor/ocr_data.db | Bin 180224 -> 0 bytes src-tauri/src/mcp_server.rs | 25 +- src-tauri/tauri.conf.json | 3 +- .../settings/AiEmbeddingSection.jsx | 64 ++++- src/i18n/locales/en.json | 10 + src/i18n/locales/zh-CN.json | 10 + 8 files changed, 88 insertions(+), 269 deletions(-) delete mode 100644 ai_embedding/skill.md delete mode 100644 monitor/ocr_data.db 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/ocr_data.db b/monitor/ocr_data.db deleted file mode 100644 index 3070193b14adf785cf27e6a0a005fd1757f5f176..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 180224 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 XRG;F8RFyYV$GNb`6QLnV 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/tauri.conf.json b/src-tauri/tauri.conf.json index 5bbee63..66a3a1e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -36,8 +36,7 @@ "icons/icon.ico" ], "resources": { - "pre-bundle": ".", - "../ai_embedding": "ai_embedding" + "pre-bundle": "." }, "windows": { "nsis": { diff --git a/src/components/settings/AiEmbeddingSection.jsx b/src/components/settings/AiEmbeddingSection.jsx index 7b2240a..afe78b5 100644 --- a/src/components/settings/AiEmbeddingSection.jsx +++ b/src/components/settings/AiEmbeddingSection.jsx @@ -7,6 +7,9 @@ import { Dialog } from '../Dialog'; import { ConfirmDialog } from '../ConfirmDialog'; import { withAuth } from '../../lib/auth_api'; +const AGENT_SKILL_NAME = 'carbonpaper-memory'; +const AGENT_SKILL_REPO = 'https://github.com/White-NX/carbonPaperSkill'; + export default function AiEmbeddingSection() { const { t } = useTranslation(); const [enabled, setEnabled] = useState(() => localStorage.getItem('mcpEnabled') === 'true'); @@ -33,6 +36,7 @@ export default function AiEmbeddingSection() { const [confirmText, setConfirmText] = useState(''); const [tokenCopied, setTokenCopied] = useState(false); + const [agentPromptCopied, setAgentPromptCopied] = useState(false); // Token reset confirmation const [showResetConfirm, setShowResetConfirm] = useState(false); @@ -358,6 +362,21 @@ export default function AiEmbeddingSection() { } }; + const handleCopyAgentSetupPrompt = async () => { + const endpoint = `http://localhost:${port}/mcp`; + const prompt = t('settings.ai_embedding.agent_setup.prompt', { + skillName: AGENT_SKILL_NAME, + repo: AGENT_SKILL_REPO, + endpoint, + }); + try { + await navigator.clipboard.writeText(prompt); + setAgentPromptCopied(true); + } catch (e) { + setError(String(e)); + } + }; + const normalizedServiceState = enabled ? (running ? 'running' : serviceState || 'pending_auth') : 'disabled'; @@ -514,7 +533,50 @@ export default function AiEmbeddingSection() { )} - {/* Row 3: Connection info */} + {/* Row 3: Agent setup */} + {enabled && ( + <> +
+
+
+ +
+

{t('settings.ai_embedding.agent_setup.description')}

+
+
+ {t('settings.ai_embedding.agent_setup.skill')}{' '} + {AGENT_SKILL_NAME} +
+
+ {t('settings.ai_embedding.agent_setup.source')}{' '} + {AGENT_SKILL_REPO} +
+
+ {t('settings.ai_embedding.agent_setup.endpoint')}{' '} + POST http://localhost:{port}/mcp +
+
+ {t('settings.ai_embedding.connection_info.auth_header')}{' '} + Authorization: Bearer <CarbonPaper token> +
+
+ {agentPromptCopied && ( +

{t('settings.ai_embedding.agent_setup.copied')}

+ )} +
+
+ +
+ + )} + + {/* Row 4: Connection info */} {enabled && running && ( <>
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1928524..6d5afaa 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -419,6 +419,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. The repository may be private; if this environment lacks access, use configured GitHub credentials or ask me for access.\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.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index be8dd79..907cf95 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -419,6 +419,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 环境中。该仓库可能是私有仓库;如果当前环境没有访问权限,请使用已配置的 GitHub 凭据或向我请求授权。\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 服务返回结果中的标记内容。", From 8d7a6d94ebb8dfc45603614725ac671c9189916d Mon Sep 17 00:00:00 2001 From: White-NX <2454053557@qq.com> Date: Mon, 6 Jul 2026 15:48:08 +0800 Subject: [PATCH 5/9] monitor: gate clustering on idle state --- monitor/smart_cluster_worker.py | 5 +- monitor/storage_client.py | 4 +- monitor/task_clustering.py | 17 +++++ .../tests/test_smart_cluster_worker_idle.py | 27 +++++++ monitor/tests/test_storage_client_ipc.py | 15 ++++ .../test_task_clustering_scheduler_paths.py | 74 +++++++++++++++++++ 6 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 monitor/tests/test_smart_cluster_worker_idle.py 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 a03c831..12b9fc1 100644 --- a/monitor/storage_client.py +++ b/monitor/storage_client.py @@ -751,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_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_ipc.py b/monitor/tests/test_storage_client_ipc.py index 95b6774..5a65a5b 100644 --- a/monitor/tests/test_storage_client_ipc.py +++ b/monitor/tests/test_storage_client_ipc.py @@ -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( From b77216ab73b13a4938b45bca312fdf40f50fb19e Mon Sep 17 00:00:00 2001 From: White-NX <2454053557@qq.com> Date: Mon, 6 Jul 2026 15:49:06 +0800 Subject: [PATCH 6/9] frontend: extract app lifecycle hooks --- src/App.jsx | 1129 ++++------------------------ src/hooks/useAppNotifications.js | 146 ++++ src/hooks/useAppTheme.js | 23 + src/hooks/useAppWindowState.js | 147 ++++ src/hooks/useAuthSession.js | 85 +++ src/hooks/useCriticalErrors.js | 16 + src/hooks/useMonitorLifecycle.js | 207 +++++ src/hooks/usePythonEnvironment.js | 84 +++ src/hooks/useSelectedSnapshot.js | 166 ++++ src/hooks/useStartupWizards.js | 131 ++++ src/hooks/useTauriEventListener.js | 45 ++ src/hooks/useUpdateManager.js | 73 ++ 12 files changed, 1256 insertions(+), 996 deletions(-) create mode 100644 src/hooks/useAppNotifications.js create mode 100644 src/hooks/useAppTheme.js create mode 100644 src/hooks/useAppWindowState.js create mode 100644 src/hooks/useAuthSession.js create mode 100644 src/hooks/useCriticalErrors.js create mode 100644 src/hooks/useMonitorLifecycle.js create mode 100644 src/hooks/usePythonEnvironment.js create mode 100644 src/hooks/useSelectedSnapshot.js create mode 100644 src/hooks/useStartupWizards.js create mode 100644 src/hooks/useTauriEventListener.js create mode 100644 src/hooks/useUpdateManager.js diff --git a/src/App.jsx b/src/App.jsx index 64390e6..23efae5 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,21 +1,9 @@ -import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import { - Moon, Sun, Settings, Bell, Terminal, Layout, Minus, Square, X, Copy, Loader2, Monitor, Clock, - WifiOff, Play, Search as SearchIcon, Info as InfoIcon, Route, PackageOpen -} from 'lucide-react'; -import { getCurrentWindow } from '@tauri-apps/api/window'; -import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; -import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification'; +import React, { useCallback, useEffect, useState } from 'react'; import Timeline from './components/Timeline'; -import { InspectorImage } from './components/InspectorImage'; -import { SearchBox } from './components/SearchBox'; -import { AdvancedSearch } from './components/AdvancedSearch'; import SettingsDialog from './components/settings/SettingsDialog'; import Mask from './components/Mask'; import AuthMask from './components/AuthMask'; import SecurityAlertMask from './components/SecurityAlertMask'; - import ExtensionSetupWizard from './components/ExtensionSetupWizard'; import ClusteringSetupWizard from './components/ClusteringSetupWizard'; import SmartClusterSetupWizard from './components/SmartClusterSetupWizard'; @@ -26,17 +14,22 @@ import { NotificationToast, NotificationPanel } from './components/Notifications import ErrorWindow from './components/ErrorWindow'; import HmacMigrationDialog from './components/HmacMigrationDialog'; import StartupVacuumDialog from './components/StartupVacuumDialog'; -import { getScreenshotDetails, fetchImage, deleteScreenshot, deleteRecordsByTimeRange } from './lib/monitor_api'; -import { checkForUpdate, downloadAndInstallUpdate } from './lib/update_api'; +import { deleteScreenshot, deleteRecordsByTimeRange } from './lib/monitor_api'; import { UpdateModal } from './components/UpdateModal'; -import { useDelayedClusteringSetupRunner } from './hooks/useDelayedClusteringSetupRunner'; -import { withAuth } from './lib/auth_api'; +import { useAppTheme } from './hooks/useAppTheme'; +import { useAppWindowActions, usePowerSavingState, useWindowMaximizedState } from './hooks/useAppWindowState'; +import { useAppNotifications } from './hooks/useAppNotifications'; +import { useAuthSession } from './hooks/useAuthSession'; +import { useCriticalErrors } from './hooks/useCriticalErrors'; +import { useMonitorLifecycle } from './hooks/useMonitorLifecycle'; +import { usePythonEnvironment } from './hooks/usePythonEnvironment'; +import { useSelectedSnapshot, normalizeTimestampToMs } from './hooks/useSelectedSnapshot'; +import { useStartupWizards } from './hooks/useStartupWizards'; +import { useUpdateManager } from './hooks/useUpdateManager'; function App() { - // Disable context menu for Tauri production feel useEffect(() => { const handleContextMenu = (e) => { - // Allow context menu only on input fields if needed, or disable globally: if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return; e.preventDefault(); return false; @@ -47,278 +40,119 @@ function App() { }; }, []); - 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; // Default to dark for IDE theme - }); - const [showSettings, setShowSettings] = useState(false); - const [autoStartMonitor, setAutoStartMonitor] = useState(() => { - if (typeof window === 'undefined') return true; - const saved = localStorage.getItem('autoStartMonitor'); - return saved === null ? true : saved === 'true'; - }); - const [autoStartSuppressed, setAutoStartSuppressed] = useState(false); - - // Power Saving Mode State (managed by Rust backend) - 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]); - - // Listen for power-saving-changed events from Rust backend - useEffect(() => { - let unlisten; - const setup = async () => { - unlisten = await listen('power-saving-changed', (event) => { - const payload = event.payload || {}; - // active = true means power saving is active (AC disconnected) - setPowerSavingSuppressed(payload.active === true); - }); - - // Fetch initial status from Rust - try { - const status = await invoke('get_power_saving_status'); - setPowerSavingMode(status.enabled !== false); - setPowerSavingSuppressed(status.active === true); - } catch (err) { - console.warn('Failed to get initial power saving status:', err); - } - }; - setup(); - return () => { - if (unlisten) unlisten(); - }; - }, []); - - // Window focus tracking for power saving SQL pause - useEffect(() => { - const appWindow = getCurrentWindow(); - const unlistenFocus = appWindow.listen('tauri://focus', () => setWindowFocused(true)); - const unlistenBlur = appWindow.listen('tauri://blur', () => setWindowFocused(false)); - return () => { - unlistenFocus.then(f => f()); - unlistenBlur.then(f => f()); - }; - }, []); - - // Windows Hello Authentication State - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [authError, setAuthError] = useState(null); - - const [showExtensionSetup, setShowExtensionSetup] = useState(false); - const [showClusteringSetup, setShowClusteringSetup] = useState(false); - const [showSmartClusterSetup, setShowSmartClusterSetup] = useState(false); - const [sessionTimeout, setSessionTimeout] = useState(() => { - const saved = localStorage.getItem('sessionTimeout'); - return saved ? parseInt(saved, 10) : 900; // 默认 15 分钟 - }); - - // Selected Timeline Event State - 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 [backendStatus, setBackendStatus] = useState('unknown'); // 'online' | 'offline' | 'waiting' - const [monitorPaused, setMonitorPaused] = useState(false); - const [backendError, setBackendError] = useState(''); - const backendStatusRef = useRef('unknown'); - const backendStartAtRef = useRef(null); - const lastBackendErrorRef = useRef(''); - const [activeTab, setActiveTab] = useState('preview'); // 'preview' | 'advanced-search' | 'smart-cluster' + const [activeTab, setActiveTab] = useState('preview'); const [sidebarExpanded, setSidebarExpanded] = useState(false); const [searchMode, setSearchMode] = useState('ocr'); const [advancedSearchParams, setAdvancedSearchParams] = useState({ query: '', mode: 'ocr', refreshKey: Date.now() }); - const [timelineRefreshKey, setTimelineRefreshKey] = useState(0); - - // debug - const [pythonVersion, setPythonVersion] = useState(null); - - // Dependency sync state - const [depsNeedUpdate, setDepsNeedUpdate] = useState(false); - const [depsSyncing, setDepsSyncing] = useState(false); - const [depsCheckDone, setDepsCheckDone] = useState(false); - - // Model file check state - const [modelsNeedDownload, setModelsNeedDownload] = useState(false); - const [missingModels, setMissingModels] = useState(null); - - // Critical error overlay state - const [criticalErrors, setCriticalErrors] = useState([]); - const [criticalErrorLogPath, setCriticalErrorLogPath] = useState(''); - - // State to trigger timeline jumps - const [timelineJump, setTimelineJump] = useState(null); // { time: number, ts: number } - - const normalizeTimestampToMs = useCallback((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; - }, []); - - useEffect(() => { - if (!selectedEvent) { - setSelectedDetails(null); - setSelectedImageSrc(null); - setLastError(null); - return; - } - - console.log("Loading details for event:", selectedEvent); - setIsLoadingDetails(true); - setLastError(null); - setSelectedImageSrc(null); // Reset immediately - - let cancelled = false; - - const loadData = async () => { - try { - const targetId = selectedEvent.id === -1 ? null : selectedEvent.id; - // Support both 'path' and 'image_path' field names for compatibility - const targetPath = selectedEvent.path || selectedEvent.image_path; - - console.log("Loading with targetId:", targetId, "targetPath:", targetPath); - - // Load details and image in parallel - const [det, img] = await Promise.all([ - getScreenshotDetails(targetId, targetPath), - fetchImage(targetId, targetPath), - ]); - - if (cancelled) return; - - console.log("Received details:", det); - - if (det && det.error) { - throw new Error(det.error); - } - setSelectedDetails(det); - - // 仅 NL 搜索结果跳转时,若元数据时间与 DB 权威时间偏差较大则修正 - 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() }); - } - } - } - - console.log("Received image:", img ? "base64 data received" : "null"); - - 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]); - // Construct boxes for InspectorOverlay from OCR results - // PaddleOCR returns box as [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] (四个角点) - // 需要计算包围盒的最小/最大 x、y 值 - const ocrBoxes = (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); // 过滤掉 null 值 + const { darkMode, setDarkMode } = useAppTheme(); + const { powerSavingMode, setPowerSavingMode, powerSavingSuppressed, windowFocused } = usePowerSavingState(); + const isMaximized = useWindowMaximizedState(); + const { minimize, toggleMaximize, hideToTray, restartApp, exitApp } = useAppWindowActions(); + const { + showNotifications, + setShowNotifications, + notifications, + toastNotifications, + pushNotification, + dismissNotification, + handleToastClose, + clearNotifications, + securityAlert, + setSecurityAlert, + formatErrorDetails, + reportBackendError, + resetBackendErrorDedupe, + } = useAppNotifications(); + const { + isAuthenticated, + authError, + setAuthError, + sessionTimeout, + setSessionTimeout, + handleAuthSuccess, + handleLockSession, + } = useAuthSession(); + const { criticalErrors, criticalErrorLogPath } = useCriticalErrors(); + const { + pythonVersion, + depsNeedUpdate, + depsSyncing, + depsCheckDone, + modelsNeedDownload, + missingModels, + refreshPythonVersion, + handleDepsSync, + handleModelsDownloadComplete, + } = usePythonEnvironment(); + const { + autoStartMonitor, + setAutoStartMonitor, + handleManualStartMonitor, + handleManualStopMonitor, + backendStatus, + monitorPaused, + backendError, + handleStartBackend, + handlePauseMonitor, + handleResumeMonitor, + } = useMonitorLifecycle({ + pythonVersion, + depsNeedUpdate, + depsSyncing, + depsCheckDone, + modelsNeedDownload, + powerSavingSuppressed, + formatErrorDetails, + reportBackendError, + resetBackendErrorDedupe, + }); + const { + showExtensionSetup, + showClusteringSetup, + showSmartClusterSetup, + handleExtensionSetupComplete, + handleClusteringSetupComplete, + handleSmartClusterSetupComplete, + } = useStartupWizards({ + backendStatus, + isAuthenticated, + setActiveTab, + pushNotification, + }); + const { + selectedEvent, + setSelectedEvent, + selectedDetails, + selectedImageSrc, + isLoadingDetails, + lastError, + highlightedEventId, + setHighlightedEventId, + timelineJump, + setTimelineJump, + timelineRefreshKey, + ocrBoxes, + clearSelection, + bumpTimelineRefresh, + } = useSelectedSnapshot(); + const { + updateModalVisible, + updateInfo, + updateDownloading, + updateDownloadProgress, + updateDownloadError, + setUpdateModalVisible, + handleDownloadUpdate, + handleLater, + } = useUpdateManager(); const handleCopyText = (text) => { navigator.clipboard.writeText(text); }; - const handleHideToTray = async () => { - await getCurrentWindow().hide(); - - // Send notification - let permissionGranted = await isPermissionGranted(); - if (!permissionGranted) { - const permission = await requestPermission(); - permissionGranted = permission === 'granted'; - } - if (permissionGranted) { - sendNotification({ - title: 'Carbonpaper', - body: '程序已最小化到系统托盘,点击托盘图标可恢复窗口' - }); - } - }; - const handleGlobalClick = useCallback((event) => { - // Clear highlight when clicking outside interactive nodes that opt-out const target = event.target; if (target && target.closest && target.closest('[data-keep-selection]')) { return; @@ -326,650 +160,9 @@ function App() { if (highlightedEventId !== null) { setHighlightedEventId(null); } - }, [highlightedEventId]); - - // Notification System - const [showNotifications, setShowNotifications] = useState(false); - const [notifications, setNotifications] = useState([]); - const [hiddenToastIds, setHiddenToastIds] = useState(() => new Set()); - - 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)); - }, []); - - // Security Alert Mask - const [securityAlert, setSecurityAlert] = useState(null); - - useEffect(() => { - let unlisten; - const setup = async () => { - unlisten = await listen('security-alert', (event) => { - const payload = event.payload || {}; - setSecurityAlert({ - code: payload.code, - message: payload.message, - detail: payload.detail, - }); - }); - }; - setup(); - return () => { - if (unlisten) unlisten(); - }; - }, []); - - useEffect(() => { - let unlisten; - const setup = async () => { - unlisten = await listen('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(), - }); - }); - }; - setup(); - return () => { - if (unlisten) unlisten(); - }; - }, [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]); - - // 检查 Windows Hello 认证状态 - 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); - }, []); - - - - // Check if extension setup wizard should be shown after auth - 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]); - - // 锁定会话回调 - const handleLockSession = useCallback(() => { - setIsAuthenticated(false); - }, []); - + }, [highlightedEventId, setHighlightedEventId]); - - // Extension setup wizard callback - const handleExtensionSetupComplete = useCallback(() => { - setShowExtensionSetup(false); - }, []); - - // Check if clustering setup wizard should be shown (after Extension wizard) - 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]); - - // Check if smart cluster setup wizard should be shown (after clustering wizard) - 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]); - - const handleSmartClusterSetupComplete = useCallback((enabled) => { - setShowSmartClusterSetup(false); - if (enabled) { - setActiveTab('smart-cluster'); - } - }, []); - - // Background thumbnail warmup after auth - useEffect(() => { - if (backendStatus !== 'online' || !isAuthenticated) return; - let cancelled = false; - console.log('[Warmup] Starting background thumbnail warmup...'); - withAuth(() => invoke('storage_warmup_thumbnails')) - .then((result) => { - if (!cancelled) { - const progress = result?.progress || {}; - if (result?.started || result?.running) { - console.log(`[Warmup] Background thumbnail warmup running — processed: ${progress.processed ?? 0}/${progress.total ?? 0}`); - } else { - console.log(`[Warmup] Thumbnail warmup skipped — cached: ${Boolean(result?.cached)}`); - } - } - }) - .catch((err) => console.warn('[Warmup] Thumbnail warmup failed:', err)); - return () => { cancelled = true; }; - }, [backendStatus, isAuthenticated]); - - const closeClusteringSetup = useCallback(() => { - setShowClusteringSetup(false); - }, []); - - const handleClusteringSetupComplete = useDelayedClusteringSetupRunner({ - onClose: closeClusteringSetup, - pushNotification, - }); - - useEffect(() => { - checkAuthStatus(); - const interval = setInterval(checkAuthStatus, 10000); - return () => clearInterval(interval); - }, [checkAuthStatus]); - - // 启动时从后端读取持久化的 session timeout;如果后端不存在则尝试将 localStorage 的值迁移到后端 - 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 (err) { - // 后端不可用或命令缺失:尝试从 localStorage 迁移到后端(若有) - 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); - }, []); - - const handleStartBackend = useCallback(async () => { - setAutoStartSuppressed(false); - setBackendError(''); - setBackendStatus('waiting'); - backendStatusRef.current = 'waiting'; - backendStartAtRef.current = Date.now(); - try { - await invoke('start_monitor'); - // Stay in waiting until IPC is reachable. - } 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(''); - lastBackendErrorRef.current = ''; - backendStartAtRef.current = null; - return; - } - - setBackendStatus('online'); - backendStatusRef.current = 'online'; - setMonitorPaused(!!res?.paused); - setBackendError(''); - lastBackendErrorRef.current = ''; - 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); - } - // When we are waiting for startup, keep waiting unless start failed explicitly. - 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); - } - }, [reportBackendError]); - - useEffect(() => { - backendStatusRef.current = backendStatus; - }, [backendStatus]); - - useEffect(() => { - checkBackendStatus(); - const interval = setInterval(checkBackendStatus, 3000); - return () => clearInterval(interval); - }, [checkBackendStatus]); - - useEffect(() => { - let unlistenExit; - let unlistenStopped; - const setup = async () => { - unlistenExit = await listen('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); - }); - unlistenStopped = await listen('monitor-stopped', () => { - setBackendStatus('offline'); - backendStatusRef.current = 'offline'; - setMonitorPaused(false); - setBackendError(''); - lastBackendErrorRef.current = ''; - backendStartAtRef.current = null; - }); - }; - setup(); - return () => { - if (unlistenExit) { - unlistenExit(); - } - if (unlistenStopped) { - unlistenStopped(); - } - }; - }, [reportBackendError, formatErrorDetails]); - - // Listen for critical errors from Rust backend - useEffect(() => { - let unlisten; - const setup = async () => { - unlisten = await listen('critical-error', (event) => { - const msg = event.payload?.message || event.payload || 'Unknown error'; - setCriticalErrors((prev) => [...prev, msg]); - // Fetch log path on first error - invoke('get_log_dir').then(setCriticalErrorLogPath).catch(() => { }); - }); - }; - setup(); - return () => { if (unlisten) unlisten(); }; - }, []); - - const [isMaximized, setIsMaximized] = useState(false); - - useEffect(() => { - const appWindow = getCurrentWindow(); - const updateState = async () => { - setIsMaximized(await appWindow.isMaximized()); - }; - updateState(); - - const unlisten = appWindow.listen('tauri://resize', updateState); - - return () => { - unlisten.then(f => f()); - } - }, []); - - useEffect(() => { - if (darkMode) { - document.documentElement.classList.add('dark'); - localStorage.setItem('theme', 'dark'); - } else { - document.documentElement.classList.remove('dark'); - localStorage.setItem('theme', 'light'); - } - }, [darkMode]); - - useEffect(() => { - localStorage.setItem('autoStartMonitor', autoStartMonitor ? 'true' : 'false'); - }, [autoStartMonitor]); - - 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, powerSavingSuppressed, backendStatus, pythonVersion, handleStartBackend, depsNeedUpdate, depsSyncing, depsCheckDone, modelsNeedDownload]); - - // Auto-start condition checks powerSavingSuppressed (updated by Rust events) - - // debug: print out python version - const refreshPythonVersion = useCallback(async () => { - try { - const version = await invoke('check_python_venv'); - setPythonVersion(version); - - // Check if dependencies need syncing after an update - if (version) { - try { - const result = await invoke('check_deps_freshness'); - if (result?.needs_update) { - console.log('Deps need update, reason:', result.reason); - setDepsNeedUpdate(true); - } else { - setDepsNeedUpdate(false); - } - } catch (err) { - console.warn('Failed to check deps freshness:', err); - setDepsNeedUpdate(false); - } - - // Check if model files are complete - try { - const modelStatus = await invoke('check_model_files'); - // Only block startup on REQUIRED models — optional feature models - // (e.g. smart cluster reranker) are downloaded later via their own - // setup wizard and must not gate the auto-start path. - const hasIncomplete = Object.values(modelStatus).some((m) => !m.complete && m.required !== false); - if (hasIncomplete) { - console.log('Model files incomplete:', modelStatus); - 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]); - - // Update state - 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); - - // Startup update check — delayed 5s, silent on failure - 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 — silently ignore - } - }, 5000); - return () => clearTimeout(timer); - }, []); - - // Debug Update Modal - 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); - }, []); - - // Debug Wizards - 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 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); - } - }; - - // Header handlers - const handleSearchSelect = (res) => { + const selectSearchResult = useCallback((res) => { const screenshotId = res.screenshot_id !== undefined ? res.screenshot_id : (res.metadata?.screenshot_id); const imagePath = res.image_path || res.metadata?.image_path; const timestamp = res.screenshot_created_at || res.metadata?.screenshot_created_at || res.metadata?.created_at || res.created_at || new Date().toISOString(); @@ -983,7 +176,7 @@ function App() { appName: res.process_name || res.metadata?.process_name, windowTitle: res.window_title || res.metadata?.window_title, timestamp: timestampMs ?? Date.now(), - _fromNlSearch: isNl + _fromNlSearch: isNl, }); setHighlightedEventId(screenshotId || -1); if (timestampMs) { @@ -991,7 +184,7 @@ function App() { } } setActiveTab('preview'); - }; + }, [setHighlightedEventId, setSelectedEvent, setTimelineJump]); const handleSearchSubmit = ({ query, mode }) => { setActiveTab('advanced-search'); @@ -999,23 +192,6 @@ function App() { setAdvancedSearchParams({ query, mode, refreshKey: Date.now() }); }; - const onMinimize = () => getCurrentWindow().minimize(); - const onToggleMaximize = () => getCurrentWindow().toggleMaximize(); - const bumpTimelineRefresh = useCallback(() => { - setTimelineRefreshKey((prev) => prev + 1); - }, []); - - const handleDepsSync = useCallback(async () => { - setDepsSyncing(true); - try { - await invoke('sync_python_deps'); - setDepsNeedUpdate(false); - } catch (err) { - throw err; - } finally { - setDepsSyncing(false); - } - }, []); return (
{ - setModelsNeedDownload(false); - setMissingModels(null); - }} + onModelsDownloadComplete={handleModelsDownloadComplete} /> 0} errors={criticalErrors} logPath={criticalErrorLogPath} - onRestart={() => invoke('restart_app').catch(() => { })} - onExit={() => invoke('exit_app').catch(() => { })} + onRestart={restartApp} + onExit={exitApp} /> @@ -1114,13 +287,12 @@ function App() { sqlPaused={!windowFocused} /> - {/* Main Workspace Grid */}
setSidebarExpanded(prev => !prev)} + onToggleExpand={() => setSidebarExpanded((prev) => !prev)} /> { - const screenshotId = res.screenshot_id !== undefined ? res.screenshot_id : (res.metadata?.screenshot_id); - const imagePath = res.image_path || res.metadata?.image_path; - const timestamp = res.screenshot_created_at || res.metadata?.screenshot_created_at || res.metadata?.created_at || res.created_at || new Date().toISOString(); - const isNl = res.similarity !== undefined || res.distance !== undefined || (res.metadata?.screenshot_id !== undefined && res.screenshot_id === undefined); - const timestampMs = normalizeTimestampToMs(timestamp, { assumeUtc: !isNl }); - if (screenshotId !== undefined || imagePath) { - setSelectedEvent({ - id: screenshotId || -1, - path: imagePath, - appName: res.process_name || res.metadata?.process_name, - windowTitle: res.window_title || res.metadata?.window_title, - timestamp: timestampMs ?? Date.now(), - _fromNlSearch: isNl - }); - setHighlightedEventId(screenshotId || -1); - if (timestampMs) { - setTimelineJump({ time: timestampMs, ts: Date.now() }); - } - } - setActiveTab('preview'); - }} + onAdvancedSelect={selectSearchResult} onInspectorBoxClick={(box) => handleCopyText(box.label)} onDeleteRecord={async (id) => { try { await deleteScreenshot(id); - setSelectedEvent(null); - setSelectedDetails(null); - setSelectedImageSrc(null); + clearSelection(); bumpTimelineRefresh(); } catch (e) { console.error('Failed to delete record', e); @@ -1177,9 +326,7 @@ function App() { if (ts) { await deleteRecordsByTimeRange(minutes, ts); } - setSelectedEvent(null); - setSelectedDetails(null); - setSelectedImageSrc(null); + clearSelection(); bumpTimelineRefresh(); } catch (e) { console.error('Failed to delete nearby records', e); @@ -1209,12 +356,7 @@ function App() { downloadProgress={updateDownloadProgress} downloadError={updateDownloadError} onDownload={handleDownloadUpdate} - onLater={() => { - setUpdateModalVisible(false); - if (updateInfo) { - localStorage.setItem('updateDismissed', updateInfo.version); - } - }} + onLater={handleLater} onClose={() => setUpdateModalVisible(false)} /> @@ -1229,14 +371,9 @@ function App() { powerSavingSuppressed={powerSavingSuppressed} powerSavingMode={powerSavingMode} onPowerSavingModeChange={setPowerSavingMode} - onAutoStartMonitorChange={(next) => { - setAutoStartMonitor(next); - if (next) { - setAutoStartSuppressed(false); - } - }} - onManualStartMonitor={() => setAutoStartSuppressed(false)} - onManualStopMonitor={() => setAutoStartSuppressed(true)} + onAutoStartMonitorChange={setAutoStartMonitor} + onManualStartMonitor={handleManualStartMonitor} + onManualStopMonitor={handleManualStopMonitor} sessionTimeout={sessionTimeout} onSessionTimeoutChange={setSessionTimeout} isSessionValid={isAuthenticated} 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/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/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/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, + }; +} From 400fa72e2f734b690a4dba5c99f6abdb673bb590 Mon Sep 17 00:00:00 2001 From: White-NX <2454053557@qq.com> Date: Mon, 6 Jul 2026 15:49:49 +0800 Subject: [PATCH 7/9] frontend: extract search and setup controllers --- src/components/AdvancedSearch.jsx | 391 ++----------- src/components/AdvancedSearch.test.jsx | 33 +- src/components/HmacMigrationDialog.jsx | 4 +- src/components/Mask.jsx | 651 +++------------------- src/components/SearchBox.jsx | 431 ++------------ src/components/SearchBox.test.jsx | 32 +- src/hooks/useAdvancedSearchController.js | 344 ++++++++++++ src/hooks/useDepsSyncOverlay.js | 71 +++ src/hooks/useHmacMigrationStatus.js | 36 ++ src/hooks/useRequiredModelDownload.js | 186 +++++++ src/hooks/useSearchBoxController.js | 400 +++++++++++++ src/hooks/useStartupRecoveryHooks.test.js | 164 ++++++ src/hooks/useVenvInstallController.js | 299 ++++++++++ 13 files changed, 1693 insertions(+), 1349 deletions(-) create mode 100644 src/hooks/useAdvancedSearchController.js create mode 100644 src/hooks/useDepsSyncOverlay.js create mode 100644 src/hooks/useHmacMigrationStatus.js create mode 100644 src/hooks/useRequiredModelDownload.js create mode 100644 src/hooks/useSearchBoxController.js create mode 100644 src/hooks/useStartupRecoveryHooks.test.js create mode 100644 src/hooks/useVenvInstallController.js diff --git a/src/components/AdvancedSearch.jsx b/src/components/AdvancedSearch.jsx index 4349562..e01a973 100644 --- a/src/components/AdvancedSearch.jsx +++ b/src/components/AdvancedSearch.jsx @@ -1,23 +1,11 @@ -import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useMemo, useRef } from 'react'; import { Search, SlidersHorizontal, Filter, CalendarRange, X, Loader2, RefreshCw, Type, Image as ImageIcon, Tag, Maximize2, Eye } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; -import { searchScreenshots, fetchImage, fetchThumbnailBatch, listProcesses, getCategoriesFromDb, batchGetCategories } from '../lib/monitor_api'; +import { fetchImage } from '../lib/monitor_api'; import { CATEGORY_COLORS } from '../lib/categories'; import { ThumbnailCard, CategoryBadge } from './ThumbnailCard'; - -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; -} +import { useAdvancedSearchController } from '../hooks/useAdvancedSearchController'; +import { useHmacMigrationStatus } from '../hooks/useHmacMigrationStatus'; const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -340,344 +328,43 @@ function CategoryFilter({ categories, selected, onChange }) { } export function AdvancedSearch({ active, searchParams, onSelectResult, onOpenSnapshotPreview, searchMode, onSearchModeChange, backendOnline }) { - 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 { t } = useTranslation(); - - 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)] : ''; + const { + 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, + } = useAdvancedSearchController({ + active, + searchParams, + searchMode, + onSearchModeChange, + backendOnline, + t, }); - - 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]); - - // Auto-switch back to OCR when backend goes offline - 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 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; - - // NL 模式:批量enrichment分类信息 - if (mode === 'nl' && fetched.length > 0) { - const hashes = fetched - .map((item) => (item.image_path || '').replace('memory://', '')) - .filter(Boolean); - if (hashes.length > 0) { - 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]; - } - } - } - } - } - - 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]); - - // Batch-fetch thumbnails when results change - useEffect(() => { - if (results.length === 0) return; - let active = true; - (async () => { - const ids = results - .map((item) => item.screenshot_id ?? item.metadata?.screenshot_id) - .filter((id) => typeof id === 'number' && id > 0); - // Deduplicate and exclude already-cached IDs - const uniqueIds = [...new Set(ids)].filter((id) => !thumbnailCache[id]); - if (uniqueIds.length === 0) return; - const batch = await fetchThumbnailBatch(uniqueIds); - if (active && batch) { - setThumbnailCache((prev) => ({ ...prev, ...batch })); - } - })(); - return () => { active = 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; - - // NL 模式:批量enrichment - if (mode === 'nl' && fetched.length > 0) { - const hashes = fetched - .map((item) => (item.image_path || '').replace('memory://', '')) - .filter(Boolean); - if (hashes.length > 0) { - 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]; - } - } - } - } - } - - 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]); - - 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(); - - let unlistenProgress = null; - let unlistenComplete = null; - - listen('hmac-migration-progress', () => { - if (mounted) setIsMigrating(true); - }).then(fn => unlistenProgress = fn); - - listen('hmac-migration-complete', () => { - if (mounted) setIsMigrating(false); - }).then(fn => unlistenComplete = fn); - - return () => { - mounted = false; - if (unlistenProgress) unlistenProgress(); - if (unlistenComplete) unlistenComplete(); - }; - }, []); + const isMigrating = useHmacMigrationStatus(); return (
diff --git a/src/components/AdvancedSearch.test.jsx b/src/components/AdvancedSearch.test.jsx index 70636ee..0d6f2f1 100644 --- a/src/components/AdvancedSearch.test.jsx +++ b/src/components/AdvancedSearch.test.jsx @@ -217,19 +217,28 @@ describe('AdvancedSearch', () => { }); it('displays search error banner when search fails', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); searchScreenshots.mockRejectedValueOnce(new Error('Chroma database is offline')); - render( - - ); - - await waitFor(() => { - expect(screen.getByText('advancedSearch.search.error')).toBeInTheDocument(); - }); + try { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('advancedSearch.search.error')).toBeInTheDocument(); + }); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Advanced search resetAndFetch failed:', + expect.any(Error) + ); + } finally { + consoleErrorSpy.mockRestore(); + } }); }); diff --git a/src/components/HmacMigrationDialog.jsx b/src/components/HmacMigrationDialog.jsx index b1070e4..a236ace 100644 --- a/src/components/HmacMigrationDialog.jsx +++ b/src/components/HmacMigrationDialog.jsx @@ -51,9 +51,7 @@ export default function HmacMigrationDialog() { } catch (err) { // Tauri errors might be strings or objects. Check for both. const errStr = typeof err === 'string' ? err : (err?.message || err?.toString() || ''); - if (errStr.includes('ALREADY_RUNNING')) { - console.log('[HMAC_MIGRATE] Already running, waiting for events'); - } else { + if (!errStr.includes('ALREADY_RUNNING')) { throw err; } } diff --git a/src/components/Mask.jsx b/src/components/Mask.jsx index ee22f21..0887ce6 100644 --- a/src/components/Mask.jsx +++ b/src/components/Mask.jsx @@ -1,145 +1,40 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { WifiOff, Loader2, Play, Route, PackageOpen, Shield, ShieldEllipsis, RotateCcw, Download, X } from 'lucide-react'; -import { open } from '@tauri-apps/plugin-dialog'; -import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; +import { Loader2, Route, PackageOpen, Shield, RotateCcw, Download, X } from 'lucide-react'; +import { useRequiredModelDownload } from '../hooks/useRequiredModelDownload'; +import { useDepsSyncOverlay } from '../hooks/useDepsSyncOverlay'; +import { useVenvInstallController } from '../hooks/useVenvInstallController'; export default function Mask({ backendStatus, pythonVersion, backendError, handleStartBackend, onRefreshPythonVersion, depsNeedUpdate, depsSyncing, onDepsSync, modelsNeedDownload, missingModels, onModelsDownloadComplete }) { - - const [venvInstallStep, setVenvInstallStep] = React.useState(null); - const [pythonPath, setPythonPath] = React.useState(''); - const [discoveredOptions, setDiscoveredOptions] = React.useState([]); - const [selectedVersions, setSelectedVersions] = React.useState([]); - const [versionErrorState, setVersionErrorState] = React.useState(null); - const [installing, setInstalling] = React.useState(false); - const [installError, setInstallError] = React.useState(null); - - // Step 1: install button logs & helper (for auto-installing system Python) - const [installLogs, setInstallLogs] = React.useState([]); - const installLogRef = React.useRef(null); - const appendInstallLog = (msg) => { - setInstallLogs((prev) => [...prev, msg]); - }; - - // Global listener ref for backend install logs - const installListenerRef = React.useRef(null); - - // Listen for 'install-log' events emitted from the Rust backend and route them - // to the appropriate log view (installer vs pip). This is registered once on - // mount and removed on unmount. - React.useEffect(() => { - let mounted = true; - (async () => { - try { - const unlisten = await listen('install-log', (event) => { - const payload = event?.payload || {}; - const line = payload.line || JSON.stringify(payload); - const source = payload.source || 'installer'; - // Route pip source to dependency logs, everything else goes to install logs - if (source === 'pip' || source === 'aria2') { - appendDepsLog(line); - } else { - appendInstallLog(line); - } - }); - installListenerRef.current = unlisten; - } catch (e) { - console.warn('Failed to register install-log listener', e); - } - })(); - - return () => { - if (installListenerRef.current) { - try { - installListenerRef.current(); - } catch (e) { - console.warn('Failed to remove install-log listener', e); - } - } - }; - }, []); - - // Step 2: dependency installation (venv + pip install) - const [depsInstalling, setDepsInstalling] = React.useState(false); - const [depsInstallLog, setDepsInstallLog] = React.useState([]); - const [depsError, setDepsError] = React.useState(null); - const [depsInstallSuccess, setDepsInstallSuccess] = React.useState(false); - const depsLogRef = React.useRef(null); - - // fetch discovered python installations asynchronously - const getDiscoveredPythonVersions = async () => { - const result = await invoke('check_python_status'); - console.log(result); - - // normalize result to an array of version strings - const versions = Array.isArray(result) ? result : [result].filter(Boolean); - - return versions.map((pv, idx) => { - // if backend returned a full path (e.g. C:\\Program Files\\Python310\\python.exe), use it directly - 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, - }; - } - - // otherwise assume pv is a version string like "3.10.11" - 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, - }; - }); - }; - - React.useEffect(() => { - if (venvInstallStep === 1) { - let cancelled = false; - setVersionErrorState(null); - (async () => { - try { - setDiscoveredOptions([]); // clear while loading - const opts = await getDiscoveredPythonVersions(); - if (!cancelled) setDiscoveredOptions(opts); - } catch (error) { - if (!cancelled) setVersionErrorState(error?.message || String(error)); - } - })(); - return () => { cancelled = true; }; - } - }, [venvInstallStep]); - - // Automatically run dependency installation when entering step 2 - const appendDepsLog = (msg) => { - const ts = new Date().toLocaleTimeString(); - setDepsInstallLog((prev) => [...prev, `[${ts}] ${msg}`]); - }; - - // Use a ref to track if installation has already started to prevent double execution - const installStartedRef = React.useRef(false); - - // Store pythonPath in a ref so the effect always has access to the latest value - const pythonPathRef = React.useRef(pythonPath); - React.useEffect(() => { - pythonPathRef.current = pythonPath; - }, [pythonPath]); - - // Reference to the actual input element so we can synchronously read the user's typed/selected path - const inputRef = React.useRef(null); - // Capture the python path that the user intended to use when they click "Next" - const [chosenPythonForInstall, setChosenPythonForInstall] = React.useState(null); - const { t } = useTranslation(); + const { + venvInstallStep, + setVenvInstallStep, + pythonPath, + setPythonPath, + discoveredOptions, + selectedVersions, + versionErrorState, + installing, + installError, + installLogs, + installLogRef, + depsInstalling, + depsInstallLog, + depsError, + depsLogRef, + inputRef, + toggleVersion, + installPython, + openFileDialog, + beginDependencyInstall, + retryDependencyInstall, + } = useVenvInstallController({ + onRefreshPythonVersion, + handleStartBackend, + t, + }); // Debug helper: in dev mode show a dropdown to preview different masks const isDev = typeof import.meta !== 'undefined' && Boolean(import.meta.env && import.meta.env.DEV); @@ -168,6 +63,34 @@ export default function Mask({ backendStatus, pythonVersion, backendError, handl const renderBackendStatus = debugOverrides.hasOwnProperty('backendStatus') ? debugOverrides.backendStatus : backendStatus; const renderPythonVersion = debugOverrides.hasOwnProperty('pythonVersion') ? debugOverrides.pythonVersion : pythonVersion; const renderVenvInstallStep = debugOverrides.hasOwnProperty('venvInstallStep') ? debugOverrides.venvInstallStep : venvInstallStep; + const { + modelDownloadLog, + modelDownloadError, + modelDownloading, + modelDownloadLogRef, + isClosedByUser, + setIsClosedByUser, + retryModelDownload, + } = useRequiredModelDownload({ + modelsNeedDownload, + missingModels, + renderVenvInstallStep, + depsNeedUpdate, + onModelsDownloadComplete, + t, + }); + const { + depsSyncLog, + depsSyncError, + depsSyncLogRef, + retryDepsSync, + } = useDepsSyncOverlay({ + depsNeedUpdate, + pythonVersion, + renderVenvInstallStep, + depsSyncing, + onDepsSync, + }); const renderDebugSelector = () => (
@@ -189,439 +112,6 @@ export default function Mask({ backendStatus, pythonVersion, backendError, handl
); - const autoPostInstallRef = React.useRef(false); - - // ==================== Model download overlay state ==================== - const [modelDownloadLog, setModelDownloadLog] = React.useState([]); - const [modelDownloadError, setModelDownloadError] = React.useState(null); - const [modelDownloading, setModelDownloading] = React.useState(false); - const modelDownloadLogRef = React.useRef(null); - const modelDownloadStartedRef = React.useRef(false); - const [isClosedByUser, setIsClosedByUser] = React.useState(false); - const [downloadProgressState, setDownloadProgressState] = React.useState({ - keys: [], - currentIdx: 0, - currentFileProgress: {}, - }); - - // Reset isClosedByUser when modelsNeedDownload becomes true - React.useEffect(() => { - if (modelsNeedDownload) { - setIsClosedByUser(false); - } - }, [modelsNeedDownload]); - - // Memoize overall progress - const overallProgress = React.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]); - - // Dispatch custom event to let other components (like SearchBox) know about model download status - React.useEffect(() => { - window.dispatchEvent( - new CustomEvent('model-download-progress', { - detail: { - active: modelDownloading && isClosedByUser, - progress: Math.round(overallProgress), - }, - }) - ); - }, [modelDownloading, isClosedByUser, overallProgress]); - - // Auto-scroll model download log - React.useEffect(() => { - if (modelDownloadLogRef?.current) { - modelDownloadLogRef.current.scrollTop = modelDownloadLogRef.current.scrollHeight; - } - }, [modelDownloadLog]); - - // Capture install-log events into model download log when downloading - React.useEffect(() => { - if (!modelDownloading) return; - let mounted = true; - let unlisten; - (async () => { - try { - unlisten = await listen('install-log', (event) => { - if (!mounted) return; - const payload = event?.payload || {}; - const line = payload.line || JSON.stringify(payload); - const ts = new Date().toLocaleTimeString(); - setModelDownloadLog((prev) => [...prev, `[${ts}] ${line}`]); - - // Parse progress percentage from aria2 log 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, - }; - }); - } - } - }); - } catch (e) { - console.warn('Failed to register model download log listener', e); - } - })(); - return () => { - mounted = false; - if (unlisten) unlisten(); - }; - }, [modelDownloading]); - - // Auto-start model download when needed - React.useEffect(() => { - if (!modelsNeedDownload || !missingModels) return; - if (modelDownloadStartedRef.current || modelDownloading) return; - // Don't show during first install or deps update - 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')}`]); - // This will fall back to default parameters inside Rust, which handles use_onnx - 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')}`]); - if (onModelsDownloadComplete) onModelsDownloadComplete(); - } catch (err) { - setModelDownloadError(err?.message || String(err)); - } finally { - setModelDownloading(false); - modelDownloadStartedRef.current = false; - } - })(); - }, [modelsNeedDownload, missingModels, modelDownloading, renderVenvInstallStep, depsNeedUpdate]); - - // ==================== Deps update overlay state ==================== - const [depsSyncLog, setDepsSyncLog] = React.useState([]); - const [depsSyncError, setDepsSyncError] = React.useState(null); - const [depsSyncStarted, setDepsSyncStarted] = React.useState(false); - const depsSyncLogRef = React.useRef(null); - - // Auto-scroll deps sync log - React.useEffect(() => { - if (depsSyncLogRef?.current) { - depsSyncLogRef.current.scrollTop = depsSyncLogRef.current.scrollHeight; - } - }, [depsSyncLog]); - - // Auto-start deps sync when overlay is shown - React.useEffect(() => { - if (!depsNeedUpdate || !pythonVersion || renderVenvInstallStep != null) return; - if (depsSyncStarted || depsSyncing) 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, onDepsSync]); - - // Listen for install-log events to populate deps sync log - // (We reuse the existing listener — route 'pip' and 'installer' source lines to depsSyncLog when syncing) - const prevDepsNeedUpdate = React.useRef(false); - React.useEffect(() => { - // When depsNeedUpdate transitions from false to true, start capturing logs - if (depsNeedUpdate && !prevDepsNeedUpdate.current) { - setDepsSyncLog([]); - setDepsSyncError(null); - } - prevDepsNeedUpdate.current = depsNeedUpdate; - }, [depsNeedUpdate]); - - // Capture install-log events into depsSyncLog when deps are syncing - React.useEffect(() => { - if (!depsNeedUpdate && !depsSyncing) return; - let mounted = true; - let unlisten; - (async () => { - try { - unlisten = await listen('install-log', (event) => { - if (!mounted) return; - const payload = event?.payload || {}; - const line = payload.line || JSON.stringify(payload); - const ts = new Date().toLocaleTimeString(); - setDepsSyncLog((prev) => [...prev, `[${ts}] ${line}`]); - }); - } catch (e) { - console.warn('Failed to register deps sync log listener', e); - } - })(); - return () => { - mounted = false; - if (unlisten) unlisten(); - }; - }, [depsNeedUpdate, depsSyncing]); - - React.useEffect(() => { - if (venvInstallStep === 2) { - // Clear previous logs/errors so each run is fresh - setDepsInstallLog([]); - setDepsError(null); - setDepsInstallSuccess(false); - autoPostInstallRef.current = false; - - // Prevent double execution (handles StrictMode/HMR by using a global flag) - if (window.__cp_install_started) { - appendDepsLog('安装已在进行中(忽略重复触发)'); - return; - } - window.__cp_install_started = true; - installStartedRef.current = true; - - setDepsInstalling(true); - - // Prefer the value captured when the user clicked 'Next' (synchronously read from input), - // otherwise fall back to the input DOM value, then the ref value. - const currentPythonPath = (chosenPythonForInstall !== null && chosenPythonForInstall !== undefined) - ? chosenPythonForInstall - : (inputRef.current?.value || pythonPathRef.current); - - // Escape backslashes by doubling them, since backend expects escaped backslashes - const processedPythonPath = currentPythonPath ? currentPythonPath.replace(/\\/g, '\\\\') : null; - - // append initial log and perform an invoke to the backend - 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 { - console.log('Calling install_python_venv invoke...', { python_path: processedPythonPath }); - // try to call monitor IPC to ask it to install requirements - // note: the monitor may not be running yet; errors are caught and logged - // pass the python executable path chosen in the previous step (escaped) - 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; - - // download model files (Chinese-CLIP) - appendDepsLog(t('mask.venv.step2.download_models')); - const modelRes = await invoke('download_model'); - appendDepsLog(modelRes); - - // download BGE classification model - 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); - - // download MiniLM clustering model - 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; // allow retries - installStartedRef.current = false; - } - })(); - } else { - // Reset the ref and global flag when leaving step 2 - installStartedRef.current = false; - if (window.__cp_install_started) window.__cp_install_started = false; - } - // no cleanup required, we do not use intervals here - }, [venvInstallStep]); - - React.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]); - - React.useEffect(() => { - if (depsLogRef?.current) { - depsLogRef.current.scrollTop = depsLogRef.current.scrollHeight; - } - }, [depsInstallLog]); - - // scroll install button log - React.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((s) => s !== id)); - } else { - setSelectedVersions([...selectedVersions, id]); - // also set path to selected candidate for convenience - setPythonPath(opt.path || ''); - } - }; - - const installPython = async () => { - setInstallError(null); - setInstalling(true); - setInstallLogs([]); - appendInstallLog(t('mask.venv.auto_install.log_start')); - - try { - - let result = await invoke('request_install_python'); - appendInstallLog(t('mask.venv.auto_install.success')); - - appendInstallLog(t('mask.venv.auto_install.finished')); - setVenvInstallStep(null); - let _ = 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); - } - }; - // ==================== Deps update overlay ==================== if (depsNeedUpdate && renderPythonVersion && renderVenvInstallStep == null) { return ( @@ -643,10 +133,7 @@ export default function Mask({ backendStatus, pythonVersion, backendError, handl
{t('mask.deps_update.failed', { error: depsSyncError })} + handleToggle('cpu_limit_enabled')} + />
{config.cpu_limit_enabled && ( @@ -270,7 +123,7 @@ export default function AdvancedSection({ monitorStatus, onRestartMonitor }) {

{t('settings.advanced.cpu.changed_notice')}

{monitorStatus === 'running' && onRestartMonitor && (
- + handleToggle('capture_on_ocr_busy')} + />
{/* 队列大小限制 */} @@ -313,16 +160,10 @@ export default function AdvancedSection({ monitorStatus, onRestartMonitor }) {

{t('settings.advanced.ocr.queue_limit_label')}

{t('settings.advanced.ocr.queue_limit_desc')}

- + handleToggle('ocr_queue_limit_enabled')} + />
{config.ocr_queue_limit_enabled && ( @@ -382,10 +223,7 @@ export default function AdvancedSection({ monitorStatus, onRestartMonitor }) { max="600" step="10" value={config.ocr_timeout_secs || 120} - onChange={(e) => { - const newConfig = { ...config, ocr_timeout_secs: e.target.value }; - setConfig(newConfig); - }} + onChange={(e) => handleOcrTimeoutDraftChange(e.target.value)} onBlur={(e) => handleOcrTimeoutChange(e.target.value)} className="w-24 px-3 py-2 bg-ide-panel border border-ide-border rounded-lg text-sm text-ide-text text-right" /> @@ -410,20 +248,17 @@ export default function AdvancedSection({ monitorStatus, onRestartMonitor }) {
-

{t('settings.advanced.dml.label')}

+

+ {t('settings.advanced.dml.label')} + {t('settings.advanced.terms.directml')} +

{t('settings.advanced.dml.description')}

{t('settings.advanced.dml.notice')}

- + handleToggle('use_dml')} + />
{config.use_dml && ( @@ -483,7 +318,7 @@ export default function AdvancedSection({ monitorStatus, onRestartMonitor }) {

{t('settings.advanced.dml.changed_notice')}

{monitorStatus === 'running' && onRestartMonitor && ( + handleToggle('use_onnx')} + />
{onnxChanged && ( @@ -526,7 +358,7 @@ export default function AdvancedSection({ monitorStatus, onRestartMonitor }) {

{t('settings.advanced.onnx.changed_notice')}

{monitorStatus === 'running' && onRestartMonitor && ( + handleToggle('clustering_allow_full_low_memory')} + />
-

{t('settings.advanced.clustering.info')}

+

+ {t('settings.advanced.clustering.info')} + {t('settings.advanced.terms.minilm')} +

@@ -631,16 +455,10 @@ export default function AdvancedSection({ monitorStatus, onRestartMonitor }) {

{t('settings.advanced.network.label')}

{t('settings.advanced.network.description')}

- + handleToggle('network_enabled')} + /> diff --git a/src/components/settings/AiEmbeddingSection.jsx b/src/components/settings/AiEmbeddingSection.jsx index afe78b5..52089e2 100644 --- a/src/components/settings/AiEmbeddingSection.jsx +++ b/src/components/settings/AiEmbeddingSection.jsx @@ -1,430 +1,68 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; -import { AlertTriangle, Copy, Check, RefreshCw, HelpCircle, ChevronDown, ChevronUp, Download, Loader2, Paperclip } from 'lucide-react'; -import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; +import { AlertTriangle, Copy, Check, RefreshCw, ChevronDown, ChevronUp, Download, Loader2, Paperclip } from 'lucide-react'; import { Dialog } from '../Dialog'; import { ConfirmDialog } from '../ConfirmDialog'; -import { withAuth } from '../../lib/auth_api'; +import SettingsHelpTooltip from './SettingsHelpTooltip'; +import { SettingsSwitch } from './SettingsControls'; +import { useAiEmbeddingController } from './useAiEmbeddingController'; const AGENT_SKILL_NAME = 'carbonpaper-memory'; const AGENT_SKILL_REPO = 'https://github.com/White-NX/carbonPaperSkill'; export default function AiEmbeddingSection() { const { t } = useTranslation(); - 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 { + 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, + } = useAiEmbeddingController({ + t, + agentSkillName: AGENT_SKILL_NAME, + agentSkillRepo: AGENT_SKILL_REPO, }); - const [running, setRunning] = useState(false); - const [serviceState, setServiceState] = useState(() => ( - localStorage.getItem('mcpEnabled') === 'true' ? 'pending_auth' : 'disabled' - )); - const [statusError, setStatusError] = useState(''); - // Skip loading spinner if we have cached state from localStorage - 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); - - // Privacy warning dialog - const [showPrivacyDialog, setShowPrivacyDialog] = useState(false); - const [confirmText, setConfirmText] = useState(''); - - const [tokenCopied, setTokenCopied] = useState(false); - const [agentPromptCopied, setAgentPromptCopied] = useState(false); - - // Token reset confirmation - const [showResetConfirm, setShowResetConfirm] = useState(false); - - // Content filter - 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); - - // PII Detection - 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)); - - // Sync to localStorage for instant state on next mount - localStorage.setItem('mcpEnabled', status.enabled ? 'true' : 'false'); - if (status.port) localStorage.setItem('mcpPort', String(status.port)); - - // Load filter config - 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); - } - - // Load spaCy model status - 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); - // Retry once after a short delay (IPC bridge may not be ready after refresh) - if (retryCount < 1) { - setTimeout(() => loadStatus(retryCount + 1), 500); - return; - } - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - loadStatus(); - }, [loadStatus]); - - useEffect(() => { - const unlisten = listen('mcp-status-changed', () => { - loadStatus(); - }); - return () => { unlisten.then(fn => fn()); }; - }, [loadStatus]); - - // Listen for background auto-install events - useEffect(() => { - const unlisten = listen('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); - } - }); - return () => { unlisten.then(fn => fn()); }; - }, []); - - 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); - } - }; - - // Derive filter level from current state - 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 }); - // Refresh model status - 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: AGENT_SKILL_NAME, - repo: AGENT_SKILL_REPO, - 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]); if (loading) { return ( @@ -445,12 +83,7 @@ export default function AiEmbeddingSection() {
-
- -
- {t('settings.ai_embedding.mcp_description')} -
-
+ {t('settings.ai_embedding.mcp_description')}
{/* Options card — single card with dividers, matching MonitorServiceSection */} @@ -481,17 +114,11 @@ export default function AiEmbeddingSection() { {restoreLoading ? t('settings.ai_embedding.starting') : t('settings.ai_embedding.start_button')} )} - + /> @@ -738,14 +365,10 @@ export default function AiEmbeddingSection() { {t('settings.ai_embedding.content_filter.pii.description')}

- + {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}`)} - + /> )} 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 7351858..131da92 100644 --- a/src/components/settings/FeaturesSection.jsx +++ b/src/components/settings/FeaturesSection.jsx @@ -1,386 +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 { getIndexHealth, retryVectorIndexing } from '../../lib/monitor_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(''); - const [indexHealth, setIndexHealth] = useState(null); - const [indexHealthLoading, setIndexHealthLoading] = useState(false); - const [indexHealthError, setIndexHealthError] = useState(null); - const [vectorRetrying, setVectorRetrying] = useState(false); - - // 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]); - - const loadIndexHealth = 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); - } - }; - - useEffect(() => { - if (monitorStatus === 'running') { - loadIndexHealth({ refreshVector: false }); - } - }, [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 handleRetryVectorIndexing = 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); - } - }; - - const formatSize = (sizeStr) => { - if (!sizeStr) return '-'; - return sizeStr; - }; - - const formatIndexCount = (value, fallback = '—') => { - if (typeof value === 'number' && Number.isFinite(value)) { - return value.toLocaleString(); - } - return fallback; - }; - - const vectorRetryBacklog = indexHealth?.pending_retry_queue_count; - const deleteQueuePending = 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 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 ( @@ -400,236 +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}

+ )} + +
-
- - {/* 聚类间隔设置 - 仅在启用时显示 */} - {config.clustering_enabled && ( - <> -
-
-

{t('settings.features.management.clustering.interval_label', '自动聚类间隔')}

-
-
- - {clusteringDropdownOpen && ( -
e.stopPropagation()} - > - {['1d', '1w', '1m', '6m'].map((interval) => ( - - ))} + + {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', '自动聚类间隔')}

+
+
- - {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) => ( - - {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 模型自动分类截图内容')}

- -
-
- {/* 索引健康 */} -
-
-
-

- - {t('settings.features.management.indexHealth.label', '索引健康')} -

-

- {t('settings.features.management.indexHealth.description', '截图、OCR、向量索引和后台重试队列的当前状态')} -

-
-
- +
-
-
-
-
-

{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` : ''} -

-
-
+ {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" + /> +
- {(indexHealthError || indexHealth?.monitor_error || lastIndexingError) && ( -
- -
-

{indexHealthError || indexHealth?.monitor_error || lastIndexingError}

- {lastIndexingErrorAt && ( -

{lastIndexingErrorAt}

- )} -
+
+ : 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) && (
-
+

@@ -639,25 +307,9 @@ export default function FeaturesSection({ monitorStatus }) { {t('settings.features.management.smartCluster.description', '输入一句话描述(如 "对加利福尼亚山脉的研究"),自动归档相关快照。仅在系统空闲时计算。')}

-
- {!config.clustering_enabled && ( + {config.smart_cluster_enabled && !config.clustering_enabled && (

@@ -672,13 +324,13 @@ export default function FeaturesSection({ monitorStatus }) {

{t('settings.features.management.smartCluster.modelNotDownloaded', 'bge-reranker-v2-m3 (uint8, ~570MB) 尚未下载')}

- +
)} @@ -701,13 +353,14 @@ export default function FeaturesSection({ monitorStatus }) { {scDownloadError && (
{scDownloadError} - +
)} @@ -725,24 +378,23 @@ export default function FeaturesSection({ monitorStatus }) {
- - +
)}
+ )}
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', 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')} +
+ )} +
- -
-
- -
-
- -

- {t('settings.general.lightweightMode.switchNow.description')} -

+
+
+ +

{t('settings.general.windowBehavior.switchNow.description')}

+
+ + {t('settings.general.windowBehavior.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')}

- + />
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 ( + + ); +} + +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 ( + + ); +} + +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 ( + + ); + })} +
+ ); +} 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 ( + + + ); +} 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、向量索引和后台重试队列的当前状态')} +

+
+
+
+ + : 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/i18n/locales/en.json b/src/i18n/locales/en.json index 6d5afaa..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", @@ -427,7 +490,7 @@ "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. The repository may be private; if this environment lacks access, use configured GitHub credentials or ask me for access.\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." + "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", @@ -519,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 907cf95..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 占用率", @@ -427,7 +490,7 @@ "endpoint": "端点", "copy": "复制配置提示词", "copied": "配置提示词已复制", - "prompt": "请配置 CarbonPaper MCP 访问。\n\n1. 从 GitHub 仓库 {{repo}} 的 {{skillName}} 目录获取 Skill,并安装到你的 Agent/Skill 环境中。该仓库可能是私有仓库;如果当前环境没有访问权限,请使用已配置的 GitHub 凭据或向我请求授权。\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、任务聚类和智能聚类数据;只在我明确要求时读取相关内容。" + "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": "内容过滤", @@ -519,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 模型将相似活动分组为长期任务", From a20662d3c9a98caaf7b2575594e3788b1528a223 Mon Sep 17 00:00:00 2001 From: White-NX <2454053557@qq.com> Date: Mon, 6 Jul 2026 15:51:49 +0800 Subject: [PATCH 9/9] test: tighten frontend contracts --- src/components/ActivityContextDrawer.test.jsx | 25 ++++++++----- src/components/Timeline.jsx | 3 -- src/lib/api_contracts.test.js | 37 ++++++++++++++++++- src/lib/categories.test.js | 30 ++++++++++----- src/lib/monitor_api.api.test.js | 26 ++++++++++++- src/lib/monitor_api.js | 1 - src/lib/task_api.test.js | 37 ++++++++++++++++++- 7 files changed, 133 insertions(+), 26 deletions(-) diff --git a/src/components/ActivityContextDrawer.test.jsx b/src/components/ActivityContextDrawer.test.jsx index c7e4eff..d672980 100644 --- a/src/components/ActivityContextDrawer.test.jsx +++ b/src/components/ActivityContextDrawer.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('react-i18next', () => ({ @@ -66,13 +67,12 @@ describe('ActivityContextDrawer', () => { }); it('asks for confirmation before removing a snapshot from an activity', async () => { + const user = userEvent.setup(); renderDrawer(); const unlinkButton = await screen.findByLabelText('activityContext.removeSnapshot'); - expect(unlinkButton.className).toContain('left-1'); - expect(unlinkButton.className).not.toContain('right-1'); - fireEvent.click(unlinkButton); + await user.click(unlinkButton); expect(removeTaskScreenshot).not.toHaveBeenCalled(); expect(screen.getByText('activityContext.removeSnapshotConfirm')).toBeInTheDocument(); @@ -80,7 +80,7 @@ describe('ActivityContextDrawer', () => { const confirmButton = screen .getAllByRole('button', { name: 'activityContext.removeSnapshot' }) .find((button) => button.textContent === 'activityContext.removeSnapshot'); - fireEvent.click(confirmButton); + await user.click(confirmButton); await waitFor(() => { expect(removeTaskScreenshot).toHaveBeenCalledWith(7, 42); @@ -88,15 +88,20 @@ describe('ActivityContextDrawer', () => { }); it('does not delete an activity when the confirmation is cancelled', async () => { + const user = userEvent.setup(); renderDrawer(); + await screen.findByText('carbonPaper'); - fireEvent.click(screen.getByRole('button', { name: 'activityContext.deleteActivity' })); + await user.click(screen.getByRole('button', { name: 'activityContext.deleteActivity' })); expect(deleteTask).not.toHaveBeenCalled(); expect(screen.getByText('activityContext.deleteActivityConfirm')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'common.cancel' })); + await user.click(screen.getByRole('button', { name: 'common.cancel' })); expect(deleteTask).not.toHaveBeenCalled(); + await waitFor(() => { + expect(screen.queryByText('activityContext.deleteActivityConfirm')).not.toBeInTheDocument(); + }); }); it('closes when the preview backdrop is clicked but not when the drawer itself is clicked', async () => { @@ -113,12 +118,13 @@ describe('ActivityContextDrawer', () => { }); it('keeps the drawer open when selecting a snapshot card', async () => { + const user = userEvent.setup(); const onClose = vi.fn(); const onSelectScreenshot = vi.fn(); renderDrawer({ onClose, onSelectScreenshot }); - fireEvent.click(await screen.findByText('carbonPaper')); + await user.click(await screen.findByText('carbonPaper')); expect(onSelectScreenshot).toHaveBeenCalledWith(expect.objectContaining({ screenshot_id: 42, @@ -129,13 +135,14 @@ describe('ActivityContextDrawer', () => { }); it('uses the activity context card click preference for standalone preview', async () => { + const user = userEvent.setup(); localStorage.setItem('cardClickBehavior_activityContext', 'standalone'); const onSelectScreenshot = vi.fn(); const onOpenFloatingPreview = vi.fn(); renderDrawer({ onSelectScreenshot, onOpenFloatingPreview }); - fireEvent.click(await screen.findByText('carbonPaper')); + await user.click(await screen.findByText('carbonPaper')); expect(onOpenFloatingPreview).toHaveBeenCalledWith(expect.objectContaining({ screenshot_id: 42, @@ -144,7 +151,7 @@ describe('ActivityContextDrawer', () => { })); expect(onSelectScreenshot).not.toHaveBeenCalled(); - fireEvent.click(screen.getByLabelText('previewAction.openMainPreview')); + await user.click(screen.getByLabelText('previewAction.openMainPreview')); expect(onSelectScreenshot).toHaveBeenCalledWith(expect.objectContaining({ screenshot_id: 42, diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index 3356455..292dcc1 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -302,7 +302,6 @@ const Timeline = ({ onSelectEvent, onClearHighlight, jumpTimestamp, highlightedE try { const records = await getTimeline(startTime, endTime); if (fetchEpochRef.current !== epoch) return; // stale response, discard - console.log('[Timeline] Raw records from API:', records); const mapped = (records || []) .filter(r => r.timestamp != null) // Filter out records without timestamp .map(r => { @@ -327,7 +326,6 @@ const Timeline = ({ onSelectEvent, onClearHighlight, jumpTimestamp, highlightedE }; }) .filter(e => !isNaN(e.timestamp)); // Filter out invalid timestamps - console.log('[Timeline] Mapped events:', mapped.length); setEvents(prev => { const combined = [...mapped, ...prev]; @@ -341,7 +339,6 @@ const Timeline = ({ onSelectEvent, onClearHighlight, jumpTimestamp, highlightedE } } const sorted = unique.sort((a, b) => a.timestamp - b.timestamp); - console.log('[Timeline] Total unique events:', sorted.length); return sorted; }); } catch (err) { diff --git a/src/lib/api_contracts.test.js b/src/lib/api_contracts.test.js index fd111b6..871c3eb 100644 --- a/src/lib/api_contracts.test.js +++ b/src/lib/api_contracts.test.js @@ -2,13 +2,14 @@ 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, @@ -34,8 +35,19 @@ 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' }) @@ -81,6 +93,15 @@ describe('API contract payloads', () => { 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 () => { @@ -121,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 () => { @@ -158,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 9d4b7a1..bc8bd4a 100644 --- a/src/lib/monitor_api.api.test.js +++ b/src/lib/monitor_api.api.test.js @@ -2,13 +2,14 @@ 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, @@ -27,6 +28,7 @@ describe('monitor_api command wrappers', () => { beforeEach(() => { invoke.mockReset(); + withAuth.mockClear(); consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); @@ -35,6 +37,16 @@ describe('monitor_api command wrappers', () => { 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 }] }); @@ -57,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 () => { @@ -84,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 () => { @@ -116,6 +134,7 @@ 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 () => { @@ -129,6 +148,7 @@ describe('monitor_api command wrappers', () => { await assertion; expect(invoke).toHaveBeenCalledWith('storage_get_image', { id: 9001, path: null }); + expectWithAuth(1); }); it('rejects thumbnail requests that exceed the queue deadline', async () => { @@ -142,6 +162,7 @@ describe('monitor_api command wrappers', () => { 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 () => { @@ -155,6 +176,7 @@ describe('monitor_api command wrappers', () => { 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 () => { @@ -170,6 +192,7 @@ describe('monitor_api command wrappers', () => { await assertion; expect(invoke).toHaveBeenCalledWith('storage_get_screenshot_details', { id: 9002, path: null }); + expectWithAuth(1); }); it('dedupes concurrent thumbnail requests by path', async () => { @@ -187,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 9b406f9..746c3c8 100644 --- a/src/lib/monitor_api.js +++ b/src/lib/monitor_api.js @@ -213,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 || []; }); }; 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 }); }); });