diff --git a/README.md b/README.md index c302b46..e21c3a3 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/examples/basic-usage.ts b/examples/basic-usage.ts index 78309d1..f7538d0 100644 --- a/examples/basic-usage.ts +++ b/examples/basic-usage.ts @@ -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 }, }); @@ -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 @@ -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 }, }); @@ -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, { @@ -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); diff --git a/package-lock.json b/package-lock.json index 8e6885f..1b81fc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@openhands/agent-server-typescript-client", + "name": "@openhands/typescript-client", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@openhands/agent-server-typescript-client", + "name": "@openhands/typescript-client", "version": "0.1.0", "license": "MIT", "dependencies": { diff --git a/src/index.ts b/src/index.ts index 84d52a2..2fc243a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { diff --git a/src/models/workspace.ts b/src/models/workspace.ts index 6259882..4f08c1d 100644 --- a/src/models/workspace.ts +++ b/src/models/workspace.ts @@ -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'; diff --git a/src/workspace/remote-workspace.ts b/src/workspace/remote-workspace.ts index cdf252b..87ea6ef 100644 --- a/src/workspace/remote-workspace.ts +++ b/src/workspace/remote-workspace.ts @@ -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; @@ -129,22 +135,33 @@ export class RemoteWorkspace { } } - async fileUpload(sourcePath: string, destinationPath: string): Promise { - console.debug(`Remote file upload: ${sourcePath} -> ${destinationPath}`); + async fileUpload( + content: string | Blob | File, + destinationPath: string, + fileName?: string + ): Promise { + 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({ @@ -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, @@ -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 { - console.debug(`Remote file download: ${sourcePath} -> ${destinationPath}`); + async fileDownload(sourcePath: string): Promise { + console.debug(`Remote file download: ${sourcePath}`); try { const response = await this.client.get( @@ -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), }; } @@ -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 { + return this.fileUpload(text, destinationPath, fileName); + } + + /** + * Convenience method to upload a File object (from file input) + */ + async uploadFileObject(file: File, destinationPath: string): Promise { + return this.fileUpload(file, destinationPath); + } + + /** + * Convenience method to download file content as text + */ + async downloadAsText(sourcePath: string): Promise { + 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 { + 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 { + 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(); }