diff --git a/ai-hub/app/api/routes/mcp.py b/ai-hub/app/api/routes/mcp.py index ba86fb3..0d961aa 100644 --- a/ai-hub/app/api/routes/mcp.py +++ b/ai-hub/app/api/routes/mcp.py @@ -366,6 +366,29 @@ "path": {"type": "string"}, "is_dir": {"type": "boolean"}, "session_id": {"type": "string"} + }, required=["node_id", "path"]), + _tool_def("move_file", "Move or rename a file/directory on a node.", { + "node_id": {"type": "string"}, + "old_path": {"type": "string"}, + "new_path": {"type": "string"}, + "session_id": {"type": "string"} + }, required=["node_id", "old_path", "new_path"]), + _tool_def("copy_file", "Copy a file/directory on a node.", { + "node_id": {"type": "string"}, + "old_path": {"type": "string"}, + "new_path": {"type": "string"}, + "session_id": {"type": "string"} + }, required=["node_id", "old_path", "new_path"]), + _tool_def("rename_file", "Rename a file/directory on a node (alias for move_file).", { + "node_id": {"type": "string"}, + "old_path": {"type": "string"}, + "new_path": {"type": "string"}, + "session_id": {"type": "string"} + }, required=["node_id", "old_path", "new_path"]), + _tool_def("get_file_stat", "Get metadata for a file in the Hub mirror.", { + "node_id": {"type": "string"}, + "path": {"type": "string"}, + "session_id": {"type": "string"} }, required=["node_id", "path"]) ] } @@ -432,7 +455,11 @@ "upload_file": self._upload_file, "download_file": self._download_file, "remove_file": self._remove_file, - "create_file": self._create_file + "create_file": self._create_file, + "move_file": self._move_file, + "copy_file": self._copy_file, + "rename_file": self._move_file, + "get_file_stat": self._get_file_stat } async def dispatch(self, name: str, args: dict, token: Optional[str]) -> Any: @@ -643,6 +670,39 @@ args["content"] = "" return await self._write_file(args, token) + async def _move_file(self, args: dict, token: Optional[str]): + if not token: raise ValueError("Authentication required.") + node_id, old_path, new_path, session_id = args.get("node_id"), args.get("old_path"), args.get("new_path"), args.get("session_id", "__fs_explorer__") + if not node_id or not old_path or not new_path: raise ValueError("node_id, old_path, and new_path required.") + def _execute(): + from app.db.session import get_db_session + with get_db_session() as db: + self.services.mesh_service.require_node_access(token, node_id, db) + return self.services.orchestrator.assistant.move(session_id, old_path, new_path) + return await self.loop.run_in_executor(None, _execute) + + async def _copy_file(self, args: dict, token: Optional[str]): + if not token: raise ValueError("Authentication required.") + node_id, old_path, new_path, session_id = args.get("node_id"), args.get("old_path"), args.get("new_path"), args.get("session_id", "__fs_explorer__") + if not node_id or not old_path or not new_path: raise ValueError("node_id, old_path, and new_path required.") + def _execute(): + from app.db.session import get_db_session + with get_db_session() as db: + self.services.mesh_service.require_node_access(token, node_id, db) + return self.services.orchestrator.assistant.copy(session_id, old_path, new_path) + return await self.loop.run_in_executor(None, _execute) + + async def _get_file_stat(self, args: dict, token: Optional[str]): + if not token: raise ValueError("Authentication required.") + node_id, path, session_id = args.get("node_id"), args.get("path"), args.get("session_id", "__fs_explorer__") + if not node_id or not path: raise ValueError("node_id and path required.") + def _execute(): + from app.db.session import get_db_session + with get_db_session() as db: + self.services.mesh_service.require_node_access(token, node_id, db) + return self.services.orchestrator.assistant.stat(session_id, path) + return await self.loop.run_in_executor(None, _execute) + async def _list_groups(self, args: dict, token: Optional[str]): if not token: raise ValueError("Authentication required.") def _query(): diff --git a/ai-hub/app/api/routes/nodes.py b/ai-hub/app/api/routes/nodes.py index e649e60..710f9a2 100644 --- a/ai-hub/app/api/routes/nodes.py +++ b/ai-hub/app/api/routes/nodes.py @@ -715,8 +715,14 @@ if session_id != "__fs_explorer__": workspace_mirror = orchestrator.mirror.get_workspace_path(session_id) for f in files: - mirror_item_path = os.path.join(workspace_mirror, f["path"]) - f["is_synced"] = os.path.exists(mirror_item_path) + mirror_item_path = os.path.join(workspace_mirror, f["path"].lstrip("/")) + if os.path.exists(mirror_item_path) and not f.get("is_dir"): + # M7: verify size completeness to avoid "Ghost Mirror" artifacts + local_size = os.path.getsize(mirror_item_path) + remote_size = f.get("size", 0) + f["is_synced"] = local_size >= remote_size + else: + f["is_synced"] = os.path.exists(mirror_item_path) return schemas.DirectoryListing(node_id=node_id, path=path, files=files) except HTTPException: @@ -814,6 +820,15 @@ if not os.path.exists(abs_path): raise HTTPException(status_code=404, detail="File did not reach mirror in time.") + + # M7: On-Demand Hydration. Detect partial mirrors and re-fetch if needed. + local_size = os.path.getsize(abs_path) + remote_size = res.get("size", 0) if isinstance(res, dict) else 0 + + if remote_size > local_size: + logger.info(f"[FS] Partial mirror detected for {path} ({local_size}/{remote_size}). Hydrating...") + # Force a remote fetch + orchestrator.assistant.cat(node_id, path, session_id=session_id, force_remote=True) return FileResponse(abs_path, filename=os.path.basename(path)) except HTTPException: diff --git a/ai-hub/app/core/grpc/services/assistant.py b/ai-hub/app/core/grpc/services/assistant.py index 686152a..3b34d81 100644 --- a/ai-hub/app/core/grpc/services/assistant.py +++ b/ai-hub/app/core/grpc/services/assistant.py @@ -408,9 +408,17 @@ def _proactive_explorer_sync(self, node_id, files, session_id): """Starts background tasks to mirror files to Hub so dots turn green.""" + # Smart Adaptive Sync: Higher threshold for media-heavy nodes (Android) + node = self.registry.get_node(node_id) + is_android = node and ( + "android" in node.metadata.get("caps", {}).get("os", "").lower() or + "android" in node.metadata.get("desc", "").lower() + ) + limit = 1024 * 1024 * 5 if is_android else 1024 * 1024 # 5MB for Android, 1MB default + for f in files: if f.get("is_dir"): continue - if not f.get("is_synced") and f.get("size", 0) < 1024 * 512: # Skip large files + if not f.get("is_synced") and f.get("size", 0) < limit: # M6: Use shared registry executor instead of spawning loose threads if self.registry.executor: self.registry.executor.submit(self.cat, node_id, f["path"], 15, session_id) @@ -423,11 +431,20 @@ abs_path = os.path.normpath(os.path.join(workspace, path.lstrip("/"))) if os.path.exists(abs_path) and os.path.isfile(abs_path): try: - # Try reading as text + file_size = os.path.getsize(abs_path) + is_media = any(path.lower().endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.gif', '.mp4', '.mov', '.webp']) + + # Optimization: Don't read large or binary files into memory + if file_size > 1024 * 512 or is_media: + return {"content": f"[File available in mirror: {file_size} bytes]", "path": path, "size": file_size} + + # Try reading as text for small files with open(abs_path, 'r', encoding='utf-8', errors='ignore') as f: content = f.read() return {"content": content, "path": path} except Exception as e: + logger.warning(f"[Assistant] Mirror read failed for {path}: {e}") + # Fallback to remote if mirror read fails logger.error(f"[📁📄] Local cat error for {session_id}/{path}: {e}") else: # File is not in the mirror — it was either never written or already deleted. diff --git a/ai-hub/integration_tests/test_mcp_file_ops_enhanced.py b/ai-hub/integration_tests/test_mcp_file_ops_enhanced.py new file mode 100644 index 0000000..df9dc18 --- /dev/null +++ b/ai-hub/integration_tests/test_mcp_file_ops_enhanced.py @@ -0,0 +1,152 @@ +import pytest +import os +import httpx +import json +import time +import jwt + +# Use the same BASE_URL as other integration tests +BASE_URL = os.getenv("HUB_API_URL", "http://localhost:8000/api/v1") +SECRET_KEY = os.getenv("SECRET_KEY", "aYc2j1lYUUZXkBFFUndnleZI") + +def _get_token(user_id): + # Generate an internal HS256 JWT + payload = { + "iss": "cortex-hub-internal", + "sub": user_id, + "exp": int(time.time()) + 3600 + } + return jwt.encode(payload, SECRET_KEY, algorithm="HS256") + +def _headers(user_id): + return { + "X-User-ID": user_id, + "Authorization": f"Bearer {_get_token(user_id)}" + } + +@pytest.mark.slow +def test_mcp_file_ops_enhanced(): + """ + Test calling 'move_file', 'copy_file', and 'get_file_stat' tools via MCP. + """ + node_id = os.getenv("SYNC_TEST_NODE1", "test-node-1") + user_id = os.getenv("SYNC_TEST_USER_ID", "admin") + + headers = _headers(user_id) + + with httpx.Client(timeout=15.0) as client: + # 0. Check initial state + r_ls = client.get(f"{BASE_URL}/nodes/{node_id}/fs/ls", params={"path": ".", "session_id": "__fs_explorer__"}, headers=headers) + print(f"[test] Initial LS (.): {r_ls.json().get('files', [])}") + + # 1. Prepare: Write a test file + test_file = "mcp_test_orig.txt" + test_content = "Original Content" + payload_write = { + "jsonrpc": "2.0", + "id": "write-1", + "method": "tools/call", + "params": { + "name": "write_file", + "arguments": { + "node_id": node_id, + "path": test_file, + "content": test_content, + "session_id": "__fs_explorer__" + } + } + } + r = client.post(f"{BASE_URL}/mcp/", json=payload_write, headers=headers) + assert r.status_code == 200, f"MCP write failed: {r.text}" + + # 2. Test get_file_stat + payload_stat = { + "jsonrpc": "2.0", + "id": "stat-1", + "method": "tools/call", + "params": { + "name": "get_file_stat", + "arguments": { + "node_id": node_id, + "path": test_file, + "session_id": "__fs_explorer__" + } + } + } + r = client.post(f"{BASE_URL}/mcp/", json=payload_stat, headers=headers) + assert r.status_code == 200, f"MCP stat failed: {r.text}" + res_stat = r.json() + assert "result" in res_stat, f"Result missing in stat: {res_stat}" + text_val = res_stat["result"]["content"][0]["text"] + data = json.loads(text_val) + assert data.get("exists") is True, f"File does not exist: {data}" + assert data.get("size") == len(test_content), f"Size mismatch: {data}" + print(f"[test] get_file_stat success: {data}") + + # 3. Test copy_file + copy_file = "mcp_test_copy.txt" + payload_copy = { + "jsonrpc": "2.0", + "id": "copy-1", + "method": "tools/call", + "params": { + "name": "copy_file", + "arguments": { + "node_id": node_id, + "old_path": test_file, + "new_path": copy_file, + "session_id": "__fs_explorer__" + } + } + } + r = client.post(f"{BASE_URL}/mcp/", json=payload_copy, headers=headers) + assert r.status_code == 200, f"MCP copy failed: {r.text}" + print(f"[test] copy_file call success") + + # Verify copy exists + time.sleep(1) + r_ls = client.get(f"{BASE_URL}/nodes/{node_id}/fs/ls", params={"path": ".", "session_id": "__fs_explorer__"}, headers=headers) + assert r_ls.status_code == 200, f"LS failed: {r_ls.text}" + files = r_ls.json().get("files", []) + assert any(f["name"] == "mcp_test_copy.txt" for f in files), f"Copy missing: {files}" + + # 4. Test move_file + move_file = "mcp_test_moved.txt" + payload_move = { + "jsonrpc": "2.0", + "id": "move-1", + "method": "tools/call", + "params": { + "name": "move_file", + "arguments": { + "node_id": node_id, + "old_path": test_file, + "new_path": move_file, + "session_id": "__fs_explorer__" + } + } + } + r = client.post(f"{BASE_URL}/mcp/", json=payload_move, headers=headers) + assert r.status_code == 200, f"MCP move failed: {r.text}" + print(f"[test] move_file call success") + + # Verify move + time.sleep(1) + r_ls = client.get(f"{BASE_URL}/nodes/{node_id}/fs/ls", params={"path": ".", "session_id": "__fs_explorer__"}, headers=headers) + assert r_ls.status_code == 200, f"LS failed: {r_ls.text}" + files = r_ls.json().get("files", []) + assert any(f["name"] == "mcp_test_moved.txt" for f in files), f"Moved file missing: {files}" + assert not any(f["name"] == "mcp_test_orig.txt" for f in files), f"Original file still exists: {files}" + + # Cleanup + client.post(f"{BASE_URL}/mcp/", json={ + "jsonrpc": "2.0", "id": "clean-1", "method": "tools/call", + "params": {"name": "remove_file", "arguments": {"node_id": node_id, "path": move_file, "session_id": "__fs_explorer__"}} + }, headers=headers) + client.post(f"{BASE_URL}/mcp/", json={ + "jsonrpc": "2.0", "id": "clean-2", "method": "tools/call", + "params": {"name": "remove_file", "arguments": {"node_id": node_id, "path": copy_file, "session_id": "__fs_explorer__"}} + }, headers=headers) + +if __name__ == "__main__": + test_mcp_file_ops_enhanced() diff --git a/frigate_config.yml b/frigate_config.yml new file mode 100644 index 0000000..7080e68 --- /dev/null +++ b/frigate_config.yml @@ -0,0 +1,285 @@ +go2rtc: + streams: + #Front Door Camera + front-camera-main: + - rtsp://admin:Y%40ngy%40ngX1e@192.168.68.180/cam/realmonitor?channel=1&subtype=0 + front-camera-sub: + - rtsp://admin:Y%40ngy%40ngX1e@192.168.68.180/cam/realmonitor?channel=1&subtype=1 + #Living Room Camera + livingroom-camera-main: + - rtsp://admin:Y%40ngy%40ngX1e@192.168.68.107/cam/realmonitor?channel=1&subtype=0 + - ffmpeg:livingroom-camera-main#audio=opus + livingroom-camera-sub: + - rtsp://admin:Y%40ngy%40ngX1e@192.168.68.107/cam/realmonitor?channel=1&subtype=1 + #Baby Room Camera + babyroom-camera-main: + - rtsp://admin:Y%40ngy%40ngX1e@192.168.68.230/cam/realmonitor?channel=1&subtype=0 + - ffmpeg:babyroom-camera-main#audio=opus + babyroom-camera-sub: + - rtsp://admin:Y%40ngy%40ngX1e@192.168.68.230/cam/realmonitor?channel=1&subtype=1 + # Backyard Camera + backyard-camera-main: + - rtsp://192.168.68.116:554/11 + - ffmpeg:backyard-camera-main#audio=opus + backyard-camera-sub: + - rtsp://192.168.68.116:554/12 +cameras: + babyroom: + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/babyroom-camera-main?video&audio + input_args: preset-rtsp-restream + roles: + - record + - path: rtsp://127.0.0.1:8554/babyroom-camera-sub + input_args: preset-rtsp-restream + roles: + - detect + onvif: + host: 192.168.68.230 + port: 80 + user: admin + password: Y%40ngy%40ngX1e + detect: + width: 640 + height: 480 + fps: 5 + live: + stream_name: babyroom-camera-main + zones: + Baby_Room_Area: + coordinates: 0,0.002,0.002,0.999,0.999,0.996,0.997,0.002 + loitering_time: 0 + objects: + - person + - cat + review: + alerts: + required_zones: Baby_Room_Area + detections: + required_zones: Baby_Room_Area + motion: + mask: + - 0.682,0.04,0.955,0.043,0.96,0.08,0.679,0.082 + - 0.657,0.288,0.625,0.476,0.689,0.655,0.713,0.798,0.724,0.879,0.721,0.915,0.728,0.979,0.746,0.989,0.766,0.903,0.819,0.897,0.83,0.967,0.856,0.994,0.866,0.739,0.85,0.618,0.796,0.489,0.728,0.306,0.704,0.276,0.663,0.276 + backyard: + ffmpeg: + output_args: + record: preset-record-generic-audio-copy + inputs: + # - path: rtsp://192.168.68.116:554/11 + - path: rtsp://127.0.0.1:8554/backyard-camera-main?video&audio + input_args: preset-rtsp-restream + roles: + - record + - path: rtsp://127.0.0.1:8554/backyard-camera-sub + input_args: preset-rtsp-restream + roles: + - detect + live: + stream_name: backyard-camera-main + detect: + width: 800 + height: 600 + fps: 5 + record: + enabled: true + events: + retain: + objects: + car: 0 + +# Optional: Database configuration + motion: + threshold: 35 + contour_area: 20 + improve_contrast: true + zones: + Backyard_Area: + coordinates: + 0.004,0.138,0.313,0.097,0.725,0.109,0.946,0.147,0.997,0.188,0.998,0.998,0.004,0.997 + loitering_time: 0 + objects: person + inertia: 3 + review: + alerts: + required_zones: Backyard_Area + detections: + required_zones: Backyard_Area + objects: + filters: + person: {} + car: + mask: + 0.003,0.135,0.003,0.996,0.998,0.994,0.997,0.184,0.913,0.138,0.63,0.095,0.307,0.098 + cat: + mask: 0.003,0.001,0.006,0.13,0.565,0.098,0.997,0.172,1,-0.001 + frontdoor: + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/front-camera-main + input_args: preset-rtsp-restream + roles: + - record + - path: rtsp://127.0.0.1:8554/front-camera-sub + input_args: preset-rtsp-restream + roles: + - detect + live: + stream_name: front-camera-main + detect: + enabled: true + width: 704 + height: 480 + fps: 10 + + motion: + threshold: 30 + contour_area: 10 + improve_contrast: true + mask: 0.729,0.032,0.974,0.039,0.973,0.081,0.733,0.08,0.729,0.034 + zones: + Front_Door_Area: + coordinates: + 0.465,0,0.512,0.155,0.63,0.483,0.719,0.564,0.748,0.686,0.775,0.711,0.821,0.707,0.844,0.59,0.887,0.475,0.922,0.571,0.94,0.592,0.951,0.486,0.945,0.443,0.954,0.401,0.943,0.385,0.958,0.317,0.978,0.298,0.992,0.324,0.999,0.995,0.001,0.998,0.001,0.004 + loitering_time: 0 + objects: + - car + - person + inertia: 3 + review: + alerts: + required_zones: Front_Door_Area + detections: + required_zones: Front_Door_Area + objects: + filters: + car: {} + person: {} + livingroom: + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/livingroom-camera-main?video&audio + input_args: preset-rtsp-restream + roles: + - record + - path: rtsp://127.0.0.1:8554/livingroom-camera-sub + input_args: preset-rtsp-restream + roles: + - detect + detect: + width: 640 + height: 480 + fps: 5 + live: + stream_name: livingroom-camera-main + objects: {} + zones: + Living_Room_Area: + coordinates: + 0.092,0,0,-0.001,0,0.996,0.999,0.994,0.999,0.002,0.901,0.001,0.788,0.06,0.788,0.135,0.742,0.174,0.721,0.177,0.716,0.202,0.659,0.234,0.585,0.221,0.517,0.259,0.225,0.416,0.162,0.437 + loitering_time: 0 + objects: + - cat + - person + Hallway: + coordinates: + 0.52,0.005,0.528,0.206,0.656,0.227,0.709,0.2,0.718,0.175,0.786,0.132,0.786,0.063,0.862,0.017,0.883,0.001 + loitering_time: 0 + objects: + - cat + - person + Dining_Area: + coordinates: + 0.095,0.001,0.165,0.424,0.219,0.411,0.451,0.285,0.543,0.234,0.527,0.213,0.522,0.182,0.513,0.001 + loitering_time: 0 + objects: + - cat + - person + review: + alerts: + required_zones: + - Dining_Area + - Hallway + - Living_Room_Area + detections: + required_zones: + - Dining_Area + - Hallway + - Living_Room_Area + motion: + threshold: 30 + contour_area: 44 + improve_contrast: true +database: + # The path to store the SQLite DB (default: shown below) + path: /config/frigate.db + +detectors: + coral: + type: edgetpu + device: usb + +objects: + track: + - person + - cat + - car + # filters: + # person: + # threshold: 0.78 + +snapshots: + enabled: true + timestamp: true + bounding_box: true + retain: + default: 7 + +mqtt: + enabled: true + host: 192.168.68.161 + port: 1883 + user: client + password: a6163484a + +logger: + default: info + +ffmpeg: + # hwaccel_args: + # - preset-vaapi + # - -hwaccel + # - vaapi + # - -hwaccel_device + # - /dev/dri/renderD128 + # - -hwaccel_output_format + # - yuv420p + output_args: + record: -f segment -segment_time 10 -segment_format mp4 -reset_timestamps 1 -strftime + 1 -c:v copy -c:a aac + +record: + enabled: true + events: + pre_capture: 5 + post_capture: 5 + retain: + default: 30 + mode: active_objects + objects: + - person + - bird + - cat + - dog + +version: 0.14 +camera_groups: + Home: + order: 1 + icon: LuHome + cameras: + - backyard + - frontdoor + - livingroom + - babyroom diff --git a/frontend/src/features/swarm/pages/SwarmControlPage.js b/frontend/src/features/swarm/pages/SwarmControlPage.js index 5e47f94..8c328d3 100644 --- a/frontend/src/features/swarm/pages/SwarmControlPage.js +++ b/frontend/src/features/swarm/pages/SwarmControlPage.js @@ -67,7 +67,11 @@ return localStorage.getItem("swarm_auto_collapse") === "true"; }); - const { registerTool, unregisterTool } = useWebMcp(); + const { registerTool, unregisterTool, setIsAiProcessing } = useWebMcp(); + + useEffect(() => { + setIsAiProcessing(isProcessing); + }, [isProcessing, setIsAiProcessing]); const onNewSessionCreated = useCallback(async (newSid) => { try { @@ -303,6 +307,97 @@ } }); + registerTool({ + name: 'move_file', + description: 'Move or rename a file/directory on an agent node.', + inputSchema: { + type: 'object', + properties: { + node_id: { type: 'string', description: 'Target node ID.' }, + old_path: { type: 'string', description: 'Source path.' }, + new_path: { type: 'string', description: 'Destination path.' } + }, + required: ['node_id', 'old_path', 'new_path'] + }, + execute: async ({ node_id, old_path, new_path }) => { + try { + const { nodeFsMv } = await import("../../../services/apiService"); + const res = await nodeFsMv(node_id, old_path, new_path, sessionId); + return { content: [{ type: 'text', text: JSON.stringify(res, null, 2) }] }; + } catch (err) { + return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true }; + } + } + }); + + registerTool({ + name: 'copy_file', + description: 'Copy a file/directory on an agent node.', + inputSchema: { + type: 'object', + properties: { + node_id: { type: 'string', description: 'Target node ID.' }, + old_path: { type: 'string', description: 'Source path.' }, + new_path: { type: 'string', description: 'Destination path.' } + }, + required: ['node_id', 'old_path', 'new_path'] + }, + execute: async ({ node_id, old_path, new_path }) => { + try { + const { nodeFsCp } = await import("../../../services/apiService"); + const res = await nodeFsCp(node_id, old_path, new_path, sessionId); + return { content: [{ type: 'text', text: JSON.stringify(res, null, 2) }] }; + } catch (err) { + return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true }; + } + } + }); + + registerTool({ + name: 'rename_file', + description: 'Rename a file/directory on an agent node (alias for move_file).', + inputSchema: { + type: 'object', + properties: { + node_id: { type: 'string', description: 'Target node ID.' }, + old_path: { type: 'string', description: 'Source path.' }, + new_path: { type: 'string', description: 'Destination path.' } + }, + required: ['node_id', 'old_path', 'new_path'] + }, + execute: async ({ node_id, old_path, new_path }) => { + try { + const { nodeFsMv } = await import("../../../services/apiService"); + const res = await nodeFsMv(node_id, old_path, new_path, sessionId); + return { content: [{ type: 'text', text: JSON.stringify(res, null, 2) }] }; + } catch (err) { + return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true }; + } + } + }); + + registerTool({ + name: 'get_file_stat', + description: 'Get metadata for a file in the Hub mirror.', + inputSchema: { + type: 'object', + properties: { + node_id: { type: 'string', description: 'Target node ID.' }, + path: { type: 'string', description: 'Path to stat.' } + }, + required: ['node_id', 'path'] + }, + execute: async ({ node_id, path }) => { + try { + const { nodeFsStat } = await import("../../../services/apiService"); + const res = await nodeFsStat(node_id, path, sessionId); + return { content: [{ type: 'text', text: JSON.stringify(res, null, 2) }] }; + } catch (err) { + return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true }; + } + } + }); + return () => { unregisterTool('get_session_nodes'); unregisterTool('list_accessible_nodes'); @@ -312,6 +407,10 @@ unregisterTool('download_file'); unregisterTool('remove_file'); unregisterTool('create_file'); + unregisterTool('move_file'); + unregisterTool('copy_file'); + unregisterTool('rename_file'); + unregisterTool('get_file_stat'); }; }, [sessionId, accessibleNodes, attachedNodeIds, workspaceId, localActiveLLM, isConfigured, registerTool, unregisterTool]); // ───────────────────────────────────────────────────────────────────────── @@ -553,6 +652,7 @@ onSwitchSession={handleSwitchSession} onNewSession={() => handleSendChat("/new")} refreshTick={sidebarRefreshTick} + isProcessing={isProcessing} /> {/* Main content area */} diff --git a/frontend/src/shared/components/Navbar.js b/frontend/src/shared/components/Navbar.js index b0ee2c5..ad99b6e 100644 --- a/frontend/src/shared/components/Navbar.js +++ b/frontend/src/shared/components/Navbar.js @@ -3,7 +3,7 @@ import { useWebMcp } from './WebMcpProvider'; const Navbar = ({ isOpen, onToggle, onNavigate, onLogout, isLoggedIn, user, Icon }) => { - const { isMcpActive } = useWebMcp(); + const { isMcpActive, isAiProcessing } = useWebMcp(); const navItems = [ { name: "Home", icon: "M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z", page: "home" }, { name: "Voice Chat", icon: "M12 1a3 3 0 0 1 3 3v7a3 3 0 1 1-6 0V4a3 3 0 0 1 3-3zm5 10a5 5 0 0 1-10 0H5a7 7 0 0 0 14 0h-2zm-5 11v-4h-2v4h2z", page: "voice-chat" }, @@ -16,6 +16,10 @@ { name: "Favorites", icon: "M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z", page: "favorites", disabled: true }, ]; + const mcpColorClass = isAiProcessing ? 'text-amber-500' : (isMcpActive ? 'text-green-500' : 'text-gray-400 opacity-50'); + const mcpBgClass = isAiProcessing ? 'bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.6)]' : (isMcpActive ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]' : 'bg-gray-400'); + const mcpStatusText = isAiProcessing ? 'AI Processing' : (isMcpActive ? 'Protocol Active' : 'Native API Missing'); + return (