Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
A TypeScript client library for the OpenHands Agent Server API. Mirrors the structure and functionality of the Python [OpenHands Software Agent SDK](https://github.com/OpenHands/software-agent-sdk),
but only supports remote conversations.

## ✨ Browser Compatible

This client is **fully browser-compatible** and works without Node.js dependencies. File operations use browser-native APIs like `Blob`, `File`, and `FormData` instead of file system operations. Perfect for web applications, React apps, and other browser-based projects.

## Installation

This package is published to GitHub Packages. You have two installation options:
Expand Down
15 changes: 8 additions & 7 deletions examples/basic-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import { Conversation, Agent, Workspace, AgentExecutionStatus } from '../src/ind

async function main() {
// Define the agent configuration
// Note: In a browser environment, you would get these values from your app's configuration
const agent = new Agent({
llm: {
model: 'gpt-4',
api_key: process.env.OPENAI_API_KEY || 'your-openai-api-key',
api_key: 'your-openai-api-key', // Replace with your actual API key
},
});

Expand All @@ -18,7 +19,7 @@ async function main() {
const workspace = new Workspace({
host: 'http://localhost:3000',
workingDir: '/tmp',
apiKey: process.env.SESSION_API_KEY || 'your-session-api-key',
apiKey: 'your-session-api-key', // Replace with your actual session API key
});

// Create a new conversation
Expand Down Expand Up @@ -87,7 +88,7 @@ async function loadExistingConversation() {
const agent = new Agent({
llm: {
model: 'gpt-4',
api_key: process.env.OPENAI_API_KEY || 'your-openai-api-key',
api_key: 'your-openai-api-key', // Replace with your actual API key
},
});

Expand All @@ -96,7 +97,7 @@ async function loadExistingConversation() {
const workspace = new Workspace({
host: 'http://localhost:3000',
workingDir: '/tmp',
apiKey: process.env.SESSION_API_KEY || 'your-session-api-key',
apiKey: 'your-session-api-key', // Replace with your actual session API key
});

const conversation = new Conversation(agent, workspace, {
Expand All @@ -120,6 +121,6 @@ async function loadExistingConversation() {
}

// Run the example
if (require.main === module) {
main().catch(console.error);
}
// Note: In a browser environment, you would call main() directly or from an event handler
// For Node.js environments, you can use import.meta.main (ES modules) or check if this is the main module
main().catch(console.error);
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,13 @@ export type { AgentOptions } from './agent/agent';
export { EventSortOrder, AgentExecutionStatus } from './types/base';

// Workspace models
export type { CommandResult, FileOperationResult, GitChange, GitDiff } from './models/workspace';
export type {
CommandResult,
FileOperationResult,
FileDownloadResult,
GitChange,
GitDiff,
} from './models/workspace';

// Conversation models
export type {
Expand Down
8 changes: 8 additions & 0 deletions src/models/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ export interface FileOperationResult {
error?: string;
}

export interface FileDownloadResult {
success: boolean;
source_path: string;
content: string | Blob;
file_size?: number;
error?: string;
}

export interface GitChange {
path: string;
status: 'added' | 'modified' | 'deleted' | 'renamed';
Expand Down
165 changes: 131 additions & 34 deletions src/workspace/remote-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
*/

import { HttpClient } from '../client/http-client';
import { CommandResult, FileOperationResult, GitChange, GitDiff } from '../models/workspace';
import {
CommandResult,
FileOperationResult,
FileDownloadResult,
GitChange,
GitDiff,
} from '../models/workspace';

export interface RemoteWorkspaceOptions {
host: string;
Expand Down Expand Up @@ -129,22 +135,33 @@ export class RemoteWorkspace {
}
}

async fileUpload(sourcePath: string, destinationPath: string): Promise<FileOperationResult> {
console.debug(`Remote file upload: ${sourcePath} -> ${destinationPath}`);
async fileUpload(
content: string | Blob | File,
destinationPath: string,
fileName?: string
): Promise<FileOperationResult> {
console.debug(`Remote file upload to: ${destinationPath}`);

try {
// For browser environments, this would need to be adapted to work with File objects
// For Node.js environments, we can read the file
const fs = await import('fs');
const path = await import('path');

const fileContent = await fs.promises.readFile(sourcePath);
const fileName = path.basename(sourcePath);

// Create FormData for file upload
const formData = new FormData();
const blob = new Blob([fileContent]);
formData.append('file', blob, fileName);

let blob: Blob;
let finalFileName: string;

if (content instanceof File) {
blob = content;
finalFileName = fileName || content.name;
} else if (content instanceof Blob) {
blob = content;
finalFileName = fileName || 'blob-file';
} else {
// String content
blob = new Blob([content], { type: 'text/plain' });
finalFileName = fileName || 'text-file.txt';
}

formData.append('file', blob, finalFileName);
formData.append('destination_path', destinationPath);

const response = await this.client.request({
Expand All @@ -158,7 +175,7 @@ export class RemoteWorkspace {

return {
success: resultData.success ?? true,
source_path: sourcePath,
source_path: finalFileName,
destination_path: destinationPath,
file_size: resultData.file_size,
error: resultData.error,
Expand All @@ -167,15 +184,15 @@ export class RemoteWorkspace {
console.error(`Remote file upload failed: ${error}`);
return {
success: false,
source_path: sourcePath,
source_path: fileName || 'unknown',
destination_path: destinationPath,
error: error instanceof Error ? error.message : String(error),
};
}
}

async fileDownload(sourcePath: string, destinationPath: string): Promise<FileOperationResult> {
console.debug(`Remote file download: ${sourcePath} -> ${destinationPath}`);
async fileDownload(sourcePath: string): Promise<FileDownloadResult> {
console.debug(`Remote file download: ${sourcePath}`);

try {
const response = await this.client.get(
Expand All @@ -185,33 +202,38 @@ export class RemoteWorkspace {
}
);

// For Node.js environments, write the file
const fs = await import('fs');
const path = await import('path');

// Ensure destination directory exists
const destDir = path.dirname(destinationPath);
await fs.promises.mkdir(destDir, { recursive: true });

// Write the file content
const content =
typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
await fs.promises.writeFile(destinationPath, content);

const stats = await fs.promises.stat(destinationPath);
// Convert response data to appropriate format
let content: string | Blob;
let fileSize: number;

if (typeof response.data === 'string') {
content = response.data;
fileSize = new Blob([response.data]).size;
} else if (response.data instanceof ArrayBuffer) {
content = new Blob([response.data]);
fileSize = response.data.byteLength;
} else if (response.data instanceof Blob) {
content = response.data;
fileSize = response.data.size;
} else {
// For other data types, stringify and create blob
const stringData = JSON.stringify(response.data);
content = stringData;
fileSize = new Blob([stringData]).size;
}

return {
success: true,
source_path: sourcePath,
destination_path: destinationPath,
file_size: stats.size,
content: content,
file_size: fileSize,
};
} catch (error) {
console.error(`Remote file download failed: ${error}`);
return {
success: false,
source_path: sourcePath,
destination_path: destinationPath,
content: '',
error: error instanceof Error ? error.message : String(error),
};
}
Expand Down Expand Up @@ -243,6 +265,81 @@ export class RemoteWorkspace {
}
}

/**
* Convenience method to upload text content as a file
*/
async uploadText(
text: string,
destinationPath: string,
fileName?: string
): Promise<FileOperationResult> {
return this.fileUpload(text, destinationPath, fileName);
}

/**
* Convenience method to upload a File object (from file input)
*/
async uploadFileObject(file: File, destinationPath: string): Promise<FileOperationResult> {
return this.fileUpload(file, destinationPath);
}

/**
* Convenience method to download file content as text
*/
async downloadAsText(sourcePath: string): Promise<string> {
const result = await this.fileDownload(sourcePath);
if (!result.success) {
throw new Error(result.error || 'Download failed');
}

if (typeof result.content === 'string') {
return result.content;
} else if (result.content instanceof Blob) {
return await result.content.text();
}

return '';
}

/**
* Convenience method to download file content as a Blob
*/
async downloadAsBlob(sourcePath: string): Promise<Blob> {
const result = await this.fileDownload(sourcePath);
if (!result.success) {
throw new Error(result.error || 'Download failed');
}

if (result.content instanceof Blob) {
return result.content;
} else if (typeof result.content === 'string') {
return new Blob([result.content], { type: 'text/plain' });
}

return new Blob();
}

/**
* Convenience method to trigger a browser download of a file
*/
async downloadAndSave(sourcePath: string, saveAsFileName?: string): Promise<void> {
const blob = await this.downloadAsBlob(sourcePath);

// Create a temporary URL for the blob
const url = URL.createObjectURL(blob);

// Create a temporary anchor element to trigger download
const a = document.createElement('a');
a.href = url;
a.download = saveAsFileName || sourcePath.split('/').pop() || 'download';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);

// Clean up the temporary URL
URL.revokeObjectURL(url);
}

close(): void {
this.client.close();
}
Expand Down
Loading