Compare commits

..

45 Commits

Author SHA1 Message Date
Robert Brennan 8ac8a35811 Merge branch 'main' into rb/github-patch 2024-11-11 18:35:00 -05:00
Robert Brennan 9d3c6d87fb Merge branch 'rb/fix-remote' into rb/github-patch 2024-11-11 18:30:24 -05:00
Robert Brennan 7df7f43e3c Revert "Add rate limiting to server endpoints" (#4910) 2024-11-11 23:26:49 +00:00
Robert Brennan 4c935a84e7 another attempt 2024-11-11 18:10:40 -05:00
tofarr 2ad0831560 Merge branch 'main' into revert-4867-feature/add-rate-limiting 2024-11-11 15:53:20 -07:00
Engel Nyst a45aba512a Tweak log levels (#4729) 2024-11-11 22:51:56 +00:00
Robert Brennan d865f1e4a7 Revert "Add rate limiting to server endpoints (#4867)"
This reverts commit 79492b6551.
2024-11-11 17:41:15 -05:00
tofarr a1a9d2f175 Refactor websocket (#4879)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2024-11-11 22:36:07 +00:00
Robert Brennan 79492b6551 Add rate limiting to server endpoints (#4867)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-11 16:54:22 -05:00
sp.wack 80fdb9a2f4 feat(posthog): Emit user activated event (#4886) 2024-11-11 23:31:41 +02:00
Robert Brennan a38c45cf75 fix remote runtimes 2024-11-11 15:44:42 -05:00
Nafis Reza 975e75531d Move assets/icons to dedicated folder (#4850) 2024-11-11 20:17:04 +00:00
Robert Brennan 1b5f5bcdad fixes for upcoming changes to remote API (#4834) 2024-11-11 14:51:14 -05:00
Rohit Malhotra 8c00d96024 Support displaying images/videos/pdfs in the workspace (#4898) 2024-11-11 20:22:17 +02:00
Robert Brennan bf8ccc8fc3 fix infinite loop (#4873)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2024-11-11 10:59:43 +00:00
OpenHands 037d770f66 Fix issue #4884: (chore) add missing FE translations (#4885)
Co-authored-by: tobitege <10787084+tobitege@users.noreply.github.com>
2024-11-11 10:09:46 +00:00
sp.wack dd50246672 test(frontend): Pass failing tests (#4887) 2024-11-11 09:49:56 +00:00
Graham Neubig 090771674c Update llms.md w/ more recent results (#4874) 2024-11-10 03:12:09 +00:00
Xingyao Wang d8ab0208ba fix: remove duplicate claude-3-5-sonnet-20241022 model from VERIFIED_MODELS (#4871)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-09 21:41:56 +00:00
Xingyao Wang a07e8272da fix: improve remote runtime reliability on large-scale evaluation (#4869) 2024-11-09 20:17:10 +00:00
Robert Brennan be82832eb1 Use keyword matching for CodeAct microagents (#4568)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2024-11-09 11:25:02 -05: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
120 changed files with 5736 additions and 4874 deletions
+1 -1
View File
@@ -11,5 +11,5 @@ jobs:
uses: All-Hands-AI/openhands-resolver/.github/workflows/openhands-resolver.yml@main uses: All-Hands-AI/openhands-resolver/.github/workflows/openhands-resolver.yml@main
if: github.event.label.name == 'fix-me' if: github.event.label.name == 'fix-me'
with: with:
issue_number: ${{ github.event.issue.number || github.event.pull_request.number }} max_iterations: 50
secrets: inherit secrets: inherit
+1 -1
View File
@@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py
### 9. Use existing Docker image ### 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: 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. 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.12-nikolaik 2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.13-nikolaik
## Develop inside Docker container ## Develop inside Docker container
+4 -3
View File
@@ -38,15 +38,16 @@ See the [Installation](https://docs.all-hands.dev/modules/usage/installation) gu
system requirements and more information. system requirements and more information.
```bash ```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 \ 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 \ -v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \ -p 3000:3000 \
-e LOG_ALL_EVENTS=true \
--add-host host.docker.internal:host-gateway \ --add-host host.docker.internal:host-gateway \
--name openhands-app \ --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)! You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
+1 -1
View File
@@ -7,7 +7,7 @@ services:
image: openhands:latest image: openhands:latest
container_name: openhands-app-${DATE:-} container_name: openhands-app-${DATE:-}
environment: environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.12-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} - SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace} - WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports: ports:
+2 -1
View File
@@ -32,7 +32,8 @@ workspace_base = "./workspace"
# Enable saving and restoring the session when run from CLI # Enable saving and restoring the session when run from CLI
#enable_cli_session = false #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" #trajectories_path="./trajectories"
# File store path # File store path
+1 -1
View File
@@ -11,7 +11,7 @@ services:
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"} - BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
- SANDBOX_API_HOSTNAME=host.docker.internal - SANDBOX_API_HOSTNAME=host.docker.internal
# #
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.12-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} - SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace} - WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports: ports:
+2 -2
View File
@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash ```bash
docker run -it \ docker run -it \
--pull=always \ --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 SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \ -e LLM_API_KEY=$LLM_API_KEY \
@@ -59,7 +59,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \ --add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \ --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 python -m openhands.core.cli
``` ```
+3 -2
View File
@@ -44,15 +44,16 @@ LLM_API_KEY="sk_test_12345"
```bash ```bash
docker run -it \ docker run -it \
--pull=always \ --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 SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \ -e LLM_API_KEY=$LLM_API_KEY \
-e LLM_MODEL=$LLM_MODEL \ -e LLM_MODEL=$LLM_MODEL \
-e LOG_ALL_EVENTS=true \
-v $WORKSPACE_BASE:/opt/workspace_base \ -v $WORKSPACE_BASE:/opt/workspace_base \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \ --add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \ --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" python -m openhands.core.main -t "write a bash script that prints hi"
``` ```
+4 -3
View File
@@ -11,15 +11,16 @@
The easiest way to run OpenHands is in Docker. The easiest way to run OpenHands is in Docker.
```bash ```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 \ 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 \ -v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \ -p 3000:3000 \
-e LOG_ALL_EVENTS=true \
--add-host host.docker.internal:host-gateway \ --add-host host.docker.internal:host-gateway \
--name openhands-app \ --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). 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).
+2 -2
View File
@@ -4,11 +4,11 @@ OpenHands can connect to any LLM supported by LiteLLM. However, it requires a po
## Model Recommendations ## Model Recommendations
Based on a recent evaluation of language models for coding tasks (using the SWE-bench dataset), we can provide some recommendations for model selection. The full analysis can be found in [this blog article](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed). Based on our evaluations of language models for coding tasks (using the SWE-bench dataset), we can provide some recommendations for model selection. Some analyses can be found in [this blog article comparing LLMs](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed) and [this blog article with some more recent results](https://www.all-hands.dev/blog/openhands-codeact-21-an-open-state-of-the-art-software-development-agent).
When choosing a model, consider both the quality of outputs and the associated costs. Here's a summary of the findings: When choosing a model, consider both the quality of outputs and the associated costs. Here's a summary of the findings:
- Claude 3.5 Sonnet is the best by a fair amount, achieving a 27% resolve rate with the default agent in OpenHands. - Claude 3.5 Sonnet is the best by a fair amount, achieving a 53% resolve rate on SWE-Bench Verified with the default agent in OpenHands.
- GPT-4o lags behind, and o1-mini actually performed somewhat worse than GPT-4o. We went in and analyzed the results a little, and briefly it seemed like o1 was sometimes "overthinking" things, performing extra environment configuration tasks when it could just go ahead and finish the task. - GPT-4o lags behind, and o1-mini actually performed somewhat worse than GPT-4o. We went in and analyzed the results a little, and briefly it seemed like o1 was sometimes "overthinking" things, performing extra environment configuration tasks when it could just go ahead and finish the task.
- Finally, the strongest open models were Llama 3.1 405 B and deepseek-v2.5, and they performed reasonably, even besting some of the closed models. - Finally, the strongest open models were Llama 3.1 405 B and deepseek-v2.5, and they performed reasonably, even besting some of the closed models.
+4 -2
View File
@@ -35,7 +35,8 @@ def codeact_user_response_eda(state: State) -> str:
# retrieve the latest model message from history # retrieve the latest model message from history
if state.history: if state.history:
model_guess = state.get_last_agent_message() last_agent_message = state.get_last_agent_message()
model_guess = last_agent_message.content if last_agent_message else ''
assert game is not None, 'Game is not initialized.' assert game is not None, 'Game is not initialized.'
msg = game.generate_user_response(model_guess) msg = game.generate_user_response(model_guess)
@@ -140,7 +141,8 @@ def process_instance(
if state is None: if state is None:
raise ValueError('State should not be None.') raise ValueError('State should not be None.')
final_message = state.get_last_agent_message() last_agent_message = state.get_last_agent_message()
final_message = last_agent_message.content if last_agent_message else ''
logger.info(f'Final message: {final_message} | Ground truth: {instance["text"]}') logger.info(f'Final message: {final_message} | Ground truth: {instance["text"]}')
test_result = game.reward() test_result = game.reward()
+2 -1
View File
@@ -102,7 +102,8 @@ def process_instance(
raise ValueError('State should not be None.') raise ValueError('State should not be None.')
# retrieve the last message from the agent # retrieve the last message from the agent
model_answer_raw = state.get_last_agent_message() last_agent_message = state.get_last_agent_message()
model_answer_raw = last_agent_message.content if last_agent_message else ''
# attempt to parse model_answer # attempt to parse model_answer
ast_eval_fn = instance['ast_eval'] ast_eval_fn = instance['ast_eval']
+1
View File
@@ -83,6 +83,7 @@ def get_config(instance: pd.Series) -> AppConfig:
timeout=1800, timeout=1800,
api_key=os.environ.get('ALLHANDS_API_KEY', None), api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'), remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
remote_runtime_init_timeout=1800,
), ),
# do not mount workspace # do not mount workspace
workspace_base=None, workspace_base=None,
+1
View File
@@ -146,6 +146,7 @@ def get_config(
api_key=os.environ.get('ALLHANDS_API_KEY', None), api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'), remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_remote_runtime_alive=False, keep_remote_runtime_alive=False,
remote_runtime_init_timeout=1800,
), ),
# do not mount workspace # do not mount workspace
workspace_base=None, workspace_base=None,
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -127,7 +127,8 @@ def process_instance(instance: Any, metadata: EvalMetadata, reset_logger: bool =
raise ValueError('State should not be None.') raise ValueError('State should not be None.')
# retrieve the last message from the agent # retrieve the last message from the agent
model_answer_raw = state.get_last_agent_message() last_agent_message = state.get_last_agent_message()
model_answer_raw = last_agent_message.content if last_agent_message else ''
# attempt to parse model_answer # attempt to parse model_answer
correct = eval_answer(str(model_answer_raw), str(answer)) correct = eval_answer(str(model_answer_raw), str(answer))
@@ -1,5 +1,5 @@
import userEvent from "@testing-library/user-event"; 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 { describe, afterEach, vi, it, expect } from "vitest";
import { ChatInput } from "#/components/chat-input"; import { ChatInput } from "#/components/chat-input";
@@ -158,4 +158,46 @@ describe("ChatInput", () => {
await user.tab(); await user.tab();
expect(onBlurMock).toHaveBeenCalledOnce(); 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]);
});
}); });
@@ -16,14 +16,14 @@ describe("Empty state", () => {
send: vi.fn(), send: vi.fn(),
})); }));
const { useSocket: useSocketMock } = vi.hoisted(() => ({ const { useWsClient: useWsClientMock } = vi.hoisted(() => ({
useSocket: vi.fn(() => ({ send: sendMock, runtimeActive: true })), useWsClient: vi.fn(() => ({ send: sendMock, runtimeActive: true })),
})); }));
beforeAll(() => { beforeAll(() => {
vi.mock("#/context/socket", async (importActual) => ({ vi.mock("#/context/socket", async (importActual) => ({
...(await importActual<typeof import("#/context/socket")>()), ...(await importActual<typeof import("#/context/ws-client-provider")>()),
useSocket: useSocketMock, useWsClient: useWsClientMock,
})); }));
}); });
@@ -77,7 +77,7 @@ describe("Empty state", () => {
"should load the a user message to the input when selecting", "should load the a user message to the input when selecting",
async () => { async () => {
// this is to test that the message is in the UI before the socket is called // this is to test that the message is in the UI before the socket is called
useSocketMock.mockImplementation(() => ({ useWsClientMock.mockImplementation(() => ({
send: sendMock, send: sendMock,
runtimeActive: false, // mock an inactive runtime setup runtimeActive: false, // mock an inactive runtime setup
})); }));
@@ -106,7 +106,7 @@ describe("Empty state", () => {
it.fails( it.fails(
"should send the message to the socket only if the runtime is active", "should send the message to the socket only if the runtime is active",
async () => { async () => {
useSocketMock.mockImplementation(() => ({ useWsClientMock.mockImplementation(() => ({
send: sendMock, send: sendMock,
runtimeActive: false, // mock an inactive runtime setup runtimeActive: false, // mock an inactive runtime setup
})); }));
@@ -123,7 +123,7 @@ describe("Empty state", () => {
await user.click(displayedSuggestions[0]); await user.click(displayedSuggestions[0]);
expect(sendMock).not.toHaveBeenCalled(); expect(sendMock).not.toHaveBeenCalled();
useSocketMock.mockImplementation(() => ({ useWsClientMock.mockImplementation(() => ({
send: sendMock, send: sendMock,
runtimeActive: true, // mock an active runtime setup runtimeActive: true, // mock an active runtime setup
})); }));
+16 -4
View File
@@ -2,8 +2,9 @@ import { beforeAll, describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { afterEach } from "node:test"; import { afterEach } from "node:test";
import { useTerminal } from "#/hooks/useTerminal"; import { useTerminal } from "#/hooks/useTerminal";
import { SocketProvider } from "#/context/socket";
import { Command } from "#/state/commandSlice"; import { Command } from "#/state/commandSlice";
import { WsClientProvider } from "#/context/ws-client-provider";
import { ReactNode } from "react";
interface TestTerminalComponentProps { interface TestTerminalComponentProps {
commands: Command[]; commands: Command[];
@@ -18,6 +19,17 @@ function TestTerminalComponent({
return <div ref={ref} />; return <div ref={ref} />;
} }
interface WrapperProps {
children: ReactNode;
}
function Wrapper({children}: WrapperProps) {
return (
<WsClientProvider enabled={true} token="NO_JWT" ghToken="NO_GITHUB" settings={null}>{children}</WsClientProvider>
)
}
describe("useTerminal", () => { describe("useTerminal", () => {
const mockTerminal = vi.hoisted(() => ({ const mockTerminal = vi.hoisted(() => ({
loadAddon: vi.fn(), loadAddon: vi.fn(),
@@ -50,7 +62,7 @@ describe("useTerminal", () => {
it("should render", () => { it("should render", () => {
render(<TestTerminalComponent commands={[]} secrets={[]} />, { render(<TestTerminalComponent commands={[]} secrets={[]} />, {
wrapper: SocketProvider, wrapper: Wrapper,
}); });
}); });
@@ -61,7 +73,7 @@ describe("useTerminal", () => {
]; ];
render(<TestTerminalComponent commands={commands} secrets={[]} />, { render(<TestTerminalComponent commands={commands} secrets={[]} />, {
wrapper: SocketProvider, wrapper: Wrapper,
}); });
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello"); expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello");
@@ -85,7 +97,7 @@ describe("useTerminal", () => {
secrets={[secret, anotherSecret]} secrets={[secret, anotherSecret]}
/>, />,
{ {
wrapper: SocketProvider, wrapper: Wrapper,
}, },
); );
+53
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();
});
});
@@ -59,9 +59,9 @@ describe("extractModelAndProvider", () => {
separator: "/", separator: "/",
}); });
expect(extractModelAndProvider("claude-3-5-sonnet-20241022")).toEqual({ expect(extractModelAndProvider("claude-3-5-sonnet-20240620")).toEqual({
provider: "anthropic", provider: "anthropic",
model: "claude-3-5-sonnet-20241022", model: "claude-3-5-sonnet-20240620",
separator: "/", separator: "/",
}); });
@@ -15,7 +15,7 @@ test("organizeModelsAndProviders", () => {
"gpt-4o", "gpt-4o",
"together-ai-21.1b-41b", "together-ai-21.1b-41b",
"gpt-4o-mini", "gpt-4o-mini",
"claude-3-5-sonnet-20241022", "anthropic/claude-3-5-sonnet-20241022",
"claude-3-haiku-20240307", "claude-3-haiku-20240307",
"claude-2", "claude-2",
"claude-2.1", "claude-2.1",
+21 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "openhands-frontend", "name": "openhands-frontend",
"version": "0.12.3", "version": "0.13.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "openhands-frontend", "name": "openhands-frontend",
"version": "0.12.3", "version": "0.13.0",
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.6.0", "@monaco-editor/react": "^4.6.0",
"@nextui-org/react": "^2.4.8", "@nextui-org/react": "^2.4.8",
@@ -63,6 +63,7 @@
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^1.6.0", "@vitest/coverage-v8": "^1.6.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0", "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": { "node_modules/cross-fetch": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
+4 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "openhands-frontend", "name": "openhands-frontend",
"version": "0.12.3", "version": "0.13.0",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
@@ -45,8 +45,8 @@
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"scripts": { "scripts": {
"dev": "npm run make-i18n && VITE_MOCK_API=false remix vite:dev", "dev": "npm run make-i18n && cross-env VITE_MOCK_API=false remix vite:dev",
"dev:mock": "npm run make-i18n && VITE_MOCK_API=true 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", "build": "npm run make-i18n && tsc && remix vite:build",
"start": "npx sirv-cli build/ --single", "start": "npx sirv-cli build/ --single",
"test": "vitest run", "test": "vitest run",
@@ -89,6 +89,7 @@
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^1.6.0", "@vitest/coverage-v8": "^1.6.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0", "eslint-config-airbnb-typescript": "^18.0.0",
-27
View File
@@ -139,33 +139,6 @@ export const retrieveGitHubUser = async (
return error; 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 ( export const retrieveLatestGitHubCommit = async (
token: string, token: string,
repository: string, repository: string,
+29 -4
View File
@@ -1,4 +1,5 @@
import { request } from "#/services/api"; import { request } from "#/services/api";
import { cache } from "#/utils/cache";
import { import {
SaveFileSuccessResponse, SaveFileSuccessResponse,
FileUploadSuccessResponse, FileUploadSuccessResponse,
@@ -15,7 +16,13 @@ class OpenHands {
* @returns List of models available * @returns List of models available
*/ */
static async getModels(): Promise<string[]> { 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 * @returns List of agents available
*/ */
static async getAgents(): Promise<string[]> { 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 * @returns List of security analyzers available
*/ */
static async getSecurityAnalyzers(): Promise<string[]> { 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> { 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;
} }
/** /**
+2 -2
View File
@@ -6,7 +6,7 @@ import PlayIcon from "#/assets/play";
import { generateAgentStateChangeEvent } from "#/services/agentStateService"; import { generateAgentStateChangeEvent } from "#/services/agentStateService";
import { RootState } from "#/store"; import { RootState } from "#/store";
import AgentState from "#/types/AgentState"; import AgentState from "#/types/AgentState";
import { useSocket } from "#/context/socket"; import { useWsClient } from "#/context/ws-client-provider";
const IgnoreTaskStateMap: Record<string, AgentState[]> = { const IgnoreTaskStateMap: Record<string, AgentState[]> = {
[AgentState.PAUSED]: [ [AgentState.PAUSED]: [
@@ -72,7 +72,7 @@ function ActionButton({
} }
function AgentControlBar() { function AgentControlBar() {
const { send } = useSocket(); const { send } = useWsClient();
const { curAgentState } = useSelector((state: RootState) => state.agent); const { curAgentState } = useSelector((state: RootState) => state.agent);
const handleAction = (action: AgentState) => { const handleAction = (action: AgentState) => {
+11 -3
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" 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" }} style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
> >
<SyntaxHighlighter language="python" style={atomOneDark}> <SyntaxHighlighter
language="python"
style={atomOneDark}
wrapLongLines
>
{code} {code}
</SyntaxHighlighter> </SyntaxHighlighter>
</pre> </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 { t } = useTranslation();
const { cells } = useSelector((state: RootState) => state.jupyter); const { cells } = useSelector((state: RootState) => state.jupyter);
@@ -88,7 +96,7 @@ function JupyterEditor(): JSX.Element {
useScrollToBottom(jupyterRef); useScrollToBottom(jupyterRef);
return ( return (
<div className="flex-1"> <div className="flex-1" style={{ maxWidth }}>
<div <div
className="overflow-y-auto h-full" className="overflow-y-auto h-full"
ref={jupyterRef} ref={jupyterRef}
@@ -1,4 +1,4 @@
import Clip from "#/assets/clip.svg?react"; import Clip from "#/icons/clip.svg?react";
export function AttachImageLabel() { export function AttachImageLabel() {
return ( return (
+8 -3
View File
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import TextareaAutosize from "react-textarea-autosize"; import TextareaAutosize from "react-textarea-autosize";
import ArrowSendIcon from "#/assets/arrow-send.svg?react"; import ArrowSendIcon from "#/icons/arrow-send.svg?react";
import { cn } from "#/utils/utils"; import { cn } from "#/utils/utils";
interface ChatInputProps { interface ChatInputProps {
@@ -40,13 +40,18 @@ export function ChatInput({
const [isDraggingOver, setIsDraggingOver] = React.useState(false); const [isDraggingOver, setIsDraggingOver] = React.useState(false);
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => { const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
event.preventDefault(); // Only handle paste if we have an image paste handler and there are files
if (onImagePaste && event.clipboardData.files.length > 0) { if (onImagePaste && event.clipboardData.files.length > 0) {
const files = Array.from(event.clipboardData.files).filter((file) => const files = Array.from(event.clipboardData.files).filter((file) =>
file.type.startsWith("image/"), file.type.startsWith("image/"),
); );
if (files.length > 0) onImagePaste(files); // 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>) => { const handleDragOver = (event: React.DragEvent<HTMLTextAreaElement>) => {
+8 -3
View File
@@ -1,6 +1,6 @@
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import React from "react"; import React from "react";
import { useSocket } from "#/context/socket"; import posthog from "posthog-js";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64"; import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { ChatMessage } from "./chat-message"; import { ChatMessage } from "./chat-message";
import { FeedbackActions } from "./feedback-actions"; import { FeedbackActions } from "./feedback-actions";
@@ -20,14 +20,15 @@ import { ContinueButton } from "./continue-button";
import { ScrollToBottomButton } from "./scroll-to-bottom-button"; import { ScrollToBottomButton } from "./scroll-to-bottom-button";
import { Suggestions } from "./suggestions"; import { Suggestions } from "./suggestions";
import { SUGGESTIONS } from "#/utils/suggestions"; import { SUGGESTIONS } from "#/utils/suggestions";
import BuildIt from "#/assets/build-it.svg?react"; import BuildIt from "#/icons/build-it.svg?react";
import { useWsClient } from "#/context/ws-client-provider";
const isErrorMessage = ( const isErrorMessage = (
message: Message | ErrorMessage, message: Message | ErrorMessage,
): message is ErrorMessage => "error" in message; ): message is ErrorMessage => "error" in message;
export function ChatInterface() { export function ChatInterface() {
const { send } = useSocket(); const { send } = useWsClient();
const dispatch = useDispatch(); const dispatch = useDispatch();
const scrollRef = React.useRef<HTMLDivElement>(null); const scrollRef = React.useRef<HTMLDivElement>(null);
const { scrollDomToBottom, onChatBodyScroll, hitBottom } = const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
@@ -43,6 +44,9 @@ export function ChatInterface() {
const [messageToSend, setMessageToSend] = React.useState<string | null>(null); const [messageToSend, setMessageToSend] = React.useState<string | null>(null);
const handleSendMessage = async (content: string, files: File[]) => { 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 promises = files.map((file) => convertImageToBase64(file));
const imageUrls = await Promise.all(promises); const imageUrls = await Promise.all(promises);
@@ -53,6 +57,7 @@ export function ChatInterface() {
}; };
const handleStop = () => { const handleStop = () => {
posthog.capture("stop_button_clicked");
send(generateAgentStateChangeEvent(AgentState.STOPPED)); send(generateAgentStateChangeEvent(AgentState.STOPPED));
}; };
@@ -5,7 +5,7 @@ import RejectIcon from "#/assets/reject";
import { I18nKey } from "#/i18n/declaration"; import { I18nKey } from "#/i18n/declaration";
import AgentState from "#/types/AgentState"; import AgentState from "#/types/AgentState";
import { generateAgentStateChangeEvent } from "#/services/agentStateService"; import { generateAgentStateChangeEvent } from "#/services/agentStateService";
import { useSocket } from "#/context/socket"; import { useWsClient } from "#/context/ws-client-provider";
interface ActionTooltipProps { interface ActionTooltipProps {
type: "confirm" | "reject"; type: "confirm" | "reject";
@@ -37,7 +37,7 @@ function ActionTooltip({ type, onClick }: ActionTooltipProps) {
function ConfirmationButtons() { function ConfirmationButtons() {
const { t } = useTranslation(); const { t } = useTranslation();
const { send } = useSocket(); const { send } = useWsClient();
const handleStateChange = (state: AgentState) => { const handleStateChange = (state: AgentState) => {
const event = generateAgentStateChangeEvent(state); const event = generateAgentStateChangeEvent(state);
+188
View File
@@ -0,0 +1,188 @@
import React from "react";
import {
useFetcher,
useLoaderData,
useRouteLoaderData,
} from "@remix-run/react";
import { useDispatch, useSelector } from "react-redux";
import toast from "react-hot-toast";
import posthog from "posthog-js";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import { ErrorObservation } from "#/types/core/observations";
import { addErrorMessage, addUserMessage } from "#/state/chatSlice";
import { handleAssistantMessage } from "#/services/actions";
import {
getCloneRepoCommand,
getGitHubTokenCommand,
} from "#/services/terminalService";
import {
clearFiles,
clearSelectedRepository,
setImportedProjectZip,
} from "#/state/initial-query-slice";
import { clientLoader as appClientLoader } from "#/routes/_oh.app";
import store, { RootState } from "#/store";
import { createChatMessage } from "#/services/chatService";
import { clientLoader as rootClientLoader } from "#/routes/_oh";
import { isGitHubErrorReponse } from "#/api/github";
import OpenHands from "#/api/open-hands";
import { base64ToBlob } from "#/utils/base64-to-blob";
import { setCurrentAgentState } from "#/state/agentSlice";
import AgentState from "#/types/AgentState";
import { getSettings } from "#/services/settings";
interface ServerError {
error: boolean | string;
message: string;
[key: string]: unknown;
}
const isServerError = (data: object): data is ServerError => "error" in data;
const isErrorObservation = (data: object): data is ErrorObservation =>
"observation" in data && data.observation === "error";
export function EventHandler({ children }: React.PropsWithChildren) {
const { events, status, send } = useWsClient();
const statusRef = React.useRef<WsClientProviderStatus | null>(null);
const runtimeActive = status === WsClientProviderStatus.ACTIVE;
const fetcher = useFetcher();
const dispatch = useDispatch();
const { files, importedProjectZip } = useSelector(
(state: RootState) => state.initalQuery,
);
const { ghToken, repo } = useLoaderData<typeof appClientLoader>();
const initialQueryRef = React.useRef<string | null>(
store.getState().initalQuery.initialQuery,
);
const sendInitialQuery = (query: string, base64Files: string[]) => {
const timestamp = new Date().toISOString();
send(createChatMessage(query, base64Files, timestamp));
};
const data = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
const userId = React.useMemo(() => {
if (data?.user && !isGitHubErrorReponse(data.user)) return data.user.id;
return null;
}, [data?.user]);
const userSettings = getSettings();
React.useEffect(() => {
if (!events.length) {
return;
}
const event = events[events.length - 1];
if (event.token) {
fetcher.submit({ token: event.token as string }, { method: "post" });
return;
}
if (isServerError(event)) {
if (event.error_code === 401) {
toast.error("Session expired.");
fetcher.submit({}, { method: "POST", action: "/end-session" });
return;
}
if (typeof event.error === "string") {
toast.error(event.error);
} else {
toast.error(event.message);
}
return;
}
if (isErrorObservation(event)) {
dispatch(
addErrorMessage({
id: event.extras?.error_id,
message: event.message,
}),
);
return;
}
handleAssistantMessage(event);
}, [events.length]);
React.useEffect(() => {
if (statusRef.current === status) {
return; // This is a check because of strict mode - if the status did not change, don't do anything
}
statusRef.current = status;
const initialQuery = initialQueryRef.current;
if (status === WsClientProviderStatus.ACTIVE) {
let additionalInfo = "";
if (ghToken && repo) {
send(getCloneRepoCommand(ghToken, repo));
additionalInfo = `Repository ${repo} has been cloned to /workspace. Please check the /workspace for files.`;
dispatch(clearSelectedRepository()); // reset selected repository; maybe better to move this to '/'?
}
// if there's an uploaded project zip, add it to the chat
else if (importedProjectZip) {
additionalInfo = `Files have been uploaded. Please check the /workspace for files.`;
}
if (initialQuery) {
if (additionalInfo) {
sendInitialQuery(`${initialQuery}\n\n[${additionalInfo}]`, files);
} else {
sendInitialQuery(initialQuery, files);
}
dispatch(clearFiles()); // reset selected files
initialQueryRef.current = null;
}
}
if (status === WsClientProviderStatus.OPENING && initialQuery) {
dispatch(
addUserMessage({
content: initialQuery,
imageUrls: files,
timestamp: new Date().toISOString(),
}),
);
}
if (status === WsClientProviderStatus.STOPPED) {
store.dispatch(setCurrentAgentState(AgentState.STOPPED));
}
}, [status]);
React.useEffect(() => {
if (runtimeActive && userId && ghToken) {
// Export if the user valid, this could happen mid-session so it is handled here
send(getGitHubTokenCommand(ghToken));
}
}, [userId, ghToken, runtimeActive]);
React.useEffect(() => {
(async () => {
if (runtimeActive && importedProjectZip) {
// upload files action
try {
const blob = base64ToBlob(importedProjectZip);
const file = new File([blob], "imported-project.zip", {
type: blob.type,
});
await OpenHands.uploadFiles([file]);
dispatch(setImportedProjectZip(null));
} catch (error) {
toast.error("Failed to upload project files.");
}
}
})();
}, [runtimeActive, importedProjectZip]);
React.useEffect(() => {
if (userSettings.LLM_API_KEY) {
posthog.capture("user_activated");
}
}, [userSettings.LLM_API_KEY]);
return children;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import CloseIcon from "#/assets/close.svg?react"; import CloseIcon from "#/icons/close.svg?react";
import { cn } from "#/utils/utils"; import { cn } from "#/utils/utils";
interface ImagePreviewProps { interface ImagePreviewProps {
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import LoadingSpinnerOuter from "#/assets/loading-outer.svg?react"; import LoadingSpinnerOuter from "#/icons/loading-outer.svg?react";
import { cn } from "#/utils/utils"; import { cn } from "#/utils/utils";
import ModalBody from "./ModalBody"; import ModalBody from "./ModalBody";
import { I18nKey } from "#/i18n/declaration"; import { I18nKey } from "#/i18n/declaration";
@@ -1,16 +1,18 @@
import React from "react"; import React from "react";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import EllipsisH from "#/assets/ellipsis-h.svg?react"; import posthog from "posthog-js";
import EllipsisH from "#/icons/ellipsis-h.svg?react";
import { ModalBackdrop } from "../modals/modal-backdrop"; import { ModalBackdrop } from "../modals/modal-backdrop";
import { ConnectToGitHubModal } from "../modals/connect-to-github-modal"; import { ConnectToGitHubModal } from "../modals/connect-to-github-modal";
import { addUserMessage } from "#/state/chatSlice"; import { addUserMessage } from "#/state/chatSlice";
import { useSocket } from "#/context/socket";
import { createChatMessage } from "#/services/chatService"; import { createChatMessage } from "#/services/chatService";
import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu"; import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder"; import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
import { ProjectMenuDetails } from "./project-menu-details"; import { ProjectMenuDetails } from "./project-menu-details";
import { downloadWorkspace } from "#/utils/download-workspace"; import { downloadWorkspace } from "#/utils/download-workspace";
import { LoadingSpinner } from "../modals/LoadingProject";
import { useWsClient } from "#/context/ws-client-provider";
interface ProjectMenuCardProps { interface ProjectMenuCardProps {
isConnectedToGitHub: boolean; isConnectedToGitHub: boolean;
@@ -25,24 +27,23 @@ export function ProjectMenuCard({
isConnectedToGitHub, isConnectedToGitHub,
githubData, githubData,
}: ProjectMenuCardProps) { }: ProjectMenuCardProps) {
const { send } = useSocket(); const { send } = useWsClient();
const dispatch = useDispatch(); const dispatch = useDispatch();
const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false); const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] = const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
React.useState(false); React.useState(false);
const [working, setWorking] = React.useState(false);
const toggleMenuVisibility = () => { const toggleMenuVisibility = () => {
setContextMenuIsOpen((prev) => !prev); setContextMenuIsOpen((prev) => !prev);
}; };
const handlePushToGitHub = () => { const handlePushToGitHub = () => {
posthog.capture("push_to_github_button_clicked");
const rawEvent = { const rawEvent = {
content: ` content: `
Let's push the code to GitHub. Please push the changes to GitHub and open a pull request.
If we're currently on the openhands-workspace branch, please create a new branch with a descriptive name.
Commit any changes and push them to the remote repository.
Finally, open up a pull request using the GitHub API and the token in the GITHUB_TOKEN environment variable, then show me the URL of the pull request.
`, `,
imageUrls: [], imageUrls: [],
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -58,20 +59,27 @@ Finally, open up a pull request using the GitHub API and the token in the GITHUB
setContextMenuIsOpen(false); 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 ( return (
<div className="px-4 py-[10px] w-[337px] rounded-xl border border-[#525252] flex justify-between items-center relative"> <div className="px-4 py-[10px] w-[337px] rounded-xl border border-[#525252] flex justify-between items-center relative">
{contextMenuIsOpen && ( {!working && contextMenuIsOpen && (
<ProjectMenuCardContextMenu <ProjectMenuCardContextMenu
isConnectedToGitHub={isConnectedToGitHub} isConnectedToGitHub={isConnectedToGitHub}
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)} onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
onPushToGitHub={handlePushToGitHub} onPushToGitHub={handlePushToGitHub}
onDownloadWorkspace={() => { onDownloadWorkspace={handleDownloadWorkspace}
try {
downloadWorkspace();
} catch (error) {
toast.error("Failed to download workspace");
}
}}
onClose={() => setContextMenuIsOpen(false)} onClose={() => setContextMenuIsOpen(false)}
/> />
)} )}
@@ -93,7 +101,11 @@ Finally, open up a pull request using the GitHub API and the token in the GITHUB
onClick={toggleMenuVisibility} onClick={toggleMenuVisibility}
aria-label="Open project menu" aria-label="Open project menu"
> >
<EllipsisH width={36} height={36} /> {working ? (
<LoadingSpinner size="small" />
) : (
<EllipsisH width={36} height={36} />
)}
</button> </button>
{connectToGitHubModalOpen && ( {connectToGitHubModalOpen && (
<ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}> <ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}>
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "#/utils/utils"; import { cn } from "#/utils/utils";
import CloudConnection from "#/assets/cloud-connection.svg?react"; import CloudConnection from "#/icons/cloud-connection.svg?react";
import { I18nKey } from "#/i18n/declaration"; import { I18nKey } from "#/i18n/declaration";
interface ProjectMenuDetailsPlaceholderProps { interface ProjectMenuDetailsPlaceholderProps {
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import ExternalLinkIcon from "#/assets/external-link.svg?react"; import ExternalLinkIcon from "#/icons/external-link.svg?react";
import { formatTimeDelta } from "#/utils/format-time-delta"; import { formatTimeDelta } from "#/utils/format-time-delta";
import { I18nKey } from "#/i18n/declaration"; import { I18nKey } from "#/i18n/declaration";
@@ -1,6 +1,8 @@
import { useTranslation } from "react-i18next";
import { useClickOutsideElement } from "#/hooks/useClickOutsideElement"; import { useClickOutsideElement } from "#/hooks/useClickOutsideElement";
import { ContextMenu } from "../context-menu/context-menu"; import { ContextMenu } from "../context-menu/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item"; import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { I18nKey } from "#/i18n/declaration";
interface ProjectMenuCardContextMenuProps { interface ProjectMenuCardContextMenuProps {
isConnectedToGitHub: boolean; isConnectedToGitHub: boolean;
@@ -18,7 +20,7 @@ export function ProjectMenuCardContextMenu({
onClose, onClose,
}: ProjectMenuCardContextMenuProps) { }: ProjectMenuCardContextMenuProps) {
const menuRef = useClickOutsideElement<HTMLUListElement>(onClose); const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);
const { t } = useTranslation();
return ( return (
<ContextMenu <ContextMenu
ref={menuRef} ref={menuRef}
@@ -26,16 +28,16 @@ export function ProjectMenuCardContextMenu({
> >
{!isConnectedToGitHub && ( {!isConnectedToGitHub && (
<ContextMenuListItem onClick={onConnectToGitHub}> <ContextMenuListItem onClick={onConnectToGitHub}>
Connect to GitHub {t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL)}
</ContextMenuListItem> </ContextMenuListItem>
)} )}
{isConnectedToGitHub && ( {isConnectedToGitHub && (
<ContextMenuListItem onClick={onPushToGitHub}> <ContextMenuListItem onClick={onPushToGitHub}>
Push to GitHub {t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL)}
</ContextMenuListItem> </ContextMenuListItem>
)} )}
<ContextMenuListItem onClick={onDownloadWorkspace}> <ContextMenuListItem onClick={onDownloadWorkspace}>
Download as .zip {t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL)}
</ContextMenuListItem> </ContextMenuListItem>
</ContextMenu> </ContextMenu>
); );
@@ -1,4 +1,4 @@
import ArrowSendIcon from "#/assets/arrow-send.svg?react"; import ArrowSendIcon from "#/icons/arrow-send.svg?react";
interface ScrollToBottomButtonProps { interface ScrollToBottomButtonProps {
onClick: () => void; onClick: () => void;
@@ -1,5 +1,5 @@
import Lightbulb from "#/assets/lightbulb.svg?react"; import Lightbulb from "#/icons/lightbulb.svg?react";
import Refresh from "#/assets/refresh.svg?react"; import Refresh from "#/icons/refresh.svg?react";
interface SuggestionBubbleProps { interface SuggestionBubbleProps {
suggestion: string; suggestion: string;
@@ -1,4 +1,4 @@
import Clip from "#/assets/clip.svg?react"; import Clip from "#/icons/clip.svg?react";
interface UploadImageInputProps { interface UploadImageInputProps {
onUpload: (files: File[]) => void; onUpload: (files: File[]) => void;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LoadingSpinner } from "./modals/LoadingProject"; import { LoadingSpinner } from "./modals/LoadingProject";
import DefaultUserAvatar from "#/assets/default-user.svg?react"; import DefaultUserAvatar from "#/icons/default-user.svg?react";
import { cn } from "#/utils/utils"; import { cn } from "#/utils/utils";
interface UserAvatarProps { interface UserAvatarProps {
-142
View File
@@ -1,142 +0,0 @@
import React from "react";
import { Data } from "ws";
import EventLogger from "#/utils/event-logger";
interface WebSocketClientOptions {
token: string | null;
onOpen?: (event: Event) => void;
onMessage?: (event: MessageEvent<Data>) => void;
onError?: (event: Event) => void;
onClose?: (event: Event) => void;
}
interface WebSocketContextType {
send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void;
start: (options?: WebSocketClientOptions) => void;
stop: () => void;
setRuntimeIsInitialized: () => void;
runtimeActive: boolean;
isConnected: boolean;
events: Record<string, unknown>[];
}
const SocketContext = React.createContext<WebSocketContextType | undefined>(
undefined,
);
interface SocketProviderProps {
children: React.ReactNode;
}
function SocketProvider({ children }: SocketProviderProps) {
const wsRef = React.useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = React.useState(false);
const [runtimeActive, setRuntimeActive] = React.useState(false);
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
const setRuntimeIsInitialized = () => {
setRuntimeActive(true);
};
const start = React.useCallback((options?: WebSocketClientOptions): void => {
if (wsRef.current) {
EventLogger.warning(
"WebSocket connection is already established, but a new one is starting anyways.",
);
}
const baseUrl =
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const sessionToken = options?.token || "NO_JWT"; // not allowed to be empty or duplicated
const ghToken = localStorage.getItem("ghToken") || "NO_GITHUB";
const ws = new WebSocket(`${protocol}//${baseUrl}/ws`, [
"openhands",
sessionToken,
ghToken,
]);
ws.addEventListener("open", (event) => {
setIsConnected(true);
options?.onOpen?.(event);
});
ws.addEventListener("message", (event) => {
EventLogger.message(event);
setEvents((prevEvents) => [...prevEvents, JSON.parse(event.data)]);
options?.onMessage?.(event);
});
ws.addEventListener("error", (event) => {
EventLogger.event(event, "SOCKET ERROR");
options?.onError?.(event);
});
ws.addEventListener("close", (event) => {
EventLogger.event(event, "SOCKET CLOSE");
setIsConnected(false);
setRuntimeActive(false);
wsRef.current = null;
options?.onClose?.(event);
});
wsRef.current = ws;
}, []);
const stop = React.useCallback((): void => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
}, []);
const send = React.useCallback(
(data: string | ArrayBufferLike | Blob | ArrayBufferView) => {
if (!wsRef.current) {
EventLogger.error("WebSocket is not connected.");
return;
}
setEvents((prevEvents) => [...prevEvents, JSON.parse(data.toString())]);
wsRef.current.send(data);
},
[],
);
const value = React.useMemo(
() => ({
send,
start,
stop,
setRuntimeIsInitialized,
runtimeActive,
isConnected,
events,
}),
[
send,
start,
stop,
setRuntimeIsInitialized,
runtimeActive,
isConnected,
events,
],
);
return (
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
);
}
function useSocket() {
const context = React.useContext(SocketContext);
if (context === undefined) {
throw new Error("useSocket must be used within a SocketProvider");
}
return context;
}
export { SocketProvider, useSocket };
+175
View File
@@ -0,0 +1,175 @@
import posthog from "posthog-js";
import React from "react";
import { Settings } from "#/services/settings";
import ActionType from "#/types/ActionType";
import EventLogger from "#/utils/event-logger";
import AgentState from "#/types/AgentState";
export enum WsClientProviderStatus {
STOPPED,
OPENING,
ACTIVE,
ERROR,
}
interface UseWsClient {
status: WsClientProviderStatus;
events: Record<string, unknown>[];
send: (event: Record<string, unknown>) => void;
}
const WsClientContext = React.createContext<UseWsClient>({
status: WsClientProviderStatus.STOPPED,
events: [],
send: () => {
throw new Error("not connected");
},
});
interface WsClientProviderProps {
enabled: boolean;
token: string | null;
ghToken: string | null;
settings: Settings | null;
}
export function WsClientProvider({
enabled,
token,
ghToken,
settings,
children,
}: React.PropsWithChildren<WsClientProviderProps>) {
const wsRef = React.useRef<WebSocket | null>(null);
const tokenRef = React.useRef<string | null>(token);
const ghTokenRef = React.useRef<string | null>(ghToken);
const closeRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const [status, setStatus] = React.useState(WsClientProviderStatus.STOPPED);
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
function send(event: Record<string, unknown>) {
if (!wsRef.current) {
EventLogger.error("WebSocket is not connected.");
return;
}
wsRef.current.send(JSON.stringify(event));
}
function handleOpen() {
setStatus(WsClientProviderStatus.OPENING);
const initEvent = {
action: ActionType.INIT,
args: settings,
};
send(initEvent);
}
function handleMessage(messageEvent: MessageEvent) {
const event = JSON.parse(messageEvent.data);
setEvents((prevEvents) => [...prevEvents, event]);
if (event.extras?.agent_state === AgentState.INIT) {
setStatus(WsClientProviderStatus.ACTIVE);
}
if (
status !== WsClientProviderStatus.ACTIVE &&
event?.observation === "error"
) {
setStatus(WsClientProviderStatus.ERROR);
}
}
function handleClose() {
setStatus(WsClientProviderStatus.STOPPED);
setEvents([]);
wsRef.current = null;
}
function handleError(event: Event) {
posthog.capture("socket_error");
EventLogger.event(event, "SOCKET ERROR");
setStatus(WsClientProviderStatus.ERROR);
}
// Connect websocket
React.useEffect(() => {
let ws = wsRef.current;
// If disabled close any existing websockets...
if (!enabled) {
if (ws) {
ws.close();
}
wsRef.current = null;
return () => {};
}
// If there is no websocket or the tokens have changed or the current websocket is closed,
// create a new one
if (
!ws ||
(tokenRef.current && token !== tokenRef.current) ||
ghToken !== ghTokenRef.current ||
ws.readyState === WebSocket.CLOSED ||
ws.readyState === WebSocket.CLOSING
) {
ws?.close();
const baseUrl =
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${protocol}//${baseUrl}/ws`, [
"openhands",
token || "NO_JWT",
ghToken || "NO_GITHUB",
]);
}
ws.addEventListener("open", handleOpen);
ws.addEventListener("message", handleMessage);
ws.addEventListener("error", handleError);
ws.addEventListener("close", handleClose);
wsRef.current = ws;
tokenRef.current = token;
ghTokenRef.current = ghToken;
return () => {
ws.removeEventListener("open", handleOpen);
ws.removeEventListener("message", handleMessage);
ws.removeEventListener("error", handleError);
ws.removeEventListener("close", handleClose);
};
}, [enabled, token, ghToken]);
// Strict mode mounts and unmounts each component twice, so we have to wait in the destructor
// before actually closing the socket and cancel the operation if the component gets remounted.
React.useEffect(() => {
const timeout = closeRef.current;
if (timeout != null) {
clearTimeout(timeout);
}
return () => {
closeRef.current = setTimeout(() => {
wsRef.current?.close();
}, 100);
};
}, []);
const value = React.useMemo<UseWsClient>(
() => ({
status,
events,
send,
}),
[status, events],
);
return (
<WsClientContext.Provider value={value}>
{children}
</WsClientContext.Provider>
);
}
export function useWsClient() {
const context = React.useContext(WsClientContext);
return context;
}
+4 -7
View File
@@ -10,7 +10,6 @@ import React, { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client"; import { hydrateRoot } from "react-dom/client";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import posthog from "posthog-js"; import posthog from "posthog-js";
import { SocketProvider } from "./context/socket";
import "./i18n"; import "./i18n";
import store from "./store"; import store from "./store";
@@ -43,12 +42,10 @@ prepareApp().then(() =>
hydrateRoot( hydrateRoot(
document, document,
<StrictMode> <StrictMode>
<SocketProvider> <Provider store={store}>
<Provider store={store}> <RemixBrowser />
<RemixBrowser /> <PosthogInit />
<PosthogInit /> </Provider>
</Provider>
</SocketProvider>
</StrictMode>, </StrictMode>,
); );
}), }),
+2 -2
View File
@@ -4,7 +4,7 @@ import React from "react";
import { Command } from "#/state/commandSlice"; import { Command } from "#/state/commandSlice";
import { getTerminalCommand } from "#/services/terminalService"; import { getTerminalCommand } from "#/services/terminalService";
import { parseTerminalOutput } from "#/utils/parseTerminalOutput"; import { parseTerminalOutput } from "#/utils/parseTerminalOutput";
import { useSocket } from "#/context/socket"; import { useWsClient } from "#/context/ws-client-provider";
/* /*
NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component. NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component.
@@ -15,7 +15,7 @@ export const useTerminal = (
commands: Command[] = [], commands: Command[] = [],
secrets: string[] = [], secrets: string[] = [],
) => { ) => {
const { send } = useSocket(); const { send } = useWsClient();
const terminal = React.useRef<Terminal | null>(null); const terminal = React.useRef<Terminal | null>(null);
const fitAddon = React.useRef<FitAddon | null>(null); const fitAddon = React.useRef<FitAddon | null>(null);
const ref = React.useRef<HTMLDivElement>(null); const ref = React.useRef<HTMLDivElement>(null);
+295 -38
View File
@@ -535,7 +535,8 @@
"pt": "Socket não inicializado", "pt": "Socket não inicializado",
"ko-KR": "소켓이 초기화되지 않았습니다", "ko-KR": "소켓이 초기화되지 않았습니다",
"ar": "لم يتم تهيئة Socket", "ar": "لم يتم تهيئة Socket",
"tr": "Soket başlatılmadı" "tr": "Soket başlatılmadı",
"no": "Socket ikke initialisert"
}, },
"EXPLORER$UPLOAD_ERROR_MESSAGE": { "EXPLORER$UPLOAD_ERROR_MESSAGE": {
"en": "Error uploading file", "en": "Error uploading file",
@@ -548,7 +549,8 @@
"pt": "Erro ao fazer upload do arquivo", "pt": "Erro ao fazer upload do arquivo",
"ko-KR": "파일 업로드 중 오류 발생", "ko-KR": "파일 업로드 중 오류 발생",
"ar": "خطأ في تحميل الملف", "ar": "خطأ في تحميل الملف",
"tr": "Dosya yüklenirken hata oluştu" "tr": "Dosya yüklenirken hata oluştu",
"no": "Feil ved opplasting av fil"
}, },
"EXPLORER$LABEL_DROP_FILES": { "EXPLORER$LABEL_DROP_FILES": {
"en": "Drop files here", "en": "Drop files here",
@@ -557,6 +559,7 @@
"zh-TW": "將檔案拖曳至此", "zh-TW": "將檔案拖曳至此",
"es": "Suelta los archivos aquí", "es": "Suelta los archivos aquí",
"fr": "Déposez les fichiers ici", "fr": "Déposez les fichiers ici",
"no": "Slipp filer her",
"it": "Trascina i file qui", "it": "Trascina i file qui",
"pt": "Solte os arquivos aqui", "pt": "Solte os arquivos aqui",
"ko-KR": "파일을 여기에 놓으세요", "ko-KR": "파일을 여기에 놓으세요",
@@ -574,7 +577,8 @@
"pt": "Espaço de trabalho", "pt": "Espaço de trabalho",
"ko-KR": "작업 공간", "ko-KR": "작업 공간",
"ar": "مساحة العمل", "ar": "مساحة العمل",
"tr": "Çalışma alanı" "tr": "Çalışma alanı",
"no": "Arbeidsområde"
}, },
"EXPLORER$EMPTY_WORKSPACE_MESSAGE": { "EXPLORER$EMPTY_WORKSPACE_MESSAGE": {
"en": "No files in workspace", "en": "No files in workspace",
@@ -587,7 +591,8 @@
"pt": "Nenhum arquivo no espaço de trabalho", "pt": "Nenhum arquivo no espaço de trabalho",
"ko-KR": "작업 공간에 파일이 없습니다", "ko-KR": "작업 공간에 파일이 없습니다",
"ar": "لا توجد ملفات في مساحة العمل", "ar": "لا توجد ملفات في مساحة العمل",
"tr": "Çalışma alanında dosya yok" "tr": "Çalışma alanında dosya yok",
"no": "Ingen filer i arbeidsområdet"
}, },
"EXPLORER$LOADING_WORKSPACE_MESSAGE": { "EXPLORER$LOADING_WORKSPACE_MESSAGE": {
"en": "Loading workspace...", "en": "Loading workspace...",
@@ -600,7 +605,8 @@
"pt": "Carregando espaço de trabalho...", "pt": "Carregando espaço de trabalho...",
"ko-KR": "작업 공간 로딩 중...", "ko-KR": "작업 공간 로딩 중...",
"ar": "جارٍ تحميل مساحة العمل...", "ar": "جارٍ تحميل مساحة العمل...",
"tr": "Çalışma alanı yükleniyor..." "tr": "Çalışma alanı yükleniyor...",
"no": "Laster arbeidsområde..."
}, },
"EXPLORER$REFRESH_ERROR_MESSAGE": { "EXPLORER$REFRESH_ERROR_MESSAGE": {
"en": "Error refreshing workspace", "en": "Error refreshing workspace",
@@ -613,7 +619,8 @@
"pt": "Erro ao atualizar o espaço de trabalho", "pt": "Erro ao atualizar o espaço de trabalho",
"ko-KR": "작업 공간 새로 고침 오류", "ko-KR": "작업 공간 새로 고침 오류",
"ar": "خطأ في تحديث مساحة العمل", "ar": "خطأ في تحديث مساحة العمل",
"tr": "Çalışma alanı yenilenirken hata oluştu" "tr": "Çalışma alanı yenilenirken hata oluştu",
"no": "Feil ved oppdatering av arbeidsområde"
}, },
"EXPLORER$UPLOAD_SUCCESS_MESSAGE": { "EXPLORER$UPLOAD_SUCCESS_MESSAGE": {
"en": "Successfully uploaded {{count}} file(s)", "en": "Successfully uploaded {{count}} file(s)",
@@ -626,7 +633,8 @@
"pt": "{{count}} arquivo(s) carregado(s) com sucesso", "pt": "{{count}} arquivo(s) carregado(s) com sucesso",
"ko-KR": "{{count}}개의 파일을 성공적으로 업로드했습니다", "ko-KR": "{{count}}개의 파일을 성공적으로 업로드했습니다",
"ar": "تم تحميل {{count}} ملف (ملفات) بنجاح", "ar": "تم تحميل {{count}} ملف (ملفات) بنجاح",
"tr": "{{count}} dosya başarıyla yüklendi" "tr": "{{count}} dosya başarıyla yüklendi",
"no": "Lastet opp {{count}} fil(er) vellykket"
}, },
"EXPLORER$NO_FILES_UPLOADED_MESSAGE": { "EXPLORER$NO_FILES_UPLOADED_MESSAGE": {
"en": "No files were uploaded", "en": "No files were uploaded",
@@ -639,7 +647,8 @@
"pt": "Nenhum arquivo foi carregado", "pt": "Nenhum arquivo foi carregado",
"ko-KR": "업로드된 파일이 없습니다", "ko-KR": "업로드된 파일이 없습니다",
"ar": "لم يتم تحميل أي ملفات", "ar": "لم يتم تحميل أي ملفات",
"tr": "Hiçbir dosya yüklenmedi" "tr": "Hiçbir dosya yüklenmedi",
"no": "Ingen filer ble lastet opp"
}, },
"EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE": { "EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE": {
"en": "{{count}} file(s) were skipped during upload", "en": "{{count}} file(s) were skipped during upload",
@@ -652,7 +661,8 @@
"pt": "{{count}} arquivo(s) foram ignorados durante o upload", "pt": "{{count}} arquivo(s) foram ignorados durante o upload",
"ko-KR": "업로드 중 {{count}}개의 파일이 건너뛰어졌습니다", "ko-KR": "업로드 중 {{count}}개의 파일이 건너뛰어졌습니다",
"ar": "تم تخطي {{count}} ملف (ملفات) أثناء التحميل", "ar": "تم تخطي {{count}} ملف (ملفات) أثناء التحميل",
"tr": "Yükleme sırasında {{count}} dosya atlandı" "tr": "Yükleme sırasında {{count}} dosya atlandı",
"no": "{{count}} fil(er) ble hoppet over under opplasting"
}, },
"EXPLORER$UPLOAD_UNEXPECTED_RESPONSE_MESSAGE": { "EXPLORER$UPLOAD_UNEXPECTED_RESPONSE_MESSAGE": {
"en": "Unexpected response structure from server", "en": "Unexpected response structure from server",
@@ -665,7 +675,8 @@
"pt": "Estrutura de resposta inesperada do servidor", "pt": "Estrutura de resposta inesperada do servidor",
"ko-KR": "서버로부터 예상치 못한 응답 구조", "ko-KR": "서버로부터 예상치 못한 응답 구조",
"ar": "بنية استجابة غير متوقعة من الخادم", "ar": "بنية استجابة غير متوقعة من الخادم",
"tr": "Sunucudan beklenmeyen yanıt yapısı" "tr": "Sunucudan beklenmeyen yanıt yapısı",
"no": "Uventet responsstruktur fra serveren"
}, },
"LOAD_SESSION$MODAL_TITLE": { "LOAD_SESSION$MODAL_TITLE": {
"en": "Return to existing session?", "en": "Return to existing session?",
@@ -799,95 +810,325 @@
}, },
"FEEDBACK$EMAIL_PLACEHOLDER": { "FEEDBACK$EMAIL_PLACEHOLDER": {
"en": "Enter your email address", "en": "Enter your email address",
"es": "Ingresa tu correo electrónico" "es": "Ingresa tu correo electrónico",
"zh-CN": "输入您的电子邮件地址",
"zh-TW": "輸入您的電子郵件地址",
"ko-KR": "이메일 주소를 입력하세요",
"no": "Skriv inn din e-postadresse",
"ar": "أدخل عنوان بريدك الإلكتروني",
"de": "Geben Sie Ihre E-Mail-Adresse ein",
"fr": "Entrez votre adresse e-mail",
"it": "Inserisci il tuo indirizzo email",
"pt": "Digite seu endereço de e-mail",
"tr": "E-posta adresinizi girin"
}, },
"FEEDBACK$PASSWORD_COPIED_MESSAGE": { "FEEDBACK$PASSWORD_COPIED_MESSAGE": {
"en": "Password copied to clipboard.", "en": "Password copied to clipboard.",
"es": "Contraseña copiada al portapapeles." "es": "Contraseña copiada al portapapeles.",
"zh-CN": "密码已复制到剪贴板。",
"zh-TW": "密碼已複製到剪貼板。",
"ko-KR": "비밀번호가 클립보드에 복사되었습니다.",
"no": "Passord kopiert til utklippstavlen.",
"ar": "تم نسخ كلمة المرور إلى الحافظة.",
"de": "Passwort in die Zwischenablage kopiert.",
"fr": "Mot de passe copié dans le presse-papiers.",
"it": "Password copiata negli appunti.",
"pt": "Senha copiada para a área de transferência.",
"tr": "Parola panoya kopyalandı."
}, },
"FEEDBACK$GO_TO_FEEDBACK": { "FEEDBACK$GO_TO_FEEDBACK": {
"en": "Go to shared feedback", "en": "Go to shared feedback",
"es": "Ir a feedback compartido" "es": "Ir a feedback compartido",
"zh-CN": "转到共享反馈",
"zh-TW": "前往共享反饋",
"ko-KR": "공유된 피드백으로 이동",
"no": "Gå til delt tilbakemelding",
"ar": "الذهاب إلى التعليقات المشتركة",
"de": "Zum geteilten Feedback gehen",
"fr": "Aller aux commentaires partagés",
"it": "Vai al feedback condiviso",
"pt": "Ir para feedback compartilhado",
"tr": "Paylaşılan geri bildirimlere git"
}, },
"FEEDBACK$PASSWORD": { "FEEDBACK$PASSWORD": {
"en": "Password:", "en": "Password:",
"es": "Contraseña:" "es": "Contraseña:",
"zh-CN": "密码:",
"zh-TW": "密碼:",
"ko-KR": "비밀번호:",
"no": "Passord:",
"ar": "كلمة المرور:",
"de": "Passwort:",
"fr": "Mot de passe :",
"it": "Password:",
"pt": "Senha:",
"tr": "Parola:"
}, },
"FEEDBACK$INVALID_EMAIL_FORMAT": { "FEEDBACK$INVALID_EMAIL_FORMAT": {
"en": "Invalid email format", "en": "Invalid email format",
"es": "Formato de correo inválido" "es": "Formato de correo inválido",
"zh-CN": "无效的电子邮件格式",
"zh-TW": "無效的電子郵件格式",
"ko-KR": "잘못된 이메일 형식",
"no": "Ugyldig e-postformat",
"ar": "تنسيق البريد الإلكتروني غير صالح",
"de": "Ungültiges E-Mail-Format",
"fr": "Format d'e-mail invalide",
"it": "Formato email non valido",
"pt": "Formato de e-mail inválido",
"tr": "Geçersiz e-posta biçimi"
}, },
"FEEDBACK$FAILED_TO_SHARE": { "FEEDBACK$FAILED_TO_SHARE": {
"en": "Failed to share, please contact the developers:", "en": "Failed to share, please contact the developers:",
"es": "Error al compartir, por favor contacta con los desarrolladores:" "es": "Error al compartir, por favor contacta con los desarrolladores:",
"zh-CN": "分享失败,请联系开发人员:",
"zh-TW": "分享失敗,請聯繫開發人員:",
"ko-KR": "공유 실패, 개발자에게 문의하세요:",
"no": "Deling mislyktes, vennligst kontakt utviklerne:",
"ar": "فشل المشاركة، يرجى الاتصال بالمطورين:",
"de": "Teilen fehlgeschlagen, bitte kontaktieren Sie die Entwickler:",
"fr": "Échec du partage, veuillez contacter les développeurs :",
"it": "Condivisione fallita, contattare gli sviluppatori:",
"pt": "Falha ao compartilhar, entre em contato com os desenvolvedores:",
"tr": "Paylaşım başarısız, lütfen geliştiricilerle iletişime geçin:"
}, },
"FEEDBACK$COPY_LABEL": { "FEEDBACK$COPY_LABEL": {
"en": "Copy", "en": "Copy",
"es": "Copiar" "es": "Copiar",
"zh-CN": "复制",
"zh-TW": "複製",
"ko-KR": "복사",
"no": "Kopier",
"ar": "نسخ",
"de": "Kopieren",
"fr": "Copier",
"it": "Copia",
"pt": "Copiar",
"tr": "Kopyala"
}, },
"FEEDBACK$SHARING_SETTINGS_LABEL": { "FEEDBACK$SHARING_SETTINGS_LABEL": {
"en": "Sharing settings", "en": "Sharing settings",
"es": "Configuración de compartir" "es": "Configuración de compartir",
"zh-CN": "共享设置",
"zh-TW": "共享設定",
"ko-KR": "공유 설정",
"no": "Delingsinnstillinger",
"ar": "إعدادات المشاركة",
"de": "Freigabeeinstellungen",
"fr": "Paramètres de partage",
"it": "Impostazioni di condivisione",
"pt": "Configurações de compartilhamento",
"tr": "Paylaşım ayarları"
}, },
"SECURITY$UNKNOWN_ANALYZER_LABEL":{ "SECURITY$UNKNOWN_ANALYZER_LABEL":{
"en": "Unknown security analyzer chosen", "en": "Unknown security analyzer chosen",
"es": "Analizador de seguridad desconocido" "es": "Analizador de seguridad desconocido",
"zh-CN": "选择了未知的安全分析器",
"zh-TW": "選擇了未知的安全分析器",
"ko-KR": "알 수 없는 보안 분석기가 선택되었습니다",
"no": "Ukjent sikkerhetsanalysator valgt",
"ar": "تم اختيار محلل أمان غير معروف",
"de": "Unbekannter Sicherheitsanalysator ausgewählt",
"fr": "Analyseur de sécurité inconnu choisi",
"it": "Analizzatore di sicurezza sconosciuto selezionato",
"pt": "Analisador de segurança desconhecido escolhido",
"tr": "Bilinmeyen güvenlik analizörü seçildi"
}, },
"INVARIANT$UPDATE_POLICY_LABEL": { "INVARIANT$UPDATE_POLICY_LABEL": {
"en": "Update Policy", "en": "Update Policy",
"es": "Actualizar política" "es": "Actualizar política",
"zh-CN": "更新策略",
"zh-TW": "更新策略",
"ko-KR": "정책 업데이트",
"no": "Oppdater policy",
"ar": "تحديث السياسة",
"de": "Richtlinie aktualisieren",
"fr": "Mettre à jour la politique",
"it": "Aggiorna policy",
"pt": "Atualizar política",
"tr": "İlkeyi güncelle"
}, },
"INVARIANT$UPDATE_SETTINGS_LABEL": { "INVARIANT$UPDATE_SETTINGS_LABEL": {
"en": "Update Settings", "en": "Update Settings",
"es": "Actualizar configuración" "es": "Actualizar configuración",
"zh-CN": "更新设置",
"zh-TW": "更新設定",
"ko-KR": "설정 업데이트",
"no": "Oppdater innstillinger",
"ar": "تحديث الإعدادات",
"de": "Einstellungen aktualisieren",
"fr": "Mettre à jour les paramètres",
"it": "Aggiorna impostazioni",
"pt": "Atualizar configurações",
"tr": "Ayarları güncelle"
}, },
"INVARIANT$SETTINGS_LABEL": { "INVARIANT$SETTINGS_LABEL": {
"en": "Settings", "en": "Settings",
"es": "Configuración" "es": "Configuración",
"zh-CN": "设置",
"zh-TW": "設定",
"ko-KR": "설정",
"no": "Innstillinger",
"ar": "الإعدادات",
"de": "Einstellungen",
"fr": "Paramètres",
"it": "Impostazioni",
"pt": "Configurações",
"tr": "Ayarlar"
}, },
"INVARIANT$ASK_CONFIRMATION_RISK_SEVERITY_LABEL": { "INVARIANT$ASK_CONFIRMATION_RISK_SEVERITY_LABEL": {
"en": "Ask for user confirmation on risk severity:", "en": "Ask for user confirmation on risk severity:",
"es": "Preguntar por confirmación del usuario sobre severidad del riesgo:" "es": "Preguntar por confirmación del usuario sobre severidad del riesgo:",
"zh-CN": "询问用户确认风险等级:",
"zh-TW": "詢問用戶確認風險等級:",
"ko-KR": "위험 심각도에 대한 사용자 확인 요청:",
"no": "Be om brukerbekreftelse på risikoalvorlighet:",
"ar": "اطلب تأكيد المستخدم على مستوى الخطورة:",
"de": "Nach Benutzerbestätigung für Risikoschweregrad fragen:",
"fr": "Demander la confirmation de l'utilisateur sur la gravité du risque :",
"it": "Chiedi conferma all'utente sulla gravità del rischio:",
"pt": "Solicitar confirmação do usuário sobre a gravidade do risco:",
"tr": "Risk şiddeti için kullanıcı onayı iste:"
}, },
"INVARIANT$DONT_ASK_FOR_CONFIRMATION_LABEL": { "INVARIANT$DONT_ASK_FOR_CONFIRMATION_LABEL": {
"en": "Don't ask for confirmation", "en": "Don't ask for confirmation",
"es": "No solicitar confirmación" "es": "No solicitar confirmación",
"zh-CN": "不要请求确认",
"zh-TW": "不要請求確認",
"ko-KR": "확인 요청하지 않음",
"no": "Ikke spør om bekreftelse",
"ar": "لا تطلب التأكيد",
"de": "Nicht nach Bestätigung fragen",
"fr": "Ne pas demander de confirmation",
"it": "Non chiedere conferma",
"pt": "Não solicitar confirmação",
"tr": "Onay isteme"
}, },
"INVARIANT$INVARIANT_ANALYZER_LABEL": { "INVARIANT$INVARIANT_ANALYZER_LABEL": {
"en": "Invariant Analyzer", "en": "Invariant Analyzer",
"es": "Analizador de invariantes" "es": "Analizador de invariantes",
"zh-CN": "不变量分析器",
"zh-TW": "不變量分析器",
"ko-KR": "불변성 분석기",
"no": "Invariant-analysator",
"ar": "محلل الثوابت",
"de": "Invarianten-Analysator",
"fr": "Analyseur d'invariants",
"it": "Analizzatore di invarianti",
"pt": "Analisador de invariantes",
"tr": "Değişmez Analizörü"
}, },
"INVARIANT$INVARIANT_ANALYZER_MESSAGE": { "INVARIANT$INVARIANT_ANALYZER_MESSAGE": {
"en": "Invariant Analyzer continuously monitors your OpenHands agent for security issues.", "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." "es": "Analizador de invariantes continuamente monitorea tu agente de OpenHands por problemas de seguridad.",
"zh-CN": "不变量分析器持续监控您的 OpenHands 代理的安全问题。",
"zh-TW": "不變量分析器持續監控您的 OpenHands 代理的安全問題。",
"ko-KR": "불변성 분석기는 OpenHands 에이전트의 보안 문제를 지속적으로 모니터링합니다.",
"no": "Invariant-analysatoren overvåker kontinuerlig OpenHands-agenten din for sikkerhetsproblemer.",
"ar": "يراقب محلل الثوابت وكيل OpenHands الخاص بك باستمرار للتحقق من المشاكل الأمنية.",
"de": "Der Invarianten-Analysator überwacht kontinuierlich Ihren OpenHands-Agenten auf Sicherheitsprobleme.",
"fr": "L'analyseur d'invariants surveille en permanence votre agent OpenHands pour détecter les problèmes de sécurité.",
"it": "L'analizzatore di invarianti monitora continuamente il tuo agente OpenHands per problemi di sicurezza.",
"pt": "O analisador de invariantes monitora continuamente seu agente OpenHands em busca de problemas de segurança.",
"tr": "Değişmez Analizörü, OpenHands ajanınızı güvenlik sorunları için sürekli olarak izler."
}, },
"INVARIANT$CLICK_TO_LEARN_MORE_LABEL": { "INVARIANT$CLICK_TO_LEARN_MORE_LABEL": {
"en": "Click to learn more", "en": "Click to learn more",
"es": "Clic para aprender más" "es": "Clic para aprender más",
"zh-CN": "点击了解更多",
"zh-TW": "點擊了解更多",
"ko-KR": "자세히 알아보기",
"no": "Klikk for å lære mer",
"ar": "انقر لمعرفة المزيد",
"de": "Klicken Sie, um mehr zu erfahren",
"fr": "Cliquez pour en savoir plus",
"it": "Clicca per saperne di più",
"pt": "Clique para saber mais",
"tr": "Daha fazla bilgi için tıklayın"
}, },
"INVARIANT$POLICY_LABEL": { "INVARIANT$POLICY_LABEL": {
"en": "Policy", "en": "Policy",
"es": "Política" "es": "Política",
"zh-CN": "策略",
"zh-TW": "策略",
"ko-KR": "정책",
"no": "Policy",
"ar": "السياسة",
"de": "Richtlinie",
"fr": "Politique",
"it": "Policy",
"pt": "Política",
"tr": "İlke"
}, },
"INVARIANT$LOG_LABEL": { "INVARIANT$LOG_LABEL": {
"en": "Logs", "en": "Logs",
"es": "Logs" "es": "Logs",
"zh-CN": "日志",
"zh-TW": "日誌",
"ko-KR": "로그",
"no": "Logger",
"ar": "السجلات",
"de": "Protokolle",
"fr": "Journaux",
"it": "Log",
"pt": "Logs",
"tr": "Günlükler"
}, },
"INVARIANT$EXPORT_TRACE_LABEL": { "INVARIANT$EXPORT_TRACE_LABEL": {
"en": "Export Trace", "en": "Export Trace",
"es": "Exportar traza" "es": "Exportar traza",
"zh-CN": "导出跟踪",
"zh-TW": "匯出追蹤",
"ko-KR": "추적 내보내기",
"no": "Eksporter sporing",
"ar": "تصدير التتبع",
"de": "Ablaufverfolgung exportieren",
"fr": "Exporter la trace",
"it": "Esporta traccia",
"pt": "Exportar rastreamento",
"tr": "İzlemeyi dışa aktar"
}, },
"INVARIANT$TRACE_EXPORTED_MESSAGE": { "INVARIANT$TRACE_EXPORTED_MESSAGE": {
"en": "Trace exported", "en": "Trace exported",
"es": "Traza exportada" "es": "Traza exportada",
"zh-CN": "跟踪已导出",
"zh-TW": "追蹤已匯出",
"ko-KR": "추적 내보내기 완료",
"no": "Sporing eksportert",
"ar": "تم تصدير التتبع",
"de": "Ablaufverfolgung exportiert",
"fr": "Trace exportée",
"it": "Traccia esportata",
"pt": "Rastreamento exportado",
"tr": "İzleme dışa aktarıldı"
}, },
"INVARIANT$POLICY_UPDATED_MESSAGE": { "INVARIANT$POLICY_UPDATED_MESSAGE": {
"en": "Policy updated", "en": "Policy updated",
"es": "Política actualizada" "es": "Política actualizada",
"zh-CN": "策略已更新",
"zh-TW": "策略已更新",
"ko-KR": "정책이 업데이트되었습니다",
"no": "Policy oppdatert",
"ar": "تم تحديث السياسة",
"de": "Richtlinie aktualisiert",
"fr": "Politique mise à jour",
"it": "Policy aggiornata",
"pt": "Política atualizada",
"tr": "İlke güncellendi"
}, },
"INVARIANT$SETTINGS_UPDATED_MESSAGE": { "INVARIANT$SETTINGS_UPDATED_MESSAGE": {
"en": "Settings updated", "en": "Settings updated",
"es": "Configuración actualizada" "es": "Configuración actualizada",
"zh-CN": "设置已更新",
"zh-TW": "設定已更新",
"ko-KR": "설정이 업데이트되었습니다",
"no": "Innstillinger oppdatert",
"ar": "تم تحديث الإعدادات",
"de": "Einstellungen aktualisiert",
"fr": "Paramètres mis à jour",
"it": "Impostazioni aggiornate",
"pt": "Configurações atualizadas",
"tr": "Ayarlar güncellendi"
}, },
"CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE": { "CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE": {
"en": "Starting up!", "en": "Starting up!",
@@ -1276,7 +1517,8 @@
"pt": "Conversa de chat", "pt": "Conversa de chat",
"es": "Conversación de chat", "es": "Conversación de chat",
"ar": "محادثة تلقيم", "ar": "محادثة تلقيم",
"fr": "Conversation de chat" "fr": "Conversation de chat",
"tr": "Sohbet Konuşması"
}, },
"CHAT_INTERFACE$UNKNOWN_SENDER": { "CHAT_INTERFACE$UNKNOWN_SENDER": {
"en": "Unknown", "en": "Unknown",
@@ -1531,10 +1773,12 @@
"tr": "Özel" "tr": "Özel"
}, },
"ERROR_MESSAGE$SHOW_DETAILS": { "ERROR_MESSAGE$SHOW_DETAILS": {
"en": "Show details" "en": "Show details",
"es": "Mostrar detalles"
}, },
"ERROR_MESSAGE$HIDE_DETAILS": { "ERROR_MESSAGE$HIDE_DETAILS": {
"en": "Hide details" "en": "Hide details",
"es": "Ocultar detalles"
}, },
"STATUS$STARTING_RUNTIME": { "STATUS$STARTING_RUNTIME": {
"en": "Starting Runtime...", "en": "Starting Runtime...",
@@ -1616,7 +1860,7 @@
}, },
"ACCOUNT_SETTINGS_MODAL$CLOSE":{ "ACCOUNT_SETTINGS_MODAL$CLOSE":{
"en": "Close", "en": "Close",
"es": "" "es": "Cerrar"
}, },
"ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID":{ "ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID":{
"en": "GitHub token is invalid. Please try again.", "en": "GitHub token is invalid. Please try again.",
@@ -1735,7 +1979,8 @@
"es":"atrás" "es":"atrás"
}, },
"STATUS$ERROR_LLM_AUTHENTICATION": { "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": { "STATUS$ERROR_RUNTIME_DISCONNECTED": {
"en": "There was an error while connecting to the runtime. Please refresh the page." "en": "There was an error while connecting to the runtime. Please refresh the page."
@@ -1745,5 +1990,17 @@
}, },
"AGENT_ERROR$ACTION_TIMEOUT": { "AGENT_ERROR$ACTION_TIMEOUT": {
"en": "Action timed out." "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"
} }
} }

Before

Width:  |  Height:  |  Size: 335 B

After

Width:  |  Height:  |  Size: 335 B

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before

Width:  |  Height:  |  Size: 662 B

After

Width:  |  Height:  |  Size: 662 B

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before

Width:  |  Height:  |  Size: 387 B

After

Width:  |  Height:  |  Size: 387 B

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before

Width:  |  Height:  |  Size: 378 B

After

Width:  |  Height:  |  Size: 378 B

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before

Width:  |  Height:  |  Size: 319 B

After

Width:  |  Height:  |  Size: 319 B

Before

Width:  |  Height:  |  Size: 552 B

After

Width:  |  Height:  |  Size: 552 B

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before

Width:  |  Height:  |  Size: 924 B

After

Width:  |  Height:  |  Size: 924 B

Before

Width:  |  Height:  |  Size: 264 B

After

Width:  |  Height:  |  Size: 264 B

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Before

Width:  |  Height:  |  Size: 649 B

After

Width:  |  Height:  |  Size: 649 B

Before

Width:  |  Height:  |  Size: 856 B

After

Width:  |  Height:  |  Size: 856 B

Before

Width:  |  Height:  |  Size: 811 B

After

Width:  |  Height:  |  Size: 811 B

@@ -1,4 +1,4 @@
import BuildIt from "#/assets/build-it.svg?react"; import BuildIt from "#/icons/build-it.svg?react";
export function HeroHeading() { export function HeroHeading() {
return ( return (
+6 -7
View File
@@ -10,6 +10,7 @@ import {
} from "@remix-run/react"; } from "@remix-run/react";
import React from "react"; import React from "react";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { SuggestionBox } from "./suggestion-box"; import { SuggestionBox } from "./suggestion-box";
import { TaskForm } from "./task-form"; import { TaskForm } from "./task-form";
import { HeroHeading } from "./hero-heading"; import { HeroHeading } from "./hero-heading";
@@ -25,9 +26,6 @@ import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
import { GitHubRepositoriesSuggestionBox } from "#/components/github-repositories-suggestion-box"; import { GitHubRepositoriesSuggestionBox } from "#/components/github-repositories-suggestion-box";
import { convertZipToBase64 } from "#/utils/convert-zip-to-base64"; import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
let cachedRepositories: ReturnType<
typeof retrieveAllGitHubUserRepositories
> | null = null;
export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => { export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
let isSaas = false; let isSaas = false;
let githubClientId: string | null = null; let githubClientId: string | null = null;
@@ -48,12 +46,9 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
let repositories: ReturnType< let repositories: ReturnType<
typeof retrieveAllGitHubUserRepositories typeof retrieveAllGitHubUserRepositories
> | null = null; > | null = null;
if (cachedRepositories) { if (ghToken) {
repositories = cachedRepositories;
} else if (ghToken) {
const data = retrieveAllGitHubUserRepositories(ghToken); const data = retrieveAllGitHubUserRepositories(ghToken);
repositories = data; repositories = data;
cachedRepositories = data;
} }
let githubAuthUrl: string | null = null; let githubAuthUrl: string | null = null;
@@ -70,6 +65,10 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
const q = formData.get("q")?.toString(); const q = formData.get("q")?.toString();
if (q) store.dispatch(setInitialQuery(q)); if (q) store.dispatch(setInitialQuery(q));
posthog.capture("initial_query_submitted", {
query_character_length: q?.length,
});
return redirect("/app"); return redirect("/app");
}; };
@@ -29,6 +29,10 @@ function CodeEditorCompoonent({
if (selectedPath && value) modifyFileContent(selectedPath, value); if (selectedPath && value) modifyFileContent(selectedPath, value);
}; };
const isBase64Image = (content: string) => content.startsWith("data:image/");
const isPDF = (content: string) => content.startsWith("data:application/pdf");
const isVideo = (content: string) => content.startsWith("data:video/");
React.useEffect(() => { React.useEffect(() => {
const handleSave = async (event: KeyboardEvent) => { const handleSave = async (event: KeyboardEvent) => {
if (selectedPath && event.metaKey && event.key === "s") { if (selectedPath && event.metaKey && event.key === "s") {
@@ -62,16 +66,40 @@ function CodeEditorCompoonent({
); );
} }
const fileContent = modifiedFiles[selectedPath] || files[selectedPath];
if (isBase64Image(fileContent)) {
return (
<section className="flex flex-col relative items-center overflow-auto h-[90%]">
<img src={fileContent} alt={selectedPath} className="object-contain" />
</section>
);
}
if (isPDF(fileContent)) {
return (
<iframe
src={fileContent}
title={selectedPath}
width="100%"
height="100%"
/>
);
}
if (isVideo(fileContent)) {
return (
<video controls src={fileContent} width="100%" height="100%">
<track kind="captions" label="English captions" />
</video>
);
}
return ( return (
<Editor <Editor
data-testid="code-editor" data-testid="code-editor"
path={selectedPath ?? undefined} path={selectedPath ?? undefined}
defaultValue="" defaultValue=""
value={ value={selectedPath ? fileContent : undefined}
selectedPath
? modifiedFiles[selectedPath] || files[selectedPath]
: undefined
}
onMount={onMount} onMount={onMount}
onChange={handleEditorChange} onChange={handleEditorChange}
options={{ readOnly: isReadOnly }} options={{ readOnly: isReadOnly }}
+17 -1
View File
@@ -1,7 +1,23 @@
import React from "react";
import JupyterEditor from "#/components/Jupyter"; import JupyterEditor from "#/components/Jupyter";
function 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; export default Jupyter;
+73 -268
View File
@@ -2,75 +2,30 @@ import { useDisclosure } from "@nextui-org/react";
import React from "react"; import React from "react";
import { import {
Outlet, Outlet,
useFetcher,
useLoaderData, useLoaderData,
json, json,
ClientActionFunctionArgs, ClientActionFunctionArgs,
useRouteLoaderData,
} from "@remix-run/react"; } from "@remix-run/react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch } from "react-redux";
import WebSocket from "ws";
import toast from "react-hot-toast";
import { getSettings } from "#/services/settings"; import { getSettings } from "#/services/settings";
import Security from "../components/modals/security/Security"; import Security from "../components/modals/security/Security";
import { Controls } from "#/components/controls"; import { Controls } from "#/components/controls";
import store, { RootState } from "#/store"; import store from "#/store";
import { Container } from "#/components/container"; import { Container } from "#/components/container";
import ActionType from "#/types/ActionType"; import { clearMessages } from "#/state/chatSlice";
import { handleAssistantMessage } from "#/services/actions";
import {
addErrorMessage,
addUserMessage,
clearMessages,
} from "#/state/chatSlice";
import { useSocket } from "#/context/socket";
import {
getGitHubTokenCommand,
getCloneRepoCommand,
} from "#/services/terminalService";
import { clearTerminal } from "#/state/commandSlice"; import { clearTerminal } from "#/state/commandSlice";
import { useEffectOnce } from "#/utils/use-effect-once"; import { useEffectOnce } from "#/utils/use-effect-once";
import CodeIcon from "#/assets/code.svg?react"; import CodeIcon from "#/icons/code.svg?react";
import GlobeIcon from "#/assets/globe.svg?react"; import GlobeIcon from "#/icons/globe.svg?react";
import ListIcon from "#/assets/list-type-number.svg?react"; import ListIcon from "#/icons/list-type-number.svg?react";
import { createChatMessage } from "#/services/chatService"; import { clearInitialQuery } from "#/state/initial-query-slice";
import {
clearFiles,
clearInitialQuery,
clearSelectedRepository,
setImportedProjectZip,
} from "#/state/initial-query-slice";
import { isGitHubErrorReponse, retrieveLatestGitHubCommit } from "#/api/github"; import { isGitHubErrorReponse, retrieveLatestGitHubCommit } from "#/api/github";
import OpenHands from "#/api/open-hands";
import AgentState from "#/types/AgentState";
import { base64ToBlob } from "#/utils/base64-to-blob";
import { clientLoader as rootClientLoader } from "#/routes/_oh";
import { clearJupyter } from "#/state/jupyterSlice"; import { clearJupyter } from "#/state/jupyterSlice";
import { FilesProvider } from "#/context/files"; import { FilesProvider } from "#/context/files";
import { ErrorObservation } from "#/types/core/observations";
import { ChatInterface } from "#/components/chat-interface"; import { ChatInterface } from "#/components/chat-interface";
import { cn } from "#/utils/utils"; import { WsClientProvider } from "#/context/ws-client-provider";
import { EventHandler } from "#/components/event-handler";
interface ServerError {
error: boolean | string;
message: string;
[key: string]: unknown;
}
const isServerError = (data: object): data is ServerError => "error" in data;
const isErrorObservation = (data: object): data is ErrorObservation =>
"observation" in data && data.observation === "error";
const isAgentStateChange = (
data: object,
): data is { extras: { agent_state: AgentState } } =>
"extras" in data &&
data.extras instanceof Object &&
"agent_state" in data.extras;
let lastCommitCached: GitHubCommit | null = null;
let repoForLastCommit: string | null = null;
export const clientLoader = async () => { export const clientLoader = async () => {
const ghToken = localStorage.getItem("ghToken"); const ghToken = localStorage.getItem("ghToken");
@@ -84,16 +39,14 @@ export const clientLoader = async () => {
if (repo) localStorage.setItem("repo", repo); if (repo) localStorage.setItem("repo", repo);
if (!lastCommitCached || repoForLastCommit !== repo) { let lastCommit: GitHubCommit | null = null;
if (ghToken && repo) { if (ghToken && repo) {
const data = await retrieveLatestGitHubCommit(ghToken, repo); const data = await retrieveLatestGitHubCommit(ghToken, repo);
if (isGitHubErrorReponse(data)) { if (isGitHubErrorReponse(data)) {
// TODO: Handle error // TODO: Handle error
console.error("Failed to retrieve latest commit", data); console.error("Failed to retrieve latest commit", data);
} else { } else {
[lastCommitCached] = data; [lastCommit] = data;
repoForLastCommit = repo;
}
} }
} }
@@ -103,7 +56,7 @@ export const clientLoader = async () => {
ghToken, ghToken,
repo, repo,
q, q,
lastCommit: lastCommitCached, lastCommit,
}); });
}; };
@@ -121,174 +74,26 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
function App() { function App() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { files, importedProjectZip } = useSelector( const { settings, token, ghToken, lastCommit } =
(state: RootState) => state.initalQuery,
);
const { start, send, setRuntimeIsInitialized, runtimeActive } = useSocket();
const { settings, token, ghToken, repo, q, lastCommit } =
useLoaderData<typeof clientLoader>(); useLoaderData<typeof clientLoader>();
const fetcher = useFetcher();
const data = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
const secrets = React.useMemo( const secrets = React.useMemo(
() => [ghToken, token].filter((secret) => secret !== null), () => [ghToken, token].filter((secret) => secret !== null),
[ghToken, token], [ghToken, token],
); );
// To avoid re-rendering the component when the user object changes, we memoize the user ID.
// We use this to ensure the github token is valid before exporting it to the terminal.
const userId = React.useMemo(() => {
if (data?.user && !isGitHubErrorReponse(data.user)) return data.user.id;
return null;
}, [data?.user]);
const Terminal = React.useMemo( const Terminal = React.useMemo(
() => React.lazy(() => import("../components/terminal/Terminal")), () => React.lazy(() => import("../components/terminal/Terminal")),
[], [],
); );
const addIntialQueryToChat = (
query: string,
base64Files: string[],
timestamp = new Date().toISOString(),
) => {
dispatch(
addUserMessage({
content: query,
imageUrls: base64Files,
timestamp,
}),
);
};
const sendInitialQuery = (query: string, base64Files: string[]) => {
const timestamp = new Date().toISOString();
send(createChatMessage(query, base64Files, timestamp));
};
const handleOpen = React.useCallback(() => {
const initEvent = {
action: ActionType.INIT,
args: settings,
};
send(JSON.stringify(initEvent));
// display query in UI, but don't send it to the server
if (q) addIntialQueryToChat(q, files);
}, [settings]);
const handleMessage = React.useCallback(
(message: MessageEvent<WebSocket.Data>) => {
// set token received from the server
const parsed = JSON.parse(message.data.toString());
if ("token" in parsed) {
fetcher.submit({ token: parsed.token }, { method: "post" });
return;
}
if (isServerError(parsed)) {
if (parsed.error_code === 401) {
toast.error("Session expired.");
fetcher.submit({}, { method: "POST", action: "/end-session" });
return;
}
if (typeof parsed.error === "string") {
toast.error(parsed.error);
} else {
toast.error(parsed.message);
}
return;
}
if (isErrorObservation(parsed)) {
dispatch(
addErrorMessage({
id: parsed.extras?.error_id,
message: parsed.message,
}),
);
return;
}
handleAssistantMessage(message.data.toString());
// handle first time connection
if (
isAgentStateChange(parsed) &&
parsed.extras.agent_state === AgentState.INIT
) {
setRuntimeIsInitialized();
// handle new session
if (!token) {
let additionalInfo = "";
if (ghToken && repo) {
send(getCloneRepoCommand(ghToken, repo));
additionalInfo = `Repository ${repo} has been cloned to /workspace. Please check the /workspace for files.`;
dispatch(clearSelectedRepository()); // reset selected repository; maybe better to move this to '/'?
}
// if there's an uploaded project zip, add it to the chat
else if (importedProjectZip) {
additionalInfo = `Files have been uploaded. Please check the /workspace for files.`;
}
if (q) {
if (additionalInfo) {
sendInitialQuery(`${q}\n\n[${additionalInfo}]`, files);
} else {
sendInitialQuery(q, files);
}
dispatch(clearFiles()); // reset selected files
}
}
}
},
[token, ghToken, repo, q, files],
);
const startSocketConnection = React.useCallback(() => {
start({
token,
onOpen: handleOpen,
onMessage: handleMessage,
});
}, [token, handleOpen, handleMessage]);
useEffectOnce(() => { useEffectOnce(() => {
// clear and restart the socket connection
dispatch(clearMessages()); dispatch(clearMessages());
dispatch(clearTerminal()); dispatch(clearTerminal());
dispatch(clearJupyter()); dispatch(clearJupyter());
dispatch(clearInitialQuery()); // Clear initial query when navigating to /app dispatch(clearInitialQuery()); // Clear initial query when navigating to /app
startSocketConnection();
}); });
React.useEffect(() => {
if (runtimeActive && userId && ghToken) {
// Export if the user valid, this could happen mid-session so it is handled here
send(getGitHubTokenCommand(ghToken));
}
}, [userId, ghToken, runtimeActive]);
React.useEffect(() => {
(async () => {
if (runtimeActive && importedProjectZip) {
// upload files action
try {
const blob = base64ToBlob(importedProjectZip);
const file = new File([blob], "imported-project.zip", {
type: blob.type,
});
await OpenHands.uploadFiles([file]);
dispatch(setImportedProjectZip(null));
} catch (error) {
toast.error("Failed to upload project files.");
}
}
})();
}, [runtimeActive, importedProjectZip]);
const { const {
isOpen: securityModalIsOpen, isOpen: securityModalIsOpen,
onOpen: onSecurityModalOpen, onOpen: onSecurityModalOpen,
@@ -296,62 +101,62 @@ function App() {
} = useDisclosure(); } = useDisclosure();
return ( return (
<div className="flex flex-col h-full gap-3"> <WsClientProvider
<div className="flex h-full overflow-auto gap-3"> enabled
<Container className="w-[390px] max-h-full relative"> token={token}
<div ghToken={ghToken}
className={cn( settings={settings}
"w-2 h-2 rounded-full border", >
"absolute left-3 top-3", <EventHandler>
runtimeActive <div className="flex flex-col h-full gap-3">
? "bg-green-800 border-green-500" <div className="flex h-full overflow-auto gap-3">
: "bg-red-800 border-red-500", <Container className="w-[390px] max-h-full relative">
)} <ChatInterface />
</Container>
<div className="flex flex-col grow gap-3">
<Container
className="h-2/3"
labels={[
{ label: "Workspace", to: "", icon: <CodeIcon /> },
{ label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
{
label: "Browser",
to: "browser",
icon: <GlobeIcon />,
isBeta: true,
},
]}
>
<FilesProvider>
<Outlet />
</FilesProvider>
</Container>
{/* Terminal uses some API that is not compatible in a server-environment. For this reason, we lazy load it to ensure
* that it loads only in the client-side. */}
<Container className="h-1/3 overflow-scroll" label="Terminal">
<React.Suspense fallback={<div className="h-full" />}>
<Terminal secrets={secrets} />
</React.Suspense>
</Container>
</div>
</div>
<div className="h-[60px]">
<Controls
setSecurityOpen={onSecurityModalOpen}
showSecurityLock={!!settings.SECURITY_ANALYZER}
lastCommitData={lastCommit}
/>
</div>
<Security
isOpen={securityModalIsOpen}
onOpenChange={onSecurityModalOpenChange}
securityAnalyzer={settings.SECURITY_ANALYZER}
/> />
<ChatInterface />
</Container>
<div className="flex flex-col grow gap-3">
<Container
className="h-2/3"
labels={[
{ label: "Workspace", to: "", icon: <CodeIcon /> },
{ label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
{
label: "Browser",
to: "browser",
icon: <GlobeIcon />,
isBeta: true,
},
]}
>
<FilesProvider>
<Outlet />
</FilesProvider>
</Container>
{/* Terminal uses some API that is not compatible in a server-environment. For this reason, we lazy load it to ensure
* that it loads only in the client-side. */}
<Container className="h-1/3 overflow-scroll" label="Terminal">
<React.Suspense fallback={<div className="h-full" />}>
<Terminal secrets={secrets} />
</React.Suspense>
</Container>
</div> </div>
</div> </EventHandler>
</WsClientProvider>
<div className="h-[60px]">
<Controls
setSecurityOpen={onSecurityModalOpen}
showSecurityLock={!!settings.SECURITY_ANALYZER}
lastCommitData={lastCommit}
/>
</div>
<Security
isOpen={securityModalIsOpen}
onOpenChange={onSecurityModalOpenChange}
securityAnalyzer={settings.SECURITY_ANALYZER}
/>
</div>
); );
} }
+20 -43
View File
@@ -21,12 +21,11 @@ import { DangerModal } from "#/components/modals/confirmation-modals/danger-moda
import { LoadingSpinner } from "#/components/modals/LoadingProject"; import { LoadingSpinner } from "#/components/modals/LoadingProject";
import { ModalBackdrop } from "#/components/modals/modal-backdrop"; import { ModalBackdrop } from "#/components/modals/modal-backdrop";
import { UserActions } from "#/components/user-actions"; import { UserActions } from "#/components/user-actions";
import { useSocket } from "#/context/socket";
import i18n from "#/i18n"; import i18n from "#/i18n";
import { getSettings, settingsAreUpToDate } from "#/services/settings"; import { getSettings, settingsAreUpToDate } from "#/services/settings";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react"; import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import NewProjectIcon from "#/assets/new-project.svg?react"; import NewProjectIcon from "#/icons/new-project.svg?react";
import DocsIcon from "#/assets/docs.svg?react"; import DocsIcon from "#/icons/docs.svg?react";
import { userIsAuthenticated } from "#/utils/user-is-authenticated"; import { userIsAuthenticated } from "#/utils/user-is-authenticated";
import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url"; import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
import { WaitlistModal } from "#/components/waitlist-modal"; import { WaitlistModal } from "#/components/waitlist-modal";
@@ -34,9 +33,6 @@ import { AnalyticsConsentFormModal } from "#/components/analytics-consent-form-m
import { setCurrentAgentState } from "#/state/agentSlice"; import { setCurrentAgentState } from "#/state/agentSlice";
import AgentState from "#/types/AgentState"; import AgentState from "#/types/AgentState";
// Cache for clientLoader results
let clientLoaderCache: any = null; // eslint-disable-line @typescript-eslint/no-explicit-any
export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => { export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
try { try {
const config = await OpenHands.getConfig(); const config = await OpenHands.getConfig();
@@ -61,28 +57,22 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
let isAuthed = false; let isAuthed = false;
let githubAuthUrl: string | null = null; let githubAuthUrl: string | null = null;
let user: GitHubUser | GitHubErrorReponse | null = null; let user: GitHubUser | GitHubErrorReponse | null = null;
if (!clientLoaderCache || clientLoaderCache.ghToken !== ghToken) { try {
try { isAuthed = await userIsAuthenticated();
isAuthed = await userIsAuthenticated(); if (!isAuthed && window.__GITHUB_CLIENT_ID__) {
if (!isAuthed && window.__GITHUB_CLIENT_ID__) { const requestUrl = new URL(request.url);
const requestUrl = new URL(request.url); githubAuthUrl = generateGitHubAuthUrl(
githubAuthUrl = generateGitHubAuthUrl( window.__GITHUB_CLIENT_ID__,
window.__GITHUB_CLIENT_ID__, requestUrl,
requestUrl, );
);
}
} catch (error) {
isAuthed = false;
githubAuthUrl = null;
} }
} catch (error) {
if (ghToken) user = await retrieveGitHubUser(ghToken); isAuthed = false;
} else { githubAuthUrl = null;
isAuthed = clientLoaderCache.isAuthed;
githubAuthUrl = clientLoaderCache.githubAuthUrl;
user = clientLoaderCache.user;
} }
if (ghToken) user = await retrieveGitHubUser(ghToken);
const settings = getSettings(); const settings = getSettings();
await i18n.changeLanguage(settings.LANGUAGE); await i18n.changeLanguage(settings.LANGUAGE);
@@ -93,7 +83,7 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
} }
// Store the results in cache // Store the results in cache
clientLoaderCache = { return defer({
token, token,
ghToken, ghToken,
isAuthed, isAuthed,
@@ -102,9 +92,7 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
settingsIsUpdated, settingsIsUpdated,
settings, settings,
analyticsConsent, analyticsConsent,
}; });
return defer(clientLoaderCache);
}; };
export function ErrorBoundary() { export function ErrorBoundary() {
@@ -146,7 +134,6 @@ type SettingsFormData = {
}; };
export default function MainApp() { export default function MainApp() {
const { stop, isConnected } = useSocket();
const navigation = useNavigation(); const navigation = useNavigation();
const location = useLocation(); const location = useLocation();
const { const {
@@ -213,14 +200,6 @@ export default function MainApp() {
} }
}, [user]); }, [user]);
React.useEffect(() => {
if (location.pathname === "/") {
// If the user is on the home page, we should stop the socket connection.
// This is relevant when the user redirects here for whatever reason.
if (isConnected) stop();
}
}, [location.pathname]);
const handleUserLogout = () => { const handleUserLogout = () => {
logoutFetcher.submit( logoutFetcher.submit(
{}, {},
@@ -324,11 +303,9 @@ export default function MainApp() {
<p className="text-xs text-[#A3A3A3]"> <p className="text-xs text-[#A3A3A3]">
To continue, connect an OpenAI, Anthropic, or other LLM account To continue, connect an OpenAI, Anthropic, or other LLM account
</p> </p>
{isConnected && ( <p className="text-xs text-danger">
<p className="text-xs text-danger"> Changing settings during an active session will end the session
Changing settings during an active session will end the session </p>
</p>
)}
<SettingsForm <SettingsForm
settings={settings} settings={settings}
models={settingsFormData.models} models={settingsFormData.models}
+2
View File
@@ -1,10 +1,12 @@
import { json } from "@remix-run/react"; import { json } from "@remix-run/react";
import posthog from "posthog-js"; import posthog from "posthog-js";
import { cache } from "#/utils/cache";
export const clientAction = () => { export const clientAction = () => {
const ghToken = localStorage.getItem("ghToken"); const ghToken = localStorage.getItem("ghToken");
if (ghToken) localStorage.removeItem("ghToken"); if (ghToken) localStorage.removeItem("ghToken");
cache.clearAll();
posthog.reset(); posthog.reset();
return json({ success: true }); return json({ success: true });
+7
View File
@@ -1,4 +1,5 @@
import { ClientActionFunctionArgs, json } from "@remix-run/react"; import { ClientActionFunctionArgs, json } from "@remix-run/react";
import posthog from "posthog-js";
import { import {
getDefaultSettings, getDefaultSettings,
LATEST_SETTINGS_VERSION, LATEST_SETTINGS_VERSION,
@@ -38,6 +39,7 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
saveSettings(getDefaultSettings()); saveSettings(getDefaultSettings());
if (requestedToEndSession(formData)) removeSessionTokenAndSelectedRepo(); if (requestedToEndSession(formData)) removeSessionTokenAndSelectedRepo();
posthog.capture("settings_reset");
return json({ success: true }); return json({ success: true });
} }
@@ -97,5 +99,10 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
} }
if (requestedToEndSession(formData)) removeSessionTokenAndSelectedRepo(); if (requestedToEndSession(formData)) removeSessionTokenAndSelectedRepo();
posthog.capture("settings_saved", {
LLM_MODEL,
LLM_API_KEY: LLM_API_KEY ? "SET" : "UNSET",
});
return json({ success: true }); return json({ success: true });
}; };
+13 -18
View File
@@ -12,8 +12,11 @@ import {
import { setCurStatusMessage } from "#/state/statusSlice"; import { setCurStatusMessage } from "#/state/statusSlice";
import store from "#/store"; import store from "#/store";
import ActionType from "#/types/ActionType"; import ActionType from "#/types/ActionType";
import { ActionMessage, StatusMessage } from "#/types/Message"; import {
import { SocketMessage } from "#/types/ResponseType"; ActionMessage,
ObservationMessage,
StatusMessage,
} from "#/types/Message";
import { handleObservationMessage } from "./observations"; import { handleObservationMessage } from "./observations";
const messageActions = { const messageActions = {
@@ -138,22 +141,14 @@ export function handleStatusMessage(message: StatusMessage) {
} }
} }
export function handleAssistantMessage(data: string | SocketMessage) { export function handleAssistantMessage(message: Record<string, unknown>) {
let socketMessage: SocketMessage; if (message.action) {
handleActionMessage(message as unknown as ActionMessage);
if (typeof data === "string") { } else if (message.observation) {
socketMessage = JSON.parse(data) as SocketMessage; handleObservationMessage(message as unknown as ObservationMessage);
} else if (message.status_update) {
handleStatusMessage(message as unknown as StatusMessage);
} else { } else {
socketMessage = data; console.error("Unknown message type", message);
}
if ("action" in socketMessage) {
handleActionMessage(socketMessage);
} else if ("observation" in socketMessage) {
handleObservationMessage(socketMessage);
} else if ("status_update" in socketMessage) {
handleStatusMessage(socketMessage);
} else {
console.error("Unknown message type", socketMessage);
} }
} }
+4 -5
View File
@@ -1,8 +1,7 @@
import ActionType from "#/types/ActionType"; import ActionType from "#/types/ActionType";
import AgentState from "#/types/AgentState"; import AgentState from "#/types/AgentState";
export const generateAgentStateChangeEvent = (state: AgentState) => export const generateAgentStateChangeEvent = (state: AgentState) => ({
JSON.stringify({ action: ActionType.CHANGE_AGENT_STATE,
action: ActionType.CHANGE_AGENT_STATE, args: { agent_state: state },
args: { agent_state: state }, });
});
+10
View File
@@ -63,6 +63,16 @@ export async function request(
} catch (e) { } catch (e) {
onFail(`Error fetching ${url}`); onFail(`Error fetching ${url}`);
} }
if (response?.status === 401 && !url.startsWith("/api/authenticate")) {
await request(
"/api/authenticate",
{
method: "POST",
},
true,
);
return request(url, options, disableToast, returnResponse, maxRetries - 1);
}
if (response?.status && response?.status >= 400) { if (response?.status && response?.status >= 400) {
onFail( onFail(
`${response.status} error while fetching ${url}: ${response?.statusText}`, `${response.status} error while fetching ${url}: ${response?.statusText}`,
+1 -1
View File
@@ -9,5 +9,5 @@ export function createChatMessage(
action: ActionType.MESSAGE, action: ActionType.MESSAGE,
args: { content: message, images_urls, timestamp }, args: { content: message, images_urls, timestamp },
}; };
return JSON.stringify(event); return event;
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import ActionType from "#/types/ActionType";
export function getTerminalCommand(command: string, hidden: boolean = false) { export function getTerminalCommand(command: string, hidden: boolean = false) {
const event = { action: ActionType.RUN, args: { command, hidden } }; const event = { action: ActionType.RUN, args: { command, hidden } };
return JSON.stringify(event); return event;
} }
export function getGitHubTokenCommand(gitHubToken: string) { export function getGitHubTokenCommand(gitHubToken: string) {
+61
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();
@@ -26,6 +26,7 @@ import { extractModelAndProvider } from "./extractModelAndProvider";
*/ */
export const organizeModelsAndProviders = (models: string[]) => { export const organizeModelsAndProviders = (models: string[]) => {
const object: Record<string, { separator: string; models: string[] }> = {}; const object: Record<string, { separator: string; models: string[] }> = {};
models.forEach((model) => { models.forEach((model) => {
const { const {
separator, separator,
@@ -45,5 +46,6 @@ export const organizeModelsAndProviders = (models: string[]) => {
} }
object[key].models.push(modelId); object[key].models.push(modelId);
}); });
return object; return object;
}; };
+10 -2
View File
@@ -1,12 +1,20 @@
import OpenHands from "#/api/open-hands"; import OpenHands from "#/api/open-hands";
import { cache } from "./cache";
export const userIsAuthenticated = async () => { export const userIsAuthenticated = async () => {
if (window.__APP_MODE__ === "oss") return true; if (window.__APP_MODE__ === "oss") return true;
const cachedData = cache.get<boolean>("user_is_authenticated");
if (cachedData) return cachedData;
let authenticated = false;
try { try {
await OpenHands.authenticate(); await OpenHands.authenticate();
return true; authenticated = true;
} catch (error) { } catch (error) {
return false; authenticated = false;
} }
cache.set("user_is_authenticated", authenticated, 3 * 60 * 1000); // cache for 3 minutes
return authenticated;
}; };
+2 -8
View File
@@ -1,10 +1,6 @@
// Here are the list of verified models and providers that we know work well with OpenHands. // Here are the list of verified models and providers that we know work well with OpenHands.
export const VERIFIED_PROVIDERS = ["openai", "azure", "anthropic"]; export const VERIFIED_PROVIDERS = ["openai", "azure", "anthropic"];
export const VERIFIED_MODELS = [ export const VERIFIED_MODELS = ["gpt-4o", "claude-3-5-sonnet-20241022"];
"gpt-4o",
"claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
];
// LiteLLM does not return OpenAI models with the provider, so we list them here to set them ourselves for consistency // LiteLLM does not return OpenAI models with the provider, so we list them here to set them ourselves for consistency
// (e.g., they return `gpt-4o` instead of `openai/gpt-4o`) // (e.g., they return `gpt-4o` instead of `openai/gpt-4o`)
@@ -23,11 +19,9 @@ export const VERIFIED_OPENAI_MODELS = [
export const VERIFIED_ANTHROPIC_MODELS = [ export const VERIFIED_ANTHROPIC_MODELS = [
"claude-2", "claude-2",
"claude-2.1", "claude-2.1",
"claude-3-5-sonnet-20241022",
"claude-3-5-sonnet-20240620", "claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
"claude-3-haiku-20240307", "claude-3-haiku-20240307",
"claude-3-opus-20240229", "claude-3-opus-20240229",
"claude-3-sonnet-20240229", "claude-3-sonnet-20240229",
"claude-instant-1",
"claude-instant-1.2",
]; ];
+2 -2
View File
@@ -6,7 +6,7 @@ import { configureStore } from "@reduxjs/toolkit";
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
import { RenderOptions, render } from "@testing-library/react"; import { RenderOptions, render } from "@testing-library/react";
import { AppStore, RootState, rootReducer } from "./src/store"; import { AppStore, RootState, rootReducer } from "./src/store";
import { SocketProvider } from "#/context/socket"; import { WsClientProvider } from "#/context/ws-client-provider";
const setupStore = (preloadedState?: Partial<RootState>): AppStore => const setupStore = (preloadedState?: Partial<RootState>): AppStore =>
configureStore({ configureStore({
@@ -35,7 +35,7 @@ export function renderWithProviders(
function Wrapper({ children }: PropsWithChildren<object>): JSX.Element { function Wrapper({ children }: PropsWithChildren<object>): JSX.Element {
return ( return (
<Provider store={store}> <Provider store={store}>
<SocketProvider>{children}</SocketProvider> <WsClientProvider enabled={true} token={null} ghToken={null} settings={null}>{children}</WsClientProvider>
</Provider> </Provider>
); );
} }
@@ -39,7 +39,6 @@ from openhands.runtime.plugins import (
JupyterRequirement, JupyterRequirement,
PluginRequirement, PluginRequirement,
) )
from openhands.utils.microagent import MicroAgent
from openhands.utils.prompt import PromptManager from openhands.utils.prompt import PromptManager
@@ -86,16 +85,6 @@ class CodeActAgent(Agent):
super().__init__(llm, config) super().__init__(llm, config)
self.reset() self.reset()
self.micro_agent = (
MicroAgent(
os.path.join(
os.path.dirname(__file__), 'micro', f'{config.micro_agent_name}.md'
)
)
if config.micro_agent_name
else None
)
self.function_calling_active = self.config.function_calling self.function_calling_active = self.config.function_calling
if self.function_calling_active and not self.llm.is_function_calling_active(): if self.function_calling_active and not self.llm.is_function_calling_active():
logger.warning( logger.warning(
@@ -105,7 +94,6 @@ class CodeActAgent(Agent):
self.function_calling_active = False self.function_calling_active = False
if self.function_calling_active: if self.function_calling_active:
# Function calling mode
self.tools = codeact_function_calling.get_tools( self.tools = codeact_function_calling.get_tools(
codeact_enable_browsing=self.config.codeact_enable_browsing, codeact_enable_browsing=self.config.codeact_enable_browsing,
codeact_enable_jupyter=self.config.codeact_enable_jupyter, codeact_enable_jupyter=self.config.codeact_enable_jupyter,
@@ -114,18 +102,17 @@ class CodeActAgent(Agent):
logger.debug( logger.debug(
f'TOOLS loaded for CodeActAgent: {json.dumps(self.tools, indent=2)}' f'TOOLS loaded for CodeActAgent: {json.dumps(self.tools, indent=2)}'
) )
self.system_prompt = codeact_function_calling.SYSTEM_PROMPT self.prompt_manager = PromptManager(
self.initial_user_message = None microagent_dir=os.path.join(os.path.dirname(__file__), 'micro'),
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'tools'),
)
else: else:
# Non-function-calling mode
self.action_parser = CodeActResponseParser() self.action_parser = CodeActResponseParser()
self.prompt_manager = PromptManager( self.prompt_manager = PromptManager(
prompt_dir=os.path.join(os.path.dirname(__file__)), microagent_dir=os.path.join(os.path.dirname(__file__), 'micro'),
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'default'),
agent_skills_docs=AgentSkillsRequirement.documentation, agent_skills_docs=AgentSkillsRequirement.documentation,
micro_agent=self.micro_agent,
) )
self.system_prompt = self.prompt_manager.system_message
self.initial_user_message = self.prompt_manager.initial_user_message
self.pending_actions: deque[Action] = deque() self.pending_actions: deque[Action] = deque()
@@ -337,8 +324,8 @@ class CodeActAgent(Agent):
return self.pending_actions.popleft() return self.pending_actions.popleft()
# if we're done, go back # if we're done, go back
last_user_message = state.get_last_user_message() latest_user_message = state.get_last_user_message()
if last_user_message and last_user_message.strip() == '/exit': if latest_user_message and latest_user_message.content.strip() == '/exit':
return AgentFinishAction() return AgentFinishAction()
# prepare what we want to send to the LLM # prepare what we want to send to the LLM
@@ -403,17 +390,19 @@ class CodeActAgent(Agent):
role='system', role='system',
content=[ content=[
TextContent( TextContent(
text=self.system_prompt, text=self.prompt_manager.get_system_message(),
cache_prompt=self.llm.is_caching_prompt_active(), # Cache system prompt cache_prompt=self.llm.is_caching_prompt_active(),
) )
], ],
) )
] ]
if self.initial_user_message: example_message = self.prompt_manager.get_example_user_message()
if example_message:
messages.append( messages.append(
Message( Message(
role='user', role='user',
content=[TextContent(text=self.initial_user_message)], content=[TextContent(text=example_message)],
cache_prompt=self.llm.is_caching_prompt_active(),
) )
) )
@@ -462,8 +451,9 @@ class CodeActAgent(Agent):
pending_tool_call_action_messages.pop(response_id) pending_tool_call_action_messages.pop(response_id)
for message in messages_to_add: for message in messages_to_add:
# add regular message
if message: if message:
if message.role == 'user':
self.prompt_manager.enhance_message(message)
# handle error if the message is the SAME role as the previous message # handle error if the message is the SAME role as the previous message
# litellm.exceptions.BadRequestError: litellm.BadRequestError: OpenAIException - Error code: 400 - {'detail': 'Only supports u/a/u/a/u...'} # litellm.exceptions.BadRequestError: litellm.BadRequestError: OpenAIException - Error code: 400 - {'detail': 'Only supports u/a/u/a/u...'}
# there shouldn't be two consecutive messages from the same role # there shouldn't be two consecutive messages from the same role
@@ -493,23 +483,6 @@ class CodeActAgent(Agent):
break break
if not self.function_calling_active: if not self.function_calling_active:
# The latest user message is important: self.prompt_manager.add_turns_left_reminder(messages, state)
# we want to remind the agent of the environment constraints
latest_user_message = next(
islice(
(
m
for m in reversed(messages)
if m.role == 'user'
and any(isinstance(c, TextContent) for c in m.content)
),
1,
),
None,
)
# do not add this for function calling
if latest_user_message:
reminder_text = f'\n\nENVIRONMENT REMINDER: You have {state.max_iterations - state.iteration} turns left to complete the task. When finished reply with <finish></finish>.'
latest_user_message.content.append(TextContent(text=reminder_text))
return messages return messages
@@ -25,13 +25,6 @@ from openhands.events.action import (
) )
from openhands.events.tool import ToolCallMetadata from openhands.events.tool import ToolCallMetadata
SYSTEM_PROMPT = """You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
<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.
</IMPORTANT>
"""
_BASH_DESCRIPTION = """Execute a bash command in the terminal. _BASH_DESCRIPTION = """Execute a bash command in the terminal.
* Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`. * Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`.
* Interactive: If a bash command returns exit code `-1`, this means the process is not yet finished. The assistant must then send a second call to terminal with an empty `command` (which will retrieve any additional logs), or it can send additional text (set `command` to the text) to STDIN of the running process, or it can send command=`ctrl+c` to interrupt the process. * Interactive: If a bash command returns exit code `-1`, this means the process is not yet finished. The assistant must then send a second call to terminal with an empty `command` (which will retrieve any additional logs), or it can send additional text (set `command` to the text) to STDIN of the running process, or it can send command=`ctrl+c` to interrupt the process.
@@ -284,6 +277,17 @@ _browser_action_space = HighLevelActionSet(
_BROWSER_DESCRIPTION = """Interact with the browser using Python code. _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. The following 15 functions are available. Nothing else is supported.
goto(url: str) goto(url: str)
@@ -385,20 +389,15 @@ upload_file(bid: str, file: str | list[str])
upload_file('572', '/home/user/my_receipt.pdf') upload_file('572', '/home/user/my_receipt.pdf')
upload_file('63', ['/home/bob/Documents/image.jpg', '/home/bob/Documents/file.zip']) 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(): for _, action in _browser_action_space.action_set.items():
assert ( 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}' ), f'Browser description mismatch. Please double check if the BrowserGym updated their action space.\n\nAction: {action.signature}'
assert ( 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}' ), f'Browser description mismatch. Please double check if the BrowserGym updated their action space.\n\nAction: {action.description}'
BrowserTool = ChatCompletionToolParam( BrowserTool = ChatCompletionToolParam(
@@ -411,7 +410,10 @@ BrowserTool = ChatCompletionToolParam(
'properties': { 'properties': {
'code': { 'code': {
'type': 'string', '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'], 'required': ['code'],
@@ -0,0 +1,9 @@
---
name: flarglebargle
agent: CodeActAgent
triggers:
- flarglebargle
---
IMPORTANT! The user has said the magic word "flarglebargle". You must
only respond with a message telling them how smart they are
@@ -1,69 +1,31 @@
--- ---
name: github name: github
agent: CodeActAgent agent: CodeActAgent
require_env_var: triggers:
SANDBOX_ENV_GITHUB_TOKEN: "Create a GitHub Personal Access Token (https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) and set it as SANDBOX_GITHUB_TOKEN in your environment variables." - github
- git
--- ---
# How to Interact with Github You have access to an environment variable, `GITHUB_TOKEN`, which allows you to interact with
the GitHub API.
## Environment Variable Available You can use `curl` with the `GITHUB_TOKEN` to interact with GitHub's API.
ALWAYS use the GitHub API for operations instead of a web browser.
- `GITHUB_TOKEN`: A read-only token for Github. Here are some instructions for pushing, but ONLY do this if the user asks you to:
* NEVER push directly to the `main` or `master` branch
## Using GitHub's RESTful API * Git config (username and email) is pre-set. Do not modify.
* You may already be on a branch called `openhands-workspace`. Create a new branch with a better name before pushing.
Use `curl` with the `GITHUB_TOKEN` to interact with GitHub's API. Here are some common operations: * Use the GitHub API to create a pull request, if you haven't already
* Use the main branch as the base branch, unless the user requests otherwise
Here's a template for API calls: * After opening or updating a pull request, send the user a short message with a link to the pull request.
* Do all of the above in as few steps as possible. E.g. you could open a PR with one step by running the following bash commands:
```sh ```bash
curl -H "Authorization: token $GITHUB_TOKEN" \ git checkout -b create-widget
"https://api.github.com/{endpoint}" git add .
git commit -m "Create widget"
git push origin create-widget
curl -X POST "https://api.github.com/repos/CodeActOrg/openhands/pulls" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-d '{"title":"Create widget","head":"create-widget","base":"openhands-workspace"}'
``` ```
First replace `{endpoint}` with the specific API path. Common operations:
1. View an issue or pull request:
- Issues: `/repos/{owner}/{repo}/issues/{issue_number}`
- Pull requests: `/repos/{owner}/{repo}/pulls/{pull_request_number}`
2. List repository issues or pull requests:
- Issues: `/repos/{owner}/{repo}/issues`
- Pull requests: `/repos/{owner}/{repo}/pulls`
3. Search issues or pull requests:
- `/search/issues?q=repo:{owner}/{repo}+is:{type}+{search_term}+state:{state}`
- Replace `{type}` with `issue` or `pr`
4. List repository branches:
`/repos/{owner}/{repo}/branches`
5. Get commit details:
`/repos/{owner}/{repo}/commits/{commit_sha}`
6. Get repository details:
`/repos/{owner}/{repo}`
7. Get user information:
`/user`
8. Search repositories:
`/search/repositories?q={query}`
9. Get rate limit status:
`/rate_limit`
Replace `{owner}`, `{repo}`, `{commit_sha}`, `{issue_number}`, `{pull_request_number}`,
`{search_term}`, `{state}`, and `{query}` with appropriate values.
## Important Notes
1. Always use the GitHub API for operations instead of a web browser.
2. The `GITHUB_TOKEN` is read-only. Avoid operations that require write access.
3. Git config (username and email) is pre-set. Do not modify.
4. Edit and test code locally. Never push directly to remote.
5. Verify correct branch before committing.
6. Commit changes frequently.
7. If the issue or task is ambiguous or lacks sufficient detail, always request clarification from the user before proceeding.
8. You should avoid using command line tools like `sed` for file editing.
@@ -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 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 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 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 %} {% endset %}
{# Combine all parts without newlines between them #} {# Combine all parts without newlines between them #}
@@ -215,12 +215,5 @@ The server is running on port 5000 with PID 126. You can access the list of numb
{% endset %} {% endset %}
Here is an example of how you can interact with the environment for task solving: Here is an example of how you can interact with the environment for task solving:
{{ DEFAULT_EXAMPLE }} {{ DEFAULT_EXAMPLE }}
{% if micro_agent %}
--- BEGIN OF GUIDELINE ---
The following information may assist you in completing your task:
{{ micro_agent }}
--- END OF GUIDELINE ---
{% endif %}
NOW, LET'S START! NOW, LET'S START!
@@ -0,0 +1,7 @@
You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
<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>
@@ -155,7 +155,7 @@ class CodeActSWEAgent(Agent):
""" """
# if we're done, go back # if we're done, go back
last_user_message = state.get_last_user_message() last_user_message = state.get_last_user_message()
if last_user_message and last_user_message.strip() == '/exit': if last_user_message and last_user_message.content.strip() == '/exit':
return AgentFinishAction() return AgentFinishAction()
# prepare what we want to send to the LLM # prepare what we want to send to the LLM
+16 -5
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import copy import copy
import os
import traceback import traceback
from typing import Callable, ClassVar, Type from typing import Callable, ClassVar, Type
@@ -259,7 +260,11 @@ class AgentController:
observation_to_print.content = truncate_content( observation_to_print.content = truncate_content(
observation_to_print.content, self.agent.llm.config.max_message_chars observation_to_print.content, self.agent.llm.config.max_message_chars
) )
self.log('debug', str(observation_to_print), extra={'msg_type': 'OBSERVATION'}) # Use info level if LOG_ALL_EVENTS is set
log_level = 'info' if os.getenv('LOG_ALL_EVENTS') in ('true', '1') else 'debug'
self.log(
log_level, str(observation_to_print), extra={'msg_type': 'OBSERVATION'}
)
if observation.llm_metrics is not None: if observation.llm_metrics is not None:
self.agent.llm.metrics.merge(observation.llm_metrics) self.agent.llm.metrics.merge(observation.llm_metrics)
@@ -282,8 +287,12 @@ class AgentController:
action (MessageAction): The message action to handle. action (MessageAction): The message action to handle.
""" """
if action.source == EventSource.USER: if action.source == EventSource.USER:
# Use info level if LOG_ALL_EVENTS is set
log_level = (
'info' if os.getenv('LOG_ALL_EVENTS') in ('true', '1') else 'debug'
)
self.log( self.log(
'debug', log_level,
str(action), str(action),
extra={'msg_type': 'ACTION', 'event_source': EventSource.USER}, extra={'msg_type': 'ACTION', 'event_source': EventSource.USER},
) )
@@ -497,7 +506,9 @@ class AgentController:
await self.update_state_after_step() await self.update_state_after_step()
self.log('debug', str(action), extra={'msg_type': 'ACTION'}) # Use info level if LOG_ALL_EVENTS is set
log_level = 'info' if os.getenv('LOG_ALL_EVENTS') in ('true', '1') else 'debug'
self.log(log_level, str(action), extra={'msg_type': 'ACTION'})
async def _delegate_step(self): async def _delegate_step(self):
"""Executes a single step of the delegate agent.""" """Executes a single step of the delegate agent."""
@@ -663,7 +674,7 @@ class AgentController:
# sanity check # sanity check
if start_id > end_id + 1: if start_id > end_id + 1:
self.log( self.log(
'debug', 'warning',
f'start_id {start_id} is greater than end_id + 1 ({end_id + 1}). History will be empty.', f'start_id {start_id} is greater than end_id + 1 ({end_id + 1}). History will be empty.',
) )
self.state.history = [] self.state.history = []
@@ -694,7 +705,7 @@ class AgentController:
# Match with most recent unmatched delegate action # Match with most recent unmatched delegate action
if not delegate_action_ids: if not delegate_action_ids:
self.log( self.log(
'error', 'warning',
f'Found AgentDelegateObservation without matching action at id={event.id}', f'Found AgentDelegateObservation without matching action at id={event.id}',
) )
continue continue
+4 -4
View File
@@ -156,14 +156,14 @@ class State:
return last_user_message, last_user_message_image_urls return last_user_message, last_user_message_image_urls
def get_last_agent_message(self) -> str | None: def get_last_agent_message(self) -> MessageAction | None:
for event in reversed(self.history): for event in reversed(self.history):
if isinstance(event, MessageAction) and event.source == EventSource.AGENT: if isinstance(event, MessageAction) and event.source == EventSource.AGENT:
return event.content return event
return None return None
def get_last_user_message(self) -> str | None: def get_last_user_message(self) -> MessageAction | None:
for event in reversed(self.history): for event in reversed(self.history):
if isinstance(event, MessageAction) and event.source == EventSource.USER: if isinstance(event, MessageAction) and event.source == EventSource.USER:
return event.content return event
return None return None
+2
View File
@@ -69,6 +69,7 @@ class AppConfig:
file_uploads_max_file_size_mb: int = 0 file_uploads_max_file_size_mb: int = 0
file_uploads_restrict_file_types: bool = False file_uploads_restrict_file_types: bool = False
file_uploads_allowed_extensions: list[str] = field(default_factory=lambda: ['.*']) file_uploads_allowed_extensions: list[str] = field(default_factory=lambda: ['.*'])
runloop_api_key: str | None = None
defaults_dict: ClassVar[dict] = {} defaults_dict: ClassVar[dict] = {}
@@ -139,6 +140,7 @@ class AppConfig:
'jwt_secret', 'jwt_secret',
'modal_api_token_id', 'modal_api_token_id',
'modal_api_token_secret', 'modal_api_token_secret',
'runloop_api_key',
]: ]:
attr_value = '******' if attr_value else None attr_value = '******' if attr_value else None
+3 -1
View File
@@ -14,7 +14,8 @@ class SandboxConfig:
base_container_image: The base container image from which to build the runtime image. base_container_image: The base container image from which to build the runtime image.
runtime_container_image: The runtime container image to use. runtime_container_image: The runtime container image to use.
user_id: The user ID for the sandbox. user_id: The user ID for the sandbox.
timeout: The timeout for the sandbox. timeout: The timeout for the default sandbox action execution.
remote_runtime_init_timeout: The timeout for the remote runtime to start.
enable_auto_lint: Whether to enable auto-lint. enable_auto_lint: Whether to enable auto-lint.
use_host_network: Whether to use the host network. use_host_network: Whether to use the host network.
initialize_plugins: Whether to initialize plugins. initialize_plugins: Whether to initialize plugins.
@@ -41,6 +42,7 @@ class SandboxConfig:
runtime_container_image: str | None = None runtime_container_image: str | None = None
user_id: int = os.getuid() if hasattr(os, 'getuid') else 1000 user_id: int = os.getuid() if hasattr(os, 'getuid') else 1000
timeout: int = 120 timeout: int = 120
remote_runtime_init_timeout: int = 180
enable_auto_lint: bool = ( enable_auto_lint: bool = (
False # once enabled, OpenHands would lint files after editing False # once enabled, OpenHands would lint files after editing
) )
+2 -2
View File
@@ -177,7 +177,7 @@ class SensitiveDataFilter(logging.Filter):
return True return True
def get_console_handler(log_level=logging.INFO, extra_info: str | None = None): def get_console_handler(log_level: int = logging.INFO, extra_info: str | None = None):
"""Returns a console handler for logging.""" """Returns a console handler for logging."""
console_handler = logging.StreamHandler() console_handler = logging.StreamHandler()
console_handler.setLevel(log_level) console_handler.setLevel(log_level)
@@ -188,7 +188,7 @@ def get_console_handler(log_level=logging.INFO, extra_info: str | None = None):
return console_handler return console_handler
def get_file_handler(log_dir, log_level=logging.INFO): def get_file_handler(log_dir: str, log_level: int = logging.INFO):
"""Returns a file handler for logging.""" """Returns a file handler for logging."""
os.makedirs(log_dir, exist_ok=True) os.makedirs(log_dir, exist_ok=True)
timestamp = datetime.now().strftime('%Y-%m-%d') timestamp = datetime.now().strftime('%Y-%m-%d')

Some files were not shown because too many files have changed in this diff Show More