Merge branch 'main' into tobitege/dyn-runtime-init

This commit is contained in:
tobitege 2024-10-08 17:18:42 +02:00 committed by GitHub
commit dc192f9b34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 386 additions and 404 deletions

View File

@ -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:

View File

@ -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=""

View File

@ -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

View File

@ -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),

View File

@ -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();

View File

@ -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

View 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[];
}

View 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;

View File

@ -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(

View File

@ -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(() => {

View File

@ -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

View File

@ -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) => {

View File

@ -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"

View File

@ -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);

View File

@ -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

View File

@ -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("/");

View File

@ -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;
}

View File

@ -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}"],
},
},
};
});
},
}));

View File

@ -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():

View File

@ -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())

View File

@ -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}')

View File

@ -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
# ====================================================================

View File

@ -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')

View File

@ -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(

View File

@ -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

View File

@ -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
View File

@ -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]

View File

@ -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