Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 160 additions & 15 deletions app/ui_layer/adapters/browser_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -8037,17 +8181,23 @@ 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", "")

if not file_path:
raise web.HTTPNotFound()

# Validate and get absolute path
target = self._validate_path(file_path)

if not target.exists():
Expand All @@ -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",
},
)
Expand Down
11 changes: 11 additions & 0 deletions app/ui_layer/browser/frontend/src/components/Chat/Chat.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading