Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: give descriptive name to downloaded zip file #4793

Closed
Closed
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
9 changes: 4 additions & 5 deletions frontend/src/api/open-hands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,11 @@ class OpenHands {
}

/**
* Get the blob of the workspace zip
* @returns Blob of the workspace zip
* Get the workspace zip from the server
* @returns Workspace zip as server response
*/
static async getWorkspaceZip(): Promise<Blob> {
const response = await request(`/api/zip-directory`, {}, false, true);
return response.blob();
static async getWorkspaceZip(): Promise<Response> {
return request(`/api/zip-directory`, {}, false, true);
}

/**
Expand Down
18 changes: 16 additions & 2 deletions frontend/src/utils/download-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,26 @@ import OpenHands from "#/api/open-hands";
* Downloads the current workspace as a .zip file.
*/
export const downloadWorkspace = async () => {
const blob = await OpenHands.getWorkspaceZip();
const response = await OpenHands.getWorkspaceZip();

// Extract filename from response headers
let filename = "workspace.zip";
const disposition = response.headers.get("Content-Disposition");
if (disposition && disposition.indexOf('filename=') !== -1) {
const matches = /filename="?([^"]+)"?/.exec(disposition);
if (matches != null && matches[1]) {
filename = matches[1];
}
}

// Get the response as a blob
const blob = await response.blob();

// Create download link and trigger download with extracted filename
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", "workspace.zip");
link.setAttribute("download", filename);
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
Expand Down
55 changes: 54 additions & 1 deletion openhands/server/listen.py
Original file line number Diff line number Diff line change
Expand Up @@ -795,13 +795,66 @@ async def zip_current_workspace(request: Request):
logger.debug('Zipping workspace')
runtime: Runtime = request.state.conversation.runtime

# Collect relevant session actions
async_stream = AsyncEventStreamWrapper(
request.state.conversation.event_stream, filter_hidden=True
)
session_actions = []
async for event in async_stream:
if (
hasattr(event, 'message')
and hasattr(event, 'source')
and event.message
and event.source
):
if event.message != 'No observation':
session_actions.append(f'{str(event.source)}: {event.message}')

# Generate a summary
if session_actions:
# Get relevant LLM data from config
llm_config = config.llms['llm']
api_key = llm_config.api_key
model = llm_config.model

# Format context string to be passed to LLM
session_actions_text = '\n'.join(session_actions)

# Prepare the prompt for the LLM
new_prompt = f"""The following context is a list of actions that represent the interaction between a user and an LLM Agent.
Please summarize the interaction into a concise phrase of 50 characters or less to be used as a filename.
When summarizing, focus on the overall purpose of the interaction as well as key features.

Context: {session_actions_text}"""

# Generate the summary using litellm
summary_response = litellm.completion(
messages=[{'role': 'user', 'content': new_prompt}],
model=model,
api_key=api_key,
)
# Extract the generated summary text
summary = summary_response['choices'][0]['message']['content'].strip()
else:
# Default summary if no actions are found
summary = 'workspace'

# Function to format the summary as a filename
def sanitize(summary):
sanitized = re.sub(r'[^\w\s-]', '', summary)
sanitized = sanitized.strip().lower().replace(' ', '-').replace('\n', '-')
sanitized = sanitized[:50]
return sanitized or 'workspace'

# Get zip file as file stream and add filename to response
filename = sanitize(summary) + '.zip'
path = runtime.config.workspace_mount_path_in_sandbox
zip_file_bytes = await call_sync_from_async(runtime.copy_from, path)
zip_stream = io.BytesIO(zip_file_bytes) # Wrap to behave like a file stream
response = StreamingResponse(
zip_stream,
media_type='application/x-zip-compressed',
headers={'Content-Disposition': 'attachment; filename=workspace.zip'},
headers={'Content-Disposition': f'attachment; filename={filename}'},
)

return response
Expand Down