mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Merge branch 'main' into tobitege/dyn-runtime-init
This commit is contained in:
commit
dc192f9b34
2
Makefile
2
Makefile
@ -195,7 +195,7 @@ start-backend:
|
||||
# Start frontend
|
||||
start-frontend:
|
||||
@echo "$(YELLOW)Starting frontend...$(RESET)"
|
||||
@cd frontend && VITE_BACKEND_HOST=$(BACKEND_HOST_PORT) VITE_FRONTEND_PORT=$(FRONTEND_PORT) npm run start
|
||||
@cd frontend && VITE_BACKEND_HOST=$(BACKEND_HOST_PORT) VITE_FRONTEND_PORT=$(FRONTEND_PORT) npm run start -- --port $(FRONTEND_PORT)
|
||||
|
||||
# Common setup for running the app (non-callable)
|
||||
_run_setup:
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
VITE_BACKEND_HOST="127.0.0.1:3000"
|
||||
VITE_USE_TLS="false"
|
||||
VITE_INSECURE_SKIP_VERIFY="false"
|
||||
VITE_FRONTEND_PORT="3001"
|
||||
VITE_BACKEND_BASE_URL="localhost:3000" # Backend URL without protocol (e.g. localhost:3000)
|
||||
|
||||
# GitHub OAuth
|
||||
VITE_GITHUB_CLIENT_ID=""
|
||||
|
||||
@ -1,63 +1,123 @@
|
||||
# Getting Started with the OpenHands Frontend
|
||||
|
||||
The frontend code can be run against the docker image defined in the [Main README](../README.md) as a backend
|
||||
## Overview
|
||||
|
||||
## Prerequisites
|
||||
This is the frontend of the OpenHands project. It is a React application that provides a web interface for the OpenHands project.
|
||||
|
||||
A recent version of NodeJS / NPM (`brew install node`)
|
||||
## Tech Stack
|
||||
|
||||
## Available Scripts
|
||||
- Remix SPA Mode (React + Vite + React Router)
|
||||
- TypeScript
|
||||
- Redux
|
||||
- Tailwind CSS
|
||||
- i18next
|
||||
- React Testing Library
|
||||
- Vitest
|
||||
- Mock Service Worker
|
||||
|
||||
In the project directory, you can run:
|
||||
## Getting Started
|
||||
|
||||
### `npm run start -- --port 3001`
|
||||
### Prerequisites
|
||||
|
||||
Runs the app in development mode.\
|
||||
Open [http://localhost:3001](http://localhost:3001) to view it in the browser.
|
||||
- Node.js 20.x or later
|
||||
- `npm`, `bun`, or any other package manager that supports the `package.json` file
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
### Installation
|
||||
|
||||
### `npm run make-i18n`
|
||||
```sh
|
||||
# Clone the repository
|
||||
git clone https://github.com/All-Hands-AI/OpenHands.git
|
||||
|
||||
Generates the i18n declaration file.\
|
||||
Run this when first setting up the repository or when updating translations.
|
||||
# Change the directory to the frontend
|
||||
cd OpenHands/frontend
|
||||
|
||||
### `npm run test`
|
||||
|
||||
Runs the available test suites for the application.\
|
||||
It launches the test runner in interactive watch mode, allowing you to see the results of your tests in real time.
|
||||
|
||||
In order to skip all but one specific test file, like the one for the ChatInterface, the following command might be used: `npm run test -- -t "ChatInterface"`
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `dist` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
## Environment Variables
|
||||
|
||||
You can set the environment variables in `frontend/.env` to configure the frontend.
|
||||
The following variables are available:
|
||||
|
||||
```javascript
|
||||
VITE_BACKEND_HOST="127.0.0.1:3000" // The host of the backend
|
||||
VITE_USE_TLS="false" // Whether to use TLS for the backend (includes HTTPS and WSS)
|
||||
VITE_INSECURE_SKIP_VERIFY="false" // Whether to skip verifying the backend's certificate. Only takes effect if `VITE_USE_TLS` is true. Don't use this in production!
|
||||
VITE_FRONTEND_PORT="3001" // The port of the frontend
|
||||
# Install the dependencies
|
||||
npm install
|
||||
```
|
||||
|
||||
You can also set the environment variables from outside the project, like `export VITE_BACKEND_HOST="127.0.0.1:3000"`.
|
||||
### Running the Application in Development Mode
|
||||
|
||||
The outside environment variables will override the ones in the `.env` file.
|
||||
We use `msw` to mock the backend API. To start the application with the mocked backend, run the following command:
|
||||
|
||||
## Learn More
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
This will start the application in development mode. Open [http://localhost:3001](http://localhost:3001) to view it in the browser.
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
**NOTE: The backend is _partially_ mocked using `msw`. Therefore, some features may not work as they would with the actual backend.**
|
||||
|
||||
For more information on tests, you can refer to the official documentation of [Vitest](https://vitest.dev/) and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/).
|
||||
### Running the Application with the Actual Backend (Production Mode)
|
||||
|
||||
There are two ways to run the application with the actual backend:
|
||||
|
||||
```sh
|
||||
# Build the application from the root directory
|
||||
make build
|
||||
|
||||
# Start the application
|
||||
make start
|
||||
```
|
||||
|
||||
OR
|
||||
|
||||
```sh
|
||||
# Start the backend from the root directory
|
||||
make start-backend
|
||||
|
||||
# Build the frontend
|
||||
cd frontend && npm run build
|
||||
|
||||
# Serve the frontend
|
||||
npm start -- --port 3001
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
TODO
|
||||
|
||||
### Project Structure
|
||||
|
||||
```sh
|
||||
frontend
|
||||
├── __tests__ # Tests
|
||||
├── public
|
||||
├── src
|
||||
│ ├── api # API calls
|
||||
│ ├── assets
|
||||
│ ├── components # Reusable components
|
||||
│ ├── context # Local state management
|
||||
│ ├── hooks # Custom hooks
|
||||
│ ├── i18n # Internationalization
|
||||
│ ├── mocks # MSW mocks for development
|
||||
│ ├── routes # React Router file-based routes
|
||||
│ ├── services
|
||||
│ ├── state # Redux state management
|
||||
│ ├── types
|
||||
│ ├── utils # Utility/helper functions
|
||||
│ └── root.tsx # Entry point
|
||||
└── .env.sample # Sample environment variables
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- Real-time updates with WebSockets
|
||||
- Internationalization
|
||||
- Router data loading with Remix
|
||||
- User authentication with GitHub OAuth (if saas mode is enabled)
|
||||
|
||||
## Testing
|
||||
|
||||
We use `Vitest` for testing. To run the tests, run the following command:
|
||||
|
||||
```sh
|
||||
npm run test
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Please read the [CONTRIBUTING.md](../CONTRIBUTING.md) file for details on our code of conduct, and the process for submitting pull requests to us.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
TODO
|
||||
|
||||
@ -2,24 +2,16 @@ import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { describe, it, expect, vi, Mock, afterEach } from "vitest";
|
||||
import { uploadFiles, listFiles } from "#/services/fileService";
|
||||
import toast from "#/utils/toast";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import FileExplorer from "#/components/file-explorer/FileExplorer";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
const toastSpy = vi.spyOn(toast, "error");
|
||||
const uploadFilesSpy = vi.spyOn(OpenHands, "uploadFiles");
|
||||
const getFilesSpy = vi.spyOn(OpenHands, "getFiles");
|
||||
|
||||
vi.mock("../../services/fileService", async () => ({
|
||||
listFiles: vi.fn(async (path: string = "/") => {
|
||||
if (path === "/") {
|
||||
return Promise.resolve(["folder1/", "file1.ts"]);
|
||||
}
|
||||
if (path === "/folder1/" || path === "folder1/") {
|
||||
return Promise.resolve(["file2.ts"]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}),
|
||||
|
||||
uploadFiles: vi.fn(),
|
||||
}));
|
||||
|
||||
@ -42,7 +34,7 @@ describe.skip("FileExplorer", () => {
|
||||
|
||||
expect(await screen.findByText("folder1")).toBeInTheDocument();
|
||||
expect(await screen.findByText("file1.ts")).toBeInTheDocument();
|
||||
expect(listFiles).toHaveBeenCalledTimes(1); // once for root
|
||||
expect(getFilesSpy).toHaveBeenCalledTimes(1); // once for root
|
||||
});
|
||||
|
||||
it.todo("should render an empty workspace");
|
||||
@ -53,12 +45,12 @@ describe.skip("FileExplorer", () => {
|
||||
|
||||
expect(await screen.findByText("folder1")).toBeInTheDocument();
|
||||
expect(await screen.findByText("file1.ts")).toBeInTheDocument();
|
||||
expect(listFiles).toHaveBeenCalledTimes(1); // once for root
|
||||
expect(getFilesSpy).toHaveBeenCalledTimes(1); // once for root
|
||||
|
||||
const refreshButton = screen.getByTestId("refresh");
|
||||
await user.click(refreshButton);
|
||||
|
||||
expect(listFiles).toHaveBeenCalledTimes(2); // once for root, once for refresh button
|
||||
expect(getFilesSpy).toHaveBeenCalledTimes(2); // once for root, once for refresh button
|
||||
});
|
||||
|
||||
it("should toggle the explorer visibility when clicking the toggle button", async () => {
|
||||
@ -84,15 +76,15 @@ describe.skip("FileExplorer", () => {
|
||||
await user.upload(uploadFileInput, file);
|
||||
|
||||
// TODO: Improve this test by passing expected argument to `uploadFiles`
|
||||
expect(uploadFiles).toHaveBeenCalledOnce();
|
||||
expect(listFiles).toHaveBeenCalled();
|
||||
expect(uploadFilesSpy).toHaveBeenCalledOnce();
|
||||
expect(getFilesSpy).toHaveBeenCalled();
|
||||
|
||||
const file2 = new File([""], "file-name-2");
|
||||
const uploadDirInput = await screen.findByTestId("file-input");
|
||||
await user.upload(uploadDirInput, [file, file2]);
|
||||
|
||||
expect(uploadFiles).toHaveBeenCalledTimes(2);
|
||||
expect(listFiles).toHaveBeenCalled();
|
||||
expect(uploadFilesSpy).toHaveBeenCalledTimes(2);
|
||||
expect(getFilesSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.todo("should upload files when dragging them to the explorer", () => {
|
||||
@ -104,7 +96,7 @@ describe.skip("FileExplorer", () => {
|
||||
it.todo("should download a file");
|
||||
|
||||
it("should display an error toast if file upload fails", async () => {
|
||||
(uploadFiles as Mock).mockRejectedValue(new Error());
|
||||
(uploadFilesSpy as Mock).mockRejectedValue(new Error());
|
||||
const user = userEvent.setup();
|
||||
renderFileExplorerWithRunningAgentState();
|
||||
|
||||
@ -113,7 +105,7 @@ describe.skip("FileExplorer", () => {
|
||||
|
||||
await user.upload(uploadFileInput, file);
|
||||
|
||||
expect(uploadFiles).rejects.toThrow();
|
||||
expect(uploadFilesSpy).rejects.toThrow();
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("upload-error"),
|
||||
expect.any(String),
|
||||
|
||||
@ -2,20 +2,13 @@ import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { vi, describe, afterEach, it, expect } from "vitest";
|
||||
import { selectFile, listFiles } from "#/services/fileService";
|
||||
import TreeNode from "#/components/file-explorer/TreeNode";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
const getFileSpy = vi.spyOn(OpenHands, "getFile");
|
||||
const getFilesSpy = vi.spyOn(OpenHands, "getFiles");
|
||||
|
||||
vi.mock("../../services/fileService", async () => ({
|
||||
listFiles: vi.fn(async (path: string = "/") => {
|
||||
if (path === "/") {
|
||||
return Promise.resolve(["folder1/", "file1.ts"]);
|
||||
}
|
||||
if (path === "/folder1/" || path === "folder1/") {
|
||||
return Promise.resolve(["file2.ts"]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}),
|
||||
selectFile: vi.fn(async () => Promise.resolve({ code: "Hello world!" })),
|
||||
uploadFile: vi.fn(),
|
||||
}));
|
||||
|
||||
@ -31,7 +24,7 @@ describe.skip("TreeNode", () => {
|
||||
|
||||
it("should render a folder if it's in a subdir", async () => {
|
||||
renderWithProviders(<TreeNode path="/folder1/" defaultOpen />);
|
||||
expect(listFiles).toHaveBeenCalledWith("/folder1/");
|
||||
expect(getFilesSpy).toHaveBeenCalledWith("/folder1/");
|
||||
|
||||
expect(await screen.findByText("folder1")).toBeInTheDocument();
|
||||
expect(await screen.findByText("file2.ts")).toBeInTheDocument();
|
||||
@ -63,27 +56,27 @@ describe.skip("TreeNode", () => {
|
||||
expect(screen.queryByText("file2.ts")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(folder1);
|
||||
expect(listFiles).toHaveBeenCalledWith("/folder1/");
|
||||
expect(getFilesSpy).toHaveBeenCalledWith("/folder1/");
|
||||
|
||||
expect(folder1).toBeInTheDocument();
|
||||
expect(await screen.findByText("file2.ts")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call `selectFile` and return the full path of a file when clicking on a file", async () => {
|
||||
it("should call `OpenHands.getFile` and return the full path of a file when clicking on a file", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<TreeNode path="/folder1/file2.ts" defaultOpen />);
|
||||
|
||||
const file2 = screen.getByText("file2.ts");
|
||||
await user.click(file2);
|
||||
|
||||
expect(selectFile).toHaveBeenCalledWith("/folder1/file2.ts");
|
||||
expect(getFileSpy).toHaveBeenCalledWith("/folder1/file2.ts");
|
||||
});
|
||||
|
||||
it("should render the full explorer given the defaultOpen prop", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<TreeNode path="/" defaultOpen />);
|
||||
|
||||
expect(listFiles).toHaveBeenCalledWith("/");
|
||||
expect(getFilesSpy).toHaveBeenCalledWith("/");
|
||||
|
||||
const file1 = await screen.findByText("file1.ts");
|
||||
const folder1 = await screen.findByText("folder1");
|
||||
@ -93,7 +86,7 @@ describe.skip("TreeNode", () => {
|
||||
expect(screen.queryByText("file2.ts")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(folder1);
|
||||
expect(listFiles).toHaveBeenCalledWith("folder1/");
|
||||
expect(getFilesSpy).toHaveBeenCalledWith("folder1/");
|
||||
|
||||
expect(file1).toBeInTheDocument();
|
||||
expect(folder1).toBeInTheDocument();
|
||||
@ -109,7 +102,7 @@ describe.skip("TreeNode", () => {
|
||||
expect(screen.queryByText("file2.ts")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(folder1);
|
||||
expect(listFiles).toHaveBeenCalledWith("/folder1/");
|
||||
expect(getFilesSpy).toHaveBeenCalledWith("/folder1/");
|
||||
|
||||
expect(folder1).toBeInTheDocument();
|
||||
expect(await screen.findByText("file2.ts")).toBeInTheDocument();
|
||||
|
||||
@ -1,36 +1,20 @@
|
||||
interface ErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
import {
|
||||
SaveFileSuccessResponse,
|
||||
FileUploadSuccessResponse,
|
||||
Feedback,
|
||||
FeedbackResponse,
|
||||
GitHubAccessTokenResponse,
|
||||
ErrorResponse,
|
||||
} from "./open-hands.types";
|
||||
|
||||
interface SaveFileSuccessResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface FileUploadSuccessResponse {
|
||||
message: string;
|
||||
uploaded_files: string[];
|
||||
skipped_files: { name: string; reason: string }[];
|
||||
}
|
||||
|
||||
interface FeedbackBodyResponse {
|
||||
message: string;
|
||||
feedback_id: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface FeedbackResponse {
|
||||
statusCode: number;
|
||||
body: FeedbackBodyResponse;
|
||||
}
|
||||
|
||||
export interface Feedback {
|
||||
version: string;
|
||||
email: string;
|
||||
token: string;
|
||||
feedback: "positive" | "negative";
|
||||
permissions: "public" | "private";
|
||||
trajectory: unknown[];
|
||||
}
|
||||
/**
|
||||
* Generate the base URL of the OpenHands API
|
||||
* @returns Base URL of the OpenHands API
|
||||
*/
|
||||
const generateBaseURL = () => {
|
||||
const baseUrl = import.meta.env.VITE_BACKEND_BASE_URL || "localhost:3000";
|
||||
return `http://${baseUrl}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Class to interact with the OpenHands API
|
||||
@ -39,7 +23,7 @@ class OpenHands {
|
||||
/**
|
||||
* Base URL of the OpenHands API
|
||||
*/
|
||||
static BASE_URL = "http://localhost:3000";
|
||||
static BASE_URL = generateBaseURL();
|
||||
|
||||
/**
|
||||
* Retrieve the list of models available
|
||||
@ -73,12 +57,17 @@ class OpenHands {
|
||||
/**
|
||||
* Retrieve the list of files available in the workspace
|
||||
* @param token User token provided by the server
|
||||
* @returns List of files available in the workspace
|
||||
* @param path Path to list files from
|
||||
* @returns List of files available in the given path. If path is not provided, it lists all the files in the workspace
|
||||
*/
|
||||
static async getFiles(token: string): Promise<string[]> {
|
||||
const response = await fetch(`${OpenHands.BASE_URL}/api/list-files`, {
|
||||
static async getFiles(token: string, path?: string): Promise<string[]> {
|
||||
const url = new URL(`${OpenHands.BASE_URL}/api/list-files`);
|
||||
if (path) url.searchParams.append("path", encodeURIComponent(path));
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: OpenHands.generateHeaders(token),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@ -126,12 +115,12 @@ class OpenHands {
|
||||
* @param file File to upload
|
||||
* @returns Success message or error message
|
||||
*/
|
||||
static async uploadFile(
|
||||
static async uploadFiles(
|
||||
token: string,
|
||||
file: File,
|
||||
file: File[],
|
||||
): Promise<FileUploadSuccessResponse | ErrorResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append("files", file);
|
||||
file.forEach((f) => formData.append("files", f));
|
||||
|
||||
const response = await fetch(`${OpenHands.BASE_URL}/api/upload-files`, {
|
||||
method: "POST",
|
||||
@ -174,6 +163,22 @@ class OpenHands {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the GitHub access token
|
||||
* @param code Code provided by GitHub
|
||||
* @returns GitHub access token
|
||||
*/
|
||||
static async getGitHubAccessToken(
|
||||
code: string,
|
||||
): Promise<GitHubAccessTokenResponse> {
|
||||
const response = await fetch(`${OpenHands.BASE_URL}/github/callback`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the headers for the request
|
||||
* @param token User token provided by the server
|
||||
|
||||
37
frontend/src/api/open-hands.types.ts
Normal file
37
frontend/src/api/open-hands.types.ts
Normal file
@ -0,0 +1,37 @@
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface SaveFileSuccessResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface FileUploadSuccessResponse {
|
||||
message: string;
|
||||
uploaded_files: string[];
|
||||
skipped_files: { name: string; reason: string }[];
|
||||
}
|
||||
|
||||
export interface FeedbackBodyResponse {
|
||||
message: string;
|
||||
feedback_id: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface FeedbackResponse {
|
||||
statusCode: number;
|
||||
body: FeedbackBodyResponse;
|
||||
}
|
||||
|
||||
export interface GitHubAccessTokenResponse {
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
export interface Feedback {
|
||||
version: string;
|
||||
email: string;
|
||||
token: string;
|
||||
feedback: "positive" | "negative";
|
||||
permissions: "public" | "private";
|
||||
trajectory: unknown[];
|
||||
}
|
||||
6
frontend/src/api/open-hands.utils.ts
Normal file
6
frontend/src/api/open-hands.utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { ErrorResponse, FileUploadSuccessResponse } from "./open-hands.types";
|
||||
|
||||
export const isOpenHandsErrorResponse = (
|
||||
data: ErrorResponse | FileUploadSuccessResponse,
|
||||
): data is ErrorResponse =>
|
||||
typeof data === "object" && data !== null && "error" in data;
|
||||
@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { setRefreshID } from "#/state/codeSlice";
|
||||
import { uploadFiles } from "#/services/fileService";
|
||||
import IconButton from "../IconButton";
|
||||
import ExplorerTree from "./ExplorerTree";
|
||||
import toast from "#/utils/toast";
|
||||
@ -20,6 +19,7 @@ import { RootState } from "#/store";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useFiles } from "#/context/files";
|
||||
import { isOpenHandsErrorResponse } from "#/api/open-hands.utils";
|
||||
|
||||
interface ExplorerActionsProps {
|
||||
onRefresh: () => void;
|
||||
@ -118,43 +118,46 @@ function FileExplorer() {
|
||||
revalidate();
|
||||
};
|
||||
|
||||
const uploadFileData = async (toAdd: FileList) => {
|
||||
const uploadFileData = async (files: FileList) => {
|
||||
try {
|
||||
const result = await uploadFiles(toAdd);
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
const result = await OpenHands.uploadFiles(token, Array.from(files));
|
||||
|
||||
if (result.error) {
|
||||
// Handle error response
|
||||
toast.error(
|
||||
`upload-error-${new Date().getTime()}`,
|
||||
result.error || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
|
||||
);
|
||||
return;
|
||||
if (isOpenHandsErrorResponse(result)) {
|
||||
// Handle error response
|
||||
toast.error(
|
||||
`upload-error-${new Date().getTime()}`,
|
||||
result.error || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadedCount = result.uploaded_files.length;
|
||||
const skippedCount = result.skipped_files.length;
|
||||
|
||||
if (uploadedCount > 0) {
|
||||
toast.success(
|
||||
`upload-success-${new Date().getTime()}`,
|
||||
t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, {
|
||||
count: uploadedCount,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (skippedCount > 0) {
|
||||
const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, {
|
||||
count: skippedCount,
|
||||
});
|
||||
toast.info(message);
|
||||
}
|
||||
|
||||
if (uploadedCount === 0 && skippedCount === 0) {
|
||||
toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE));
|
||||
}
|
||||
|
||||
refreshWorkspace();
|
||||
}
|
||||
|
||||
const uploadedCount = result.uploadedFiles.length;
|
||||
const skippedCount = result.skippedFiles.length;
|
||||
|
||||
if (uploadedCount > 0) {
|
||||
toast.success(
|
||||
`upload-success-${new Date().getTime()}`,
|
||||
t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, {
|
||||
count: uploadedCount,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (skippedCount > 0) {
|
||||
const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, {
|
||||
count: skippedCount,
|
||||
});
|
||||
toast.info(message);
|
||||
}
|
||||
|
||||
if (uploadedCount === 0 && skippedCount === 0) {
|
||||
toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE));
|
||||
}
|
||||
|
||||
refreshWorkspace();
|
||||
} catch (error) {
|
||||
// Handle unexpected errors (network issues, etc.)
|
||||
toast.error(
|
||||
|
||||
@ -3,7 +3,6 @@ import { useSelector } from "react-redux";
|
||||
import { RootState } from "#/store";
|
||||
import FolderIcon from "../FolderIcon";
|
||||
import FileIcon from "../FileIcons";
|
||||
import { listFiles } from "#/services/fileService";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useFiles } from "#/context/files";
|
||||
import { cn } from "#/utils/utils";
|
||||
@ -58,7 +57,12 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
|
||||
setChildren(null);
|
||||
return;
|
||||
}
|
||||
setChildren(await listFiles(path));
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
const newChildren = await OpenHands.getFiles(token, path);
|
||||
setChildren(newChildren);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@ -8,7 +8,8 @@ import toast from "#/utils/toast";
|
||||
import { getToken } from "#/services/auth";
|
||||
import { removeApiKey, removeUnwantedKeys } from "#/utils/utils";
|
||||
import { useSocket } from "#/context/socket";
|
||||
import OpenHands, { Feedback } from "#/api/open-hands";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { Feedback } from "#/api/open-hands.types";
|
||||
|
||||
const isEmailValid = (email: string) => {
|
||||
// Regular expression to validate email format
|
||||
|
||||
@ -45,15 +45,9 @@ function SocketProvider({ children }: SocketProviderProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
const wsUrl = new URL("/", document.baseURI);
|
||||
wsUrl.protocol = wsUrl.protocol.replace("http", "ws");
|
||||
if (options?.token) wsUrl.searchParams.set("token", options.token);
|
||||
const ws = new WebSocket(`${wsUrl.origin}/ws`);
|
||||
*/
|
||||
// TODO: Remove hardcoded URL; may have to use a proxy
|
||||
const baseUrl = import.meta.env.VITE_BACKEND_BASE_URL || "localhost:3000";
|
||||
const ws = new WebSocket(
|
||||
`ws://localhost:3000/ws${options?.token ? `?token=${options.token}` : ""}`,
|
||||
`ws://${baseUrl}/ws${options?.token ? `?token=${options.token}` : ""}`,
|
||||
);
|
||||
|
||||
ws.addEventListener("open", (event) => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
Links,
|
||||
Meta,
|
||||
MetaFunction,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
@ -49,6 +50,11 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
export const meta: MetaFunction = () => [
|
||||
{ title: "OpenHands" },
|
||||
{ name: "description", content: "Let's Start Building!" },
|
||||
];
|
||||
|
||||
export const clientLoader = async () => {
|
||||
let token = localStorage.getItem("token");
|
||||
const ghToken = localStorage.getItem("ghToken");
|
||||
@ -157,7 +163,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-root-primary p-3 h-screen flex gap-3">
|
||||
<div className="bg-root-primary p-3 h-screen min-w-[1024px] overflow-x-hidden flex gap-3">
|
||||
<aside className="px-1 flex flex-col gap-[15px]">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import {
|
||||
ClientActionFunctionArgs,
|
||||
ClientLoaderFunctionArgs,
|
||||
json,
|
||||
redirect,
|
||||
useLoaderData,
|
||||
@ -26,10 +27,6 @@ import { removeFile, setInitialQuery } from "#/state/initial-query-slice";
|
||||
import { clientLoader as rootClientLoader } from "#/root";
|
||||
import { UploadedFilePreview } from "./uploaded-file-preview";
|
||||
|
||||
const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID;
|
||||
const redirectUri = "http://localhost:3001/oauth/github/callback";
|
||||
const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=repo,user`;
|
||||
|
||||
interface AttachedFilesSliderProps {
|
||||
files: string[];
|
||||
onRemove: (file: string) => void;
|
||||
@ -74,7 +71,7 @@ function GitHubAuth({
|
||||
);
|
||||
}
|
||||
|
||||
export const clientLoader = async () => {
|
||||
export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
|
||||
const ghToken = localStorage.getItem("ghToken");
|
||||
let repositories: GitHubRepository[] = [];
|
||||
if (ghToken) {
|
||||
@ -84,7 +81,12 @@ export const clientLoader = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
return json({ repositories });
|
||||
const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID;
|
||||
const requestUrl = new URL(request.url);
|
||||
const redirectUri = `${requestUrl.origin}/oauth/github/callback`;
|
||||
const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=repo,user`;
|
||||
|
||||
return json({ repositories, githubAuthUrl });
|
||||
};
|
||||
|
||||
export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
|
||||
@ -98,7 +100,7 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
|
||||
function Home() {
|
||||
const rootData = useRouteLoaderData<typeof rootClientLoader>("root");
|
||||
const navigation = useNavigation();
|
||||
const { repositories } = useLoaderData<typeof clientLoader>();
|
||||
const { repositories, githubAuthUrl } = useLoaderData<typeof clientLoader>();
|
||||
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
|
||||
React.useState(false);
|
||||
const [importedFile, setImportedFile] = React.useState<File | null>(null);
|
||||
|
||||
@ -62,7 +62,7 @@ export const clientLoader = async () => {
|
||||
const file = new File([blob], "imported-project.zip", {
|
||||
type: blob.type,
|
||||
});
|
||||
await OpenHands.uploadFile(token, file);
|
||||
await OpenHands.uploadFiles(token, [file]);
|
||||
}
|
||||
|
||||
if (repo) localStorage.setItem("repo", repo);
|
||||
@ -180,6 +180,11 @@ function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("error" in parsed) {
|
||||
fetcher.submit({}, { method: "POST", action: "/end-session" });
|
||||
return;
|
||||
}
|
||||
|
||||
handleAssistantMessage(message.data.toString());
|
||||
|
||||
// handle first time connection
|
||||
|
||||
@ -4,24 +4,7 @@ import {
|
||||
redirect,
|
||||
useLoaderData,
|
||||
} from "@remix-run/react";
|
||||
|
||||
const retrieveGitHubAccessToken = async (
|
||||
code: string,
|
||||
): Promise<{ access_token: string }> => {
|
||||
const response = await fetch("http://localhost:3000/github/callback", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to retrieve access token");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
|
||||
const url = new URL(request.url);
|
||||
@ -29,7 +12,8 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
|
||||
|
||||
if (code) {
|
||||
// request to the server to exchange the code for a token
|
||||
const { access_token: accessToken } = await retrieveGitHubAccessToken(code);
|
||||
const { access_token: accessToken } =
|
||||
await OpenHands.getGitHubAccessToken(code);
|
||||
// set the token in local storage
|
||||
localStorage.setItem("ghToken", accessToken);
|
||||
return redirect("/");
|
||||
|
||||
@ -1,75 +0,0 @@
|
||||
import { request } from "./api";
|
||||
|
||||
export async function selectFile(file: string): Promise<string> {
|
||||
const encodedFile = encodeURIComponent(file);
|
||||
const data = await request(`/api/select-file?file=${encodedFile}`);
|
||||
return data.code as string;
|
||||
}
|
||||
|
||||
interface UploadResult {
|
||||
message: string;
|
||||
uploadedFiles: string[];
|
||||
skippedFiles: Array<{ name: string; reason: string }>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function uploadFiles(files: FileList): Promise<UploadResult> {
|
||||
const formData = new FormData();
|
||||
const skippedFiles: Array<{ name: string; reason: string }> = [];
|
||||
|
||||
let uploadedCount = 0;
|
||||
|
||||
for (let i = 0; i < files.length; i += 1) {
|
||||
const file = files[i];
|
||||
|
||||
if (
|
||||
file.name.includes("..") ||
|
||||
file.name.includes("/") ||
|
||||
file.name.includes("\\")
|
||||
) {
|
||||
skippedFiles.push({
|
||||
name: file.name,
|
||||
reason: "Invalid file name",
|
||||
});
|
||||
} else {
|
||||
formData.append("files", file);
|
||||
uploadedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
formData.append("skippedFilesCount", skippedFiles.length.toString());
|
||||
formData.append("uploadedFilesCount", uploadedCount.toString());
|
||||
|
||||
const response = await request("http://localhost:3000/api/upload-files", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (
|
||||
typeof response.message !== "string" ||
|
||||
!Array.isArray(response.uploaded_files) ||
|
||||
!Array.isArray(response.skipped_files)
|
||||
) {
|
||||
throw new Error("Unexpected response structure from server");
|
||||
}
|
||||
|
||||
return {
|
||||
message: response.message,
|
||||
uploadedFiles: response.uploaded_files,
|
||||
skippedFiles: [...skippedFiles, ...response.skipped_files],
|
||||
};
|
||||
}
|
||||
|
||||
export async function listFiles(
|
||||
path: string | undefined = undefined,
|
||||
): Promise<string[]> {
|
||||
let url = "http://localhost:3000/api/list-files";
|
||||
if (path) {
|
||||
url = `http://localhost:3000/api/list-files?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
const data = await request(url);
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error("Invalid response format: data is not an array");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@ -1,82 +1,37 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
/// <reference types="vitest" />
|
||||
/// <reference types="vite-plugin-svgr/client" />
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import { defineConfig } from "vite";
|
||||
import viteTsconfigPaths from "vite-tsconfig-paths";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import { vitePlugin as remix } from "@remix-run/dev";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const {
|
||||
VITE_BACKEND_HOST = "127.0.0.1:3000",
|
||||
VITE_USE_TLS = "false",
|
||||
VITE_FRONTEND_PORT = "3001",
|
||||
VITE_INSECURE_SKIP_VERIFY = "false",
|
||||
VITE_WATCH_USE_POLLING = "false",
|
||||
} = loadEnv(mode, process.cwd());
|
||||
|
||||
const USE_TLS = VITE_USE_TLS === "true";
|
||||
const INSECURE_SKIP_VERIFY = VITE_INSECURE_SKIP_VERIFY === "true";
|
||||
const PROTOCOL = USE_TLS ? "https" : "http";
|
||||
const WS_PROTOCOL = USE_TLS ? "wss" : "ws";
|
||||
|
||||
const API_URL = `${PROTOCOL}://${VITE_BACKEND_HOST}/`;
|
||||
const WS_URL = `${WS_PROTOCOL}://${VITE_BACKEND_HOST}/`;
|
||||
const FE_PORT = Number.parseInt(VITE_FRONTEND_PORT, 10);
|
||||
|
||||
// check BACKEND_HOST is something like "example.com"
|
||||
if (!VITE_BACKEND_HOST.match(/^([\w\d-]+(\.[\w\d-]+)+(:\d+)?)/)) {
|
||||
throw new Error(
|
||||
`Invalid BACKEND_HOST ${VITE_BACKEND_HOST}, example BACKEND_HOST 127.0.0.1:3000`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
!process.env.VITEST &&
|
||||
remix({
|
||||
future: {
|
||||
v3_fetcherPersist: true,
|
||||
v3_relativeSplatPath: true,
|
||||
v3_throwAbortReason: true,
|
||||
},
|
||||
appDirectory: "src",
|
||||
ssr: false,
|
||||
}),
|
||||
viteTsconfigPaths(),
|
||||
svgr(),
|
||||
],
|
||||
ssr: {
|
||||
noExternal: ["react-syntax-highlighter"],
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
watch: {
|
||||
usePolling: VITE_WATCH_USE_POLLING === "true",
|
||||
},
|
||||
port: FE_PORT,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: API_URL,
|
||||
changeOrigin: true,
|
||||
secure: !INSECURE_SKIP_VERIFY,
|
||||
export default defineConfig(() => ({
|
||||
plugins: [
|
||||
!process.env.VITEST &&
|
||||
remix({
|
||||
future: {
|
||||
v3_fetcherPersist: true,
|
||||
v3_relativeSplatPath: true,
|
||||
v3_throwAbortReason: true,
|
||||
},
|
||||
"/ws": {
|
||||
target: WS_URL,
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
secure: !INSECURE_SKIP_VERIFY,
|
||||
},
|
||||
},
|
||||
appDirectory: "src",
|
||||
ssr: false,
|
||||
}),
|
||||
viteTsconfigPaths(),
|
||||
svgr(),
|
||||
],
|
||||
ssr: {
|
||||
noExternal: ["react-syntax-highlighter"],
|
||||
},
|
||||
clearScreen: false,
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["vitest.setup.ts"],
|
||||
coverage: {
|
||||
reporter: ["text", "json", "html", "lcov", "text-summary"],
|
||||
reportsDirectory: "coverage",
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["vitest.setup.ts"],
|
||||
coverage: {
|
||||
reporter: ["text", "json", "html", "lcov", "text-summary"],
|
||||
reportsDirectory: "coverage",
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@ -129,6 +129,13 @@ class EventStream:
|
||||
del self._subscribers[id]
|
||||
|
||||
def add_event(self, event: Event, source: EventSource):
|
||||
try:
|
||||
asyncio.get_running_loop().create_task(self.async_add_event(event, source))
|
||||
except RuntimeError:
|
||||
# No event loop running...
|
||||
asyncio.run(self.async_add_event(event, source))
|
||||
|
||||
async def async_add_event(self, event: Event, source: EventSource):
|
||||
with self._lock:
|
||||
event._id = self._cur_id # type: ignore [attr-defined]
|
||||
self._cur_id += 1
|
||||
@ -138,10 +145,16 @@ class EventStream:
|
||||
data = event_to_dict(event)
|
||||
if event.id is not None:
|
||||
self.file_store.write(self._get_filename_for_id(event.id), json.dumps(data))
|
||||
tasks = []
|
||||
for key in sorted(self._subscribers.keys()):
|
||||
stack = self._subscribers[key]
|
||||
callback = stack[-1]
|
||||
asyncio.create_task(callback(event))
|
||||
tasks.append(asyncio.create_task(callback(event)))
|
||||
if tasks:
|
||||
await asyncio.wait(tasks)
|
||||
|
||||
def _callback(self, callback: Callable, event: Event):
|
||||
asyncio.run(callback(event))
|
||||
|
||||
def filtered_events_by_source(self, source: EventSource):
|
||||
for event in self.get_events():
|
||||
|
||||
@ -73,7 +73,7 @@ class AsyncLLM(LLM):
|
||||
and self.config.on_cancel_requested_fn is not None
|
||||
and await self.config.on_cancel_requested_fn()
|
||||
):
|
||||
raise UserCancelledError('LLM request cancelled by user')
|
||||
return
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
stop_check_task = asyncio.create_task(check_stopped())
|
||||
|
||||
@ -200,6 +200,9 @@ class RemoteRuntime(Runtime):
|
||||
assert (
|
||||
self.runtime_url is not None
|
||||
), 'Runtime URL is not set. This should never happen.'
|
||||
|
||||
self._wait_until_alive()
|
||||
|
||||
self.send_status_message(' ')
|
||||
|
||||
self._wait_until_alive()
|
||||
@ -229,7 +232,7 @@ class RemoteRuntime(Runtime):
|
||||
logger.warning(msg)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
def close(self):
|
||||
def close(self, timeout: int = 10):
|
||||
if self.runtime_id:
|
||||
try:
|
||||
response = send_request(
|
||||
@ -237,6 +240,7 @@ class RemoteRuntime(Runtime):
|
||||
'POST',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/stop',
|
||||
json={'runtime_id': self.runtime_id},
|
||||
timeout=timeout,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.error(f'Failed to stop sandbox: {response.text}')
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import atexit
|
||||
import copy
|
||||
import json
|
||||
@ -117,10 +118,10 @@ class Runtime:
|
||||
if event.timeout is None:
|
||||
event.timeout = self.config.sandbox.timeout
|
||||
assert event.timeout is not None
|
||||
observation = self.run_action(event)
|
||||
observation = await self.async_run_action(event)
|
||||
observation._cause = event.id # type: ignore[attr-defined]
|
||||
source = event.source if event.source else EventSource.AGENT
|
||||
self.event_stream.add_event(observation, source) # type: ignore[arg-type]
|
||||
await self.event_stream.async_add_event(observation, source) # type: ignore[arg-type]
|
||||
|
||||
def run_action(self, action: Action) -> Observation:
|
||||
"""Run an action and return the resulting observation.
|
||||
@ -151,6 +152,12 @@ class Runtime:
|
||||
observation = getattr(self, action_type)(action)
|
||||
return observation
|
||||
|
||||
async def async_run_action(self, action: Action) -> Observation:
|
||||
observation = await asyncio.get_event_loop().run_in_executor(
|
||||
None, self.run_action, action
|
||||
)
|
||||
return observation
|
||||
|
||||
# ====================================================================
|
||||
# Context manager
|
||||
# ====================================================================
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
@ -144,10 +145,8 @@ class InvariantAnalyzer(SecurityAnalyzer):
|
||||
new_event = action_from_dict(
|
||||
{'action': 'change_agent_state', 'args': {'agent_state': 'user_confirmed'}}
|
||||
)
|
||||
if event.source:
|
||||
self.event_stream.add_event(new_event, event.source)
|
||||
else:
|
||||
self.event_stream.add_event(new_event, EventSource.AGENT)
|
||||
event_source = event.source if event.source else EventSource.AGENT
|
||||
await asyncio.get_event_loop().run_in_executor(None, self.event_stream.add_event, new_event, event_source)
|
||||
|
||||
async def security_risk(self, event: Action) -> ActionSecurityRisk:
|
||||
logger.info('Calling security_risk on InvariantAnalyzer')
|
||||
|
||||
@ -430,7 +430,9 @@ async def list_files(request: Request, path: str | None = None):
|
||||
content={'error': 'Runtime not yet initialized'},
|
||||
)
|
||||
runtime: Runtime = request.state.session.agent_session.runtime
|
||||
file_list = runtime.list_files(path)
|
||||
file_list = await asyncio.get_event_loop().run_in_executor(
|
||||
None, runtime.list_files, path
|
||||
)
|
||||
if path:
|
||||
file_list = [os.path.join(path, f) for f in file_list]
|
||||
|
||||
@ -451,6 +453,7 @@ async def list_files(request: Request, path: str | None = None):
|
||||
return file_list
|
||||
|
||||
file_list = filter_for_gitignore(file_list, '')
|
||||
|
||||
return file_list
|
||||
|
||||
|
||||
@ -478,7 +481,7 @@ async def select_file(file: str, request: Request):
|
||||
|
||||
file = os.path.join(runtime.config.workspace_mount_path_in_sandbox, file)
|
||||
read_action = FileReadAction(file)
|
||||
observation = runtime.run_action(read_action)
|
||||
observation = await runtime.async_run_action(read_action)
|
||||
|
||||
if isinstance(observation, FileReadObservation):
|
||||
content = observation.content
|
||||
@ -720,7 +723,7 @@ async def save_file(request: Request):
|
||||
runtime.config.workspace_mount_path_in_sandbox, file_path
|
||||
)
|
||||
write_action = FileWriteAction(file_path, content)
|
||||
observation = runtime.run_action(write_action)
|
||||
observation = await runtime.async_run_action(write_action)
|
||||
|
||||
if isinstance(observation, FileWriteObservation):
|
||||
return JSONResponse(
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from threading import Thread
|
||||
from typing import Callable, Optional
|
||||
|
||||
from openhands.controller import AgentController
|
||||
@ -32,7 +30,7 @@ class AgentSession:
|
||||
runtime: Runtime | None = None
|
||||
security_analyzer: SecurityAnalyzer | None = None
|
||||
_closed: bool = False
|
||||
loop: asyncio.AbstractEventLoop
|
||||
loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
def __init__(self, sid: str, file_store: FileStore):
|
||||
"""Initializes a new instance of the Session class
|
||||
@ -45,7 +43,6 @@ class AgentSession:
|
||||
self.sid = sid
|
||||
self.event_stream = EventStream(sid, file_store)
|
||||
self.file_store = file_store
|
||||
self.loop = asyncio.new_event_loop()
|
||||
|
||||
async def start(
|
||||
self,
|
||||
@ -73,17 +70,9 @@ class AgentSession:
|
||||
'Session already started. You need to close this session and start a new one.'
|
||||
)
|
||||
|
||||
self.thread = Thread(target=self._run, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def coro_callback(task):
|
||||
fut: concurrent.futures.Future = concurrent.futures.Future()
|
||||
try:
|
||||
fut.set_result(task.result())
|
||||
except Exception as e:
|
||||
logger.error(f'Error starting session: {e}')
|
||||
|
||||
coro = self._start(
|
||||
asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
self._start_thread,
|
||||
runtime_name,
|
||||
config,
|
||||
agent,
|
||||
@ -93,9 +82,12 @@ class AgentSession:
|
||||
agent_configs,
|
||||
status_message_callback,
|
||||
)
|
||||
asyncio.run_coroutine_threadsafe(coro, self.loop).add_done_callback(
|
||||
coro_callback
|
||||
) # type: ignore
|
||||
|
||||
def _start_thread(self, *args):
|
||||
try:
|
||||
asyncio.run(self._start(*args), debug=True)
|
||||
except RuntimeError:
|
||||
logger.info('Session Finished')
|
||||
|
||||
async def _start(
|
||||
self,
|
||||
@ -108,6 +100,7 @@ class AgentSession:
|
||||
agent_configs: dict[str, AgentConfig] | None = None,
|
||||
status_message_callback: Optional[Callable] = None,
|
||||
):
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self._create_security_analyzer(config.security.security_analyzer)
|
||||
self._create_runtime(runtime_name, config, agent, status_message_callback)
|
||||
self._create_controller(
|
||||
@ -125,10 +118,6 @@ class AgentSession:
|
||||
self.controller.agent_task = self.controller.start_step_loop()
|
||||
await self.controller.agent_task # type: ignore
|
||||
|
||||
def _run(self):
|
||||
asyncio.set_event_loop(self.loop)
|
||||
self.loop.run_forever()
|
||||
|
||||
async def close(self):
|
||||
"""Closes the Agent session"""
|
||||
|
||||
@ -143,10 +132,8 @@ class AgentSession:
|
||||
if self.security_analyzer is not None:
|
||||
await self.security_analyzer.close()
|
||||
|
||||
self.loop.call_soon_threadsafe(self.loop.stop)
|
||||
if self.thread:
|
||||
# We may be closing an agent_session that was never actually started
|
||||
self.thread.join()
|
||||
if self.loop:
|
||||
self.loop.call_soon_threadsafe(self.loop.stop)
|
||||
|
||||
self._closed = True
|
||||
|
||||
|
||||
@ -162,9 +162,10 @@ class Session:
|
||||
'Model does not support image upload, change to a different model or try without an image.'
|
||||
)
|
||||
return
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._add_event(event, EventSource.USER), self.agent_session.loop
|
||||
) # type: ignore
|
||||
if self.agent_session.loop:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._add_event(event, EventSource.USER), self.agent_session.loop
|
||||
) # type: ignore
|
||||
|
||||
async def _add_event(self, event, event_source):
|
||||
self.agent_session.event_stream.add_event(event, EventSource.USER)
|
||||
|
||||
16
poetry.lock
generated
16
poetry.lock
generated
@ -2454,12 +2454,12 @@ testing = ["pytest"]
|
||||
|
||||
[[package]]
|
||||
name = "google-generativeai"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
description = "Google Generative AI High level API client library and tools."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "google_generativeai-0.8.2-py3-none-any.whl", hash = "sha256:fabc0e2e8d2bfb6fdb1653e91dba83fecb2a2a6878883b80017def90fda8032d"},
|
||||
{file = "google_generativeai-0.8.3-py3-none-any.whl", hash = "sha256:1108ff89d5b8e59f51e63d1a8bf84701cd84656e17ca28d73aeed745e736d9b7"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -3243,13 +3243,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "json-repair"
|
||||
version = "0.29.8"
|
||||
version = "0.29.10"
|
||||
description = "A package to repair broken json strings"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "json_repair-0.29.8-py3-none-any.whl", hash = "sha256:f3e2203b9a26a439f9bc177f11702dcfcd8a366b4cbe90ff91f0c44faf0f00ef"},
|
||||
{file = "json_repair-0.29.8.tar.gz", hash = "sha256:1bf8037f2ccf416109f42d080adf99295165ba3e930fadf9e3f7d9049d377e8e"},
|
||||
{file = "json_repair-0.29.10-py3-none-any.whl", hash = "sha256:750eacc3c0228a72b512654855515c1a88174b641b7b834f769a01f49a0e65ca"},
|
||||
{file = "json_repair-0.29.10.tar.gz", hash = "sha256:8050f9db6e6a42f843e21b3fe8410308b0f6085bfd81506343552522b6b707f8"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -5408,13 +5408,13 @@ sympy = "*"
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "1.51.0"
|
||||
version = "1.51.1"
|
||||
description = "The official Python library for the openai API"
|
||||
optional = false
|
||||
python-versions = ">=3.7.1"
|
||||
files = [
|
||||
{file = "openai-1.51.0-py3-none-any.whl", hash = "sha256:d9affafb7e51e5a27dce78589d4964ce4d6f6d560307265933a94b2e3f3c5d2c"},
|
||||
{file = "openai-1.51.0.tar.gz", hash = "sha256:8dc4f9d75ccdd5466fc8c99a952186eddceb9fd6ba694044773f3736a847149d"},
|
||||
{file = "openai-1.51.1-py3-none-any.whl", hash = "sha256:035ba637bef7523282b5b8d9f2f5fdc0bb5bc18d52af2bfc7f64e4a7b0a169fb"},
|
||||
{file = "openai-1.51.1.tar.gz", hash = "sha256:a4908d68e0a1f4bcb45cbaf273c5fbdc3a4fa6239bb75128b58b94f7d5411563"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import pathlib
|
||||
import tempfile
|
||||
|
||||
@ -42,7 +41,7 @@ def temp_dir(monkeypatch):
|
||||
yield temp_dir
|
||||
|
||||
|
||||
async def add_events(event_stream: EventStream, data: list[tuple[Event, EventSource]]):
|
||||
def add_events(event_stream: EventStream, data: list[tuple[Event, EventSource]]):
|
||||
for event, source in data:
|
||||
event_stream.add_event(event, source)
|
||||
|
||||
@ -62,7 +61,7 @@ def test_msg(temp_dir: str):
|
||||
(MessageAction('Hello world!'), EventSource.USER),
|
||||
(MessageAction('ABC!'), EventSource.AGENT),
|
||||
]
|
||||
asyncio.run(add_events(event_stream, data))
|
||||
add_events(event_stream, data)
|
||||
for i in range(3):
|
||||
assert data[i][0].security_risk == ActionSecurityRisk.LOW
|
||||
assert data[3][0].security_risk == ActionSecurityRisk.MEDIUM
|
||||
@ -86,7 +85,7 @@ def test_cmd(cmd, expected_risk, temp_dir: str):
|
||||
(MessageAction('Hello world!'), EventSource.USER),
|
||||
(CmdRunAction(cmd), EventSource.USER),
|
||||
]
|
||||
asyncio.run(add_events(event_stream, data))
|
||||
add_events(event_stream, data)
|
||||
assert data[0][0].security_risk == ActionSecurityRisk.LOW
|
||||
assert data[1][0].security_risk == expected_risk
|
||||
|
||||
@ -115,7 +114,7 @@ def test_leak_secrets(code, expected_risk, temp_dir: str):
|
||||
(IPythonRunCellAction(code), EventSource.AGENT),
|
||||
(IPythonRunCellAction('hello'), EventSource.AGENT),
|
||||
]
|
||||
asyncio.run(add_events(event_stream, data))
|
||||
add_events(event_stream, data)
|
||||
assert data[0][0].security_risk == ActionSecurityRisk.LOW
|
||||
assert data[1][0].security_risk == expected_risk
|
||||
assert data[2][0].security_risk == ActionSecurityRisk.LOW
|
||||
@ -133,7 +132,7 @@ def test_unsafe_python_code(temp_dir: str):
|
||||
(MessageAction('Hello world!'), EventSource.USER),
|
||||
(IPythonRunCellAction(code), EventSource.AGENT),
|
||||
]
|
||||
asyncio.run(add_events(event_stream, data))
|
||||
add_events(event_stream, data)
|
||||
assert data[0][0].security_risk == ActionSecurityRisk.LOW
|
||||
# TODO: this failed but idk why and seems not deterministic to me
|
||||
# assert data[1][0].security_risk == ActionSecurityRisk.MEDIUM
|
||||
@ -148,7 +147,7 @@ def test_unsafe_bash_command(temp_dir: str):
|
||||
(MessageAction('Hello world!'), EventSource.USER),
|
||||
(CmdRunAction(code), EventSource.AGENT),
|
||||
]
|
||||
asyncio.run(add_events(event_stream, data))
|
||||
add_events(event_stream, data)
|
||||
assert data[0][0].security_risk == ActionSecurityRisk.LOW
|
||||
assert data[1][0].security_risk == ActionSecurityRisk.MEDIUM
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user