mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
80 Commits
fix-git-ch
...
feature/pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d88ebc971f | ||
|
|
46e39739c4 | ||
|
|
eda353e0d7 | ||
|
|
04d585513c | ||
|
|
7a4ea23b9d | ||
|
|
7490c1927f | ||
|
|
8d2ac59909 | ||
|
|
68e5f485aa | ||
|
|
c1e3ae6dac | ||
|
|
e95831ec09 | ||
|
|
04c7f31498 | ||
|
|
e4c284f96d | ||
|
|
3ca585b79f | ||
|
|
7e88d4195f | ||
|
|
f046efd53d | ||
|
|
e5f81a283a | ||
|
|
74f8d68ac5 | ||
|
|
a2e028d707 | ||
|
|
8eb2281f21 | ||
|
|
e4586432ad | ||
|
|
d956abe56b | ||
|
|
6145552841 | ||
|
|
b1dca48c8e | ||
|
|
81ba80dde0 | ||
|
|
08a790c4ca | ||
|
|
1b57fd4d1e | ||
|
|
c36cbf6543 | ||
|
|
1c3052702e | ||
|
|
ca4051f585 | ||
|
|
11c7a39c12 | ||
|
|
67d438ea4f | ||
|
|
154eed148f | ||
|
|
f9b0fcd76e | ||
|
|
0782aeb1c4 | ||
|
|
55fbb65e05 | ||
|
|
1abed30b44 | ||
|
|
1f29ec836b | ||
|
|
0ec75bc0d0 | ||
|
|
9ca9cb8f3a | ||
|
|
81c754ec65 | ||
|
|
880ec57c78 | ||
|
|
e06aac7521 | ||
|
|
60d9b519e0 | ||
|
|
5ad11e73b8 | ||
|
|
3e5b16b348 | ||
|
|
f3d0ae3fbf | ||
|
|
dea3ddfcc6 | ||
|
|
31b2f3c9c2 | ||
|
|
a95e43fc03 | ||
|
|
c5f9910dc2 | ||
|
|
ca5df82804 | ||
|
|
a4b8d08b2f | ||
|
|
4bb6ec2ee5 | ||
|
|
ae8ed49280 | ||
|
|
786e21fb8a | ||
|
|
f317c03b1b | ||
|
|
e72153629d | ||
|
|
b127d5f656 | ||
|
|
f75fa50b80 | ||
|
|
5a927c8651 | ||
|
|
2693360ad0 | ||
|
|
1081f8091d | ||
|
|
8d0e5c6c34 | ||
|
|
0b897ff3dc | ||
|
|
c5ace563c4 | ||
|
|
9af132933c | ||
|
|
10c56932af | ||
|
|
e9905115c4 | ||
|
|
6b11fff735 | ||
|
|
3d02c0c3a3 | ||
|
|
a17c57d82e | ||
|
|
da637a0dad | ||
|
|
27c49471a8 | ||
|
|
bffe8de597 | ||
|
|
f0bb7de1c6 | ||
|
|
90aab29bc0 | ||
|
|
ade059bfba | ||
|
|
72a13cc42d | ||
|
|
728f8e239c | ||
|
|
24c93478ac |
19
.github/.codecov.yml
vendored
19
.github/.codecov.yml
vendored
@@ -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
|
||||
4
.github/workflows/fe-unit-tests.yml
vendored
4
.github/workflows/fe-unit-tests.yml
vendored
@@ -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 }}
|
||||
|
||||
12
.github/workflows/ghcr-build.yml
vendored
12
.github/workflows/ghcr-build.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/py-unit-tests.yml
vendored
6
.github/workflows/py-unit-tests.yml
vendored
@@ -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:
|
||||
|
||||
14
.github/workflows/run-eval.yml
vendored
14
.github/workflows/run-eval.yml
vendored
@@ -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 \
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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**.
|
||||
|
||||
|
||||
17
Makefile
17
Makefile
@@ -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)"
|
||||
|
||||
|
||||
@@ -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)!
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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 !
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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 で実行されています!
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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 运行!
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<model-name> 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/<model-name> 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/<model-name>).
|
||||
If the model is not in the list, enable `Advanced` options, and enter it in `Custom Model`
|
||||
(e.g. vertex_ai/<model-name>).
|
||||
|
||||
@@ -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/<model-name> 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/<model-name> 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`)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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/<model-name> like `openai/gpt-4o`).
|
||||
If the model is not in the list, enable `Advanced` options, and enter it in `Custom Model` (e.g. openai/<model-name> 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/<model-name> (e.g. `openai/gpt-4o` or openai/<proxy-prefix>/<model-name>)
|
||||
|
||||
@@ -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/<model-name> 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/<model-name> like `openrouter/anthropic/claude-3.5-sonnet`).
|
||||
* `API Key` to your OpenRouter API key.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"...')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,6 @@ describe("HomeHeader", () => {
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
"gui",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
@@ -173,7 +173,6 @@ describe("RepoConnector", () => {
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
"gui",
|
||||
"rbren/polaris",
|
||||
"github",
|
||||
undefined,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
565
frontend/__tests__/routes/secrets-settings.test.tsx
Normal file
565
frontend/__tests__/routes/secrets-settings.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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}": [
|
||||
|
||||
88
frontend/scripts/check-translation-completeness.cjs
Executable file
88
frontend/scripts/check-translation-completeness.cjs
Executable 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!');
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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]}
|
||||
>
|
||||
|
||||
@@ -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]}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
11
frontend/src/components/features/markdown/paragraph.tsx
Normal file
11
frontend/src/components/features/markdown/paragraph.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
45
frontend/src/components/shared/modals/confirmation-modal.tsx
Normal file
45
frontend/src/components/shared/modals/confirmation-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
15
frontend/src/hooks/mutation/use-create-secret.ts
Normal file
15
frontend/src/hooks/mutation/use-create-secret.ts
Normal 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),
|
||||
});
|
||||
7
frontend/src/hooks/mutation/use-delete-secret.ts
Normal file
7
frontend/src/hooks/mutation/use-delete-secret.ts
Normal 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),
|
||||
});
|
||||
@@ -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("/");
|
||||
|
||||
15
frontend/src/hooks/mutation/use-update-secret.ts
Normal file
15
frontend/src/hooks/mutation/use-update-secret.ts
Normal 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),
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
17
frontend/src/hooks/query/use-get-secrets.ts
Normal file
17
frontend/src/hooks/query/use-get-secrets.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
72
frontend/src/mocks/secrets-handlers.ts
Normal file
72
frontend/src/mocks/secrets-handlers.ts
Normal 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 });
|
||||
}),
|
||||
];
|
||||
@@ -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", [
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
151
frontend/src/routes/secrets-settings.tsx
Normal file
151
frontend/src/routes/secrets-settings.tsx
Normal 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;
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user