diff --git a/ai-hub/app/api/routes/nodes.py b/ai-hub/app/api/routes/nodes.py index bdf96a5..cc44c98 100644 --- a/ai-hub/app/api/routes/nodes.py +++ b/ai-hub/app/api/routes/nodes.py @@ -28,8 +28,8 @@ import secrets from typing import Optional, Annotated import logging -from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, Depends, Query, Header, Request -from fastapi.responses import StreamingResponse +from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, Depends, Query, Header, Request, UploadFile, File +from fastapi.responses import StreamingResponse, FileResponse from sqlalchemy.orm import Session @@ -1084,7 +1084,7 @@ res = orchestrator.assistant.write( node_id, req.path, - req.content.encode('utf-8'), + req.content.encode('utf-8') if isinstance(req.content, str) else req.content, req.is_dir, session_id=req.session_id ) @@ -1099,6 +1099,74 @@ logger.error(f"[FS] Touch error: {e}") raise HTTPException(status_code=500, detail=str(e)) + @router.get("/{node_id}/fs/download", summary="Download File") + def fs_download( + node_id: str, + path: str = Query(...), + session_id: str = "__fs_explorer__", + user_id: str = Header(..., alias="X-User-ID"), + db: Session = Depends(get_db) + ): + """ + Download a file from an agent node. + Triggers a fetch to the hub's mirror, then serves it. + """ + _require_node_access(user_id, node_id, db) + try: + orchestrator = services.orchestrator + # First, trigger the cat to get it into the mirror + orchestrator.assistant.cat(node_id, path, session_id=session_id) + + # Now, serve from mirror + workspace = orchestrator.mirror.get_workspace_path(session_id) + abs_path = os.path.normpath(os.path.join(workspace, path.lstrip("/"))) + + # Wait a moment for it to land on disk if it's large + import time + max_wait = 5.0 + start = time.time() + while not os.path.exists(abs_path) and (time.time() - start) < max_wait: + time.sleep(0.2) + + if not os.path.exists(abs_path): + raise HTTPException(status_code=404, detail="File did not reach mirror in time.") + + return FileResponse(abs_path, filename=os.path.basename(path)) + except Exception as e: + logger.error(f"[FS] Download error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/{node_id}/fs/upload", summary="Upload File") + async def fs_upload( + node_id: str, + path: str = Query(...), + file: UploadFile = File(...), + session_id: str = "__fs_explorer__", + user_id: str = Header(..., alias="X-User-ID"), + db: Session = Depends(get_db) + ): + """ + Upload a file to an agent node. + """ + _require_node_access(user_id, node_id, db) + try: + orchestrator = services.orchestrator + content = await file.read() + # If path ends in /, treat as parent directory + full_path = os.path.join(path, file.filename) if path.endswith("/") or path == "." else path + + res = orchestrator.assistant.write( + node_id, + full_path, + content, + is_dir=False, + session_id=session_id + ) + return res + except Exception as e: + logger.error(f"[FS] Upload error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + @router.post("/{node_id}/fs/rm", summary="Delete File/Directory") def fs_rm( node_id: str, diff --git a/frontend/src/components/FileSystemNavigator.js b/frontend/src/components/FileSystemNavigator.js index 8e8d1a6..18de142 100644 --- a/frontend/src/components/FileSystemNavigator.js +++ b/frontend/src/components/FileSystemNavigator.js @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { nodeFsList, nodeFsCat, nodeFsTouch, nodeFsRm } from '../services/apiService'; +import { nodeFsList, nodeFsCat, nodeFsTouch, nodeFsRm, nodeFsUpload, nodeFsDownloadBlob } from '../services/apiService'; /** * A modular File Navigator component similar to VS Code's side panel. @@ -21,6 +21,9 @@ const [deleteModal, setDeleteModal] = useState(null); // path const [operationLoading, setOperationLoading] = useState(false); const [folderLoading, setFolderLoading] = useState({}); // { [path]: boolean } + const [previewImage, setPreviewImage] = useState(null); // { path, url } + const uploadInputRef = React.useRef(null); + const [uploadTargetPath, setUploadTargetPath] = useState(null); const fetchLevel = useCallback(async (path) => { const data = await nodeFsList(nodeId, path, sessionId); @@ -137,11 +140,31 @@ const isBinaryFile = (path) => { const ext = path.split('.').pop().toLowerCase(); - const binaryExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'pdf', 'zip', 'gz', 'tar', 'exe', 'dll', 'so', 'bin', 'pyc', 'node', 'db', 'sqlite']; + const binaryExts = ['pdf', 'zip', 'gz', 'tar', 'exe', 'dll', 'so', 'bin', 'pyc', 'node', 'db', 'sqlite', 'mp3', 'mp4', 'wav', 'mov']; return binaryExts.includes(ext); }; + const isImageFile = (path) => { + const ext = path.split('.').pop().toLowerCase(); + const imgExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp']; + return imgExts.includes(ext); + }; + const handleView = async (path) => { + if (isImageFile(path)) { + setOperationLoading(true); + try { + const blob = await nodeFsDownloadBlob(nodeId, path, sessionId); + const url = URL.createObjectURL(blob); + setPreviewImage({ path, url }); + } catch (err) { + setError(`Failed to load image: ${err.message}`); + } finally { + setOperationLoading(false); + } + return; + } + if (isBinaryFile(path)) { setError(`Cannot view binary file: ${path}. Please download it instead.`); return; @@ -160,6 +183,51 @@ } }; + const handleDownload = async (path) => { + setOperationLoading(true); + setError(null); + try { + const blob = await nodeFsDownloadBlob(nodeId, path, sessionId); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', path.split('/').pop()); + document.body.appendChild(link); + link.click(); + link.remove(); + } catch (err) { + setError(`Download failed: ${err.message}`); + } finally { + setOperationLoading(false); + } + }; + + const handleUploadClick = (targetPath) => { + setUploadTargetPath(targetPath); + if (uploadInputRef.current) { + uploadInputRef.current.click(); + } + }; + + const handleFileUpload = async (event) => { + const file = event.target.files[0]; + if (!file || !uploadTargetPath) return; + + setOperationLoading(true); + setError(null); + try { + // If uploadTargetPath is ".", upload to root. If a dir path, use as-is. + await nodeFsUpload(nodeId, uploadTargetPath, file, sessionId); + setTimeout(loadRoot, 600); + } catch (err) { + setError(`Upload failed: ${err.message}`); + } finally { + setOperationLoading(false); + setUploadTargetPath(null); + event.target.value = ''; // Reset input + } + }; + const handleSave = async () => { setOperationLoading(true); setError(null); @@ -233,8 +301,11 @@
- {node.is_dir && ( + {node.is_dir ? ( <> + @@ -242,6 +313,10 @@ + ) : ( + )} @@ -407,6 +485,48 @@
)} + {/* Hidden File Input for Uploads */} + + + {/* Image Preview Modal */} + {previewImage && ( +
+
+ + +
+
+ {previewImage.path} +

+ {previewImage.path} +

+
+
+ )} ); }; diff --git a/frontend/src/services/apiService.js b/frontend/src/services/apiService.js index c8a89b0..d10b3ec 100644 --- a/frontend/src/services/apiService.js +++ b/frontend/src/services/apiService.js @@ -1000,6 +1000,44 @@ }; /** + * [FS] Upload a file to an agent node via multipart form. + */ +export const nodeFsUpload = async (nodeId, path, file, sessionId = null) => { + const userId = getUserId(); + const formData = new FormData(); + formData.append("file", file); + + const params = new URLSearchParams({ path }); + if (sessionId) params.append("session_id", sessionId); + + const response = await fetch(`${NODES_BASE_ENDPOINT}/${nodeId}/fs/upload?${params.toString()}`, { + method: "POST", + headers: { + "X-User-ID": userId, + }, + body: formData, + }); + if (!response.ok) throw new Error("Failed to upload file"); + return await response.json(); +}; + +/** + * [FS] Downloads a file as a blob (preserves headers). + */ +export const nodeFsDownloadBlob = async (nodeId, path, sessionId = null) => { + const userId = getUserId(); + const params = new URLSearchParams({ path }); + if (sessionId) params.append("session_id", sessionId); + + const response = await fetch(`${NODES_BASE_ENDPOINT}/${nodeId}/fs/download?${params.toString()}`, { + method: "GET", + headers: { "X-User-ID": userId }, + }); + if (!response.ok) throw new Error("Failed to download file"); + return await response.blob(); +}; + +/** * [FS] Delete a file or directory from an agent node. */ export const nodeFsRm = async (nodeId, path, sessionId = null) => {