Compare commits

...

80 Commits

Author SHA1 Message Date
Xingyao Wang
d88ebc971f override browsergym playwright import 2025-05-15 17:02:07 +00:00
Xingyao Wang
46e39739c4 update lock 2025-05-15 16:46:12 +00:00
Xingyao Wang
eda353e0d7 Merge commit '04d585513c8952efbe56aaefbf11754846320360' into feature/patchright-integration 2025-05-15 16:45:35 +00:00
sp.wack
04d585513c feat: secrets manager settings (#8068)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: rohitvinodmalhotra@gmail.com <rohitvinodmalhotra@gmail.com>
2025-05-15 11:30:10 -04:00
Robert Brennan
7a4ea23b9d Revert "Add Docker, Java, Golang, and other programming languages to runtime image" (#8518) 2025-05-15 14:29:15 +00:00
sp.wack
7490c1927f fix(frontend): Failing tests (#8519) 2025-05-15 14:20:52 +00:00
tofarr
8d2ac59909 Fix passing environment (#8483)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-15 07:23:21 -06:00
Ryan H. Tran
68e5f485aa fix: validation error when saving SSE MCP server url on the UI (#8502) 2025-05-15 06:35:06 +00:00
Xingyao Wang
c1e3ae6dac try to simplify 2025-05-15 06:09:22 +00:00
Xingyao Wang
e95831ec09 change to patchright_chromium 2025-05-15 05:48:13 +00:00
Xingyao Wang
04c7f31498 move unit test 2025-05-15 05:47:48 +00:00
Graham Neubig
e4c284f96d Add timeout parameter to bash tool for hard timeout control (#8106)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-05-15 13:24:42 +08:00
Yueqi Song
3ca585b79f Update run_infer.py to incorporate selection of task based on repo (#8509) 2025-05-15 12:27:28 +08:00
tofarr
7e88d4195f Refactor event store cleanup (#8505)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-14 16:20:43 -06:00
Engel Nyst
f046efd53d Revert "Make str_replace_editor description more clear (#8434)" (#8501) 2025-05-15 04:08:16 +08:00
Xingyao Wang
e5f81a283a Merge commit '74f8d68ac57b2c9df7aa8bd9009b51eefac69201' into feature/patchright-integration 2025-05-14 19:53:55 +00:00
openhands
74f8d68ac5 Regenerate poetry.lock file after merging from main 2025-05-14 19:52:40 +00:00
Xingyao Wang
a2e028d707 remove patchright readme 2025-05-14 19:52:14 +00:00
openhands
8eb2281f21 Merge main into feature/patchright-integration 2025-05-14 19:51:46 +00:00
Engel Nyst
e4586432ad Add top_k (#8480) 2025-05-14 21:46:03 +02:00
sp.wack
d956abe56b fix(frontend): Show actions when idle (#8507) 2025-05-14 18:53:32 +00:00
Robert Brennan
6145552841 Add Docker, Java, Golang, and other programming languages to runtime image (#8026)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-14 13:46:06 -04:00
dependabot[bot]
b1dca48c8e chore(deps): bump the version-all group with 6 updates (#8504)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-05-14 16:39:01 +00:00
Robert Brennan
81ba80dde0 Fix issue #8327: Unable to delete the entire default branch name in input box (#8329)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-14 12:07:12 -04:00
mamoodi
08a790c4ca Update default model to sonnet 3.7 in all applicable places (#8489)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-14 10:55:34 -04:00
Robert Brennan
1b57fd4d1e Remove github_user_id in favor of user_id (#8406)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-14 10:17:35 -04:00
Robert Brennan
c36cbf6543 Fix padding on last paragraph in messages (#8491)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-05-14 10:24:21 +00:00
openhands
1c3052702e Regenerate poetry.lock file after merging from main 2025-05-14 10:19:51 +00:00
openhands
ca4051f585 Merge main into feature/patchright-integration 2025-05-14 10:19:00 +00:00
openhands
11c7a39c12 Remove poetry.lock for regeneration 2025-05-14 10:18:48 +00:00
Xingyao Wang
67d438ea4f Fix workspace mount behavior with SANDBOX_VOLUMES (#8500) 2025-05-14 14:58:03 +08:00
Graham Neubig
154eed148f Fix typing in server directory (#8375)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-05-13 21:27:59 +00:00
Graham Neubig
f9b0fcd76e Add API documentation link to API Keys tab in settings (#8363)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-13 20:09:58 +00:00
mamoodi
0782aeb1c4 Update recommended models (#8488)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-05-13 15:46:22 -04:00
dependabot[bot]
55fbb65e05 chore(deps): bump the version-all group across 1 directory with 12 updates (#8478)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-13 21:20:50 +02:00
mamoodi
1abed30b44 Update MCP docs and LLM docs with more accurate information (#8482) 2025-05-13 13:09:25 -04:00
tofarr
1f29ec836b Fix missing translations in frontend i18n files (#8481)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-13 17:02:06 +00:00
openhands
0ec75bc0d0 Regenerate poetry.lock file to fix compatibility issues with patchright integration 2025-05-13 16:51:37 +00:00
openhands
9ca9cb8f3a Merge main into feature/patchright-integration 2025-05-13 16:33:57 +00:00
Rohit Malhotra
81c754ec65 [Fix]: Don't allow endpoint to modify conversation trigger (#8396) 2025-05-13 11:57:32 -04:00
Rohit Malhotra
880ec57c78 [Fix]: Status icon regression (#8427) 2025-05-13 11:43:35 -04:00
mamoodi
e06aac7521 Remove unnecessary frontmatter from repo microagent (#8477) 2025-05-13 11:32:35 -04:00
Graham Neubig
60d9b519e0 Add proper typing to cli directory (#8374)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-13 09:55:44 -04:00
Graham Neubig
5ad11e73b8 Proposed update to resolver prompt (#8473) 2025-05-13 13:48:23 +00:00
Graham Neubig
3e5b16b348 Fix translation completeness issues (#8472)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-13 09:42:33 -04:00
Graham Neubig
f3d0ae3fbf Add type annotations to local runtime implementation (#8376)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-13 09:42:07 -04:00
Engel Nyst
dea3ddfcc6 Clean up codecov (#8465) 2025-05-13 14:51:47 +02:00
kotauchisunsun
31b2f3c9c2 [refactor]: Refactor sandbox configuration setup in IssueResolver class (#8414) 2025-05-13 13:37:15 +02:00
openhands
a95e43fc03 Fix patchright integration tests and improve documentation 2025-05-13 11:30:02 +00:00
openhands
c5f9910dc2 Fix test_patchright_import by importing playwright.sync_api after use_patchright() 2025-05-13 11:17:34 +00:00
openhands
ca5df82804 Fix linting and test issues in patchright integration 2025-05-13 11:12:18 +00:00
openhands
a4b8d08b2f Fix linting and test issues in patchright integration 2025-05-13 11:08:05 +00:00
omahs
4bb6ec2ee5 Fix typos (#8469) 2025-05-13 09:34:21 +00:00
Engel Nyst
ae8ed49280 Make str_replace_editor description more clear (#8434) 2025-05-13 13:08:53 +07:00
mamoodi
786e21fb8a Add more run eval options (#8468) 2025-05-13 02:33:14 +00:00
Graham Neubig
f317c03b1b Fix inconsistent max_iterations in SWE-bench evaluation (#8467)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-13 02:07:57 +00:00
Chase
e72153629d fix #8424: change native_tool_calling semantics (#8463) 2025-05-12 19:21:51 -04:00
mamoodi
b127d5f656 Add exc_info to remote runtime log (#8457) 2025-05-12 15:45:58 -05:00
tofarr
f75fa50b80 Add number of connections to Conversation Info (#8456)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-12 13:57:52 -06:00
mamoodi
5a927c8651 Release 0.38.0 (#8446) 2025-05-12 15:41:14 -04:00
chuckbutkus
2693360ad0 Auth URL fix of on-prem (#8455) 2025-05-12 17:28:04 +00:00
sp.wack
1081f8091d improve(frontend): Changes tab status message logic (#8454) 2025-05-12 12:57:35 -04:00
sp.wack
8d0e5c6c34 hotfix: Don't handle git changes side effect too frequently (#8451) 2025-05-12 12:57:00 -04:00
Robert Brennan
0b897ff3dc Add 10px bottom padding to paragraph tags in markdown rendering (#8440)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-12 11:03:17 -04:00
Ryan H. Tran
c5ace563c4 fix: remove duplicate rendering of tab components (#8442)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-05-12 11:15:41 +00:00
Engel Nyst
9af132933c Fix log for clearing pending action to be at the same level (#8430) 2025-05-12 02:50:36 +02:00
Engel Nyst
10c56932af Fix: update pre-commit docs (#8433)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-12 02:13:33 +02:00
Robert Brennan
e9905115c4 Allow websocket connection to pass in Authorization header to conversation validator (#8405)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-11 16:27:02 -04:00
Emmanuel Ferdman
6b11fff735 Resolve warnings of logger library (#8432)
Signed-off-by: Emmanuel Ferdman <emmanuelferdman@gmail.com>
2025-05-11 15:29:53 +00:00
Xingyao Wang
3d02c0c3a3 Fix issue #8372: Implement browser screenshot saving functionality (#8383)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-05-11 15:51:18 +08:00
kotauchisunsun
a17c57d82e [refactor]: Refactored the initialization of issue_handler within IssueResolver (#8417)
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-05-10 21:27:06 -04:00
Muly Oved
da637a0dad Update docker_runtime.py #8422 (#8423) 2025-05-11 00:08:14 +02:00
Polly
27c49471a8 Fix Bug #8425 - Enable prompt cache for OpenRouter model of calude-3.7-sonnet (#8426) 2025-05-11 00:07:31 +02:00
Robert Brennan
bffe8de597 Add support for user/org level microagents (#8402)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-10 09:34:34 -04:00
OpenHands
f0bb7de1c6 Fix issue #8145: Add docs about runtime tests (#8146) 2025-05-10 12:40:35 +02:00
Polly
90aab29bc0 Fix Issue #8413 max_output_tokens in openrouter/anthropic/claude-3.7-sonnet doesn't work correctly (#8415) 2025-05-10 08:29:39 +00:00
sp.wack
ade059bfba feat/fix(fontend): Get public repos via repo URL (#8223)
Co-authored-by: Robert Brennan <accounts@rbren.io>
Co-authored-by: rohitvinodmalhotra@gmail.com <rohitvinodmalhotra@gmail.com>
2025-05-09 23:45:33 +00:00
openhands
72a13cc42d Add patchright to pyproject.toml and update Makefile to use it 2025-05-09 14:22:06 +00:00
openhands
728f8e239c Update Makefile to use patchright instead of playwright for Chromium installation 2025-05-09 14:20:09 +00:00
openhands
24c93478ac Add utility to use patchright as a drop-in replacement for playwright 2025-05-09 14:17:12 +00:00
184 changed files with 6816 additions and 3673 deletions

19
.github/.codecov.yml vendored
View File

@@ -1,19 +0,0 @@
codecov:
notify:
wait_for_ci: true
# our project is large, so 6 builds are typically uploaded. this waits till 5/6
# See https://docs.codecov.com/docs/notifications#section-preventing-notifications-until-after-n-builds
after_n_builds: 5
coverage:
status:
patch:
default:
threshold: 100% # allow patch coverage to be lower than project coverage by any amount
project:
default:
threshold: 5% # allow project coverage to drop at most 5%
comment: false
github_checks:
annotations: false

View File

@@ -42,7 +42,3 @@ jobs:
- name: Run tests and collect coverage
working-directory: ./frontend
run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -312,11 +312,7 @@ jobs:
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=false \
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 --cov=openhands --cov-report=xml -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
# Run unit tests with the Docker runtime Docker images as openhands user
test_runtime_oh:
@@ -381,11 +377,7 @@ jobs:
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=true \
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 --cov=openhands --cov-report=xml -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
# The two following jobs (named identically) are to check whether all the runtime tests have passed as the
# "All Runtime Tests Passed" is a required job for PRs to merge

View File

@@ -48,11 +48,7 @@ jobs:
- name: Build Environment
run: make build
- name: Run Tests
run: poetry run pytest --forked -n auto --cov=openhands --cov-report=xml -svv ./tests/unit
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: poetry run pytest --forked -n auto -svv ./tests/unit
# Run specific Windows python tests
test-on-windows:

View File

@@ -9,7 +9,7 @@ on:
jobs:
trigger-job:
name: Trigger remote eval job
if: ${{ github.event.label.name == 'run-eval-xs' || github.event.label.name == 'run-eval-s' || github.event.label.name == 'run-eval-m' }}
if: ${{ github.event.label.name == 'run-eval-1' || github.event.label.name == 'run-eval-2' || github.event.label.name == 'run-eval-50' || github.event.label.name == 'run-eval-100' }}
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
@@ -26,12 +26,14 @@ jobs:
echo "Repository URL: $REPO_URL"
echo "PR Branch: $PR_BRANCH"
if [[ "${{ github.event.label.name }}" == "run-eval-xs" ]]; then
if [[ "${{ github.event.label.name }}" == "run-eval-1" ]]; then
EVAL_INSTANCES="1"
elif [[ "${{ github.event.label.name }}" == "run-eval-s" ]]; then
EVAL_INSTANCES="5"
elif [[ "${{ github.event.label.name }}" == "run-eval-m" ]]; then
EVAL_INSTANCES="30"
elif [[ "${{ github.event.label.name }}" == "run-eval-2" ]]; then
EVAL_INSTANCES="2"
elif [[ "${{ github.event.label.name }}" == "run-eval-50" ]]; then
EVAL_INSTANCES="50"
elif [[ "${{ github.event.label.name }}" == "run-eval-100" ]]; then
EVAL_INSTANCES="100"
fi
curl -X POST \

View File

@@ -1,8 +1,3 @@
---
name: repo
type: repo
agent: CodeActAgent
---
This repository contains the code for OpenHands, an automated AI software engineer. It has a Python backend
(in the `openhands` directory) and React frontend (in the `frontend` directory).
@@ -14,7 +9,7 @@ IMPORTANT: Before making any changes to the codebase, ALWAYS run `make install-p
Before pushing any changes, you MUST ensure that any lint errors or simple test errors have been fixed.
* If you've made changes to the backend, you should run `pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml`
* If you've made changes to the backend, you should run `pre-commit run --config ./dev_config/python/.pre-commit-config.yaml` (this will run on staged files).
* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
The pre-commit hooks MUST pass successfully before pushing any changes to the repository. This is a mandatory requirement to maintain code quality and consistency.

View File

@@ -1,8 +1,8 @@
# Development Guide
This guide is for people working on OpenHands and editing the source code.
If you wish to contribute your changes, check out the [CONTRIBUTING.md](https://github.com/All-Hands-AI/OpenHands/blob/main/CONTRIBUTING.md) on how to clone and setup the project initially before moving on.
Otherwise, you can clone the OpenHands project directly.
If you wish to contribute your changes, check out the [CONTRIBUTING.md](https://github.com/All-Hands-AI/OpenHands/blob/main/CONTRIBUTING.md) on how to clone and setup the project
initially before moving on. Otherwise, you can clone the OpenHands project directly.
## Start the Server for Development
@@ -21,7 +21,8 @@ Make sure you have all these dependencies installed before moving on to `make bu
#### Develop without sudo access
If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJs`, you can use `conda` or `mamba` to manage the packages for you:
If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJs`, you can use
`conda` or `mamba` to manage the packages for you:
```bash
# Download and install Mamba (a faster version of conda)
@@ -36,7 +37,8 @@ mamba install conda-forge::poetry
### 2. Build and Setup The Environment
Begin by building the project which includes setting up the environment and installing dependencies. This step ensures that OpenHands is ready to run on your system:
Begin by building the project which includes setting up the environment and installing dependencies. This step ensures
that OpenHands is ready to run on your system:
```bash
make build
@@ -45,8 +47,6 @@ make build
### 3. Configuring the Language Model
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
By default, we've chosen Claude Sonnet 3.5 as our go-to model, but the world is your oyster! You can unleash the
potential of any other LM that piques your interest.
To configure the LM of your choice, run:
@@ -54,9 +54,12 @@ To configure the LM of your choice, run:
make setup-config
```
This command will prompt you to enter the LLM API key, model name, and other variables ensuring that OpenHands is tailored to your specific needs. Note that the model name will apply only when you run headless. If you use the UI, please set the model in the UI.
This command will prompt you to enter the LLM API key, model name, and other variables ensuring that OpenHands is
tailored to your specific needs. Note that the model name will apply only when you run headless. If you use the UI,
please set the model in the UI.
Note: If you have previously run OpenHands using the docker command, you may have already set some environmental variables in your terminal. The final configurations are set from highest to lowest priority:
Note: If you have previously run OpenHands using the docker command, you may have already set some environmental
variables in your terminal. The final configurations are set from highest to lowest priority:
Environment variables > config.toml variables > default variables
**Note on Alternative Models:**
@@ -74,13 +77,15 @@ make run
#### Option B: Individual Server Startup
- **Start the Backend Server:** If you prefer, you can start the backend server independently to focus on backend-related tasks or configurations.
- **Start the Backend Server:** If you prefer, you can start the backend server independently to focus on
backend-related tasks or configurations.
```bash
make start-backend
```
- **Start the Frontend Server:** Similarly, you can start the frontend server on its own to work on frontend-related components or interface enhancements.
- **Start the Frontend Server:** Similarly, you can start the frontend server on its own to work on frontend-related
components or interface enhancements.
```bash
make start-frontend
```
@@ -115,10 +120,10 @@ poetry run pytest ./tests/unit/test_*.py
### 9. Use existing Docker image
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by
setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired 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 by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.37-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.38-nikolaik`
## Develop inside Docker container

View File

@@ -3,17 +3,12 @@ These are the procedures and guidelines on how issues are triaged in this repo b
## General
* All issues must be tagged with **enhancement**, **bug** or **troubleshooting/help**.
* Issues may be tagged with what it relates to (**agent quality**, **frontend**, **resolver**, etc.).
* Issues may be tagged with what it relates to (**agent quality**, **resolver**, **CLI**, etc.).
## Severity
* **Low**: Minor issues or affecting single user.
* **Medium**: Affecting multiple users.
* **High**: High visibility issues or affecting many users.
* **Critical**: Affecting all users or potential security issues.
## Effort
* Issues may be estimated with effort required (**small effort**, **medium effort**, **large effort**).
## Difficulty
* Issues with low implementation difficulty may be tagged with **good first issue**.

View File

@@ -154,21 +154,20 @@ install-python-dependencies:
fi
@if [ "${INSTALL_PLAYWRIGHT}" != "false" ] && [ "${INSTALL_PLAYWRIGHT}" != "0" ]; then \
if [ -f "/etc/manjaro-release" ]; then \
echo "$(BLUE)Detected Manjaro Linux. Installing Playwright dependencies...$(RESET)"; \
poetry run pip install playwright; \
poetry run playwright install chromium; \
echo "$(BLUE)Detected Manjaro Linux. Installing Patchright dependencies...$(RESET)"; \
poetry run patchright install chromium; \
else \
if [ ! -f cache/playwright_chromium_is_installed.txt ]; then \
echo "Running playwright install --with-deps chromium..."; \
poetry run playwright install --with-deps chromium; \
if [ ! -f cache/patchright_chromium_is_installed.txt ]; then \
echo "Installing patchright chromium..."; \
poetry run patchright install chromium; \
mkdir -p cache; \
touch cache/playwright_chromium_is_installed.txt; \
touch cache/patchright_chromium_is_installed.txt; \
else \
echo "Setup already done. Skipping playwright installation."; \
echo "Setup already done. Skipping patchright installation."; \
fi \
fi \
else \
echo "Skipping Playwright installation (INSTALL_PLAYWRIGHT=${INSTALL_PLAYWRIGHT})."; \
echo "Skipping Patchright installation (INSTALL_PLAYWRIGHT=${INSTALL_PLAYWRIGHT})."; \
fi
@echo "$(GREEN)Python dependencies installed successfully.$(RESET)"

View File

@@ -51,17 +51,17 @@ system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.37
docker.all-hands.dev/all-hands-ai/openhands:0.38
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!

View File

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

View File

@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of openhands-state for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -37,7 +37,7 @@ Pour exécuter OpenHands en mode CLI avec Docker :
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -46,7 +46,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.core.cli
```

View File

@@ -34,7 +34,7 @@ Pour exécuter OpenHands en mode Headless avec Docker :
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -44,7 +44,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -58,17 +58,17 @@ Un système avec un processeur moderne et un minimum de **4 Go de RAM** est reco
La façon la plus simple d'exécuter OpenHands est dans Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.37
docker.all-hands.dev/all-hands-ai/openhands:0.38
```
Vous trouverez OpenHands en cours d'exécution à l'adresse http://localhost:3000 !

View File

@@ -36,7 +36,7 @@ DockerでOpenHandsをCLIモードで実行するには
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -45,7 +45,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.core.cli
```

View File

@@ -33,7 +33,7 @@ DockerでヘッドレスモードでOpenHandsを実行するには
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -43,7 +43,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -58,17 +58,17 @@ OpenHandsを実行するには、最新のプロセッサと最低**4GB RAM**を
OpenHandsを実行する最も簡単な方法はDockerを使用することです。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.37
docker.all-hands.dev/all-hands-ai/openhands:0.38
```
OpenHandsは http://localhost:3000 で実行されています!

View File

@@ -37,7 +37,7 @@ Para executar o OpenHands no modo CLI com Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -46,7 +46,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.core.cli
```

View File

@@ -34,7 +34,7 @@ Para executar o OpenHands em modo Headless com Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -44,7 +44,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -58,17 +58,17 @@
A maneira mais fácil de executar o OpenHands é no Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.37
docker.all-hands.dev/all-hands-ai/openhands:0.38
```
Você encontrará o OpenHands rodando em http://localhost:3000!

View File

@@ -36,7 +36,7 @@ poetry run python -m openhands.core.cli
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -45,7 +45,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.core.cli
```

View File

@@ -33,7 +33,7 @@ poetry run python -m openhands.core.main -t "write a bash script that prints hi"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -43,7 +43,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -58,17 +58,17 @@
运行 OpenHands 最简单的方法是使用 Docker。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.37
docker.all-hands.dev/all-hands-ai/openhands:0.38
```
OpenHands 将在 http://localhost:3000 运行!

View File

@@ -31,7 +31,7 @@ This command opens an interactive prompt where you can type tasks or commands an
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -40,7 +40,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.cli.main
```

View File

@@ -31,7 +31,7 @@ To run OpenHands in Headless mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -41,7 +41,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -58,17 +58,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.37
docker.all-hands.dev/all-hands-ai/openhands:0.38
```
You'll find OpenHands running at http://localhost:3000!

View File

@@ -1,6 +1,7 @@
# Azure
OpenHands uses LiteLLM to make calls to Azure's chat models. You can find their documentation on using Azure as a provider [here](https://docs.litellm.ai/docs/providers/azure).
OpenHands uses LiteLLM to make calls to Azure's chat models. You can find their documentation on using Azure as a
provider [here](https://docs.litellm.ai/docs/providers/azure).
## Azure OpenAI Configuration
@@ -18,7 +19,7 @@ docker run -it --pull=always \
...
```
Then in the OpenHands UI Settings:
Then in the OpenHands UI Settings under the `LLM` tab:
:::note
You will need your ChatGPT deployment name which can be found on the deployments page in Azure. This is referenced as

View File

@@ -7,10 +7,11 @@ OpenHands uses LiteLLM to make calls to Google's chat models. You can find their
## Gemini - Google AI Studio Configs
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings:
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
- `LLM Provider` to `Gemini`
- `LLM Model` to the model you will be using.
If the model is not in the list, toggle `Advanced` options, and enter it in `Custom Model` (e.g. gemini/&lt;model-name&gt; like `gemini/gemini-2.0-flash`).
If the model is not in the list, enable `Advanced` options, and enter it in `Custom Model`
(e.g. gemini/&lt;model-name&gt; like `gemini/gemini-2.0-flash`).
- `API Key` to your Gemini API key
## VertexAI - Google Cloud Platform Configs
@@ -24,7 +25,8 @@ VERTEXAI_PROJECT="<your-gcp-project-id>"
VERTEXAI_LOCATION="<your-gcp-location>"
```
Then set the following in the OpenHands UI through the Settings:
Then set the following in the OpenHands UI through the Settings under the `LLM` tab:
- `LLM Provider` to `VertexAI`
- `LLM Model` to the model you will be using.
If the model is not in the list, toggle `Advanced` options, and enter it in `Custom Model` (e.g. vertex_ai/&lt;model-name&gt;).
If the model is not in the list, enable `Advanced` options, and enter it in `Custom Model`
(e.g. vertex_ai/&lt;model-name&gt;).

View File

@@ -1,22 +1,21 @@
# Groq
OpenHands uses LiteLLM to make calls to chat models on Groq. You can find their documentation on using Groq as a provider [here](https://docs.litellm.ai/docs/providers/groq).
OpenHands uses LiteLLM to make calls to chat models on Groq. You can find their documentation on using Groq as a
provider [here](https://docs.litellm.ai/docs/providers/groq).
## Configuration
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings:
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
- `LLM Provider` to `Groq`
- `LLM Model` to the model you will be using. [Visit here to see the list of
models that Groq hosts](https://console.groq.com/docs/models). If the model is not in the list, toggle
`Advanced` options, and enter it in `Custom Model` (e.g. groq/&lt;model-name&gt; like `groq/llama3-70b-8192`).
models that Groq hosts](https://console.groq.com/docs/models). If the model is not in the list,
enable `Advanced` options, and enter it in `Custom Model` (e.g. groq/&lt;model-name&gt; like `groq/llama3-70b-8192`).
- `API key` to your Groq API key. To find or create your Groq API Key, [see here](https://console.groq.com/keys).
## Using Groq as an OpenAI-Compatible Endpoint
The Groq endpoint for chat completion is [mostly OpenAI-compatible](https://console.groq.com/docs/openai). Therefore, you can access Groq models as you
would access any OpenAI-compatible endpoint. In the OpenHands UI through the Settings:
would access any OpenAI-compatible endpoint. In the OpenHands UI through the Settings under the `LLM` tab:
1. Enable `Advanced` options
2. Set the following:
- `Custom Model` to the prefix `openai/` + the model you will be using (e.g. `openai/llama3-70b-8192`)

View File

@@ -7,7 +7,7 @@ OpenHands supports using the [LiteLLM proxy](https://docs.litellm.ai/docs/proxy/
To use LiteLLM proxy with OpenHands, you need to:
1. Set up a LiteLLM proxy server (see [LiteLLM documentation](https://docs.litellm.ai/docs/proxy/quick_start))
2. When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings:
2. When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
* Enable `Advanced` options
* `Custom Model` to the prefix `litellm_proxy/` + the model you will be using (e.g. `litellm_proxy/anthropic.claude-3-5-sonnet-20241022-v2:0`)
* `Base URL` to your LiteLLM proxy URL (e.g. `https://your-litellm-proxy.com`)
@@ -15,6 +15,7 @@ To use LiteLLM proxy with OpenHands, you need to:
## Supported Models
The supported models depend on your LiteLLM proxy configuration. OpenHands supports any model that your LiteLLM proxy is configured to handle.
The supported models depend on your LiteLLM proxy configuration. OpenHands supports any model that your LiteLLM proxy
is configured to handle.
Refer to your LiteLLM proxy configuration for the list of available models and their names.

View File

@@ -11,14 +11,12 @@ OpenHands can connect to any LLM supported by LiteLLM. However, it requires a po
Based on our evaluations of language models for coding tasks (using the SWE-bench dataset), we can provide some
recommendations for model selection. Our latest benchmarking results can be found in [this spreadsheet](https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=0).
Based on these findings and community feedback, the following models have been verified to work reasonably well with OpenHands:
Based on these findings and community feedback, these are the latest models that have been verified to work reasonably well with OpenHands:
- [anthropic/claude-3-7-sonnet-20250219](https://www.anthropic.com/api) (recommended)
- [openai/o4-mini](https://openai.com/index/introducing-o3-and-o4-mini/)
- [gemini/gemini-2.5-pro](https://blog.google/technology/google-deepmind/gemini-model-thinking-updates-march-2025/)
- [deepseek/deepseek-chat](https://api-docs.deepseek.com/)
- [openai/o3-mini](https://openai.com/index/openai-o3-mini/)
- [openai/o3](https://openai.com/index/introducing-o3-and-o4-mini/)
- [openai/o4-mini](https://openai.com/index/introducing-o3-and-o4-mini/)
- [all-hands/openhands-lm-32b-v0.1](https://www.all-hands.dev/blog/introducing-openhands-lm-32b----a-strong-open-coding-agent-model) -- available through [OpenRouter](https://openrouter.ai/all-hands/openhands-lm-32b-v0.1)
@@ -27,8 +25,8 @@ OpenHands will issue many prompts to the LLM you configure. Most of these LLMs c
limits and monitor usage.
:::
If you have successfully run OpenHands with specific LLMs not in the list, please add them to the verified list. We
also encourage you to open a PR to share your setup process to help others using the same provider and LLM!
If you have successfully run OpenHands with specific providers, we encourage you to open a PR to share your setup process
to help others using the same provider!
For a full list of the providers and models available, please consult the
[litellm documentation](https://docs.litellm.ai/docs/providers).

View File

@@ -75,7 +75,7 @@ Start OpenHands using `make run`.
### Configure OpenHands
Once OpenHands is running, you'll need to set the following in the OpenHands UI through the Settings:
Once OpenHands is running, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
1. Enable `Advanced` options.
2. Set the following:
- `Custom Model` to `openai/<served-model-name>` (e.g. `openai/openhands-lm-32b-v0.1`)

View File

@@ -1,14 +1,15 @@
# OpenAI
OpenHands uses LiteLLM to make calls to OpenAI's chat models. You can find their documentation on using OpenAI as a provider [here](https://docs.litellm.ai/docs/providers/openai).
OpenHands uses LiteLLM to make calls to OpenAI's chat models. You can find their documentation on using OpenAI as a
provider [here](https://docs.litellm.ai/docs/providers/openai).
## Configuration
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings:
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
* `LLM Provider` to `OpenAI`
* `LLM Model` to the model you will be using.
[Visit here to see a full list of OpenAI models that LiteLLM supports.](https://docs.litellm.ai/docs/providers/openai#openai-chat-completion-models)
If the model is not in the list, toggle `Advanced` options, and enter it in `Custom Model` (e.g. openai/&lt;model-name&gt; like `openai/gpt-4o`).
If the model is not in the list, enable `Advanced` options, and enter it in `Custom Model` (e.g. openai/&lt;model-name&gt; like `openai/gpt-4o`).
* `API Key` to your OpenAI API key. To find or create your OpenAI Project API Key, [see here](https://platform.openai.com/api-keys).
## Using OpenAI-Compatible Endpoints
@@ -17,7 +18,7 @@ Just as for OpenAI Chat completions, we use LiteLLM for OpenAI-compatible endpoi
## Using an OpenAI Proxy
If you're using an OpenAI proxy, in the OpenHands UI through the Settings:
If you're using an OpenAI proxy, in the OpenHands UI through the Settings under the `LLM` tab:
1. Enable `Advanced` options
2. Set the following:
- `Custom Model` to openai/&lt;model-name&gt; (e.g. `openai/gpt-4o` or openai/&lt;proxy-prefix&gt;/&lt;model-name&gt;)

View File

@@ -1,12 +1,14 @@
# OpenRouter
OpenHands uses LiteLLM to make calls to chat models on OpenRouter. You can find their documentation on using OpenRouter as a provider [here](https://docs.litellm.ai/docs/providers/openrouter).
OpenHands uses LiteLLM to make calls to chat models on OpenRouter. You can find their documentation on using
OpenRouter as a provider [here](https://docs.litellm.ai/docs/providers/openrouter).
## Configuration
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings:
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
* `LLM Provider` to `OpenRouter`
* `LLM Model` to the model you will be using.
[Visit here to see a full list of OpenRouter models](https://openrouter.ai/models).
If the model is not in the list, toggle `Advanced` options, and enter it in `Custom Model` (e.g. openrouter/&lt;model-name&gt; like `openrouter/anthropic/claude-3.5-sonnet`).
If the model is not in the list, enable `Advanced` options, and enter it in
`Custom Model` (e.g. openrouter/&lt;model-name&gt; like `openrouter/anthropic/claude-3.5-sonnet`).
* `API Key` to your OpenRouter API key.

View File

@@ -13,9 +13,11 @@ or custom tools. MCP is based on the open standard defined at [modelcontextproto
## Configuration
MCP configuration is defined in the `[mcp]` section of your `config.toml` file.
MCP configuration can be defined in:
* The OpenHands UI through the Settings under the `MCP` tab.
* The `config.toml` file under the `[mcp]` section if not using the UI.
### Configuration Example
### Configuration Example via config.toml
```toml
[mcp]
@@ -82,7 +84,7 @@ Stdio servers are configured using an object with the following properties:
When OpenHands starts, it:
1. Reads the MCP configuration from `config.toml`.
1. Reads the MCP configuration.
2. Connects to any configured SSE servers.
3. Starts any configured stdio servers.
4. Registers the tools provided by these servers with the agent.

View File

@@ -17,7 +17,7 @@ RUN git checkout 4eddc7db6449a5ade3e37285747c8b208cd54ce7
RUN micromamba create -n sci-agent python=3.10 pip setuptools wheel
RUN micromamba run -n sci-agent pip install -r requirements.txt
# Replace all occurence of conda with micromamba under the /workspace
# Replace all occurrences of conda with micromamba under the /workspace
RUN find ./ -type f -exec sed -i 's/conda/micromamba/g' {} \;
# pushd evaluation/scienceagentbench

View File

@@ -63,7 +63,7 @@ to `CodeActAgent`.
default, the script evaluates the entire SWE-bench_Lite test set (300 issues). Note:
in order to use `eval_limit`, you must also set `agent`.
- `max_iter`, e.g. `20`, is the maximum number of iterations for the agent to run. By
default, it is set to 60.
default, it is set to 100.
- `num_workers`, e.g. `3`, is the number of parallel workers to run the evaluation. By
default, it is set to 1.
- `dataset`, a huggingface dataset name. e.g. `princeton-nlp/SWE-bench`, `princeton-nlp/SWE-bench_Lite`, `princeton-nlp/SWE-bench_Verified`, or `princeton-nlp/SWE-bench_Multimodal`, specifies which dataset to evaluate on.
@@ -102,9 +102,9 @@ Fill out [this form](https://docs.google.com/forms/d/e/1FAIpQLSckVz_JFwg2_mOxNZj
```bash
./evaluation/benchmarks/swe_bench/scripts/run_infer.sh [model_config] [git-version] [agent] [eval_limit] [max_iter] [num_workers] [dataset] [dataset_split]
# Example - This runs evaluation on CodeActAgent for 300 instances on "princeton-nlp/SWE-bench_Lite"'s test set, with max 30 iteration per instances, with 16 number of workers running in parallel
# Example - This runs evaluation on CodeActAgent for 300 instances on "princeton-nlp/SWE-bench_Lite"'s test set, with max 100 iteration per instances, with 16 number of workers running in parallel
ALLHANDS_API_KEY="YOUR-API-KEY" RUNTIME=remote SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.eval.all-hands.dev" EVAL_DOCKER_IMAGE_PREFIX="us-central1-docker.pkg.dev/evaluation-092424/swe-bench-images" \
./evaluation/benchmarks/swe_bench/scripts/run_infer.sh llm.eval HEAD CodeActAgent 300 30 16 "princeton-nlp/SWE-bench_Lite" test
./evaluation/benchmarks/swe_bench/scripts/run_infer.sh llm.eval HEAD CodeActAgent 300 100 16 "princeton-nlp/SWE-bench_Lite" test
```
To clean-up all existing runtime you've already started, run:
@@ -176,7 +176,7 @@ Fill out [this form](https://docs.google.com/forms/d/e/1FAIpQLSckVz_JFwg2_mOxNZj
# Example - This evaluates patches generated by CodeActAgent on Llama-3.1-70B-Instruct-Turbo on "princeton-nlp/SWE-bench_Lite"'s test set, with 16 number of workers running in parallel
ALLHANDS_API_KEY="YOUR-API-KEY" RUNTIME=remote SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.eval.all-hands.dev" EVAL_DOCKER_IMAGE_PREFIX="us-central1-docker.pkg.dev/evaluation-092424/swe-bench-images" \
evaluation/benchmarks/swe_bench/scripts/eval_infer_remote.sh evaluation/evaluation_outputs/outputs/swe-bench-lite/CodeActAgent/Llama-3.1-70B-Instruct-Turbo_maxiter_30_N_v1.9-no-hint/output.jsonl 16 "princeton-nlp/SWE-bench_Lite" "test"
evaluation/benchmarks/swe_bench/scripts/eval_infer_remote.sh evaluation/evaluation_outputs/outputs/swe-bench-lite/CodeActAgent/Llama-3.1-70B-Instruct-Turbo_maxiter_100_N_v1.9-no-hint/output.jsonl 16 "princeton-nlp/SWE-bench_Lite" "test"
```
To clean-up all existing runtimes that you've already started, run:

View File

@@ -714,6 +714,19 @@ def filter_dataset(dataset: pd.DataFrame, filter_column: str) -> pd.DataFrame:
subset = dataset[dataset[filter_column].isin(selected_ids)]
logger.info(f'Retained {subset.shape[0]} tasks after filtering')
return subset
if 'selected_repos' in data:
# repos for the swe-bench instances:
# ['astropy/astropy', 'django/django', 'matplotlib/matplotlib', 'mwaskom/seaborn', 'pallets/flask', 'psf/requests', 'pydata/xarray', 'pylint-dev/pylint', 'pytest-dev/pytest', 'scikit-learn/scikit-learn', 'sphinx-doc/sphinx', 'sympy/sympy']
selected_repos = data['selected_repos']
if isinstance(selected_repos, str): selected_repos = [selected_repos]
assert isinstance(selected_repos, list)
logger.info(
f'Filtering {selected_repos} tasks from "selected_repos"...'
)
subset = dataset[dataset["repo"].isin(selected_repos)]
logger.info(f'Retained {subset.shape[0]} tasks after filtering')
return subset
skip_ids = os.environ.get('SKIP_IDS', '').split(',')
if len(skip_ids) > 0:
logger.info(f'Filtering {len(skip_ids)} tasks from "SKIP_IDS"...')

View File

@@ -26,8 +26,8 @@ if [ -z "$AGENT" ]; then
fi
if [ -z "$MAX_ITER" ]; then
echo "MAX_ITER not specified, use default 60"
MAX_ITER=60
echo "MAX_ITER not specified, use default 100"
MAX_ITER=100
fi
if [ -z "$RUN_WITH_BROWSING" ]; then

View File

@@ -2,6 +2,7 @@
echo "Running frontend checks..."
cd frontend
npm run check-unlocalized-strings
npm run check-translation-completeness
npx lint-staged
# Run backend pre-commit

View File

@@ -61,7 +61,7 @@ make build
# Start the application
make run
```
Or to run backend and frontend seperately.
Or to run backend and frontend separately.
```sh
# Start the backend from the root directory

View File

@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ActionSuggestions } from "#/components/features/chat/action-suggestions";
import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { ConversationProvider } from "#/context/conversation-context";
// Mock dependencies
vi.mock("posthog-js", () => ({
@@ -38,12 +39,20 @@ vi.mock("react-i18next", () => ({
}),
}));
vi.mock("react-router", () => ({
useParams: () => ({
conversationId: "test-conversation-id",
}),
}));
const renderActionSuggestions = () =>
render(<ActionSuggestions onSuggestionsClick={() => {}} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
<ConversationProvider>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</ConversationProvider>
),
});
@@ -65,6 +74,11 @@ describe("ActionSuggestions", () => {
});
it("should render both GitHub buttons when GitHub token is set and repository is selected", async () => {
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
// @ts-expect-error - only required for testing
getConversationSpy.mockResolvedValue({
selected_repository: "test-repo",
});
renderActionSuggestions();
// Find all buttons with data-testid="suggestion"

View File

@@ -1,89 +0,0 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { GitRepositorySelector } from "#/components/features/git/git-repo-selector";
import OpenHands from "#/api/open-hands";
import { Provider } from "#/types/settings";
describe("GitRepositorySelector", () => {
const onInputChangeMock = vi.fn();
const onSelectMock = vi.fn();
it("should render the search input", () => {
renderWithProviders(
<GitRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
publicRepositories={[]}
userRepositories={[]}
/>,
);
expect(
screen.getByPlaceholderText("LANDING$SELECT_GIT_REPO"),
).toBeInTheDocument();
});
it("should show the GitHub login button in OSS mode", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
APP_SLUG: "openhands",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
renderWithProviders(
<GitRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
publicRepositories={[]}
userRepositories={[]}
/>,
);
expect(screen.getByTestId("github-repo-selector")).toBeInTheDocument();
});
it("should show the search results", () => {
const mockSearchedRepos = [
{
id: 1,
full_name: "test/repo1",
git_provider: "github" as Provider,
stargazers_count: 100,
is_public: true,
pushed_at: "2023-01-01T00:00:00Z",
},
{
id: 2,
full_name: "test/repo2",
git_provider: "github" as Provider,
stargazers_count: 200,
is_public: true,
pushed_at: "2023-01-02T00:00:00Z",
},
];
const searchPublicRepositoriesSpy = vi.spyOn(
OpenHands,
"searchGitRepositories",
);
searchPublicRepositoriesSpy.mockResolvedValue(mockSearchedRepos);
renderWithProviders(
<GitRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
publicRepositories={[]}
userRepositories={[]}
/>,
);
expect(screen.getByTestId("github-repo-selector")).toBeInTheDocument();
});
});

View File

@@ -43,7 +43,6 @@ describe("HomeHeader", () => {
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
"gui",
undefined,
undefined,
undefined,

View File

@@ -173,7 +173,6 @@ describe("RepoConnector", () => {
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
"gui",
"rbren/polaris",
"github",
undefined,

View File

@@ -0,0 +1,259 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, vi, beforeEach, it } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { RepositorySelectionForm } from "../../../../src/components/features/home/repo-selection-form";
import OpenHands from "#/api/open-hands";
import { GitRepository } from "#/types/git";
// Create mock functions
const mockUseUserRepositories = vi.fn();
const mockUseCreateConversation = vi.fn();
const mockUseIsCreatingConversation = vi.fn();
const mockUseTranslation = vi.fn();
const mockUseAuth = vi.fn();
// Setup default mock returns
mockUseUserRepositories.mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
mockUseCreateConversation.mockReturnValue({
mutate: vi.fn(),
isPending: false,
isSuccess: false,
});
mockUseIsCreatingConversation.mockReturnValue(false);
mockUseTranslation.mockReturnValue({ t: (key: string) => key });
mockUseAuth.mockReturnValue({
isAuthenticated: true,
isLoading: false,
providersAreSet: true,
user: {
id: 1,
login: "testuser",
avatar_url: "https://example.com/avatar.png",
name: "Test User",
email: "test@example.com",
company: "Test Company",
},
login: vi.fn(),
logout: vi.fn(),
});
vi.mock("#/hooks/mutation/use-create-conversation", () => ({
useCreateConversation: () => mockUseCreateConversation(),
}));
vi.mock("#/hooks/use-is-creating-conversation", () => ({
useIsCreatingConversation: () => mockUseIsCreatingConversation(),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => mockUseTranslation(),
}));
vi.mock("#/context/auth-context", () => ({
useAuth: () => mockUseAuth(),
}));
vi.mock("#/hooks/use-debounce", () => ({
useDebounce: (value: string) => value,
}));
const mockOnRepoSelection = vi.fn();
const renderForm = () =>
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />, {
wrapper: ({ children }) => (
<QueryClientProvider
client={
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
}
>
{children}
</QueryClientProvider>
),
});
describe("RepositorySelectionForm", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows loading indicator when repositories are being fetched", () => {
const MOCK_REPOS: GitRepository[] = [
{
id: 1,
full_name: "user/repo1",
git_provider: "github",
is_public: true,
},
{
id: 2,
full_name: "user/repo2",
git_provider: "github",
is_public: true,
},
];
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
renderForm();
// Check if loading indicator is displayed
expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
expect(screen.getByText("HOME$LOADING_REPOSITORIES")).toBeInTheDocument();
});
it("shows dropdown when repositories are loaded", async () => {
const MOCK_REPOS: GitRepository[] = [
{
id: 1,
full_name: "user/repo1",
git_provider: "github",
is_public: true,
},
{
id: 2,
full_name: "user/repo2",
git_provider: "github",
is_public: true,
},
];
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
renderForm();
expect(await screen.findByTestId("repo-dropdown")).toBeInTheDocument();
});
it("shows error message when repository fetch fails", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockRejectedValue(
new Error("Failed to load"),
);
renderForm();
expect(
await screen.findByTestId("repo-dropdown-error"),
).toBeInTheDocument();
expect(
screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
).toBeInTheDocument();
});
it("should call the search repos API when searching a URL", async () => {
const MOCK_REPOS: GitRepository[] = [
{
id: 1,
full_name: "user/repo1",
git_provider: "github",
is_public: true,
},
{
id: 2,
full_name: "user/repo2",
git_provider: "github",
is_public: true,
},
];
const MOCK_SEARCH_REPOS: GitRepository[] = [
{
id: 3,
full_name: "kubernetes/kubernetes",
git_provider: "github",
is_public: true,
},
];
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
renderForm();
const input = await screen.findByTestId("repo-dropdown");
await userEvent.click(input);
for (const repo of MOCK_REPOS) {
expect(screen.getByText(repo.full_name)).toBeInTheDocument();
}
expect(
screen.queryByText(MOCK_SEARCH_REPOS[0].full_name),
).not.toBeInTheDocument();
expect(searchGitReposSpy).not.toHaveBeenCalled();
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
"kubernetes/kubernetes",
3,
);
expect(
screen.getByText(MOCK_SEARCH_REPOS[0].full_name),
).toBeInTheDocument();
for (const repo of MOCK_REPOS) {
expect(screen.queryByText(repo.full_name)).not.toBeInTheDocument();
}
});
it("should call onRepoSelection when a searched repository is selected", async () => {
const MOCK_SEARCH_REPOS: GitRepository[] = [
{
id: 3,
full_name: "kubernetes/kubernetes",
git_provider: "github",
is_public: true,
},
];
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
renderForm();
const input = await screen.findByTestId("repo-dropdown");
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
"kubernetes/kubernetes",
3,
);
const searchedRepo = screen.getByText(MOCK_SEARCH_REPOS[0].full_name);
expect(searchedRepo).toBeInTheDocument();
await userEvent.click(searchedRepo);
expect(mockOnRepoSelection).toHaveBeenCalledWith(
MOCK_SEARCH_REPOS[0].full_name,
);
});
});

View File

@@ -85,7 +85,6 @@ describe("TaskCard", () => {
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledWith(
"suggested_task",
MOCK_RESPOSITORIES[0].full_name,
MOCK_RESPOSITORIES[0].git_provider,
undefined,

View File

@@ -0,0 +1,59 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApiKeysManager } from "#/components/features/settings/api-keys-manager";
// Mock the react-i18next
vi.mock("react-i18next", async () => {
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => key,
}),
Trans: ({ i18nKey, components }: { i18nKey: string; components: Record<string, React.ReactNode> }) => {
// Simplified Trans component that renders the link
if (i18nKey === "SETTINGS$API_KEYS_DESCRIPTION") {
return (
<span>
API keys allow you to authenticate with the OpenHands API programmatically.
Keep your API keys secure; anyone with your API key can access your account.
For more information on how to use the API, see our {components.a}
</span>
);
}
return <span>{i18nKey}</span>;
},
};
});
// Mock the API keys hook
vi.mock("#/hooks/query/use-api-keys", () => ({
useApiKeys: () => ({
data: [],
isLoading: false,
error: null,
}),
}));
describe("ApiKeysManager", () => {
const renderComponent = () => {
const queryClient = new QueryClient();
return render(
<QueryClientProvider client={queryClient}>
<ApiKeysManager />
</QueryClientProvider>
);
};
it("should render the API documentation link", () => {
renderComponent();
// Find the link to the API documentation
const link = screen.getByRole("link");
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "https://docs.all-hands.dev/modules/usage/cloud/cloud-api");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
});

View File

@@ -48,7 +48,7 @@ describe("Content", () => {
await waitFor(() => {
expect(provider).toHaveValue("Anthropic");
expect(model).toHaveValue("claude-3-5-sonnet-20241022");
expect(model).toHaveValue("claude-3-7-sonnet-20250219");
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "");
@@ -135,7 +135,7 @@ describe("Content", () => {
);
const condensor = screen.getByTestId("enable-memory-condenser-switch");
expect(model).toHaveValue("anthropic/claude-3-5-sonnet-20241022");
expect(model).toHaveValue("anthropic/claude-3-7-sonnet-20250219");
expect(baseUrl).toHaveValue("");
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "");
@@ -542,7 +542,7 @@ describe("Form submission", () => {
// select model
await userEvent.click(model);
const modelOption = screen.getByText("claude-3-5-sonnet-20241022");
const modelOption = screen.getByText("claude-3-7-sonnet-20250219");
await userEvent.click(modelOption);
const submitButton = screen.getByTestId("submit-button");
@@ -550,7 +550,7 @@ describe("Form submission", () => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_model: "anthropic/claude-3-5-sonnet-20241022",
llm_model: "anthropic/claude-3-7-sonnet-20250219",
llm_base_url: "",
confirmation_mode: false,
}),

View File

@@ -0,0 +1,565 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { createRoutesStub, Outlet } from "react-router";
import SecretsSettingsScreen from "#/routes/secrets-settings";
import { SecretsService } from "#/api/secrets-service";
import { GetSecretsResponse } from "#/api/secrets-service.types";
import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
const MOCK_GET_SECRETS_RESPONSE: GetSecretsResponse["custom_secrets"] = [
{
name: "My_Secret_1",
description: "My first secret",
},
{
name: "My_Secret_2",
description: "My second secret",
},
];
const RouterStub = createRoutesStub([
{
Component: Outlet,
path: "/settings",
children: [
{
Component: SecretsSettingsScreen,
path: "/settings/secrets",
},
{
Component: () => <div data-testid="git-settings-screen" />,
path: "/settings/git",
},
],
},
]);
const renderSecretsSettings = () =>
render(<RouterStub initialEntries={["/settings/secrets"]} />, {
wrapper: ({ children }) => (
<QueryClientProvider
client={
new QueryClient({
defaultOptions: { queries: { retry: false } },
})
}
>
{children}
</QueryClientProvider>
),
});
beforeEach(() => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the config we need
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
});
});
describe("Content", () => {
it("should render the secrets settings screen", () => {
renderSecretsSettings();
screen.getByTestId("secrets-settings-screen");
});
it("should NOT render a button to connect with git if they havent already in oss", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
// @ts-expect-error - only return the config we need
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {},
});
renderSecretsSettings();
expect(getConfigSpy).toHaveBeenCalled();
await waitFor(() => expect(getSecretsSpy).toHaveBeenCalled());
expect(screen.queryByTestId("connect-git-button")).not.toBeInTheDocument();
});
it("should render a button to connect with git if they havent already in saas", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
// @ts-expect-error - only return the config we need
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {},
});
renderSecretsSettings();
expect(getSecretsSpy).not.toHaveBeenCalled();
await waitFor(() =>
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument(),
);
const button = await screen.findByTestId("connect-git-button");
await userEvent.click(button);
screen.getByTestId("git-settings-screen");
});
it("should render a message if there are no existing secrets", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue([]);
renderSecretsSettings();
await screen.findByTestId("no-secrets-message");
});
it("should render existing secrets", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
renderSecretsSettings();
const secrets = await screen.findAllByTestId("secret-item");
expect(secrets).toHaveLength(2);
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
});
});
describe("Secret actions", () => {
it("should create a new secret", async () => {
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
createSecretSpy.mockResolvedValue(true);
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
const secrets = screen.queryAllByTestId("secret-item");
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument();
expect(secretForm).toBeInTheDocument();
expect(secrets).toHaveLength(0);
// enter details
const nameInput = within(secretForm).getByTestId("name-input");
const valueInput = within(secretForm).getByTestId("value-input");
const descriptionInput =
within(secretForm).getByTestId("description-input");
const submitButton = within(secretForm).getByTestId("submit-button");
vi.clearAllMocks(); // reset mocks to check for upcoming calls
await userEvent.type(nameInput, "My_Custom_Secret");
await userEvent.type(valueInput, "my-custom-secret-value");
await userEvent.type(descriptionInput, "My custom secret description");
await userEvent.click(submitButton);
// make POST request
expect(createSecretSpy).toHaveBeenCalledWith(
"My_Custom_Secret",
"my-custom-secret-value",
"My custom secret description",
);
// hide form & render items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
expect(getSecretsSpy).toHaveBeenCalled();
});
it("should edit a secret", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
const updateSecretSpy = vi.spyOn(SecretsService, "updateSecret");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
updateSecretSpy.mockResolvedValue(true);
renderSecretsSettings();
// render edit button within a secret list item
const secrets = await screen.findAllByTestId("secret-item");
const firstSecret = within(secrets[0]);
const editButton = firstSecret.getByTestId("edit-secret-button");
await userEvent.click(editButton);
// render edit form
const editForm = screen.getByTestId("edit-secret-form");
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument();
expect(editForm).toBeInTheDocument();
expect(screen.queryAllByTestId("secret-item")).toHaveLength(0);
// enter details
const nameInput = within(editForm).getByTestId("name-input");
const descriptionInput = within(editForm).getByTestId("description-input");
const submitButton = within(editForm).getByTestId("submit-button");
// should not show value input
const valueInput = within(editForm).queryByTestId("value-input");
expect(valueInput).not.toBeInTheDocument();
expect(nameInput).toHaveValue("My_Secret_1");
expect(descriptionInput).toHaveValue("My first secret");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "My_Edited_Secret");
await userEvent.clear(descriptionInput);
await userEvent.type(descriptionInput, "My edited secret description");
await userEvent.click(submitButton);
// make POST request
expect(updateSecretSpy).toHaveBeenCalledWith(
"My_Secret_1",
"My_Edited_Secret",
"My edited secret description",
);
// hide form
expect(screen.queryByTestId("edit-secret-form")).not.toBeInTheDocument();
// optimistic update
const updatedSecrets = await screen.findAllByTestId("secret-item");
expect(updatedSecrets).toHaveLength(2);
expect(updatedSecrets[0]).toHaveTextContent(/my_edited_secret/i);
});
it("should be able to cancel the create or edit form", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
// cancel button
const cancelButton = within(secretForm).getByTestId("cancel-button");
await userEvent.click(cancelButton);
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
expect(screen.queryByTestId("add-secret-button")).toBeInTheDocument();
// render edit button within a secret list item
const secrets = await screen.findAllByTestId("secret-item");
const firstSecret = within(secrets[0]);
const editButton = firstSecret.getByTestId("edit-secret-button");
await userEvent.click(editButton);
// render edit form
const editForm = screen.getByTestId("edit-secret-form");
expect(editForm).toBeInTheDocument();
expect(screen.queryAllByTestId("secret-item")).toHaveLength(0);
// cancel button
const cancelEditButton = within(editForm).getByTestId("cancel-button");
await userEvent.click(cancelEditButton);
expect(screen.queryByTestId("edit-secret-form")).not.toBeInTheDocument();
expect(screen.queryAllByTestId("secret-item")).toHaveLength(2);
});
it("should undo the optimistic update if the request fails", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
const updateSecretSpy = vi.spyOn(SecretsService, "updateSecret");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
updateSecretSpy.mockRejectedValue(new Error("Failed to update secret"));
renderSecretsSettings();
// render edit button within a secret list item
const secrets = await screen.findAllByTestId("secret-item");
const firstSecret = within(secrets[0]);
const editButton = firstSecret.getByTestId("edit-secret-button");
await userEvent.click(editButton);
// render edit form
const editForm = screen.getByTestId("edit-secret-form");
expect(editForm).toBeInTheDocument();
expect(screen.queryAllByTestId("secret-item")).toHaveLength(0);
// enter details
const nameInput = within(editForm).getByTestId("name-input");
const submitButton = within(editForm).getByTestId("submit-button");
// should not show value input
const valueInput = within(editForm).queryByTestId("value-input");
expect(valueInput).not.toBeInTheDocument();
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "My_Edited_Secret");
await userEvent.click(submitButton);
// make POST request
expect(updateSecretSpy).toHaveBeenCalledWith(
"My_Secret_1",
"My_Edited_Secret",
"My first secret",
);
// hide form
expect(screen.queryByTestId("edit-secret-form")).not.toBeInTheDocument();
// no optimistic update
const updatedSecrets = await screen.findAllByTestId("secret-item");
expect(updatedSecrets).toHaveLength(2);
expect(updatedSecrets[0]).not.toHaveTextContent(/my edited secret/i);
});
it("should remove the secret from the list after deletion", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
const deleteSecretSpy = vi.spyOn(SecretsService, "deleteSecret");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
deleteSecretSpy.mockResolvedValue(true);
renderSecretsSettings();
// render delete button within a secret list item
const secrets = await screen.findAllByTestId("secret-item");
const secondSecret = within(secrets[1]);
const deleteButton = secondSecret.getByTestId("delete-secret-button");
await userEvent.click(deleteButton);
// confirmation modal
const confirmationModal = screen.getByTestId("confirmation-modal");
const confirmButton =
within(confirmationModal).getByTestId("confirm-button");
await userEvent.click(confirmButton);
// make DELETE request
expect(deleteSecretSpy).toHaveBeenCalledWith("My_Secret_2");
expect(screen.queryByTestId("confirmation-modal")).not.toBeInTheDocument();
// optimistic update
expect(screen.queryAllByTestId("secret-item")).toHaveLength(1);
expect(screen.queryByText("My_Secret_2")).not.toBeInTheDocument();
});
it("should be able to cancel the delete confirmation modal", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
const deleteSecretSpy = vi.spyOn(SecretsService, "deleteSecret");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
deleteSecretSpy.mockResolvedValue(true);
renderSecretsSettings();
// render delete button within a secret list item
const secrets = await screen.findAllByTestId("secret-item");
const secondSecret = within(secrets[1]);
const deleteButton = secondSecret.getByTestId("delete-secret-button");
await userEvent.click(deleteButton);
// confirmation modal
const confirmationModal = screen.getByTestId("confirmation-modal");
const cancelButton = within(confirmationModal).getByTestId("cancel-button");
await userEvent.click(cancelButton);
// no DELETE request
expect(deleteSecretSpy).not.toHaveBeenCalled();
expect(screen.queryByTestId("confirmation-modal")).not.toBeInTheDocument();
expect(screen.queryAllByTestId("secret-item")).toHaveLength(2);
});
it("should revert the optimistic update if the request fails", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
const deleteSecretSpy = vi.spyOn(SecretsService, "deleteSecret");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
deleteSecretSpy.mockRejectedValue(new Error("Failed to delete secret"));
renderSecretsSettings();
// render delete button within a secret list item
const secrets = await screen.findAllByTestId("secret-item");
const secondSecret = within(secrets[1]);
const deleteButton = secondSecret.getByTestId("delete-secret-button");
await userEvent.click(deleteButton);
// confirmation modal
const confirmationModal = screen.getByTestId("confirmation-modal");
const confirmButton =
within(confirmationModal).getByTestId("confirm-button");
await userEvent.click(confirmButton);
// make DELETE request
expect(deleteSecretSpy).toHaveBeenCalledWith("My_Secret_2");
expect(screen.queryByTestId("confirmation-modal")).not.toBeInTheDocument();
// optimistic update
expect(screen.queryAllByTestId("secret-item")).toHaveLength(2);
expect(screen.queryByText("My_Secret_2")).toBeInTheDocument();
});
it("should hide the no items message when in form view", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue([]);
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
});
it("should not allow spaces in secret names", async () => {
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
// enter details
const nameInput = within(secretForm).getByTestId("name-input");
const valueInput = within(secretForm).getByTestId("value-input");
const submitButton = within(secretForm).getByTestId("submit-button");
await userEvent.type(nameInput, "My Custom Secret With Spaces");
await userEvent.type(valueInput, "my-custom-secret-value");
await userEvent.click(submitButton);
// make POST request
expect(createSecretSpy).not.toHaveBeenCalled();
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "MyCustomSecret");
await userEvent.click(submitButton);
expect(createSecretSpy).toHaveBeenCalledWith(
"MyCustomSecret",
"my-custom-secret-value",
undefined,
);
});
it("should not allow existing secret names", async () => {
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE.slice(0, 1));
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
// enter details
const nameInput = within(secretForm).getByTestId("name-input");
const valueInput = within(secretForm).getByTestId("value-input");
const submitButton = within(secretForm).getByTestId("submit-button");
await userEvent.type(nameInput, "My_Secret_1");
await userEvent.type(valueInput, "my-custom-secret-value");
await userEvent.click(submitButton);
// make POST request
expect(createSecretSpy).not.toHaveBeenCalled();
expect(screen.queryByText(/secret already exists/i)).toBeInTheDocument();
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "My_Custom_Secret");
await userEvent.clear(valueInput);
await userEvent.type(valueInput, "my-custom-secret-value");
await userEvent.click(submitButton);
expect(createSecretSpy).toHaveBeenCalledWith(
"My_Custom_Secret",
"my-custom-secret-value",
undefined,
);
expect(
screen.queryByText("SECRETS$SECRET_VALUE_REQUIRED"),
).not.toBeInTheDocument();
});
it("should not submit whitespace secret names or values", async () => {
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
// enter details
const nameInput = within(secretForm).getByTestId("name-input");
const valueInput = within(secretForm).getByTestId("value-input");
const submitButton = within(secretForm).getByTestId("submit-button");
await userEvent.type(nameInput, " ");
await userEvent.type(valueInput, "my-custom-secret-value");
await userEvent.click(submitButton);
// make POST request
expect(createSecretSpy).not.toHaveBeenCalled();
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "My_Custom_Secret");
await userEvent.clear(valueInput);
await userEvent.type(valueInput, " ");
await userEvent.click(submitButton);
expect(createSecretSpy).not.toHaveBeenCalled();
expect(
screen.queryByText("SECRETS$SECRET_VALUE_REQUIRED"),
).toBeInTheDocument();
});
it("should not reset ipout values on an invalid submit", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
// enter details
const nameInput = within(secretForm).getByTestId("name-input");
const valueInput = within(secretForm).getByTestId("value-input");
const submitButton = within(secretForm).getByTestId("submit-button");
await userEvent.type(nameInput, MOCK_GET_SECRETS_RESPONSE[0].name);
await userEvent.type(valueInput, "my-custom-secret-value");
await userEvent.click(submitButton);
// make POST request
expect(createSecretSpy).not.toHaveBeenCalled();
expect(screen.queryByText(/secret already exists/i)).toBeInTheDocument();
expect(nameInput).toHaveValue(MOCK_GET_SECRETS_RESPONSE[0].name);
expect(valueInput).toHaveValue("my-custom-secret-value");
});
});

View File

@@ -79,7 +79,7 @@ describe("Settings Screen", () => {
};
it("should render the navbar", async () => {
const sectionsToInclude = ["llm", "git", "application"];
const sectionsToInclude = ["llm", "git", "application", "secrets"];
const sectionsToExclude = ["api keys", "credits"];
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return app mode
@@ -110,7 +110,13 @@ describe("Settings Screen", () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
const sectionsToInclude = ["git", "application", "credits", "api keys"];
const sectionsToInclude = [
"git",
"application",
"credits",
"secrets",
"api keys",
];
const sectionsToExclude = ["llm"];
renderSettingsScreen();

View File

@@ -32,7 +32,7 @@ describe("handleObservationMessage", () => {
screenshot: "base64-screenshot-data",
},
};
handleObservationMessage(message);
// Check that setScreenshotSrc and setUrl were called with the correct values
@@ -52,11 +52,11 @@ describe("handleObservationMessage", () => {
screenshot: "base64-screenshot-data",
},
};
handleObservationMessage(message);
// Check that setScreenshotSrc and setUrl were called with the correct values
expect(store.dispatch).toHaveBeenCalledWith(setScreenshotSrc("base64-screenshot-data"));
expect(store.dispatch).toHaveBeenCalledWith(setUrl("https://example.com"));
});
});
});

View File

@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.37.0",
"version": "0.38.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.37.0",
"version": "0.38.0",
"dependencies": {
"@heroui/react": "2.7.8",
"@microlink/react-json-view": "^1.26.1",

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.37.0",
"version": "0.38.0",
"private": true,
"type": "module",
"engines": {
@@ -68,7 +68,8 @@
"lint:fix": "eslint src --ext .ts,.tsx,.js --fix && prettier --write src/**/*.{ts,tsx}",
"prepare": "cd .. && husky frontend/.husky",
"typecheck": "react-router typegen && tsc",
"check-unlocalized-strings": "node scripts/check-unlocalized-strings.cjs"
"check-unlocalized-strings": "node scripts/check-unlocalized-strings.cjs",
"check-translation-completeness": "node scripts/check-translation-completeness.cjs"
},
"lint-staged": {
"src/**/*.{ts,tsx,js}": [

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Pre-commit hook script to check for translation completeness
* This script ensures that all translation keys have entries for all supported languages
*/
const fs = require('fs');
const path = require('path');
// Load the translation file
const translationJsonPath = path.join(__dirname, '../src/i18n/translation.json');
const translationJson = require(translationJsonPath);
// Load the available languages from the i18n index file
const i18nIndexPath = path.join(__dirname, '../src/i18n/index.ts');
const i18nIndexContent = fs.readFileSync(i18nIndexPath, 'utf8');
// Extract the language codes from the AvailableLanguages array
const languageCodesRegex = /\{ label: "[^"]+", value: "([^"]+)" \}/g;
const supportedLanguageCodes = [];
let match;
while ((match = languageCodesRegex.exec(i18nIndexContent)) !== null) {
supportedLanguageCodes.push(match[1]);
}
// Track missing and extra translations
const missingTranslations = {};
const extraLanguages = {};
let hasErrors = false;
// Check each translation key
Object.entries(translationJson).forEach(([key, translations]) => {
// Get the languages available for this key
const availableLanguages = Object.keys(translations);
// Find missing languages for this key
const missing = supportedLanguageCodes.filter(
(langCode) => !availableLanguages.includes(langCode)
);
if (missing.length > 0) {
missingTranslations[key] = missing;
hasErrors = true;
}
// Find extra languages for this key
const extra = availableLanguages.filter(
(langCode) => !supportedLanguageCodes.includes(langCode)
);
if (extra.length > 0) {
extraLanguages[key] = extra;
hasErrors = true;
}
});
// Generate detailed error message if there are missing translations
if (Object.keys(missingTranslations).length > 0) {
console.error('\x1b[31m%s\x1b[0m', 'ERROR: Missing translations detected');
console.error(`Found ${Object.keys(missingTranslations).length} translation keys with missing languages:`);
Object.entries(missingTranslations).forEach(([key, langs]) => {
console.error(`- Key "${key}" is missing translations for: ${langs.join(', ')}`);
});
console.error('\nPlease add the missing translations before committing.');
}
// Generate detailed error message if there are extra languages
if (Object.keys(extraLanguages).length > 0) {
console.error('\x1b[31m%s\x1b[0m', 'ERROR: Extra languages detected');
console.error(`Found ${Object.keys(extraLanguages).length} translation keys with extra languages not in AvailableLanguages:`);
Object.entries(extraLanguages).forEach(([key, langs]) => {
console.error(`- Key "${key}" has translations for unsupported languages: ${langs.join(', ')}`);
});
console.error('\nPlease remove the extra languages before committing.');
}
// Exit with error code if there are issues
if (hasErrors) {
process.exit(1);
} else {
console.log('\x1b[32m%s\x1b[0m', 'All translation keys have complete language coverage!');
}

View File

@@ -111,6 +111,8 @@ const EXCLUDED_TECHNICAL_STRINGS = [
"GitLab API", // Git provider specific terminology
"Pull Request", // Git provider specific terminology
"GitHub API", // Git provider specific terminology
"add-secret-form", // Test ID for secret form
"edit-secret-form", // Test ID for secret form
];
function isExcludedTechnicalString(str) {

View File

@@ -10,7 +10,6 @@ import {
GetTrajectoryResponse,
GitChangeDiff,
GitChange,
ConversationTrigger,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
@@ -144,7 +143,6 @@ class OpenHands {
}
static async createConversation(
conversation_trigger: ConversationTrigger = "gui",
selectedRepository?: string,
git_provider?: Provider,
initialUserMsg?: string,
@@ -154,7 +152,6 @@ class OpenHands {
selected_branch?: string,
): Promise<Conversation> {
const body = {
conversation_trigger,
repository: selectedRepository,
git_provider,
selected_branch,

View File

@@ -1,8 +1,43 @@
import { Provider, ProviderToken } from "#/types/settings";
import { openHands } from "./open-hands-axios";
import { POSTProviderTokens } from "./secrets-service.types";
import {
CustomSecret,
GetSecretsResponse,
POSTProviderTokens,
} from "./secrets-service.types";
import { Provider, ProviderToken } from "#/types/settings";
export class SecretsService {
static async getSecrets() {
const { data } = await openHands.get<GetSecretsResponse>("/api/secrets");
return data.custom_secrets;
}
static async createSecret(name: string, value: string, description?: string) {
const secret: CustomSecret = {
name,
value,
description,
};
const { status } = await openHands.post("/api/secrets", secret);
return status === 201;
}
static async updateSecret(id: string, name: string, description?: string) {
const secret: Omit<CustomSecret, "value"> = {
name,
description,
};
const { status } = await openHands.put(`/api/secrets/${id}`, secret);
return status === 200;
}
static async deleteSecret(id: string) {
const { status } = await openHands.delete<boolean>(`/api/secrets/${id}`);
return status === 200;
}
static async addGitProvider(providers: Record<Provider, ProviderToken>) {
const tokens: POSTProviderTokens = {
provider_tokens: providers,

View File

@@ -1,5 +1,15 @@
import { Provider, ProviderToken } from "#/types/settings";
export type CustomSecret = {
name: string;
value: string;
description?: string;
};
export interface GetSecretsResponse {
custom_secrets: Omit<CustomSecret, "value">[];
}
export interface POSTProviderTokens {
provider_tokens: Record<Provider, ProviderToken>;
}

View File

@@ -1,11 +1,11 @@
import posthog from "posthog-js";
import React from "react";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
import type { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { useUserProviders } from "#/hooks/use-user-providers";
import { useConversation } from "#/context/conversation-context";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
interface ActionSuggestionsProps {
onSuggestionsClick: (value: string) => void;
@@ -16,17 +16,13 @@ export function ActionSuggestions({
}: ActionSuggestionsProps) {
const { t } = useTranslation();
const { providers } = useUserProviders();
const { selectedRepository } = useSelector(
(state: RootState) => state.initialQuery,
);
const { conversationId } = useConversation();
const { data: conversation } = useUserConversation(conversationId);
const [hasPullRequest, setHasPullRequest] = React.useState(false);
const providersAreSet = providers.length > 0;
const isGitLab =
selectedRepository !== null &&
selectedRepository.git_provider &&
selectedRepository.git_provider.toLowerCase() === "gitlab";
const isGitLab = providers.includes("gitlab");
const pr = isGitLab ? "merge request" : "pull request";
const prShort = isGitLab ? "MR" : "PR";
@@ -45,7 +41,7 @@ export function ActionSuggestions({
return (
<div className="flex flex-col gap-2 mb-2">
{providersAreSet && selectedRepository && (
{providersAreSet && conversation?.selected_repository && (
<div className="flex flex-row gap-2 justify-center w-full">
{!hasPullRequest ? (
<>

View File

@@ -6,6 +6,7 @@ import { cn } from "#/utils/utils";
import { ul, ol } from "../markdown/list";
import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button";
import { anchor } from "../markdown/anchor";
import { paragraph } from "../markdown/paragraph";
interface ChatMessageProps {
type: "user" | "assistant";
@@ -64,6 +65,7 @@ export function ChatMessage({
ul,
ol,
a: anchor,
p: paragraph,
}}
remarkPlugins={[remarkGfm]}
>

View File

@@ -15,6 +15,7 @@ import { OpenHandsObservation } from "#/types/core/observations";
import { cn } from "#/utils/utils";
import { code } from "../markdown/code";
import { ol, ul } from "../markdown/list";
import { paragraph } from "../markdown/paragraph";
import { MonoComponent } from "./mono-component";
import { PathComponent } from "./path-component";
@@ -196,6 +197,7 @@ export function ExpandableMessage({
code,
ul,
ol,
p: paragraph,
}}
remarkPlugins={[remarkGfm]}
>

View File

@@ -1,203 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
Autocomplete,
AutocompleteItem,
AutocompleteSection,
Spinner,
} from "@heroui/react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { I18nKey } from "#/i18n/declaration";
import { setSelectedRepository } from "#/state/initial-query-slice";
import { useConfig } from "#/hooks/query/use-config";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { GitRepository } from "#/types/git";
import { Provider, ProviderOptions } from "#/types/settings";
interface GitRepositorySelectorProps {
onInputChange: (value: string) => void;
onSelect: () => void;
userRepositories: GitRepository[];
publicRepositories: GitRepository[];
isLoading?: boolean;
}
export function GitRepositorySelector({
onInputChange,
onSelect,
userRepositories,
publicRepositories,
isLoading = false,
}: GitRepositorySelectorProps) {
const { t } = useTranslation();
const { data: config } = useConfig();
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
const allRepositories: GitRepository[] = [
...publicRepositories.filter(
(repo) => !userRepositories.find((r) => r.id === repo.id),
),
...userRepositories,
];
// Group repositories by provider
const groupedUserRepos = userRepositories.reduce<
Record<Provider, GitRepository[]>
>(
(acc, repo) => {
if (!acc[repo.git_provider]) {
acc[repo.git_provider] = [];
}
acc[repo.git_provider].push(repo);
return acc;
},
{} as Record<Provider, GitRepository[]>,
);
const groupedPublicRepos = publicRepositories.reduce<
Record<Provider, GitRepository[]>
>(
(acc, repo) => {
if (!acc[repo.git_provider]) {
acc[repo.git_provider] = [];
}
acc[repo.git_provider].push(repo);
return acc;
},
{} as Record<Provider, GitRepository[]>,
);
const dispatch = useDispatch();
const handleRepoSelection = (id: string | null) => {
const repo = allRepositories.find((r) => r.id.toString() === id);
if (repo) {
dispatch(setSelectedRepository(repo));
posthog.capture("repository_selected");
onSelect();
setSelectedKey(id);
}
};
const handleClearSelection = () => {
dispatch(setSelectedRepository(null));
};
const emptyContent = isLoading ? (
<div className="flex items-center justify-center py-2">
<Spinner size="sm" className="mr-2" />
<span>{t(I18nKey.GITHUB$LOADING_REPOSITORIES)}</span>
</div>
) : (
t(I18nKey.GITHUB$NO_RESULTS)
);
return (
<Autocomplete
data-testid="github-repo-selector"
name="repo"
aria-label="Git Repository"
placeholder={t(I18nKey.LANDING$SELECT_GIT_REPO)}
isVirtualized={false}
selectedKey={selectedKey}
inputProps={{
classNames: {
inputWrapper:
"text-sm w-full rounded-[4px] px-3 py-[10px] bg-[#525252] text-[#A3A3A3]",
},
endContent: isLoading ? <Spinner size="sm" /> : undefined,
}}
onSelectionChange={(id) => handleRepoSelection(id?.toString() ?? null)}
onInputChange={onInputChange}
clearButtonProps={{ onPress: handleClearSelection }}
listboxProps={{
emptyContent,
}}
defaultFilter={(textValue, inputValue) => {
if (!inputValue) return true;
const sanitizedInput = sanitizeQuery(inputValue);
const repo = allRepositories.find((r) => r.full_name === textValue);
if (!repo) return false;
const provider = repo.git_provider?.toLowerCase() as Provider;
const providerKeys = Object.keys(ProviderOptions) as Provider[];
// If input is exactly "git", show repos from any git-based provider
if (sanitizedInput === "git") {
return providerKeys.includes(provider);
}
// Provider based typeahead
for (const p of providerKeys) {
if (p.startsWith(sanitizedInput)) {
return provider === p;
}
}
// Default case: check if the repository name matches the input
return sanitizeQuery(textValue).includes(sanitizedInput);
}}
>
{config?.APP_MODE === "saas" &&
config?.APP_SLUG &&
((
<AutocompleteItem key="install">
<a
href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
target="_blank"
rel="noreferrer noopener"
onClick={(e) => e.stopPropagation()}
>
{t(I18nKey.GITHUB$ADD_MORE_REPOS)}
</a>
</AutocompleteItem> // eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any)}
{Object.entries(groupedUserRepos).map(([provider, repos]) =>
repos.length > 0 ? (
<AutocompleteSection
key={`user-${provider}`}
showDivider
title={`${t(I18nKey.GITHUB$YOUR_REPOS)} - ${provider}`}
>
{repos.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
</AutocompleteItem>
))}
</AutocompleteSection>
) : null,
)}
{Object.entries(groupedPublicRepos).map(([provider, repos]) =>
repos.length > 0 ? (
<AutocompleteSection
key={`public-${provider}`}
showDivider
title={`${t(I18nKey.GITHUB$PUBLIC_REPOS)} - ${provider}`}
>
{repos.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
<span className="ml-1 text-gray-400">
({repo.stargazers_count || 0})
</span>
</AutocompleteItem>
))}
</AutocompleteSection>
) : null,
)}
</Autocomplete>
);
}

View File

@@ -1,77 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { I18nKey } from "#/i18n/declaration";
import { SuggestionBox } from "#/components/features/suggestions/suggestion-box";
import { GitRepositorySelector } from "./git-repo-selector";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { useDebounce } from "#/hooks/use-debounce";
import { BrandButton } from "../settings/brand-button";
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
import { GitHubErrorReponse, GitUser } from "#/types/git";
interface GitRepositoriesSuggestionBoxProps {
handleSubmit: () => void;
gitHubAuthUrl: string | null;
user: GitHubErrorReponse | GitUser | null;
}
export function GitRepositoriesSuggestionBox({
handleSubmit,
gitHubAuthUrl,
user,
}: GitRepositoriesSuggestionBoxProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = React.useState<string>("");
const debouncedSearchQuery = useDebounce(searchQuery, 300);
// TODO: Use `useQueries` to fetch all repositories in parallel
const { data: userRepositories, isLoading: isUserReposLoading } =
useUserRepositories();
const { data: searchedRepos, isLoading: isSearchReposLoading } =
useSearchRepositories(sanitizeQuery(debouncedSearchQuery));
const isLoading = isUserReposLoading || isSearchReposLoading;
const handleConnectToGitHub = () => {
if (gitHubAuthUrl) {
window.location.href = gitHubAuthUrl;
} else {
navigate("/settings");
}
};
const isLoggedIn = !!user;
return (
<SuggestionBox
title={t(I18nKey.LANDING$OPEN_REPO)}
content={
isLoggedIn ? (
<GitRepositorySelector
onInputChange={setSearchQuery}
onSelect={handleSubmit}
publicRepositories={searchedRepos || []}
userRepositories={userRepositories || []}
isLoading={isLoading}
/>
) : (
<BrandButton
testId="connect-to-github"
type="button"
variant="secondary"
className="w-full text-content border-content"
onClick={handleConnectToGitHub}
startContent={<GitHubLogo width={20} height={20} />}
>
{t(I18nKey.GITHUB$CONNECT)}
</BrandButton>
)
}
/>
);
}

View File

@@ -28,7 +28,7 @@ export function HomeHeader() {
testId="header-launch-button"
variant="primary"
type="button"
onClick={() => createConversation({ conversation_trigger: "gui" })}
onClick={() => createConversation({})}
isDisabled={isCreatingConversation}
>
{!isCreatingConversation && "Launch from Scratch"}

View File

@@ -1,5 +1,6 @@
import { render, screen } from "@testing-library/react";
import { describe, test, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RepositorySelectionForm } from "./repo-selection-form";
// Create mock functions
@@ -74,9 +75,16 @@ vi.mock("#/context/auth-context", () => ({
useAuth: () => mockUseAuth(),
}));
describe("RepositorySelectionForm", () => {
const mockOnRepoSelection = vi.fn();
const renderRepositorySelectionForm = () =>
render(<RepositorySelectionForm onRepoSelection={vi.fn()} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
describe("RepositorySelectionForm", () => {
beforeEach(() => {
vi.clearAllMocks();
});
@@ -89,7 +97,7 @@ describe("RepositorySelectionForm", () => {
isError: false,
});
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />);
renderRepositorySelectionForm();
// Check if loading indicator is displayed
expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
@@ -117,7 +125,7 @@ describe("RepositorySelectionForm", () => {
isError: false,
});
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />);
renderRepositorySelectionForm();
// Check if dropdown is displayed
expect(screen.getByTestId("repo-dropdown")).toBeInTheDocument();
@@ -132,7 +140,7 @@ describe("RepositorySelectionForm", () => {
error: new Error("Failed to fetch repositories"),
});
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />);
renderRepositorySelectionForm();
// Check if error message is displayed
expect(screen.getByTestId("repo-dropdown-error")).toBeInTheDocument();

View File

@@ -6,6 +6,9 @@ import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { Branch, GitRepository } from "#/types/git";
import { BrandButton } from "../settings/brand-button";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useDebounce } from "#/hooks/use-debounce";
import { sanitizeQuery } from "#/utils/sanitize-query";
import {
RepositoryDropdown,
RepositoryLoadingState,
@@ -27,6 +30,8 @@ export function RepositorySelectionForm({
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
null,
);
// Add a ref to track if the branch was manually cleared by the user
const branchManuallyClearedRef = React.useRef<boolean>(false);
const {
data: repositories,
isLoading: isLoadingRepositories,
@@ -45,13 +50,18 @@ export function RepositorySelectionForm({
const isCreatingConversationElsewhere = useIsCreatingConversation();
const { t } = useTranslation();
// Auto-select main or master branch if it exists
const [searchQuery, setSearchQuery] = React.useState("");
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const { data: searchedRepos } = useSearchRepositories(debouncedSearchQuery);
// Auto-select main or master branch if it exists, but only if the branch wasn't manually cleared
React.useEffect(() => {
if (
branches &&
branches.length > 0 &&
!selectedBranch &&
!isLoadingBranches
!isLoadingBranches &&
!branchManuallyClearedRef.current // Only auto-select if not manually cleared
) {
// Look for main or master branch
const mainBranch = branches.find((branch) => branch.name === "main");
@@ -64,14 +74,15 @@ export function RepositorySelectionForm({
setSelectedBranch(masterBranch);
}
}
}, [branches, selectedBranch, isLoadingBranches]);
}, [branches, isLoadingBranches, selectedBranch]);
// We check for isSuccess because the app might require time to render
// into the new conversation screen after the conversation is created.
const isCreatingConversation =
isPending || isSuccess || isCreatingConversationElsewhere;
const repositoriesItems = repositories?.map((repo) => ({
const allRepositories = repositories?.concat(searchedRepos || []);
const repositoriesItems = allRepositories?.map((repo) => ({
key: repo.id,
label: repo.full_name,
}));
@@ -82,18 +93,21 @@ export function RepositorySelectionForm({
}));
const handleRepoSelection = (key: React.Key | null) => {
const selectedRepo = repositories?.find(
const selectedRepo = allRepositories?.find(
(repo) => repo.id.toString() === key,
);
if (selectedRepo) onRepoSelection(selectedRepo.full_name);
setSelectedRepository(selectedRepo || null);
setSelectedBranch(null); // Reset branch selection when repo changes
branchManuallyClearedRef.current = false; // Reset the flag when repo changes
};
const handleBranchSelection = (key: React.Key | null) => {
const selectedBranchObj = branches?.find((branch) => branch.name === key);
setSelectedBranch(selectedBranchObj || null);
// Reset the manually cleared flag when a branch is explicitly selected
branchManuallyClearedRef.current = false;
};
const handleRepoInputChange = (value: string) => {
@@ -101,12 +115,22 @@ export function RepositorySelectionForm({
setSelectedRepository(null);
setSelectedBranch(null);
onRepoSelection(null);
} else if (value.startsWith("https://")) {
const repoName = sanitizeQuery(value);
setSearchQuery(repoName);
}
};
const handleBranchInputChange = (value: string) => {
if (value === "") {
// Clear the selected branch if the input is empty or contains only whitespace
// This fixes the issue where users can't delete the entire default branch name
if (value === "" || value.trim() === "") {
setSelectedBranch(null);
// Set the flag to indicate that the branch was manually cleared
branchManuallyClearedRef.current = true;
} else {
// Reset the flag when the user starts typing again
branchManuallyClearedRef.current = false;
}
};
@@ -125,6 +149,15 @@ export function RepositorySelectionForm({
items={repositoriesItems || []}
onSelectionChange={handleRepoSelection}
onInputChange={handleRepoInputChange}
defaultFilter={(textValue, inputValue) => {
if (!inputValue) return true;
const repo = allRepositories?.find((r) => r.full_name === textValue);
if (!repo) return false;
const sanitizedInput = sanitizeQuery(inputValue);
return sanitizeQuery(textValue).includes(sanitizedInput);
}}
/>
);
};
@@ -180,7 +213,6 @@ export function RepositorySelectionForm({
onClick={() =>
createConversation({
selectedRepository,
conversation_trigger: "gui",
selected_branch: selectedBranch?.name,
})
}

View File

@@ -5,12 +5,14 @@ export interface RepositoryDropdownProps {
items: { key: React.Key; label: string }[];
onSelectionChange: (key: React.Key | null) => void;
onInputChange: (value: string) => void;
defaultFilter?: (textValue: string, inputValue: string) => boolean;
}
export function RepositoryDropdown({
items,
onSelectionChange,
onInputChange,
defaultFilter,
}: RepositoryDropdownProps) {
return (
<SettingsDropdownInput
@@ -21,6 +23,7 @@ export function RepositoryDropdown({
wrapperClassName="max-w-[500px]"
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
defaultFilter={defaultFilter}
/>
);
}

View File

@@ -40,7 +40,6 @@ export function TaskCard({ task }: TaskCardProps) {
const repo = getRepo(task.repo, task.git_provider);
return createConversation({
conversation_trigger: "suggested_task",
selectedRepository: repo,
suggested_task: task,
});

View File

@@ -4,6 +4,7 @@ import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { JupyterLine } from "#/utils/parse-cell-content";
import { paragraph } from "../markdown/paragraph";
interface JupyterCellOutputProps {
lines: JupyterLine[];
@@ -25,7 +26,12 @@ export function JupyterCellOutput({ lines }: JupyterCellOutputProps) {
if (line.type === "image") {
return (
<div key={index}>
<Markdown urlTransform={(value: string) => value}>
<Markdown
components={{
p: paragraph,
}}
urlTransform={(value: string) => value}
>
{line.content}
</Markdown>
</div>

View File

@@ -0,0 +1,11 @@
import React from "react";
import { ExtraProps } from "react-markdown";
// Custom component to render <p> in markdown with bottom padding
export function paragraph({
children,
}: React.ClassAttributes<HTMLParagraphElement> &
React.HTMLAttributes<HTMLParagraphElement> &
ExtraProps) {
return <p className="pb-[10px] last:pb-0">{children}</p>;
}

View File

@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useTranslation, Trans } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
@@ -64,7 +64,21 @@ export function ApiKeysManager() {
</div>
<p className="text-sm text-gray-300">
{t(I18nKey.SETTINGS$API_KEYS_DESCRIPTION)}
<Trans
i18nKey={I18nKey.SETTINGS$API_KEYS_DESCRIPTION}
components={{
a: (
<a
href="https://docs.all-hands.dev/modules/usage/cloud/cloud-api"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
>
API documentation
</a>
),
}}
/>
</p>
{isLoading && (

View File

@@ -51,10 +51,9 @@ export function GitHubTokenInput({
placeholder="github.com"
defaultValue={githubHostSet || undefined}
startContent={
githubHostSet && githubHostSet.trim() !== "" ? (
githubHostSet &&
githubHostSet.trim() !== "" && (
<KeyStatusIcon testId="gh-set-host-indicator" isSet />
) : (
<KeyStatusIcon testId="gh-set-host-indicator" isSet={false} />
)
}
/>

View File

@@ -51,10 +51,9 @@ export function GitLabTokenInput({
placeholder="gitlab.com"
defaultValue={gitlabHostSet || undefined}
startContent={
gitlabHostSet && gitlabHostSet.trim() !== "" ? (
gitlabHostSet &&
gitlabHostSet.trim() !== "" && (
<KeyStatusIcon testId="gl-set-host-indicator" isSet />
) : (
<KeyStatusIcon testId="gl-set-host-indicator" isSet={false} />
)
}
/>

View File

@@ -0,0 +1,202 @@
import { useQueryClient } from "@tanstack/react-query";
import React from "react";
import { useTranslation } from "react-i18next";
import { useCreateSecret } from "#/hooks/mutation/use-create-secret";
import { useUpdateSecret } from "#/hooks/mutation/use-update-secret";
import { SettingsInput } from "../settings-input";
import { cn } from "#/utils/utils";
import { BrandButton } from "../brand-button";
import { useGetSecrets } from "#/hooks/query/use-get-secrets";
import { GetSecretsResponse } from "#/api/secrets-service.types";
import { OptionalTag } from "../optional-tag";
interface SecretFormProps {
mode: "add" | "edit";
selectedSecret: string | null;
onCancel: () => void;
}
export function SecretForm({
mode,
selectedSecret,
onCancel,
}: SecretFormProps) {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { data: secrets } = useGetSecrets();
const { mutate: createSecret } = useCreateSecret();
const { mutate: updateSecret } = useUpdateSecret();
const [error, setError] = React.useState<string | null>(null);
const secretDescription =
(mode === "edit" &&
selectedSecret &&
secrets
?.find((secret) => secret.name === selectedSecret)
?.description?.trim()) ||
"";
const handleCreateSecret = (
name: string,
value: string,
description?: string,
) => {
createSecret(
{ name, value, description },
{
onSettled: onCancel,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["secrets"] });
},
},
);
};
const updateSecretOptimistically = (
oldName: string,
name: string,
description?: string,
) => {
queryClient.setQueryData<GetSecretsResponse["custom_secrets"]>(
["secrets"],
(oldSecrets) => {
if (!oldSecrets) return [];
return oldSecrets.map((secret) => {
if (secret.name === oldName) {
return {
...secret,
name,
description,
};
}
return secret;
});
},
);
};
const revertOptimisticUpdate = () => {
queryClient.invalidateQueries({ queryKey: ["secrets"] });
};
const handleEditSecret = (
secretToEdit: string,
name: string,
description?: string,
) => {
updateSecretOptimistically(secretToEdit, name, description);
updateSecret(
{ secretToEdit, name, description },
{
onSettled: onCancel,
onError: revertOptimisticUpdate,
},
);
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const name = formData.get("secret-name")?.toString();
const value = formData.get("secret-value")?.toString().trim();
const description = formData.get("secret-description")?.toString();
if (name) {
setError(null);
const isNameAlreadyUsed = secrets?.some(
(secret) => secret.name === name && secret.name !== selectedSecret,
);
if (isNameAlreadyUsed) {
setError("Secret already exists");
return;
}
if (mode === "add") {
if (!value) {
setError(t("SECRETS$SECRET_VALUE_REQUIRED"));
return;
}
handleCreateSecret(name, value, description || undefined);
} else if (mode === "edit" && selectedSecret) {
handleEditSecret(selectedSecret, name, description || undefined);
}
}
};
const formTestId = mode === "add" ? "add-secret-form" : "edit-secret-form";
return (
<form
data-testid={formTestId}
onSubmit={handleSubmit}
className="flex flex-col items-start gap-6"
>
<SettingsInput
testId="name-input"
name="secret-name"
type="text"
label="Name"
className="w-[350px]"
required
defaultValue={mode === "edit" && selectedSecret ? selectedSecret : ""}
placeholder="e.g. OpenAI_API_Key"
pattern="^\S*$"
/>
{error && <p className="text-red-500 text-sm">{error}</p>}
{mode === "add" && (
<label className="flex flex-col gap-2.5 w-fit">
<span className="text-sm">Value</span>
<textarea
data-testid="value-input"
name="secret-value"
required
className={cn(
"resize-none w-[680px]",
"bg-tertiary border border-[#717888] rounded p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
rows={8}
/>
</label>
)}
<label className="flex flex-col gap-2.5 w-fit">
<div className="flex items-center gap-2">
<span className="text-sm">Description</span>
<OptionalTag />
</div>
<input
data-testid="description-input"
name="secret-description"
defaultValue={secretDescription}
className={cn(
"resize-none w-[680px]",
"bg-tertiary border border-[#717888] rounded p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
</label>
<div className="flex items-center gap-4">
<BrandButton
testId="cancel-button"
type="button"
variant="secondary"
onClick={onCancel}
>
Cancel
</BrandButton>
<BrandButton testId="submit-button" type="submit" variant="primary">
{mode === "add" && t("SECRETS$ADD_SECRET")}
{mode === "edit" && t("SECRETS$EDIT_SECRET")}
</BrandButton>
</div>
</form>
);
}

View File

@@ -0,0 +1,63 @@
import { FaPencil, FaTrash } from "react-icons/fa6";
export function SecretListItemSkeleton() {
return (
<div className="border-t border-[#717888] last-of-type:border-b max-w-[830px] pr-2.5 py-[13px] flex items-center justify-between">
<div className="flex items-center justify-between w-1/3">
<span className="skeleton h-4 w-1/2" />
<span className="skeleton h-4 w-1/4" />
</div>
<div className="flex items-center gap-8">
<span className="skeleton h-4 w-4" />
<span className="skeleton h-4 w-4" />
</div>
</div>
);
}
interface SecretListItemProps {
title: string;
description?: string;
onEdit: () => void;
onDelete: () => void;
}
export function SecretListItem({
title,
description,
onEdit,
onDelete,
}: SecretListItemProps) {
return (
<tr
data-testid="secret-item"
className="border-t border-[#717888] last-of-type:border-b max-w-[830px] py-[13px] flex w-full items-center"
>
<td className="w-1/4 text-sm text-content-2">{title}</td>
<td className="w-1/2 truncate overflow-hidden whitespace-nowrap text-sm text-content-2 opacity-80 italic">
{description || "-"}
</td>
<td className="w-1/4 flex items-center justify-end gap-4">
<button
data-testid="edit-secret-button"
type="button"
onClick={onEdit}
aria-label={`Edit ${title}`}
>
<FaPencil size={16} />
</button>
<button
data-testid="delete-secret-button"
type="button"
onClick={onDelete}
aria-label={`Delete ${title}`}
>
<FaTrash size={16} />
</button>
</td>
</tr>
);
}

View File

@@ -17,6 +17,7 @@ interface SettingsDropdownInputProps {
isClearable?: boolean;
onSelectionChange?: (key: React.Key | null) => void;
onInputChange?: (value: string) => void;
defaultFilter?: (textValue: string, inputValue: string) => boolean;
}
export function SettingsDropdownInput({
@@ -33,6 +34,7 @@ export function SettingsDropdownInput({
isClearable,
onSelectionChange,
onInputChange,
defaultFilter,
}: SettingsDropdownInputProps) {
return (
<label className={cn("flex flex-col gap-2.5", wrapperClassName)}>
@@ -64,6 +66,7 @@ export function SettingsDropdownInput({
"bg-tertiary border border-[#717888] h-10 w-full rounded p-2 placeholder:italic",
},
}}
defaultFilter={defaultFilter}
>
{(item) => (
<AutocompleteItem key={item.key}>{item.label}</AutocompleteItem>

View File

@@ -14,9 +14,11 @@ interface SettingsInputProps {
startContent?: React.ReactNode;
className?: string;
onChange?: (value: string) => void;
required?: boolean;
min?: number;
max?: number;
step?: number;
pattern?: string;
}
export function SettingsInput({
@@ -32,9 +34,11 @@ export function SettingsInput({
startContent,
className,
onChange,
required,
min,
max,
step,
pattern,
}: SettingsInputProps) {
return (
<label className={cn("flex flex-col gap-2.5 w-fit", className)}>
@@ -55,6 +59,8 @@ export function SettingsInput({
min={min}
max={max}
step={step}
required={required}
pattern={pattern}
className={cn(
"bg-tertiary border border-[#717888] h-10 w-full rounded p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",

View File

@@ -0,0 +1,45 @@
import { BrandButton } from "#/components/features/settings/brand-button";
import { ModalBackdrop } from "./modal-backdrop";
interface ConfirmationModalProps {
text: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmationModal({
text,
onConfirm,
onCancel,
}: ConfirmationModalProps) {
return (
<ModalBackdrop onClose={onCancel}>
<div
data-testid="confirmation-modal"
className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary"
>
<p>{text}</p>
<div className="w-full flex gap-2">
<BrandButton
testId="cancel-button"
type="button"
onClick={onCancel}
variant="secondary"
className="grow"
>
Cancel
</BrandButton>
<BrandButton
testId="confirm-button"
type="button"
onClick={onConfirm}
variant="primary"
className="grow"
>
Confirm
</BrandButton>
</div>
</div>
</ModalBackdrop>
);
}

View File

@@ -52,7 +52,7 @@ export function TaskForm({ ref }: TaskFormProps) {
const formData = new FormData(event.currentTarget);
const q = formData.get("q")?.toString();
createConversation({ q, conversation_trigger: "gui" });
createConversation({ q });
};
return (

View File

@@ -6,7 +6,6 @@ import OpenHands from "#/api/open-hands";
import { setInitialPrompt } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { GitRepository } from "#/types/git";
import { ConversationTrigger } from "#/api/open-hands.types";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
export const useCreateConversation = () => {
@@ -21,7 +20,6 @@ export const useCreateConversation = () => {
return useMutation({
mutationKey: ["create-conversation"],
mutationFn: async (variables: {
conversation_trigger: ConversationTrigger;
q?: string;
selectedRepository?: GitRepository | null;
selected_branch?: string;
@@ -30,7 +28,6 @@ export const useCreateConversation = () => {
if (variables.q) dispatch(setInitialPrompt(variables.q));
return OpenHands.createConversation(
variables.conversation_trigger,
variables.selectedRepository
? variables.selectedRepository.full_name
: undefined,

View File

@@ -0,0 +1,15 @@
import { useMutation } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
export const useCreateSecret = () =>
useMutation({
mutationFn: ({
name,
value,
description,
}: {
name: string;
value: string;
description?: string;
}) => SecretsService.createSecret(name, value, description),
});

View File

@@ -0,0 +1,7 @@
import { useMutation } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
export const useDeleteSecret = () =>
useMutation({
mutationFn: (id: string) => SecretsService.deleteSecret(id),
});

View File

@@ -15,6 +15,7 @@ export const useLogout = () => {
queryClient.removeQueries({ queryKey: ["tasks"] });
queryClient.removeQueries({ queryKey: ["settings"] });
queryClient.removeQueries({ queryKey: ["user"] });
queryClient.removeQueries({ queryKey: ["secrets"] });
posthog.reset();
await navigate("/");

View File

@@ -0,0 +1,15 @@
import { useMutation } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
export const useUpdateSecret = () =>
useMutation({
mutationFn: ({
secretToEdit,
name,
description,
}: {
secretToEdit: string;
name: string;
description?: string;
}) => SecretsService.updateSecret(secretToEdit, name, description),
});

View File

@@ -29,7 +29,7 @@ export const useGetGitChanges = () => {
// Latest changes should be on top
React.useEffect(() => {
if (result.data) {
if (!result.isFetching && result.isSuccess && result.data) {
const currentData = result.data;
// If this is new data (not the same reference as before)
@@ -59,10 +59,11 @@ export const useGetGitChanges = () => {
}
}
}
}, [result.data]);
}, [result.isFetching, result.isSuccess, result.data]);
return {
data: orderedChanges,
isLoading: result.isLoading,
isSuccess: result.isSuccess,
isError: result.isError,
error: result.error,

View File

@@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
import { useUserProviders } from "../use-user-providers";
import { useConfig } from "./use-config";
export const useGetSecrets = () => {
const { data: config } = useConfig();
const { providers } = useUserProviders();
const isOss = config?.APP_MODE === "oss";
return useQuery({
queryKey: ["secrets"],
queryFn: SecretsService.getSecrets,
enabled: isOss || providers.length > 0,
});
};

View File

@@ -6,7 +6,6 @@ export function useSearchRepositories(query: string) {
queryKey: ["repositories", query],
queryFn: () => OpenHands.searchGitRepositories(query, 3),
enabled: !!query,
select: (data) => data.map((repo) => ({ ...repo, is_public: true })),
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});

View File

@@ -1,5 +1,11 @@
// this file generate by script, don't modify it manually!!!
export enum I18nKey {
SECRETS$SECRET_VALUE_REQUIRED = "SECRETS$SECRET_VALUE_REQUIRED",
SECRETS$ADD_SECRET = "SECRETS$ADD_SECRET",
SECRETS$EDIT_SECRET = "SECRETS$EDIT_SECRET",
SECRETS$NO_SECRETS_FOUND = "SECRETS$NO_SECRETS_FOUND",
SECRETS$ADD_NEW_SECRET = "SECRETS$ADD_NEW_SECRET",
SECRETS$CONFIRM_DELETE_KEY = "SECRETS$CONFIRM_DELETE_KEY",
SETTINGS$MCP_TITLE = "SETTINGS$MCP_TITLE",
SETTINGS$MCP_DESCRIPTION = "SETTINGS$MCP_DESCRIPTION",
SETTINGS$NAV_MCP = "SETTINGS$NAV_MCP",
@@ -60,6 +66,7 @@ export enum I18nKey {
SETTINGS$NAV_GIT = "SETTINGS$NAV_GIT",
SETTINGS$NAV_APPLICATION = "SETTINGS$NAV_APPLICATION",
SETTINGS$NAV_CREDITS = "SETTINGS$NAV_CREDITS",
SETTINGS$NAV_SECRETS = "SETTINGS$NAV_SECRETS",
SETTINGS$NAV_API_KEYS = "SETTINGS$NAV_API_KEYS",
SETTINGS$NAV_LLM = "SETTINGS$NAV_LLM",
GIT$MERGE_REQUEST = "GIT$MERGE_REQUEST",
@@ -459,7 +466,6 @@ export enum I18nKey {
CONVERSATION$DOWNLOAD_ERROR = "CONVERSATION$DOWNLOAD_ERROR",
CONVERSATION$UPDATED = "CONVERSATION$UPDATED",
CONVERSATION$TOTAL_COST = "CONVERSATION$TOTAL_COST",
CONVERSATION$TOKENS_USED = "CONVERSATION$TOKENS_USED",
CONVERSATION$INPUT = "CONVERSATION$INPUT",
CONVERSATION$OUTPUT = "CONVERSATION$OUTPUT",
CONVERSATION$TOTAL = "CONVERSATION$TOTAL",

View File

@@ -1,4 +1,94 @@
{
"SECRETS$SECRET_VALUE_REQUIRED": {
"en": "Secret value is required",
"ja": "シークレット値は必須です",
"zh-CN": "密钥值是必需的",
"zh-TW": "密鑰值是必需的",
"ko-KR": "비밀 값이 필요합니다",
"no": "Hemmelig verdi er påkrevd",
"it": "Il valore del segreto è obbligatorio",
"pt": "O valor do segredo é obrigatório",
"es": "El valor del secreto es obligatorio",
"ar": "قيمة السر مطلوبة",
"fr": "La valeur du secret est requise",
"tr": "Gizli değer gereklidir",
"de": "Geheimer Wert ist erforderlich"
},
"SECRETS$ADD_SECRET": {
"en": "Add secret",
"ja": "シークレットを追加",
"zh-CN": "添加密钥",
"zh-TW": "添加密鑰",
"ko-KR": "비밀 추가",
"no": "Legg til hemmelighet",
"it": "Aggiungi segreto",
"pt": "Adicionar segredo",
"es": "Añadir secreto",
"ar": "إضافة سر",
"fr": "Ajouter un secret",
"tr": "Gizli ekle",
"de": "Geheimnis hinzufügen"
},
"SECRETS$EDIT_SECRET": {
"en": "Edit secret",
"ja": "シークレットを編集",
"zh-CN": "编辑密钥",
"zh-TW": "編輯密鑰",
"ko-KR": "비밀 편집",
"no": "Rediger hemmelighet",
"it": "Modifica segreto",
"pt": "Editar segredo",
"es": "Editar secreto",
"ar": "تعديل السر",
"fr": "Modifier le secret",
"tr": "Gizliyi düzenle",
"de": "Geheimnis bearbeiten"
},
"SECRETS$NO_SECRETS_FOUND": {
"en": "No secrets found",
"ja": "シークレットが見つかりません",
"zh-CN": "未找到密钥",
"zh-TW": "未找到密鑰",
"ko-KR": "비밀을 찾을 수 없습니다",
"no": "Ingen hemmeligheter funnet",
"it": "Nessun segreto trovato",
"pt": "Nenhum segredo encontrado",
"es": "No se encontraron secretos",
"ar": "لم يتم العثور على أسرار",
"fr": "Aucun secret trouvé",
"tr": "Gizli bulunamadı",
"de": "Keine Geheimnisse gefunden"
},
"SECRETS$ADD_NEW_SECRET": {
"en": "Add a new secret",
"ja": "新しいシークレットを追加",
"zh-CN": "添加新密钥",
"zh-TW": "添加新密鑰",
"ko-KR": "새 비밀 추가",
"no": "Legg til en ny hemmelighet",
"it": "Aggiungi un nuovo segreto",
"pt": "Adicionar um novo segredo",
"es": "Añadir un nuevo secreto",
"ar": "إضافة سر جديد",
"fr": "Ajouter un nouveau secret",
"tr": "Yeni bir gizli ekle",
"de": "Neues Geheimnis hinzufügen"
},
"SECRETS$CONFIRM_DELETE_KEY": {
"en": "Are you sure you want to delete this key?",
"ja": "このキーを削除してもよろしいですか?",
"zh-CN": "您确定要删除此密钥吗?",
"zh-TW": "您確定要刪除此密鑰嗎?",
"ko-KR": "이 키를 삭제하시겠습니까?",
"no": "Er du sikker på at du vil slette denne nøkkelen?",
"it": "Sei sicuro di voler eliminare questa chiave?",
"pt": "Tem certeza de que deseja excluir esta chave?",
"es": "¿Está seguro de que desea eliminar esta clave?",
"ar": "هل أنت متأكد أنك تريد حذف هذا المفتاح؟",
"fr": "Êtes-vous sûr de vouloir supprimer cette clé ?",
"tr": "Bu anahtarı silmek istediğinizden emin misiniz?",
"de": "Sind Sie sicher, dass Sie diesen Schlüssel löschen möchten?"
},
"SETTINGS$MCP_TITLE": {
"en": "Model Context Protocol (MCP)",
"ja": "モデルコンテキストプロトコル (MCP)",
@@ -959,6 +1049,21 @@
"de": "Guthaben",
"uk": "Кредити"
},
"SETTINGS$NAV_SECRETS": {
"en": "Secrets",
"ja": "シークレット",
"zh-CN": "机密",
"zh-TW": "機密",
"ko-KR": "비밀",
"no": "Hemmeligheter",
"it": "Segreti",
"pt": "Segredos",
"es": "Secretos",
"ar": "أسرار",
"fr": "Secrets",
"tr": "Sırları",
"de": "Geheimnisse"
},
"SETTINGS$NAV_API_KEYS": {
"en": "API Keys",
"ja": "APIキー",
@@ -1069,7 +1174,6 @@
"fr": "Copier dans le presse-papiers",
"tr": "Panoya kopyala",
"de": "In die Zwischenablage kopieren",
"fa": "کپی به کلیپ‌بورد",
"uk": "Копіювати в буфер обміну"
},
"BUTTON$COPIED": {
@@ -1086,7 +1190,6 @@
"fr": "Copié dans le presse-papiers",
"tr": "Panoya kopyalandı",
"de": "In die Zwischenablage kopiert",
"fa": "در کلیپ‌بورد کپی شد",
"uk": "Copied to clipboard"
},
"APP$TITLE": {
@@ -2158,7 +2261,8 @@
"ar": "مضيف GitHub (اختياري)",
"fr": "Hôte GitHub (optionnel)",
"tr": "GitHub Sunucusu (isteğe bağlı)",
"de": "GitHub-Host (optional)"
"de": "GitHub-Host (optional)",
"uk": "Хост GitHub (необов'язково)"
},
"GITHUB$TOKEN_OPTIONAL": {
"en": "GitHub Token (Optional)",
@@ -2756,13 +2860,33 @@
"en": "Settings not found. Please check your API key",
"es": "Configuraciones no encontradas. Por favor revisa tu API key",
"zh-TW": "找不到設定。請檢查您的 API 金鑰",
"uk": "Налаштування не знайдено. Будь ласка, перевірте свій ключ API."
"uk": "Налаштування не знайдено. Будь ласка, перевірте свій ключ API.",
"ja": "設定が見つかりません。APIキーを確認してください",
"zh-CN": "未找到设置。请检查您的API密钥",
"ko-KR": "설정을 찾을 수 없습니다. API 키를 확인하세요",
"no": "Innstillinger ikke funnet. Vennligst sjekk API-nøkkelen din",
"ar": "لم يتم العثور على الإعدادات. يرجى التحقق من مفتاح API الخاص بك",
"de": "Einstellungen nicht gefunden. Bitte überprüfen Sie Ihren API-Schlüssel",
"fr": "Paramètres non trouvés. Veuillez vérifier votre clé API",
"it": "Impostazioni non trovate. Controlla la tua chiave API",
"pt": "Configurações não encontradas. Por favor, verifique sua chave API",
"tr": "Ayarlar bulunamadı. Lütfen API anahtarınızı kontrol edin"
},
"CONNECT_TO_GITHUB_BY_TOKEN_MODAL$TERMS_OF_SERVICE": {
"en": "terms of service",
"es": "términos de servicio",
"zh-TW": "服務條款",
"uk": "умови обслуговування"
"uk": "умови обслуговування",
"ja": "利用規約",
"zh-CN": "服务条款",
"ko-KR": "서비스 약관",
"no": "tjenestevilkår",
"ar": "شروط الخدمة",
"de": "Nutzungsbedingungen",
"fr": "conditions d'utilisation",
"it": "termini di servizio",
"pt": "termos de serviço",
"tr": "hizmet şartları"
},
"SESSION$SERVER_CONNECTED_MESSAGE": {
"en": "Connected to server",
@@ -2777,7 +2901,8 @@
"ar": "تم الاتصال بالخادم",
"tr": "Sunucuya bağlandı",
"no": "Koblet til server",
"uk": "Підключено до сервера"
"uk": "Підключено до сервера",
"ja": "サーバーに接続しました"
},
"SESSION$SESSION_HANDLING_ERROR_MESSAGE": {
"en": "Error handling message",
@@ -3734,7 +3859,10 @@
"de": "Keine Ergebnisse gefunden.",
"it": "Nessun risultato trovato.",
"pt": "Nenhum resultado encontrado.",
"uk": "Результатів не знайдено."
"uk": "Результатів не знайдено.",
"no": "Ingen resultater funnet.",
"ar": "لم يتم العثور على نتائج.",
"tr": "Sonuç bulunamadı."
},
"GITHUB$LOADING_REPOSITORIES": {
"en": "Loading repositories...",
@@ -4117,7 +4245,8 @@
"ar": "Upload a .json",
"no": "Upload a .json",
"tr": "Upload a .json",
"uk": "Завантажити .json"
"uk": "Завантажити .json",
"ja": ".jsonをアップロード"
},
"LANDING$RECENT_CONVERSATION": {
"en": "jump back to your most recent conversation",
@@ -4245,7 +4374,6 @@
"ar": "أو",
"no": "Eller",
"tr": "veya",
"fa": "یا",
"uk": "Або"
},
"SUGGESTIONS$TEST_COVERAGE": {
@@ -4278,7 +4406,6 @@
"ar": "دمج تلقائي لطلبات سحب Dependabot",
"no": "Auto-flett Dependabot PRs",
"tr": "Otomatik birleştirme",
"fa": "ادغام خودکار درخواست‌های Dependabot",
"uk": "Автоматичне об'єднання Dependabot PR"
},
"CHAT_INTERFACE$AGENT_STOPPED_MESSAGE": {
@@ -4939,83 +5066,323 @@
},
"SETTINGS$API_KEYS": {
"en": "API Keys",
"uk": "API ключі"
"uk": "API ключі",
"ja": "APIキー",
"zh-CN": "API密钥",
"zh-TW": "API金鑰",
"ko-KR": "API 키",
"no": "API-nøkler",
"ar": "مفاتيح API",
"de": "API-Schlüssel",
"fr": "Clés API",
"it": "Chiavi API",
"pt": "Chaves API",
"es": "Claves API",
"tr": "API Anahtarları"
},
"SETTINGS$API_KEYS_DESCRIPTION": {
"en": "API keys allow you to authenticate with the OpenHands API programmatically. Keep your API keys secure; anyone with your API key can access your account.",
"uk": "Ключі API дозволяють вам програмно автентифікуватися за допомогою API OpenHands. Зберігайте свої ключі API в безпеці; будь-хто, хто має ваш ключ API, може отримати доступ до вашого облікового запису."
"en": "API keys allow you to authenticate with the OpenHands API programmatically. Keep your API keys secure; anyone with your API key can access your account. For more information on how to use the API, see our <a>API documentation</a>.",
"ja": "APIキーを使用すると、OpenHands APIにプログラムで認証できます。APIキーは安全に保管してください。APIキーを持っている人は誰でもあなたのアカウントにアクセスできます。APIの使用方法の詳細については、<a>APIドキュメント</a>をご覧ください。",
"zh-CN": "API密钥允许您以编程方式验证OpenHands API。请确保您的API密钥安全任何拥有您API密钥的人都可以访问您的账户。有关如何使用API的更多信息请参阅我们的<a>API文档</a>。",
"zh-TW": "API密鑰允許您以編程方式驗證OpenHands API。請確保您的API密鑰安全任何擁有您API密鑰的人都可以訪問您的賬戶。有關如何使用API的更多信息請參閱我們的<a>API文檔</a>。",
"ko-KR": "API 키를 사용하면 OpenHands API에 프로그래밍 방식으로 인증할 수 있습니다. API 키를 안전하게 보관하세요. API 키가 있는 사람은 누구나 귀하의 계정에 액세스할 수 있습니다. API 사용 방법에 대한 자세한 내용은 <a>API 문서</a>를 참조하세요.",
"no": "API-nøkler lar deg autentisere med OpenHands API programmatisk. Hold API-nøklene dine sikre; alle med API-nøkkelen din kan få tilgang til kontoen din. For mer informasjon om hvordan du bruker API-et, se vår <a>API-dokumentasjon</a>.",
"it": "Le chiavi API ti consentono di autenticarti con l'API OpenHands in modo programmatico. Mantieni le tue chiavi API al sicuro; chiunque abbia la tua chiave API può accedere al tuo account. Per ulteriori informazioni su come utilizzare l'API, consulta la nostra <a>documentazione API</a>.",
"pt": "As chaves de API permitem que você se autentique com a API OpenHands programaticamente. Mantenha suas chaves de API seguras; qualquer pessoa com sua chave de API pode acessar sua conta. Para obter mais informações sobre como usar a API, consulte nossa <a>documentação da API</a>.",
"es": "Las claves API le permiten autenticarse con la API de OpenHands de forma programática. Mantenga sus claves API seguras; cualquier persona con su clave API puede acceder a su cuenta. Para obtener más información sobre cómo usar la API, consulte nuestra <a>documentación de API</a>.",
"ar": "تتيح لك مفاتيح API المصادقة مع واجهة برمجة تطبيقات OpenHands برمجيًا. حافظ على أمان مفاتيح API الخاصة بك؛ يمكن لأي شخص لديه مفتاح API الخاص بك الوصول إلى حسابك. لمزيد من المعلومات حول كيفية استخدام واجهة برمجة التطبيقات، راجع <a>وثائق API</a> الخاصة بنا.",
"fr": "Les clés API vous permettent de vous authentifier auprès de l'API OpenHands par programmation. Gardez vos clés API en sécurité ; toute personne disposant de votre clé API peut accéder à votre compte. Pour plus d'informations sur l'utilisation de l'API, consultez notre <a>documentation API</a>.",
"tr": "API anahtarları, OpenHands API ile programlı olarak kimlik doğrulamanıza olanak tanır. API anahtarlarınızı güvende tutun; API anahtarınıza sahip olan herkes hesabınıza erişebilir. API'nin nasıl kullanılacağı hakkında daha fazla bilgi için <a>API belgelerimize</a> bakın.",
"de": "API-Schlüssel ermöglichen es Ihnen, sich programmatisch bei der OpenHands-API zu authentifizieren. Halten Sie Ihre API-Schlüssel sicher; jeder mit Ihrem API-Schlüssel kann auf Ihr Konto zugreifen. Weitere Informationen zur Verwendung der API finden Sie in unserer <a>API-Dokumentation</a>.",
"uk": "Ключі API дозволяють вам програмно автентифікуватися за допомогою API OpenHands. Зберігайте свої ключі API в безпеці; будь-хто, хто має ваш ключ API, може отримати доступ до вашого облікового запису. Для отримання додаткової інформації про використання API, перегляньте нашу <a>документацію API</a>."
},
"SETTINGS$CREATE_API_KEY": {
"en": "Create API Key",
"uk": "Створити API ключ"
"uk": "Створити API ключ",
"ja": "APIキーを作成",
"zh-CN": "创建API密钥",
"zh-TW": "創建API金鑰",
"ko-KR": "API 키 생성",
"no": "Opprett API-nøkkel",
"ar": "إنشاء مفتاح API",
"de": "API-Schlüssel erstellen",
"fr": "Créer une clé API",
"it": "Crea chiave API",
"pt": "Criar chave API",
"es": "Crear clave API",
"tr": "API Anahtarı Oluştur"
},
"SETTINGS$CREATE_API_KEY_DESCRIPTION": {
"en": "Give your API key a descriptive name to help you identify it later.",
"uk": "Дайте своєму ключу API змістовну назву, щоб ви могли його пізніше ідентифікувати."
"uk": "Дайте своєму ключу API змістовну назву, щоб ви могли його пізніше ідентифікувати.",
"ja": "後で識別しやすいように、APIキーにわかりやすい名前を付けてください。",
"zh-CN": "为您的API密钥提供一个描述性名称以便您以后能够识别它。",
"zh-TW": "為您的API金鑰提供一個描述性名稱以便您以後能夠識別它。",
"ko-KR": "나중에 식별하는 데 도움이 되도록 API 키에 설명적인 이름을 지정하세요.",
"no": "Gi API-nøkkelen din et beskrivende navn for å hjelpe deg med å identifisere den senere.",
"ar": "أعط مفتاح API الخاص بك اسمًا وصفيًا لمساعدتك في التعرف عليه لاحقًا.",
"de": "Geben Sie Ihrem API-Schlüssel einen beschreibenden Namen, um ihn später leichter identifizieren zu können.",
"fr": "Donnez à votre clé API un nom descriptif pour vous aider à l'identifier ultérieurement.",
"it": "Dai alla tua chiave API un nome descrittivo per aiutarti a identificarla in seguito.",
"pt": "Dê à sua chave API um nome descritivo para ajudá-lo a identificá-la posteriormente.",
"es": "Asigne a su clave API un nombre descriptivo para ayudarle a identificarla más adelante.",
"tr": "API anahtarınıza, daha sonra tanımlamanıza yardımcı olacak açıklayıcı bir isim verin."
},
"SETTINGS$DELETE_API_KEY": {
"en": "Delete API Key",
"uk": "Видалити API ключ"
"uk": "Видалити API ключ",
"ja": "APIキーを削除",
"zh-CN": "删除API密钥",
"zh-TW": "刪除API金鑰",
"ko-KR": "API 키 삭제",
"no": "Slett API-nøkkel",
"ar": "حذف مفتاح API",
"de": "API-Schlüssel löschen",
"fr": "Supprimer la clé API",
"it": "Elimina chiave API",
"pt": "Excluir chave API",
"es": "Eliminar clave API",
"tr": "API Anahtarını Sil"
},
"SETTINGS$DELETE_API_KEY_CONFIRMATION": {
"en": "Are you sure you want to delete the API key \"{{name}}\"? This action cannot be undone.",
"uk": "Ви впевнені, що хочете видалити ключ API \"{{name}}\"? Цю дію не можна скасувати."
"uk": "Ви впевнені, що хочете видалити ключ API \"{{name}}\"? Цю дію не можна скасувати.",
"ja": "APIキー\"{{name}}\"を削除してもよろしいですか?この操作は元に戻せません。",
"zh-CN": "您确定要删除API密钥\"{{name}}\"吗?此操作无法撤消。",
"zh-TW": "您確定要刪除API金鑰\"{{name}}\"嗎?此操作無法撤消。",
"ko-KR": "API 키 \"{{name}}\"을(를) 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"no": "Er du sikker på at du vil slette API-nøkkelen \"{{name}}\"? Denne handlingen kan ikke angres.",
"ar": "هل أنت متأكد أنك تريد حذف مفتاح API \"{{name}}\"؟ لا يمكن التراجع عن هذا الإجراء.",
"de": "Sind Sie sicher, dass Sie den API-Schlüssel \"{{name}}\" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"fr": "Êtes-vous sûr de vouloir supprimer la clé API \"{{name}}\" ? Cette action ne peut pas être annulée.",
"it": "Sei sicuro di voler eliminare la chiave API \"{{name}}\"? Questa azione non può essere annullata.",
"pt": "Tem certeza de que deseja excluir a chave API \"{{name}}\"? Esta ação não pode ser desfeita.",
"es": "¿Está seguro de que desea eliminar la clave API \"{{name}}\"? Esta acción no se puede deshacer.",
"tr": "\"{{name}}\" API anahtarını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz."
},
"SETTINGS$NO_API_KEYS": {
"en": "You don't have any API keys yet. Create one to get started.",
"uk": "У вас ще немає API-ключів. Створіть один, щоб почати."
"uk": "У вас ще немає API-ключів. Створіть один, щоб почати.",
"ja": "まだAPIキーがありません。作成して始めましょう。",
"zh-CN": "您还没有任何API密钥。创建一个以开始使用。",
"zh-TW": "您還沒有任何API金鑰。創建一個以開始使用。",
"ko-KR": "아직 API 키가 없습니다. 시작하려면 하나를 생성하세요.",
"no": "Du har ingen API-nøkler ennå. Opprett en for å komme i gang.",
"ar": "ليس لديك أي مفاتيح API حتى الآن. قم بإنشاء واحد للبدء.",
"de": "Sie haben noch keine API-Schlüssel. Erstellen Sie einen, um zu beginnen.",
"fr": "Vous n'avez pas encore de clés API. Créez-en une pour commencer.",
"it": "Non hai ancora chiavi API. Creane una per iniziare.",
"pt": "Você ainda não tem nenhuma chave API. Crie uma para começar.",
"es": "Aún no tiene ninguna clave API. Cree una para comenzar.",
"tr": "Henüz API anahtarınız yok. Başlamak için bir tane oluşturun."
},
"SETTINGS$NAME": {
"en": "Name",
"uk": "Назва"
"uk": "Назва",
"ja": "名前",
"zh-CN": "名称",
"zh-TW": "名稱",
"ko-KR": "이름",
"no": "Navn",
"ar": "الاسم",
"de": "Name",
"fr": "Nom",
"it": "Nome",
"pt": "Nome",
"es": "Nombre",
"tr": "İsim"
},
"SETTINGS$KEY_PREFIX": {
"en": "Key Prefix",
"uk": "Префікс ключа"
"uk": "Префікс ключа",
"ja": "キーのプレフィックス",
"zh-CN": "密钥前缀",
"zh-TW": "金鑰前綴",
"ko-KR": "키 접두사",
"no": "Nøkkelprefix",
"ar": "بادئة المفتاح",
"de": "Schlüsselpräfix",
"fr": "Préfixe de clé",
"it": "Prefisso chiave",
"pt": "Prefixo da chave",
"es": "Prefijo de clave",
"tr": "Anahtar Öneki"
},
"SETTINGS$CREATED_AT": {
"en": "Created",
"uk": "Створено"
"uk": "Створено",
"ja": "作成日時",
"zh-CN": "创建时间",
"zh-TW": "創建時間",
"ko-KR": "생성됨",
"no": "Opprettet",
"ar": "تم الإنشاء",
"de": "Erstellt",
"fr": "Créé",
"it": "Creato",
"pt": "Criado",
"es": "Creado",
"tr": "Oluşturuldu"
},
"SETTINGS$LAST_USED": {
"en": "Last Used",
"uk": "Останнє використання"
"uk": "Останнє використання",
"ja": "最終使用日時",
"zh-CN": "最后使用时间",
"zh-TW": "最後使用時間",
"ko-KR": "마지막 사용",
"no": "Sist brukt",
"ar": "آخر استخدام",
"de": "Zuletzt verwendet",
"fr": "Dernière utilisation",
"it": "Ultimo utilizzo",
"pt": "Último uso",
"es": "Último uso",
"tr": "Son Kullanım"
},
"SETTINGS$ACTIONS": {
"en": "Actions",
"uk": "Дії"
"uk": "Дії",
"ja": "アクション",
"zh-CN": "操作",
"zh-TW": "操作",
"ko-KR": "작업",
"no": "Handlinger",
"ar": "إجراءات",
"de": "Aktionen",
"fr": "Actions",
"it": "Azioni",
"pt": "Ações",
"es": "Acciones",
"tr": "İşlemler"
},
"SETTINGS$API_KEY_CREATED": {
"en": "API Key Created",
"uk": "API-ключ створено"
"uk": "API-ключ створено",
"ja": "APIキーが作成されました",
"zh-CN": "API密钥已创建",
"zh-TW": "API金鑰已創建",
"ko-KR": "API 키 생성됨",
"no": "API-nøkkel opprettet",
"ar": "تم إنشاء مفتاح API",
"de": "API-Schlüssel erstellt",
"fr": "Clé API créée",
"it": "Chiave API creata",
"pt": "Chave API criada",
"es": "Clave API creada",
"tr": "API Anahtarı Oluşturuldu"
},
"SETTINGS$API_KEY_DELETED": {
"en": "API key deleted successfully",
"uk": "API-ключ видалено"
"uk": "API-ключ видалено",
"ja": "APIキーが正常に削除されました",
"zh-CN": "API密钥已成功删除",
"zh-TW": "API金鑰已成功刪除",
"ko-KR": "API 키가 성공적으로 삭제되었습니다",
"no": "API-nøkkel slettet",
"ar": "تم حذف مفتاح API بنجاح",
"de": "API-Schlüssel erfolgreich gelöscht",
"fr": "Clé API supprimée avec succès",
"it": "Chiave API eliminata con successo",
"pt": "Chave API excluída com sucesso",
"es": "Clave API eliminada con éxito",
"tr": "API anahtarı başarıyla silindi"
},
"SETTINGS$API_KEY_WARNING": {
"en": "This is the only time your API key will be displayed. Please copy it now and store it securely.",
"uk": "Це єдиний раз, коли буде відображено ваш ключ API. Будь ласка, скопіюйте його зараз і збережіть у безпеці.."
"uk": "Це єдиний раз, коли буде відображено ваш ключ API. Будь ласка, скопіюйте його зараз і збережіть у безпеці.",
"ja": "これはAPIキーが表示される唯一の機会です。今すぐコピーして安全に保管してください。",
"zh-CN": "这是您的API密钥唯一显示的时间。请立即复制并安全存储。",
"zh-TW": "這是您的API金鑰唯一顯示的時間。請立即複製並安全存儲。",
"ko-KR": "이것은 API 키가 표시되는 유일한 시간입니다. 지금 복사하여 안전하게 보관하세요.",
"no": "Dette er den eneste gangen API-nøkkelen din vil bli vist. Vennligst kopier den nå og lagre den sikkert.",
"ar": "هذه هي المرة الوحيدة التي سيتم فيها عرض مفتاح API الخاص بك. يرجى نسخه الآن وتخزينه بشكل آمن.",
"de": "Dies ist das einzige Mal, dass Ihr API-Schlüssel angezeigt wird. Bitte kopieren Sie ihn jetzt und speichern Sie ihn sicher.",
"fr": "C'est la seule fois que votre clé API sera affichée. Veuillez la copier maintenant et la stocker en toute sécurité.",
"it": "Questa è l'unica volta che la tua chiave API verrà visualizzata. Copiala ora e conservala in modo sicuro.",
"pt": "Esta é a única vez que sua chave API será exibida. Por favor, copie-a agora e armazene-a com segurança.",
"es": "Esta es la única vez que se mostrará su clave API. Por favor, cópiela ahora y guárdela de forma segura.",
"tr": "API anahtarınız yalnızca bu kez görüntülenecektir. Lütfen şimdi kopyalayın ve güvenli bir şekilde saklayın."
},
"SETTINGS$API_KEY_COPIED": {
"en": "API key copied to clipboard",
"uk": "Ключ API скопійовано в буфер обміну"
"uk": "Ключ API скопійовано в буфер обміну",
"ja": "APIキーがクリップボードにコピーされました",
"zh-CN": "API密钥已复制到剪贴板",
"zh-TW": "API金鑰已複製到剪貼板",
"ko-KR": "API 키가 클립보드에 복사되었습니다",
"no": "API-nøkkel kopiert til utklippstavlen",
"ar": "تم نسخ مفتاح API إلى الحافظة",
"de": "API-Schlüssel in die Zwischenablage kopiert",
"fr": "Clé API copiée dans le presse-papiers",
"it": "Chiave API copiata negli appunti",
"pt": "Chave API copiada para a área de transferência",
"es": "Clave API copiada al portapapeles",
"tr": "API anahtarı panoya kopyalandı"
},
"SETTINGS$API_KEY_NAME_PLACEHOLDER": {
"en": "My API Key",
"uk": "Мій ключ API"
"uk": "Мій ключ API",
"ja": "私のAPIキー",
"zh-CN": "我的API密钥",
"zh-TW": "我的API金鑰",
"ko-KR": "내 API 키",
"no": "Min API-nøkkel",
"ar": "مفتاح API الخاص بي",
"de": "Mein API-Schlüssel",
"fr": "Ma clé API",
"it": "La mia chiave API",
"pt": "Minha chave API",
"es": "Mi clave API",
"tr": "API Anahtarım"
},
"BUTTON$CREATE": {
"en": "Create",
"uk": "Створити"
"uk": "Створити",
"ja": "作成",
"zh-CN": "创建",
"zh-TW": "創建",
"ko-KR": "생성",
"no": "Opprett",
"ar": "إنشاء",
"de": "Erstellen",
"fr": "Créer",
"it": "Crea",
"pt": "Criar",
"es": "Crear",
"tr": "Oluştur"
},
"BUTTON$DELETE": {
"en": "Delete",
"uk": "Видалити"
"uk": "Видалити",
"ja": "削除",
"zh-CN": "删除",
"zh-TW": "刪除",
"ko-KR": "삭제",
"no": "Slett",
"ar": "حذف",
"de": "Löschen",
"fr": "Supprimer",
"it": "Elimina",
"pt": "Excluir",
"es": "Eliminar",
"tr": "Sil"
},
"BUTTON$COPY_TO_CLIPBOARD": {
"en": "Copy to Clipboard",
"uk": "Копіювати в буфер обміну"
"uk": "Копіювати в буфер обміну",
"ja": "クリップボードにコピー",
"zh-CN": "复制到剪贴板",
"zh-TW": "複製到剪貼板",
"ko-KR": "클립보드에 복사",
"no": "Kopier til utklippstavlen",
"ar": "نسخ إلى الحافظة",
"de": "In die Zwischenablage kopieren",
"fr": "Copier dans le presse-papiers",
"it": "Copia negli appunti",
"pt": "Copiar para a área de transferência",
"es": "Copiar al portapapeles",
"tr": "Panoya Kopyala"
},
"BUTTON$REFRESH": {
"en": "Refresh",
@@ -5035,7 +5402,19 @@
},
"ERROR$REQUIRED_FIELD": {
"en": "This field is required",
"uk": "Це поле обов'язкове"
"uk": "Це поле обов'язкове",
"ja": "この項目は必須です",
"zh-CN": "此字段为必填项",
"zh-TW": "此欄位為必填項",
"ko-KR": "이 필드는 필수입니다",
"no": "Dette feltet er påkrevd",
"ar": "هذا الحقل مطلوب",
"de": "Dieses Feld ist erforderlich",
"fr": "Ce champ est obligatoire",
"it": "Questo campo è obbligatorio",
"pt": "Este campo é obrigatório",
"es": "Este campo es obligatorio",
"tr": "Bu alan zorunludur"
},
"PLANNER$EMPTY_MESSAGE": {
"en": "No plan created.",
@@ -5839,7 +6218,19 @@
},
"AGENT_ERROR$TOO_MANY_CONVERSATIONS": {
"en": "Too many conversations at once.",
"uk": "Занадто багато розмов одночасно."
"uk": "Занадто багато розмов одночасно.",
"ja": "一度に多すぎる会話があります。",
"zh-CN": "同时进行的对话过多。",
"zh-TW": "同時進行的對話過多。",
"ko-KR": "한 번에 너무 많은 대화가 있습니다.",
"no": "For mange samtaler på en gang.",
"ar": "الكثير من المحادثات في وقت واحد.",
"de": "Zu viele Gespräche auf einmal.",
"fr": "Trop de conversations à la fois.",
"it": "Troppe conversazioni contemporaneamente.",
"pt": "Muitas conversas ao mesmo tempo.",
"es": "Demasiadas conversaciones a la vez.",
"tr": "Aynı anda çok fazla konuşma var."
},
"PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL": {
"en": "Connect to GitHub",
@@ -5951,7 +6342,6 @@
"pt": "Captura de tela do navegador",
"es": "Captura de pantalla del navegador",
"tr": "Tarayıcı ekran görüntüsü",
"fa": "تصویر صفحه مرورگر",
"uk": "Знімок екрана браузера"
},
"ERROR_TOAST$CLOSE_BUTTON_LABEL": {
@@ -6597,7 +6987,18 @@
"SETTINGS_FORM$ENABLE_DEFAULT_CONDENSER_SWITCH_LABEL": {
"en": "Enable Memory Condenser",
"zh-TW": "啟用記憶體壓縮器",
"uk": "Увімкнути конденсатор пам'яті"
"uk": "Увімкнути конденсатор пам'яті",
"ja": "メモリコンデンサーを有効にする",
"zh-CN": "启用内存压缩器",
"ko-KR": "메모리 컨덴서 활성화",
"no": "Aktiver minnekondensator",
"ar": "تمكين مكثف الذاكرة",
"de": "Speicherkondensator aktivieren",
"fr": "Activer le condensateur de mémoire",
"it": "Abilita condensatore di memoria",
"pt": "Ativar condensador de memória",
"es": "Habilitar condensador de memoria",
"tr": "Bellek Yoğunlaştırıcıyı Etkinleştir"
},
"BUTTON$MARK_HELPFUL": {
"en": "Mark this solution as helpful",
@@ -7108,7 +7509,8 @@
"es": "Ventana de contexto",
"ar": "نافذة السياق",
"fr": "Fenêtre de contexte",
"tr": "Bağlam Penceresi"
"tr": "Bağlam Penceresi",
"uk": "Вікно контексту"
},
"CONVERSATION$USED": {
"en": "used",
@@ -7123,7 +7525,8 @@
"es": "usado",
"ar": "مستخدم",
"fr": "utilisé",
"tr": "kullanıldı"
"tr": "kullanıldı",
"uk": "використано"
},
"SETTINGS$RUNTIME_SETTINGS": {
"en": "Runtime Settings (",
@@ -7474,7 +7877,8 @@
"ar": "مضيف GitLab (اختياري)",
"fr": "Hôte GitLab (optionnel)",
"tr": "GitLab Sunucusu (isteğe bağlı)",
"de": "GitLab-Host (optional)"
"de": "GitLab-Host (optional)",
"uk": "Хост GitLab (необов'язково)"
},
"GITLAB$GET_TOKEN": {
"en": "Generate a token on",
@@ -7589,20 +7993,20 @@
"uk": "Дію не виконано. Можливо, це сталося через натискання користувачем кнопки зупинки або через збій та перезапуск системи виконання через обмеження ресурсів. Можливо, було втрачено будь-який раніше встановлений стан системи, залежності або змінні середовища."
},
"DIFF_VIEWER$LOADING": {
"en": "Loading...",
"ja": "読み込み中...",
"zh-CN": "加载中...",
"zh-TW": "加載中...",
"ko-KR": "로딩 중...",
"no": "Laster inn...",
"it": "Caricamento in corso...",
"pt": "Carregando...",
"es": "Cargando...",
"ar": "جار التحميل...",
"fr": "Chargement...",
"tr": "Yükleniyor...",
"de": "Wird geladen...",
"uk": "Завантаження..."
"en": "Loading changes...",
"ja": "変更を読み込んでいます...",
"zh-CN": "正在加载更改...",
"zh-TW": "正在加載更改...",
"ko-KR": "변경 사항을 로드하는 중...",
"no": "Laster inn endringer...",
"it": "Caricamento modifiche...",
"pt": "Carregando alterações...",
"es": "Cargando cambios...",
"ar": "جارٍ تحميل التغييرات...",
"fr": "Chargement des modifications...",
"tr": "Değişiklikler yükleniyor...",
"de": "Änderungen werden geladen...",
"uk": "Завантаження змін..."
},
"DIFF_VIEWER$GETTING_LATEST_CHANGES": {
"en": "Getting latest changes...",
@@ -7807,7 +8211,10 @@
"de": "Nutzungsbedingungen akzeptieren",
"it": "Accetta i termini di servizio",
"pt": "Aceitar termos de serviço",
"uk": "Прийняти Умови надання послуг"
"uk": "Прийняти Умови надання послуг",
"no": "Godta vilkår for tjenesten",
"ar": "قبول شروط الخدمة",
"tr": "Hizmet Şartlarını Kabul Et"
},
"TOS$ACCEPT_TERMS_DESCRIPTION": {
"en": "Please review and accept our terms of service before continuing",
@@ -7820,7 +8227,10 @@
"de": "Bitte überprüfen und akzeptieren Sie unsere Nutzungsbedingungen, bevor Sie fortfahren",
"it": "Si prega di rivedere e accettare i nostri termini di servizio prima di continuare",
"pt": "Por favor, revise e aceite nossos termos de serviço antes de continuar",
"uk": "Будь ласка, ознайомтеся та прийміть наші умови надання послуг, перш ніж продовжити"
"uk": "Будь ласка, ознайомтеся та прийміть наші умови надання послуг, перш ніж продовжити",
"no": "Vennligst gjennomgå og godta våre vilkår for tjenesten før du fortsetter",
"ar": "يرجى مراجعة وقبول شروط الخدمة الخاصة بنا قبل المتابعة",
"tr": "Devam etmeden önce lütfen hizmet şartlarımızı gözden geçirin ve kabul edin"
},
"TOS$CONTINUE": {
"en": "Continue",
@@ -7833,7 +8243,10 @@
"de": "Fortfahren",
"it": "Continua",
"pt": "Continuar",
"uk": "Продовжити"
"uk": "Продовжити",
"no": "Fortsett",
"ar": "متابعة",
"tr": "Devam Et"
},
"TOS$ERROR_ACCEPTING": {
"en": "Error accepting Terms of Service",
@@ -7846,7 +8259,10 @@
"de": "Fehler beim Akzeptieren der Nutzungsbedingungen",
"it": "Errore nell'accettazione dei Termini di Servizio",
"pt": "Erro ao aceitar os Termos de Serviço",
"uk": "Помилка прийняття Умов обслуговування"
"uk": "Помилка прийняття Умов обслуговування",
"no": "Feil ved godkjenning av vilkår for tjenesten",
"ar": "خطأ في قبول شروط الخدمة",
"tr": "Hizmet Şartlarını kabul ederken hata oluştu"
},
"TIPS$CUSTOMIZE_MICROAGENT": {
"en": "You can customize OpenHands for your repo using a microagent. Ask OpenHands to put a description of the repo, including how to run the code, into .openhands/microagents/repo.md.",

View File

@@ -10,6 +10,7 @@ import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
import { GitRepository, GitUser } from "#/types/git";
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
import { SECRETS_HANDLERS } from "./secrets-handlers";
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
@@ -92,7 +93,7 @@ const openHandsHandlers = [
"gpt-4o",
"gpt-4o-mini",
"anthropic/claude-3.5",
"anthropic/claude-3-5-sonnet-20241022",
"anthropic/claude-3-7-sonnet-20250219",
]),
),
@@ -118,6 +119,7 @@ export const handlers = [
...STRIPE_BILLING_HANDLERS,
...FILE_SERVICE_HANDLERS,
...TASK_SUGGESTIONS_HANDLERS,
...SECRETS_HANDLERS,
...openHandsHandlers,
http.get("/api/user/repositories", () => {
const data: GitRepository[] = [
@@ -164,7 +166,7 @@ export const handlers = [
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
STRIPE_PUBLISHABLE_KEY: "",
FEATURE_FLAGS: {
ENABLE_BILLING: mockSaas,
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: mockSaas,
},
};
@@ -210,8 +212,6 @@ export const handlers = [
HttpResponse.json({ message: "Authenticated" }),
),
http.get("/api/options/config", () => HttpResponse.json({ APP_MODE: "oss" })),
http.get("/api/conversations", async () => {
const values = Array.from(CONVERSATIONS.values());
const results: ResultSet<Conversation> = {

View File

@@ -0,0 +1,72 @@
import { http, HttpResponse } from "msw";
import { CustomSecret, GetSecretsResponse } from "#/api/secrets-service.types";
const DEFAULT_SECRETS: CustomSecret[] = [
{
name: "OpenAI_API_Key",
value: "test-123",
description: "OpenAI API Key",
},
{
name: "Google_Maps_API_Key",
value: "test-123",
description: "Google Maps API Key",
},
];
const secrets = new Map<string, CustomSecret>(
DEFAULT_SECRETS.map((secret) => [secret.name, secret]),
);
export const SECRETS_HANDLERS = [
http.get("/api/secrets", async () => {
const secretsArray = Array.from(secrets.values());
const secretsWithoutValue: Omit<CustomSecret, "value">[] = secretsArray.map(
({ value, ...rest }) => rest,
);
const data: GetSecretsResponse = {
custom_secrets: secretsWithoutValue,
};
return HttpResponse.json(data);
}),
http.post("/api/secrets", async ({ request }) => {
const body = (await request.json()) as CustomSecret;
if (typeof body === "object" && body && body.name) {
secrets.set(body.name, body);
return HttpResponse.json(true);
}
return HttpResponse.json(false, { status: 400 });
}),
http.put("/api/secrets/:id", async ({ params, request }) => {
const { id } = params;
const body = (await request.json()) as Omit<CustomSecret, "value">;
if (typeof id === "string" && typeof body === "object") {
const secret = secrets.get(id);
if (secret && body && body.name) {
const newSecret: CustomSecret = { ...secret, ...body };
secrets.delete(id);
secrets.set(body.name, newSecret);
return HttpResponse.json(true);
}
}
return HttpResponse.json(false, { status: 400 });
}),
http.delete("/api/secrets/:id", async ({ params }) => {
const { id } = params;
if (typeof id === "string") {
secrets.delete(id);
return HttpResponse.json(true);
}
return HttpResponse.json(false, { status: 400 });
}),
];

View File

@@ -15,6 +15,7 @@ export default [
route("git", "routes/git-settings.tsx"),
route("app", "routes/app-settings.tsx"),
route("billing", "routes/billing.tsx"),
route("secrets", "routes/secrets-settings.tsx"),
route("api-keys", "routes/api-keys.tsx"),
]),
route("conversations/:conversationId", "routes/conversation.tsx", [

View File

@@ -14,7 +14,7 @@ const GIT_REPO_ERROR_PATTERN = /not a git repository/i;
function StatusMessage({ children }: React.PropsWithChildren) {
return (
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
<div className="w-full h-full flex flex-col items-center text-center justify-center text-2xl text-tertiary-light">
{children}
</div>
);
@@ -22,7 +22,17 @@ function StatusMessage({ children }: React.PropsWithChildren) {
function GitChanges() {
const { t } = useTranslation();
const { data: gitChanges, isSuccess, isError, error } = useGetGitChanges();
const {
data: gitChanges,
isSuccess,
isError,
error,
isLoading: loadingGitChanges,
} = useGetGitChanges();
const [statusMessage, setStatusMessage] = React.useState<string[] | null>(
null,
);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const runtimeIsActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
@@ -30,29 +40,44 @@ function GitChanges() {
const isNotGitRepoError =
error && GIT_REPO_ERROR_PATTERN.test(retrieveAxiosErrorMessage(error));
let statusMessage: React.ReactNode = null;
if (!runtimeIsActive) {
statusMessage = <span>{t(I18nKey.DIFF_VIEWER$WAITING_FOR_RUNTIME)}</span>;
} else if (isNotGitRepoError) {
if (error) {
statusMessage = <span>{retrieveAxiosErrorMessage(error)}</span>;
React.useEffect(() => {
if (!runtimeIsActive) {
setStatusMessage([I18nKey.DIFF_VIEWER$WAITING_FOR_RUNTIME]);
} else if (error) {
const errorMessage = retrieveAxiosErrorMessage(error);
if (GIT_REPO_ERROR_PATTERN.test(errorMessage)) {
setStatusMessage([
I18nKey.DIFF_VIEWER$NOT_A_GIT_REPO,
I18nKey.DIFF_VIEWER$ASK_OH,
]);
} else {
setStatusMessage([errorMessage]);
}
} else if (loadingGitChanges) {
setStatusMessage([I18nKey.DIFF_VIEWER$LOADING]);
} else {
statusMessage = (
<span>
{t(I18nKey.DIFF_VIEWER$NOT_A_GIT_REPO)}
<br />
{t(I18nKey.DIFF_VIEWER$ASK_OH)}
</span>
);
setStatusMessage(null);
}
}
}, [
runtimeIsActive,
isNotGitRepoError,
loadingGitChanges,
error,
setStatusMessage,
]);
return (
<main className="h-full overflow-y-scroll px-4 py-3 gap-3 flex flex-col items-center">
{!isSuccess || !gitChanges.length ? (
<div className="relative flex h-full w-full items-center">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2">
{statusMessage && <StatusMessage>{statusMessage}</StatusMessage>}
{statusMessage && (
<StatusMessage>
{statusMessage.map((msg) => (
<span key={msg}>{t(msg)}</span>
))}
</StatusMessage>
)}
</div>
<div className="absolute inset-x-0 bottom-0">

View File

@@ -1,6 +1,6 @@
import { useDisclosure } from "@heroui/react";
import React from "react";
import { Outlet, useNavigate } from "react-router";
import { useNavigate } from "react-router";
import { useDispatch, useSelector } from "react-redux";
import { FaServer, FaExternalLinkAlt } from "react-icons/fa";
import { useTranslation } from "react-i18next";
@@ -200,11 +200,6 @@ function AppContent() {
>
{/* Use both Outlet and TabContent */}
<div className="h-full w-full">
{/* Keep the Outlet for React Router to work properly */}
<div className="hidden">
<Outlet />
</div>
{/* Use TabContent to keep all tabs loaded but only show the active one */}
<TabContent conversationPath={basePath} />
</div>
</Container>

View File

@@ -304,9 +304,9 @@ function LlmSettingsScreen() {
name="llm-custom-model-input"
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
defaultValue={
settings.LLM_MODEL || "anthropic/claude-3-5-sonnet-20241022"
settings.LLM_MODEL || "anthropic/claude-3-7-sonnet-20250219"
}
placeholder="anthropic/claude-3-5-sonnet-20241022"
placeholder="anthropic/claude-3-7-sonnet-20250219"
type="text"
className="w-[680px]"
onChange={handleCustomModelIsDirty}

View File

@@ -0,0 +1,151 @@
import { useQueryClient } from "@tanstack/react-query";
import React from "react";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { useGetSecrets } from "#/hooks/query/use-get-secrets";
import { useDeleteSecret } from "#/hooks/mutation/use-delete-secret";
import { SecretForm } from "#/components/features/settings/secrets-settings/secret-form";
import {
SecretListItem,
SecretListItemSkeleton,
} from "#/components/features/settings/secrets-settings/secret-list-item";
import { BrandButton } from "#/components/features/settings/brand-button";
import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal";
import { GetSecretsResponse } from "#/api/secrets-service.types";
import { useUserProviders } from "#/hooks/use-user-providers";
import { useConfig } from "#/hooks/query/use-config";
function SecretsSettingsScreen() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { data: config } = useConfig();
const { data: secrets, isLoading: isLoadingSecrets } = useGetSecrets();
const { mutate: deleteSecret } = useDeleteSecret();
const { providers } = useUserProviders();
const isSaas = config?.APP_MODE === "saas";
const hasProviderSet = providers.length > 0;
const [view, setView] = React.useState<
"list" | "add-secret-form" | "edit-secret-form"
>("list");
const [selectedSecret, setSelectedSecret] = React.useState<string | null>(
null,
);
const [confirmationModalIsVisible, setConfirmationModalIsVisible] =
React.useState(false);
const deleteSecretOptimistically = (secret: string) => {
queryClient.setQueryData<GetSecretsResponse["custom_secrets"]>(
["secrets"],
(oldSecrets) => {
if (!oldSecrets) return [];
return oldSecrets.filter((s) => s.name !== secret);
},
);
};
const revertOptimisticUpdate = () => {
queryClient.invalidateQueries({ queryKey: ["secrets"] });
};
const handleDeleteSecret = (secret: string) => {
deleteSecretOptimistically(secret);
deleteSecret(secret, {
onSettled: () => {
setConfirmationModalIsVisible(false);
},
onError: revertOptimisticUpdate,
});
};
const onConfirmDeleteSecret = () => {
if (selectedSecret) handleDeleteSecret(selectedSecret);
};
const onCancelDeleteSecret = () => {
setConfirmationModalIsVisible(false);
};
const shouldRenderConnectToGitButton = isSaas && !hasProviderSet;
return (
<div
data-testid="secrets-settings-screen"
className="px-11 py-9 flex flex-col gap-5"
>
{isLoadingSecrets && view === "list" && (
<ul>
<SecretListItemSkeleton />
<SecretListItemSkeleton />
<SecretListItemSkeleton />
</ul>
)}
{shouldRenderConnectToGitButton && (
<Link to="/settings/git" data-testid="connect-git-button" type="button">
<BrandButton type="button" variant="secondary">
Connect a Git provider to manage secrets
</BrandButton>
</Link>
)}
{secrets?.length === 0 && view === "list" && (
<p data-testid="no-secrets-message">{t("SECRETS$NO_SECRETS_FOUND")}</p>
)}
{view === "list" && (
<table className="w-full">
<tbody>
{secrets?.map((secret) => (
<SecretListItem
key={secret.name}
title={secret.name}
description={secret.description}
onEdit={() => {
setView("edit-secret-form");
setSelectedSecret(secret.name);
}}
onDelete={() => {
setConfirmationModalIsVisible(true);
setSelectedSecret(secret.name);
}}
/>
))}
</tbody>
</table>
)}
{!shouldRenderConnectToGitButton && view === "list" && (
<BrandButton
testId="add-secret-button"
type="button"
variant="primary"
onClick={() => setView("add-secret-form")}
isDisabled={isLoadingSecrets}
>
{t("SECRETS$ADD_NEW_SECRET")}
</BrandButton>
)}
{(view === "add-secret-form" || view === "edit-secret-form") && (
<SecretForm
mode={view === "add-secret-form" ? "add" : "edit"}
selectedSecret={selectedSecret}
onCancel={() => setView("list")}
/>
)}
{confirmationModalIsVisible && (
<ConfirmationModal
text={t("SECRETS$CONFIRM_DELETE_KEY")}
onConfirm={onConfirmDeleteSecret}
onCancel={onCancelDeleteSecret}
/>
)}
</div>
);
}
export default SecretsSettingsScreen;

View File

@@ -18,6 +18,7 @@ function SettingsScreen() {
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
{ to: "/settings/secrets", text: t("SETTINGS$NAV_SECRETS") },
{ to: "/settings/api-keys", text: t("SETTINGS$NAV_API_KEYS") },
];
@@ -26,6 +27,7 @@ function SettingsScreen() {
{ to: "/settings/mcp", text: t("SETTINGS$NAV_MCP") },
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
{ to: "/settings/secrets", text: t("SETTINGS$NAV_SECRETS") },
];
React.useEffect(() => {

View File

@@ -3,7 +3,7 @@ import { Settings } from "#/types/settings";
export const LATEST_SETTINGS_VERSION = 5;
export const DEFAULT_SETTINGS: Settings = {
LLM_MODEL: "anthropic/claude-3-5-sonnet-20241022",
LLM_MODEL: "anthropic/claude-3-7-sonnet-20250219",
LLM_BASE_URL: "",
AGENT: "CodeActAgent",
LANGUAGE: "en",

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