Compare commits

..

43 Commits

Author SHA1 Message Date
openhands
f5f2846ef5 Move file-related endpoints to api_files.py for better modularity 2024-11-09 16:37:51 +00:00
openhands
004d10b116 Refactor listen.py into smaller modules for better maintainability 2024-11-09 16:33:01 +00:00
ross
67c8915d51 feat(runtime): Add prototype Runloop runtime impl (#4598)
Co-authored-by: Robert Brennan <contact@rbren.io>
2024-11-08 23:40:31 -05:00
Daniel Cruz
40b3ccb17c Adds missing spanish translations (#4858) 2024-11-09 05:14:55 +01:00
Robert Brennan
35c68863dc Don't persist cache on reload (#4854) 2024-11-08 22:31:24 +00:00
mamoodi
8bfee87bcf Release 0.13.0 (#4849) 2024-11-08 22:24:56 +00:00
Robert Brennan
e1383afbc3 Add signed cookie-based GitHub authentication caching (#4853)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-08 22:19:34 +00:00
Xingyao Wang
4ce3b9094a Revert "(feat): Prompt engineering to remind o1 to generate a patch" (#4846) 2024-11-08 16:12:57 +00:00
Graham Neubig
0a4e196670 Update openhands-resolver.yml to remove issue number (#4843) 2024-11-08 15:13:56 +00:00
Daniel Cruz
8d32a59f55 Adds missing localization and translation to spanish (#4837)
Co-authored-by: adrianamorenogt <adrianamorenogutierrez@gmail.com>
2024-11-08 09:33:19 +02:00
tofarr
38b92f4251 UX: Show a loading indicator when downloading a zip (#4833) 2024-11-08 09:28:18 +02:00
Boxuan Li
88dbe85594 Make trajectories_path support file path (#4840) 2024-11-08 06:26:12 +00:00
OpenHands
f5003a7449 Fix issue #4830: [Bug]: Copy-paste into the "What do you want to build?" bar doesn't work (#4832)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2024-11-07 23:20:43 -06:00
Alejandro Cuadron Lafuente
a6810fa6ad (feat): Prompt engineering to remind o1 to generate a patch (#4807)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
Co-authored-by: tofarr <tofarr@gmail.com>
Co-authored-by: Robert Brennan <contact@rbren.io>
2024-11-08 03:10:18 +00:00
Robert Brennan
fc05d8d4eb instruct the agent to comment less (#4681) 2024-11-08 05:21:48 +08:00
sp.wack
1d6ef0e18e fix(frontend): Remove runtime indicator (#4829) 2024-11-08 02:37:59 +08:00
Xingyao Wang
dc0e223d1a fix(agent controller): misplaced runtime.connect that cause swebench workspace to fail (#4826) 2024-11-08 01:50:33 +08:00
tofarr
932de79154 Fix: Buffering zip downloads to files rather than holding in memory (#4802) 2024-11-07 10:24:30 -07:00
Robert Brennan
fa625fed70 Retry on github auth failure (#4767) 2024-11-07 16:57:06 +00:00
Xingyao Wang
f9fa1d95cb fix(RemoteRuntime): add retry for pod status after /start (#4825) 2024-11-07 16:22:47 +00:00
sp.wack
5615d54f81 feat(posthog): Emit useful events (#4798)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2024-11-07 16:16:33 +00:00
Xingyao Wang
8166bf768a fix(agent, browsing): too long tool description for openai (#4778) 2024-11-08 00:11:08 +08:00
sp.wack
c3991c870d feat(frontend): Cache request data (#4816) 2024-11-07 16:53:34 +02:00
sp.wack
1a27619b39 feat(frontend): Update npm scripts for cross-platform compatibility with PowerShell and Unix shells (#4727) 2024-11-07 16:51:02 +02:00
sp.wack
cc15aee405 fix(frontend): Fix Jupyter tab overflow (#4818) 2024-11-07 22:48:10 +08:00
Xingyao Wang
53390d9885 Fix issue #4583: [Bug]: Unable to pull the full SWE-Bench test set (#4813)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-07 22:35:20 +08:00
sp.wack
0335b1a634 feat(posthog): Identify users logged in with GitHub (#4794) 2024-11-07 08:37:07 +00:00
Daniel Cruz
bb362cd377 Use i18n Keys (2) (#4464)
Co-authored-by: adrianamorenogt <adrianamorenogutierrez@gmail.com>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2024-11-07 08:34:59 +00:00
Xingyao Wang
4405b109e3 Fix issue #4809: [Bug]: Model does not support image upload when usin… (#4810)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-07 02:28:16 +00:00
Engel Nyst
47464a9cfa Revert "Feature: Add ability to reconnect websockets" (#4801) 2024-11-07 01:56:39 +00:00
Engel Nyst
2b3fd94540 Fix init order in the agent controller (#4796)
Co-authored-by: tofarr <tofarr@gmail.com>
2024-11-06 22:44:12 +00:00
tofarr
1bd46f3832 Fix - terminal not working (#4800) 2024-11-06 20:34:42 +00:00
Xingyao Wang
8a063fdf6a fix(agent): not default to /repo path (#4799) 2024-11-06 20:21:41 +00:00
OpenHands
025dac5d8f Fix issue #4776: [Bug]: Files are not uploaded to the environment (SWE-Bench) (#4795) 2024-11-06 19:05:06 +00:00
tofarr
0e5e754420 Feature: Add ability to reconnect websockets (#4526) 2024-11-06 18:12:31 +00:00
Robert Brennan
7a8e207985 Fix: Implement caching for clientLoader to prevent repeated calls (#4772)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-06 12:51:09 -05:00
mamoodi
a4de0f2142 Update leftover versions (#4792) 2024-11-06 17:21:38 +00:00
dependabot[bot]
27716171bf chore(deps): bump the docusaurus group in /docs with 7 updates (#4789)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-06 17:44:32 +02:00
sp.wack
e5d7735d75 ALL-677 fix(frontend) Truncate long CMD outputs to prevent UI freezing (#4785) 2024-11-06 23:43:25 +08:00
OpenHands
83ccb74d36 Fix issue #4780: [Bug]: Initial query is not cleared after submission (#4781) 2024-11-06 09:54:15 +00:00
sp.wack
118957235d feat(frontend): Chat interface empty state (#4737) 2024-11-06 08:55:50 +00:00
Xingyao Wang
4a6406ed71 feat: add drag & paste image support to ChatInput (#4762)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2024-11-06 07:44:16 +00:00
Rohit Malhotra
4bef974a89 Adding PR number variable to openhands-resolver (#4777) 2024-11-06 02:26:04 +00:00
86 changed files with 8870 additions and 7415 deletions

View File

@@ -11,5 +11,5 @@ jobs:
uses: All-Hands-AI/openhands-resolver/.github/workflows/openhands-resolver.yml@main
if: github.event.label.name == 'fix-me'
with:
issue_number: ${{ github.event.issue.number }}
max_iterations: 50
secrets: inherit

View File

@@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py
### 9. Use existing Docker image
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image. Follow these steps:
1. Set the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.9-nikolaik
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.13-nikolaik
## Develop inside Docker container

View File

@@ -38,15 +38,15 @@ See the [Installation](https://docs.all-hands.dev/modules/usage/installation) gu
system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
docker run -it --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.12
docker.all-hands.dev/all-hands-ai/openhands:0.13
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!

View File

@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.9-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.13-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -32,7 +32,8 @@ workspace_base = "./workspace"
# Enable saving and restoring the session when run from CLI
#enable_cli_session = false
# Path to store trajectories
# Path to store trajectories, can be a folder or a file
# If it's a folder, the session id will be used as the file name
#trajectories_path="./trajectories"
# File store path

View File

@@ -11,7 +11,7 @@ services:
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
- SANDBOX_API_HOSTNAME=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.9-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.13-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -59,7 +59,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.12 \
docker.all-hands.dev/all-hands-ai/openhands:0.13 \
python -m openhands.core.cli
```

View File

@@ -58,4 +58,3 @@ The main interface consists of several key components:
3. Use one of the recommended models, as described in the [LLMs section](usage/llms/llms.md).
Remember, the GUI mode of OpenHands is designed to make your interaction with the AI assistant as smooth and intuitive as possible. Don't hesitate to explore its features to maximize your productivity.

View File

@@ -44,7 +44,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -53,6 +53,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.12 \
docker.all-hands.dev/all-hands-ai/openhands:0.13 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -11,15 +11,15 @@
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.12
docker.all-hands.dev/all-hands-ai/openhands:0.13
```
You can also run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), as an [interactive CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), or using the [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action).

2715
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,10 +15,10 @@
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "^3.5.2",
"@docusaurus/plugin-content-pages": "^3.5.2",
"@docusaurus/preset-classic": "^3.5.2",
"@docusaurus/theme-mermaid": "^3.5.2",
"@docusaurus/core": "^3.6.0",
"@docusaurus/plugin-content-pages": "^3.6.0",
"@docusaurus/preset-classic": "^3.6.0",
"@docusaurus/theme-mermaid": "^3.6.0",
"@mdx-js/react": "^3.1.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.4.0",
@@ -29,7 +29,7 @@
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.5.1",
"@docusaurus/tsconfig": "^3.5.2",
"@docusaurus/tsconfig": "^3.6.0",
"@docusaurus/types": "^3.5.1",
"typescript": "~5.6.3"
},

File diff suppressed because it is too large Load Diff

View File

@@ -81,7 +81,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
'</pr_description>\n\n'
'Can you help me implement the necessary changes to the repository so that the requirements specified in the <pr_description> are met?\n'
"I've already taken care of all changes to any of the test files described in the <pr_description>. This means you DON'T have to modify the testing logic or any of the tests in any way!\n"
'Your task is to make the minimal changes to non-tests files in the /repo directory to ensure the <pr_description> is satisfied.\n'
'Your task is to make the minimal changes to non-tests files in the /workspace directory to ensure the <pr_description> is satisfied.\n'
'Follow these steps to resolve the issue:\n'
'1. As a first step, it might be a good idea to explore the repo to familiarize yourself with its structure.\n'
'2. Create a script to reproduce the error and execute it with `python <filename.py>` using the BashTool, to confirm the error\n'

View File

@@ -1,5 +1,5 @@
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, afterEach, vi, it, expect } from "vitest";
import { ChatInput } from "#/components/chat-input";
@@ -158,4 +158,46 @@ describe("ChatInput", () => {
await user.tab();
expect(onBlurMock).toHaveBeenCalledOnce();
});
it("should handle text paste correctly", () => {
const onSubmit = vi.fn();
const onChange = vi.fn();
render(<ChatInput onSubmit={onSubmit} onChange={onChange} />);
const input = screen.getByTestId("chat-input").querySelector("textarea");
expect(input).toBeTruthy();
// Fire paste event with text data
fireEvent.paste(input!, {
clipboardData: {
getData: (type: string) => type === 'text/plain' ? 'test paste' : '',
files: []
}
});
});
it("should handle image paste correctly", () => {
const onSubmit = vi.fn();
const onImagePaste = vi.fn();
render(<ChatInput onSubmit={onSubmit} onImagePaste={onImagePaste} />);
const input = screen.getByTestId("chat-input").querySelector("textarea");
expect(input).toBeTruthy();
// Create a paste event with an image file
const file = new File(["dummy content"], "image.png", { type: "image/png" });
// Fire paste event with image data
fireEvent.paste(input!, {
clipboardData: {
getData: () => '',
files: [file]
}
});
// Verify image paste was handled
expect(onImagePaste).toHaveBeenCalledWith([file]);
});
});

View File

@@ -1,19 +1,156 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { ChatInterface } from "#/components/chat-interface";
import { SocketProvider } from "#/context/socket";
import { addUserMessage } from "#/state/chatSlice";
import { SUGGESTIONS } from "#/utils/suggestions";
import * as ChatSlice from "#/state/chatSlice";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const renderChatInterface = (messages: (Message | ErrorMessage)[]) =>
render(<ChatInterface />, { wrapper: SocketProvider });
renderWithProviders(<ChatInterface />);
describe("Empty state", () => {
const { send: sendMock } = vi.hoisted(() => ({
send: vi.fn(),
}));
const { useSocket: useSocketMock } = vi.hoisted(() => ({
useSocket: vi.fn(() => ({ send: sendMock, runtimeActive: true })),
}));
beforeAll(() => {
vi.mock("#/context/socket", async (importActual) => ({
...(await importActual<typeof import("#/context/socket")>()),
useSocket: useSocketMock,
}));
});
describe.skip("ChatInterface", () => {
afterEach(() => {
vi.clearAllMocks();
});
it.todo("should render suggestions if empty");
it("should render suggestions if empty", () => {
const { store } = renderWithProviders(<ChatInterface />, {
preloadedState: {
chat: { messages: [] },
},
});
expect(screen.getByTestId("suggestions")).toBeInTheDocument();
act(() => {
store.dispatch(
addUserMessage({
content: "Hello",
imageUrls: [],
timestamp: new Date().toISOString(),
}),
);
});
expect(screen.queryByTestId("suggestions")).not.toBeInTheDocument();
});
it("should render the default suggestions", () => {
renderWithProviders(<ChatInterface />, {
preloadedState: {
chat: { messages: [] },
},
});
const suggestions = screen.getByTestId("suggestions");
const repoSuggestions = Object.keys(SUGGESTIONS.repo);
// check that there are at most 4 suggestions displayed
const displayedSuggestions = within(suggestions).getAllByRole("button");
expect(displayedSuggestions.length).toBeLessThanOrEqual(4);
// Check that each displayed suggestion is one of the repo suggestions
displayedSuggestions.forEach((suggestion) => {
expect(repoSuggestions).toContain(suggestion.textContent);
});
});
it.fails(
"should load the a user message to the input when selecting",
async () => {
// this is to test that the message is in the UI before the socket is called
useSocketMock.mockImplementation(() => ({
send: sendMock,
runtimeActive: false, // mock an inactive runtime setup
}));
const addUserMessageSpy = vi.spyOn(ChatSlice, "addUserMessage");
const user = userEvent.setup();
const { store } = renderWithProviders(<ChatInterface />, {
preloadedState: {
chat: { messages: [] },
},
});
const suggestions = screen.getByTestId("suggestions");
const displayedSuggestions = within(suggestions).getAllByRole("button");
const input = screen.getByTestId("chat-input");
await user.click(displayedSuggestions[0]);
// user message loaded to input
expect(addUserMessageSpy).not.toHaveBeenCalled();
expect(screen.queryByTestId("suggestions")).toBeInTheDocument();
expect(store.getState().chat.messages).toHaveLength(0);
expect(input).toHaveValue(displayedSuggestions[0].textContent);
},
);
it.fails(
"should send the message to the socket only if the runtime is active",
async () => {
useSocketMock.mockImplementation(() => ({
send: sendMock,
runtimeActive: false, // mock an inactive runtime setup
}));
const user = userEvent.setup();
const { rerender } = renderWithProviders(<ChatInterface />, {
preloadedState: {
chat: { messages: [] },
},
});
const suggestions = screen.getByTestId("suggestions");
const displayedSuggestions = within(suggestions).getAllByRole("button");
await user.click(displayedSuggestions[0]);
expect(sendMock).not.toHaveBeenCalled();
useSocketMock.mockImplementation(() => ({
send: sendMock,
runtimeActive: true, // mock an active runtime setup
}));
rerender(<ChatInterface />);
await waitFor(() =>
expect(sendMock).toHaveBeenCalledWith(expect.any(String)),
);
},
);
});
describe.skip("ChatInterface", () => {
beforeAll(() => {
// mock useScrollToBottom hook
vi.mock("#/hooks/useScrollToBottom", () => ({
useScrollToBottom: vi.fn(() => ({
scrollDomToBottom: vi.fn(),
onChatBodyScroll: vi.fn(),
hitBottom: vi.fn(),
})),
}));
});
afterEach(() => {
vi.clearAllMocks();
});
it("should render messages", () => {
const messages: Message[] = [

View File

@@ -25,6 +25,21 @@ describe("InteractiveChatBox", () => {
within(chatBox).getByTestId("upload-image-input");
});
it.fails("should set custom values", () => {
render(
<InteractiveChatBox
onSubmit={onSubmitMock}
onStop={onStopMock}
value="Hello, world!"
/>,
);
const chatBox = screen.getByTestId("interactive-chat-box");
const chatInput = within(chatBox).getByTestId("chat-input");
expect(chatInput).toHaveValue("Hello, world!");
});
it("should display the image previews when images are uploaded", async () => {
const user = userEvent.setup();
render(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);

View File

@@ -0,0 +1,30 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SuggestionItem } from "#/components/suggestion-item";
describe("SuggestionItem", () => {
const suggestionItem = { label: "suggestion1", value: "a long text value" };
const onClick = vi.fn();
afterEach(() => {
vi.clearAllMocks();
});
it("should render a suggestion", () => {
render(<SuggestionItem suggestion={suggestionItem} onClick={onClick} />);
expect(screen.getByTestId("suggestion")).toBeInTheDocument();
expect(screen.getByText(/suggestion1/i)).toBeInTheDocument();
});
it("should call onClick when clicking a suggestion", async () => {
const user = userEvent.setup();
render(<SuggestionItem suggestion={suggestionItem} onClick={onClick} />);
const suggestion = screen.getByTestId("suggestion");
await user.click(suggestion);
expect(onClick).toHaveBeenCalledWith("a long text value");
});
});

View File

@@ -0,0 +1,60 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Suggestions } from "#/components/suggestions";
describe("Suggestions", () => {
const firstSuggestion = {
label: "first-suggestion",
value: "value-of-first-suggestion",
};
const secondSuggestion = {
label: "second-suggestion",
value: "value-of-second-suggestion",
};
const suggestions = [firstSuggestion, secondSuggestion];
const onSuggestionClickMock = vi.fn();
afterEach(() => {
vi.clearAllMocks();
});
it("should render suggestions", () => {
render(
<Suggestions
suggestions={suggestions}
onSuggestionClick={onSuggestionClickMock}
/>,
);
expect(screen.getByTestId("suggestions")).toBeInTheDocument();
const suggestionElements = screen.getAllByTestId("suggestion");
expect(suggestionElements).toHaveLength(2);
expect(suggestionElements[0]).toHaveTextContent("first-suggestion");
expect(suggestionElements[1]).toHaveTextContent("second-suggestion");
});
it("should call onSuggestionClick when clicking a suggestion", async () => {
const user = userEvent.setup();
render(
<Suggestions
suggestions={suggestions}
onSuggestionClick={onSuggestionClickMock}
/>,
);
const suggestionElements = screen.getAllByTestId("suggestion");
await user.click(suggestionElements[0]);
expect(onSuggestionClickMock).toHaveBeenCalledWith(
"value-of-first-suggestion",
);
await user.click(suggestionElements[1]);
expect(onSuggestionClickMock).toHaveBeenCalledWith(
"value-of-second-suggestion",
);
});
});

View File

@@ -1,74 +0,0 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { Provider } from "react-redux";
import store from "../src/store";
import { describe, it, expect, vi } from "vitest";
import { createMemoryRouter, RouterProvider } from "react-router-dom";
const mockNavigate = vi.fn();
// Mock the Remix components
vi.mock("@remix-run/react", async () => {
const actual = await vi.importActual("@remix-run/react");
return {
...actual,
useLoaderData: () => ({
repositories: null,
githubAuthUrl: null,
}),
useRouteLoaderData: () => null,
useNavigate: () => mockNavigate,
Await: ({ children }) => children(null),
Form: ({ children, onSubmit }: any) => (
<form onSubmit={(e) => { e.preventDefault(); onSubmit?.(e); }}>
{children}
</form>
),
};
});
// Mock the SuggestionBox component
vi.mock("../src/components/github-repositories-suggestion-box", () => ({
GitHubRepositoriesSuggestionBox: () => null,
}));
// Mock the SuggestionBubble component
vi.mock("../src/components/suggestion-bubble", () => ({
SuggestionBubble: () => null,
}));
// Mock the ChatInput component
vi.mock("../src/components/chat-input", () => ({
ChatInput: () => null,
}));
describe("Import Project", () => {
it("should not navigate immediately after selecting a zip file", async () => {
// Import the component after mocking
const { default: Home } = await import("../src/routes/_oh._index/route");
const router = createMemoryRouter([
{
path: "/",
element: (
<Provider store={store}>
<Home />
</Provider>
),
},
]);
render(<RouterProvider router={router} />);
// Find the file input
const fileInput = screen.getByLabelText("Upload a .zip");
// Create a mock file
const file = new File(["dummy content"], "test.zip", { type: "application/zip" });
// Simulate file selection
fireEvent.change(fileInput, { target: { files: [file] } });
// Verify that navigate was not called
expect(mockNavigate).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,17 @@
import { describe, it, expect } from "vitest";
import store from "../src/store";
import { setInitialQuery, clearInitialQuery } from "../src/state/initial-query-slice";
describe("Initial Query Behavior", () => {
it("should clear initial query when clearInitialQuery is dispatched", () => {
// Set up initial query in the store
store.dispatch(setInitialQuery("test query"));
expect(store.getState().initalQuery.initialQuery).toBe("test query");
// Clear the initial query
store.dispatch(clearInitialQuery());
// Verify initial query is cleared
expect(store.getState().initalQuery.initialQuery).toBeNull();
});
});

View File

@@ -0,0 +1,5 @@
import { describe, it } from "vitest";
describe("App", () => {
it.todo("should render");
});

View File

@@ -0,0 +1,53 @@
import { afterEach } from "node:test";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cache } from "#/utils/cache";
describe("Cache", () => {
const testKey = "key";
const testData = { message: "Hello, world!" };
const testTTL = 1000; // 1 second
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("gets data from memory if not expired", () => {
cache.set(testKey, testData, testTTL);
expect(cache.get(testKey)).toEqual(testData);
});
it("should expire after 5 minutes by default", () => {
cache.set(testKey, testData);
expect(cache.get(testKey)).not.toBeNull();
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
expect(cache.get(testKey)).toBeNull();
});
it("returns null if cached data is expired", () => {
cache.set(testKey, testData, testTTL);
vi.advanceTimersByTime(testTTL + 1);
expect(cache.get(testKey)).toBeNull();
});
it("deletes data from memory", () => {
cache.set(testKey, testData, testTTL);
cache.delete(testKey);
expect(cache.get(testKey)).toBeNull();
});
it("clears all data with the app prefix from memory", () => {
cache.set(testKey, testData, testTTL);
cache.set("anotherKey", { data: "More data" }, testTTL);
cache.clearAll();
expect(cache.get(testKey)).toBeNull();
expect(cache.get("anotherKey")).toBeNull();
});
});

View File

@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.12.3",
"version": "0.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.12.3",
"version": "0.13.0",
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@nextui-org/react": "^2.4.8",
@@ -63,6 +63,7 @@
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^1.6.0",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
@@ -7923,6 +7924,24 @@
}
}
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.12.3",
"version": "0.13.0",
"private": true,
"type": "module",
"engines": {
@@ -45,8 +45,8 @@
"ws": "^8.18.0"
},
"scripts": {
"dev": "npm run make-i18n && VITE_MOCK_API=false remix vite:dev",
"dev:mock": "npm run make-i18n && VITE_MOCK_API=true remix vite:dev",
"dev": "npm run make-i18n && cross-env VITE_MOCK_API=false remix vite:dev",
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true remix vite:dev",
"build": "npm run make-i18n && tsc && remix vite:build",
"start": "npx sirv-cli build/ --single",
"test": "vitest run",
@@ -89,6 +89,7 @@
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^1.6.0",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",

View File

@@ -122,6 +122,9 @@ export const retrieveGitHubUser = async (
id: data.id,
login: data.login,
avatar_url: data.avatar_url,
company: data.company,
name: data.name,
email: data.email,
};
return user;
@@ -136,33 +139,6 @@ export const retrieveGitHubUser = async (
return error;
};
/**
* Given a GitHub token and a repository name, creates a repository for the authenticated user
* @param token The GitHub token
* @param repositoryName Name of the repository to create
* @param description Description of the repository
* @param isPrivate Boolean indicating if the repository should be private
* @returns The created repository or an error response
*/
export const createGitHubRepository = async (
token: string,
repositoryName: string,
description?: string,
isPrivate = true,
): Promise<GitHubRepository | GitHubErrorReponse> => {
const response = await fetch("https://api.github.com/user/repos", {
method: "POST",
headers: generateGitHubAPIHeaders(token),
body: JSON.stringify({
name: repositoryName,
description,
private: isPrivate,
}),
});
return response.json();
};
export const retrieveLatestGitHubCommit = async (
token: string,
repository: string,

View File

@@ -1,4 +1,5 @@
import { request } from "#/services/api";
import { cache } from "#/utils/cache";
import {
SaveFileSuccessResponse,
FileUploadSuccessResponse,
@@ -15,7 +16,13 @@ class OpenHands {
* @returns List of models available
*/
static async getModels(): Promise<string[]> {
return request("/api/options/models");
const cachedData = cache.get<string[]>("models");
if (cachedData) return cachedData;
const data = await request("/api/options/models");
cache.set("models", data);
return data;
}
/**
@@ -23,7 +30,13 @@ class OpenHands {
* @returns List of agents available
*/
static async getAgents(): Promise<string[]> {
return request(`/api/options/agents`);
const cachedData = cache.get<string[]>("agents");
if (cachedData) return cachedData;
const data = await request(`/api/options/agents`);
cache.set("agents", data);
return data;
}
/**
@@ -31,11 +44,23 @@ class OpenHands {
* @returns List of security analyzers available
*/
static async getSecurityAnalyzers(): Promise<string[]> {
return request(`/api/options/security-analyzers`);
const cachedData = cache.get<string[]>("agents");
if (cachedData) return cachedData;
const data = await request(`/api/options/security-analyzers`);
cache.set("security-analyzers", data);
return data;
}
static async getConfig(): Promise<GetConfigResponse> {
return request("/config.json");
const cachedData = cache.get<GetConfigResponse>("config");
if (cachedData) return cachedData;
const data = await request("/config.json");
cache.set("config", data);
return data;
}
/**

View File

@@ -25,7 +25,11 @@ function JupyterCell({ cell }: IJupyterCell): JSX.Element {
className="scrollbar-custom scrollbar-thumb-gray-500 hover:scrollbar-thumb-gray-400 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20 overflow-auto px-5"
style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
>
<SyntaxHighlighter language="python" style={atomOneDark}>
<SyntaxHighlighter
language="python"
style={atomOneDark}
wrapLongLines
>
{code}
</SyntaxHighlighter>
</pre>
@@ -78,7 +82,11 @@ function JupyterCell({ cell }: IJupyterCell): JSX.Element {
);
}
function JupyterEditor(): JSX.Element {
interface JupyterEditorProps {
maxWidth: number;
}
function JupyterEditor({ maxWidth }: JupyterEditorProps) {
const { t } = useTranslation();
const { cells } = useSelector((state: RootState) => state.jupyter);
@@ -88,7 +96,7 @@ function JupyterEditor(): JSX.Element {
useScrollToBottom(jupyterRef);
return (
<div className="flex-1">
<div className="flex-1" style={{ maxWidth }}>
<div
className="overflow-y-auto h-full"
ref={jupyterRef}

View File

@@ -16,6 +16,7 @@ interface ChatInputProps {
onChange?: (message: string) => void;
onFocus?: () => void;
onBlur?: () => void;
onImagePaste?: (files: File[]) => void;
className?: React.HTMLAttributes<HTMLDivElement>["className"];
}
@@ -32,9 +33,51 @@ export function ChatInput({
onChange,
onFocus,
onBlur,
onImagePaste,
className,
}: ChatInputProps) {
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const [isDraggingOver, setIsDraggingOver] = React.useState(false);
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
// Only handle paste if we have an image paste handler and there are files
if (onImagePaste && event.clipboardData.files.length > 0) {
const files = Array.from(event.clipboardData.files).filter((file) =>
file.type.startsWith("image/"),
);
// Only prevent default if we found image files to handle
if (files.length > 0) {
event.preventDefault();
onImagePaste(files);
}
}
// For text paste, let the default behavior handle it
};
const handleDragOver = (event: React.DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
if (event.dataTransfer.types.includes("Files")) {
setIsDraggingOver(true);
}
};
const handleDragLeave = (event: React.DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
setIsDraggingOver(false);
};
const handleDrop = (event: React.DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
setIsDraggingOver(false);
if (onImagePaste && event.dataTransfer.files.length > 0) {
const files = Array.from(event.dataTransfer.files).filter((file) =>
file.type.startsWith("image/"),
);
if (files.length > 0) {
onImagePaste(files);
}
}
};
const handleSubmitMessage = () => {
if (textareaRef.current?.value) {
@@ -67,12 +110,20 @@ export function ChatInput({
onChange={handleChange}
onFocus={onFocus}
onBlur={onBlur}
onPaste={handlePaste}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
value={value}
minRows={1}
maxRows={maxRows}
data-dragging-over={isDraggingOver}
className={cn(
"grow text-sm self-center placeholder:text-neutral-400 text-white resize-none bg-transparent outline-none ring-0",
"transition-[height] duration-200 ease-in-out",
"grow text-sm self-center placeholder:text-neutral-400 text-white resize-none outline-none ring-0",
"transition-all duration-200 ease-in-out",
isDraggingOver
? "bg-neutral-600/50 rounded-lg px-2"
: "bg-transparent",
className,
)}
/>

View File

@@ -1,5 +1,6 @@
import { useDispatch, useSelector } from "react-redux";
import React from "react";
import posthog from "posthog-js";
import { useSocket } from "#/context/socket";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { ChatMessage } from "./chat-message";
@@ -18,6 +19,9 @@ import ConfirmationButtons from "./chat/ConfirmationButtons";
import { ErrorMessage } from "./error-message";
import { ContinueButton } from "./continue-button";
import { ScrollToBottomButton } from "./scroll-to-bottom-button";
import { Suggestions } from "./suggestions";
import { SUGGESTIONS } from "#/utils/suggestions";
import BuildIt from "#/assets/build-it.svg?react";
const isErrorMessage = (
message: Message | ErrorMessage,
@@ -37,17 +41,23 @@ export function ChatInterface() {
"positive" | "negative"
>("positive");
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
const [messageToSend, setMessageToSend] = React.useState<string | null>(null);
const handleSendMessage = async (content: string, files: File[]) => {
posthog.capture("user_message_sent", {
current_message_count: messages.length,
});
const promises = files.map((file) => convertImageToBase64(file));
const imageUrls = await Promise.all(promises);
const timestamp = new Date().toISOString();
dispatch(addUserMessage({ content, imageUrls, timestamp }));
send(createChatMessage(content, imageUrls, timestamp));
setMessageToSend(null);
};
const handleStop = () => {
posthog.capture("stop_button_clicked");
send(generateAgentStateChangeEvent(AgentState.STOPPED));
};
@@ -64,6 +74,28 @@ export function ChatInterface() {
return (
<div className="h-full flex flex-col justify-between">
{messages.length === 0 && (
<div className="flex flex-col gap-6 h-full px-4 items-center justify-center">
<div className="flex flex-col items-center p-4 bg-neutral-700 rounded-xl w-full">
<BuildIt width={45} height={54} />
<span className="font-semibold text-[20px] leading-6 -tracking-[0.01em] gap-1">
Let&apos;s start building!
</span>
</div>
<Suggestions
suggestions={Object.entries(SUGGESTIONS.repo)
.slice(0, 4)
.map(([label, value]) => ({
label,
value,
}))}
onSuggestionClick={(value) => {
setMessageToSend(value);
}}
/>
</div>
)}
<div
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
@@ -123,6 +155,8 @@ export function ChatInterface() {
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
mode={curAgentState === AgentState.RUNNING ? "stop" : "submit"}
value={messageToSend ?? undefined}
onChange={setMessageToSend}
/>
</div>

View File

@@ -1,3 +1,6 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
interface CustomInputProps {
name: string;
label: string;
@@ -13,12 +16,19 @@ export function CustomInput({
defaultValue,
type = "text",
}: CustomInputProps) {
const { t } = useTranslation();
return (
<label htmlFor={name} className="flex flex-col gap-2">
<span className="text-[11px] leading-4 tracking-[0.5px] font-[500] text-[#A3A3A3]">
{label}
{required && <span className="text-[#FF4D4F]">*</span>}
{!required && <span className="text-[#A3A3A3]"> (optional)</span>}
{!required && (
<span className="text-[#A3A3A3]">
{" "}
{t(I18nKey.CUSTOM_INPUT$OPTIONAL_LABEL)}
</span>
)}
</span>
<input
id={name}

View File

@@ -5,16 +5,18 @@ import {
Switch,
} from "@nextui-org/react";
import { useFetcher, useLocation, useNavigate } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import React from "react";
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
import { ModelSelector } from "#/components/modals/settings/ModelSelector";
import { clientAction } from "#/routes/settings";
import { Settings } from "#/services/settings";
import { extractModelAndProvider } from "#/utils/extractModelAndProvider";
import { organizeModelsAndProviders } from "#/utils/organizeModelsAndProviders";
import { ModelSelector } from "#/components/modals/settings/ModelSelector";
import { Settings } from "#/services/settings";
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
import { clientAction } from "#/routes/settings";
import { extractModelAndProvider } from "#/utils/extractModelAndProvider";
import ModalButton from "../buttons/ModalButton";
import { DangerModal } from "../modals/confirmation-modals/danger-modal";
import { I18nKey } from "#/i18n/declaration";
interface SettingsFormProps {
disabled?: boolean;
@@ -35,6 +37,7 @@ export function SettingsForm({
}: SettingsFormProps) {
const location = useLocation();
const navigate = useNavigate();
const { t } = useTranslation();
const fetcher = useFetcher<typeof clientAction>();
const formRef = React.useRef<HTMLFormElement>(null);
@@ -161,7 +164,7 @@ export function SettingsForm({
label: "text-[#A3A3A3] text-xs",
}}
>
Advanced Options
{t(I18nKey.SETTINGS_FORM$ADVANCED_OPTIONS_LABEL)}
</Switch>
{showAdvancedOptions && (
@@ -171,7 +174,7 @@ export function SettingsForm({
htmlFor="custom-model"
className="font-[500] text-[#A3A3A3] text-xs"
>
Custom Model
{t(I18nKey.SETTINGS_FORM$CUSTOM_MODEL_LABEL)}
</label>
<Input
isDisabled={disabled}
@@ -190,7 +193,7 @@ export function SettingsForm({
htmlFor="base-url"
className="font-[500] text-[#A3A3A3] text-xs"
>
Base URL
{t(I18nKey.SETTINGS_FORM$BASE_URL_LABEL)}
</label>
<Input
isDisabled={disabled}
@@ -220,7 +223,7 @@ export function SettingsForm({
htmlFor="api-key"
className="font-[500] text-[#A3A3A3] text-xs"
>
API Key
{t(I18nKey.SETTINGS_FORM$API_KEY_LABEL)}
</label>
<Input
isDisabled={disabled}
@@ -234,14 +237,14 @@ export function SettingsForm({
}}
/>
<p className="text-sm text-[#A3A3A3]">
Don&apos;t know your API key?{" "}
{t(I18nKey.SETTINGS_FORM$DONT_KNOW_API_KEY_LABEL)}{" "}
<a
href="https://docs.all-hands.dev/modules/usage/llms"
rel="noreferrer noopener"
target="_blank"
className="underline underline-offset-2"
>
Click here for instructions
{t(I18nKey.SETTINGS_FORM$CLICK_HERE_FOR_INSTRUCTIONS_LABEL)}
</a>
</p>
</fieldset>
@@ -255,7 +258,7 @@ export function SettingsForm({
htmlFor="agent"
className="font-[500] text-[#A3A3A3] text-xs"
>
Agent
{t(I18nKey.SETTINGS_FORM$AGENT_LABEL)}
</label>
<Autocomplete
isDisabled={disabled}
@@ -291,7 +294,7 @@ export function SettingsForm({
htmlFor="security-analyzer"
className="font-[500] text-[#A3A3A3] text-xs"
>
Security Analyzer (Optional)
{t(I18nKey.SETTINGS_FORM$SECURITY_ANALYZER_LABEL)}
</label>
<Autocomplete
isDisabled={disabled}
@@ -334,7 +337,7 @@ export function SettingsForm({
label: "text-[#A3A3A3] text-xs",
}}
>
Enable Confirmation Mode
{t(I18nKey.SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL)}
</Switch>
</>
)}
@@ -345,18 +348,18 @@ export function SettingsForm({
<ModalButton
disabled={disabled || fetcher.state === "submitting"}
type="submit"
text="Save"
text={t(I18nKey.SETTINGS_FORM$SAVE_LABEL)}
className="bg-[#4465DB] w-full"
/>
<ModalButton
text="Close"
text={t(I18nKey.SETTINGS_FORM$CLOSE_LABEL)}
className="bg-[#737373] w-full"
onClick={handleCloseClick}
/>
</div>
<ModalButton
disabled={disabled}
text="Reset to defaults"
text={t(I18nKey.SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL)}
variant="text-like"
className="text-danger self-start"
onClick={() => {
@@ -369,15 +372,17 @@ export function SettingsForm({
{confirmResetDefaultsModalOpen && (
<ModalBackdrop>
<DangerModal
title="Are you sure?"
description="All saved information in your AI settings will be deleted including any API keys."
title={t(I18nKey.SETTINGS_FORM$ARE_YOU_SURE_LABEL)}
description={t(
I18nKey.SETTINGS_FORM$ALL_INFORMATION_WILL_BE_DELETED_MESSAGE,
)}
buttons={{
danger: {
text: "Reset Defaults",
text: t(I18nKey.SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL),
onClick: handleConfirmResetSettings,
},
cancel: {
text: "Cancel",
text: t(I18nKey.SETTINGS_FORM$CANCEL_LABEL),
onClick: () => setConfirmResetDefaultsModalOpen(false),
},
}}
@@ -387,12 +392,17 @@ export function SettingsForm({
{confirmEndSessionModalOpen && (
<ModalBackdrop>
<DangerModal
title="End Session"
description="Changing your settings will clear your workspace and start a new session. Are you sure you want to continue?"
title={t(I18nKey.SETTINGS_FORM$END_SESSION_LABEL)}
description={t(
I18nKey.SETTINGS_FORM$CHANGING_WORKSPACE_WARNING_MESSAGE,
)}
buttons={{
danger: { text: "End Session", onClick: handleConfirmEndSession },
danger: {
text: t(I18nKey.SETTINGS_FORM$END_SESSION_LABEL),
onClick: handleConfirmEndSession,
},
cancel: {
text: "Cancel",
text: t(I18nKey.SETTINGS_FORM$CANCEL_LABEL),
onClick: () => setConfirmEndSessionModalOpen(false),
},
}}

View File

@@ -9,6 +9,8 @@ interface InteractiveChatBoxProps {
mode?: "stop" | "submit";
onSubmit: (message: string, images: File[]) => void;
onStop: () => void;
value?: string;
onChange?: (message: string) => void;
}
export function InteractiveChatBox({
@@ -16,6 +18,8 @@ export function InteractiveChatBox({
mode = "submit",
onSubmit,
onStop,
value,
onChange,
}: InteractiveChatBoxProps) {
const [images, setImages] = React.useState<File[]>([]);
@@ -53,6 +57,13 @@ export function InteractiveChatBox({
className={cn(
"flex items-end gap-1",
"bg-neutral-700 border border-neutral-600 rounded-lg px-2 py-[10px]",
"transition-colors duration-200",
"hover:border-neutral-500 focus-within:border-neutral-500",
"group relative",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-lg before:transition-colors",
"before:border-2 before:border-dashed before:border-transparent",
"[&:has(*:focus-within)]:before:border-neutral-500/50",
"[&:has(*[data-dragging-over='true'])]:before:border-neutral-500/50",
)}
>
<UploadImageInput onUpload={handleUpload} />
@@ -60,8 +71,11 @@ export function InteractiveChatBox({
disabled={isDisabled}
button={mode}
placeholder="What do you want to build?"
onChange={onChange}
onSubmit={handleSubmit}
onStop={onStop}
value={value}
onImagePaste={handleUpload}
/>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { useFetcher, useRouteLoaderData } from "@remix-run/react";
import React from "react";
import { useTranslation } from "react-i18next";
import { BaseModalTitle } from "./confirmation-modals/BaseModal";
import ModalBody from "./ModalBody";
import ModalButton from "../buttons/ModalButton";
@@ -9,6 +10,7 @@ import { clientLoader } from "#/routes/_oh";
import { clientAction as settingsClientAction } from "#/routes/settings";
import { clientAction as loginClientAction } from "#/routes/login";
import { AvailableLanguages } from "#/i18n";
import { I18nKey } from "#/i18n/declaration";
interface AccountSettingsModalProps {
onClose: () => void;
@@ -23,6 +25,7 @@ function AccountSettingsModal({
gitHubError,
analyticsConsent,
}: AccountSettingsModalProps) {
const { t } = useTranslation();
const data = useRouteLoaderData<typeof clientLoader>("routes/_oh");
const settingsFetcher = useFetcher<typeof settingsClientAction>({
key: "settings",
@@ -86,13 +89,13 @@ function AccountSettingsModal({
/>
{gitHubError && (
<p className="text-danger text-xs">
GitHub token is invalid. Please try again.
{t(I18nKey.ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID)}
</p>
)}
{data?.ghToken && !gitHubError && (
<ModalButton
variant="text-like"
text="Disconnect"
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$DISCONNECT)}
onClick={() => {
settingsFetcher.submit(
{},
@@ -122,11 +125,11 @@ function AccountSettingsModal({
}
type="submit"
intent="account"
text="Save"
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$SAVE)}
className="bg-[#4465DB]"
/>
<ModalButton
text="Close"
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$CLOSE)}
onClick={onClose}
className="bg-[#737373]"
/>

View File

@@ -1,4 +1,5 @@
import { Form, useNavigation } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import {
BaseModalDescription,
BaseModalTitle,
@@ -7,10 +8,11 @@ import ModalButton from "../buttons/ModalButton";
import AllHandsLogo from "#/assets/branding/all-hands-logo-spark.svg?react";
import ModalBody from "./ModalBody";
import { CustomInput } from "../form/custom-input";
import { I18nKey } from "#/i18n/declaration";
function ConnectToGitHubByTokenModal() {
const navigation = useNavigation();
const { t } = useTranslation();
return (
<ModalBody testID="auth-modal">
<div className="flex flex-col gap-2">
@@ -29,13 +31,18 @@ function ConnectToGitHubByTokenModal() {
required
/>
<p className="text-xs text-[#A3A3A3]">
By connecting you agree to our{" "}
<span className="text-hyperlink">terms of service</span>.
{t(
I18nKey.CONNECT_TO_GITHUB_BY_TOKEN_MODAL$BY_CONNECTING_YOU_AGREE,
)}{" "}
<span className="text-hyperlink">
{t(I18nKey.CONNECT_TO_GITHUB_BY_TOKEN_MODAL$TERMS_OF_SERVICE)}
</span>
.
</p>
</label>
<ModalButton
type="submit"
text="Continue"
text={t(I18nKey.CONNECT_TO_GITHUB_BY_TOKEN_MODAL$CONTINUE)}
className="bg-[#791B80] w-full"
disabled={navigation.state === "loading"}
/>

View File

@@ -1,6 +1,8 @@
import { useTranslation } from "react-i18next";
import LoadingSpinnerOuter from "#/assets/loading-outer.svg?react";
import { cn } from "#/utils/utils";
import ModalBody from "./ModalBody";
import { I18nKey } from "#/i18n/declaration";
interface LoadingSpinnerProps {
size: "small" | "large";
@@ -28,10 +30,12 @@ interface LoadingProjectModalProps {
}
function LoadingProjectModal({ message }: LoadingProjectModalProps) {
const { t } = useTranslation();
return (
<ModalBody>
<span className="text-xl leading-6 -tracking-[0.01em] font-semibold">
{message || "Loading..."}
{message || t(I18nKey.LOADING_PROJECT$LOADING)}
</span>
<LoadingSpinner size="large" />
</ModalBody>

View File

@@ -1,4 +1,5 @@
import { useFetcher, useRouteLoaderData } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import ModalBody from "./ModalBody";
import { CustomInput } from "../form/custom-input";
import ModalButton from "../buttons/ModalButton";
@@ -8,6 +9,7 @@ import {
} from "./confirmation-modals/BaseModal";
import { clientLoader } from "#/routes/_oh";
import { clientAction } from "#/routes/login";
import { I18nKey } from "#/i18n/declaration";
interface ConnectToGitHubModalProps {
onClose: () => void;
@@ -16,6 +18,7 @@ interface ConnectToGitHubModalProps {
export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
const data = useRouteLoaderData<typeof clientLoader>("routes/_oh");
const fetcher = useFetcher<typeof clientAction>({ key: "login" });
const { t } = useTranslation();
return (
<ModalBody>
@@ -24,14 +27,14 @@ export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
<BaseModalDescription
description={
<span>
Get your token{" "}
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "}
<a
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
rel="noreferrer noopener"
className="text-[#791B80] underline"
>
here
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)}
</a>
</span>
}
@@ -55,13 +58,13 @@ export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
<ModalButton
testId="connect-to-github"
type="submit"
text="Connect"
text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CONNECT)}
disabled={fetcher.state === "submitting"}
className="bg-[#791B80] w-full"
/>
<ModalButton
onClick={onClose}
text="Close"
text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CLOSE)}
className="bg-[#737373] w-full"
/>
</div>

View File

@@ -1,6 +1,8 @@
import React from "react";
import { useTranslation } from "react-i18next";
import SecurityInvariant from "./invariant/Invariant";
import BaseModal from "../base-modal/BaseModal";
import { I18nKey } from "#/i18n/declaration";
interface SecurityProps {
isOpen: boolean;
@@ -17,11 +19,13 @@ const SecurityAnalyzers: Record<SecurityAnalyzerOption, React.ElementType> = {
};
function Security({ isOpen, onOpenChange, securityAnalyzer }: SecurityProps) {
const { t } = useTranslation();
const AnalyzerComponent =
securityAnalyzer &&
SecurityAnalyzers[securityAnalyzer as SecurityAnalyzerOption]
? SecurityAnalyzers[securityAnalyzer as SecurityAnalyzerOption]
: () => <div>Unknown security analyzer chosen</div>;
: () => <div>{t(I18nKey.SECURITY$UNKNOWN_ANALYZER_LABEL)}</div>;
return (
<BaseModal

View File

@@ -123,7 +123,7 @@ function SecurityInvariant(): JSX.Element {
async function exportTraces(): Promise<void> {
const data = await request(`/api/security/export-trace`);
toast.info("Trace exported");
toast.info(t(I18nKey.INVARIANT$TRACE_EXPORTED_MESSAGE));
const filename = `openhands-trace-${getFormattedDateTime()}.json`;
downloadJSON(data, filename);
@@ -134,7 +134,7 @@ function SecurityInvariant(): JSX.Element {
method: "POST",
body: JSON.stringify({ policy }),
});
toast.info("Policy updated");
toast.info(t(I18nKey.INVARIANT$POLICY_UPDATED_MESSAGE));
}
async function updateSettings(): Promise<void> {
@@ -143,7 +143,7 @@ function SecurityInvariant(): JSX.Element {
method: "POST",
body: JSON.stringify(payload),
});
toast.info("Settings updated");
toast.info(t(I18nKey.INVARIANT$SETTINGS_UPDATED_MESSAGE));
}
const handleExportTraces = useCallback(() => {
@@ -162,9 +162,9 @@ function SecurityInvariant(): JSX.Element {
logs: (
<>
<div className="flex justify-between items-center border-b border-neutral-600 mb-4 p-4">
<h2 className="text-2xl">Logs</h2>
<h2 className="text-2xl">{t(I18nKey.INVARIANT$LOG_LABEL)}</h2>
<Button onClick={handleExportTraces} className="bg-neutral-700">
Export Trace
{t(I18nKey.INVARIANT$EXPORT_TRACE_LABEL)}
</Button>
</div>
<div className="flex-1 p-4 max-h-screen overflow-y-auto" ref={logsRef}>
@@ -195,9 +195,9 @@ function SecurityInvariant(): JSX.Element {
policy: (
<>
<div className="flex justify-between items-center border-b border-neutral-600 mb-4 p-4">
<h2 className="text-2xl">Policy</h2>
<h2 className="text-2xl">{t(I18nKey.INVARIANT$POLICY_LABEL)}</h2>
<Button className="bg-neutral-700" onClick={handleUpdatePolicy}>
Update Policy
{t(I18nKey.INVARIANT$UPDATE_POLICY_LABEL)}
</Button>
</div>
<div className="flex grow items-center justify-center">
@@ -214,14 +214,16 @@ function SecurityInvariant(): JSX.Element {
settings: (
<>
<div className="flex justify-between items-center border-b border-neutral-600 mb-4 p-4">
<h2 className="text-2xl">Settings</h2>
<h2 className="text-2xl">{t(I18nKey.INVARIANT$SETTINGS_LABEL)}</h2>
<Button className="bg-neutral-700" onClick={handleUpdateSettings}>
Update Settings
{t(I18nKey.INVARIANT$UPDATE_SETTINGS_LABEL)}
</Button>
</div>
<div className="flex grow p-4">
<div className="flex flex-col w-full">
<p className="mb-2">Ask for user confirmation on risk severity:</p>
<p className="mb-2">
{t(I18nKey.INVARIANT$ASK_CONFIRMATION_RISK_SEVERITY_LABEL)}
</p>
<Select
placeholder="Select risk severity"
value={selectedRisk}
@@ -264,7 +266,7 @@ function SecurityInvariant(): JSX.Element {
key={ActionSecurityRisk.HIGH + 1}
aria-label="Don't ask for confirmation"
>
Don&apos;t ask for confirmation
{t(I18nKey.INVARIANT$DONT_ASK_FOR_CONFIRMATION_LABEL)}
</SelectItem>
</Select>
</div>
@@ -278,18 +280,17 @@ function SecurityInvariant(): JSX.Element {
<div className="w-60 bg-neutral-800 border-r border-r-neutral-600 p-4 flex-shrink-0">
<div className="text-center mb-2">
<InvariantLogoIcon className="mx-auto mb-1" />
<b>Invariant Analyzer</b>
<b>{t(I18nKey.INVARIANT$INVARIANT_ANALYZER_LABEL)}</b>
</div>
<p className="text-[0.6rem]">
Invariant Analyzer continuously monitors your OpenHands agent for
security issues.{" "}
{t(I18nKey.INVARIANT$INVARIANT_ANALYZER_MESSAGE)}{" "}
<a
className="underline"
href="https://github.com/invariantlabs-ai/invariant"
target="_blank"
rel="noreferrer"
>
Click to learn more
{t(I18nKey.INVARIANT$CLICK_TO_LEARN_MORE_LABEL)}
</a>
</p>
<hr className="border-t border-neutral-600 my-2" />
@@ -298,19 +299,19 @@ function SecurityInvariant(): JSX.Element {
className={`cursor-pointer p-2 rounded ${activeSection === "logs" && "bg-neutral-600"}`}
onClick={() => setActiveSection("logs")}
>
Logs
{t(I18nKey.INVARIANT$LOG_LABEL)}
</div>
<div
className={`cursor-pointer p-2 rounded ${activeSection === "policy" && "bg-neutral-600"}`}
onClick={() => setActiveSection("policy")}
>
Policy
{t(I18nKey.INVARIANT$POLICY_LABEL)}
</div>
<div
className={`cursor-pointer p-2 rounded ${activeSection === "settings" && "bg-neutral-600"}`}
onClick={() => setActiveSection("settings")}
>
Settings
{t(I18nKey.INVARIANT$SETTINGS_LABEL)}
</div>
</ul>
</div>

View File

@@ -1,6 +1,7 @@
import React from "react";
import { useDispatch } from "react-redux";
import toast from "react-hot-toast";
import posthog from "posthog-js";
import EllipsisH from "#/assets/ellipsis-h.svg?react";
import { ModalBackdrop } from "../modals/modal-backdrop";
import { ConnectToGitHubModal } from "../modals/connect-to-github-modal";
@@ -11,6 +12,7 @@ import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
import { ProjectMenuDetails } from "./project-menu-details";
import { downloadWorkspace } from "#/utils/download-workspace";
import { LoadingSpinner } from "../modals/LoadingProject";
interface ProjectMenuCardProps {
isConnectedToGitHub: boolean;
@@ -31,12 +33,14 @@ export function ProjectMenuCard({
const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
React.useState(false);
const [working, setWorking] = React.useState(false);
const toggleMenuVisibility = () => {
setContextMenuIsOpen((prev) => !prev);
};
const handlePushToGitHub = () => {
posthog.capture("push_to_github_button_clicked");
const rawEvent = {
content: `
Let's push the code to GitHub.
@@ -58,20 +62,27 @@ Finally, open up a pull request using the GitHub API and the token in the GITHUB
setContextMenuIsOpen(false);
};
const handleDownloadWorkspace = () => {
posthog.capture("download_workspace_button_clicked");
try {
setWorking(true);
downloadWorkspace().then(
() => setWorking(false),
() => setWorking(false),
);
} catch (error) {
toast.error("Failed to download workspace");
}
};
return (
<div className="px-4 py-[10px] w-[337px] rounded-xl border border-[#525252] flex justify-between items-center relative">
{contextMenuIsOpen && (
{!working && contextMenuIsOpen && (
<ProjectMenuCardContextMenu
isConnectedToGitHub={isConnectedToGitHub}
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
onPushToGitHub={handlePushToGitHub}
onDownloadWorkspace={() => {
try {
downloadWorkspace();
} catch (error) {
toast.error("Failed to download workspace");
}
}}
onDownloadWorkspace={handleDownloadWorkspace}
onClose={() => setContextMenuIsOpen(false)}
/>
)}
@@ -93,7 +104,11 @@ Finally, open up a pull request using the GitHub API and the token in the GITHUB
onClick={toggleMenuVisibility}
aria-label="Open project menu"
>
<EllipsisH width={36} height={36} />
{working ? (
<LoadingSpinner size="small" />
) : (
<EllipsisH width={36} height={36} />
)}
</button>
{connectToGitHubModalOpen && (
<ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}>

View File

@@ -1,5 +1,7 @@
import { useTranslation } from "react-i18next";
import { cn } from "#/utils/utils";
import CloudConnection from "#/assets/cloud-connection.svg?react";
import { I18nKey } from "#/i18n/declaration";
interface ProjectMenuDetailsPlaceholderProps {
isConnectedToGitHub: boolean;
@@ -10,9 +12,13 @@ export function ProjectMenuDetailsPlaceholder({
isConnectedToGitHub,
onConnectToGitHub,
}: ProjectMenuDetailsPlaceholderProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col">
<span className="text-sm leading-6 font-semibold">New Project</span>
<span className="text-sm leading-6 font-semibold">
{t(I18nKey.PROJECT_MENU_DETAILS_PLACEHOLDER$NEW_PROJECT_LABEL)}
</span>
<button
type="button"
onClick={onConnectToGitHub}

View File

@@ -1,5 +1,7 @@
import { useTranslation } from "react-i18next";
import ExternalLinkIcon from "#/assets/external-link.svg?react";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { I18nKey } from "#/i18n/declaration";
interface ProjectMenuDetailsProps {
repoName: string;
@@ -12,6 +14,7 @@ export function ProjectMenuDetails({
avatar,
lastCommit,
}: ProjectMenuDetailsProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col">
<a
@@ -32,7 +35,8 @@ export function ProjectMenuDetails({
>
<span>{lastCommit.sha.slice(-7)}</span> <span>&middot;</span>{" "}
<span>
{formatTimeDelta(new Date(lastCommit.commit.author.date))} ago
{formatTimeDelta(new Date(lastCommit.commit.author.date))}{" "}
{t(I18nKey.PROJECT_MENU_DETAILS$AGO_LABEL)}
</span>
</a>
</div>

View File

@@ -1,6 +1,8 @@
import { useTranslation } from "react-i18next";
import { useClickOutsideElement } from "#/hooks/useClickOutsideElement";
import { ContextMenu } from "../context-menu/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { I18nKey } from "#/i18n/declaration";
interface ProjectMenuCardContextMenuProps {
isConnectedToGitHub: boolean;
@@ -18,7 +20,7 @@ export function ProjectMenuCardContextMenu({
onClose,
}: ProjectMenuCardContextMenuProps) {
const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);
const { t } = useTranslation();
return (
<ContextMenu
ref={menuRef}
@@ -26,16 +28,16 @@ export function ProjectMenuCardContextMenu({
>
{!isConnectedToGitHub && (
<ContextMenuListItem onClick={onConnectToGitHub}>
Connect to GitHub
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL)}
</ContextMenuListItem>
)}
{isConnectedToGitHub && (
<ContextMenuListItem onClick={onPushToGitHub}>
Push to GitHub
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL)}
</ContextMenuListItem>
)}
<ContextMenuListItem onClick={onDownloadWorkspace}>
Download as .zip
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL)}
</ContextMenuListItem>
</ContextMenu>
);

View File

@@ -0,0 +1,21 @@
export type Suggestion = { label: string; value: string };
interface SuggestionItemProps {
suggestion: Suggestion;
onClick: (value: string) => void;
}
export function SuggestionItem({ suggestion, onClick }: SuggestionItemProps) {
return (
<li className="border border-neutral-600 rounded-xl hover:bg-neutral-700">
<button
type="button"
data-testid="suggestion"
onClick={() => onClick(suggestion.value)}
className="text-[16px] leading-6 -tracking-[0.01em] text-center w-full p-4 font-semibold"
>
{suggestion.label}
</button>
</li>
);
}

View File

@@ -0,0 +1,23 @@
import { SuggestionItem, type Suggestion } from "./suggestion-item";
interface SuggestionsProps {
suggestions: Suggestion[];
onSuggestionClick: (value: string) => void;
}
export function Suggestions({
suggestions,
onSuggestionClick,
}: SuggestionsProps) {
return (
<ul data-testid="suggestions" className="flex flex-col gap-4 w-full">
{suggestions.map((suggestion, index) => (
<SuggestionItem
key={index}
suggestion={suggestion}
onClick={onSuggestionClick}
/>
))}
</ul>
);
}

View File

@@ -1,5 +1,6 @@
import React from "react";
import { Data } from "ws";
import posthog from "posthog-js";
import EventLogger from "#/utils/event-logger";
interface WebSocketClientOptions {
@@ -58,6 +59,7 @@ function SocketProvider({ children }: SocketProviderProps) {
]);
ws.addEventListener("open", (event) => {
posthog.capture("socket_opened");
setIsConnected(true);
options?.onOpen?.(event);
});
@@ -70,11 +72,13 @@ function SocketProvider({ children }: SocketProviderProps) {
});
ws.addEventListener("error", (event) => {
posthog.capture("socket_error");
EventLogger.event(event, "SOCKET ERROR");
options?.onError?.(event);
});
ws.addEventListener("close", (event) => {
posthog.capture("socket_closed");
EventLogger.event(event, "SOCKET CLOSE");
setIsConnected(false);

View File

@@ -798,7 +798,96 @@
"tr": "İptal"
},
"FEEDBACK$EMAIL_PLACEHOLDER": {
"en": "Enter your email address."
"en": "Enter your email address",
"es": "Ingresa tu correo electrónico"
},
"FEEDBACK$PASSWORD_COPIED_MESSAGE": {
"en": "Password copied to clipboard.",
"es": "Contraseña copiada al portapapeles."
},
"FEEDBACK$GO_TO_FEEDBACK": {
"en": "Go to shared feedback",
"es": "Ir a feedback compartido"
},
"FEEDBACK$PASSWORD": {
"en": "Password:",
"es": "Contraseña:"
},
"FEEDBACK$INVALID_EMAIL_FORMAT": {
"en": "Invalid email format",
"es": "Formato de correo inválido"
},
"FEEDBACK$FAILED_TO_SHARE": {
"en": "Failed to share, please contact the developers:",
"es": "Error al compartir, por favor contacta con los desarrolladores:"
},
"FEEDBACK$COPY_LABEL": {
"en": "Copy",
"es": "Copiar"
},
"FEEDBACK$SHARING_SETTINGS_LABEL": {
"en": "Sharing settings",
"es": "Configuración de compartir"
},
"SECURITY$UNKNOWN_ANALYZER_LABEL":{
"en": "Unknown security analyzer chosen",
"es": "Analizador de seguridad desconocido"
},
"INVARIANT$UPDATE_POLICY_LABEL": {
"en": "Update Policy",
"es": "Actualizar política"
},
"INVARIANT$UPDATE_SETTINGS_LABEL": {
"en": "Update Settings",
"es": "Actualizar configuración"
},
"INVARIANT$SETTINGS_LABEL": {
"en": "Settings",
"es": "Configuración"
},
"INVARIANT$ASK_CONFIRMATION_RISK_SEVERITY_LABEL": {
"en": "Ask for user confirmation on risk severity:",
"es": "Preguntar por confirmación del usuario sobre severidad del riesgo:"
},
"INVARIANT$DONT_ASK_FOR_CONFIRMATION_LABEL": {
"en": "Don't ask for confirmation",
"es": "No solicitar confirmación"
},
"INVARIANT$INVARIANT_ANALYZER_LABEL": {
"en": "Invariant Analyzer",
"es": "Analizador de invariantes"
},
"INVARIANT$INVARIANT_ANALYZER_MESSAGE": {
"en": "Invariant Analyzer continuously monitors your OpenHands agent for security issues.",
"es": "Analizador de invariantes continuamente monitorea tu agente de OpenHands por problemas de seguridad."
},
"INVARIANT$CLICK_TO_LEARN_MORE_LABEL": {
"en": "Click to learn more",
"es": "Clic para aprender más"
},
"INVARIANT$POLICY_LABEL": {
"en": "Policy",
"es": "Política"
},
"INVARIANT$LOG_LABEL": {
"en": "Logs",
"es": "Logs"
},
"INVARIANT$EXPORT_TRACE_LABEL": {
"en": "Export Trace",
"es": "Exportar traza"
},
"INVARIANT$TRACE_EXPORTED_MESSAGE": {
"en": "Trace exported",
"es": "Traza exportada"
},
"INVARIANT$POLICY_UPDATED_MESSAGE": {
"en": "Policy updated",
"es": "Política actualizada"
},
"INVARIANT$SETTINGS_UPDATED_MESSAGE": {
"en": "Settings updated",
"es": "Configuración actualizada"
},
"CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE": {
"en": "Starting up!",
@@ -1442,10 +1531,12 @@
"tr": "Özel"
},
"ERROR_MESSAGE$SHOW_DETAILS": {
"en": "Show details"
"en": "Show details",
"es": "Mostrar detalles"
},
"ERROR_MESSAGE$HIDE_DETAILS": {
"en": "Hide details"
"en": "Hide details",
"es": "Ocultar detalles"
},
"STATUS$STARTING_RUNTIME": {
"en": "Starting Runtime...",
@@ -1517,8 +1608,137 @@
"fr": "En attente que le client soit prêt...",
"tr": "İstemcinin hazır olması bekleniyor..."
},
"ACCOUNT_SETTINGS_MODAL$DISCONNECT":{
"en": "Disconnect",
"es": "Desconectar"
},
"ACCOUNT_SETTINGS_MODAL$SAVE":{
"en": "Save",
"es": "Guardar"
},
"ACCOUNT_SETTINGS_MODAL$CLOSE":{
"en": "Close",
"es": "Cerrar"
},
"ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID":{
"en": "GitHub token is invalid. Please try again.",
"es": ""
},
"CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN": {
"en": "Get your token",
"es": "Obten tu token"
},
"CONNECT_TO_GITHUB_MODAL$HERE": {
"en": "here",
"es": "aquí"
},
"CONNECT_TO_GITHUB_MODAL$CONNECT": {
"en": "Connect",
"es": "Conectar"
},
"CONNECT_TO_GITHUB_MODAL$CLOSE": {
"en": "Close",
"es": "Cerrar"
},
"CONNECT_TO_GITHUB_BY_TOKEN_MODAL$BY_CONNECTING_YOU_AGREE": {
"en": "By connecting you agree to our",
"es": "Al conectarte tu aceptas nuestros"
},
"CONNECT_TO_GITHUB_BY_TOKEN_MODAL$TERMS_OF_SERVICE": {
"en": "terms of service",
"es": "términos de servicio"
},
"CONNECT_TO_GITHUB_BY_TOKEN_MODAL$CONTINUE": {
"en": "Continue",
"es": "Continuar"
},
"LOADING_PROJECT$LOADING": {
"en": "Loading...",
"es": "Cargando..."
},
"CUSTOM_INPUT$OPTIONAL_LABEL": {
"en": "(Optional)",
"es": "(Opcional)"
},
"SETTINGS_FORM$ADVANCED_OPTIONS_LABEL": {
"en": "Advanced Options",
"es": "Opciones avanzadas"
},
"SETTINGS_FORM$CUSTOM_MODEL_LABEL": {
"en": "Custom Model",
"es": "Modelo personalizado"
},
"SETTINGS_FORM$BASE_URL_LABEL": {
"en": "Base URL",
"es": "URL base"
},
"SETTINGS_FORM$API_KEY_LABEL": {
"en": "API Key",
"es": "API Key"
},
"SETTINGS_FORM$DONT_KNOW_API_KEY_LABEL": {
"en": "Don't know your API key?",
"es": "¿No sabes tu API key?"
},
"SETTINGS_FORM$CLICK_HERE_FOR_INSTRUCTIONS_LABEL": {
"en": "Click here for instructions",
"es": "Clic aquí para instrucciones"
},
"SETTINGS_FORM$AGENT_LABEL": {
"en": "Agent",
"es": "Agente"
},
"SETTINGS_FORM$SECURITY_ANALYZER_LABEL": {
"en": "Security Analyzer (Optional)",
"es": "Analizador de seguridad (opcional)"
},
"SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL": {
"en": "Enable Confirmation Mode",
"es": "Habilitar modo de confirmación"
},
"SETTINGS_FORM$SAVE_LABEL": {
"en": "Save",
"es": "Guardar"
},
"SETTINGS_FORM$CLOSE_LABEL": {
"en": "Close",
"es": "Cerrar"
},
"SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL": {
"en": "Reset to defaults",
"es": "Reiniciar valores por defect"
},
"SETTINGS_FORM$CANCEL_LABEL": {
"en": "Cancel",
"es": "Cancelar"
},
"SETTINGS_FORM$END_SESSION_LABEL": {
"en": "End Session",
"es": "Terminar sesión"
},
"SETTINGS_FORM$CHANGING_WORKSPACE_WARNING_MESSAGE": {
"en": "Changing your settings will clear your workspace and start a new session. Are you sure you want to continue?",
"es": "Cambiar tu configuración limpiará tu espacio de trabajo e iniciará una nueva sesión. ¿Estás seguro de continuar?"
},
"SETTINGS_FORM$ARE_YOU_SURE_LABEL": {
"en": "Are you sure?",
"es": "¿Estás seguro?"
},
"SETTINGS_FORM$ALL_INFORMATION_WILL_BE_DELETED_MESSAGE": {
"en": "All saved information in your AI settings will be deleted, including any API keys.",
"es": "Toda la información guardada en tu configuración de IA será eliminada, incluyendo tus API Keys"
},
"PROJECT_MENU_DETAILS_PLACEHOLDER$NEW_PROJECT_LABEL": {
"en":"New Project",
"es":"Nuevo proyecto"
},
"PROJECT_MENU_DETAILS$AGO_LABEL": {
"en":"ago",
"es":"atrás"
},
"STATUS$ERROR_LLM_AUTHENTICATION": {
"en": "Error authenticating with the LLM provider. Please check your API key"
"en": "Error authenticating with the LLM provider. Please check your API key",
"es": "Error autenticando con el proveedor de LLM. Por favor revisa tu API key"
},
"STATUS$ERROR_RUNTIME_DISCONNECTED": {
"en": "There was an error while connecting to the runtime. Please refresh the page."
@@ -1528,5 +1748,17 @@
},
"AGENT_ERROR$ACTION_TIMEOUT": {
"en": "Action timed out."
},
"PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL": {
"en": "Connect to GitHub",
"es": "Conectar a GitHub"
},
"PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL": {
"en": "Push to GitHub",
"es": "Subir a GitHub"
},
"PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL": {
"en": "Download as .zip",
"es": "Descargar como .zip"
}
}

View File

@@ -92,6 +92,9 @@ export const handlers = [
id: 1,
login: "octocat",
avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4",
company: "GitHub",
email: "placeholder@placeholder.placeholder",
name: "monalisa octocat",
};
return HttpResponse.json(user);

View File

@@ -8,17 +8,23 @@ import {
useNavigate,
useRouteLoaderData,
} from "@remix-run/react";
import React, { Suspense } from "react";
import React from "react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { SuggestionBox } from "./suggestion-box";
import { TaskForm } from "./task-form";
import { HeroHeading } from "./hero-heading";
import { retrieveAllGitHubUserRepositories } from "#/api/github";
import store from "#/store";
import { setInitialQuery } from "#/state/initial-query-slice";
import {
setImportedProjectZip,
setInitialQuery,
} from "#/state/initial-query-slice";
import { clientLoader as rootClientLoader } from "#/routes/_oh";
import OpenHands from "#/api/open-hands";
import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
import { GitHubRepositoriesSuggestionBox } from "#/components/github-repositories-suggestion-box";
import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
let isSaas = false;
@@ -59,14 +65,18 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
const q = formData.get("q")?.toString();
if (q) store.dispatch(setInitialQuery(q));
posthog.capture("initial_query_submitted", {
query_character_length: q?.length,
});
return redirect("/app");
};
function Home() {
const navigate = useNavigate();
const dispatch = useDispatch();
const rootData = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
const { repositories, githubAuthUrl } = useLoaderData<typeof clientLoader>();
const [importedFile, setImportedFile] = React.useState<File | null>(null);
return (
<div
@@ -76,10 +86,10 @@ function Home() {
<HeroHeading />
<div className="flex flex-col gap-16 w-[600px] items-center">
<div className="flex flex-col gap-2 w-full">
<TaskForm importedProjectZip={importedFile} />
<TaskForm />
</div>
<div className="flex gap-4 w-full">
<Suspense
<React.Suspense
fallback={
<SuggestionBox
title="Open a Repo"
@@ -96,35 +106,36 @@ function Home() {
/>
)}
</Await>
</Suspense>
</React.Suspense>
<SuggestionBox
title={importedFile ? "Project Loaded" : "+ Import Project"}
title="+ Import Project"
content={
importedFile?.name ?? (
<label
htmlFor="import-project"
className="w-full flex justify-center"
>
<span className="border-2 border-dashed border-neutral-600 rounded px-2 py-1 cursor-pointer">
Upload a .zip
</span>
<input
hidden
type="file"
accept="application/zip"
id="import-project"
multiple={false}
onChange={(event) => {
if (event.target.files) {
const zip = event.target.files[0];
setImportedFile(zip);
} else {
// TODO: handle error
}
}}
/>
</label>
)
<label
htmlFor="import-project"
className="w-full flex justify-center"
>
<span className="border-2 border-dashed border-neutral-600 rounded px-2 py-1 cursor-pointer">
Upload a .zip
</span>
<input
hidden
type="file"
accept="application/zip"
id="import-project"
multiple={false}
onChange={async (event) => {
if (event.target.files) {
const zip = event.target.files[0];
dispatch(
setImportedProjectZip(await convertZipToBase64(zip)),
);
navigate("/app");
} else {
// TODO: handle error
}
}}
/>
</label>
}
/>
</div>

View File

@@ -2,11 +2,7 @@ import React from "react";
import { Form, useNavigation } from "@remix-run/react";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "#/store";
import {
addFile,
removeFile,
setImportedProjectZip,
} from "#/state/initial-query-slice";
import { addFile, removeFile } from "#/state/initial-query-slice";
import { SuggestionBubble } from "#/components/suggestion-bubble";
import { SUGGESTIONS } from "#/utils/suggestions";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
@@ -14,15 +10,10 @@ import { ChatInput } from "#/components/chat-input";
import { UploadImageInput } from "#/components/upload-image-input";
import { ImageCarousel } from "#/components/image-carousel";
import { getRandomKey } from "#/utils/get-random-key";
import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
import { AttachImageLabel } from "#/components/attach-image-label";
import { cn } from "#/utils/utils";
interface TaskFormProps {
importedProjectZip: File | null;
}
export function TaskForm({ importedProjectZip }: TaskFormProps) {
export function TaskForm() {
const dispatch = useDispatch();
const navigation = useNavigation();
@@ -30,29 +21,15 @@ export function TaskForm({ importedProjectZip }: TaskFormProps) {
(state: RootState) => state.initalQuery,
);
const hasLoadedProject = React.useMemo(
() => importedProjectZip || selectedRepository,
[importedProjectZip, selectedRepository],
);
const formRef = React.useRef<HTMLFormElement>(null);
const [text, setText] = React.useState("");
const [suggestion, setSuggestion] = React.useState(
getRandomKey(hasLoadedProject ? SUGGESTIONS.repo : SUGGESTIONS["non-repo"]),
getRandomKey(SUGGESTIONS["non-repo"]),
);
const [inputIsFocused, setInputIsFocused] = React.useState(false);
React.useEffect(() => {
// Display a suggestion based on whether a repository is selected
if (hasLoadedProject) {
setSuggestion(getRandomKey(SUGGESTIONS.repo));
} else {
setSuggestion(getRandomKey(SUGGESTIONS["non-repo"]));
}
}, [selectedRepository, importedProjectZip]);
const onRefreshSuggestion = () => {
const suggestions = SUGGESTIONS[hasLoadedProject ? "repo" : "non-repo"];
const suggestions = SUGGESTIONS["non-repo"];
// remove current suggestion to avoid refreshing to the same suggestion
const suggestionCopy = { ...suggestions };
delete suggestionCopy[suggestion];
@@ -62,20 +39,11 @@ export function TaskForm({ importedProjectZip }: TaskFormProps) {
};
const onClickSuggestion = () => {
const suggestions = SUGGESTIONS[hasLoadedProject ? "repo" : "non-repo"];
const suggestions = SUGGESTIONS["non-repo"];
const value = suggestions[suggestion];
setText(value);
};
const handleSubmitForm = async () => {
// This is handled on top of the form submission
if (importedProjectZip) {
dispatch(
setImportedProjectZip(await convertZipToBase64(importedProjectZip)),
);
}
};
const placeholder = React.useMemo(() => {
if (selectedRepository) {
return `What would you like to change in ${selectedRepository}?`;
@@ -90,7 +58,6 @@ export function TaskForm({ importedProjectZip }: TaskFormProps) {
ref={formRef}
method="post"
className="flex flex-col items-center gap-2"
onSubmit={handleSubmitForm}
replace
>
<SuggestionBubble
@@ -100,8 +67,14 @@ export function TaskForm({ importedProjectZip }: TaskFormProps) {
/>
<div
className={cn(
"border border-neutral-600 px-4 py-[17px] rounded-lg text-[17px] leading-5 w-full",
"border border-neutral-600 px-4 py-[17px] rounded-lg text-[17px] leading-5 w-full transition-colors duration-200",
inputIsFocused ? "bg-neutral-600" : "bg-neutral-700",
"hover:border-neutral-500 focus-within:border-neutral-500",
"group relative",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-lg before:transition-colors",
"before:border-2 before:border-dashed before:border-transparent",
"[&:has(*:focus-within)]:before:border-neutral-500/50",
"[&:has(*[data-dragging-over='true'])]:before:border-neutral-500/50",
)}
>
<ChatInput
@@ -112,6 +85,13 @@ export function TaskForm({ importedProjectZip }: TaskFormProps) {
onChange={(message) => setText(message)}
onFocus={() => setInputIsFocused(true)}
onBlur={() => setInputIsFocused(false)}
onImagePaste={async (imageFiles) => {
const promises = imageFiles.map(convertImageToBase64);
const base64Images = await Promise.all(promises);
base64Images.forEach((base64) => {
dispatch(addFile(base64));
});
}}
placeholder={placeholder}
value={text}
maxRows={15}

View File

@@ -1,7 +1,23 @@
import React from "react";
import JupyterEditor from "#/components/Jupyter";
function Jupyter() {
return <JupyterEditor />;
const parentRef = React.useRef<HTMLDivElement>(null);
const [parentWidth, setParentWidth] = React.useState(0);
// This is a hack to prevent the editor from overflowing
// Should be removed after revising the parent and containers
React.useEffect(() => {
if (parentRef.current) {
setParentWidth(parentRef.current.offsetWidth);
}
}, []);
return (
<div ref={parentRef}>
<JupyterEditor maxWidth={parentWidth} />
</div>
);
}
export default Jupyter;

View File

@@ -36,6 +36,7 @@ import ListIcon from "#/assets/list-type-number.svg?react";
import { createChatMessage } from "#/services/chatService";
import {
clearFiles,
clearInitialQuery,
clearSelectedRepository,
setImportedProjectZip,
} from "#/state/initial-query-slice";
@@ -114,7 +115,6 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
};
function App() {
console.log("render app");
const dispatch = useDispatch();
const { files, importedProjectZip } = useSelector(
(state: RootState) => state.initalQuery,
@@ -255,6 +255,7 @@ function App() {
dispatch(clearMessages());
dispatch(clearTerminal());
dispatch(clearJupyter());
dispatch(clearInitialQuery()); // Clear initial query when navigating to /app
startSocketConnection();
});
@@ -292,7 +293,7 @@ function App() {
return (
<div className="flex flex-col h-full gap-3">
<div className="flex h-full overflow-auto gap-3">
<Container className="w-[390px] max-h-full">
<Container className="w-[390px] max-h-full relative">
<ChatInterface />
</Container>

View File

@@ -51,13 +51,13 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
if (!userConsents) {
posthog.opt_out_capturing();
} else {
} else if (userConsents && !posthog.has_opted_in_capturing()) {
posthog.opt_in_capturing();
}
let isAuthed = false;
let githubAuthUrl: string | null = null;
let user: GitHubUser | GitHubErrorReponse | null = null;
try {
isAuthed = await userIsAuthenticated();
if (!isAuthed && window.__GITHUB_CLIENT_ID__) {
@@ -72,7 +72,6 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
githubAuthUrl = null;
}
let user: GitHubUser | GitHubErrorReponse | null = null;
if (ghToken) user = await retrieveGitHubUser(ghToken);
const settings = getSettings();
@@ -84,6 +83,7 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
token = null;
}
// Store the results in cache
return defer({
token,
ghToken,
@@ -167,6 +167,16 @@ export default function MainApp() {
string | null
>(null);
React.useEffect(() => {
if (user && !isGitHubErrorReponse(user)) {
posthog.identify(user.login, {
company: user.company,
name: user.name,
email: user.email,
});
}
}, [user]);
React.useEffect(() => {
// We fetch this here instead of the data loader because the server seems to block
// the retrieval when the session is closing -- preventing the screen from rendering until

View File

@@ -1,8 +1,13 @@
import { json } from "@remix-run/react";
import posthog from "posthog-js";
import { cache } from "#/utils/cache";
export const clientAction = () => {
const ghToken = localStorage.getItem("ghToken");
if (ghToken) localStorage.removeItem("ghToken");
cache.clearAll();
posthog.reset();
return json({ success: true });
};

View File

@@ -1,4 +1,5 @@
import { ClientActionFunctionArgs, json } from "@remix-run/react";
import posthog from "posthog-js";
import {
getDefaultSettings,
LATEST_SETTINGS_VERSION,
@@ -38,6 +39,7 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
saveSettings(getDefaultSettings());
if (requestedToEndSession(formData)) removeSessionTokenAndSelectedRepo();
posthog.capture("settings_reset");
return json({ success: true });
}
@@ -97,5 +99,10 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
}
if (requestedToEndSession(formData)) removeSessionTokenAndSelectedRepo();
posthog.capture("settings_saved", {
LLM_MODEL,
LLM_API_KEY: LLM_API_KEY ? "SET" : "UNSET",
});
return json({ success: true });
};

View File

@@ -63,6 +63,16 @@ export async function request(
} catch (e) {
onFail(`Error fetching ${url}`);
}
if (response?.status === 401) {
await request(
"/api/authenticate",
{
method: "POST",
},
true,
);
return request(url, options, disableToast, returnResponse, maxRetries - 1);
}
if (response?.status && response?.status >= 400) {
onFail(
`${response.status} error while fetching ${url}: ${response?.statusText}`,

View File

@@ -9,10 +9,18 @@ import { addAssistantMessage } from "#/state/chatSlice";
export function handleObservationMessage(message: ObservationMessage) {
switch (message.observation) {
case ObservationType.RUN:
case ObservationType.RUN: {
if (message.extras.hidden) break;
store.dispatch(appendOutput(message.content));
let { content } = message;
if (content.length > 5000) {
const head = content.slice(0, 5000);
content = `${head}\r\n\n... (truncated ${message.content.length - 5000} characters) ...`;
}
store.dispatch(appendOutput(content));
break;
}
case ObservationType.RUN_IPYTHON:
// FIXME: render this as markdown
store.dispatch(appendJupyterOutput(message.content));

View File

@@ -30,6 +30,9 @@ export const selectedFilesSlice = createSlice({
setInitialQuery(state, action: PayloadAction<string>) {
state.initialQuery = action.payload;
},
clearInitialQuery(state) {
state.initialQuery = null;
},
setSelectedRepository(state, action: PayloadAction<string | null>) {
state.selectedRepository = action.payload;
},
@@ -47,6 +50,7 @@ export const {
removeFile,
clearFiles,
setInitialQuery,
clearInitialQuery,
setSelectedRepository,
clearSelectedRepository,
setImportedProjectZip,

View File

@@ -8,6 +8,9 @@ interface GitHubUser {
id: number;
login: string;
avatar_url: string;
company: string | null;
name: string | null;
email: string | null;
}
interface GitHubRepository {

View File

@@ -0,0 +1,61 @@
type CacheKey = string;
type CacheEntry<T> = {
data: T;
expiration: number;
};
class Cache {
private defaultTTL = 5 * 60 * 1000; // 5 minutes
private cacheMemory: Record<string, string> = {};
/**
* Retrieve the cached data from memory
* @param key The key to be retrieved from memory
* @returns The data stored in memory
*/
public get<T>(key: CacheKey): T | null {
const cachedEntry = this.cacheMemory[key];
if (cachedEntry) {
const { data, expiration } = JSON.parse(cachedEntry) as CacheEntry<T>;
if (Date.now() < expiration) return data;
this.delete(key); // Remove expired cache
}
return null;
}
/**
* Store the data in memory with expiration
* @param key The key to be stored in memory
* @param data The data to be stored in memory
* @param ttl The time to live for the data in milliseconds
* @returns void
*/
public set<T>(key: CacheKey, data: T, ttl = this.defaultTTL): void {
const expiration = Date.now() + ttl;
const entry: CacheEntry<T> = { data, expiration };
this.cacheMemory[key] = JSON.stringify(entry);
}
/**
* Remove the data from memory
* @param key The key to be removed from memory
* @returns void
*/
public delete(key: CacheKey): void {
delete this.cacheMemory[key];
}
/**
* Clear all data
* @returns void
*/
public clearAll(): void {
Object.keys(this.cacheMemory).forEach((key) => {
delete this.cacheMemory[key];
});
}
}
export const cache = new Cache();

View File

@@ -1,12 +1,20 @@
import OpenHands from "#/api/open-hands";
import { cache } from "./cache";
export const userIsAuthenticated = async () => {
if (window.__APP_MODE__ === "oss") return true;
const cachedData = cache.get<boolean>("user_is_authenticated");
if (cachedData) return cachedData;
let authenticated = false;
try {
await OpenHands.authenticate();
return true;
authenticated = true;
} catch (error) {
return false;
authenticated = false;
}
cache.set("user_is_authenticated", authenticated, 3 * 60 * 1000); // cache for 3 minutes
return authenticated;
};

View File

@@ -6,12 +6,14 @@ __package_name__ = 'openhands_ai'
def get_version():
try:
from importlib.metadata import PackageNotFoundError, version
return version(__package_name__)
except (ImportError, PackageNotFoundError):
pass
try:
from pkg_resources import DistributionNotFound, get_distribution
return get_distribution(__package_name__).version
except (ImportError, DistributionNotFound):
pass

View File

@@ -29,6 +29,7 @@ SYSTEM_PROMPT = """You are OpenHands agent, a helpful AI assistant that can inte
<IMPORTANT>
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
</IMPORTANT>
"""
@@ -245,7 +246,7 @@ StrReplaceEditorTool = ChatCompletionToolParam(
'type': 'string',
},
'path': {
'description': 'Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.',
'description': 'Absolute path to file or directory, e.g. `/workspace/file.py` or `/workspace`.',
'type': 'string',
},
'file_text': {
@@ -284,6 +285,17 @@ _browser_action_space = HighLevelActionSet(
_BROWSER_DESCRIPTION = """Interact with the browser using Python code.
See the description of "code" parameter for more details.
Multiple actions can be provided at once, but will be executed sequentially without any feedback from the page.
More than 2-3 actions usually leads to failure or unexpected behavior. Example:
fill('a12', 'example with "quotes"')
click('a51')
click('48', button='middle', modifiers=['Shift'])
"""
_BROWSER_TOOL_DESCRIPTION = """
The following 15 functions are available. Nothing else is supported.
goto(url: str)
@@ -385,20 +397,15 @@ upload_file(bid: str, file: str | list[str])
upload_file('572', '/home/user/my_receipt.pdf')
upload_file('63', ['/home/bob/Documents/image.jpg', '/home/bob/Documents/file.zip'])
Multiple actions can be provided at once, but will be executed sequentially without any feedback from the page.
More than 2-3 actions usually leads to failure or unexpected behavior. Example:
fill('a12', 'example with "quotes"')
click('a51')
click('48', button='middle', modifiers=['Shift'])
"""
for _, action in _browser_action_space.action_set.items():
assert (
action.signature in _BROWSER_DESCRIPTION
action.signature in _BROWSER_TOOL_DESCRIPTION
), f'Browser description mismatch. Please double check if the BrowserGym updated their action space.\n\nAction: {action.signature}'
assert (
action.description in _BROWSER_DESCRIPTION
action.description in _BROWSER_TOOL_DESCRIPTION
), f'Browser description mismatch. Please double check if the BrowserGym updated their action space.\n\nAction: {action.description}'
BrowserTool = ChatCompletionToolParam(
@@ -411,7 +418,10 @@ BrowserTool = ChatCompletionToolParam(
'properties': {
'code': {
'type': 'string',
'description': 'The Python code that interacts with the browser.',
'description': (
'The Python code that interacts with the browser.\n'
+ _BROWSER_TOOL_DESCRIPTION
),
}
},
'required': ['code'],

View File

@@ -163,6 +163,9 @@ IMPORTANT: Execute code using <execute_ipython>, <execute_bash>, or <execute_bro
The assistant should utilize full file paths and the `pwd` command to prevent path-related errors.
The assistant MUST NOT apologize to the user or thank the user after running commands or editing files. It should only address the user in response to an explicit message from the user, or to ask for more information.
The assistant MUST NOT push any changes to GitHub unless explicitly requested to do so.
The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior, or
to describe precisely how to apply proposed edits. Comments about applying edits should always have blank lines above
and below.
{% endset %}
{# Combine all parts without newlines between them #}

View File

@@ -69,6 +69,7 @@ class AppConfig:
file_uploads_max_file_size_mb: int = 0
file_uploads_restrict_file_types: bool = False
file_uploads_allowed_extensions: list[str] = field(default_factory=lambda: ['.*'])
runloop_api_key: str | None = None
defaults_dict: ClassVar[dict] = {}
@@ -139,6 +140,7 @@ class AppConfig:
'jwt_secret',
'modal_api_token_id',
'modal_api_token_secret',
'runloop_api_key',
]:
attr_value = '******' if attr_value else None

View File

@@ -123,6 +123,7 @@ async def run_controller(
if runtime is None:
runtime = create_runtime(config, sid=sid)
await runtime.connect()
event_stream = runtime.event_stream
@@ -188,8 +189,6 @@ async def run_controller(
event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, sid)
await runtime.connect()
end_states = [
AgentState.FINISHED,
AgentState.REJECTED,
@@ -213,7 +212,11 @@ async def run_controller(
# save trajectories if applicable
if config.trajectories_path is not None:
file_path = os.path.join(config.trajectories_path, sid + '.json')
# if trajectories_path is a folder, use session id as file name
if os.path.isdir(config.trajectories_path):
file_path = os.path.join(config.trajectories_path, sid + '.json')
else:
file_path = config.trajectories_path
os.makedirs(os.path.dirname(file_path), exist_ok=True)
histories = [event_to_trajectory(event) for event in state.history]
with open(file_path, 'w') as f:

View File

@@ -342,9 +342,14 @@ class LLM(RetryMixin, DebugMixin):
# but model_info will have the correct value for some reason.
# we can go with it, but we will need to keep an eye if model_info is correct for Vertex or other providers
# remove when litellm is updated to fix https://github.com/BerriAI/litellm/issues/5608
return litellm.supports_vision(self.config.model) or (
self.model_info is not None
and self.model_info.get('supports_vision', False)
# Check both the full model name and the name after proxy prefix for vision support
return (
litellm.supports_vision(self.config.model)
or litellm.supports_vision(self.config.model.split('/')[-1])
or (
self.model_info is not None
and self.model_info.get('supports_vision', False)
)
)
def is_caching_prompt_active(self) -> bool:

View File

@@ -23,6 +23,10 @@ def get_runtime_cls(name: str):
from openhands.runtime.impl.modal.modal_runtime import ModalRuntime
return ModalRuntime
elif name == 'runloop':
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
return RunloopRuntime
else:
raise ValueError(f'Runtime {name} not supported')

View File

@@ -3,6 +3,7 @@ import copy
import json
import os
from abc import abstractmethod
from pathlib import Path
from typing import Callable
from requests.exceptions import ConnectionError
@@ -274,6 +275,6 @@ class Runtime(FileEditRuntimeMixin):
raise NotImplementedError('This method is not implemented in the base class.')
@abstractmethod
def copy_from(self, path: str) -> bytes:
"""Zip all files in the sandbox and return as a stream of bytes."""
def copy_from(self, path: str) -> Path:
"""Zip all files in the sandbox and return a path in the local filesystem."""
raise NotImplementedError('This method is not implemented in the base class.')

View File

@@ -1,4 +1,5 @@
import os
from pathlib import Path
import tempfile
import threading
from functools import lru_cache
@@ -604,7 +605,7 @@ class EventStreamRuntime(Runtime):
except requests.Timeout:
raise TimeoutError('List files operation timed out')
def copy_from(self, path: str) -> bytes:
def copy_from(self, path: str) -> Path:
"""Zip all files in the sandbox and return as a stream of bytes."""
self._refresh_logs()
try:
@@ -617,8 +618,11 @@ class EventStreamRuntime(Runtime):
stream=True,
timeout=30,
)
data = response.content
return data
temp_file = tempfile.NamedTemporaryFile(delete=False)
for chunk in response.iter_content(chunk_size=8192):
if chunk: # filter out keep-alive new chunks
temp_file.write(chunk)
return Path(temp_file.name)
except requests.Timeout:
raise TimeoutError('Copy operation timed out')

View File

@@ -1,4 +1,5 @@
import os
from pathlib import Path
import tempfile
import threading
from typing import Callable, Optional
@@ -90,9 +91,8 @@ class RemoteRuntime(Runtime):
self.runtime_url: str | None = None
async def connect(self):
await call_sync_from_async(self._start_or_attach_to_runtime)
try:
await call_sync_from_async(self._wait_until_alive)
await call_sync_from_async(self._start_or_attach_to_runtime)
except RuntimeNotReadyError:
self.log('error', 'Runtime failed to start, timed out before ready')
raise
@@ -277,6 +277,14 @@ class RemoteRuntime(Runtime):
assert runtime_data['runtime_id'] == self.runtime_id
assert 'pod_status' in runtime_data
pod_status = runtime_data['pod_status']
# FIXME: We should fix it at the backend of /start endpoint, make sure
# the pod is created before returning the response.
# Retry a period of time to give the cluster time to start the pod
if pod_status == 'Not Found':
raise RuntimeNotReadyError(
f'Runtime (ID={self.runtime_id}) is not yet ready. Status: {pod_status}'
)
if pod_status == 'Ready':
try:
self._send_request(
@@ -291,7 +299,7 @@ class RemoteRuntime(Runtime):
f'Runtime /alive failed to respond with 200: {e}'
)
return
if pod_status in ('Failed', 'Unknown', 'Not Found'):
if pod_status in ('Failed', 'Unknown'):
# clean up the runtime
self.close()
raise RuntimeError(
@@ -460,13 +468,18 @@ class RemoteRuntime(Runtime):
assert isinstance(response_json, list)
return response_json
def copy_from(self, path: str) -> bytes:
def copy_from(self, path: str) -> Path:
"""Zip all files in the sandbox and return as a stream of bytes."""
params = {'path': path}
response = self._send_request(
'GET',
f'{self.runtime_url}/download_files',
params=params,
stream=True,
timeout=30,
)
return response.content
temp_file = tempfile.NamedTemporaryFile(delete=False)
for chunk in response.iter_content(chunk_size=8192):
if chunk: # filter out keep-alive new chunks
temp_file.write(chunk)
return Path(temp_file.name)

View File

@@ -0,0 +1,31 @@
# Runloop Runtime
Runloop provides a fast, secure and scalable AI sandbox (Devbox).
Check out the [runloop docs](https://docs.runloop.ai/overview/what-is-runloop)
for more detail
## Access
Runloop is currently available in a closed beta. For early access, or
just to say hello, sign up at https://www.runloop.ai/hello
## Set up
With your runloop API,
```bash
export RUNLOOP_API_KEY=<your-api-key>
```
Configure the runtime
```bash
export RUNTIME="runloop"
```
## Interact with your devbox
Runloop provides additional tools to interact with your Devbox based
runtime environment. See the [docs](https://docs.runloop.ai/tools) for an up
to date list of tools.
### Dashboard
View logs, ssh into, or view your Devbox status from the [dashboard](https://platform.runloop.ai)
### CLI
Use the Runloop CLI to view logs, execute commands, and more.
See the setup instructions [here](https://docs.runloop.ai/tools/cli)

View File

@@ -0,0 +1,272 @@
import logging
import threading
import time
from typing import Callable
import requests
import tenacity
from runloop_api_client import Runloop
from runloop_api_client.types import DevboxView
from runloop_api_client.types.shared_params import LaunchParameters
from openhands.core.config import AppConfig
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventStream
from openhands.runtime.impl.eventstream.eventstream_runtime import (
EventStreamRuntime,
LogBuffer,
)
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils.command import get_remote_startup_command
from openhands.runtime.utils.request import send_request
from openhands.utils.tenacity_stop import stop_if_should_exit
class RunloopLogBuffer(LogBuffer):
"""Synchronous buffer for Runloop devbox logs.
This class provides a thread-safe way to collect, store, and retrieve logs
from a Docker container. It uses a list to store log lines and provides methods
for appending, retrieving, and clearing logs.
"""
def __init__(self, runloop_api_client: Runloop, devbox_id: str):
self.client_ready = False
self.init_msg = 'Runtime client initialized.'
self.buffer: list[str] = []
self.lock = threading.Lock()
self._stop_event = threading.Event()
self.runloop_api_client = runloop_api_client
self.devbox_id = devbox_id
self.log_index = 0
self.log_stream_thread = threading.Thread(target=self.stream_logs)
self.log_stream_thread.daemon = True
self.log_stream_thread.start()
def stream_logs(self):
"""Stream logs from the Docker container in a separate thread.
This method runs in its own thread to handle the blocking
operation of reading log lines from the Docker SDK's synchronous generator.
"""
try:
# TODO(Runloop) Replace with stream
while True:
raw_logs = self.runloop_api_client.devboxes.logs.list(
self.devbox_id
).logs[self.log_index :]
logs = [
log.message
for log in raw_logs
if log.message and log.cmd_id is None
]
self.log_index += len(raw_logs)
if self._stop_event.is_set():
break
if logs:
for log_line in logs:
self.append(log_line)
if self.init_msg in log_line:
self.client_ready = True
time.sleep(1)
except Exception as e:
logger.error(f'Error streaming runloop logs: {e}')
# NB: Match LogBuffer behavior on below methods
def get_and_clear(self) -> list[str]:
with self.lock:
logs = list(self.buffer)
self.buffer.clear()
return logs
def append(self, log_line: str):
with self.lock:
self.buffer.append(log_line)
def close(self, timeout: float = 5.0):
self._stop_event.set()
self.log_stream_thread.join(timeout)
class RunloopRuntime(EventStreamRuntime):
"""The RunloopRuntime class is an EventStreamRuntime that utilizes Runloop Devbox as a runtime environment."""
_sandbox_port: int = 4444
def __init__(
self,
config: AppConfig,
event_stream: EventStream,
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable | None = None,
attach_to_existing: bool = False,
):
assert config.runloop_api_key is not None, 'Runloop API key is required'
self.devbox: DevboxView | None = None
self.config = config
self.runloop_api_client = Runloop(
bearer_token=config.runloop_api_key,
)
self.session = requests.Session()
self.container_name = self.container_name_prefix + sid
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
self.init_base_runtime(
config,
event_stream,
sid,
plugins,
env_vars,
status_callback,
attach_to_existing,
)
# Buffer for container logs
self.log_buffer: LogBuffer | None = None
@tenacity.retry(
stop=tenacity.stop_after_attempt(120),
wait=tenacity.wait_fixed(1),
)
def _wait_for_devbox(self, devbox: DevboxView) -> DevboxView:
"""Pull devbox status until it is running"""
if devbox == 'running':
return devbox
devbox = self.runloop_api_client.devboxes.retrieve(id=devbox.id)
if devbox.status != 'running':
raise ConnectionRefusedError('Devbox is not running')
# Devbox is connected and running
logging.debug(f'devbox.id={devbox.id} is running')
return devbox
def _create_new_devbox(self) -> DevboxView:
# Note: Runloop connect
sandbox_workspace_dir = self.config.workspace_mount_path_in_sandbox
plugin_args = []
if self.plugins is not None and len(self.plugins) > 0:
plugin_args.append('--plugins')
plugin_args.extend([plugin.name for plugin in self.plugins])
browsergym_args = []
if self.config.sandbox.browsergym_eval_env is not None:
browsergym_args = [
'-browsergym-eval-env',
self.config.sandbox.browsergym_eval_env,
]
# Copied from EventstreamRuntime
start_command = get_remote_startup_command(
self._sandbox_port,
sandbox_workspace_dir,
'openhands' if self.config.run_as_openhands else 'root',
self.config.sandbox.user_id,
plugin_args,
browsergym_args,
)
# Add some additional commands based on our image
# NB: start off as root, action_execution_server will ultimately choose user but expects all context
# (ie browser) to be installed as root
start_command = (
'export MAMBA_ROOT_PREFIX=/openhands/micromamba && '
'cd /openhands/code && '
+ '/openhands/micromamba/bin/micromamba run -n openhands poetry config virtualenvs.path /openhands/poetry && '
+ ' '.join(start_command)
)
entrypoint = f"sudo bash -c '{start_command}'"
devbox = self.runloop_api_client.devboxes.create(
entrypoint=entrypoint,
setup_commands=[f'mkdir -p {self.config.workspace_mount_path_in_sandbox}'],
name=self.sid,
environment_variables={'DEBUG': 'true'} if self.config.debug else {},
prebuilt='openhands',
launch_parameters=LaunchParameters(
available_ports=[self._sandbox_port],
resource_size_request="LARGE",
),
metadata={'container-name': self.container_name},
)
return self._wait_for_devbox(devbox)
async def connect(self):
self.send_status_message('STATUS$STARTING_RUNTIME')
if self.attach_to_existing:
active_devboxes = self.runloop_api_client.devboxes.list(
status='running'
).devboxes
self.devbox = next(
(devbox for devbox in active_devboxes if devbox.name == self.sid), None
)
if self.devbox is None:
self.devbox = self._create_new_devbox()
# Create tunnel - this will return a stable url, so is safe to call if we are attaching to existing
tunnel = self.runloop_api_client.devboxes.create_tunnel(
id=self.devbox.id,
port=self._sandbox_port,
)
# Hook up logs
self.log_buffer = RunloopLogBuffer(self.runloop_api_client, self.devbox.id)
self.api_url = f'https://{tunnel.url}'
logger.info(f'Container started. Server url: {self.api_url}')
# End Runloop connect
# NOTE: Copied from EventStreamRuntime
logger.info('Waiting for client to become ready...')
self.send_status_message('STATUS$WAITING_FOR_CLIENT')
self._wait_until_alive()
if not self.attach_to_existing:
self.setup_initial_env()
logger.info(
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}'
)
self.send_status_message(' ')
@tenacity.retry(
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
wait=tenacity.wait_fixed(1),
reraise=(ConnectionRefusedError,),
)
def _wait_until_alive(self):
# NB(Runloop): Remote logs are not guaranteed realtime, removing client_ready check from logs
self._refresh_logs()
if not self.log_buffer:
raise RuntimeError('Runtime client is not ready.')
response = send_request(
self.session,
'GET',
f'{self.api_url}/alive',
timeout=5,
)
if response.status_code == 200:
return
else:
msg = f'Action execution API is not alive. Response: {response}'
logger.error(msg)
raise RuntimeError(msg)
def close(self, rm_all_containers: bool = True):
if self.log_buffer:
self.log_buffer.close()
if self.session:
self.session.close()
if self.attach_to_existing:
return
if self.devbox:
self.runloop_api_client.devboxes.shutdown(self.devbox.id)

View File

@@ -0,0 +1,49 @@
from fastapi import APIRouter, Request, HTTPException, status
from fastapi.responses import JSONResponse
from openhands.runtime.base import Runtime
from openhands.utils.async_utils import call_sync_from_async
import os
router = APIRouter()
FILES_TO_IGNORE = [
'.git/',
'.DS_Store',
'node_modules/',
'__pycache__/',
]
@router.get('/list-files')
async def list_files(request: Request, path: str | None = None):
"""List files in the specified path.
This function retrieves a list of files from the agent's runtime file store,
excluding certain system and hidden files/directories.
To list files:
```sh
curl http://localhost:3000/api/list-files
```
Args:
request (Request): The incoming request object.
path (str, optional): The path to list files from. Defaults to None.
Returns:
list: A list of file names in the specified path.
Raises:
HTTPException: If there's an error listing the files.
"""
if not request.state.conversation.runtime:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': 'Runtime not yet initialized'},
)
runtime: Runtime = request.state.conversation.runtime
file_list = await call_sync_from_async(runtime.list_files, path)
if path:
file_list = [os.path.join(path, f) for f in file_list]
return [f for f in file_list if not any(ignored in f for ignored in FILES_TO_IGNORE)]

View File

@@ -0,0 +1,56 @@
from fastapi import APIRouter
from openhands.llm import bedrock
from openhands.core.config import LLMConfig
from openhands.controller.agent import Agent
from openhands.security.options import SecurityAnalyzers
from openhands.core.logger import openhands_logger as logger
import requests
router = APIRouter()
@router.get('/models')
async def get_litellm_models(config) -> list[str]:
litellm_model_list = litellm.model_list + list(litellm.model_cost.keys())
litellm_model_list_without_bedrock = bedrock.remove_error_modelId(
litellm_model_list
)
llm_config: LLMConfig = config.get_llm_config()
bedrock_model_list = []
if (
llm_config.aws_region_name
and llm_config.aws_access_key_id
and llm_config.aws_secret_access_key
):
bedrock_model_list = bedrock.list_foundation_models(
llm_config.aws_region_name,
llm_config.aws_access_key_id,
llm_config.aws_secret_access_key,
)
model_list = litellm_model_list_without_bedrock + bedrock_model_list
for llm_config in config.llms.values():
ollama_base_url = llm_config.ollama_base_url
if llm_config.model.startswith('ollama'):
if not ollama_base_url:
ollama_base_url = llm_config.base_url
if ollama_base_url:
ollama_url = ollama_base_url.strip('/') + '/api/tags'
try:
ollama_models_list = requests.get(ollama_url, timeout=3).json()[
'models'
]
for model in ollama_models_list:
model_list.append('ollama/' + model['name'])
break
except requests.exceptions.RequestException as e:
logger.error(f'Error getting OLLAMA models: {e}', exc_info=True)
return list(sorted(set(model_list)))
@router.get('/agents')
async def get_agents():
agents = sorted(Agent.list_agents())
return agents
@router.get('/security-analyzers')
async def get_security_analyzers():
return sorted(SecurityAnalyzers.keys())

View File

@@ -0,0 +1,16 @@
import os
import warnings
from dotenv import load_dotenv
from openhands.core.config import load_app_config
from openhands.storage import get_file_store
from openhands.server.session import SessionManager
load_dotenv()
config = load_app_config()
file_store = get_file_store(config.file_store, config.file_store_path)
session_manager = SessionManager(config, file_store)
with warnings.catch_warnings():
warnings.simplefilter('ignore')
import litellm

View File

@@ -1,6 +1,7 @@
import os
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential
from openhands.core.logger import openhands_logger as logger
from openhands.server.sheets_client import GoogleSheetsClient
@@ -101,6 +102,7 @@ async def authenticate_github_user(auth_token) -> bool:
return True
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=5))
async def get_github_user(token: str) -> str:
"""Get GitHub user info from token.
@@ -108,19 +110,25 @@ async def get_github_user(token: str) -> str:
token: GitHub access token
Returns:
Tuple of (login, error_message)
If successful, error_message is None
If failed, login is None and error_message contains the error
github handle of the user
"""
logger.info('Fetching GitHub user info from token')
headers = {
'Accept': 'application/vnd.github+json',
'Authorization': f'Bearer {token}',
'X-GitHub-Api-Version': '2022-11-28',
}
async with httpx.AsyncClient() as client:
logger.debug('Making request to GitHub API')
response = await client.get('https://api.github.com/user', headers=headers)
async with httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=5.0, write=5.0, pool=5.0)
) as client:
try:
response = await client.get('https://api.github.com/user', headers=headers)
except httpx.RequestError as e:
logger.error(f'Error making request to GitHub API: {str(e)}')
logger.error(e)
raise
logger.info('Received response from GitHub API')
logger.debug(f'Response status code: {response.status_code}')
response.raise_for_status()
user_data = response.json()
login = user_data.get('login')

View File

@@ -1,460 +1,26 @@
import asyncio
import io
import os
import re
import tempfile
import uuid
import warnings
from contextlib import asynccontextmanager
import requests
from pathspec import PathSpec
from pathspec.patterns import GitWildMatchPattern
from openhands.security.options import SecurityAnalyzers
from openhands.server.data_models.feedback import FeedbackDataModel, store_feedback
from openhands.server.github import (
GITHUB_CLIENT_ID,
GITHUB_CLIENT_SECRET,
authenticate_github_user,
)
from openhands.storage import get_file_store
from openhands.utils.async_utils import call_sync_from_async
with warnings.catch_warnings():
warnings.simplefilter('ignore')
import litellm
from dotenv import load_dotenv
from fastapi import (
FastAPI,
HTTPException,
Request,
UploadFile,
WebSocket,
status,
)
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi import FastAPI
from fastapi.security import HTTPBearer
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from openhands.server.config import config, session_manager
from openhands.server.middleware import LocalhostCORSMiddleware, NoCacheMiddleware, attach_session
from openhands.server.websocket import websocket_endpoint
from openhands.server.api_options import router as api_options_router
from openhands.server.api_files import router as api_files_router
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
from openhands.controller.agent import Agent
from openhands.core.config import LLMConfig, load_app_config
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import (
ChangeAgentStateAction,
FileReadAction,
FileWriteAction,
NullAction,
)
from openhands.events.observation import (
AgentStateChangedObservation,
ErrorObservation,
FileReadObservation,
FileWriteObservation,
NullObservation,
)
from openhands.events.serialization import event_to_dict
from openhands.events.stream import AsyncEventStreamWrapper
from openhands.llm import bedrock
from openhands.runtime.base import Runtime
from openhands.server.auth import get_sid_from_token, sign_token
from openhands.server.middleware import LocalhostCORSMiddleware, NoCacheMiddleware
from openhands.server.session import SessionManager
load_dotenv()
config = load_app_config()
file_store = get_file_store(config.file_store, config.file_store_path)
session_manager = SessionManager(config, file_store)
@asynccontextmanager
async def lifespan(app: FastAPI):
global session_manager
async with session_manager:
yield
app = FastAPI(lifespan=lifespan)
app = FastAPI()
app.add_middleware(
LocalhostCORSMiddleware,
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
app.add_middleware(NoCacheMiddleware)
app.middleware('http')(attach_session)
app.websocket('/ws')(websocket_endpoint)
app.include_router(api_options_router, prefix='/api/options')
app.include_router(api_files_router, prefix='/api')
security_scheme = HTTPBearer()
def load_file_upload_config() -> tuple[int, bool, list[str]]:
"""Load file upload configuration from the config object.
This function retrieves the file upload settings from the global config object.
It handles the following settings:
- Maximum file size for uploads
- Whether to restrict file types
- List of allowed file extensions
It also performs sanity checks on the values to ensure they are valid and safe.
Returns:
tuple: A tuple containing:
- max_file_size_mb (int): Maximum file size in MB. 0 means no limit.
- restrict_file_types (bool): Whether file type restrictions are enabled.
- allowed_extensions (set): Set of allowed file extensions.
"""
# Retrieve values from config
max_file_size_mb = config.file_uploads_max_file_size_mb
restrict_file_types = config.file_uploads_restrict_file_types
allowed_extensions = config.file_uploads_allowed_extensions
# Sanity check for max_file_size_mb
if not isinstance(max_file_size_mb, int) or max_file_size_mb < 0:
logger.warning(
f'Invalid max_file_size_mb: {max_file_size_mb}. Setting to 0 (no limit).'
)
max_file_size_mb = 0
# Sanity check for allowed_extensions
if not isinstance(allowed_extensions, (list, set)) or not allowed_extensions:
logger.warning(
f'Invalid allowed_extensions: {allowed_extensions}. Setting to [".*"].'
)
allowed_extensions = ['.*']
else:
# Ensure all extensions start with a dot and are lowercase
allowed_extensions = [
ext.lower() if ext.startswith('.') else f'.{ext.lower()}'
for ext in allowed_extensions
]
# If restrictions are disabled, allow all
if not restrict_file_types:
allowed_extensions = ['.*']
logger.debug(
f'File upload config: max_size={max_file_size_mb}MB, '
f'restrict_types={restrict_file_types}, '
f'allowed_extensions={allowed_extensions}'
)
return max_file_size_mb, restrict_file_types, allowed_extensions
# Load configuration
MAX_FILE_SIZE_MB, RESTRICT_FILE_TYPES, ALLOWED_EXTENSIONS = load_file_upload_config()
def is_extension_allowed(filename):
"""Check if the file extension is allowed based on the current configuration.
This function supports wildcards and files without extensions.
The check is case-insensitive for extensions.
Args:
filename (str): The name of the file to check.
Returns:
bool: True if the file extension is allowed, False otherwise.
"""
if not RESTRICT_FILE_TYPES:
return True
file_ext = os.path.splitext(filename)[1].lower() # Convert to lowercase
return (
'.*' in ALLOWED_EXTENSIONS
or file_ext in (ext.lower() for ext in ALLOWED_EXTENSIONS)
or (file_ext == '' and '.' in ALLOWED_EXTENSIONS)
)
@app.middleware('http')
async def attach_session(request: Request, call_next):
"""Middleware to attach session information to the request.
This middleware checks for the Authorization header, validates the token,
and attaches the corresponding session to the request state.
Args:
request (Request): The incoming request object.
call_next (Callable): The next middleware or route handler in the chain.
Returns:
Response: The response from the next middleware or route handler.
"""
non_authed_paths = [
'/api/options/',
'/api/github/callback',
'/api/authenticate',
]
if any(
request.url.path.startswith(path) for path in non_authed_paths
) or not request.url.path.startswith('/api/'):
response = await call_next(request)
return response
# Bypass authentication for OPTIONS requests (preflight)
if request.method == 'OPTIONS':
response = await call_next(request)
return response
github_token = request.headers.get('X-GitHub-Token')
if not await authenticate_github_user(github_token):
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Not authenticated'},
)
if not request.headers.get('Authorization'):
logger.warning('Missing Authorization header')
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Missing Authorization header'},
)
auth_token = request.headers.get('Authorization')
if 'Bearer' in auth_token:
auth_token = auth_token.split('Bearer')[1].strip()
request.state.sid = get_sid_from_token(auth_token, config.jwt_secret)
if request.state.sid == '':
logger.warning('Invalid token')
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Invalid token'},
)
request.state.conversation = await session_manager.attach_to_conversation(
request.state.sid
)
if request.state.conversation is None:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': 'Session not found'},
)
try:
response = await call_next(request)
finally:
await session_manager.detach_from_conversation(request.state.conversation)
return response
@app.websocket('/ws')
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for receiving events from the client (i.e., the browser).
Once connected, the client can send various actions:
- Initialize the agent:
session management, and event streaming.
```json
{"action": "initialize", "args": {"LLM_MODEL": "ollama/llama3", "AGENT": "CodeActAgent", "LANGUAGE": "en", "LLM_API_KEY": "ollama"}}
Args:
```
websocket (WebSocket): The WebSocket connection object.
- Start a new development task:
```json
{"action": "start", "args": {"task": "write a bash script that prints hello"}}
```
- Send a message:
```json
{"action": "message", "args": {"content": "Hello, how are you?", "images_urls": ["base64_url1", "base64_url2"]}}
```
- Write contents to a file:
```json
{"action": "write", "args": {"path": "./greetings.txt", "content": "Hello, OpenHands?"}}
```
- Read the contents of a file:
```json
{"action": "read", "args": {"path": "./greetings.txt"}}
```
- Run a command:
```json
{"action": "run", "args": {"command": "ls -l", "thought": "", "confirmation_state": "confirmed"}}
```
- Run an IPython command:
```json
{"action": "run_ipython", "args": {"command": "print('Hello, IPython!')"}}
```
- Open a web page:
```json
{"action": "browse", "args": {"url": "https://arxiv.org/html/2402.01030v2"}}
```
- Add a task to the root_task:
```json
{"action": "add_task", "args": {"task": "Implement feature X"}}
```
- Update a task in the root_task:
```json
{"action": "modify_task", "args": {"id": "0", "state": "in_progress", "thought": ""}}
```
- Change the agent's state:
```json
{"action": "change_agent_state", "args": {"state": "paused"}}
```
- Finish the task:
```json
{"action": "finish", "args": {}}
```
"""
# Get protocols from Sec-WebSocket-Protocol header
protocols = websocket.headers.get('sec-websocket-protocol', '').split(', ')
# The first protocol should be our real protocol (e.g. 'openhands')
# The second protocol should contain our auth token
if len(protocols) < 3:
logger.error('Expected 3 websocket protocols, got %d', len(protocols))
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
real_protocol = protocols[0]
jwt_token = protocols[1] if protocols[1] != 'NO_JWT' else ''
github_token = protocols[2] if protocols[2] != 'NO_GITHUB' else ''
if not await authenticate_github_user(github_token):
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
await asyncio.wait_for(websocket.accept(subprotocol=real_protocol), 10)
if jwt_token:
sid = get_sid_from_token(jwt_token, config.jwt_secret)
if sid == '':
await websocket.send_json({'error': 'Invalid token', 'error_code': 401})
await websocket.close()
return
else:
sid = str(uuid.uuid4())
jwt_token = sign_token({'sid': sid}, config.jwt_secret)
logger.info(f'New session: {sid}')
session = session_manager.add_or_restart_session(sid, websocket)
await websocket.send_json({'token': jwt_token, 'status': 'ok'})
latest_event_id = -1
if websocket.query_params.get('latest_event_id'):
latest_event_id = int(websocket.query_params.get('latest_event_id'))
async_stream = AsyncEventStreamWrapper(
session.agent_session.event_stream, latest_event_id + 1
)
async for event in async_stream:
if isinstance(
event,
(
NullAction,
NullObservation,
ChangeAgentStateAction,
AgentStateChangedObservation,
),
):
continue
await websocket.send_json(event_to_dict(event))
await session.loop_recv()
@app.get('/api/options/models')
async def get_litellm_models() -> list[str]:
"""
Get all models supported by LiteLLM.
This function combines models from litellm and Bedrock, removing any
error-prone Bedrock models.
To get the models:
```sh
curl http://localhost:3000/api/litellm-models
```
Returns:
list: A sorted list of unique model names.
"""
litellm_model_list = litellm.model_list + list(litellm.model_cost.keys())
litellm_model_list_without_bedrock = bedrock.remove_error_modelId(
litellm_model_list
)
# TODO: for bedrock, this is using the default config
llm_config: LLMConfig = config.get_llm_config()
bedrock_model_list = []
if (
llm_config.aws_region_name
and llm_config.aws_access_key_id
and llm_config.aws_secret_access_key
):
bedrock_model_list = bedrock.list_foundation_models(
llm_config.aws_region_name,
llm_config.aws_access_key_id,
llm_config.aws_secret_access_key,
)
model_list = litellm_model_list_without_bedrock + bedrock_model_list
for llm_config in config.llms.values():
ollama_base_url = llm_config.ollama_base_url
if llm_config.model.startswith('ollama'):
if not ollama_base_url:
ollama_base_url = llm_config.base_url
if ollama_base_url:
ollama_url = ollama_base_url.strip('/') + '/api/tags'
try:
ollama_models_list = requests.get(ollama_url, timeout=3).json()[
'models'
]
for model in ollama_models_list:
model_list.append('ollama/' + model['name'])
break
except requests.exceptions.RequestException as e:
logger.error(f'Error getting OLLAMA models: {e}', exc_info=True)
return list(sorted(set(model_list)))
@app.get('/api/options/agents')
async def get_agents():
"""Get all agents supported by LiteLLM.
To get the agents:
```sh
curl http://localhost:3000/api/agents
```
Returns:
list: A sorted list of agent names.
"""
agents = sorted(Agent.list_agents())
return agents
@app.get('/api/options/security-analyzers')
async def get_security_analyzers():
"""Get all supported security analyzers.
To get the security analyzers:
```sh
curl http://localhost:3000/api/security-analyzers
```
Returns:
list: A sorted list of security analyzer names.
"""
return sorted(SecurityAnalyzers.keys())
FILES_TO_IGNORE = [
'.git/',
'.DS_Store',
'node_modules/',
'__pycache__/',
]
@app.get('/api/list-files')
async def list_files(request: Request, path: str | None = None):
"""List files in the specified path.
@@ -790,20 +356,21 @@ async def security_api(request: Request):
@app.get('/api/zip-directory')
async def zip_current_workspace(request: Request):
async def zip_current_workspace(request: Request, background_tasks: BackgroundTasks):
try:
logger.debug('Zipping workspace')
runtime: Runtime = request.state.conversation.runtime
path = runtime.config.workspace_mount_path_in_sandbox
zip_file_bytes = await call_sync_from_async(runtime.copy_from, path)
zip_stream = io.BytesIO(zip_file_bytes) # Wrap to behave like a file stream
response = StreamingResponse(
zip_stream,
zip_file = await call_sync_from_async(runtime.copy_from, path)
response = FileResponse(
path=zip_file,
filename='workspace.zip',
media_type='application/x-zip-compressed',
headers={'Content-Disposition': 'attachment; filename=workspace.zip'},
)
# This will execute after the response is sent (So the file is not deleted before being sent)
background_tasks.add_task(zip_file.unlink)
return response
except Exception as e:
logger.error(f'Error zipping workspace: {e}', exc_info=True)
@@ -863,10 +430,26 @@ async def authenticate(request: Request):
content={'error': 'Not authorized via GitHub waitlist'},
)
# Create a signed JWT token with 1-hour expiration
cookie_data = {
'github_token': token,
'exp': int(time.time()) + 3600, # 1 hour expiration
}
signed_token = sign_token(cookie_data, config.jwt_secret)
response = JSONResponse(
status_code=status.HTTP_200_OK, content={'message': 'User authenticated'}
)
# Set secure cookie with signed token
response.set_cookie(
key='github_auth',
value=signed_token,
max_age=3600, # 1 hour in seconds
httponly=True,
secure=True,
samesite='strict',
)
return response

View File

@@ -138,6 +138,10 @@ class Session:
return
if event.source == EventSource.AGENT:
await self.send(event_to_dict(event))
elif event.source == EventSource.USER and isinstance(
event, CmdOutputObservation
):
await self.send(event_to_dict(event))
# NOTE: ipython observations are not sent here currently
elif event.source == EventSource.ENVIRONMENT and isinstance(
event, (CmdOutputObservation, AgentStateChangedObservation)

View File

@@ -0,0 +1,70 @@
import asyncio
import uuid
from fastapi import WebSocket, status
from openhands.server.auth.auth import get_sid_from_token, sign_token
from openhands.server.github import authenticate_github_user
from openhands.events.stream import AsyncEventStreamWrapper
from openhands.events.serialization import event_to_dict
from openhands.events.action import (
ChangeAgentStateAction,
NullAction,
)
from openhands.events.observation import (
AgentStateChangedObservation,
NullObservation,
)
from openhands.core.logger import openhands_logger as logger
async def websocket_endpoint(websocket: WebSocket, session_manager, config):
protocols = websocket.headers.get('sec-websocket-protocol', '').split(', ')
if len(protocols) < 3:
logger.error('Expected 3 websocket protocols, got %d', len(protocols))
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
real_protocol = protocols[0]
jwt_token = protocols[1] if protocols[1] != 'NO_JWT' else ''
github_token = protocols[2] if protocols[2] != 'NO_GITHUB' else ''
if not await authenticate_github_user(github_token):
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
await asyncio.wait_for(websocket.accept(subprotocol=real_protocol), 10)
if jwt_token:
sid = get_sid_from_token(jwt_token, config.jwt_secret)
if sid == '':
await websocket.send_json({'error': 'Invalid token', 'error_code': 401})
await websocket.close()
return
else:
sid = str(uuid.uuid4())
jwt_token = sign_token({'sid': sid}, config.jwt_secret)
logger.info(f'New session: {sid}')
session = session_manager.add_or_restart_session(sid, websocket)
await websocket.send_json({'token': jwt_token, 'status': 'ok'})
latest_event_id = -1
if websocket.query_params.get('latest_event_id'):
latest_event_id = int(websocket.query_params.get('latest_event_id'))
async_stream = AsyncEventStreamWrapper(
session.agent_session.event_stream, latest_event_id + 1
)
async for event in async_stream:
if isinstance(
event,
(
NullAction,
NullObservation,
ChangeAgentStateAction,
AgentStateChangedObservation,
),
):
continue
await websocket.send_json(event_to_dict(event))
await session.loop_recv()

2959
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "openhands-ai"
version = "0.12.3"
version = "0.13.0"
description = "OpenHands: Code Less, Make More"
authors = ["OpenHands"]
license = "MIT"
@@ -61,6 +61,7 @@ protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
opentelemetry-api = "1.25.0"
opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
modal = "^0.64.145"
runloop-api-client = "0.7.0"
[tool.poetry.group.llama-index.dependencies]
llama-index = "*"

View File

@@ -14,6 +14,7 @@ from openhands.events import EventStream
from openhands.runtime.base import Runtime
from openhands.runtime.impl.eventstream.eventstream_runtime import EventStreamRuntime
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
from openhands.runtime.plugins import AgentSkillsRequirement, JupyterRequirement
from openhands.storage import get_file_store
from openhands.utils.async_utils import call_async_from_sync
@@ -131,6 +132,8 @@ def get_runtime_classes():
return [EventStreamRuntime]
elif runtime.lower() == 'remote':
return [RemoteRuntime]
elif runtime.lower() == 'runloop':
return [RunloopRuntime]
else:
raise ValueError(f'Invalid runtime: {runtime}')

View File

@@ -1,6 +1,7 @@
"""Bash-related tests for the EventStreamRuntime, which connects to the ActionExecutor running in the sandbox."""
import os
from pathlib import Path
import pytest
from conftest import (
@@ -586,8 +587,10 @@ def test_copy_from_directory(temp_dir, runtime_cls):
path_to_copy_from = f'{sandbox_dir}/test_dir'
result = runtime.copy_from(path=path_to_copy_from)
# Result is returned in bytes
assert isinstance(result, bytes)
# Result is returned as a path
assert isinstance(result, Path)
result.unlink()
finally:
_close_test_runtime(runtime)

View File

@@ -461,6 +461,7 @@ def test_api_keys_repr_str():
jwt_secret='my_jwt_secret',
modal_api_token_id='my_modal_api_token_id',
modal_api_token_secret='my_modal_api_token_secret',
runloop_api_key='my_runloop_api_key',
)
assert "e2b_api_key='******'" in repr(app_config)
assert "e2b_api_key='******'" in str(app_config)
@@ -470,6 +471,8 @@ def test_api_keys_repr_str():
assert "modal_api_token_id='******'" in str(app_config)
assert "modal_api_token_secret='******'" in repr(app_config)
assert "modal_api_token_secret='******'" in str(app_config)
assert "runloop_api_key='******'" in repr(app_config)
assert "runloop_api_key='******'" in str(app_config)
# Check that no other attrs in AppConfig have 'key' or 'token' in their name
# This will fail when new attrs are added, and attract attention
@@ -477,6 +480,7 @@ def test_api_keys_repr_str():
'e2b_api_key',
'modal_api_token_id',
'modal_api_token_secret',
'runloop_api_key',
]
for attr_name in dir(AppConfig):
if (