diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index 57f8a42d..917aa023 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -1145,6 +1145,14 @@ async def _on_start(self) -> None: "/api/living-ui/import", self._living_ui_import_handler ) + # Workspace and chat HTTP upload routes + self._app.router.add_post( + "/api/workspace/upload", self._workspace_upload_handler + ) + self._app.router.add_post( + "/api/chat-attachments/upload", self._chat_attachment_upload_handler + ) + # Integration bridge routes (Living UI → external APIs) from app.living_ui.integration_bridge import IntegrationBridge @@ -2920,6 +2928,126 @@ async def _living_ui_import_handler(self, request: "web.Request") -> "web.Respon logger.error(f"[LIVING_UI] Upload staging error: {e}") return web.json_response({"error": str(e)}, status=500) + async def _workspace_upload_handler(self, request: "web.Request") -> "web.Response": + """HTTP handler: stream-upload a file directly into the workspace. + + Accepts multipart/form-data with a single 'file' field. + The target path is passed as the 'path' query parameter. + """ + from aiohttp import web + + try: + file_path = request.rel_url.query.get("path", "").strip() + if not file_path: + return web.json_response( + {"success": False, "error": "Missing 'path' query parameter"}, + status=400, + ) + + target = self._validate_path(file_path) + target.parent.mkdir(parents=True, exist_ok=True) + + reader = await request.multipart() + written = False + async for part in reader: + if part.name == "file": + with open(target, "wb") as f: + while True: + chunk = await part.read_chunk() + if not chunk: + break + f.write(chunk) + written = True + break + + if not written: + return web.json_response( + {"success": False, "error": "No file field in request"}, + status=400, + ) + + file_info = self._get_file_info(target) + + await self._broadcast( + { + "type": "file_upload", + "data": { + "path": file_path, + "fileInfo": file_info, + "success": True, + }, + } + ) + + return web.json_response( + {"success": True, "path": file_path, "fileInfo": file_info} + ) + except ValueError as e: + return web.json_response({"success": False, "error": str(e)}, status=400) + except Exception as e: + logger.error(f"[WORKSPACE] Upload error: {e}") + return web.json_response({"success": False, "error": str(e)}, status=500) + + async def _chat_attachment_upload_handler( + self, request: "web.Request" + ) -> "web.Response": + """HTTP handler: stream-upload a chat attachment into workspace/download/. + + Accepts multipart/form-data with a single 'file' field. + Pass 'name' and 'type' as query parameters. + """ + import uuid + from aiohttp import web + + try: + name = request.rel_url.query.get("name", "attachment").strip() or "attachment" + file_type = ( + request.rel_url.query.get("type", "application/octet-stream").strip() + or "application/octet-stream" + ) + + download_dir = Path(AGENT_WORKSPACE_ROOT) / "download" + download_dir.mkdir(parents=True, exist_ok=True) + + unique_name = f"{uuid.uuid4().hex[:8]}_{name}" + file_path = download_dir / unique_name + relative_path = f"download/{unique_name}" + + reader = await request.multipart() + size = 0 + written = False + async for part in reader: + if part.name == "file": + with open(file_path, "wb") as f: + while True: + chunk = await part.read_chunk() + if not chunk: + break + f.write(chunk) + size += len(chunk) + written = True + break + + if not written: + return web.json_response( + {"success": False, "error": "No file field in request"}, + status=400, + ) + + return web.json_response( + { + "success": True, + "serverPath": relative_path, + "url": f"/api/workspace/{relative_path}", + "name": name, + "size": size, + "type": file_type, + } + ) + except Exception as e: + logger.error(f"[CHAT ATTACHMENT] Upload error: {e}") + return web.json_response({"success": False, "error": str(e)}, status=500) + async def _handle_living_ui_state_update(self, data: Dict[str, Any]) -> None: """Handle state update from a Living UI for agent awareness.""" try: @@ -7350,8 +7478,10 @@ async def _handle_chat_message_with_attachments( unique_name = f"{uuid.uuid4().hex[:8]}_{name}" file_path = download_dir / unique_name relative_path = f"download/{unique_name}" + server_path = att.get("serverPath", "") - # Save file to workspace + # Save file to workspace (base64 inline) or reference a + # file that was already uploaded via HTTP pre-upload. if content_b64: try: file_content = base64.b64decode(content_b64) @@ -7362,6 +7492,20 @@ async def _handle_chat_message_with_attachments( f"[BROWSER ADAPTER] Error saving attachment {name}: {e}" ) continue + elif server_path: + # File was pre-uploaded via HTTP; it already lives in + # workspace/download/ — use its existing path directly. + pre_uploaded = Path(AGENT_WORKSPACE_ROOT) / server_path + if not pre_uploaded.exists(): + print( + f"[BROWSER ADAPTER] Pre-uploaded file missing: {server_path}" + ) + continue + relative_path = server_path + file_path = pre_uploaded + size = file_path.stat().st_size + else: + continue # Create attachment object attachment = Attachment( @@ -8037,9 +8181,16 @@ async def _agent_profile_picture_handler( raise web.HTTPInternalServerError(reason=str(e)) async def _workspace_file_handler(self, request: "web.Request") -> "web.Response": - """Serve files from the workspace directory.""" + """Serve files from the workspace directory. + + Pass ?download=1 to force Content-Disposition: attachment (triggers a + browser Save-As dialog). Omitting the param keeps 'inline' so chat + attachment previews continue to work as before. + + Uses web.FileResponse for true streaming — no full-file read into RAM — + which supports arbitrarily large files and HTTP Range requests. + """ from aiohttp import web - import mimetypes try: file_path = request.match_info.get("path", "") @@ -8047,7 +8198,6 @@ async def _workspace_file_handler(self, request: "web.Request") -> "web.Response if not file_path: raise web.HTTPNotFound() - # Validate and get absolute path target = self._validate_path(file_path) if not target.exists(): @@ -8056,19 +8206,14 @@ async def _workspace_file_handler(self, request: "web.Request") -> "web.Response if target.is_dir(): raise web.HTTPBadRequest(reason="Cannot serve directory") - # Determine content type - mime_type, _ = mimetypes.guess_type(target.name) - if mime_type is None: - mime_type = "application/octet-stream" - - # Read and serve file - content = target.read_bytes() + disposition = ( + "attachment" if request.rel_url.query.get("download") else "inline" + ) - return web.Response( - body=content, - content_type=mime_type, + return web.FileResponse( + target, headers={ - "Content-Disposition": f'inline; filename="{target.name}"', + "Content-Disposition": f'{disposition}; filename="{target.name}"', "Cache-Control": "no-cache", }, ) diff --git a/app/ui_layer/browser/frontend/src/components/Chat/Chat.module.css b/app/ui_layer/browser/frontend/src/components/Chat/Chat.module.css index e1e5eb0b..b6550918 100644 --- a/app/ui_layer/browser/frontend/src/components/Chat/Chat.module.css +++ b/app/ui_layer/browser/frontend/src/components/Chat/Chat.module.css @@ -220,6 +220,17 @@ flex-shrink: 0; } +.uploadingSpinner { + flex-shrink: 0; + color: var(--color-primary); + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + .pendingFileName { max-width: 120px; overflow: hidden; diff --git a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx index 80f9a428..1ce8f932 100644 --- a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx +++ b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx @@ -14,7 +14,10 @@ interface PendingAttachment { name: string type: string size: number - content: string // base64 + content: string // base64 for small files; '' when serverPath is set + serverPath?: string // set after HTTP pre-upload for large files + url?: string // server URL returned with serverPath (for preview) + uploadStatus?: 'uploading' | 'ready' | 'error' } interface ChatProps { @@ -44,7 +47,8 @@ const MIC_LANGUAGES = [ // Attachment limits const MAX_ATTACHMENT_COUNT = 10 -const MAX_TOTAL_SIZE_BYTES = 70 * 1024 * 1024 // 70MB +const MAX_TOTAL_SIZE_BYTES = 200 * 1024 * 1024 // 200MB hard cap +const HTTP_UPLOAD_THRESHOLD = 50 * 1024 * 1024 // >50MB → HTTP pre-upload const formatFileSize = (bytes: number): string => { if (bytes === 0) return '0 B' @@ -159,7 +163,7 @@ export function Chat({ livingUIId, placeholder, emptyMessage }: ChatProps) { return { valid: false, error: `Maximum ${MAX_ATTACHMENT_COUNT} files allowed. You have ${count} files.` } } if (totalSize > MAX_TOTAL_SIZE_BYTES) { - return { valid: false, error: `Total size (${formatFileSize(totalSize)}) exceeds 70MB limit.` } + return { valid: false, error: `Total size (${formatFileSize(totalSize)}) exceeds 200 MB limit.` } } return { valid: true, error: null } }, [pendingAttachments]) @@ -456,6 +460,45 @@ export function Chat({ livingUIId, placeholder, emptyMessage }: ChatProps) { fileInputRef.current?.click() } + const uploadChatAttachment = (file: globalThis.File, placeholder: PendingAttachment) => { + const formData = new FormData() + formData.append('file', file) + + const xhr = new XMLHttpRequest() + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const data = JSON.parse(xhr.responseText) + if (data.success) { + setPendingAttachments(prev => prev.map(a => + a === placeholder + ? { ...a, serverPath: data.serverPath, url: data.url, uploadStatus: 'ready' as const } + : a + )) + return + } + } catch {} + } + setPendingAttachments(prev => prev.map(a => + a === placeholder ? { ...a, uploadStatus: 'error' as const } : a + )) + setAttachmentError(`Failed to upload "${file.name}".`) + } + + xhr.onerror = () => { + setPendingAttachments(prev => prev.map(a => + a === placeholder ? { ...a, uploadStatus: 'error' as const } : a + )) + setAttachmentError(`Failed to upload "${file.name}": network error.`) + } + + const name = encodeURIComponent(file.name) + const type = encodeURIComponent(file.type || 'application/octet-stream') + xhr.open('POST', `/api/chat-attachments/upload?name=${name}&type=${type}`) + xhr.send(formData) + } + const processFiles = async (files: globalThis.File[]) => { if (files.length === 0) return @@ -465,30 +508,51 @@ export function Chat({ livingUIId, placeholder, emptyMessage }: ChatProps) { return } - const newAttachments: PendingAttachment[] = [] let newTotalSize = pendingAttachments.reduce((sum, att) => sum + att.size, 0) + // Validate sizes first before doing any I/O for (const file of files) { if (file.size > MAX_TOTAL_SIZE_BYTES) { - setAttachmentError(`File "${file.name}" (${formatFileSize(file.size)}) exceeds the 70MB limit.`) + setAttachmentError(`File "${file.name}" (${formatFileSize(file.size)}) exceeds the 200 MB limit.`) return } if (newTotalSize + file.size > MAX_TOTAL_SIZE_BYTES) { - setAttachmentError(`Adding "${file.name}" would exceed the 70MB total size limit.`) - return - } - try { - const content = await readFileAsBase64(file) - newAttachments.push({ name: file.name, type: file.type || 'application/octet-stream', size: file.size, content }) - newTotalSize += file.size - } catch { - setAttachmentError(`Failed to read file "${file.name}".`) + setAttachmentError(`Adding "${file.name}" would exceed the 200 MB total size limit.`) return } + newTotalSize += file.size } setAttachmentError(null) - setPendingAttachments(prev => [...prev, ...newAttachments]) + + for (const file of files) { + if (file.size <= HTTP_UPLOAD_THRESHOLD) { + // Small file: read to base64 inline + try { + const content = await readFileAsBase64(file) + setPendingAttachments(prev => [...prev, { + name: file.name, + type: file.type || 'application/octet-stream', + size: file.size, + content, + }]) + } catch { + setAttachmentError(`Failed to read file "${file.name}".`) + return + } + } else { + // Large file: HTTP pre-upload so it never gets base64-encoded in memory + const placeholder: PendingAttachment = { + name: file.name, + type: file.type || 'application/octet-stream', + size: file.size, + content: '', + uploadStatus: 'uploading', + } + setPendingAttachments(prev => [...prev, placeholder]) + uploadChatAttachment(file, placeholder) + } + } } const handleFileSelect = async (e: ChangeEvent) => { @@ -711,9 +775,11 @@ export function Chat({ livingUIId, placeholder, emptyMessage }: ChatProps) { onClick={() => openPreview(att)} title="Click to preview" > - {att.type.startsWith('image/') ? ( + {att.uploadStatus === 'uploading' ? ( + + ) : att.type.startsWith('image/') ? ( {att.name} diff --git a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx index a5fb608b..e58cee35 100644 --- a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx +++ b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx @@ -97,7 +97,8 @@ interface PendingAttachment { name: string type: string size: number - content: string // base64 + content: string // base64 for small files; '' when serverPath is set + serverPath?: string // pre-uploaded via HTTP (large files) } // Reply target for reply-to-chat/task feature @@ -405,7 +406,10 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { sendOrQueue(JSON.stringify({ type: 'message', content, - attachments: attachments || [], + attachments: (attachments || []).map(att => att.serverPath + ? { name: att.name, type: att.type, size: att.size, serverPath: att.serverPath } + : { name: att.name, type: att.type, size: att.size, content: att.content } + ), replyContext: replyContext || null, livingUIId: livingUIId || null, clientId, diff --git a/app/ui_layer/browser/frontend/src/contexts/WorkspaceContext.tsx b/app/ui_layer/browser/frontend/src/contexts/WorkspaceContext.tsx index bc8c5384..de91fb64 100644 --- a/app/ui_layer/browser/frontend/src/contexts/WorkspaceContext.tsx +++ b/app/ui_layer/browser/frontend/src/contexts/WorkspaceContext.tsx @@ -11,7 +11,6 @@ import type { FileMoveResponse, FileCopyResponse, FileUploadResponse, - FileDownloadResponse, WSMessage, } from '../types' import { getSocketClient } from '../store/socket/socketInstance' @@ -85,7 +84,7 @@ interface WorkspaceContextType { batchDelete: (paths: string[]) => Promise moveFile: (srcPath: string, destPath: string) => Promise copyFile: (srcPath: string, destPath: string) => Promise - uploadFile: (path: string, file: File) => Promise + uploadFile: (path: string, file: File, onProgress?: (percent: number) => void) => Promise downloadFile: (path: string) => Promise } @@ -243,41 +242,52 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { sendOperation('file_copy', { srcPath, destPath }, 'file_copy'), [sendOperation]) - const uploadFile = useCallback(async (path: string, file: File): Promise => { + const uploadFile = useCallback(async ( + path: string, + file: File, + onProgress?: (percent: number) => void, + ): Promise => { return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = async () => { + const formData = new FormData() + formData.append('file', file) + + const xhr = new XMLHttpRequest() + + if (onProgress) { + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)) + } + } + + xhr.onload = () => { try { - const base64 = (reader.result as string).split(',')[1] - const response = await sendOperation( - 'file_upload', { path, content: base64 }, 'file_upload', - ) - resolve(response) - } catch (e) { - reject(e) + const data = JSON.parse(xhr.responseText) as FileUploadResponse + if (xhr.status >= 200 && xhr.status < 300 && data.success) { + resolve(data) + } else { + reject(new Error((data as { error?: string }).error ?? `Upload failed (HTTP ${xhr.status})`)) + } + } catch { + reject(new Error(`Upload failed (HTTP ${xhr.status})`)) } } - reader.onerror = () => reject(new Error('Failed to read file')) - reader.readAsDataURL(file) + xhr.onerror = () => reject(new Error('Network error during upload')) + xhr.onabort = () => reject(new Error('Upload cancelled')) + + xhr.open('POST', `/api/workspace/upload?path=${encodeURIComponent(path)}`) + xhr.send(formData) }) - }, [sendOperation]) + }, []) const downloadFile = useCallback(async (path: string): Promise => { try { - const response = await sendOperation( - 'file_download', { path }, 'file_download', - ) - if (response.success && response.content) { - const byteString = atob(response.content) - const bytes = new Uint8Array(byteString.length) - for (let i = 0; i < byteString.length; i++) bytes[i] = byteString.charCodeAt(i) - return new Blob([bytes]) - } - return null + const response = await fetch(`/api/workspace/${path}?download=1`) + if (!response.ok) return null + return await response.blob() } catch { return null } - }, [sendOperation]) + }, []) // ───────────────────────────────────────────────────────────────────── // Effects diff --git a/app/ui_layer/browser/frontend/src/pages/Workspace/WorkspacePage.module.css b/app/ui_layer/browser/frontend/src/pages/Workspace/WorkspacePage.module.css index a33e6079..ff0d312d 100644 --- a/app/ui_layer/browser/frontend/src/pages/Workspace/WorkspacePage.module.css +++ b/app/ui_layer/browser/frontend/src/pages/Workspace/WorkspacePage.module.css @@ -650,6 +650,60 @@ display: none; } +/* ───────────────────────────────────────────────────────────────────── + Upload Progress + ───────────────────────────────────────────────────────────────────── */ + +.uploadProgressSection { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-2) var(--space-4); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); +} + +.uploadProgressItem { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 20px; +} + +.uploadFileName { + flex: 0 0 120px; + font-size: var(--text-xs); + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.uploadProgressTrack { + flex: 1; + height: 4px; + background: var(--border-color); + border-radius: 2px; + overflow: hidden; + min-width: 60px; +} + +.uploadProgressFill { + height: 100%; + background: var(--color-primary); + border-radius: 2px; + transition: width 0.15s ease; +} + +.uploadProgressPct { + width: 34px; + text-align: right; + font-size: var(--text-xs); + color: var(--text-muted); + flex-shrink: 0; +} + /* ───────────────────────────────────────────────────────────────────── Responsive ───────────────────────────────────────────────────────────────────── */ diff --git a/app/ui_layer/browser/frontend/src/pages/Workspace/WorkspacePage.tsx b/app/ui_layer/browser/frontend/src/pages/Workspace/WorkspacePage.tsx index addecc64..543ab695 100644 --- a/app/ui_layer/browser/frontend/src/pages/Workspace/WorkspacePage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Workspace/WorkspacePage.tsx @@ -127,6 +127,9 @@ export function WorkspacePage() { const [selectedFiles, setSelectedFiles] = useState>(new Set()) const [lastSelectedIndex, setLastSelectedIndex] = useState(-1) + // Upload progress: filename → 0–100 + const [uploadProgress, setUploadProgress] = useState>({}) + // UI state const [contextMenu, setContextMenu] = useState<{ x: number; y: number; file: FileItem } | null>(null) const [emptySpaceMenu, setEmptySpaceMenu] = useState<{ x: number; y: number } | null>(null) @@ -320,8 +323,10 @@ export function WorkspacePage() { if (result.success) { setShowCreateDialog(null) setCreateName('') + } else { + showToast('error', result.error ?? `Failed to create ${showCreateDialog}`) } - }, [createName, showCreateDialog, currentDirectory, createFile]) + }, [createName, showCreateDialog, currentDirectory, createFile, showToast]) const handleRenameSubmit = useCallback(async () => { if (!editingFile || !editName.trim()) return @@ -333,8 +338,10 @@ export function WorkspacePage() { setEditingFile(null) setEditName('') setEditExt('') + } else { + showToast('error', result.error ?? 'Failed to rename') } - }, [editingFile, editName, editExt, renameFile]) + }, [editingFile, editName, editExt, renameFile, showToast]) const handleDelete = useCallback((paths: string[]) => { if (paths.length === 0) return @@ -438,12 +445,30 @@ export function WorkspacePage() { }, [clipboard, currentDirectory, copyFile, moveFile, refresh, listDirectory]) const handleUpload = useCallback(async (uploadFiles: FileList) => { + const MAX_UPLOAD_BYTES = 200 * 1024 * 1024 for (const file of Array.from(uploadFiles)) { + if (file.size > MAX_UPLOAD_BYTES) { + showToast('error', `"${file.name}" (${formatFileSize(file.size)}) exceeds the 200 MB upload limit`) + continue + } const path = currentDirectory ? `${currentDirectory}/${file.name}` : file.name - await uploadFile(path, file) + setUploadProgress(prev => ({ ...prev, [file.name]: 0 })) + try { + await uploadFile(path, file, (pct) => + setUploadProgress(prev => ({ ...prev, [file.name]: pct })) + ) + } catch (e) { + showToast('error', `Failed to upload "${file.name}": ${(e as Error).message}`) + } finally { + setUploadProgress(prev => { + const next = { ...prev } + delete next[file.name] + return next + }) + } } await refresh() - }, [currentDirectory, uploadFile, refresh]) + }, [currentDirectory, uploadFile, refresh, showToast]) const handleDownload = useCallback(async (path: string, fileName: string) => { const blob = await downloadFile(path) @@ -869,6 +894,15 @@ export function WorkspacePage() { Download )} + - ) @@ -996,6 +1021,21 @@ export function WorkspacePage() { + {/* Upload Progress */} + {Object.keys(uploadProgress).length > 0 && ( +
+ {Object.entries(uploadProgress).map(([name, pct]) => ( +
+ {name} +
+
+
+ {pct}% +
+ ))} +
+ )} + {/* Batch Actions Bar */} {selectedFiles.size > 1 && (